| // Copyright (C) 2026 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| // Patch state helpers for the SoundSynth plugin. |
| // |
| // The single source of truth is a `protos.ISynthesizeAudioArgs` object. |
| // Everything else is a derived view over it. See |
| // docs/design-docs/trace-to-techno/ui-design.md for the full spec. |
| |
| import {z} from 'zod'; |
| import protos from '../../protos'; |
| |
| // --- Reserved module IDs --- |
| |
| /** Virtual module ID: the gate signal coming into an instrument. */ |
| export const VIRTUAL_INPUT_GATE = '__input__gate'; |
| /** Virtual module ID: the pitch/freq CV coming into an instrument. */ |
| export const VIRTUAL_INPUT_FREQ = '__input__freq'; |
| /** The ID of the rack-level master mixer. */ |
| export const RACK_MASTER_ID = 'master'; |
| |
| export function isVirtualInputId(id: string | undefined | null): boolean { |
| return id === VIRTUAL_INPUT_GATE || id === VIRTUAL_INPUT_FREQ; |
| } |
| |
| // --- UI state schemas --- |
| |
| /** Top-level page UI state (stored in SynthPatch.ui_state_json). */ |
| export const PatchUiStateSchema = z.object({ |
| editingInstrumentId: z.string().nullable().default(null), |
| }); |
| export type PatchUiState = z.infer<typeof PatchUiStateSchema>; |
| |
| /** Node kinds used by the UI for rendering/grouping. */ |
| const NodeKindSchema = z.enum([ |
| 'trace_source', // TraceSliceSource at the rack level |
| 'instrument_root', // The instrument's own output mixer (rack-visible) |
| 'instrument_internal', // Any non-root module belonging to an instrument |
| 'master', // The rack master mixer |
| 'unknown', // Fallback |
| ]); |
| |
| /** UI state stored per module (SynthModule.ui_state_json). */ |
| export const ModuleUiStateSchema = z.object({ |
| nodeKind: NodeKindSchema.default('unknown'), |
| displayName: z.string().default(''), |
| // Canvas position. For trace sources and the instrument root module, |
| // this is the node's position on the rack canvas (top). For |
| // instrument_internal modules, it's the position inside the |
| // instrument editor canvas (bottom). |
| x: z.number().default(0), |
| y: z.number().default(0), |
| // The instrument root's OUTPUT node position in the instrument editor |
| // (bottom canvas). Stored separately from x/y because the same module |
| // is rendered in both canvases (as an Instrument on the rack and as |
| // the OUTPUT inside the instrument editor). |
| outX: z.number().nullable().default(null), |
| outY: z.number().nullable().default(null), |
| // Instrument-root specific fields: |
| presetId: z.string().default(''), |
| muted: z.boolean().default(false), |
| soloed: z.boolean().default(false), |
| level: z.number().default(0.8), |
| // IDs of the rack-level modules bound to this instrument's virtual |
| // gate / freq inputs. Empty string = not bound. |
| gateSource: z.string().default(''), |
| freqSource: z.string().default(''), |
| }); |
| export type ModuleUiState = z.infer<typeof ModuleUiStateSchema>; |
| |
| // --- JSON parsing helpers --- |
| |
| export function parsePatchUiState( |
| json: string | null | undefined, |
| ): PatchUiState { |
| if (!json) return PatchUiStateSchema.parse({}); |
| try { |
| return PatchUiStateSchema.parse(JSON.parse(json)); |
| } catch { |
| return PatchUiStateSchema.parse({}); |
| } |
| } |
| |
| export function parseModuleUiState( |
| json: string | null | undefined, |
| ): ModuleUiState { |
| if (!json) return ModuleUiStateSchema.parse({}); |
| try { |
| return ModuleUiStateSchema.parse(JSON.parse(json)); |
| } catch { |
| return ModuleUiStateSchema.parse({}); |
| } |
| } |
| |
| export function writeModuleUiState( |
| mod: protos.ISynthModule, |
| patch: Partial<ModuleUiState>, |
| ) { |
| const current = parseModuleUiState(mod.uiStateJson); |
| mod.uiStateJson = JSON.stringify({...current, ...patch}); |
| } |
| |
| export function writePatchUiState( |
| patch: protos.ISynthPatch, |
| state: Partial<PatchUiState>, |
| ) { |
| const current = parsePatchUiState(patch.uiStateJson); |
| patch.uiStateJson = JSON.stringify({...current, ...state}); |
| } |
| |
| // --- Derived views --- |
| |
| /** A trace source module at the rack level. */ |
| export interface TraceSourceEntity { |
| moduleIdx: number; |
| module: protos.ISynthModule; |
| uiState: ModuleUiState; |
| } |
| |
| /** An instrument: a group of modules sharing a common ID prefix. */ |
| export interface InstrumentEntity { |
| instrumentId: string; // The shared prefix (without trailing __) |
| rootModuleIdx: number; |
| rootModule: protos.ISynthModule; // The output mixer (`${id}__master`) |
| uiState: ModuleUiState; |
| // Modules whose IDs start with `${instrumentId}__`. Always non-empty |
| // (at least contains the root). |
| internalModuleIdxs: number[]; |
| } |
| |
| /** The rack master mixer. */ |
| export interface MasterEntity { |
| moduleIdx: number; |
| module: protos.ISynthModule; |
| uiState: ModuleUiState; |
| } |
| |
| /** Computed view over a SynthPatch. */ |
| export interface PatchView { |
| traceSources: TraceSourceEntity[]; |
| instruments: InstrumentEntity[]; |
| master: MasterEntity | null; |
| } |
| |
| /** |
| * Checks whether a module ID belongs to a particular instrument's internal |
| * namespace. The root itself matches; any `${instrumentId}__*` does too. |
| */ |
| export function moduleBelongsToInstrument( |
| moduleId: string, |
| instrumentId: string, |
| ): boolean { |
| return moduleId.startsWith(`${instrumentId}__`); |
| } |
| |
| /** |
| * Extracts the instrument ID from a prefixed module ID. |
| * `inst_abc__osc` → `inst_abc` |
| * Returns null if the ID is not in instrument namespace format. |
| */ |
| export function instrumentIdOf(moduleId: string): string | null { |
| const idx = moduleId.indexOf('__'); |
| if (idx < 0) return null; |
| return moduleId.substring(0, idx); |
| } |
| |
| /** Compute the derived view. */ |
| export function computePatchView( |
| patch: protos.ISynthPatch | null | undefined, |
| ): PatchView { |
| const traceSources: TraceSourceEntity[] = []; |
| const instrumentMap = new Map<string, InstrumentEntity>(); |
| let master: MasterEntity | null = null; |
| |
| if (!patch?.modules) { |
| return {traceSources, instruments: [], master}; |
| } |
| const modules = patch.modules; |
| |
| for (let i = 0; i < modules.length; i++) { |
| const mod = modules[i]; |
| const ui = parseModuleUiState(mod.uiStateJson); |
| const id = mod.id ?? ''; |
| |
| // Rack master. |
| if (id === RACK_MASTER_ID && mod.mixer) { |
| master = {moduleIdx: i, module: mod, uiState: ui}; |
| continue; |
| } |
| // Rack-level trace source (no `__` namespace). |
| if (mod.traceSliceSource && |
| instrumentIdOf(id) === null) { |
| traceSources.push({moduleIdx: i, module: mod, uiState: ui}); |
| continue; |
| } |
| // Instrument member: has `__` namespace. |
| const instId = instrumentIdOf(id); |
| if (instId !== null) { |
| let entity = instrumentMap.get(instId); |
| if (!entity) { |
| entity = { |
| instrumentId: instId, |
| rootModuleIdx: -1, |
| rootModule: mod, // Placeholder; overwritten when we find the root. |
| uiState: parseModuleUiState(mod.uiStateJson), |
| internalModuleIdxs: [], |
| }; |
| instrumentMap.set(instId, entity); |
| } |
| entity.internalModuleIdxs.push(i); |
| if (ui.nodeKind === 'instrument_root') { |
| entity.rootModuleIdx = i; |
| entity.rootModule = mod; |
| entity.uiState = ui; |
| } |
| } |
| } |
| |
| // Discard instruments that never registered a root (shouldn't happen |
| // normally, but keeps things safe). |
| const instruments: InstrumentEntity[] = []; |
| for (const inst of instrumentMap.values()) { |
| if (inst.rootModuleIdx >= 0) { |
| instruments.push(inst); |
| } |
| } |
| |
| return {traceSources, instruments, master}; |
| } |
| |
| // --- Mutation helpers --- |
| |
| let _counter = 0; |
| function uniqueSuffix(): string { |
| return `${Date.now().toString(36)}${(_counter++).toString(36)}`; |
| } |
| |
| /** Generate a fresh instrument ID. */ |
| export function freshInstrumentId(): string { |
| return `inst_${uniqueSuffix()}`; |
| } |
| |
| /** Create the initial patch with just a rack master mixer. */ |
| export function createEmptyPatch(): protos.ISynthesizeAudioArgs { |
| const masterUi: ModuleUiState = ModuleUiStateSchema.parse({ |
| nodeKind: 'master', |
| displayName: 'Master Out', |
| x: 760, |
| y: 180, |
| }); |
| return { |
| patch: { |
| modules: [{ |
| id: RACK_MASTER_ID, |
| mixer: {}, |
| uiStateJson: JSON.stringify(masterUi), |
| }], |
| wires: [], |
| uiStateJson: JSON.stringify(PatchUiStateSchema.parse({})), |
| }, |
| }; |
| } |
| |
| /** Ensure the rack master mixer exists. */ |
| export function ensureMaster(patch: protos.ISynthPatch): void { |
| if (!patch.modules) patch.modules = []; |
| if (!patch.wires) patch.wires = []; |
| const hasMaster = patch.modules.some((m) => m.id === RACK_MASTER_ID); |
| if (!hasMaster) { |
| const ui: ModuleUiState = ModuleUiStateSchema.parse({ |
| nodeKind: 'master', |
| displayName: 'Master Out', |
| x: 760, |
| y: 180, |
| }); |
| patch.modules.push({ |
| id: RACK_MASTER_ID, |
| mixer: {}, |
| uiStateJson: JSON.stringify(ui), |
| }); |
| } |
| } |
| |
| /** |
| * Add a new trace source node to the rack. Returns the new module id. |
| */ |
| export function addTraceSource( |
| patch: protos.ISynthPatch, |
| trackNameGlob: string, |
| displayName: string, |
| x: number, |
| y: number, |
| ): string { |
| if (!patch.modules) patch.modules = []; |
| const id = `src_${uniqueSuffix()}`; |
| const ui: ModuleUiState = ModuleUiStateSchema.parse({ |
| nodeKind: 'trace_source', |
| displayName, |
| x, |
| y, |
| }); |
| patch.modules.push({ |
| id, |
| traceSliceSource: { |
| trackNameGlob, |
| signalType: protos.TraceSliceSourceConfig.SignalType.GATE, |
| }, |
| uiStateJson: JSON.stringify(ui), |
| }); |
| return id; |
| } |
| |
| /** |
| * Remove a module by id. Also removes any wire that references it |
| * (as source or destination). |
| */ |
| export function removeModule(patch: protos.ISynthPatch, id: string): void { |
| if (!patch.modules || !patch.wires) return; |
| patch.modules = patch.modules.filter((m) => m.id !== id); |
| patch.wires = patch.wires.filter( |
| (w) => w.fromModule !== id && w.toModule !== id, |
| ); |
| // Clear any instrument root ui_state bindings that referenced this id. |
| for (const m of patch.modules) { |
| const ui = parseModuleUiState(m.uiStateJson); |
| if (ui.nodeKind === 'instrument_root') { |
| let changed = false; |
| if (ui.gateSource === id) { ui.gateSource = ''; changed = true; } |
| if (ui.freqSource === id) { ui.freqSource = ''; changed = true; } |
| if (changed) m.uiStateJson = JSON.stringify(ui); |
| } |
| } |
| } |
| |
| /** |
| * Remove an entire instrument: all modules in its namespace, all wires |
| * referencing those modules, and the rack wire into the master. |
| */ |
| export function removeInstrument( |
| patch: protos.ISynthPatch, |
| instrumentId: string, |
| ): void { |
| if (!patch.modules || !patch.wires) return; |
| const prefix = `${instrumentId}__`; |
| patch.modules = patch.modules.filter((m) => !(m.id ?? '').startsWith(prefix)); |
| patch.wires = patch.wires.filter((w) => { |
| const f = w.fromModule ?? ''; |
| const t = w.toModule ?? ''; |
| return !f.startsWith(prefix) && !t.startsWith(prefix); |
| }); |
| } |
| |
| /** |
| * Bind an instrument's gate or freq input to a rack-level source module. |
| */ |
| export function setInstrumentSource( |
| _patch: protos.ISynthPatch, |
| instrumentRoot: protos.ISynthModule, |
| kind: 'gate' | 'freq', |
| sourceId: string, |
| ): void { |
| const ui = parseModuleUiState(instrumentRoot.uiStateJson); |
| if (kind === 'gate') ui.gateSource = sourceId; |
| else ui.freqSource = sourceId; |
| instrumentRoot.uiStateJson = JSON.stringify(ui); |
| } |
| |
| // --- Render patch builders --- |
| |
| /** |
| * Resolve a virtual INPUT module id to a real source module id, or |
| * return null if no binding exists. |
| */ |
| function resolveVirtualSource( |
| fromModule: string, |
| bindings: Map<string, {gate: string; freq: string}>, |
| instrumentIdForWire: string, |
| ): string | null { |
| if (fromModule !== VIRTUAL_INPUT_GATE && |
| fromModule !== VIRTUAL_INPUT_FREQ) { |
| return fromModule; // Not virtual, return as-is. |
| } |
| const b = bindings.get(instrumentIdForWire); |
| if (!b) return null; |
| if (fromModule === VIRTUAL_INPUT_GATE) return b.gate || null; |
| return b.freq || null; |
| } |
| |
| /** |
| * Build the render patch for the Render button (whole rack). |
| * |
| * - Walks all non-muted instruments (respecting solo) |
| * - Includes all trace sources referenced by any audible instrument |
| * - Rewrites virtual INPUT wires to point at real rack sources |
| * - Drops any virtual wires for unbound inputs |
| */ |
| export function buildRenderPatch( |
| view: PatchView, |
| allModules: protos.ISynthModule[], |
| allWires: protos.ISynthWire[], |
| ): protos.ISynthPatch { |
| const hasSoloed = view.instruments.some((i) => i.uiState.soloed); |
| |
| // Build a gate/freq binding map and collect audible instrument ids. |
| const bindings = new Map<string, {gate: string; freq: string}>(); |
| const includeInstIds = new Set<string>(); |
| const usedRackSourceIds = new Set<string>(); |
| for (const inst of view.instruments) { |
| const audible = hasSoloed ? inst.uiState.soloed : !inst.uiState.muted; |
| if (!audible) continue; |
| includeInstIds.add(inst.instrumentId); |
| bindings.set(inst.instrumentId, { |
| gate: inst.uiState.gateSource, |
| freq: inst.uiState.freqSource, |
| }); |
| if (inst.uiState.gateSource) usedRackSourceIds.add(inst.uiState.gateSource); |
| if (inst.uiState.freqSource) usedRackSourceIds.add(inst.uiState.freqSource); |
| } |
| |
| const modules: protos.ISynthModule[] = []; |
| const wires: protos.ISynthWire[] = []; |
| |
| // Include used trace sources. |
| for (const src of view.traceSources) { |
| if (usedRackSourceIds.has(src.module.id ?? '')) { |
| modules.push(src.module); |
| } |
| } |
| |
| // Include all internal modules of audible instruments. |
| for (const inst of view.instruments) { |
| if (!includeInstIds.has(inst.instrumentId)) continue; |
| for (const idx of inst.internalModuleIdxs) { |
| modules.push(allModules[idx]); |
| } |
| } |
| |
| // Include the master. |
| if (view.master) modules.push(view.master.module); |
| |
| // Rewrite wires: |
| // - virtual INPUT from_module → real source id (or drop wire) |
| // - keep only wires whose endpoints are in the included set |
| const includedIds = new Set<string>(modules.map((m) => m.id ?? '')); |
| for (const wire of allWires) { |
| const fromRaw = wire.fromModule ?? ''; |
| const toRaw = wire.toModule ?? ''; |
| |
| // Figure out which instrument's namespace this wire belongs to |
| // (if any). We use the to-module's namespace because the virtual |
| // INPUT always appears as a from-module, and its destination is |
| // inside the instrument. |
| const toInstId = instrumentIdOf(toRaw); |
| let resolvedFrom: string | null = fromRaw; |
| if (isVirtualInputId(fromRaw)) { |
| if (toInstId !== null) { |
| resolvedFrom = resolveVirtualSource(fromRaw, bindings, toInstId); |
| } else { |
| resolvedFrom = null; // Virtual wire outside any instrument = drop. |
| } |
| } |
| if (resolvedFrom === null) continue; |
| if (!includedIds.has(resolvedFrom)) continue; |
| if (!includedIds.has(toRaw)) continue; |
| |
| wires.push({ |
| ...wire, |
| fromModule: resolvedFrom, |
| }); |
| } |
| |
| return {modules, wires}; |
| } |
| |
| /** |
| * Build a standalone test patch for a single instrument. Used by the |
| * Test button on a rack instrument or in the bottom canvas. |
| * |
| * This: |
| * - Takes the instrument's internal modules as-is |
| * - Prepends a fresh TestPatternSource (ARPEGGIO, 128 BPM) |
| * - Rewrites virtual INPUT wires to point at the test source |
| * - Adds a fresh master Mixer receiving the instrument root's output |
| * |
| * Nothing is ever saved; this is constructed on the fly for one render. |
| */ |
| export function buildTestPatch( |
| inst: InstrumentEntity, |
| allModules: protos.ISynthModule[], |
| allWires: protos.ISynthWire[], |
| ): protos.ISynthPatch { |
| const TEST_SRC_ID = '__test_arp__'; |
| const TEST_MASTER_ID = '__test_master__'; |
| |
| const modules: protos.ISynthModule[] = []; |
| |
| // Include all internal modules of this instrument. |
| for (const idx of inst.internalModuleIdxs) { |
| modules.push(allModules[idx]); |
| } |
| |
| // Prepend the test pattern source. |
| modules.unshift({ |
| id: TEST_SRC_ID, |
| testPatternSource: { |
| mode: protos.TestPatternSourceConfig.Mode.ARPEGGIO, |
| bpm: 128, |
| bars: 4, |
| }, |
| }); |
| |
| // Append a standalone master mixer. |
| modules.push({ |
| id: TEST_MASTER_ID, |
| mixer: {}, |
| }); |
| |
| const includedIds = new Set<string>(modules.map((m) => m.id ?? '')); |
| |
| const wires: protos.ISynthWire[] = []; |
| for (const wire of allWires) { |
| const fromRaw = wire.fromModule ?? ''; |
| const toRaw = wire.toModule ?? ''; |
| |
| // Only keep wires that are inside this instrument. |
| const toInstId = instrumentIdOf(toRaw); |
| const fromInstId = instrumentIdOf(fromRaw); |
| const isFromVirtual = isVirtualInputId(fromRaw); |
| if (toInstId !== inst.instrumentId) continue; |
| if (!isFromVirtual && fromInstId !== inst.instrumentId) continue; |
| |
| let resolvedFrom = fromRaw; |
| let fromPort = wire.fromPort ?? 'out'; |
| if (fromRaw === VIRTUAL_INPUT_GATE) { |
| resolvedFrom = TEST_SRC_ID; |
| fromPort = 'out'; |
| } else if (fromRaw === VIRTUAL_INPUT_FREQ) { |
| resolvedFrom = TEST_SRC_ID; |
| fromPort = 'freq'; |
| } |
| |
| if (!includedIds.has(resolvedFrom)) continue; |
| if (!includedIds.has(toRaw)) continue; |
| |
| wires.push({ |
| ...wire, |
| fromModule: resolvedFrom, |
| fromPort, |
| }); |
| } |
| |
| // Wire the instrument root → test master. |
| wires.push({ |
| fromModule: inst.rootModule.id ?? '', |
| fromPort: 'out', |
| toModule: TEST_MASTER_ID, |
| toPort: 'in', |
| }); |
| |
| return {modules, wires}; |
| } |
| |
| // --- Preset import --- |
| |
| /** |
| * Import a preset patch into the main patch as a new instrument. |
| * The preset JSON is expected already converted to camelCase and |
| * decoded via protobufjs. |
| * |
| * Steps: |
| * 1. Strip any TestPatternSource modules from the preset. |
| * 2. Record which module IDs the test source occupied so we can |
| * rewrite dangling wires to virtual INPUT. |
| * 3. Prefix every remaining module ID with `${instrumentId}__`. |
| * 4. Rewrite all wire endpoints (keeping virtual INPUT for wires |
| * that were pointing at the stripped test source). |
| * 5. Mark the former internal `master` mixer as the instrument root. |
| * 6. Append everything to the target patch; add a rack wire from the |
| * root to the rack master. |
| * |
| * Returns the new instrument id. |
| */ |
| export function importPresetAsInstrument( |
| target: protos.ISynthPatch, |
| preset: { |
| patch: protos.ISynthPatch; |
| name: string; |
| category: string; |
| description: string; |
| }, |
| displayName: string, |
| x: number, |
| y: number, |
| ): string { |
| ensureMaster(target); |
| if (!target.modules) target.modules = []; |
| if (!target.wires) target.wires = []; |
| |
| const instrumentId = freshInstrumentId(); |
| const prefix = `${instrumentId}__`; |
| |
| // 1. Identify test pattern source ids to strip. |
| const stripIds = new Set<string>(); |
| for (const m of preset.patch.modules ?? []) { |
| if (m.testPatternSource) stripIds.add(m.id ?? ''); |
| } |
| |
| // 2. Build a map from old ID → new (prefixed) ID for the survivors. |
| const idMap = new Map<string, string>(); |
| for (const m of preset.patch.modules ?? []) { |
| const oldId = m.id ?? ''; |
| if (stripIds.has(oldId)) continue; |
| idMap.set(oldId, `${prefix}${oldId}`); |
| } |
| |
| // 3. Clone and rewrite modules. We deep-clone via toObject/fromObject |
| // to get a completely independent tree (protos.SynthModule.create does |
| // a shallow copy that reuses nested config objects). |
| let rootFound = false; |
| for (const m of preset.patch.modules ?? []) { |
| const oldId = m.id ?? ''; |
| if (stripIds.has(oldId)) continue; |
| |
| const plain = protos.SynthModule.toObject( |
| m as protos.SynthModule, {defaults: false, enums: Number}); |
| const cloned = protos.SynthModule.fromObject(plain); |
| cloned.id = idMap.get(oldId) ?? oldId; |
| |
| // Check the original module's oneof. When deep-cloning, we keep the |
| // mixer test on the cloned form which preserves the oneof structure. |
| const isMaster = oldId === 'master' && cloned.mixer != null; |
| if (isMaster) { |
| const ui = ModuleUiStateSchema.parse({ |
| nodeKind: 'instrument_root', |
| displayName, |
| presetId: preset.name, |
| x, |
| y, |
| level: 0.8, |
| }); |
| cloned.uiStateJson = JSON.stringify(ui); |
| rootFound = true; |
| } else { |
| const ui = ModuleUiStateSchema.parse({ |
| nodeKind: 'instrument_internal', |
| }); |
| cloned.uiStateJson = JSON.stringify(ui); |
| } |
| |
| target.modules.push(cloned); |
| } |
| |
| // 4. Clone and rewrite wires. Wires that previously referenced the |
| // test source get their from_module rewritten to virtual INPUT ids. |
| for (const w of preset.patch.wires ?? []) { |
| const fromOld = w.fromModule ?? ''; |
| const toOld = w.toModule ?? ''; |
| const fromPort = w.fromPort ?? 'out'; |
| if (stripIds.has(toOld)) continue; // Dropping: wire into test source. |
| |
| let newFrom: string; |
| if (stripIds.has(fromOld)) { |
| if (fromPort === 'freq') newFrom = VIRTUAL_INPUT_FREQ; |
| else newFrom = VIRTUAL_INPUT_GATE; |
| } else { |
| newFrom = idMap.get(fromOld) ?? fromOld; |
| } |
| const newTo = idMap.get(toOld) ?? toOld; |
| |
| target.wires.push({ |
| ...w, |
| fromModule: newFrom, |
| // When the source is virtualized, fromPort must be "out" because |
| // the virtual node only exposes an "out" port to downstream modules. |
| // The gate-vs-freq distinction is encoded in the virtual ID itself. |
| fromPort: isVirtualInputId(newFrom) ? 'out' : fromPort, |
| toModule: newTo, |
| }); |
| } |
| |
| // 5. If the preset had no `master` mixer, we can't identify a root. |
| // Fall back: treat the last module as the root and add a marker. |
| if (!rootFound) { |
| const lastIdx = target.modules.length - 1; |
| if (lastIdx >= 0) { |
| const last = target.modules[lastIdx]; |
| const ui = ModuleUiStateSchema.parse({ |
| nodeKind: 'instrument_root', |
| displayName, |
| presetId: preset.name, |
| x, |
| y, |
| level: 0.8, |
| }); |
| last.uiStateJson = JSON.stringify(ui); |
| } |
| } |
| |
| // 6. Add rack wire from instrument root to rack master. |
| const rootId = `${prefix}master`; |
| target.wires.push({ |
| fromModule: rootId, |
| fromPort: 'out', |
| toModule: RACK_MASTER_ID, |
| toPort: 'in', |
| }); |
| |
| // 7. Auto-layout the instrument's internal modules. Compute a |
| // topological depth from the instrument's virtual INPUT (gate/freq |
| // sources) towards the instrument root, then assign x/y based on |
| // depth and order within depth. Modules unreachable from the input |
| // (e.g. disconnected envelope-only chains) fall back to depth 0. |
| layoutInstrumentModules(target, instrumentId); |
| |
| return instrumentId; |
| } |
| |
| /** |
| * Assign (x, y) positions to all modules belonging to an instrument. |
| * The layout is a simple column-based topological layout: nodes are |
| * placed in columns by their BFS depth from the virtual INPUT. Within |
| * a column, nodes are stacked vertically. |
| * |
| * Column 0 is the INPUT (virtual, not laid out here). Columns 1..N-1 |
| * are internal modules. Column N is the instrument root (OUTPUT). |
| */ |
| function layoutInstrumentModules( |
| patch: protos.ISynthPatch, |
| instrumentId: string, |
| ): void { |
| const prefix = `${instrumentId}__`; |
| const rootId = `${prefix}master`; |
| const modules = patch.modules ?? []; |
| const wires = patch.wires ?? []; |
| |
| // Collect this instrument's internal module IDs. |
| const internalIds = new Set<string>(); |
| for (const m of modules) { |
| const id = m.id ?? ''; |
| if (id.startsWith(prefix)) internalIds.add(id); |
| } |
| |
| // Build adjacency: for each module, the set of predecessor module IDs. |
| // Virtual INPUT wires are treated as depth 0 and contribute no real |
| // predecessor for the layout. |
| const preds = new Map<string, Set<string>>(); |
| for (const id of internalIds) preds.set(id, new Set()); |
| for (const w of wires) { |
| const from = w.fromModule ?? ''; |
| const to = w.toModule ?? ''; |
| if (!internalIds.has(to)) continue; |
| if (isVirtualInputId(from)) continue; |
| if (internalIds.has(from)) preds.get(to)!.add(from); |
| } |
| |
| // BFS depth from nodes with no predecessors (which are either |
| // connected only to the virtual input, or truly disconnected). |
| const depth = new Map<string, number>(); |
| const queue: string[] = []; |
| for (const id of internalIds) { |
| if (preds.get(id)!.size === 0) { |
| depth.set(id, 0); |
| queue.push(id); |
| } |
| } |
| while (queue.length > 0) { |
| const cur = queue.shift()!; |
| const curDepth = depth.get(cur) ?? 0; |
| // For every wire from cur to another internal node, bump its depth. |
| for (const w of wires) { |
| if (w.fromModule !== cur) continue; |
| const to = w.toModule ?? ''; |
| if (!internalIds.has(to)) continue; |
| const existing = depth.get(to); |
| const newDepth = curDepth + 1; |
| if (existing === undefined || newDepth > existing) { |
| depth.set(to, newDepth); |
| queue.push(to); |
| } |
| } |
| } |
| |
| // Any still-unknown nodes (cycles or disconnected) → depth 0. |
| for (const id of internalIds) { |
| if (!depth.has(id)) depth.set(id, 0); |
| } |
| |
| // Force the instrument root to be in the rightmost column (max depth |
| // + 1) so it always appears at the end of the chain. |
| let maxDepth = 0; |
| for (const d of depth.values()) { |
| if (d > maxDepth) maxDepth = d; |
| } |
| if (internalIds.has(rootId)) { |
| depth.set(rootId, maxDepth + 1); |
| } |
| maxDepth = Math.max(maxDepth + 1, 1); |
| |
| // Group modules by depth. |
| const byDepth = new Map<number, string[]>(); |
| for (const id of internalIds) { |
| const d = depth.get(id) ?? 0; |
| const list = byDepth.get(d) ?? []; |
| list.push(id); |
| byDepth.set(d, list); |
| } |
| |
| // Layout constants. |
| const COL_SPACING = 220; |
| const ROW_SPACING = 140; |
| const X_OFFSET = 200; // leave room for the virtual INPUT node at x=30 |
| const Y_OFFSET = 60; |
| |
| // Assign positions. For the instrument root (OUTPUT), we write to |
| // outX/outY so we don't clobber its rack position (x/y). For |
| // everything else, x/y are the instrument-editor-canvas positions |
| // (those modules are never shown on the rack). |
| const idToModule = new Map<string, protos.ISynthModule>(); |
| for (const m of modules) idToModule.set(m.id ?? '', m); |
| |
| for (const [d, ids] of byDepth.entries()) { |
| // Sort for deterministic ordering within a column. |
| ids.sort(); |
| for (let i = 0; i < ids.length; i++) { |
| const mod = idToModule.get(ids[i]); |
| if (!mod) continue; |
| const ui = parseModuleUiState(mod.uiStateJson); |
| const posX = X_OFFSET + d * COL_SPACING; |
| const posY = Y_OFFSET + i * ROW_SPACING; |
| if (ids[i] === rootId) { |
| // Instrument root: persist the OUTPUT position separately from |
| // its rack position. |
| ui.outX = posX; |
| ui.outY = posY; |
| } else { |
| ui.x = posX; |
| ui.y = posY; |
| } |
| mod.uiStateJson = JSON.stringify(ui); |
| } |
| } |
| } |
| |
| // --- Instrument editor mutations --- |
| |
| /** Add a new module inside an instrument's namespace. */ |
| export function addModuleToInstrument( |
| patch: protos.ISynthPatch, |
| instrumentId: string, |
| mod: protos.ISynthModule, |
| localName: string, |
| x: number, |
| y: number, |
| ): string { |
| if (!patch.modules) patch.modules = []; |
| const id = `${instrumentId}__${localName}_${uniqueSuffix()}`; |
| const cloned: protos.ISynthModule = protos.SynthModule.create(mod); |
| cloned.id = id; |
| const ui = ModuleUiStateSchema.parse({ |
| nodeKind: 'instrument_internal', |
| x, |
| y, |
| }); |
| cloned.uiStateJson = JSON.stringify(ui); |
| patch.modules.push(cloned); |
| return id; |
| } |
| |
| /** Add a wire. Does nothing if an identical wire already exists. */ |
| export function addWire( |
| patch: protos.ISynthPatch, |
| wire: protos.ISynthWire, |
| ): void { |
| if (!patch.wires) patch.wires = []; |
| const exists = patch.wires.some( |
| (w) => |
| w.fromModule === wire.fromModule && |
| w.fromPort === wire.fromPort && |
| w.toModule === wire.toModule && |
| w.toPort === wire.toPort, |
| ); |
| if (exists) return; |
| patch.wires.push(wire); |
| } |
| |
| /** Remove a wire at index `idx` in patch.wires. */ |
| export function removeWireAt( |
| patch: protos.ISynthPatch, |
| idx: number, |
| ): void { |
| if (!patch.wires || idx < 0 || idx >= patch.wires.length) return; |
| patch.wires.splice(idx, 1); |
| } |