From 4e802f0f4a55a69abf1c334473ed183127b2c7b9 Mon Sep 17 00:00:00 2001 From: Diego Madero Islas Date: Sun, 14 Jun 2026 20:55:29 -0600 Subject: [PATCH 1/5] feat(routing): add confirmed stale reference cleanup --- .../routing-architecture-audit-2026-05.md | 22 ++ docs/routing-overview-ui.md | 8 + docs/status.md | 1 + src/routingGraph.ts | 131 +++++++ src/ui/app.ts | 8 + src/ui/header/routingOverviewPanel.ts | 31 +- src/ui/header/transportHeader.ts | 2 + src/ui/routingInspector.ts | 27 +- src/ui/style.css | 33 ++ tests/routingCleanup.test.mjs | 339 ++++++++++++++++++ 10 files changed, 599 insertions(+), 3 deletions(-) create mode 100644 tests/routingCleanup.test.mjs diff --git a/docs/audits/routing-architecture-audit-2026-05.md b/docs/audits/routing-architecture-audit-2026-05.md index 8b61435..ef1fe3a 100644 --- a/docs/audits/routing-architecture-audit-2026-05.md +++ b/docs/audits/routing-architecture-audit-2026-05.md @@ -840,3 +840,25 @@ Recommended Phase 3 follow-ups: 1. Add a confirmed `Clean stale routing refs` action once cleanup rules are covered by focused tests for legacy `triggerSource`, legacy `modulations`, legacy `connections`, and typed route records. 2. Consider exposing validation issue details behind a small disclosure inside the same routing overview, without modal errors. 3. Keep graphical patchbay exploration separate from health/inspection so v0.4 remains compatibility-first. + + +## Phase 3 implementation note — Confirmed stale-reference cleanup + +Routing v0.4 Phase 3 adds a narrow, user-confirmed cleanup action to the existing Routing health / inspector surface. The action is intentionally limited to stale references whose module or bus endpoint no longer exists. Opening the Routing overview remains read-only and never mutates the patch. + +Cleanup coverage: + +- legacy sound-module `triggerSource` values pointing at missing modules are cleared to `null`; +- legacy `modulations` assignments pointing at missing control/module IDs are removed from their target module maps; +- legacy `Patch.connections` entries are removed when their source module, target module, or target bus no longer exists; +- typed `Patch.routes` entries are removed when a module or bus endpoint no longer exists. + +Intentional constraints preserved: + +- `Patch.version` remains `0.3`; +- `Patch.routes` is still not the sole runtime routing authority; +- valid legacy routing remains supported and is not migrated into typed routes; +- invalid-but-existing references, duplicate route IDs, unknown modulation parameters, unsupported bus runtime behavior, and route-ownership questions remain validation/future-routing work rather than automatic cleanup targets; +- no graph editor, patchbay, route-only migration, or routing schema redesign was introduced. + +Recommended next follow-up: typed/legacy parity hardening, especially partial typed event route adoption, multiple event routes into one sound, and runtime modulation equivalence for typed routes versus legacy `modulations`. diff --git a/docs/routing-overview-ui.md b/docs/routing-overview-ui.md index 4474494..e5d9dba 100644 --- a/docs/routing-overview-ui.md +++ b/docs/routing-overview-ui.md @@ -22,6 +22,8 @@ Current capabilities: - Domain filter (all/event/modulation/audio) - Module filter (focus routes touching one module) - Hover/click inspection signal that highlights related modules in the workspace +- Routing health summary with compact warning counts +- Confirmed `Clean stale routing refs` action for missing module/bus references only - Compact empty state when a section has no routes ## Intentional constraints in this phase @@ -38,3 +40,9 @@ Not included yet: - Audio bus runtime expansion Phase 3 is visibility + canonical read-model migration, not patchbay editing. + +## Stale-reference cleanup + +The Routing overview can now offer a confirmed cleanup action when validation finds references to modules or buses that no longer exist. The action removes only invalid references from legacy `triggerSource`, legacy `modulations`, legacy `connections`, and typed `Patch.routes` records. Opening the overview does not mutate state, and cancellation leaves the patch unchanged. + +This is not a routing ownership migration. Legacy routing remains supported, `Patch.routes` remains an optional typed overlay rather than the sole runtime authority, and typed-route parity/ownership consolidation remain future v0.4 work. diff --git a/docs/status.md b/docs/status.md index 3c47349..38bcc18 100644 --- a/docs/status.md +++ b/docs/status.md @@ -74,6 +74,7 @@ Reference: [`docs/gen-mode-design-principles.md`](gen-mode-design-principles.md) - Session factory examples are now a small onboarding set (`Example 01 · Basic Pulse`, `Example 02 · Dual Generators`, `Example 03 · Experimental Field`) that demonstrates simple GEN-to-DRUM/SYNTH routing without replacing local/user-created sessions. User sessions remain browser-local and persistent; broader curated artist/composer/engineer banks are a future preset-bank direction, not part of the current starter set. - First live Web MIDI keyboard input foundation is now active for synth modules (single target, note on/off, mono/poly-aware reception, compact input selector with hardware-first auto preference). - MIDI now appears as a first-class routing domain in the global Routing UI: users can assign `MIDI IN` input source + synth target explicitly, and MIDI routes are listed alongside event/modulation/audio routes. +- Routing health now includes a confirmed stale-reference cleanup action for missing module/bus references across legacy `triggerSource`, legacy `modulations`, legacy audio `connections`, and typed `Patch.routes`. This does not change routing ownership, schema version, or legacy compatibility. ## Near-term next steps (active priority) diff --git a/src/routingGraph.ts b/src/routingGraph.ts index e18ea46..af8204f 100644 --- a/src/routingGraph.ts +++ b/src/routingGraph.ts @@ -70,6 +70,16 @@ export type RoutingValidationResult = { warnings: string[]; }; +export type StaleRoutingCleanupPlan = { + legacyTriggerSources: Array<{ moduleId: string; refId: string }>; + legacyModulations: Array<{ moduleId: string; parameter: string; refId: string }>; + legacyConnections: Array<{ connectionId: string; refId: string; reason: "missing-source-module" | "missing-target-module" | "missing-target-bus" }>; + typedRoutes: Array<{ routeIndex: number; routeId: string; refId: string; reason: "missing-source-module" | "missing-target-module" | "missing-source-bus" | "missing-target-bus" }>; + totalRemovals: number; +}; + +export type RoutingCleanupConfirm = (plan: StaleRoutingCleanupPlan) => boolean; + type NormalizeRouteResult = { routes: PatchRoute[]; warnings: string[]; @@ -423,6 +433,127 @@ export function validatePatchRouting(patch: Pick issue.message) }; } +function emptyStaleRoutingCleanupPlan(): StaleRoutingCleanupPlan { + return { + legacyTriggerSources: [], + legacyModulations: [], + legacyConnections: [], + typedRoutes: [], + totalRemovals: 0, + }; +} + +function routeMissingEndpoint( + route: PatchRoute, + modulesById: Set, + busesById: Set, +): Omit | null { + if (route.source.kind === "module" && !modulesById.has(route.source.moduleId)) { + return { routeId: route.id, refId: route.source.moduleId, reason: "missing-source-module" }; + } + if (route.target.kind === "module" && !modulesById.has(route.target.moduleId)) { + return { routeId: route.id, refId: route.target.moduleId, reason: "missing-target-module" }; + } + if (route.source.kind === "bus" && !busesById.has(route.source.busId)) { + return { routeId: route.id, refId: route.source.busId, reason: "missing-source-bus" }; + } + if (route.target.kind === "bus" && !busesById.has(route.target.busId)) { + return { routeId: route.id, refId: route.target.busId, reason: "missing-target-bus" }; + } + return null; +} + +export function planStaleRoutingCleanup(patch: Pick & { routes?: unknown }): StaleRoutingCleanupPlan { + const plan = emptyStaleRoutingCleanupPlan(); + const modulesById = new Set(patch.modules.map((module) => module.id)); + const busesById = new Set((patch.buses ?? []).map((bus) => bus.id)); + + for (const module of patch.modules) { + if (isSoundModule(module) && module.triggerSource && !modulesById.has(module.triggerSource)) { + plan.legacyTriggerSources.push({ moduleId: module.id, refId: module.triggerSource }); + } + + const modulations = "modulations" in module && module.modulations && typeof module.modulations === "object" + ? module.modulations + : {}; + for (const [parameter, sourceId] of Object.entries(modulations)) { + if (!sourceId || typeof sourceId !== "string") continue; + if (!modulesById.has(sourceId)) { + plan.legacyModulations.push({ moduleId: module.id, parameter, refId: sourceId }); + } + } + } + + for (const connection of patch.connections) { + if (!modulesById.has(connection.fromModuleId)) { + plan.legacyConnections.push({ connectionId: connection.id, refId: connection.fromModuleId, reason: "missing-source-module" }); + continue; + } + if (connection.to.type === "module" && connection.to.id && !modulesById.has(connection.to.id)) { + plan.legacyConnections.push({ connectionId: connection.id, refId: connection.to.id, reason: "missing-target-module" }); + continue; + } + if (connection.to.type === "bus" && connection.to.id && !busesById.has(connection.to.id)) { + plan.legacyConnections.push({ connectionId: connection.id, refId: connection.to.id, reason: "missing-target-bus" }); + } + } + + if (Array.isArray(patch.routes)) { + for (let routeIndex = 0; routeIndex < patch.routes.length; routeIndex += 1) { + const route = normalizeRawRoute(patch.routes[routeIndex]); + if (!route) continue; + const staleEndpoint = routeMissingEndpoint(route, modulesById, busesById); + if (staleEndpoint) plan.typedRoutes.push({ routeIndex, ...staleEndpoint }); + } + } + + plan.totalRemovals = plan.legacyTriggerSources.length + + plan.legacyModulations.length + + plan.legacyConnections.length + + plan.typedRoutes.length; + return plan; +} + +export function applyStaleRoutingCleanup(patch: Patch): StaleRoutingCleanupPlan { + const plan = planStaleRoutingCleanup(patch); + if (plan.totalRemovals === 0) return plan; + + const triggerCleanupByModule = new Set(plan.legacyTriggerSources.map((item) => item.moduleId)); + const modulationCleanup = new Set(plan.legacyModulations.map((item) => `${item.moduleId}\u0000${item.parameter}`)); + const connectionCleanup = new Set(plan.legacyConnections.map((item) => item.connectionId)); + const routeCleanup = new Set(plan.typedRoutes.map((item) => item.routeIndex)); + + for (const module of patch.modules) { + if (isSoundModule(module) && triggerCleanupByModule.has(module.id)) { + module.triggerSource = null; + } + + if (!("modulations" in module) || !module.modulations || typeof module.modulations !== "object") continue; + for (const parameter of Object.keys(module.modulations)) { + if (modulationCleanup.has(`${module.id}\u0000${parameter}`)) { + delete module.modulations[parameter]; + } + } + } + + if (connectionCleanup.size > 0) { + patch.connections = patch.connections.filter((connection) => !connectionCleanup.has(connection.id)); + } + + if (Array.isArray(patch.routes) && routeCleanup.size > 0) { + patch.routes = patch.routes.filter((_, routeIndex) => !routeCleanup.has(routeIndex)); + } + + return plan; +} + +export function cleanStaleRoutingRefsIfConfirmed(patch: Patch, confirmCleanup: RoutingCleanupConfirm): StaleRoutingCleanupPlan { + const plan = planStaleRoutingCleanup(patch); + if (plan.totalRemovals === 0) return plan; + if (!confirmCleanup(plan)) return emptyStaleRoutingCleanupPlan(); + return applyStaleRoutingCleanup(patch); +} + function makeLegacyEventRoute(sourceId: string, targetId: string): PatchRoute { return { id: `event:${sourceId}:${targetId}`, diff --git a/src/ui/app.ts b/src/ui/app.ts index 0ca17a1..1f6a2dd 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -1,4 +1,5 @@ import type { Module, Patch } from "../patch"; +import { applyStaleRoutingCleanup } from "../routingGraph"; import { clamp, defaultPatch, emptyPatch, getSoundModules, getTriggers, isEffect } from "../patch"; import type { Engine } from "../engine/audio"; import type { Scheduler } from "../engine/scheduler"; @@ -1018,6 +1019,13 @@ export function mountApp(root: HTMLElement, engine: Engine, sched: Scheduler) { header.updateMasterGainUI(); maybeAutosaveCurrentPreset(); }, + onCleanStaleRoutingRefs: () => { + onPatchChange((draft) => { + applyStaleRoutingCleanup(draft); + }, { regen: false }); + gridRenderer.rerender(); + header.updateRoutingOverview(); + }, onInspectRoutingModule: (moduleId) => { gridRenderer.setRoutingInspect(moduleId); }, diff --git a/src/ui/header/routingOverviewPanel.ts b/src/ui/header/routingOverviewPanel.ts index 57038be..53bcd3e 100644 --- a/src/ui/header/routingOverviewPanel.ts +++ b/src/ui/header/routingOverviewPanel.ts @@ -1,7 +1,7 @@ import type { Patch } from "../../patch"; import { bindFloatingPanelReposition, placeFloatingPanel } from "../floatingPanel"; import { buildRoutingSnapshot, type RoutingSnapshot, type UIRoutingOverviewRoute } from "../routingVisibility"; -import { buildEventRoutingInspectorRows, buildRoutingHealthSummary } from "../routingInspector"; +import { buildEventRoutingInspectorRows, buildRoutingHealthSummary, buildStaleRoutingCleanupSummary, formatStaleRoutingCleanupConfirmation } from "../routingInspector"; import type { MidiInputStatus } from "../midiInput"; import type { MidiOutputStatus } from "../midiOutput"; @@ -15,6 +15,7 @@ type RoutingOverviewPanelParams = { onSelectMidiOutput: (outputId: string | null) => void; onSetMidiTargetModule: (moduleId: string | null) => void; onSetMidiOutSourceModule: (moduleId: string | null) => void; + onCleanStaleRoutingRefs?: () => void; }; function routeDomainLabel(route: UIRoutingOverviewRoute) { @@ -166,7 +167,16 @@ export function createRoutingOverviewPanel(params: RoutingOverviewPanelParams) { healthStatus.className = "routingOverviewHealthStatus"; const healthCounts = document.createElement("div"); healthCounts.className = "routingOverviewHealthCounts"; - healthBlock.append(healthStatus, healthCounts); + const cleanupBlock = document.createElement("div"); + cleanupBlock.className = "routingOverviewCleanup"; + const cleanupSummary = document.createElement("div"); + cleanupSummary.className = "routingOverviewCleanupSummary"; + const cleanupButton = document.createElement("button"); + cleanupButton.type = "button"; + cleanupButton.className = "routingOverviewCleanupButton"; + cleanupButton.textContent = "Clean stale routing refs"; + cleanupBlock.append(cleanupSummary, cleanupButton); + healthBlock.append(healthStatus, healthCounts, cleanupBlock); const inspectorBlock = document.createElement("section"); inspectorBlock.className = "routingOverviewSection routingOverviewInspector"; @@ -292,6 +302,13 @@ export function createRoutingOverviewPanel(params: RoutingOverviewPanelParams) { if (staleModulations) healthCounts.appendChild(createHealthCountChip("stale modulations", staleModulations)); } + const cleanupPlan = buildStaleRoutingCleanupSummary(patch); + cleanupBlock.hidden = cleanupPlan.totalRemovals === 0; + cleanupSummary.textContent = cleanupPlan.totalRemovals === 0 + ? "No stale references to clean." + : `${cleanupPlan.totalRemovals} removable stale ref${cleanupPlan.totalRemovals === 1 ? "" : "s"}`; + cleanupButton.disabled = cleanupPlan.totalRemovals === 0; + const eventRows = buildEventRoutingInspectorRows(patch) .filter((row) => !selectedModuleId || row.voiceId === selectedModuleId || row.sourceId === selectedModuleId); inspectorList.replaceChildren(...createInspectorRows(eventRows)); @@ -426,6 +443,16 @@ export function createRoutingOverviewPanel(params: RoutingOverviewPanelParams) { params.onSetMidiOutSourceModule(midiOutSourceSelect.value || null); render(); }; + cleanupButton.onclick = () => { + const cleanupPlan = buildStaleRoutingCleanupSummary(params.patch()); + if (cleanupPlan.totalRemovals === 0) { + render(); + return; + } + if (!window.confirm(formatStaleRoutingCleanupConfirmation(cleanupPlan))) return; + params.onCleanStaleRoutingRefs?.(); + render(); + }; const close = () => { if (panel.classList.contains("hidden")) return; diff --git a/src/ui/header/transportHeader.ts b/src/ui/header/transportHeader.ts index 289434e..fbdec96 100644 --- a/src/ui/header/transportHeader.ts +++ b/src/ui/header/transportHeader.ts @@ -36,6 +36,7 @@ type HeaderParams = { onSetBpm: (v: number) => void; onSetMasterGain: (v: number) => void; onInspectRoutingModule?: (moduleId: string | null) => void; + onCleanStaleRoutingRefs?: () => void; getSelectionSummary: () => { selectedCount: number; copiedCount: number }; onCopySelection: () => void; onPasteModules: () => void; @@ -734,6 +735,7 @@ export function createTransportHeader(params: HeaderParams) { onSelectMidiOutput: params.onSelectMidiOutput, onSetMidiTargetModule: params.onSetMidiTargetModule, onSetMidiOutSourceModule: params.onSetMidiOutSourceModule, + onCleanStaleRoutingRefs: params.onCleanStaleRoutingRefs, }); const closeSessionMenu = () => { diff --git a/src/ui/routingInspector.ts b/src/ui/routingInspector.ts index 8ce936b..bce37d8 100644 --- a/src/ui/routingInspector.ts +++ b/src/ui/routingInspector.ts @@ -1,5 +1,5 @@ import type { Module, Patch, SoundModule } from "../patch"; -import { compileRoutingGraph, validatePatchRouting, type RoutingValidationIssue, type RoutingValidationIssueCode } from "../routingGraph.ts"; +import { compileRoutingGraph, planStaleRoutingCleanup, validatePatchRouting, type RoutingValidationIssue, type RoutingValidationIssueCode, type StaleRoutingCleanupPlan } from "../routingGraph.ts"; import { resolveTriggerSourceLabelState, type RoutingLabelStatus } from "./routingLabels"; export type RoutingHealthCounts = { @@ -93,3 +93,28 @@ export function buildEventRoutingInspectorRows(patch: Pick 0 ? `- ${label}: ${count}` : null; +} + +export function buildStaleRoutingCleanupSummary(patch: Pick & { routes?: unknown }): StaleRoutingCleanupPlan { + return planStaleRoutingCleanup(patch); +} + +export function formatStaleRoutingCleanupConfirmation(plan: StaleRoutingCleanupPlan) { + if (plan.totalRemovals === 0) return "No stale routing references were found."; + const lines = [ + "Clean stale routing refs?", + "", + "This will remove only references whose module or bus no longer exists:", + cleanupLine("legacy triggerSource refs", plan.legacyTriggerSources.length), + cleanupLine("legacy modulation assignments", plan.legacyModulations.length), + cleanupLine("legacy audio connections", plan.legacyConnections.length), + cleanupLine("typed Patch.routes entries", plan.typedRoutes.length), + "", + "Valid legacy and typed routes will be preserved.", + ].filter((line): line is string => Boolean(line)); + return lines.join("\n"); +} diff --git a/src/ui/style.css b/src/ui/style.css index 1ecb558..c3e5ca8 100644 --- a/src/ui/style.css +++ b/src/ui/style.css @@ -3847,6 +3847,39 @@ button:focus-visible, opacity: 0.9; } +.routingOverviewCleanup { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 6px; + margin-top: 4px; +} + +.routingOverviewCleanup[hidden] { + display: none; +} + +.routingOverviewCleanupSummary { + min-width: 0; + font-size: 10px; + opacity: 0.82; +} + +.routingOverviewCleanupButton { + min-height: 24px; + padding: 3px 8px; + border-radius: 7px; + border: 1px solid rgba(255, 204, 112, 0.38); + background: rgba(18, 27, 38, 0.74); + color: var(--text); + font-size: 10px; + font-weight: 700; +} + +.routingOverviewCleanupButton:disabled { + opacity: 0.5; +} + .routingOverviewInspectorRow { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); diff --git a/tests/routingCleanup.test.mjs b/tests/routingCleanup.test.mjs new file mode 100644 index 0000000..d58b40c --- /dev/null +++ b/tests/routingCleanup.test.mjs @@ -0,0 +1,339 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + applyStaleRoutingCleanup, + cleanStaleRoutingRefsIfConfirmed, + planStaleRoutingCleanup, +} from '../src/routingGraph.ts'; +import { makePatch, makeSound, makeTrigger } from './helpers.mjs'; + +function makeControl(overrides = {}) { + return { + id: 'ctl-1', + type: 'control', + name: 'CTL', + enabled: true, + kind: 'lfo', + waveform: 'sine', + speed: 0.3, + amount: 0.5, + phase: 0, + rate: 0.4, + drift: 0.2, + randomness: 0.2, + ...overrides, + }; +} + +function makeEffect(overrides = {}) { + return { + id: 'fx-1', + type: 'effect', + name: 'FX', + enabled: true, + kind: 'gain', + bypass: false, + gain: 1, + ...overrides, + }; +} + +test('cleanup removes stale legacy triggerSource references', () => { + const sound = makeSound({ id: 'drm-1', triggerSource: 'missing-trigger' }); + const patch = makePatch([sound]); + + const plan = applyStaleRoutingCleanup(patch); + + assert.equal(plan.legacyTriggerSources.length, 1); + assert.equal(plan.totalRemovals, 1); + assert.equal(patch.modules[0].triggerSource, null); +}); + +test('cleanup removes stale legacy modulation assignments', () => { + const trigger = makeTrigger({ id: 'trg-1' }); + const sound = makeSound({ id: 'drm-1', triggerSource: trigger.id, modulations: { basePitch: 'missing-control', decay: 'ctl-1' } }); + const control = makeControl({ id: 'ctl-1' }); + const patch = makePatch([trigger, sound, control]); + + const plan = applyStaleRoutingCleanup(patch); + + const cleaned = patch.modules.find((module) => module.id === sound.id); + assert.equal(plan.legacyModulations.length, 1); + assert.equal(plan.totalRemovals, 1); + assert.deepEqual(cleaned.modulations, { decay: 'ctl-1' }); +}); + +test('cleanup removes stale legacy audio connections', () => { + const trigger = makeTrigger({ id: 'trg-1' }); + const sound = makeSound({ id: 'drm-1', triggerSource: trigger.id }); + const patch = makePatch([trigger, sound]); + patch.connections = [ + { id: 'valid-master', fromModuleId: sound.id, fromPort: 'main', to: { type: 'master' }, gain: 1, enabled: true }, + { id: 'missing-source', fromModuleId: 'missing-sound', fromPort: 'main', to: { type: 'master' }, gain: 1, enabled: true }, + { id: 'missing-target', fromModuleId: sound.id, fromPort: 'main', to: { type: 'module', id: 'missing-fx' }, gain: 1, enabled: true }, + ]; + + const plan = applyStaleRoutingCleanup(patch); + + assert.equal(plan.legacyConnections.length, 2); + assert.equal(plan.totalRemovals, 2); + assert.deepEqual(patch.connections.map((connection) => connection.id), ['valid-master']); +}); + +test('cleanup removes stale typed event routes', () => { + const trigger = makeTrigger({ id: 'trg-1' }); + const sound = makeSound({ id: 'drm-1', triggerSource: trigger.id }); + const patch = makePatch([trigger, sound]); + patch.routes = [ + { + id: 'event-valid', + domain: 'event', + source: { kind: 'module', moduleId: trigger.id, port: 'trigger-out' }, + target: { kind: 'module', moduleId: sound.id, port: 'trigger-in' }, + enabled: true, + }, + { + id: 'event-stale-source', + domain: 'event', + source: { kind: 'module', moduleId: 'missing-trigger', port: 'trigger-out' }, + target: { kind: 'module', moduleId: sound.id, port: 'trigger-in' }, + enabled: true, + }, + ]; + + const plan = applyStaleRoutingCleanup(patch); + + assert.equal(plan.typedRoutes.length, 1); + assert.deepEqual(patch.routes.map((route) => route.id), ['event-valid']); +}); + +test('cleanup removes stale typed modulation routes', () => { + const sound = makeSound({ id: 'drm-1', triggerSource: null }); + const control = makeControl({ id: 'ctl-1' }); + const patch = makePatch([sound, control]); + patch.routes = [ + { + id: 'mod-valid', + domain: 'modulation', + source: { kind: 'module', moduleId: control.id, port: 'cv-out' }, + target: { kind: 'module', moduleId: sound.id, port: 'cv-in' }, + enabled: true, + metadata: { parameter: 'basePitch' }, + }, + { + id: 'mod-stale-target', + domain: 'modulation', + source: { kind: 'module', moduleId: control.id, port: 'cv-out' }, + target: { kind: 'module', moduleId: 'missing-drum', port: 'cv-in' }, + enabled: true, + metadata: { parameter: 'basePitch' }, + }, + ]; + + const plan = applyStaleRoutingCleanup(patch); + + assert.equal(plan.typedRoutes.length, 1); + assert.deepEqual(patch.routes.map((route) => route.id), ['mod-valid']); +}); + +test('cleanup removes stale typed audio routes', () => { + const sound = makeSound({ id: 'drm-1', triggerSource: null }); + const effect = makeEffect({ id: 'fx-1' }); + const patch = makePatch([sound, effect]); + patch.routes = [ + { + id: 'audio-valid', + domain: 'audio', + source: { kind: 'module', moduleId: sound.id, port: 'main' }, + target: { kind: 'module', moduleId: effect.id, port: 'in' }, + enabled: true, + }, + { + id: 'audio-stale-target', + domain: 'audio', + source: { kind: 'module', moduleId: sound.id, port: 'main' }, + target: { kind: 'module', moduleId: 'missing-fx', port: 'in' }, + enabled: true, + }, + ]; + + const plan = applyStaleRoutingCleanup(patch); + + assert.equal(plan.typedRoutes.length, 1); + assert.deepEqual(patch.routes.map((route) => route.id), ['audio-valid']); +}); + + + +test('cleanup removes stale typed routes by position when route ids are duplicated', () => { + const trigger = makeTrigger({ id: 'trg-1' }); + const sound = makeSound({ id: 'drm-1', triggerSource: trigger.id }); + const patch = makePatch([trigger, sound]); + patch.routes = [ + { + id: 'duplicate-id', + domain: 'event', + source: { kind: 'module', moduleId: trigger.id, port: 'trigger-out' }, + target: { kind: 'module', moduleId: sound.id, port: 'trigger-in' }, + enabled: true, + }, + { + id: 'duplicate-id', + domain: 'event', + source: { kind: 'module', moduleId: 'missing-trigger', port: 'trigger-out' }, + target: { kind: 'module', moduleId: sound.id, port: 'trigger-in' }, + enabled: true, + }, + ]; + + const plan = applyStaleRoutingCleanup(patch); + + assert.deepEqual(plan.typedRoutes.map((route) => route.routeIndex), [1]); + assert.equal(patch.routes.length, 1); + assert.equal(patch.routes[0].id, 'duplicate-id'); + assert.equal(patch.routes[0].source.moduleId, trigger.id); +}); + +test('cleanup preserves valid routing references', () => { + const trigger = makeTrigger({ id: 'trg-1' }); + const sound = makeSound({ id: 'drm-1', triggerSource: trigger.id, modulations: { basePitch: 'ctl-1' } }); + const control = makeControl({ id: 'ctl-1' }); + const effect = makeEffect({ id: 'fx-1' }); + const patch = makePatch([trigger, sound, control, effect]); + patch.connections = [ + { id: 'conn-valid', fromModuleId: sound.id, fromPort: 'main', to: { type: 'module', id: effect.id }, gain: 1, enabled: true }, + ]; + patch.routes = [ + { + id: 'evt-valid', + domain: 'event', + source: { kind: 'module', moduleId: trigger.id, port: 'trigger-out' }, + target: { kind: 'module', moduleId: sound.id, port: 'trigger-in' }, + enabled: true, + }, + { + id: 'mod-valid', + domain: 'modulation', + source: { kind: 'module', moduleId: control.id, port: 'cv-out' }, + target: { kind: 'module', moduleId: sound.id, port: 'cv-in' }, + enabled: true, + metadata: { parameter: 'basePitch' }, + }, + { + id: 'aud-valid', + domain: 'audio', + source: { kind: 'module', moduleId: sound.id, port: 'main' }, + target: { kind: 'master', port: 'in' }, + enabled: true, + }, + ]; + const before = structuredClone(patch); + + const plan = applyStaleRoutingCleanup(patch); + + assert.equal(plan.totalRemovals, 0); + assert.deepEqual(patch, before); +}); + +test('cleanup handles mixed valid and stale references deterministically', () => { + const trigger = makeTrigger({ id: 'trg-1' }); + const sound = makeSound({ id: 'drm-1', triggerSource: 'missing-trigger', modulations: { basePitch: 'missing-control', decay: 'ctl-1' } }); + const control = makeControl({ id: 'ctl-1' }); + const patch = makePatch([trigger, sound, control]); + patch.connections = [ + { id: 'a-valid', fromModuleId: sound.id, fromPort: 'main', to: { type: 'master' }, gain: 1, enabled: true }, + { id: 'b-stale', fromModuleId: 'missing-source', fromPort: 'main', to: { type: 'master' }, gain: 1, enabled: true }, + ]; + patch.routes = [ + { + id: 'a-valid-route', + domain: 'event', + source: { kind: 'module', moduleId: trigger.id, port: 'trigger-out' }, + target: { kind: 'module', moduleId: sound.id, port: 'trigger-in' }, + enabled: true, + }, + { + id: 'b-stale-route', + domain: 'audio', + source: { kind: 'module', moduleId: 'missing-sound', port: 'main' }, + target: { kind: 'master', port: 'in' }, + enabled: true, + }, + ]; + + const plan = planStaleRoutingCleanup(patch); + applyStaleRoutingCleanup(patch); + + assert.deepEqual({ + triggerSources: plan.legacyTriggerSources.map((item) => item.moduleId), + modulations: plan.legacyModulations.map((item) => `${item.moduleId}:${item.parameter}`), + connections: plan.legacyConnections.map((item) => item.connectionId), + routes: plan.typedRoutes.map((item) => item.routeId), + total: plan.totalRemovals, + }, { + triggerSources: ['drm-1'], + modulations: ['drm-1:basePitch'], + connections: ['b-stale'], + routes: ['b-stale-route'], + total: 4, + }); + assert.equal(patch.modules.find((module) => module.id === sound.id).triggerSource, null); + assert.deepEqual(patch.modules.find((module) => module.id === sound.id).modulations, { decay: 'ctl-1' }); + assert.deepEqual(patch.connections.map((connection) => connection.id), ['a-valid']); + assert.deepEqual(patch.routes.map((route) => route.id), ['a-valid-route']); +}); + +test('cleanup is idempotent', () => { + const sound = makeSound({ id: 'drm-1', triggerSource: 'missing-trigger', modulations: { basePitch: 'missing-control' } }); + const patch = makePatch([sound]); + patch.connections = [ + { id: 'stale-conn', fromModuleId: 'missing-source', fromPort: 'main', to: { type: 'master' }, gain: 1, enabled: true }, + ]; + patch.routes = [ + { + id: 'stale-route', + domain: 'event', + source: { kind: 'module', moduleId: 'missing-trigger', port: 'trigger-out' }, + target: { kind: 'module', moduleId: sound.id, port: 'trigger-in' }, + enabled: true, + }, + ]; + + const first = applyStaleRoutingCleanup(patch); + const afterFirst = structuredClone(patch); + const second = applyStaleRoutingCleanup(patch); + + assert.equal(first.totalRemovals, 4); + assert.equal(second.totalRemovals, 0); + assert.deepEqual(patch, afterFirst); +}); + +test('cleanup cancellation leaves patch unchanged', () => { + const sound = makeSound({ id: 'drm-1', triggerSource: 'missing-trigger' }); + const patch = makePatch([sound]); + const before = structuredClone(patch); + let sawPlan = false; + + const result = cleanStaleRoutingRefsIfConfirmed(patch, (plan) => { + sawPlan = plan.totalRemovals === 1; + return false; + }); + + assert.equal(sawPlan, true); + assert.equal(result.totalRemovals, 0); + assert.deepEqual(patch, before); +}); + +test('patches with no stale references remain unchanged', () => { + const trigger = makeTrigger({ id: 'trg-1' }); + const sound = makeSound({ id: 'drm-1', triggerSource: trigger.id }); + const patch = makePatch([trigger, sound]); + const before = structuredClone(patch); + + const planned = planStaleRoutingCleanup(patch); + const applied = applyStaleRoutingCleanup(patch); + + assert.equal(planned.totalRemovals, 0); + assert.equal(applied.totalRemovals, 0); + assert.deepEqual(patch, before); +}); From 5b9d1b9c35d8ca497e06ab8256d4ed428b113027 Mon Sep 17 00:00:00 2001 From: Diego Madero Islas Date: Sun, 14 Jun 2026 21:22:41 -0600 Subject: [PATCH 2/5] test(routing): characterize typed and legacy parity --- README.md | 1 + .../routing-architecture-audit-2026-05.md | 25 ++ docs/routing-compatibility-matrix.md | 49 +++ docs/routing-overview-ui.md | 6 + docs/status.md | 1 + tests/routingParityMatrix.test.mjs | 381 ++++++++++++++++++ 6 files changed, 463 insertions(+) create mode 100644 docs/routing-compatibility-matrix.md create mode 100644 tests/routingParityMatrix.test.mjs diff --git a/README.md b/README.md index 305d236..e5dc2be 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ npm run test:e2e:headed - User Manual: [`docs/manual/index.md`](docs/manual/index.md) - Architecture: [`docs/architecture.md`](docs/architecture.md) +- Routing compatibility matrix: [`docs/routing-compatibility-matrix.md`](docs/routing-compatibility-matrix.md) - Module families: [`docs/module-types.md`](docs/module-types.md) - GEN modes reference: [`docs/gen-modes.md`](docs/gen-modes.md) - UI principles: [`docs/ui-principles.md`](docs/ui-principles.md) diff --git a/docs/audits/routing-architecture-audit-2026-05.md b/docs/audits/routing-architecture-audit-2026-05.md index ef1fe3a..ca85d35 100644 --- a/docs/audits/routing-architecture-audit-2026-05.md +++ b/docs/audits/routing-architecture-audit-2026-05.md @@ -862,3 +862,28 @@ Intentional constraints preserved: - no graph editor, patchbay, route-only migration, or routing schema redesign was introduced. Recommended next follow-up: typed/legacy parity hardening, especially partial typed event route adoption, multiple event routes into one sound, and runtime modulation equivalence for typed routes versus legacy `modulations`. + +## Phase 4 implementation note — Typed/legacy parity characterization + +Routing v0.4 Phase 4 adds an executable compatibility matrix for the current hybrid routing model. The pass is documentation and tests only; it does not migrate routing ownership, change `Patch.version`, remove legacy fields, or make `Patch.routes` the sole runtime authority. + +Characterized behavior is recorded in [`../routing-compatibility-matrix.md`](../routing-compatibility-matrix.md) and covered by `tests/routingParityMatrix.test.mjs`. + +Important confirmed findings: + +- `compileRoutingGraph()` applies typed-route precedence per domain and remains the global routing overview's read model. +- Scheduler event runtime resolves compiled event routes first, then still falls back to a sound module's legacy `triggerSource` per sound. +- Partial typed event adoption can therefore hide legacy-only event links from the compiled graph/inspector while those sounds can still play through scheduler fallback. +- Typed modulation routes are compiled and visible, but scheduler/audio modulation runtime still reads target-owned legacy `modulations` maps for currently implemented modulation behavior. +- Typed audio routes compile into connection-like records and are then accepted or rejected by the existing legacy audio validator. +- Existing bus endpoints can compile as audio routes, but bus audio routing remains unsupported at runtime; missing bus endpoints are rejected before runtime use. + +Intentional constraints preserved: + +- Legacy `triggerSource`, `modulations`, and `connections` remain supported. +- Valid legacy routes are not migrated into typed routes. +- Valid typed routes are not mirrored into legacy fields. +- Multiple event inputs, partial typed-domain adoption policy, typed modulation runtime authority, and bus runtime support remain open policy/architecture decisions. + +Recommended next follow-up: add an event-domain read resolver that reports both compiler-canonical and scheduler-effective event sources. Use it first for tests and inspector clarity before changing scheduler behavior, patch writes, or schema ownership. + diff --git a/docs/routing-compatibility-matrix.md b/docs/routing-compatibility-matrix.md new file mode 100644 index 0000000..7a64dc6 --- /dev/null +++ b/docs/routing-compatibility-matrix.md @@ -0,0 +1,49 @@ +# Routing compatibility matrix + +This document records the current v0.4 typed/legacy routing compatibility contract as executable characterization, not as a migration plan. + +Reference test suite: `tests/routingParityMatrix.test.mjs`. + +## Scope + +- `Patch.routes` remains an optional typed overlay. +- Legacy `triggerSource`, `modulations`, and `connections` remain supported. +- `compileRoutingGraph()` is a normalizer/read model. It is not the sole runtime authority. +- No patch schema version change is implied by this matrix. +- The confirmed stale-reference cleanup only removes references whose module or bus endpoint no longer exists. It does not migrate valid legacy routing into typed routes. + +## Current matrix + +| Case | Accepted patch representation | Compiled graph representation | Actual runtime authority | Precedence / merge behavior | Inspector-visible behavior | Known ambiguity or incompatibility | +| --- | --- | --- | --- | --- | --- | --- | +| 1. Legacy event only | Sound module has `triggerSource` pointing to a trigger module. | Backfilled event route with `metadata.createdFrom: "legacy-triggerSource"`; `eventSourceBySoundId` maps sound to trigger. | Scheduler resolves compiled event source, with `triggerSource` fallback also available. | Legacy is used when there are no valid typed event routes. | Shows trigger-to-sound route. | None for simple GEN-to-sound patches. | +| 2. Typed event only | `Patch.routes[]` has an enabled `domain: "event"` trigger-module to sound-module route; sound may have `triggerSource: null`. | Typed event route appears directly; `eventSourceBySoundId` maps sound to route source. | Scheduler resolves the compiled typed route. | Typed route works without legacy `triggerSource`. | Shows typed trigger-to-sound route. | Patch migration may backfill `triggerSource` in some import paths for compatibility, but runtime does not require it here. | +| 3. Legacy and typed event on same sound | Sound has `triggerSource`; `Patch.routes[]` also has a valid typed event route for that sound. | Typed event route is compiled; legacy event backfill is suppressed for the event domain. | Scheduler uses compiled typed source first. | Typed source wins for that sound; no double-apply. | Shows typed source, not legacy source. | Compatibility depends on preserving scheduler fallback for patches without complete typed coverage. | +| 4. Partial typed adoption across domains | A patch may have a typed event route while legacy modulation and legacy audio still exist. | Typed event suppresses only legacy event backfill; legacy modulation/audio still backfill because their domains have no typed routes. | Scheduler still falls back to `triggerSource` per sound when a sound has no compiled event source; audio uses compiled audio connections; modulation runtime remains target-owned. | Precedence is per domain in the compiler, but scheduler event fallback is per sound. | Legacy-only event links can be invisible in the overview when any typed event route exists, even though scheduler fallback can still play them. | This is the clearest graph-vs-runtime divergence. Decide whether inspector should show scheduler-effective fallback or compiler-canonical routes only. | +| 5. Multiple typed event routes to one sound | Multiple enabled event routes target the same sound from different triggers. | All event routes remain visible; `eventSourceBySoundId` stores the first source; `triggerTargets` lists the target under each source. | Scheduler resolves the first compiled source for the sound. | First typed event source wins for playback; other routes are visible but not event-authoritative for that target. | Overview lists routes; per-voice incoming trigger resolves to first route source. | Multi-input event semantics are undefined: invalid, first-wins, merge, or role-filtered routing are future policy choices. | +| 6. Multiple sources targeting same destination | Event routes may target one sound; modulation routes may target one target parameter. | Event routes are all retained with first source in `eventSourceBySoundId`; modulation stores first control per target parameter and warns for later conflicting sources. | Event scheduler uses first source; modulation runtime still reads legacy `modulations` maps rather than typed graph. | Event first-wins for scheduler; modulation first-wins only in compiled graph. | Inspector follows compiled graph. | Event and modulation conflict policies are not expressed as a single shared route-ownership rule. | +| 7. Legacy modulation only | Module has `modulations` map from parameter to control module ID. | Backfilled modulation route with `metadata.createdFrom: "legacy-modulations"`; incoming modulation index includes source/parameter. | Scheduler density and audio voice modulation read target module `modulations` maps. | Legacy is used when there are no valid typed modulation routes. | Shows control-to-parameter modulation. | Runtime only applies a subset of assignable parameters. | +| 8. Typed modulation only | `Patch.routes[]` has an enabled `domain: "modulation"` route with `metadata.parameter`. | Typed modulation route appears in `modulationIncomingByTarget`. | Current scheduler/audio runtime does not consume typed modulation graph for density/pitch/cutoff; it reads legacy target-owned `modulations`. | Typed route is visible but does not currently provide runtime parity for legacy modulation. | Shows typed modulation route. | Typed modulation parity is incomplete. Follow-up must decide whether to mirror typed routes into runtime resolver or keep typed modulation as visibility/interoperability only. | +| 9. Legacy and typed modulation on same parameter | Target module has legacy `modulations`; `Patch.routes[]` also has a valid typed modulation route. | Typed modulation domain suppresses all legacy modulation backfill; compiled graph shows typed source. | Runtime still reads target-owned legacy `modulations`, so legacy can drive behavior while inspector shows typed source. | Compiler typed precedence and runtime legacy authority can disagree. | Shows typed modulation source. | This is a high-priority ownership mismatch. Do not change without explicit policy because it can alter sound. | +| 10. Legacy audio only | `Patch.connections[]` contains enabled audio connection records. | Backfilled audio routes with `metadata.createdFrom: "legacy-connections"`; `audioConnections` mirrors connection-like records. | Audio engine compiles graph, then validates compiled `audioConnections` through legacy `validateConnections()`. | Legacy audio is used when there are no valid typed audio routes. | Shows audio route. | Runtime validation still constrains supported ports/targets. | +| 11. Typed audio only | `Patch.routes[]` has enabled `domain: "audio"` route to module/master/bus endpoint. | Typed audio route converts to connection-like `audioConnections` record. | Audio engine validates compiled audio connections through `validateConnections()`. | Typed route can run if legacy validator accepts the converted connection. | Shows typed audio route. | Graph acceptance and runtime acceptance can differ for buses or unsupported ports. | +| 12. Legacy and typed audio | `Patch.connections[]` and valid typed audio routes both exist. | Typed audio domain suppresses legacy audio backfill; `audioConnections` contains typed routes only. | Audio engine uses compiled typed audio connections after validation. | Typed audio wins for the entire audio domain. | Shows typed audio only. | Partial typed audio adoption can hide/disable legacy connections for the compiled/runtime audio graph. | +| 13. Missing or unsupported bus endpoints | Missing buses may be referenced by routes/connections; existing bus endpoints can also be referenced. | Missing bus typed routes are rejected with warnings and no compiled audio connection. Existing bus endpoints can compile. | Existing bus audio connections are rejected by `validateConnections()` because bus routing is not supported at runtime. | Missing endpoints are invalid; existing buses are serializable/visible but not runtime-active. | Missing endpoints appear through routing health; existing bus routes can appear as compiled audio routes. | Bus serialization exists ahead of runtime implementation. UI copy should not imply bus audio works yet. | +| 14. Compiler output vs runtime behavior | All hybrid forms above are accepted if validation permits their endpoints. | Compiler applies per-domain typed precedence and produces route indexes for visibility. | Scheduler uses compiled event source first, then `triggerSource`; modulation runtime reads legacy maps; audio runtime validates compiled audio through legacy validator. | There is no single runtime authority across all domains. | Inspector follows compiled graph, not every runtime fallback. | The next consolidation work should pick one domain at a time and preserve compatibility with explicit tests. | + +## Policy decisions still open + +1. Should partial typed event adoption remain compiler-canonical while scheduler keeps per-sound legacy fallback, or should the inspector expose scheduler-effective fallback routes? +2. Should multiple event inputs to one sound be invalid, first-wins, merged, or role/lane-filtered? +3. Should typed modulation routes become runtime-authoritative, and if so should they mirror into legacy maps or feed a new resolver used by scheduler/audio? +4. Should typed audio routes suppress legacy audio for the whole domain, or only for destinations/sources they explicitly replace? +5. How should existing bus endpoints be presented while runtime bus routing remains unsupported? + +## Recommended next step + +The first safe ownership-consolidation step is an event-domain read resolver that reports both: + +- the compiler-canonical event source used by `compileRoutingGraph()`; +- the scheduler-effective event source after per-sound `triggerSource` fallback. + +That resolver can power tests and inspector labels before changing scheduler behavior or patch writes. It would address the clearest user-visible mismatch without migrating schemas or removing legacy routing. diff --git a/docs/routing-overview-ui.md b/docs/routing-overview-ui.md index e5d9dba..54675bb 100644 --- a/docs/routing-overview-ui.md +++ b/docs/routing-overview-ui.md @@ -46,3 +46,9 @@ Phase 3 is visibility + canonical read-model migration, not patchbay editing. The Routing overview can now offer a confirmed cleanup action when validation finds references to modules or buses that no longer exist. The action removes only invalid references from legacy `triggerSource`, legacy `modulations`, legacy `connections`, and typed `Patch.routes` records. Opening the overview does not mutate state, and cancellation leaves the patch unchanged. This is not a routing ownership migration. Legacy routing remains supported, `Patch.routes` remains an optional typed overlay rather than the sole runtime authority, and typed-route parity/ownership consolidation remain future v0.4 work. + +## Typed/legacy parity characterization + +The next v0.4 routing pass added an executable compatibility matrix for typed and legacy route coexistence: [`routing-compatibility-matrix.md`](routing-compatibility-matrix.md). + +That pass did not change routing ownership. The global overview still follows `compileRoutingGraph()` output, which means it can intentionally differ from runtime fallback behavior in known hybrid cases, especially partial typed event adoption and typed modulation routes. diff --git a/docs/status.md b/docs/status.md index 38bcc18..ca0a984 100644 --- a/docs/status.md +++ b/docs/status.md @@ -75,6 +75,7 @@ Reference: [`docs/gen-mode-design-principles.md`](gen-mode-design-principles.md) - First live Web MIDI keyboard input foundation is now active for synth modules (single target, note on/off, mono/poly-aware reception, compact input selector with hardware-first auto preference). - MIDI now appears as a first-class routing domain in the global Routing UI: users can assign `MIDI IN` input source + synth target explicitly, and MIDI routes are listed alongside event/modulation/audio routes. - Routing health now includes a confirmed stale-reference cleanup action for missing module/bus references across legacy `triggerSource`, legacy `modulations`, legacy audio `connections`, and typed `Patch.routes`. This does not change routing ownership, schema version, or legacy compatibility. +- Typed/legacy routing parity is now characterized in [`routing-compatibility-matrix.md`](routing-compatibility-matrix.md) and covered by executable tests. The matrix documents current compiler/runtime/inspector differences without migrating routing ownership. ## Near-term next steps (active priority) diff --git a/tests/routingParityMatrix.test.mjs b/tests/routingParityMatrix.test.mjs new file mode 100644 index 0000000..e9d5f7b --- /dev/null +++ b/tests/routingParityMatrix.test.mjs @@ -0,0 +1,381 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { validateConnections } from '../src/engine/routing.ts'; +import { createScheduler } from '../src/engine/scheduler.ts'; +import { compileRoutingGraph, validatePatchRouting } from '../src/routingGraph.ts'; +import { buildRoutingSnapshot } from '../src/ui/routingVisibility.ts'; +import { makePatch, makeSound, makeTrigger } from './helpers.mjs'; + +function makeControl(overrides = {}) { + return { + id: 'ctl-1', + type: 'control', + name: 'CTL', + enabled: true, + kind: 'lfo', + waveform: 'square', + speed: 0.1, + amount: 1, + phase: 0, + rate: 0.4, + drift: 0.2, + randomness: 0.2, + ...overrides, + }; +} + +function makeEffect(overrides = {}) { + return { + id: 'fx-1', + type: 'effect', + name: 'FX', + enabled: true, + effectType: 'filter', + mix: 0.5, + drive: 0.2, + cutoff: 0.6, + resonance: 0.2, + ...overrides, + }; +} + +function eventRoute(id, sourceId, targetId) { + return { + id, + domain: 'event', + source: { kind: 'module', moduleId: sourceId, port: 'trigger-out' }, + target: { kind: 'module', moduleId: targetId, port: 'trigger-in' }, + enabled: true, + metadata: { createdFrom: 'ui' }, + }; +} + +function modulationRoute(id, sourceId, targetId, parameter) { + return { + id, + domain: 'modulation', + source: { kind: 'module', moduleId: sourceId, port: 'cv-out' }, + target: { kind: 'module', moduleId: targetId, port: 'cv-in' }, + enabled: true, + metadata: { createdFrom: 'ui', parameter }, + }; +} + +function audioRoute(id, sourceId, target) { + return { + id, + domain: 'audio', + source: { kind: 'module', moduleId: sourceId, port: 'main' }, + target, + enabled: true, + gain: 0.75, + metadata: { createdFrom: 'ui' }, + }; +} + +function withWindowTimer(fn) { + const prevWindow = globalThis.window; + let intervalFn = null; + globalThis.window = { setInterval: (cb) => (intervalFn = cb, 1), clearInterval: () => {} }; + try { return fn(() => intervalFn && intervalFn()); } finally { globalThis.window = prevWindow; } +} + +function runScheduler(patch, times = [0, 0.025, 0.05, 0.075, 0.1]) { + const triggered = []; + const engine = { + ctx: { currentTime: 0, state: 'running' }, + setTransportRunning: () => {}, + triggerVoice: (id, _patch, when) => triggered.push({ id, when }), + }; + + withWindowTimer((tick) => { + const scheduler = createScheduler(engine); + scheduler.setBpm(120); + scheduler.setPatch(patch); + scheduler.start(); + for (const t of times) { + engine.ctx.currentTime = t; + tick(); + } + scheduler.stop(); + }); + + return triggered; +} + +test('parity matrix: legacy event routing only is compiled, visible, and scheduled from triggerSource', () => { + const trigger = makeTrigger({ id: 'trg-legacy', density: 1, drop: 0, length: 8 }); + const sound = makeSound({ id: 'drm-legacy', triggerSource: trigger.id }); + const patch = makePatch([trigger, sound]); + + const compiled = compileRoutingGraph(patch); + const snapshot = buildRoutingSnapshot(patch); + const triggered = runScheduler(patch); + + assert.equal(compiled.eventSourceBySoundId.get(sound.id), trigger.id); + assert.equal(compiled.routes[0].metadata.createdFrom, 'legacy-triggerSource'); + assert.equal(snapshot.voiceIncoming.get(sound.id).trigger.id, trigger.id); + assert.ok(triggered.some((ev) => ev.id === sound.id)); +}); + +test('parity matrix: typed event routing only is compiled, visible, and scheduled without triggerSource', () => { + const trigger = makeTrigger({ id: 'trg-typed', density: 1, drop: 0, length: 8 }); + const sound = makeSound({ id: 'drm-typed', triggerSource: null }); + const patch = makePatch([trigger, sound]); + patch.routes = [eventRoute('evt-typed', trigger.id, sound.id)]; + + const compiled = compileRoutingGraph(patch); + const snapshot = buildRoutingSnapshot(patch); + const triggered = runScheduler(patch); + + assert.equal(compiled.eventSourceBySoundId.get(sound.id), trigger.id); + assert.equal(compiled.routes[0].id, 'evt-typed'); + assert.equal(snapshot.voiceIncoming.get(sound.id).trigger.id, trigger.id); + assert.ok(triggered.some((ev) => ev.id === sound.id)); +}); + +test('parity matrix: simultaneous legacy triggerSource and typed event route uses typed event in graph and scheduler', () => { + const legacyTrigger = makeTrigger({ id: 'trg-legacy', density: 1, drop: 0, length: 8 }); + const typedTrigger = makeTrigger({ id: 'trg-typed', density: 1, drop: 0, length: 8 }); + const sound = makeSound({ id: 'drm-hybrid', triggerSource: legacyTrigger.id }); + const patch = makePatch([legacyTrigger, typedTrigger, sound]); + patch.routes = [eventRoute('evt-typed', typedTrigger.id, sound.id)]; + + const compiled = compileRoutingGraph(patch); + const snapshot = buildRoutingSnapshot(patch); + const observed = []; + const engine = { + ctx: { currentTime: 0, state: 'running' }, + setTransportRunning: () => {}, + triggerVoice: () => {}, + }; + + withWindowTimer((tick) => { + const scheduler = createScheduler(engine); + scheduler.setScheduledEventObserver((ev) => observed.push({ sourceId: ev.source.id, targetId: ev.target.id })); + scheduler.setBpm(120); + scheduler.setPatch(patch); + scheduler.start(); + for (const t of [0, 0.025, 0.05, 0.075]) { + engine.ctx.currentTime = t; + tick(); + } + scheduler.stop(); + }); + + assert.equal(compiled.eventSourceBySoundId.get(sound.id), typedTrigger.id); + assert.equal(compiled.triggerTargets.has(legacyTrigger.id), false); + assert.equal(snapshot.voiceIncoming.get(sound.id).trigger.id, typedTrigger.id); + assert.ok(observed.some((ev) => ev.sourceId === typedTrigger.id && ev.targetId === sound.id)); + assert.equal(observed.some((ev) => ev.sourceId === legacyTrigger.id), false); +}); + +test('parity matrix: partial typed adoption is per domain in graph, while scheduler still falls back per sound', () => { + const triggerA = makeTrigger({ id: 'trg-a', density: 1, drop: 0, length: 8 }); + const triggerB = makeTrigger({ id: 'trg-b', density: 1, drop: 0, length: 8 }); + const control = makeControl({ id: 'ctl-legacy' }); + const routedSound = makeSound({ id: 'drm-routed', triggerSource: triggerA.id, modulations: { basePitch: control.id } }); + const legacyOnlySound = makeSound({ id: 'drm-legacy-only', triggerSource: triggerA.id }); + const patch = makePatch([triggerA, triggerB, control, routedSound, legacyOnlySound]); + patch.connections = [ + { id: 'conn-legacy', fromModuleId: legacyOnlySound.id, fromPort: 'main', to: { type: 'master' }, gain: 1, enabled: true }, + ]; + patch.routes = [eventRoute('evt-partial', triggerB.id, routedSound.id)]; + + const compiled = compileRoutingGraph(patch); + const snapshot = buildRoutingSnapshot(patch); + const triggered = runScheduler(patch); + + assert.equal(compiled.eventSourceBySoundId.get(routedSound.id), triggerB.id); + assert.equal(compiled.eventSourceBySoundId.has(legacyOnlySound.id), false); + assert.deepEqual(compiled.modulationIncomingByTarget.get(routedSound.id), [{ parameter: 'basePitch', sourceId: control.id }]); + assert.equal(compiled.audioConnections[0].id, 'audio:conn-legacy'); + assert.equal(snapshot.voiceIncoming.get(legacyOnlySound.id).trigger, null); + assert.ok(triggered.some((ev) => ev.id === routedSound.id)); + assert.ok(triggered.some((ev) => ev.id === legacyOnlySound.id)); +}); + +test('parity matrix: multiple typed event routes into one sound are visible as many routes but first source wins for runtime', () => { + const triggerA = makeTrigger({ id: 'trg-a', density: 1, drop: 0, length: 8 }); + const triggerB = makeTrigger({ id: 'trg-b', density: 1, drop: 0, length: 8 }); + const sound = makeSound({ id: 'drm-target', triggerSource: null }); + const patch = makePatch([triggerA, triggerB, sound]); + patch.routes = [ + eventRoute('evt-a', triggerA.id, sound.id), + eventRoute('evt-b', triggerB.id, sound.id), + ]; + + const compiled = compileRoutingGraph(patch); + const snapshot = buildRoutingSnapshot(patch); + const observed = []; + const engine = { + ctx: { currentTime: 0, state: 'running' }, + setTransportRunning: () => {}, + triggerVoice: () => {}, + }; + + withWindowTimer((tick) => { + const scheduler = createScheduler(engine); + scheduler.setScheduledEventObserver((ev) => observed.push(ev.source.id)); + scheduler.setBpm(120); + scheduler.setPatch(patch); + scheduler.start(); + for (const t of [0, 0.025, 0.05, 0.075]) { + engine.ctx.currentTime = t; + tick(); + } + scheduler.stop(); + }); + + assert.equal(compiled.routes.filter((route) => route.domain === 'event').length, 2); + assert.equal(compiled.eventSourceBySoundId.get(sound.id), triggerA.id); + assert.deepEqual(compiled.triggerTargets.get(triggerA.id), [sound.id]); + assert.deepEqual(compiled.triggerTargets.get(triggerB.id), [sound.id]); + assert.equal(snapshot.voiceIncoming.get(sound.id).trigger.id, triggerA.id); + assert.ok(observed.length > 0); + assert.ok(observed.every((sourceId) => sourceId === triggerA.id)); +}); + +test('parity matrix: multiple sources targeting one modulation parameter keep first compiled owner and warn', () => { + const trigger = makeTrigger({ id: 'trg-1' }); + const sound = makeSound({ id: 'drm-1', triggerSource: trigger.id }); + const controlA = makeControl({ id: 'ctl-a' }); + const controlB = makeControl({ id: 'ctl-b' }); + const patch = makePatch([trigger, sound, controlA, controlB]); + patch.routes = [ + modulationRoute('mod-a', controlA.id, sound.id, 'basePitch'), + modulationRoute('mod-b', controlB.id, sound.id, 'basePitch'), + ]; + + const compiled = compileRoutingGraph(patch); + assert.deepEqual(compiled.modulationIncomingByTarget.get(sound.id), [{ parameter: 'basePitch', sourceId: controlA.id }]); + assert.equal(compiled.warnings.some((warning) => warning.includes('already controlled by ctl-a')), true); +}); + +test('parity matrix: legacy modulation only is compiled and visible from target-owned modulations', () => { + const trigger = makeTrigger({ id: 'trg-1' }); + const control = makeControl({ id: 'ctl-legacy' }); + const sound = makeSound({ id: 'drm-1', triggerSource: trigger.id, modulations: { basePitch: control.id } }); + const patch = makePatch([trigger, control, sound]); + + const compiled = compileRoutingGraph(patch); + const snapshot = buildRoutingSnapshot(patch); + + assert.deepEqual(compiled.modulationIncomingByTarget.get(sound.id), [{ parameter: 'basePitch', sourceId: control.id }]); + assert.equal(compiled.routes.find((route) => route.domain === 'modulation').metadata.createdFrom, 'legacy-modulations'); + assert.equal(snapshot.voiceIncoming.get(sound.id).modulations[0].source.id, control.id); +}); + +test('parity matrix: typed modulation only is compiled and visible but scheduler density modulation remains legacy-owned', () => { + const trigger = makeTrigger({ id: 'trg-1', density: 0, drop: 0, length: 8, modulations: {} }); + const control = makeControl({ id: 'ctl-typed', waveform: 'square', amount: 1, phase: 0 }); + const sound = makeSound({ id: 'drm-1', triggerSource: trigger.id }); + const patch = makePatch([trigger, control, sound]); + patch.routes = [modulationRoute('mod-density', control.id, trigger.id, 'density')]; + + const compiled = compileRoutingGraph(patch); + const snapshot = buildRoutingSnapshot(patch); + const triggered = runScheduler(patch); + + assert.deepEqual(compiled.modulationIncomingByTarget.get(trigger.id), [{ parameter: 'density', sourceId: control.id }]); + assert.equal(snapshot.triggerIncoming.get(trigger.id)[0].source.id, control.id); + assert.deepEqual(triggered, []); +}); + +test('parity matrix: simultaneous legacy and typed modulation shows typed route while runtime still reads legacy modulations', () => { + const trigger = makeTrigger({ id: 'trg-1', density: 0, drop: 0, length: 8, modulations: { density: 'ctl-legacy' } }); + const legacyControl = makeControl({ id: 'ctl-legacy', waveform: 'square', amount: 1, phase: 0 }); + const typedControl = makeControl({ id: 'ctl-typed', waveform: 'sine', amount: 0, phase: 0 }); + const sound = makeSound({ id: 'drm-1', triggerSource: trigger.id }); + const patch = makePatch([trigger, legacyControl, typedControl, sound]); + patch.routes = [modulationRoute('mod-density', typedControl.id, trigger.id, 'density')]; + + const compiled = compileRoutingGraph(patch); + const snapshot = buildRoutingSnapshot(patch); + const triggered = runScheduler(patch); + + assert.deepEqual(compiled.modulationIncomingByTarget.get(trigger.id), [{ parameter: 'density', sourceId: typedControl.id }]); + assert.equal(snapshot.triggerIncoming.get(trigger.id)[0].source.id, typedControl.id); + assert.ok(triggered.some((ev) => ev.id === sound.id)); +}); + +test('parity matrix: legacy audio connections only are compiled to legacy audio routes and accepted by runtime validation', () => { + const trigger = makeTrigger({ id: 'trg-1' }); + const sound = makeSound({ id: 'drm-1', triggerSource: trigger.id }); + const patch = makePatch([trigger, sound]); + patch.connections = [ + { id: 'conn-legacy', fromModuleId: sound.id, fromPort: 'main', to: { type: 'master' }, gain: 0.6, enabled: true }, + ]; + + const compiled = compileRoutingGraph(patch); + const validation = validateConnections({ ...patch, connections: compiled.audioConnections }); + + assert.equal(compiled.audioConnections[0].id, 'audio:conn-legacy'); + assert.equal(compiled.routes.find((route) => route.domain === 'audio').metadata.createdFrom, 'legacy-connections'); + assert.equal(validation.validConnections.length, 1); +}); + +test('parity matrix: typed audio routes only compile to runtime connection records', () => { + const trigger = makeTrigger({ id: 'trg-1' }); + const sound = makeSound({ id: 'drm-1', triggerSource: trigger.id }); + const patch = makePatch([trigger, sound]); + patch.routes = [audioRoute('aud-typed', sound.id, { kind: 'master', port: 'in' })]; + + const compiled = compileRoutingGraph(patch); + const snapshot = buildRoutingSnapshot(patch); + const validation = validateConnections({ ...patch, connections: compiled.audioConnections }); + + assert.deepEqual(compiled.audioConnections, [ + { id: 'aud-typed', fromModuleId: sound.id, fromPort: 'main', to: { type: 'master', port: 'in' }, gain: 0.75, enabled: true }, + ]); + assert.equal(snapshot.overview.audioRoutes[0].id, 'aud-typed'); + assert.equal(validation.validConnections.length, 1); +}); + +test('parity matrix: simultaneous legacy and typed audio routing uses typed audio for compiled/runtime graph', () => { + const trigger = makeTrigger({ id: 'trg-1' }); + const sound = makeSound({ id: 'drm-1', triggerSource: trigger.id }); + const effect = makeEffect({ id: 'fx-1' }); + const patch = makePatch([trigger, sound, effect]); + patch.connections = [ + { id: 'conn-legacy', fromModuleId: sound.id, fromPort: 'main', to: { type: 'master' }, gain: 1, enabled: true }, + ]; + patch.routes = [audioRoute('aud-typed', sound.id, { kind: 'module', moduleId: effect.id, port: 'in' })]; + + const compiled = compileRoutingGraph(patch); + const validation = validateConnections({ ...patch, connections: compiled.audioConnections }); + + assert.deepEqual(compiled.audioConnections.map((conn) => conn.id), ['aud-typed']); + assert.deepEqual(compiled.audioConnections[0].to, { type: 'module', id: effect.id, port: 'in' }); + assert.equal(validation.validConnections.length, 1); +}); + +test('parity matrix: bus endpoints can compile when present but remain unsupported by runtime audio validation', () => { + const trigger = makeTrigger({ id: 'trg-1' }); + const sound = makeSound({ id: 'drm-1', triggerSource: trigger.id }); + const patch = makePatch([trigger, sound]); + patch.buses = [{ id: 'bus-1', name: 'Bus 1', enabled: true, gain: 1 }]; + patch.routes = [audioRoute('aud-bus', sound.id, { kind: 'bus', busId: 'bus-1', port: 'in' })]; + + const compiled = compileRoutingGraph(patch); + const validation = validateConnections({ ...patch, connections: compiled.audioConnections }); + + assert.deepEqual(compiled.audioConnections[0].to, { type: 'bus', id: 'bus-1', port: 'in' }); + assert.deepEqual(validation.validConnections, []); + assert.equal(validation.warnings.some((warning) => warning.includes('bus routing is not currently supported')), true); +}); + +test('parity matrix: missing bus endpoints are rejected before graph/runtime use', () => { + const trigger = makeTrigger({ id: 'trg-1' }); + const sound = makeSound({ id: 'drm-1', triggerSource: trigger.id }); + const patch = makePatch([trigger, sound]); + patch.routes = [audioRoute('aud-missing-bus', sound.id, { kind: 'bus', busId: 'missing-bus', port: 'in' })]; + + const compiled = compileRoutingGraph(patch); + const validation = validatePatchRouting(patch); + + assert.equal(compiled.audioConnections.length, 0); + assert.equal(compiled.warnings.some((warning) => warning.includes('missing target bus: missing-bus')), true); + assert.equal(validation.issues.some((issue) => issue.code === 'route-missing-target-bus'), true); +}); From 29296686f9a61bad76e3db124969190cdc5b2692 Mon Sep 17 00:00:00 2001 From: Diego Madero Islas Date: Sun, 14 Jun 2026 21:26:26 -0600 Subject: [PATCH 3/5] docs(routing): remove trailing blank line --- docs/audits/routing-architecture-audit-2026-05.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/audits/routing-architecture-audit-2026-05.md b/docs/audits/routing-architecture-audit-2026-05.md index ca85d35..4cf5693 100644 --- a/docs/audits/routing-architecture-audit-2026-05.md +++ b/docs/audits/routing-architecture-audit-2026-05.md @@ -886,4 +886,3 @@ Intentional constraints preserved: - Multiple event inputs, partial typed-domain adoption policy, typed modulation runtime authority, and bus runtime support remain open policy/architecture decisions. Recommended next follow-up: add an event-domain read resolver that reports both compiler-canonical and scheduler-effective event sources. Use it first for tests and inspector clarity before changing scheduler behavior, patch writes, or schema ownership. - From 4bdf356d0e684b149b08639379e946f6e85fcd68 Mon Sep 17 00:00:00 2001 From: Diego Madero Islas Date: Sun, 14 Jun 2026 21:55:17 -0600 Subject: [PATCH 4/5] feat(routing): enforce one primary event source per voice --- .../routing-architecture-audit-2026-05.md | 21 ++ docs/routing-compatibility-matrix.md | 27 +- docs/routing-overview-ui.md | 6 + docs/status.md | 1 + src/routingGraph.ts | 168 +++++++++++ src/ui/routingInspector.ts | 20 +- src/ui/routingLabels.ts | 2 +- src/ui/triggerModule.ts | 5 +- src/ui/voiceModule.ts | 16 +- tests/eventRoutingPrimary.test.mjs | 275 ++++++++++++++++++ 10 files changed, 515 insertions(+), 26 deletions(-) create mode 100644 tests/eventRoutingPrimary.test.mjs diff --git a/docs/audits/routing-architecture-audit-2026-05.md b/docs/audits/routing-architecture-audit-2026-05.md index 4cf5693..deb1a28 100644 --- a/docs/audits/routing-architecture-audit-2026-05.md +++ b/docs/audits/routing-architecture-audit-2026-05.md @@ -886,3 +886,24 @@ Intentional constraints preserved: - Multiple event inputs, partial typed-domain adoption policy, typed modulation runtime authority, and bus runtime support remain open policy/architecture decisions. Recommended next follow-up: add an event-domain read resolver that reports both compiler-canonical and scheduler-effective event sources. Use it first for tests and inspector clarity before changing scheduler behavior, patch writes, or schema ownership. + +## Phase 5 implementation note — Primary event assignment consolidation + +Routing v0.4 Phase 5 formalizes the current product rule that each voice has one effective event input role: `primary`. Existing typed event routes without an explicit role are treated conceptually as primary routes, but no required role field, route-role migration, or patch schema version change was introduced. + +Implemented behavior: + +- `resolveVoiceEventRouting()` reports compiled/canonical source, legacy `triggerSource`, scheduler-effective source, typed event route candidates, and the voice event routing state. +- Routing states include `legacy-only`, `typed-only`, `matching-hybrid`, `conflicting-hybrid`, `missing`, `stale`, and `ambiguous`. +- New UI assignment paths use replacement semantics through `setVoicePrimaryEventSource()`: assigning a GEN to a voice updates legacy `triggerSource`, removes existing implicit-primary typed event routes targeting that voice, and writes one typed primary event route when assigned. +- Repeated assignment is idempotent; disabled or stale previous primary routes do not block replacement; unrelated event routes to other voices are preserved. +- Existing ambiguous patches with multiple enabled primary typed event routes to one voice are detected by the resolver and routing validation but are not silently rewritten on load or inspection. + +Intentional constraints preserved: + +- `Patch.version` remains `0.3`. +- Legacy `triggerSource` remains supported for compatibility. +- No multi-source event merge, first-wins product policy, last-wins product policy, role/lane event model, graph editor, audio routing change, or modulation routing change was introduced. + +Recommended next follow-up: draft a role-aware event routing RFC that keeps `primary` stable while defining future optional roles such as `accent`, `fill`, `reset`, or lane-specific inputs. In parallel, typed modulation runtime parity remains the next high-impact consolidation target. + diff --git a/docs/routing-compatibility-matrix.md b/docs/routing-compatibility-matrix.md index 7a64dc6..06f633a 100644 --- a/docs/routing-compatibility-matrix.md +++ b/docs/routing-compatibility-matrix.md @@ -20,8 +20,8 @@ Reference test suite: `tests/routingParityMatrix.test.mjs`. | 2. Typed event only | `Patch.routes[]` has an enabled `domain: "event"` trigger-module to sound-module route; sound may have `triggerSource: null`. | Typed event route appears directly; `eventSourceBySoundId` maps sound to route source. | Scheduler resolves the compiled typed route. | Typed route works without legacy `triggerSource`. | Shows typed trigger-to-sound route. | Patch migration may backfill `triggerSource` in some import paths for compatibility, but runtime does not require it here. | | 3. Legacy and typed event on same sound | Sound has `triggerSource`; `Patch.routes[]` also has a valid typed event route for that sound. | Typed event route is compiled; legacy event backfill is suppressed for the event domain. | Scheduler uses compiled typed source first. | Typed source wins for that sound; no double-apply. | Shows typed source, not legacy source. | Compatibility depends on preserving scheduler fallback for patches without complete typed coverage. | | 4. Partial typed adoption across domains | A patch may have a typed event route while legacy modulation and legacy audio still exist. | Typed event suppresses only legacy event backfill; legacy modulation/audio still backfill because their domains have no typed routes. | Scheduler still falls back to `triggerSource` per sound when a sound has no compiled event source; audio uses compiled audio connections; modulation runtime remains target-owned. | Precedence is per domain in the compiler, but scheduler event fallback is per sound. | Legacy-only event links can be invisible in the overview when any typed event route exists, even though scheduler fallback can still play them. | This is the clearest graph-vs-runtime divergence. Decide whether inspector should show scheduler-effective fallback or compiler-canonical routes only. | -| 5. Multiple typed event routes to one sound | Multiple enabled event routes target the same sound from different triggers. | All event routes remain visible; `eventSourceBySoundId` stores the first source; `triggerTargets` lists the target under each source. | Scheduler resolves the first compiled source for the sound. | First typed event source wins for playback; other routes are visible but not event-authoritative for that target. | Overview lists routes; per-voice incoming trigger resolves to first route source. | Multi-input event semantics are undefined: invalid, first-wins, merge, or role-filtered routing are future policy choices. | -| 6. Multiple sources targeting same destination | Event routes may target one sound; modulation routes may target one target parameter. | Event routes are all retained with first source in `eventSourceBySoundId`; modulation stores first control per target parameter and warns for later conflicting sources. | Event scheduler uses first source; modulation runtime still reads legacy `modulations` maps rather than typed graph. | Event first-wins for scheduler; modulation first-wins only in compiled graph. | Inspector follows compiled graph. | Event and modulation conflict policies are not expressed as a single shared route-ownership rule. | +| 5. Multiple typed event routes to one sound | Old patches may contain multiple enabled event routes targeting the same sound. Current routes without an explicit role are treated as implicit `primary` routes. | All event routes remain compiled for compatibility; `eventSourceBySoundId` still reflects existing compiler behavior for playback preservation. The new resolver reports this state as `ambiguous`. | Existing ambiguous patches preserve current playback behavior until explicit user assignment/repair. | New assignment operations replace all implicit-primary event routes for that voice with one primary assignment. No first-wins/last-wins policy is treated as the product rule. | Inspector/health can report ambiguous primary event inputs. After reassignment, inspector and scheduler agree on the new source. | Future role/lane event inputs remain planned but are not implemented. | +| 6. Multiple sources targeting same destination | Event routes may target one sound in old patches; modulation routes may target one target parameter. | Event ambiguity is now classified by `resolveVoiceEventRouting()` when multiple enabled implicit-primary typed routes target one voice. Modulation still stores first control per target parameter and warns for later conflicting sources. | Event runtime remains compatibility-preserving for old ambiguous patches; explicit reassignment normalizes that voice to one primary source. Modulation runtime still reads legacy `modulations` maps rather than typed graph. | Event assignment replacement is the product rule for new operations; modulation ownership remains separate. | Inspector follows the event resolver for ambiguous/newly assigned event input state and compiled graph for other domains. | Role-aware event inputs and modulation runtime parity remain separate future decisions. | | 7. Legacy modulation only | Module has `modulations` map from parameter to control module ID. | Backfilled modulation route with `metadata.createdFrom: "legacy-modulations"`; incoming modulation index includes source/parameter. | Scheduler density and audio voice modulation read target module `modulations` maps. | Legacy is used when there are no valid typed modulation routes. | Shows control-to-parameter modulation. | Runtime only applies a subset of assignable parameters. | | 8. Typed modulation only | `Patch.routes[]` has an enabled `domain: "modulation"` route with `metadata.parameter`. | Typed modulation route appears in `modulationIncomingByTarget`. | Current scheduler/audio runtime does not consume typed modulation graph for density/pitch/cutoff; it reads legacy target-owned `modulations`. | Typed route is visible but does not currently provide runtime parity for legacy modulation. | Shows typed modulation route. | Typed modulation parity is incomplete. Follow-up must decide whether to mirror typed routes into runtime resolver or keep typed modulation as visibility/interoperability only. | | 9. Legacy and typed modulation on same parameter | Target module has legacy `modulations`; `Patch.routes[]` also has a valid typed modulation route. | Typed modulation domain suppresses all legacy modulation backfill; compiled graph shows typed source. | Runtime still reads target-owned legacy `modulations`, so legacy can drive behavior while inspector shows typed source. | Compiler typed precedence and runtime legacy authority can disagree. | Shows typed modulation source. | This is a high-priority ownership mismatch. Do not change without explicit policy because it can alter sound. | @@ -31,19 +31,28 @@ Reference test suite: `tests/routingParityMatrix.test.mjs`. | 13. Missing or unsupported bus endpoints | Missing buses may be referenced by routes/connections; existing bus endpoints can also be referenced. | Missing bus typed routes are rejected with warnings and no compiled audio connection. Existing bus endpoints can compile. | Existing bus audio connections are rejected by `validateConnections()` because bus routing is not supported at runtime. | Missing endpoints are invalid; existing buses are serializable/visible but not runtime-active. | Missing endpoints appear through routing health; existing bus routes can appear as compiled audio routes. | Bus serialization exists ahead of runtime implementation. UI copy should not imply bus audio works yet. | | 14. Compiler output vs runtime behavior | All hybrid forms above are accepted if validation permits their endpoints. | Compiler applies per-domain typed precedence and produces route indexes for visibility. | Scheduler uses compiled event source first, then `triggerSource`; modulation runtime reads legacy maps; audio runtime validates compiled audio through legacy validator. | There is no single runtime authority across all domains. | Inspector follows compiled graph, not every runtime fallback. | The next consolidation work should pick one domain at a time and preserve compatibility with explicit tests. | +## Current primary event routing rule + +A voice currently has one effective event input role: `primary`. Typed event routes without an explicit role are treated as `primary`; no required role field or schema migration is introduced. + +New assignment operations use replacement semantics: + +- assigning GEN B to a voice previously assigned to GEN A updates legacy `triggerSource` to GEN B; +- existing implicit-primary typed event routes targeting that voice are removed; +- one typed primary event route is written for GEN B; +- disabled or stale previous primary routes do not block replacement; +- unrelated event routes to other voices are preserved. + +Old patches with multiple enabled primary event routes to the same voice are detected as `ambiguous` by `resolveVoiceEventRouting()` and validation. They are not silently rewritten during load, inspection, or validation. + ## Policy decisions still open 1. Should partial typed event adoption remain compiler-canonical while scheduler keeps per-sound legacy fallback, or should the inspector expose scheduler-effective fallback routes? -2. Should multiple event inputs to one sound be invalid, first-wins, merged, or role/lane-filtered? +2. Which role/lane event inputs should be added after `primary`, and how should they interact with DRUM lanes, accents, fills, and resets? 3. Should typed modulation routes become runtime-authoritative, and if so should they mirror into legacy maps or feed a new resolver used by scheduler/audio? 4. Should typed audio routes suppress legacy audio for the whole domain, or only for destinations/sources they explicitly replace? 5. How should existing bus endpoints be presented while runtime bus routing remains unsupported? ## Recommended next step -The first safe ownership-consolidation step is an event-domain read resolver that reports both: - -- the compiler-canonical event source used by `compileRoutingGraph()`; -- the scheduler-effective event source after per-sound `triggerSource` fallback. - -That resolver can power tests and inspector labels before changing scheduler behavior or patch writes. It would address the clearest user-visible mismatch without migrating schemas or removing legacy routing. +The first safe ownership-consolidation step after this pass is to define the future event-role vocabulary in an RFC: keep `primary` as the current runtime input, then specify whether `accent`, `fill`, `reset`, or lane-specific inputs should be represented as optional route metadata, separate ports, or both. Typed modulation runtime parity remains the other high-priority consolidation path. diff --git a/docs/routing-overview-ui.md b/docs/routing-overview-ui.md index 54675bb..f9fd469 100644 --- a/docs/routing-overview-ui.md +++ b/docs/routing-overview-ui.md @@ -52,3 +52,9 @@ This is not a routing ownership migration. Legacy routing remains supported, `Pa The next v0.4 routing pass added an executable compatibility matrix for typed and legacy route coexistence: [`routing-compatibility-matrix.md`](routing-compatibility-matrix.md). That pass did not change routing ownership. The global overview still follows `compileRoutingGraph()` output, which means it can intentionally differ from runtime fallback behavior in known hybrid cases, especially partial typed event adoption and typed modulation routes. + +## Primary event assignment rule + +Current voices have one effective event input role: `primary`. Existing typed event routes without explicit role metadata are treated as primary routes. New UI assignments replace the previous primary GEN for that voice, update legacy `triggerSource` for compatibility, and avoid creating competing primary typed event routes. + +Old patches that already contain multiple enabled primary event routes to one voice are reported as ambiguous. Opening the overview, inspector, or patch does not repair them automatically; explicit reassignment is the narrow repair path for this phase. Future role/lane event inputs remain planned extension work and are not implemented here. diff --git a/docs/status.md b/docs/status.md index ca0a984..a1e46e4 100644 --- a/docs/status.md +++ b/docs/status.md @@ -76,6 +76,7 @@ Reference: [`docs/gen-mode-design-principles.md`](gen-mode-design-principles.md) - MIDI now appears as a first-class routing domain in the global Routing UI: users can assign `MIDI IN` input source + synth target explicitly, and MIDI routes are listed alongside event/modulation/audio routes. - Routing health now includes a confirmed stale-reference cleanup action for missing module/bus references across legacy `triggerSource`, legacy `modulations`, legacy audio `connections`, and typed `Patch.routes`. This does not change routing ownership, schema version, or legacy compatibility. - Typed/legacy routing parity is now characterized in [`routing-compatibility-matrix.md`](routing-compatibility-matrix.md) and covered by executable tests. The matrix documents current compiler/runtime/inspector differences without migrating routing ownership. +- Event routing now formalizes one implicit `primary` event input per voice for new assignments. Assigning a GEN replaces the previous primary source across typed event routes and legacy `triggerSource`, while old ambiguous multi-route patches are detected rather than silently rewritten. ## Near-term next steps (active priority) diff --git a/src/routingGraph.ts b/src/routingGraph.ts index af8204f..af85f4d 100644 --- a/src/routingGraph.ts +++ b/src/routingGraph.ts @@ -39,6 +39,7 @@ export type CompiledRouting = { export type RoutingValidationIssueCode = | "voice-missing-trigger-source" | "voice-invalid-trigger-source" + | "voice-ambiguous-primary-event-source" | "route-invalid-record" | "route-duplicate-id" | "route-missing-source-module" @@ -80,6 +81,35 @@ export type StaleRoutingCleanupPlan = { export type RoutingCleanupConfirm = (plan: StaleRoutingCleanupPlan) => boolean; +export type VoiceEventRoutingState = + | "legacy-only" + | "typed-only" + | "matching-hybrid" + | "conflicting-hybrid" + | "missing" + | "stale" + | "ambiguous"; + +export type VoiceEventRouteCandidate = { + routeIndex: number; + routeId: string; + sourceId: string | null; + enabled: boolean; + inputRole: string; + isPrimary: boolean; + sourceExists: boolean; + targetExists: boolean; +}; + +export type VoiceEventRoutingResolution = { + voiceId: string; + compiledCanonicalSourceId: string | null; + legacyTriggerSourceId: string | null; + schedulerEffectiveSourceId: string | null; + typedEventRouteCandidates: VoiceEventRouteCandidate[]; + state: VoiceEventRoutingState; +}; + type NormalizeRouteResult = { routes: PatchRoute[]; warnings: string[]; @@ -202,6 +232,132 @@ function normalizeRawRoute(raw: unknown): PatchRoute | null { }; } +function eventRouteInputRole(route: PatchRoute) { + const metadata = route.metadata as (PatchRouteMetadata & { role?: unknown }) | undefined; + const role = typeof metadata?.role === "string" && metadata.role.trim() ? metadata.role.trim() : "primary"; + return role; +} + +function rawEventRouteInputRole(raw: unknown, route: PatchRoute) { + if (!raw || typeof raw !== "object") return eventRouteInputRole(route); + const rawMetadata = (raw as { metadata?: unknown }).metadata; + if (!rawMetadata || typeof rawMetadata !== "object") return eventRouteInputRole(route); + const role = (rawMetadata as { role?: unknown }).role; + return typeof role === "string" && role.trim() ? role.trim() : eventRouteInputRole(route); +} + + +function isRawPrimaryEventRoute(raw: unknown, route: PatchRoute) { + return route.domain === "event" && rawEventRouteInputRole(raw, route) === "primary"; +} + +function uniqueRouteId(base: string, routes: PatchRoute[]) { + const ids = new Set(routes.map((route) => route.id)); + if (!ids.has(base)) return base; + for (let i = 2; ; i += 1) { + const candidate = `${base}:${i}`; + if (!ids.has(candidate)) return candidate; + } +} + +function makePrimaryEventRoute(sourceId: string, voiceId: string, existingRoutes: PatchRoute[]): PatchRoute { + return { + id: uniqueRouteId(`event:primary:${sourceId}:${voiceId}`, existingRoutes), + domain: "event", + source: { kind: "module", moduleId: sourceId, port: "trigger-out" }, + target: { kind: "module", moduleId: voiceId, port: "trigger-in" }, + enabled: true, + metadata: { createdFrom: "ui" }, + }; +} + +export function resolveVoiceEventRouting( + patch: Pick & { routes?: unknown }, + voiceId: string, +): VoiceEventRoutingResolution { + const modulesById = new Map(patch.modules.map((module) => [module.id, module])); + const triggerIds = new Set(patch.modules.filter((module) => module.type === "trigger").map((module) => module.id)); + const voice = modulesById.get(voiceId); + const legacyTriggerSourceId = voice && isSoundModule(voice) ? voice.triggerSource ?? null : null; + const typedEventRouteCandidates: VoiceEventRouteCandidate[] = []; + + if (Array.isArray(patch.routes)) { + for (let routeIndex = 0; routeIndex < patch.routes.length; routeIndex += 1) { + const route = normalizeRawRoute(patch.routes[routeIndex]); + if (!route || route.domain !== "event") continue; + if (route.target.kind !== "module" || route.target.moduleId !== voiceId) continue; + const sourceId = route.source.kind === "module" ? route.source.moduleId : null; + typedEventRouteCandidates.push({ + routeIndex, + routeId: route.id, + sourceId, + enabled: route.enabled !== false, + inputRole: rawEventRouteInputRole(patch.routes[routeIndex], route), + isPrimary: isRawPrimaryEventRoute(patch.routes[routeIndex], route), + sourceExists: !!sourceId && triggerIds.has(sourceId), + targetExists: modulesById.has(voiceId), + }); + } + } + + const compiled = compileRoutingGraph(patch); + const compiledCanonicalSourceId = compiled.eventSourceBySoundId.get(voiceId) ?? null; + const activePrimaryCandidates = typedEventRouteCandidates.filter((candidate) => ( + candidate.enabled && candidate.isPrimary && candidate.sourceExists && candidate.targetExists + )); + const hasStaleReference = !!(legacyTriggerSourceId && !triggerIds.has(legacyTriggerSourceId)) + || typedEventRouteCandidates.some((candidate) => candidate.enabled && candidate.isPrimary && (!candidate.sourceExists || !candidate.targetExists)); + const legacyValid = !!(legacyTriggerSourceId && triggerIds.has(legacyTriggerSourceId)); + const typedSourceId = activePrimaryCandidates.length === 1 ? activePrimaryCandidates[0].sourceId : null; + const schedulerCandidateId = compiledCanonicalSourceId ?? legacyTriggerSourceId; + const schedulerEffectiveSourceId = schedulerCandidateId && triggerIds.has(schedulerCandidateId) ? schedulerCandidateId : null; + + let state: VoiceEventRoutingState; + if (activePrimaryCandidates.length > 1) state = "ambiguous"; + else if (hasStaleReference) state = "stale"; + else if (typedSourceId && legacyValid) state = typedSourceId === legacyTriggerSourceId ? "matching-hybrid" : "conflicting-hybrid"; + else if (typedSourceId) state = "typed-only"; + else if (legacyValid) state = "legacy-only"; + else state = "missing"; + + return { + voiceId, + compiledCanonicalSourceId, + legacyTriggerSourceId, + schedulerEffectiveSourceId, + typedEventRouteCandidates, + state, + }; +} + +export function setVoicePrimaryEventSource(patch: Patch, voiceId: string, sourceId: string | null): VoiceEventRoutingResolution { + const voice = patch.modules.find((module): module is SoundModule => module.id === voiceId && isSoundModule(module)); + if (!voice) return resolveVoiceEventRouting(patch, voiceId); + + const source = sourceId + ? patch.modules.find((module) => module.id === sourceId && module.type === "trigger") + : null; + const nextSourceId = source ? source.id : null; + voice.triggerSource = nextSourceId; + + const existingRoutes = Array.isArray(patch.routes) ? patch.routes : []; + const keptRoutes: PatchRoute[] = []; + for (const rawRoute of existingRoutes) { + const route = normalizeRawRoute(rawRoute); + const targetsVoicePrimary = route + && route.domain === "event" + && route.target.kind === "module" + && route.target.moduleId === voiceId + && isRawPrimaryEventRoute(rawRoute, route); + if (!targetsVoicePrimary) keptRoutes.push(rawRoute as PatchRoute); + } + + if (nextSourceId) keptRoutes.push(makePrimaryEventRoute(nextSourceId, voiceId, keptRoutes)); + if (Array.isArray(patch.routes) || keptRoutes.length > 0) patch.routes = keptRoutes; + + return resolveVoiceEventRouting(patch, voiceId); +} + function routeIdentity(route: PatchRoute) { const sourceId = route.source.kind === "module" ? route.source.moduleId @@ -403,6 +559,18 @@ export function validatePatchRouting(patch: Pick((counts, issue) => { if (issue.code === "voice-missing-trigger-source" || issue.code === "voice-invalid-trigger-source") { counts.missingSources += 1; + } else if (issue.code === "voice-ambiguous-primary-event-source") { + counts.invalidRoutes += 1; } else if (INVALID_ROUTE_CODES.has(issue.code)) { counts.invalidRoutes += 1; } else if (issue.code.startsWith("connection-")) { @@ -71,12 +73,24 @@ export function buildRoutingHealthSummary(patch: Pick & { routes?: unknown }): EventRoutingInspectorRow[] { const modulesById = new Map(patch.modules.map((module) => [module.id, module])); - const compiled = compileRoutingGraph(patch); return patch.modules .filter(isSoundModule) .map((voice) => { - const sourceId = compiled.eventSourceBySoundId.get(voice.id) ?? voice.triggerSource ?? null; + const resolution = resolveVoiceEventRouting(patch, voice.id); + if (resolution.state === "ambiguous") { + const sourceLabel = "Ambiguous source"; + return { + voiceId: voice.id, + voiceLabel: voice.name, + sourceId: resolution.schedulerEffectiveSourceId, + sourceLabel, + sourceStatus: "ambiguous", + text: `${sourceLabel} → ${voice.name}`, + }; + } + + const sourceId = resolution.schedulerEffectiveSourceId ?? resolution.legacyTriggerSourceId ?? null; const state = resolveTriggerSourceLabelState(modulesById, sourceId); const sourceLabel = state.status === "none" ? "Unassigned" diff --git a/src/ui/routingLabels.ts b/src/ui/routingLabels.ts index be1a485..3ecc0f3 100644 --- a/src/ui/routingLabels.ts +++ b/src/ui/routingLabels.ts @@ -3,7 +3,7 @@ import type { Module, Patch, SoundModule } from "../patch"; const UNASSIGNED_LABEL = "None"; const MISSING_PREFIX = "Missing"; -export type RoutingLabelStatus = "none" | "ok" | "missing"; +export type RoutingLabelStatus = "none" | "ok" | "missing" | "ambiguous"; export type RoutingLabelState = { label: string; diff --git a/src/ui/triggerModule.ts b/src/ui/triggerModule.ts index b49fa1d..e9698fe 100644 --- a/src/ui/triggerModule.ts +++ b/src/ui/triggerModule.ts @@ -1,4 +1,5 @@ import type { Mode, Patch, TriggerModule } from "../patch"; +import { setVoicePrimaryEventSource } from "../routingGraph.ts"; import { getPatternPreview } from "../engine/pattern/module"; import { GEN_MODES, getGenModeMeta } from "../engine/pattern/genModeRegistry"; import { ctlFloat, type CtlFloatElement } from "./ctl"; @@ -1222,9 +1223,7 @@ export function renderTriggerSurface( row.onclick = () => { closeRoutingPanel(); onRoutingChange((p) => { - const targetModule = p.modules.find((module) => module.id === target.id); - if (!targetModule || (targetModule.type !== "drum" && targetModule.type !== "tonal")) return; - targetModule.triggerSource = targetModule.triggerSource === t.id ? null : t.id; + setVoicePrimaryEventSource(p, target.id, isConnected ? null : t.id); }, { regen: true }); }; panelList.appendChild(row); diff --git a/src/ui/voiceModule.ts b/src/ui/voiceModule.ts index 419d1a6..7939f51 100644 --- a/src/ui/voiceModule.ts +++ b/src/ui/voiceModule.ts @@ -1,4 +1,5 @@ import type { DrumModule, Patch, SoundModule, TonalModule } from "../patch"; +import { setVoicePrimaryEventSource } from "../routingGraph.ts"; import { normalizeDrumChannelMode } from "../patch"; import { ctlFloat } from "./ctl"; import { wireSafeDeleteButton } from "./deleteButton"; @@ -318,8 +319,7 @@ function createFaceTabs( selected: v.triggerSource, emptyLabel: "None", onChange: (value) => onRoutingChange((p) => { - const m = p.modules.find((x) => x.id === v.id); - if (m && (m.type === "drum" || m.type === "tonal")) m.triggerSource = value; + setVoicePrimaryEventSource(p, v.id, value); }, { regen: true }), }); const routeMap = document.createElement("div"); @@ -680,11 +680,9 @@ export function renderDrumModuleSurface(params: SurfaceParams) { selected: d.triggerSource, emptyLabel: "None", onChange: (value) => onRoutingChange((p) => { + setVoicePrimaryEventSource(p, v.id, value); const m = p.modules.find((z) => z.id === v.id); - if (m?.type === "drum") { - m.triggerSource = value; - setReactive({ triggerSource: m.triggerSource }); - } + if (m?.type === "drum") setReactive({ triggerSource: m.triggerSource }); }, { regen: true }), }); featureZone.routeField.wrap.replaceWith(triggerField.wrap); @@ -1001,11 +999,9 @@ export function renderSynthModuleSurface(params: SurfaceParams) { selected: t.triggerSource, emptyLabel: "None", onChange: (value) => onRoutingChange((p) => { + setVoicePrimaryEventSource(p, v.id, value); const m = p.modules.find((x) => x.id === v.id); - if (m?.type === "tonal") { - m.triggerSource = value; - setReactive({ triggerSource: value }); - } + if (m?.type === "tonal") setReactive({ triggerSource: m.triggerSource }); }, { regen: true }), }); const receptionModeField = createCompactSelectField({ diff --git a/tests/eventRoutingPrimary.test.mjs b/tests/eventRoutingPrimary.test.mjs new file mode 100644 index 0000000..dff0b1f --- /dev/null +++ b/tests/eventRoutingPrimary.test.mjs @@ -0,0 +1,275 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { migratePatch } from '../src/patch.ts'; +import { + resolveVoiceEventRouting, + setVoicePrimaryEventSource, + validatePatchRouting, +} from '../src/routingGraph.ts'; +import { createScheduler } from '../src/engine/scheduler.ts'; +import { buildEventRoutingInspectorRows } from '../src/ui/routingInspector.ts'; +import { makePatch, makeSound, makeTrigger } from './helpers.mjs'; + +function eventRoute(id, sourceId, targetId, extra = {}) { + return { + id, + domain: 'event', + source: { kind: 'module', moduleId: sourceId, port: 'trigger-out' }, + target: { kind: 'module', moduleId: targetId, port: 'trigger-in' }, + enabled: true, + ...extra, + }; +} + +function withWindowTimer(fn) { + const prevWindow = globalThis.window; + let intervalFn = null; + globalThis.window = { setInterval: (cb) => (intervalFn = cb, 1), clearInterval: () => {} }; + try { return fn(() => intervalFn && intervalFn()); } finally { globalThis.window = prevWindow; } +} + +function observedSchedulerSources(patch) { + const observed = []; + const engine = { + ctx: { currentTime: 0, state: 'running' }, + setTransportRunning: () => {}, + triggerVoice: () => {}, + }; + withWindowTimer((tick) => { + const scheduler = createScheduler(engine); + scheduler.setScheduledEventObserver((ev) => observed.push({ sourceId: ev.source.id, targetId: ev.target.id })); + scheduler.setBpm(120); + scheduler.setPatch(patch); + scheduler.start(); + for (const t of [0, 0.025, 0.05, 0.075]) { + engine.ctx.currentTime = t; + tick(); + } + scheduler.stop(); + }); + return observed; +} + +test('primary event resolver reports legacy primary assignment only', () => { + const trigger = makeTrigger({ id: 'trg-a' }); + const sound = makeSound({ id: 'drm-1', triggerSource: trigger.id }); + const patch = makePatch([trigger, sound]); + + const resolution = resolveVoiceEventRouting(patch, sound.id); + + assert.equal(resolution.state, 'legacy-only'); + assert.equal(resolution.compiledCanonicalSourceId, trigger.id); + assert.equal(resolution.legacyTriggerSourceId, trigger.id); + assert.equal(resolution.schedulerEffectiveSourceId, trigger.id); + assert.deepEqual(resolution.typedEventRouteCandidates, []); +}); + +test('primary event resolver reports typed primary assignment only', () => { + const trigger = makeTrigger({ id: 'trg-a' }); + const sound = makeSound({ id: 'drm-1', triggerSource: null }); + const patch = makePatch([trigger, sound]); + patch.routes = [eventRoute('evt-a', trigger.id, sound.id)]; + + const resolution = resolveVoiceEventRouting(patch, sound.id); + + assert.equal(resolution.state, 'typed-only'); + assert.equal(resolution.compiledCanonicalSourceId, trigger.id); + assert.equal(resolution.legacyTriggerSourceId, null); + assert.equal(resolution.schedulerEffectiveSourceId, trigger.id); + assert.deepEqual(resolution.typedEventRouteCandidates.map((candidate) => candidate.sourceId), [trigger.id]); +}); + +test('assigning GEN B replaces GEN A as the single primary event assignment', () => { + const triggerA = makeTrigger({ id: 'trg-a' }); + const triggerB = makeTrigger({ id: 'trg-b' }); + const sound = makeSound({ id: 'drm-1', triggerSource: triggerA.id }); + const patch = makePatch([triggerA, triggerB, sound]); + patch.routes = [eventRoute('evt-a', triggerA.id, sound.id)]; + + const resolution = setVoicePrimaryEventSource(patch, sound.id, triggerB.id); + + assert.equal(sound.triggerSource, triggerB.id); + assert.equal(resolution.state, 'matching-hybrid'); + assert.deepEqual(patch.routes.map((route) => route.source.moduleId), [triggerB.id]); + assert.equal(resolveVoiceEventRouting(patch, sound.id).schedulerEffectiveSourceId, triggerB.id); +}); + +test('repeated assignment of the same GEN is idempotent', () => { + const trigger = makeTrigger({ id: 'trg-a' }); + const sound = makeSound({ id: 'drm-1', triggerSource: null }); + const patch = makePatch([trigger, sound]); + + setVoicePrimaryEventSource(patch, sound.id, trigger.id); + const once = structuredClone(patch); + setVoicePrimaryEventSource(patch, sound.id, trigger.id); + + assert.deepEqual(patch, once); +}); + +test('reassignment does not create duplicate typed event routes', () => { + const triggerA = makeTrigger({ id: 'trg-a' }); + const triggerB = makeTrigger({ id: 'trg-b' }); + const sound = makeSound({ id: 'drm-1', triggerSource: triggerA.id }); + const patch = makePatch([triggerA, triggerB, sound]); + patch.routes = [ + eventRoute('evt-a', triggerA.id, sound.id), + eventRoute('evt-a-copy', triggerA.id, sound.id), + ]; + + setVoicePrimaryEventSource(patch, sound.id, triggerB.id); + + const resolution = resolveVoiceEventRouting(patch, sound.id); + assert.equal(resolution.state, 'matching-hybrid'); + assert.deepEqual(resolution.typedEventRouteCandidates.map((candidate) => candidate.sourceId), [triggerB.id]); +}); + +test('reassignment updates legacy compatibility state correctly', () => { + const triggerA = makeTrigger({ id: 'trg-a' }); + const triggerB = makeTrigger({ id: 'trg-b' }); + const sound = makeSound({ id: 'drm-1', triggerSource: triggerA.id }); + const patch = makePatch([triggerA, triggerB, sound]); + + setVoicePrimaryEventSource(patch, sound.id, triggerB.id); + + assert.equal(sound.triggerSource, triggerB.id); + assert.equal(resolveVoiceEventRouting(patch, sound.id).legacyTriggerSourceId, triggerB.id); +}); + +test('scheduler uses the newly assigned GEN', () => { + const triggerA = makeTrigger({ id: 'trg-a', density: 1, drop: 0, length: 8 }); + const triggerB = makeTrigger({ id: 'trg-b', density: 1, drop: 0, length: 8 }); + const sound = makeSound({ id: 'drm-1', triggerSource: triggerA.id }); + const patch = makePatch([triggerA, triggerB, sound]); + + setVoicePrimaryEventSource(patch, sound.id, triggerB.id); + const observed = observedSchedulerSources(patch); + + assert.ok(observed.length > 0); + assert.ok(observed.every((event) => event.sourceId === triggerB.id && event.targetId === sound.id)); +}); + +test('inspector shows the newly assigned GEN', () => { + const triggerA = makeTrigger({ id: 'trg-a', name: 'GEN A' }); + const triggerB = makeTrigger({ id: 'trg-b', name: 'GEN B' }); + const sound = makeSound({ id: 'drm-1', name: 'Kick', triggerSource: triggerA.id }); + const patch = makePatch([triggerA, triggerB, sound]); + + setVoicePrimaryEventSource(patch, sound.id, triggerB.id); + + assert.deepEqual(buildEventRoutingInspectorRows(patch).map((row) => row.text), ['GEN B → Kick']); +}); + +test('saved and reloaded patch preserves the new assignment without schema change', () => { + const triggerA = makeTrigger({ id: 'trg-a' }); + const triggerB = makeTrigger({ id: 'trg-b' }); + const sound = makeSound({ id: 'drm-1', triggerSource: triggerA.id }); + const patch = makePatch([triggerA, triggerB, sound]); + + setVoicePrimaryEventSource(patch, sound.id, triggerB.id); + const reloaded = migratePatch(JSON.parse(JSON.stringify(patch))); + const reloadedSound = reloaded.modules.find((module) => module.id === sound.id); + + assert.equal(reloaded.version, '0.3'); + assert.equal(reloadedSound.triggerSource, triggerB.id); + assert.equal(resolveVoiceEventRouting(reloaded, sound.id).schedulerEffectiveSourceId, triggerB.id); +}); + +test('existing ambiguous multi-route patch is detected but not silently modified', () => { + const triggerA = makeTrigger({ id: 'trg-a' }); + const triggerB = makeTrigger({ id: 'trg-b' }); + const sound = makeSound({ id: 'drm-1', triggerSource: null }); + const patch = makePatch([triggerA, triggerB, sound]); + patch.routes = [ + eventRoute('evt-a', triggerA.id, sound.id), + eventRoute('evt-b', triggerB.id, sound.id), + ]; + const before = structuredClone(patch); + + const resolution = resolveVoiceEventRouting(patch, sound.id); + const validation = validatePatchRouting(patch); + + assert.equal(resolution.state, 'ambiguous'); + assert.equal(validation.issues.some((issue) => issue.code === 'voice-ambiguous-primary-event-source'), true); + assert.deepEqual(patch, before); +}); + +test('stale previous typed route is removed when explicitly replacing the assignment', () => { + const triggerB = makeTrigger({ id: 'trg-b' }); + const sound = makeSound({ id: 'drm-1', triggerSource: 'missing-trigger' }); + const patch = makePatch([triggerB, sound]); + patch.routes = [eventRoute('evt-stale', 'missing-trigger', sound.id)]; + + const before = resolveVoiceEventRouting(patch, sound.id); + setVoicePrimaryEventSource(patch, sound.id, triggerB.id); + const after = resolveVoiceEventRouting(patch, sound.id); + + assert.equal(before.state, 'stale'); + assert.equal(after.state, 'matching-hybrid'); + assert.deepEqual(after.typedEventRouteCandidates.map((candidate) => candidate.sourceId), [triggerB.id]); +}); + +test('disabled event routes do not block replacement', () => { + const triggerA = makeTrigger({ id: 'trg-a' }); + const triggerB = makeTrigger({ id: 'trg-b' }); + const sound = makeSound({ id: 'drm-1', triggerSource: triggerA.id }); + const patch = makePatch([triggerA, triggerB, sound]); + patch.routes = [eventRoute('evt-disabled', triggerA.id, sound.id, { enabled: false })]; + + setVoicePrimaryEventSource(patch, sound.id, triggerB.id); + + assert.deepEqual(resolveVoiceEventRouting(patch, sound.id).typedEventRouteCandidates.map((candidate) => candidate.sourceId), [triggerB.id]); + assert.equal(validatePatchRouting(patch).issues.some((issue) => issue.code === 'voice-ambiguous-primary-event-source'), false); +}); + +test('unrelated event routes to other voices remain unchanged', () => { + const triggerA = makeTrigger({ id: 'trg-a' }); + const triggerB = makeTrigger({ id: 'trg-b' }); + const soundA = makeSound({ id: 'drm-a', triggerSource: triggerA.id }); + const soundB = makeSound({ id: 'drm-b', triggerSource: triggerA.id }); + const patch = makePatch([triggerA, triggerB, soundA, soundB]); + patch.routes = [ + eventRoute('evt-a', triggerA.id, soundA.id), + eventRoute('evt-b', triggerA.id, soundB.id), + ]; + + setVoicePrimaryEventSource(patch, soundA.id, triggerB.id); + + const soundBResolution = resolveVoiceEventRouting(patch, soundB.id); + assert.equal(soundB.triggerSource, triggerA.id); + assert.equal(soundBResolution.schedulerEffectiveSourceId, triggerA.id); + assert.ok(patch.routes.some((route) => route.id === 'evt-b' && route.target.moduleId === soundB.id)); +}); + + +test('replacement preserves future non-primary event role routes', () => { + const triggerA = makeTrigger({ id: 'trg-a' }); + const triggerB = makeTrigger({ id: 'trg-b' }); + const sound = makeSound({ id: 'drm-1', triggerSource: triggerA.id }); + const patch = makePatch([triggerA, triggerB, sound]); + patch.routes = [ + eventRoute('evt-primary', triggerA.id, sound.id), + eventRoute('evt-fill', triggerA.id, sound.id, { metadata: { role: 'fill' } }), + ]; + + setVoicePrimaryEventSource(patch, sound.id, triggerB.id); + + assert.ok(patch.routes.some((route) => route.id === 'evt-fill')); + assert.equal(resolveVoiceEventRouting(patch, sound.id).typedEventRouteCandidates.find((candidate) => candidate.routeId === 'evt-fill').inputRole, 'fill'); + assert.deepEqual( + resolveVoiceEventRouting(patch, sound.id).typedEventRouteCandidates + .filter((candidate) => candidate.isPrimary) + .map((candidate) => candidate.sourceId), + [triggerB.id], + ); +}); + +test('primary event assignment does not change the patch schema version', () => { + const trigger = makeTrigger({ id: 'trg-a' }); + const sound = makeSound({ id: 'drm-1', triggerSource: null }); + const patch = makePatch([trigger, sound]); + + setVoicePrimaryEventSource(patch, sound.id, trigger.id); + + assert.equal(patch.version, '0.3'); +}); From c27f236395258e35d18a9c440d1ee1157b430e51 Mon Sep 17 00:00:00 2001 From: Diego Madero Islas Date: Sun, 14 Jun 2026 22:15:28 -0600 Subject: [PATCH 5/5] feat(routing): add effective modulation resolver --- .../routing-architecture-audit-2026-05.md | 21 ++ .../control-routing-and-visible-modulation.md | 27 +++ docs/routing-compatibility-matrix.md | 18 +- docs/status.md | 1 + src/routingGraph.ts | 201 ++++++++++++++++ src/ui/controlModule.ts | 12 +- src/ui/routingInspector.ts | 100 +++++++- src/ui/triggerModule.ts | 6 +- src/ui/voiceModule.ts | 6 +- tests/modulationResolver.test.mjs | 226 ++++++++++++++++++ 10 files changed, 604 insertions(+), 14 deletions(-) create mode 100644 tests/modulationResolver.test.mjs diff --git a/docs/audits/routing-architecture-audit-2026-05.md b/docs/audits/routing-architecture-audit-2026-05.md index deb1a28..c86b461 100644 --- a/docs/audits/routing-architecture-audit-2026-05.md +++ b/docs/audits/routing-architecture-audit-2026-05.md @@ -907,3 +907,24 @@ Intentional constraints preserved: Recommended next follow-up: draft a role-aware event routing RFC that keeps `primary` stable while defining future optional roles such as `accent`, `fill`, `reset`, or lane-specific inputs. In parallel, typed modulation runtime parity remains the next high-impact consolidation target. +## Phase 6 implementation note — Modulation effective-source characterization + +Routing v0.4 Phase 6 adds a pure modulation resolver and capability matrix without changing playback authority. Runtime modulation remains legacy-map-backed in this pass: scheduler density reads `trigger.modulations.density`, DRUM pitch reads `drum.modulations.basePitch`, and SYNTH cutoff reads `tonal.modulations.cutoff`. Typed modulation routes remain declarations until a future authority change. + +Implemented behavior: + +- `resolveParameterModulation()` reports typed source, legacy source, effective runtime source, match/conflict state, runtime support, runtime owner, fallback usage, and typed candidates for a target parameter. +- `getModulationCapabilityMatrix()` documents assignable/typed/visible/runtime-consumed modulation support. +- `buildModulationRoutingInspectorRows()` exposes typed declaration, legacy declaration, effective runtime source, unsupported parameters, stale references, and conflicts. +- New explicit CTRL assignments use `setParameterModulationSource()` replacement semantics: one source per target parameter, matching legacy + typed declarations, unrelated parameters preserved. + +Intentional constraints preserved: + +- `Patch.version` remains `0.3`. +- Legacy `modulations` remain supported and are still runtime-critical. +- Existing patches are not automatically migrated. +- Typed modulation routes are not made runtime-authoritative yet. +- No modulation merging, depth/amount schema, audio routing change, or event role/lane work was introduced. + +Recommended next follow-up: make the runtime consume the modulation resolver for one supported parameter at a time, starting with `trigger.density`, while keeping legacy-map fallback and tests proving typed-only, legacy-only, matching hybrid, and conflicting hybrid behavior. + diff --git a/docs/control-routing-and-visible-modulation.md b/docs/control-routing-and-visible-modulation.md index 0147d0c..d9d2f9a 100644 --- a/docs/control-routing-and-visible-modulation.md +++ b/docs/control-routing-and-visible-modulation.md @@ -97,3 +97,30 @@ Still intentionally deferred: The modulation display/ownership pattern introduced here is compatible with later velocity/intensity-aware expansions: - event intensity can be layered on top of stable per-parameter control ownership, - future trigger/MIDI intensity mappings can reuse the same "single owner + temporary user override" interaction policy. + +## v0.4 modulation resolver and capability matrix + +The v0.4 routing consolidation pass adds a pure modulation resolver for each `(target module, parameter)` pair. It reports: + +- typed modulation source declared in `Patch.routes`; +- legacy target-owned `module.modulations[parameter]` source; +- effective runtime source; +- whether typed and legacy sources match or conflict; +- whether the parameter is currently consumed by runtime; +- whether runtime is using the legacy compatibility fallback. + +Current runtime capability matrix: + +| Module family | Parameter | Assignable in UI | Typed route representation | Inspector visibility | Runtime consumer | Limitation | +| --- | --- | --- | --- | --- | --- | --- | +| GEN / trigger | `density` | Yes | Yes | Yes | Scheduler | Runtime reads legacy `trigger.modulations.density`; typed route is declaration until authority changes. | +| DRUM | `basePitch` | Yes | Yes | Yes | Audio engine | Runtime reads legacy `drum.modulations.basePitch`; typed route is declaration until authority changes. | +| SYNTH / tonal | `cutoff` | Yes | Yes | Yes | Audio engine | Runtime reads legacy `tonal.modulations.cutoff`; typed route is declaration until authority changes. | +| GEN / trigger | other catalog parameters | Yes | Yes | Yes | None | Assignable and visible, but not currently consumed by scheduler. | +| DRUM | other catalog parameters | Yes | Yes | Yes | None | Assignable and visible, but not currently consumed by audio runtime. | +| SYNTH / tonal | other catalog parameters | Yes | Yes | Yes | None | Assignable and visible, but not currently consumed by audio runtime. | + +Playback behavior is intentionally preserved in this pass. Typed modulation routes are not runtime-authoritative yet, no schema version changed, and patches are not automatically migrated. + +New explicit modulation assignments use replacement semantics for one effective source per target parameter: assigning CTRL B to a parameter controlled by CTRL A updates the legacy map, removes competing typed modulation routes for that same target parameter, and writes one matching typed modulation route. Unrelated parameters remain unchanged. + diff --git a/docs/routing-compatibility-matrix.md b/docs/routing-compatibility-matrix.md index 06f633a..057902f 100644 --- a/docs/routing-compatibility-matrix.md +++ b/docs/routing-compatibility-matrix.md @@ -23,8 +23,8 @@ Reference test suite: `tests/routingParityMatrix.test.mjs`. | 5. Multiple typed event routes to one sound | Old patches may contain multiple enabled event routes targeting the same sound. Current routes without an explicit role are treated as implicit `primary` routes. | All event routes remain compiled for compatibility; `eventSourceBySoundId` still reflects existing compiler behavior for playback preservation. The new resolver reports this state as `ambiguous`. | Existing ambiguous patches preserve current playback behavior until explicit user assignment/repair. | New assignment operations replace all implicit-primary event routes for that voice with one primary assignment. No first-wins/last-wins policy is treated as the product rule. | Inspector/health can report ambiguous primary event inputs. After reassignment, inspector and scheduler agree on the new source. | Future role/lane event inputs remain planned but are not implemented. | | 6. Multiple sources targeting same destination | Event routes may target one sound in old patches; modulation routes may target one target parameter. | Event ambiguity is now classified by `resolveVoiceEventRouting()` when multiple enabled implicit-primary typed routes target one voice. Modulation still stores first control per target parameter and warns for later conflicting sources. | Event runtime remains compatibility-preserving for old ambiguous patches; explicit reassignment normalizes that voice to one primary source. Modulation runtime still reads legacy `modulations` maps rather than typed graph. | Event assignment replacement is the product rule for new operations; modulation ownership remains separate. | Inspector follows the event resolver for ambiguous/newly assigned event input state and compiled graph for other domains. | Role-aware event inputs and modulation runtime parity remain separate future decisions. | | 7. Legacy modulation only | Module has `modulations` map from parameter to control module ID. | Backfilled modulation route with `metadata.createdFrom: "legacy-modulations"`; incoming modulation index includes source/parameter. | Scheduler density and audio voice modulation read target module `modulations` maps. | Legacy is used when there are no valid typed modulation routes. | Shows control-to-parameter modulation. | Runtime only applies a subset of assignable parameters. | -| 8. Typed modulation only | `Patch.routes[]` has an enabled `domain: "modulation"` route with `metadata.parameter`. | Typed modulation route appears in `modulationIncomingByTarget`. | Current scheduler/audio runtime does not consume typed modulation graph for density/pitch/cutoff; it reads legacy target-owned `modulations`. | Typed route is visible but does not currently provide runtime parity for legacy modulation. | Shows typed modulation route. | Typed modulation parity is incomplete. Follow-up must decide whether to mirror typed routes into runtime resolver or keep typed modulation as visibility/interoperability only. | -| 9. Legacy and typed modulation on same parameter | Target module has legacy `modulations`; `Patch.routes[]` also has a valid typed modulation route. | Typed modulation domain suppresses all legacy modulation backfill; compiled graph shows typed source. | Runtime still reads target-owned legacy `modulations`, so legacy can drive behavior while inspector shows typed source. | Compiler typed precedence and runtime legacy authority can disagree. | Shows typed modulation source. | This is a high-priority ownership mismatch. Do not change without explicit policy because it can alter sound. | +| 8. Typed modulation only | `Patch.routes[]` has an enabled `domain: "modulation"` route with `metadata.parameter`. | Typed modulation route appears in `modulationIncomingByTarget`. | Current scheduler/audio runtime does not consume typed modulation graph for density/pitch/cutoff; it reads legacy target-owned `modulations`. | Resolver reports typed-only as a declaration with no effective runtime source. | Inspector can show typed declaration separately from effective runtime source. | Typed modulation authority remains future work. | +| 9. Legacy and typed modulation on same parameter | Target module has legacy `modulations`; `Patch.routes[]` also has a valid typed modulation route. | Typed modulation domain suppresses all legacy modulation backfill; compiled graph shows typed source. | Runtime still reads target-owned legacy `modulations`. | Resolver reports matching hybrid or conflicting hybrid. New explicit assignments replace competing sources and write matching legacy + typed declarations. | Inspector can show typed source, legacy source, effective runtime source, unsupported parameters, and conflicts. | Existing conflicts are characterized, not load-migrated. Typed runtime authority remains future work. | | 10. Legacy audio only | `Patch.connections[]` contains enabled audio connection records. | Backfilled audio routes with `metadata.createdFrom: "legacy-connections"`; `audioConnections` mirrors connection-like records. | Audio engine compiles graph, then validates compiled `audioConnections` through legacy `validateConnections()`. | Legacy audio is used when there are no valid typed audio routes. | Shows audio route. | Runtime validation still constrains supported ports/targets. | | 11. Typed audio only | `Patch.routes[]` has enabled `domain: "audio"` route to module/master/bus endpoint. | Typed audio route converts to connection-like `audioConnections` record. | Audio engine validates compiled audio connections through `validateConnections()`. | Typed route can run if legacy validator accepts the converted connection. | Shows typed audio route. | Graph acceptance and runtime acceptance can differ for buses or unsupported ports. | | 12. Legacy and typed audio | `Patch.connections[]` and valid typed audio routes both exist. | Typed audio domain suppresses legacy audio backfill; `audioConnections` contains typed routes only. | Audio engine uses compiled typed audio connections after validation. | Typed audio wins for the entire audio domain. | Shows typed audio only. | Partial typed audio adoption can hide/disable legacy connections for the compiled/runtime audio graph. | @@ -45,6 +45,20 @@ New assignment operations use replacement semantics: Old patches with multiple enabled primary event routes to the same voice are detected as `ambiguous` by `resolveVoiceEventRouting()` and validation. They are not silently rewritten during load, inspection, or validation. + +## Current modulation routing rule + +A target parameter currently has one effective modulation source. Runtime remains legacy-map-backed in this pass: scheduler/audio consume the target module's `modulations` map for the runtime-supported parameters only. Typed modulation routes are declarations until a future authority change. + +New assignment operations use replacement semantics: + +- assigning CTRL B to a parameter previously controlled by CTRL A updates `module.modulations[parameter]` to CTRL B; +- existing typed modulation routes for the same target parameter are removed; +- one matching typed modulation route is written for CTRL B; +- unrelated parameters remain unchanged. + +The runtime-supported modulation set is currently `trigger.density`, `drum.basePitch`, and `tonal.cutoff`. Other catalog parameters are assignable and visible but not scheduler/audio consumed yet. + ## Policy decisions still open 1. Should partial typed event adoption remain compiler-canonical while scheduler keeps per-sound legacy fallback, or should the inspector expose scheduler-effective fallback routes? diff --git a/docs/status.md b/docs/status.md index a1e46e4..84e9d43 100644 --- a/docs/status.md +++ b/docs/status.md @@ -77,6 +77,7 @@ Reference: [`docs/gen-mode-design-principles.md`](gen-mode-design-principles.md) - Routing health now includes a confirmed stale-reference cleanup action for missing module/bus references across legacy `triggerSource`, legacy `modulations`, legacy audio `connections`, and typed `Patch.routes`. This does not change routing ownership, schema version, or legacy compatibility. - Typed/legacy routing parity is now characterized in [`routing-compatibility-matrix.md`](routing-compatibility-matrix.md) and covered by executable tests. The matrix documents current compiler/runtime/inspector differences without migrating routing ownership. - Event routing now formalizes one implicit `primary` event input per voice for new assignments. Assigning a GEN replaces the previous primary source across typed event routes and legacy `triggerSource`, while old ambiguous multi-route patches are detected rather than silently rewritten. +- Modulation routing now has an effective-source resolver and executable capability matrix. New CTRL assignments replace the previous source for a target parameter across legacy `modulations` and typed modulation routes, while runtime behavior remains legacy-backed for `trigger.density`, `drum.basePitch`, and `tonal.cutoff`. ## Near-term next steps (active priority) diff --git a/src/routingGraph.ts b/src/routingGraph.ts index af85f4d..72ce2bb 100644 --- a/src/routingGraph.ts +++ b/src/routingGraph.ts @@ -110,6 +110,53 @@ export type VoiceEventRoutingResolution = { state: VoiceEventRoutingState; }; +export type ModulationRuntimeOwner = "scheduler" | "audio" | null; + +export type ModulationRoutingState = + | "none" + | "legacy-only" + | "typed-only" + | "matching-hybrid" + | "conflicting-hybrid" + | "stale-typed" + | "stale-legacy" + | "unsupported"; + +export type ModulationRouteCandidate = { + routeIndex: number; + routeId: string; + sourceId: string | null; + enabled: boolean; + sourceExists: boolean; + targetExists: boolean; +}; + +export type ModulationRoutingResolution = { + targetId: string; + parameter: string; + typedSourceId: string | null; + legacySourceId: string | null; + effectiveRuntimeSourceId: string | null; + typedAndLegacyMatch: boolean; + typedAndLegacyConflict: boolean; + runtimeSupported: boolean; + runtimeOwner: ModulationRuntimeOwner; + fallbackUsed: boolean; + typedCandidates: ModulationRouteCandidate[]; + state: ModulationRoutingState; +}; + +export type ModulationCapability = { + moduleType: "trigger" | "drum" | "tonal"; + parameter: string; + assignableInUi: boolean; + representedInTypedRouting: boolean; + visibleInInspector: boolean; + consumedByRuntime: ModulationRuntimeOwner; + knownLimitations: string; +}; + + type NormalizeRouteResult = { routes: PatchRoute[]; warnings: string[]; @@ -330,6 +377,160 @@ export function resolveVoiceEventRouting( }; } +const MODULATION_UI_PARAMETERS: Record<"trigger" | "drum" | "tonal", string[]> = { + trigger: ["density", "length", "subdiv", "drop", "determinism", "gravity", "weird", "accent", "euclidRot", "caRule", "caInit"], + drum: ["attack", "decay", "amp", "basePitch", "tone", "bodyTone", "noise", "snap", "pitchEnvAmt", "pan", "stereoWidth", "panBias", "comp", "compThreshold", "compRatio", "boost"], + tonal: ["attack", "decay", "sustain", "release", "amp", "cutoff", "resonance", "waveform", "modDepth", "modRate", "coarseTune", "fineTune", "glide", "pan"], +}; + +function modulationRuntimeOwner(module: Module | null | undefined, parameter: string): ModulationRuntimeOwner { + if (!module) return null; + if (module.type === "trigger" && parameter === "density") return "scheduler"; + if (module.type === "drum" && parameter === "basePitch") return "audio"; + if (module.type === "tonal" && parameter === "cutoff") return "audio"; + return null; +} + +function typedModulationRouteTargets(route: PatchRoute, targetId: string, parameter: string) { + return route.domain === "modulation" + && route.target.kind === "module" + && route.target.moduleId === targetId + && route.metadata?.parameter === parameter; +} + +function makeModulationRoute(sourceId: string, targetId: string, parameter: string, existingRoutes: PatchRoute[]): PatchRoute { + return { + id: uniqueRouteId(`mod:${sourceId}:${targetId}:${parameter}`, existingRoutes), + domain: "modulation", + source: { kind: "module", moduleId: sourceId, port: "cv-out" }, + target: { kind: "module", moduleId: targetId, port: "cv-in" }, + enabled: true, + metadata: { createdFrom: "ui", parameter }, + }; +} + +export function getModulationCapability(moduleType: Module["type"], parameter: string): ModulationCapability | null { + if (moduleType !== "trigger" && moduleType !== "drum" && moduleType !== "tonal") return null; + const consumedByRuntime = moduleType === "trigger" && parameter === "density" + ? "scheduler" + : moduleType === "drum" && parameter === "basePitch" + ? "audio" + : moduleType === "tonal" && parameter === "cutoff" + ? "audio" + : null; + const assignableInUi = MODULATION_UI_PARAMETERS[moduleType].includes(parameter); + return { + moduleType, + parameter, + assignableInUi, + representedInTypedRouting: true, + visibleInInspector: true, + consumedByRuntime, + knownLimitations: consumedByRuntime + ? "Runtime currently consumes the legacy target-owned modulations map; typed routes are declarations until authority changes." + : "Assignable/representable, but not currently consumed by scheduler or audio runtime.", + }; +} + +export function getModulationCapabilityMatrix(): ModulationCapability[] { + return (Object.entries(MODULATION_UI_PARAMETERS) as Array<["trigger" | "drum" | "tonal", string[]]>).flatMap(([moduleType, parameters]) => ( + parameters.map((parameter) => getModulationCapability(moduleType, parameter) as ModulationCapability) + )); +} + +export function resolveParameterModulation( + patch: Pick & { routes?: unknown }, + targetId: string, + parameter: string, +): ModulationRoutingResolution { + const modulesById = new Map(patch.modules.map((module) => [module.id, module])); + const target = modulesById.get(targetId); + const controlIds = new Set(patch.modules.filter((module) => module.type === "control").map((module) => module.id)); + const legacySourceId = target && "modulations" in target && target.modulations && typeof target.modulations === "object" + ? target.modulations[parameter] ?? null + : null; + const typedCandidates: ModulationRouteCandidate[] = []; + + if (Array.isArray(patch.routes)) { + for (let routeIndex = 0; routeIndex < patch.routes.length; routeIndex += 1) { + const route = normalizeRawRoute(patch.routes[routeIndex]); + if (!route || !typedModulationRouteTargets(route, targetId, parameter)) continue; + const sourceId = route.source.kind === "module" ? route.source.moduleId : null; + typedCandidates.push({ + routeIndex, + routeId: route.id, + sourceId, + enabled: route.enabled !== false, + sourceExists: !!sourceId && controlIds.has(sourceId), + targetExists: modulesById.has(targetId), + }); + } + } + + const activeTypedCandidates = typedCandidates.filter((candidate) => candidate.enabled && candidate.sourceExists && candidate.targetExists); + const typedSourceId = activeTypedCandidates[0]?.sourceId ?? null; + const legacySourceValid = !!(legacySourceId && controlIds.has(legacySourceId)); + const typedHasStale = typedCandidates.some((candidate) => candidate.enabled && (!candidate.sourceExists || !candidate.targetExists)); + const runtimeOwner = modulationRuntimeOwner(target, parameter); + const runtimeSupported = runtimeOwner !== null; + const effectiveRuntimeSourceId = runtimeSupported && legacySourceValid ? legacySourceId : null; + const typedAndLegacyMatch = !!(typedSourceId && legacySourceId && typedSourceId === legacySourceId); + const typedAndLegacyConflict = !!(typedSourceId && legacySourceId && typedSourceId !== legacySourceId); + const fallbackUsed = !!(typedSourceId && legacySourceId && runtimeSupported); + + let state: ModulationRoutingState; + if (typedHasStale) state = "stale-typed"; + else if (legacySourceId && !legacySourceValid) state = "stale-legacy"; + else if (!runtimeSupported && (typedSourceId || legacySourceId)) state = "unsupported"; + else if (typedAndLegacyConflict) state = "conflicting-hybrid"; + else if (typedAndLegacyMatch) state = "matching-hybrid"; + else if (typedSourceId) state = "typed-only"; + else if (legacySourceValid) state = "legacy-only"; + else state = "none"; + + return { + targetId, + parameter, + typedSourceId, + legacySourceId, + effectiveRuntimeSourceId, + typedAndLegacyMatch, + typedAndLegacyConflict, + runtimeSupported, + runtimeOwner, + fallbackUsed, + typedCandidates, + state, + }; +} + +export function setParameterModulationSource(patch: Patch, targetId: string, parameter: string, sourceId: string | null): ModulationRoutingResolution { + const target = patch.modules.find((module) => module.id === targetId); + if (!target || !("modulations" in target)) return resolveParameterModulation(patch, targetId, parameter); + + const source = sourceId + ? patch.modules.find((module) => module.id === sourceId && module.type === "control") + : null; + const nextSourceId = source ? source.id : null; + + target.modulations = target.modulations ?? {}; + if (nextSourceId) target.modulations[parameter] = nextSourceId; + else delete target.modulations[parameter]; + + const existingRoutes = Array.isArray(patch.routes) ? patch.routes : []; + const keptRoutes: PatchRoute[] = []; + for (const rawRoute of existingRoutes) { + const route = normalizeRawRoute(rawRoute); + if (route && typedModulationRouteTargets(route, targetId, parameter)) continue; + keptRoutes.push(rawRoute as PatchRoute); + } + + if (nextSourceId) keptRoutes.push(makeModulationRoute(nextSourceId, targetId, parameter, keptRoutes)); + if (Array.isArray(patch.routes) || keptRoutes.length > 0) patch.routes = keptRoutes; + + return resolveParameterModulation(patch, targetId, parameter); +} + export function setVoicePrimaryEventSource(patch: Patch, voiceId: string, sourceId: string | null): VoiceEventRoutingResolution { const voice = patch.modules.find((module): module is SoundModule => module.id === voiceId && isSoundModule(module)); if (!voice) return resolveVoiceEventRouting(patch, voiceId); diff --git a/src/ui/controlModule.ts b/src/ui/controlModule.ts index 001cd84..e583461 100644 --- a/src/ui/controlModule.ts +++ b/src/ui/controlModule.ts @@ -1,5 +1,6 @@ import type { ControlKind, LfoWaveform, Patch, ControlModule } from "../patch"; import { sampleControl01 } from "../engine/control"; +import { resolveParameterModulation, setParameterModulationSource } from "../routingGraph.ts"; import { ctlFloat } from "./ctl"; import { wireSafeDeleteButton } from "./deleteButton"; import { createFaceplateMainPanel, createFaceplateSection, createFaceplateStackPanel } from "./faceplateSections"; @@ -243,9 +244,14 @@ export function renderControlSurface( onRoutingChange((p) => { const targetModule = p.modules.find((module) => module.id === selectedTargetId); if (!targetModule || !(targetModule.type === "trigger" || targetModule.type === "drum" || targetModule.type === "tonal")) return; - targetModule.modulations = targetModule.modulations ?? {}; - if (checkbox.checked) targetModule.modulations[param.key] = mod.id; - else if (targetModule.modulations[param.key] === mod.id) delete targetModule.modulations[param.key]; + if (checkbox.checked) { + setParameterModulationSource(p, targetModule.id, param.key, mod.id); + } else { + const resolution = resolveParameterModulation(p, targetModule.id, param.key); + if (resolution.typedSourceId === mod.id || resolution.legacySourceId === mod.id) { + setParameterModulationSource(p, targetModule.id, param.key, null); + } + } }, { regen: false }); }); diff --git a/src/ui/routingInspector.ts b/src/ui/routingInspector.ts index 5df52e5..31090eb 100644 --- a/src/ui/routingInspector.ts +++ b/src/ui/routingInspector.ts @@ -1,5 +1,5 @@ import type { Module, Patch, SoundModule } from "../patch"; -import { planStaleRoutingCleanup, resolveVoiceEventRouting, validatePatchRouting, type RoutingValidationIssue, type RoutingValidationIssueCode, type StaleRoutingCleanupPlan } from "../routingGraph.ts"; +import { planStaleRoutingCleanup, getModulationCapability, resolveParameterModulation, resolveVoiceEventRouting, validatePatchRouting, type RoutingValidationIssue, type RoutingValidationIssueCode, type StaleRoutingCleanupPlan } from "../routingGraph.ts"; import { resolveTriggerSourceLabelState, type RoutingLabelStatus } from "./routingLabels"; export type RoutingHealthCounts = { @@ -26,6 +26,23 @@ export type EventRoutingInspectorRow = { text: string; }; +export type ModulationRoutingInspectorRow = { + targetId: string; + targetLabel: string; + parameter: string; + parameterLabel: string; + typedSourceId: string | null; + typedSourceLabel: string; + legacySourceId: string | null; + legacySourceLabel: string; + effectiveRuntimeSourceId: string | null; + effectiveRuntimeSourceLabel: string; + runtimeSupported: boolean; + fallbackUsed: boolean; + status: "none" | "typed-declaration" | "legacy-runtime" | "matching" | "conflict" | "unsupported" | "stale"; + text: string; +}; + const INVALID_ROUTE_CODES = new Set([ "route-invalid-record", "route-duplicate-id", @@ -42,6 +59,36 @@ function isSoundModule(module: Module): module is SoundModule { return module.type === "drum" || module.type === "tonal"; } +function isModulationTarget(module: Module) { + return module.type === "trigger" || module.type === "drum" || module.type === "tonal"; +} + +function modulationSourceLabel(modulesById: Map, sourceId: string | null) { + if (!sourceId) return "None"; + const source = modulesById.get(sourceId); + return source ? source.name : `Missing ${sourceId.slice(-4).toUpperCase()}`; +} + +function modulationParametersForTarget(patch: Pick & { routes?: unknown }, target: Module) { + const parameters = new Set(); + if ("modulations" in target && target.modulations && typeof target.modulations === "object") { + Object.keys(target.modulations).forEach((parameter) => parameters.add(parameter)); + } + if (Array.isArray(patch.routes)) { + for (const route of patch.routes) { + if (!route || typeof route !== "object") continue; + const candidate = route as { domain?: unknown; target?: unknown; metadata?: { parameter?: unknown } }; + if (candidate.domain !== "modulation") continue; + const endpoint = candidate.target as { kind?: unknown; moduleId?: unknown } | undefined; + if (endpoint?.kind !== "module" || endpoint.moduleId !== target.id) continue; + const parameter = candidate.metadata?.parameter; + if (typeof parameter === "string" && parameter.trim()) parameters.add(parameter); + } + } + return [...parameters].sort(); +} + + function countIssues(issues: RoutingValidationIssue[]): RoutingHealthCounts { return issues.reduce((counts, issue) => { if (issue.code === "voice-missing-trigger-source" || issue.code === "voice-invalid-trigger-source") { @@ -132,3 +179,54 @@ export function formatStaleRoutingCleanupConfirmation(plan: StaleRoutingCleanupP ].filter((line): line is string => Boolean(line)); return lines.join("\n"); } + + +export function buildModulationRoutingInspectorRows(patch: Pick & { routes?: unknown }): ModulationRoutingInspectorRow[] { + const modulesById = new Map(patch.modules.map((module) => [module.id, module])); + const rows: ModulationRoutingInspectorRow[] = []; + + for (const target of patch.modules) { + if (!isModulationTarget(target)) continue; + for (const parameter of modulationParametersForTarget(patch, target)) { + const resolution = resolveParameterModulation(patch, target.id, parameter); + const capability = getModulationCapability(target.type, parameter); + const typedSourceLabel = modulationSourceLabel(modulesById, resolution.typedSourceId); + const legacySourceLabel = modulationSourceLabel(modulesById, resolution.legacySourceId); + const effectiveRuntimeSourceLabel = modulationSourceLabel(modulesById, resolution.effectiveRuntimeSourceId); + const stale = resolution.state === "stale-typed" || resolution.state === "stale-legacy"; + const status: ModulationRoutingInspectorRow["status"] = stale + ? "stale" + : !resolution.runtimeSupported && (resolution.typedSourceId || resolution.legacySourceId) + ? "unsupported" + : resolution.typedAndLegacyConflict + ? "conflict" + : resolution.typedAndLegacyMatch + ? "matching" + : resolution.effectiveRuntimeSourceId + ? "legacy-runtime" + : resolution.typedSourceId + ? "typed-declaration" + : "none"; + const targetLabel = modulesById.get(target.id)?.name ?? target.id; + const parameterLabel = capability?.parameter ?? parameter; + rows.push({ + targetId: target.id, + targetLabel, + parameter, + parameterLabel, + typedSourceId: resolution.typedSourceId, + typedSourceLabel, + legacySourceId: resolution.legacySourceId, + legacySourceLabel, + effectiveRuntimeSourceId: resolution.effectiveRuntimeSourceId, + effectiveRuntimeSourceLabel, + runtimeSupported: resolution.runtimeSupported, + fallbackUsed: resolution.fallbackUsed, + status, + text: `${targetLabel} · ${parameter}: typed ${typedSourceLabel}, legacy ${legacySourceLabel}, runtime ${effectiveRuntimeSourceLabel}${resolution.runtimeSupported ? "" : " (unsupported)"}`, + }); + } + } + + return rows; +} diff --git a/src/ui/triggerModule.ts b/src/ui/triggerModule.ts index e9698fe..b11c465 100644 --- a/src/ui/triggerModule.ts +++ b/src/ui/triggerModule.ts @@ -1,5 +1,5 @@ import type { Mode, Patch, TriggerModule } from "../patch"; -import { setVoicePrimaryEventSource } from "../routingGraph.ts"; +import { setParameterModulationSource, setVoicePrimaryEventSource } from "../routingGraph.ts"; import { getPatternPreview } from "../engine/pattern/module"; import { GEN_MODES, getGenModeMeta } from "../engine/pattern/genModeRegistry"; import { ctlFloat, type CtlFloatElement } from "./ctl"; @@ -1397,9 +1397,7 @@ export function renderTriggerSurface( onAssign: (parameter, value) => onRoutingChange((p) => { const m = p.modules.find((x) => x.id === t.id); if (m?.type !== "trigger") return; - m.modulations = m.modulations ?? {}; - if (value) m.modulations[parameter] = value; - else delete m.modulations[parameter]; + setParameterModulationSource(p, m.id, parameter, value); }, { regen: false }), }); const modList = document.createElement("div"); diff --git a/src/ui/voiceModule.ts b/src/ui/voiceModule.ts index 7939f51..72e7077 100644 --- a/src/ui/voiceModule.ts +++ b/src/ui/voiceModule.ts @@ -1,5 +1,5 @@ import type { DrumModule, Patch, SoundModule, TonalModule } from "../patch"; -import { setVoicePrimaryEventSource } from "../routingGraph.ts"; +import { setParameterModulationSource, setVoicePrimaryEventSource } from "../routingGraph.ts"; import { normalizeDrumChannelMode } from "../patch"; import { ctlFloat } from "./ctl"; import { wireSafeDeleteButton } from "./deleteButton"; @@ -361,9 +361,7 @@ function createVoiceRoutingSelectors(v: SoundModule, controlOptions: ControlOpti onAssign: (parameter, source) => onRoutingChange((p) => { const m = p.modules.find((z) => z.id === v.id); if (m?.type !== v.type) return; - m.modulations = m.modulations ?? {}; - if (source) m.modulations[parameter] = source; - else delete m.modulations[parameter]; + setParameterModulationSource(p, m.id, parameter, source); }, { regen: false }), }); } diff --git a/tests/modulationResolver.test.mjs b/tests/modulationResolver.test.mjs new file mode 100644 index 0000000..46fe389 --- /dev/null +++ b/tests/modulationResolver.test.mjs @@ -0,0 +1,226 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + getModulationCapability, + getModulationCapabilityMatrix, + resolveParameterModulation, + setParameterModulationSource, +} from '../src/routingGraph.ts'; +import { buildModulationRoutingInspectorRows } from '../src/ui/routingInspector.ts'; +import { makePatch, makeSound, makeTrigger } from './helpers.mjs'; + +function makeControl(overrides = {}) { + return { + id: 'ctl-1', + type: 'control', + name: 'CTL', + enabled: true, + kind: 'lfo', + waveform: 'square', + speed: 0.1, + amount: 1, + phase: 0, + rate: 0.4, + drift: 0.2, + randomness: 0.2, + ...overrides, + }; +} + +function modulationRoute(id, sourceId, targetId, parameter, extra = {}) { + return { + id, + domain: 'modulation', + source: { kind: 'module', moduleId: sourceId, port: 'cv-out' }, + target: { kind: 'module', moduleId: targetId, port: 'cv-in' }, + enabled: true, + metadata: { createdFrom: 'ui', parameter }, + ...extra, + }; +} + +test('modulation resolver reports legacy modulation only as runtime-effective when supported', () => { + const trigger = makeTrigger({ id: 'trg-1' }); + const control = makeControl({ id: 'ctl-1' }); + trigger.modulations = { density: control.id }; + const patch = makePatch([trigger, control]); + + const resolution = resolveParameterModulation(patch, trigger.id, 'density'); + + assert.equal(resolution.state, 'legacy-only'); + assert.equal(resolution.legacySourceId, control.id); + assert.equal(resolution.typedSourceId, null); + assert.equal(resolution.effectiveRuntimeSourceId, control.id); + assert.equal(resolution.runtimeOwner, 'scheduler'); +}); + +test('modulation resolver reports typed modulation only as declaration without runtime authority', () => { + const trigger = makeTrigger({ id: 'trg-1', modulations: {} }); + const control = makeControl({ id: 'ctl-1' }); + const patch = makePatch([trigger, control]); + patch.routes = [modulationRoute('mod-typed', control.id, trigger.id, 'density')]; + + const resolution = resolveParameterModulation(patch, trigger.id, 'density'); + + assert.equal(resolution.state, 'typed-only'); + assert.equal(resolution.typedSourceId, control.id); + assert.equal(resolution.legacySourceId, null); + assert.equal(resolution.effectiveRuntimeSourceId, null); + assert.equal(resolution.runtimeSupported, true); +}); + +test('modulation resolver reports matching typed and legacy sources with fallback in use', () => { + const sound = makeSound({ id: 'drm-1', modulations: { basePitch: 'ctl-1' } }); + const control = makeControl({ id: 'ctl-1' }); + const patch = makePatch([sound, control]); + patch.routes = [modulationRoute('mod-match', control.id, sound.id, 'basePitch')]; + + const resolution = resolveParameterModulation(patch, sound.id, 'basePitch'); + + assert.equal(resolution.state, 'matching-hybrid'); + assert.equal(resolution.typedAndLegacyMatch, true); + assert.equal(resolution.effectiveRuntimeSourceId, control.id); + assert.equal(resolution.fallbackUsed, true); +}); + +test('modulation resolver reports conflicting typed and legacy sources', () => { + const sound = makeSound({ id: 'drm-1', modulations: { basePitch: 'ctl-a' } }); + const controlA = makeControl({ id: 'ctl-a' }); + const controlB = makeControl({ id: 'ctl-b' }); + const patch = makePatch([sound, controlA, controlB]); + patch.routes = [modulationRoute('mod-conflict', controlB.id, sound.id, 'basePitch')]; + + const resolution = resolveParameterModulation(patch, sound.id, 'basePitch'); + + assert.equal(resolution.state, 'conflicting-hybrid'); + assert.equal(resolution.typedSourceId, controlB.id); + assert.equal(resolution.legacySourceId, controlA.id); + assert.equal(resolution.effectiveRuntimeSourceId, controlA.id); + assert.equal(resolution.typedAndLegacyConflict, true); +}); + +test('modulation resolver reports no modulation', () => { + const sound = makeSound({ id: 'drm-1', modulations: {} }); + const patch = makePatch([sound]); + + const resolution = resolveParameterModulation(patch, sound.id, 'basePitch'); + + assert.equal(resolution.state, 'none'); + assert.equal(resolution.effectiveRuntimeSourceId, null); +}); + +test('modulation resolver reports stale typed source', () => { + const sound = makeSound({ id: 'drm-1', modulations: {} }); + const patch = makePatch([sound]); + patch.routes = [modulationRoute('mod-stale', 'missing-control', sound.id, 'basePitch')]; + + const resolution = resolveParameterModulation(patch, sound.id, 'basePitch'); + + assert.equal(resolution.state, 'stale-typed'); + assert.equal(resolution.typedCandidates[0].sourceId, 'missing-control'); + assert.equal(resolution.effectiveRuntimeSourceId, null); +}); + +test('modulation resolver reports stale legacy source', () => { + const sound = makeSound({ id: 'drm-1', modulations: { basePitch: 'missing-control' } }); + const patch = makePatch([sound]); + + const resolution = resolveParameterModulation(patch, sound.id, 'basePitch'); + + assert.equal(resolution.state, 'stale-legacy'); + assert.equal(resolution.legacySourceId, 'missing-control'); + assert.equal(resolution.effectiveRuntimeSourceId, null); +}); + +test('modulation resolver reports unsupported assigned parameter', () => { + const sound = makeSound({ id: 'drm-1', modulations: { decay: 'ctl-1' } }); + const control = makeControl({ id: 'ctl-1' }); + const patch = makePatch([sound, control]); + + const resolution = resolveParameterModulation(patch, sound.id, 'decay'); + + assert.equal(resolution.state, 'unsupported'); + assert.equal(resolution.runtimeSupported, false); + assert.equal(resolution.effectiveRuntimeSourceId, null); +}); + +test('modulation capability matrix identifies scheduler and audio-owned runtime parameters', () => { + assert.equal(getModulationCapability('trigger', 'density').consumedByRuntime, 'scheduler'); + assert.equal(getModulationCapability('drum', 'basePitch').consumedByRuntime, 'audio'); + assert.equal(getModulationCapability('tonal', 'cutoff').consumedByRuntime, 'audio'); + assert.equal(getModulationCapability('drum', 'decay').consumedByRuntime, null); + + const matrix = getModulationCapabilityMatrix(); + assert.ok(matrix.some((entry) => entry.moduleType === 'trigger' && entry.parameter === 'density' && entry.assignableInUi)); + assert.ok(matrix.some((entry) => entry.moduleType === 'tonal' && entry.parameter === 'cutoff' && entry.visibleInInspector)); +}); + +test('modulation inspector distinguishes typed, legacy, effective runtime, unsupported, and conflict', () => { + const sound = makeSound({ id: 'drm-1', name: 'Kick', modulations: { basePitch: 'ctl-a', decay: 'ctl-a' } }); + const controlA = makeControl({ id: 'ctl-a', name: 'CTRL A' }); + const controlB = makeControl({ id: 'ctl-b', name: 'CTRL B' }); + const patch = makePatch([sound, controlA, controlB]); + patch.routes = [ + modulationRoute('mod-conflict', controlB.id, sound.id, 'basePitch'), + modulationRoute('mod-typed', controlB.id, sound.id, 'tone'), + ]; + + const rows = buildModulationRoutingInspectorRows(patch); + const pitch = rows.find((row) => row.targetId === sound.id && row.parameter === 'basePitch'); + const tone = rows.find((row) => row.targetId === sound.id && row.parameter === 'tone'); + const decay = rows.find((row) => row.targetId === sound.id && row.parameter === 'decay'); + + assert.equal(pitch.status, 'conflict'); + assert.equal(pitch.typedSourceId, controlB.id); + assert.equal(pitch.legacySourceId, controlA.id); + assert.equal(pitch.effectiveRuntimeSourceId, controlA.id); + assert.equal(tone.status, 'unsupported'); + assert.equal(tone.typedSourceId, controlB.id); + assert.equal(decay.status, 'unsupported'); + assert.equal(decay.legacySourceId, controlA.id); +}); + +test('repeated modulation assignment is idempotent and keeps schema version', () => { + const sound = makeSound({ id: 'drm-1', modulations: {} }); + const control = makeControl({ id: 'ctl-1' }); + const patch = makePatch([sound, control]); + + setParameterModulationSource(patch, sound.id, 'basePitch', control.id); + const once = structuredClone(patch); + setParameterModulationSource(patch, sound.id, 'basePitch', control.id); + + assert.deepEqual(patch, once); + assert.equal(patch.version, '0.3'); +}); + +test('assigning a new control replaces previous typed and legacy modulation sources', () => { + const sound = makeSound({ id: 'drm-1', modulations: { basePitch: 'ctl-a' } }); + const controlA = makeControl({ id: 'ctl-a' }); + const controlB = makeControl({ id: 'ctl-b' }); + const patch = makePatch([sound, controlA, controlB]); + patch.routes = [modulationRoute('mod-a', controlA.id, sound.id, 'basePitch')]; + + const resolution = setParameterModulationSource(patch, sound.id, 'basePitch', controlB.id); + + assert.equal(resolution.state, 'matching-hybrid'); + assert.equal(sound.modulations.basePitch, controlB.id); + assert.deepEqual(patch.routes.filter((route) => route.domain === 'modulation' && route.target.moduleId === sound.id && route.metadata.parameter === 'basePitch').map((route) => route.source.moduleId), [controlB.id]); +}); + +test('modulation reassignment leaves unrelated parameters unchanged', () => { + const sound = makeSound({ id: 'drm-1', modulations: { basePitch: 'ctl-a', decay: 'ctl-a' } }); + const controlA = makeControl({ id: 'ctl-a' }); + const controlB = makeControl({ id: 'ctl-b' }); + const patch = makePatch([sound, controlA, controlB]); + patch.routes = [ + modulationRoute('mod-pitch', controlA.id, sound.id, 'basePitch'), + modulationRoute('mod-decay', controlA.id, sound.id, 'decay'), + ]; + + setParameterModulationSource(patch, sound.id, 'basePitch', controlB.id); + + assert.equal(sound.modulations.decay, controlA.id); + assert.ok(patch.routes.some((route) => route.id === 'mod-decay')); + assert.equal(resolveParameterModulation(patch, sound.id, 'decay').legacySourceId, controlA.id); +});