From 7c4aea672951d01f644f11908b896402ad39b85f Mon Sep 17 00:00:00 2001 From: John Durrant Date: Thu, 29 Oct 2026 13:34:04 +0000 Subject: [PATCH 1/3] Define a synth's signal routing and work out its live path, with Model D's (t-141) Routing types in the contract, a pure routingState() that lights the path the patch takes, routingProblems() to hold a routing to its definition, and Model D's routing projected by hand from its spec. Co-Authored-By: Claude Opus 5.5 --- lib/app/synths/contract.ts | 80 +++++ lib/app/synths/lib/routing.ts | 205 +++++++++++ lib/app/synths/routing/index.ts | 14 + lib/app/synths/routing/model-d.ts | 331 ++++++++++++++++++ tests/unit/lib/app/synths/lib/routing.test.ts | 147 ++++++++ tests/unit/lib/app/synths/routing.test.ts | 161 +++++++++ 6 files changed, 938 insertions(+) create mode 100644 lib/app/synths/lib/routing.ts create mode 100644 lib/app/synths/routing/index.ts create mode 100644 lib/app/synths/routing/model-d.ts create mode 100644 tests/unit/lib/app/synths/lib/routing.test.ts create mode 100644 tests/unit/lib/app/synths/routing.test.ts diff --git a/lib/app/synths/contract.ts b/lib/app/synths/contract.ts index cd674fbb4..8699d28a2 100644 --- a/lib/app/synths/contract.ts +++ b/lib/app/synths/contract.ts @@ -1226,6 +1226,86 @@ export interface BankMaker { innovations: string[]; } +// ─── Signal routing ────────────────────────────────────────────────────────── + +/** + * How a synth's parts are wired behind the panel: its blocks (oscillators, mixer, filter, …) and the + * edges between them, each with the rule that says when it carries signal. The routing view draws it, + * and `routingState()` (`lib/routing.ts`) lights the path the current patch takes. + * + * Kept beside the definition, not on it (`lib/app/synths/routing/.ts`), and projected by hand from the + * synth's research spec (`.context/app/synth-data//.synth.json`): block and edge ids are the + * spec's module and connection ids, and every condition and jack is in the definition's ids. A test holds + * each routing to both (`tests/unit/lib/app/synths/routing.test.ts`). + */ +export interface SynthRouting { + /** The definition this routing is for. */ + synth: string; + blocks: RoutingBlock[]; + edges: RoutingEdge[]; + /** The block every live path ends at: the synth's output. */ + out: string; + /** Which block each of the definition's jacks belongs to. Every jack is listed, so any cable can be drawn. */ + jacks: Record; + /** Spec modules and connections the routing leaves out, by spec id, with the reason. */ + omitted?: Record; +} + +/** One part of the synth, drawn as a box. */ +export interface RoutingBlock { + /** The spec's module id (`osc1`, `filter`). */ + id: string; + name: string; + /** Picks the colour, from `MODULES`, so the diagram matches the panel's lesson highlights. */ + module: SynthModule; + /** One or two plain sentences on what the part does. */ + description: string; + /** What the hover card lists: `{ label: 'Slope', value: '24 dB per octave' }`. Facts from the spec only. */ + specs?: { label: string; value: string }[]; +} + +/** What an edge carries. */ +export type RoutingSignal = 'audio' | 'cv' | 'gate'; + +/** + * When an edge carries signal, in the definition's control ids. `{ id, eq }` and `{ id, in }` read as for + * `ShowCondition`; `all` and `any` combine. + */ +export type RoutingWhen = + | { id: string; eq: ControlValue } + | { id: string; in: ControlValue[] } + | { all: RoutingWhen[] } + | { any: RoutingWhen[] }; + +/** + * A control that scales an edge, which carries nothing while the level is at zero: + * - a continuous control's id, at zero at its `min`; + * - `{ id, off }`, at zero at the value `off` (MOD MIX fully right silences source A); + * - `{ sum }`, at zero only while every part is (MOD DEPTH plus the mod wheel). + * The id `@wheel` is the mod wheel. + */ +export type RoutingLevel = string | { id: string; off: ControlValue } | { sum: RoutingLevel[] }; + +/** One connection between two blocks, inside the synth. Cables are not edges: they come from the patch. */ +export interface RoutingEdge { + /** The spec's connection id (`c40`). */ + id: string; + from: string; + to: string; + signal: RoutingSignal; + /** What it does where it arrives, when the block's name does not say (`pitch`, `cutoff`). */ + label?: string; + /** A switch it depends on; absent = fixed. */ + when?: RoutingWhen; + /** Controls that scale it; all must be above zero. */ + levels?: RoutingLevel[]; + /** + * A normal: the input jack whose cable breaks this edge. A cable into that jack takes the edge's place + * and needs its `when` and `levels` to be heard. The jack must not be `add` (an `add` jack sums). + */ + breaks?: string; +} + // ─── EngineParams ──────────────────────────────────────────────────────────── /** Gain per waveform; omitted or 0 = silent. */ diff --git a/lib/app/synths/lib/routing.ts b/lib/app/synths/lib/routing.ts new file mode 100644 index 000000000..3a1931282 --- /dev/null +++ b/lib/app/synths/lib/routing.ts @@ -0,0 +1,205 @@ +// Signal routing: which parts of a synth the current patch actually uses, read from its routing (`SynthRouting`). +// Structural, not heard: an edge is live while its switches are set, its levels are above zero and no cable has broken +// it, and a block is live while some live path runs from it to the output. The sound map (`soundmap.ts`) answers the +// other question, whether a control changes what is heard, by probing the real voice; the two can disagree (a filter +// closed to nothing is still on the path), and the view says which it shows. +// +// Pure, like `toEngine()`: same definition, routing, values and cables in, same answer out. +import { controlMap, jackMap } from '@/lib/app/synths/lib/patch'; +import type { + ControlValues, + RoutingEdge, + RoutingLevel, + RoutingWhen, + SynthDef, + SynthRouting, +} from '@/lib/app/synths/contract'; + +/** The mod wheel, in a `RoutingLevel` (the sound map's `WHEEL` uses the same name). */ +export const ROUTING_WHEEL = '@wheel'; + +/** A cable as the panel holds it: output jack to input jack. */ +export interface RoutingCable { + from: string; + to: string; +} + +/** A cable between two blocks, and whether it is on a live path. */ +export interface RoutingCableState { + from: string; + to: string; + fromBlock: string; + toBlock: string; + live: boolean; +} + +export interface RoutingState { + /** Every block, true while it is on a live path to the output. */ + blocks: Record; + /** Every edge, true while it carries signal to a live block. */ + edges: Record; + /** Every cable between two of the routing's jacks, in patch order. */ + cables: RoutingCableState[]; +} + +export interface RoutingInput { + values: ControlValues; + cables: RoutingCable[]; + /** Mod wheel position, 0..1. */ + wheel?: number; +} + +function holds(when: RoutingWhen, values: ControlValues): boolean { + if ('all' in when) return when.all.every((w) => holds(w, values)); + if ('any' in when) return when.any.some((w) => holds(w, values)); + if ('in' in when) return when.in.includes(values[when.id]); + return values[when.id] === when.eq; +} + +function aboveZero(def: SynthDef, level: RoutingLevel, input: RoutingInput): boolean { + if (typeof level !== 'string') { + if ('sum' in level) return level.sum.some((l) => aboveZero(def, l, input)); + return input.values[level.id] !== level.off; + } + if (level === ROUTING_WHEEL) return (input.wheel ?? 0) > 0; + const c = controlMap(def)[level]; + const v = input.values[level]; + return c?.kind === 'cont' && typeof v === 'number' ? v > c.min : false; +} + +const enabled = (def: SynthDef, edge: RoutingEdge, input: RoutingInput): boolean => + (!edge.when || holds(edge.when, input.values)) && + (edge.levels ?? []).every((l) => aboveZero(def, l, input)); + +/** + * Which blocks, edges and cables of `routing` the patch uses. A cable into a jack that breaks a normal takes the + * normal's place, and is heard only under the normal's switches and levels. + */ +export function routingState( + def: SynthDef, + routing: SynthRouting, + input: RoutingInput +): RoutingState { + const patched = new Set(input.cables.flatMap((c) => [c.from, c.to])); + const normalAt = new Map(routing.edges.filter((e) => e.breaks).map((e) => [e.breaks, e])); + + const edgeOn = new Map( + routing.edges.map((e) => [e.id, enabled(def, e, input) && !(e.breaks && patched.has(e.breaks))]) + ); + const cables = input.cables.flatMap((c) => { + const fromBlock = routing.jacks[c.from]; + const toBlock = routing.jacks[c.to]; + if (!fromBlock || !toBlock) return []; + const normal = normalAt.get(c.to); + return [{ ...c, fromBlock, toBlock, on: !normal || enabled(def, normal, input) }]; + }); + + // Walk back from the output over everything that carries signal. + const into = new Map(); + const link = (from: string, to: string) => into.set(to, [...(into.get(to) ?? []), from]); + for (const e of routing.edges) if (edgeOn.get(e.id)) link(e.from, e.to); + for (const c of cables) if (c.on) link(c.fromBlock, c.toBlock); + const live = new Set([routing.out]); + const queue = [routing.out]; + for (let b = queue.pop(); b !== undefined; b = queue.pop()) + for (const from of into.get(b) ?? []) + if (!live.has(from)) { + live.add(from); + queue.push(from); + } + + return { + blocks: Object.fromEntries(routing.blocks.map((b) => [b.id, live.has(b.id)])), + edges: Object.fromEntries( + routing.edges.map((e) => [e.id, !!edgeOn.get(e.id) && live.has(e.to)]) + ), + cables: cables.map(({ on, ...c }) => ({ ...c, live: on && live.has(c.toBlock) })), + }; +} + +// ─── Validation ────────────────────────────────────────────────────────────── + +/** Everything wrong with a routing against its definition, as plain sentences; empty when it is sound. */ +export function routingProblems(def: SynthDef, routing: SynthRouting): string[] { + const problems: string[] = []; + const controls = controlMap(def); + const jacks = jackMap(def); + const blocks = new Set(routing.blocks.map((b) => b.id)); + const seen = new Set(); + const once = (kind: string, id: string) => { + if (seen.has(`${kind}:${id}`)) problems.push(`${kind} ${id} is listed twice`); + seen.add(`${kind}:${id}`); + }; + const block = (where: string, id: string) => { + if (!blocks.has(id)) problems.push(`${where}: no block ${id}`); + }; + + const checkWhen = (where: string, w: RoutingWhen): void => { + if ('all' in w) return w.all.forEach((x) => checkWhen(where, x)); + if ('any' in w) return w.any.forEach((x) => checkWhen(where, x)); + const c = controls[w.id]; + if (!c) return void problems.push(`${where}: no control ${w.id}`); + const legal = + c.kind === 'enum' + ? (x: unknown) => c.options.some((o) => o.v === x) + : c.kind === 'bool' + ? (x: unknown) => typeof x === 'boolean' + : (x: unknown) => typeof x === 'number' && x >= c.min && x <= c.max; + for (const x of 'in' in w ? w.in : [w.eq]) + if (!legal(x)) problems.push(`${where}: ${JSON.stringify(x)} is not a value of ${w.id}`); + }; + const checkLevel = (where: string, l: RoutingLevel): void => { + if (typeof l !== 'string') { + if ('sum' in l) return l.sum.forEach((x) => checkLevel(where, x)); + return checkWhen(where, { id: l.id, eq: l.off }); + } + if (l === ROUTING_WHEEL) return; + const c = controls[l]; + if (!c) problems.push(`${where}: no control ${l}`); + else if (c.kind !== 'cont') + problems.push(`${where}: ${l} is not continuous; give it an off value`); + }; + + if (routing.synth !== def.id) problems.push(`routing is for ${routing.synth}, not ${def.id}`); + routing.blocks.forEach((b) => once('block', b.id)); + block('out', routing.out); + for (const e of routing.edges) { + const where = `edge ${e.id}`; + once('edge', e.id); + block(where, e.from); + block(where, e.to); + if (e.when) checkWhen(where, e.when); + e.levels?.forEach((l) => checkLevel(where, l)); + if (e.breaks) { + const j = jacks[e.breaks]; + if (!j) problems.push(`${where}: no jack ${e.breaks}`); + else if (j.dir !== 'in') problems.push(`${where}: ${e.breaks} is not an input`); + else if (j.add) + problems.push(`${where}: ${e.breaks} sums with its normal, so cannot break it`); + else if (routing.jacks[e.breaks] !== e.to) + problems.push(`${where}: ${e.breaks} does not feed ${e.to}`); + } + } + for (const [id, b] of Object.entries(routing.jacks)) { + if (!jacks[id]) problems.push(`jacks: no jack ${id}`); + block(`jack ${id}`, b); + } + for (const j of def.jacks) + if (!(j.id in routing.jacks)) problems.push(`jack ${j.id} belongs to no block`); + + // Every block has a way to the output, with every switch and cable allowed. + const into = new Map(); + for (const e of routing.edges) into.set(e.to, [...(into.get(e.to) ?? []), e.from]); + const reach = new Set([routing.out]); + const queue = [routing.out]; + for (let b = queue.pop(); b !== undefined; b = queue.pop()) + for (const from of into.get(b) ?? []) + if (!reach.has(from)) { + reach.add(from); + queue.push(from); + } + for (const b of routing.blocks) + if (!reach.has(b.id)) problems.push(`block ${b.id} has no way to ${routing.out}`); + + return problems; +} diff --git a/lib/app/synths/routing/index.ts b/lib/app/synths/routing/index.ts new file mode 100644 index 000000000..56895ad2b --- /dev/null +++ b/lib/app/synths/routing/index.ts @@ -0,0 +1,14 @@ +/** + * The routing registry: every synth's signal routing, by synth id. A synth without one has no routing view yet. + * + * A routing joins by adding its module under `routing/` and one line below (`.context/app/synths.md`). + * `tests/unit/lib/app/synths/routing.test.ts` checks every one listed here against its definition and its spec. + */ +import type { SynthRouting } from '@/lib/app/synths/contract'; +import { routing as modelD } from '@/lib/app/synths/routing/model-d'; + +export const SYNTH_ROUTINGS: SynthRouting[] = [modelD]; + +const BY_ID = new Map(SYNTH_ROUTINGS.map((r) => [r.synth, r])); + +export const getSynthRouting = (id: string): SynthRouting | undefined => BY_ID.get(id); diff --git a/lib/app/synths/routing/model-d.ts b/lib/app/synths/routing/model-d.ts new file mode 100644 index 000000000..bf92eaef5 --- /dev/null +++ b/lib/app/synths/routing/model-d.ts @@ -0,0 +1,331 @@ +/** + * Model D signal routing, projected from its spec (`.context/app/synth-data/model-d/model-d.synth.json`, 0.1.0): + * block and edge ids are the spec's module and connection ids; switches, levels and jacks are the definition's + * (`defs/model-d.ts`). + * + * Where the definition says more than the spec, it decides: the mod bus's crossfade (MOD MIX fully right silences + * source A, fully left source B) and its depth (MOD DEPTH plus the mod wheel) are read from `toEngine()`. + */ +import type { RoutingLevel, SynthRouting } from '@/lib/app/synths/contract'; +import { ROUTING_WHEEL } from '@/lib/app/synths/lib/routing'; + +const SOURCE_A: RoutingLevel[] = [{ id: 'mod.mix', off: 10 }]; +const SOURCE_B: RoutingLevel[] = ['mod.mix']; +const DEPTH: RoutingLevel[] = [{ sum: ['mod.depth', ROUTING_WHEEL] }]; +const OSC_SPECS = [ + { label: 'Frequency', value: '0.1 Hz to 20 kHz' }, + { label: 'Ranges', value: "LO, 32', 16', 8', 4', 2'" }, +]; + +export const routing: SynthRouting = { + synth: 'model-d', + out: 'output', + blocks: [ + { + id: 'midi', + name: 'MIDI / USB', + module: 'util', + description: + 'Turns incoming MIDI into the keyboard’s pitch, gate, pitch bend and mod wheel voltages.', + }, + { + id: 'pitch_bus', + name: 'Master pitch', + module: 'glide', + description: + 'Adds up keyboard pitch, pitch bend, the OSC 1V/OCT input and TUNE, applies GLIDE, and sets the oscillators’ pitch.', + }, + { + id: 'mod_bus', + name: 'Modulation mix', + module: 'mod', + description: + 'Crossfades source A (Oscillator 3 or the filter contour) with source B (noise or the LFO), scales the result by MOD DEPTH and the mod wheel, and sends it to oscillator pitch, filter cutoff or both.', + }, + { + id: 'lfo', + name: 'LFO', + module: 'lfo', + description: + 'A dedicated low-frequency oscillator. Both shapes are always on their jacks; the switch picks which one feeds the mod bus.', + specs: [ + { label: 'Rate', value: '0.05 to 200 Hz (300 Hz with CV)' }, + { label: 'Shapes', value: 'Triangle, square' }, + ], + }, + { + id: 'osc1', + name: 'Oscillator 1', + module: 'osc', + description: + 'The reference oscillator: no fine-tune knob, its pitch set by RANGE and the master TUNE.', + specs: [ + ...OSC_SPECS, + { + label: 'Waveforms', + value: 'Triangle, triangle-saw, sawtooth, square, wide pulse, narrow pulse', + }, + ], + }, + { + id: 'osc2', + name: 'Oscillator 2', + module: 'osc', + description: 'An oscillator with a detune knob of about seven semitones either way.', + specs: [ + ...OSC_SPECS, + { + label: 'Waveforms', + value: 'Triangle, triangle-saw, sawtooth, square, wide pulse, narrow pulse', + }, + ], + }, + { + id: 'osc3', + name: 'Oscillator 3', + module: 'osc', + description: + 'An oscillator that doubles as a modulation source. Off keyboard control it runs free, usually at LO as a second LFO.', + specs: [ + ...OSC_SPECS, + { + label: 'Waveforms', + value: 'Triangle, reverse sawtooth, sawtooth, square, wide pulse, narrow pulse', + }, + ], + }, + { + id: 'noise', + name: 'Noise', + module: 'mixer', + description: 'White or pink noise: a sound source in the mixer and source B of the mod bus.', + specs: [{ label: 'Colours', value: 'White, pink' }], + }, + { + id: 'ext_in', + name: 'External input', + module: 'mixer', + description: + 'Audio from outside into the mixer. With nothing plugged into EXT, the main output is fed back here, the loop used for overdrive.', + specs: [{ label: 'Input impedance', value: '1 MΩ' }], + }, + { + id: 'mixer', + name: 'Mixer', + module: 'mixer', + description: 'Five channels, each with a level knob and an on/off switch, into the filter.', + specs: [{ label: 'Channels', value: 'Oscillators 1–3, external input, noise' }], + }, + { + id: 'filter', + name: 'Ladder filter', + module: 'filter', + description: 'A transistor ladder filter with EMPHASIS (resonance), low-pass or high-pass.', + specs: [ + { label: 'Slope', value: '24 dB per octave (4 poles)' }, + { label: 'Modes', value: 'Low-pass, high-pass' }, + ], + }, + { + id: 'filter_contour', + name: 'Filter contour', + module: 'env', + description: + 'An attack, decay, sustain envelope for the cutoff. Release is either immediate or the decay time, set by FILTER DECAY.', + specs: [{ label: 'Output', value: '0 to 4 V' }], + }, + { + id: 'loudness_contour', + name: 'Loudness contour', + module: 'env', + description: + 'An attack, decay, sustain envelope for the amplifier. Release is either immediate or the decay time, set by LOUD DECAY.', + specs: [ + { label: 'Output', value: '0 to 4.6 V' }, + { label: 'Dynamic range', value: '80 dB' }, + ], + }, + { + id: 'vca', + name: 'Amplifier', + module: 'amp', + description: 'Sets the loudness after the filter, from the loudness contour and LOUD CV.', + }, + { + id: 'output', + name: 'Output', + module: 'out', + description: + 'Master volume, the MAIN OUT switch, the headphone amplifier and the A-440 tone.', + }, + ], + edges: [ + { id: 'c01', from: 'midi', to: 'pitch_bus', signal: 'cv', label: 'pitch' }, + { id: 'c02', from: 'midi', to: 'pitch_bus', signal: 'cv', label: 'pitch bend' }, + { id: 'c03', from: 'midi', to: 'mod_bus', signal: 'cv', label: 'mod wheel' }, + { id: 'c04', from: 'midi', to: 'filter_contour', signal: 'gate' }, + { id: 'c05', from: 'midi', to: 'loudness_contour', signal: 'gate' }, + { id: 'c10', from: 'pitch_bus', to: 'osc1', signal: 'cv', label: 'pitch' }, + { id: 'c11', from: 'pitch_bus', to: 'osc2', signal: 'cv', label: 'pitch' }, + { + id: 'c12', + from: 'pitch_bus', + to: 'osc3', + signal: 'cv', + label: 'pitch', + when: { id: 'osc3.kbd', eq: true }, + }, + { + id: 'c20', + from: 'osc3', + to: 'mod_bus', + signal: 'cv', + label: 'source A', + when: { id: 'mod.srcA', eq: 'osc3' }, + levels: SOURCE_A, + }, + { + id: 'c21', + from: 'filter_contour', + to: 'mod_bus', + signal: 'cv', + label: 'source A', + when: { id: 'mod.srcA', eq: 'eg' }, + levels: SOURCE_A, + }, + { + id: 'c22', + from: 'noise', + to: 'mod_bus', + signal: 'cv', + label: 'source B', + when: { id: 'mod.srcB', eq: 'noise' }, + levels: SOURCE_B, + breaks: 'j.modSrc', + }, + { + id: 'c23', + from: 'lfo', + to: 'mod_bus', + signal: 'cv', + label: 'source B', + when: { id: 'mod.srcB', eq: 'lfo' }, + levels: SOURCE_B, + }, + { + id: 'c30', + from: 'mod_bus', + to: 'osc1', + signal: 'cv', + label: 'pitch', + when: { id: 'mod.toOsc', eq: true }, + levels: DEPTH, + }, + { + id: 'c31', + from: 'mod_bus', + to: 'osc2', + signal: 'cv', + label: 'pitch', + when: { id: 'mod.toOsc', eq: true }, + levels: DEPTH, + }, + { + id: 'c32', + from: 'mod_bus', + to: 'osc3', + signal: 'cv', + label: 'pitch', + when: { + all: [ + { id: 'mod.toOsc', eq: true }, + { id: 'osc3.kbd', eq: true }, + ], + }, + levels: DEPTH, + }, + { + id: 'c33', + from: 'mod_bus', + to: 'filter', + signal: 'cv', + label: 'cutoff', + when: { id: 'mod.toFilter', eq: true }, + levels: DEPTH, + }, + ...[1, 2, 3].map((n) => ({ + id: `c4${n - 1}`, + from: `osc${n}`, + to: 'mixer', + signal: 'audio' as const, + when: { id: `mix.osc${n}On`, eq: true }, + levels: [`mix.osc${n}`], + })), + { + id: 'c43', + from: 'noise', + to: 'mixer', + signal: 'audio', + when: { id: 'mix.noiseOn', eq: true }, + levels: ['mix.noise'], + }, + { + id: 'c44', + from: 'ext_in', + to: 'mixer', + signal: 'audio', + when: { id: 'mix.extOn', eq: true }, + levels: ['mix.ext'], + }, + { + id: 'c45', + from: 'output', + to: 'ext_in', + signal: 'audio', + label: 'feedback', + breaks: 'j.ext', + }, + { id: 'c50', from: 'mixer', to: 'filter', signal: 'audio' }, + { + id: 'c51', + from: 'filter_contour', + to: 'filter', + signal: 'cv', + label: 'cutoff', + levels: ['filter.contour'], + }, + { + id: 'c52', + from: 'midi', + to: 'filter', + signal: 'cv', + label: 'keyboard tracking', + when: { + any: [ + { id: 'filter.kbd1', eq: true }, + { id: 'filter.kbd2', eq: true }, + ], + }, + }, + { id: 'c53', from: 'filter', to: 'vca', signal: 'audio' }, + { id: 'c54', from: 'loudness_contour', to: 'vca', signal: 'cv', label: 'loudness' }, + { id: 'c55', from: 'vca', to: 'output', signal: 'audio' }, + ], + jacks: { + 'j.modSrc': 'mod_bus', + 'j.oscCv': 'pitch_bus', + 'j.lfoCv': 'lfo', + 'j.lfoTri': 'lfo', + 'j.lfoSq': 'lfo', + 'j.ext': 'ext_in', + 'j.mix': 'mixer', + 'j.cutCv': 'filter', + 'j.fcGate': 'filter_contour', + 'j.filtCont': 'filter_contour', + 'j.lcGate': 'loudness_contour', + 'j.loudCont': 'loudness_contour', + 'j.loudCv': 'vca', + 'j.main': 'output', + 'j.phones': 'output', + }, +}; diff --git a/tests/unit/lib/app/synths/lib/routing.test.ts b/tests/unit/lib/app/synths/lib/routing.test.ts new file mode 100644 index 000000000..8f648796f --- /dev/null +++ b/tests/unit/lib/app/synths/lib/routing.test.ts @@ -0,0 +1,147 @@ +/** + * The live path: which blocks, edges and cables of a routing the patch uses, on Model D. + * + * Every case has something dark in it, so a `routingState` that lit everything would fail it. + * + * @see lib/app/synths/lib/routing.ts + */ +import { describe, expect, it } from 'vitest'; +import type { ControlValues } from '@/lib/app/synths/contract'; +import modelD from '@/lib/app/synths/defs/model-d'; +import { routingState, type RoutingCable } from '@/lib/app/synths/lib/routing'; +import { routing } from '@/lib/app/synths/routing/model-d'; + +const state = (set: ControlValues = {}, cables: RoutingCable[] = [], wheel = 0) => + routingState(modelD, routing, { values: { ...modelD.init, ...set }, cables, wheel }); + +/** Oscillator 3 and the LFO both feeding the filter through the mod bus, nothing else on it. */ +const MOD_TO_FILTER: ControlValues = { 'mod.toFilter': true, 'mod.depth': 5, 'mod.mix': 5 }; + +describe('routingState on Model D', () => { + it('lights the init patch: Oscillator 1 through the mixer, filter and amplifier', () => { + const s = state(); + expect(modelD.init['mix.osc1On']).toBe(true); + for (const b of ['midi', 'pitch_bus', 'osc1', 'mixer', 'filter', 'vca', 'output']) + expect(s.blocks[b], b).toBe(true); + for (const e of ['c10', 'c40', 'c50', 'c53', 'c55']) expect(s.edges[e], e).toBe(true); + // Switched off in the mixer, and the mod bus going nowhere. + for (const b of ['osc2', 'osc3', 'noise', 'ext_in', 'mod_bus', 'lfo']) + expect(s.blocks[b], b).toBe(false); + expect(s.edges.c41).toBe(false); + }); + + it('darkens an oscillator switched off in the mixer, and the edges that fed it', () => { + const s = state({ 'mix.osc1On': false }); + expect(s.blocks.osc1).toBe(false); + expect(s.edges.c40).toBe(false); + expect(s.edges.c10).toBe(false); + expect(s.blocks.filter).toBe(true); + }); + + it('darkens an oscillator whose mixer level is at zero, though its switch is on', () => { + const s = state({ 'mix.osc2On': true, 'mix.osc2': 0 }); + expect(s.blocks.osc2).toBe(false); + expect(state({ 'mix.osc2On': true, 'mix.osc2': 0.5 }).blocks.osc2).toBe(true); + }); + + it('keeps Oscillator 3 lit as a modulator when it is off in the mixer', () => { + const s = state({ ...MOD_TO_FILTER, 'mod.srcA': 'osc3', 'mix.osc3On': false }); + expect(s.blocks.osc3).toBe(true); + expect(s.edges.c20).toBe(true); + expect(s.edges.c33).toBe(true); + expect(s.edges.c42).toBe(false); + }); + + it('follows MOD MIX: fully right silences source A, fully left source B', () => { + const right = state({ ...MOD_TO_FILTER, 'mod.mix': 10 }); + expect([right.edges.c20, right.edges.c23]).toEqual([false, true]); + expect([right.blocks.osc3, right.blocks.lfo]).toEqual([false, true]); + const left = state({ ...MOD_TO_FILTER, 'mod.mix': 0 }); + expect([left.edges.c20, left.edges.c23]).toEqual([true, false]); + expect(left.blocks.lfo).toBe(false); + }); + + it('lights the mod bus from the mod wheel when MOD DEPTH is at zero', () => { + const off = { ...MOD_TO_FILTER, 'mod.depth': 0 }; + expect(state(off).edges.c33).toBe(false); + expect(state(off).blocks.mod_bus).toBe(false); + expect(state(off, [], 0.5).edges.c33).toBe(true); + expect(state(off, [], 0.5).blocks.mod_bus).toBe(true); + }); + + it('needs every part of an all-condition: modulation reaches Oscillator 3 only under keyboard control', () => { + const on = { ...MOD_TO_FILTER, 'mod.toOsc': true, 'mix.osc3On': true, 'mix.osc3': 5 }; + expect(state({ ...on, 'osc3.kbd': true }).edges.c32).toBe(true); + expect(state({ ...on, 'osc3.kbd': false }).edges.c32).toBe(false); + expect(state({ ...on, 'osc3.kbd': false }).edges.c12).toBe(false); + }); + + it('lights keyboard tracking with either switch', () => { + expect(state().edges.c52).toBe(false); + expect(state({ 'filter.kbd2': true }).edges.c52).toBe(true); + }); + + describe('cables', () => { + const NOISE_B = { ...MOD_TO_FILTER, 'mod.srcB': 'noise' }; + const SQUARE_IN: RoutingCable = { from: 'j.lfoSq', to: 'j.modSrc' }; + + it('lights the noise normal into the mod bus with no cable in MOD SOURCE', () => { + const s = state(NOISE_B); + expect(s.edges.c22).toBe(true); + expect(s.blocks.noise).toBe(true); + }); + + it('breaks the normal with a cable into MOD SOURCE, and lights the cable instead', () => { + const s = state(NOISE_B, [SQUARE_IN]); + expect(s.edges.c22).toBe(false); + expect(s.blocks.noise).toBe(false); + expect(s.blocks.lfo).toBe(true); + expect(s.cables).toEqual([ + { from: 'j.lfoSq', to: 'j.modSrc', fromBlock: 'lfo', toBlock: 'mod_bus', live: true }, + ]); + }); + + it('holds a cable that replaces a normal to the normal’s switch', () => { + const s = state({ ...NOISE_B, 'mod.srcB': 'lfo', 'mod.srcA': 'eg' }, [SQUARE_IN]); + expect(s.cables[0].live).toBe(false); + // The LFO is still lit, through its own switch position. + expect(s.edges.c23).toBe(true); + }); + + it('breaks the feedback normal with a cable into EXT', () => { + const ext = { 'mix.extOn': true, 'mix.ext': 4 }; + expect(state(ext).edges.c45).toBe(true); + const s = state(ext, [{ from: 'j.filtCont', to: 'j.ext' }]); + expect(s.edges.c45).toBe(false); + expect(s.cables[0].live).toBe(true); + expect(s.blocks.filter_contour).toBe(true); + }); + + it('leaves a cable dark when nothing after it reaches the output', () => { + const s = state({}, [{ from: 'j.lfoTri', to: 'j.ext' }]); + expect(s.blocks.ext_in).toBe(false); + expect(s.cables[0]).toMatchObject({ fromBlock: 'lfo', toBlock: 'ext_in', live: false }); + expect(s.blocks.lfo).toBe(false); + }); + + it('lights a summing cable and the block it comes from', () => { + const s = state({}, [{ from: 'j.lfoTri', to: 'j.cutCv' }]); + expect(s.cables[0].live).toBe(true); + expect(s.blocks.lfo).toBe(true); + }); + + it('skips a cable to a jack the routing does not know', () => { + expect(state({}, [{ from: 'j.lfoTri', to: 'j.nope' }]).cables).toEqual([]); + }); + }); + + it('is pure: the same patch gives the same answer, and the input is not changed', () => { + const values = { ...modelD.init, ...MOD_TO_FILTER }; + const cables = [{ from: 'j.lfoSq', to: 'j.modSrc' }]; + const frozen = JSON.stringify({ values, cables }); + const a = routingState(modelD, routing, { values, cables }); + const b = routingState(modelD, routing, { values, cables }); + expect(b).toEqual(a); + expect(JSON.stringify({ values, cables })).toBe(frozen); + }); +}); diff --git a/tests/unit/lib/app/synths/routing.test.ts b/tests/unit/lib/app/synths/routing.test.ts new file mode 100644 index 000000000..f22ee07e6 --- /dev/null +++ b/tests/unit/lib/app/synths/routing.test.ts @@ -0,0 +1,161 @@ +/** + * Every registered routing against its definition and its spec. + * + * Against the definition (`routingProblems`): every control, value and jack it names exists, a normal is broken only + * by an input that does not sum, every jack belongs to a block, and every block has a way to the output. A renamed + * control or jack therefore fails here rather than leaving part of the diagram dark for good. + * + * Against the spec it was projected from (`.context/app/synth-data//.synth.json`): every module and + * connection the spec lists is drawn, or left out with a reason. + * + * @see lib/app/synths/lib/routing.ts + */ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { z } from 'zod'; +import type { SynthRouting } from '@/lib/app/synths/contract'; +import { getSynthDef } from '@/lib/app/synths/defs'; +import { routingProblems } from '@/lib/app/synths/lib/routing'; +import { getSynthRouting, SYNTH_ROUTINGS } from '@/lib/app/synths/routing'; + +const Spec = z.object({ + modules: z.array(z.object({ id: z.string() })), + connections: z.array(z.object({ id: z.string() })).default([]), +}); +const spec = (id: string) => + Spec.parse( + JSON.parse( + readFileSync( + join(process.cwd(), '.context', 'app', 'synth-data', id, `${id}.synth.json`), + 'utf8' + ) + ) + ); + +const defOf = (id: string) => { + const def = getSynthDef(id); + if (!def) throw new Error(`${id} is not a registered synth`); + return def; +}; + +it('has a routing for Model D, and one routing per synth', () => { + const ids = SYNTH_ROUTINGS.map((r) => r.synth); + expect(ids).toContain('model-d'); + expect(new Set(ids).size).toBe(ids.length); + expect(getSynthRouting('model-d')?.synth).toBe('model-d'); + expect(getSynthRouting('no-such-synth')).toBeUndefined(); +}); + +describe.each(SYNTH_ROUTINGS.map((r) => [r.synth, r] as const))('%s', (id, routing) => { + it('names only what its definition has, and reaches the output from every block', () => { + expect(routingProblems(defOf(id), routing)).toEqual([]); + }); + + it('draws every module and connection of its spec, or says why not', () => { + const { modules, connections } = spec(id); + expect(modules.length).toBeGreaterThan(0); + const drawn = new Set([ + ...routing.blocks.map((b) => b.id), + ...routing.edges.map((e) => e.id), + ...Object.keys(routing.omitted ?? {}), + ]); + const missing = [...modules, ...connections].map((x) => x.id).filter((x) => !drawn.has(x)); + expect(missing).toEqual([]); + }); +}); + +describe('routingProblems', () => { + const def = defOf('model-d'); + const base = getSynthRouting('model-d'); + if (!base) throw new Error('model-d has a routing'); + const edit = (change: (r: SynthRouting) => void): string[] => { + const r = structuredClone(base); + change(r); + return routingProblems(def, r); + }; + const edge = (r: SynthRouting, id: string) => { + const e = r.edges.find((x) => x.id === id); + if (!e) throw new Error(`no edge ${id}`); + return e; + }; + + it('refuses a switch the definition does not have', () => { + expect( + edit((r) => { + edge(r, 'c40').when = { id: 'mix.osc1Onn', eq: true }; + }) + ).toEqual(['edge c40: no control mix.osc1Onn']); + }); + + it('refuses a value the switch cannot take', () => { + expect( + edit((r) => { + edge(r, 'c20').when = { id: 'mod.srcA', eq: 'lfo' }; + }) + ).toEqual(['edge c20: "lfo" is not a value of mod.srcA']); + }); + + it('refuses a level that is not a control, or not continuous without an off value', () => { + expect( + edit((r) => { + edge(r, 'c40').levels = ['mix.osc9', 'mix.osc1On']; + }) + ).toEqual([ + 'edge c40: no control mix.osc9', + 'edge c40: mix.osc1On is not continuous; give it an off value', + ]); + }); + + it('refuses a normal broken by a jack that sums, or by no jack at all', () => { + expect( + edit((r) => { + edge(r, 'c22').breaks = 'j.cutCv'; + edge(r, 'c45').breaks = 'j.nope'; + }) + ).toEqual([ + 'edge c22: j.cutCv sums with its normal, so cannot break it', + 'edge c45: no jack j.nope', + ]); + }); + + it('refuses a jack id the definition does not have, and a definition jack left out', () => { + expect( + edit((r) => { + r.jacks['j.modSource'] = r.jacks['j.modSrc']; + delete r.jacks['j.modSrc']; + }) + ).toEqual([ + 'edge c22: j.modSrc does not feed mod_bus', + 'jacks: no jack j.modSource', + 'jack j.modSrc belongs to no block', + ]); + }); + + it('refuses an edge to a block that does not exist, and a block with no way out', () => { + expect( + edit((r) => { + edge(r, 'c55').to = 'outptu'; + }) + ).toEqual([ + 'edge c55: no block outptu', + // With the amplifier cut off from the output, everything behind it is too. + ...[ + 'midi', + 'pitch_bus', + 'mod_bus', + 'lfo', + 'osc1', + 'osc2', + 'osc3', + 'noise', + 'ext_in', + 'mixer', + 'filter', + 'filter_contour', + 'loudness_contour', + 'vca', + ].map((b) => `block ${b} has no way to output`), + ]); + }); +}); From 98fdab90b8a1cb78882d3571a2c9e50db33af358 Mon Sep 17 00:00:00 2001 From: John Durrant Date: Thu, 29 Oct 2026 13:35:13 +0000 Subject: [PATCH 2/3] Document signal routing, and add it to the new-synth recipe (t-141) Co-Authored-By: Claude Opus 5.5 --- .context/app/adding-a-synth.md | 7 +++++++ .context/app/synths.md | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/.context/app/adding-a-synth.md b/.context/app/adding-a-synth.md index 263a1e78b..fbc1e604b 100644 --- a/.context/app/adding-a-synth.md +++ b/.context/app/adding-a-synth.md @@ -37,6 +37,13 @@ them. `goldens.test.ts` then holds the definition to them. Write `tests/unit/lib/app/synths/defs/.test.ts` for the engine params at init and for each switch (the B1's mirrors `model-d.test.ts`). +### Routing + +Write `lib/app/synths/routing/.ts` from the spec's `modules` and `connections`, in the definition's control and +jack ids, and add it to `SYNTH_ROUTINGS` (`routing/index.ts`). Start from `routing/model-d.ts`; the rules are in +[`synths.md`](./synths.md#signal-routing). `routing.test.ts` fails until every id it names exists in the definition +and every spec module and connection is drawn or `omitted` with a reason. + ## 2. Brand tags As for a port: every maker's mark in `decor` gets `brand: true` (the B1's DONNER logo and the diff --git a/.context/app/synths.md b/.context/app/synths.md index 777dbe774..d94321813 100644 --- a/.context/app/synths.md +++ b/.context/app/synths.md @@ -10,6 +10,7 @@ from the definition. | The shapes (source of truth) | `lib/app/synths/contract.ts` | | The definitions | `lib/app/synths/defs/.ts` (ported in m1 and p1–p5) | | Sound validator (unknown controls, ranges, bad cables) | `lib/app/synths/validate.ts` | +| Signal routing (the routing view's diagram) | `lib/app/synths/routing/.ts`, `lib/routing.ts` | | The sound engine, worklet and probe worker | `lib/app/synths/audio/`, built to `public/worklets/` | | Shared library (layout, explainers, sound map, tour) | `lib/app/synths/lib/` | | The check: every sound against the baseline | `npm run check:synths` (`.context/app/check/README.md`) | @@ -200,6 +201,41 @@ functions are how a panel depth knob scales whatever is patched into (or normall jack. **Routes** are panel routings: they sum and never break a normal; `by` multiplies a route's depth by a second signal. +## Signal routing + +How the parts are wired behind the panel, for the routing view: one **`SynthRouting`** per synth in +`lib/app/synths/routing/.ts`, registered in `routing/index.ts` (`SYNTH_ROUTINGS`). It sits beside the +definition, not on it. A synth with no routing has no routing view. + +- **Blocks** are the parts (`osc1`, `mixer`, `filter`), each with a `module` for its colour, a `description` + and the `specs` its hover card lists. **Edges** join them, each with its `signal` (`audio`, `cv`, `gate`) and + the rule for when it carries signal: `when` (switches, in the definition's control ids), `levels` (controls + that scale it) and `breaks` (the input jack whose cable breaks this normal). An edge with none is fixed. + `jacks` puts every jack of the definition in a block, so any cable can be drawn. +- **Projected by hand from the spec.** Block and edge ids are the spec's module and connection ids + (`.context/app/synth-data//.synth.json`); everything they depend on is in the definition's ids. Hand-written + rather than generated: the spec and the definition share no ids, and each edge takes judgement (which control + is the spec's `mixer.osc1_on`, whether a jack sums or breaks its normal, a crossfade the spec only describes), + so a generated module would be edited afterwards and could never be regenerated. The test below gives the + drift check a script would have. +- **`routingState(def, routing, { values, cables, wheel })`** (`lib/routing.ts`) says which blocks, edges and + cables are live. Pure, like `toEngine()`. An edge is enabled while its `when` holds, every level is above zero + and no cable has broken it; a block is live while an enabled edge or cable leads from it to `out`; an edge or + cable is live while it is enabled and its block is. A cable into a jack that breaks a normal takes the + normal's place, and needs the normal's `when` and `levels` to be heard (MOD SOURCE is ignored with the switch + on LFO). `@wheel` (`ROUTING_WHEEL`) in a level is the mod wheel. +- **Structural, not heard.** A level at zero is judged from the panel: a continuous control at its `min`, or + the `off` value given. The sound map answers a different question by probing the voice, asynchronously and + only in the browser, so the routing cannot use it and stay pure. The two can disagree: a filter closed to + nothing is still on the path. Where the definition says more than the spec (Model D's MOD MIX crossfade), the + routing follows the definition. +- **`tests/unit/lib/app/synths/routing.test.ts`** holds every registered routing to its definition + (`routingProblems`: every control, value and jack exists, a normal is broken only by a jack that is not + `add`, every jack has a block, every block has a way to the output) and to its spec (every module and + connection is drawn, or listed in `omitted` with the reason). + +Model D's is the worked example. + ## Sounds A `Preset` is built step by step: each step's `set` and `cables` apply cumulatively on top of From a94ec207923975a3cee31708105988862cf958bb Mon Sep 17 00:00:00 2001 From: John Durrant Date: Thu, 29 Oct 2026 15:30:25 +0000 Subject: [PATCH 3/3] Light a mixer or the mod bus only while something live feeds it (t-141) A block was live whenever a live path ran from it to the output, whatever reached it, so Model D's mod bus and its lines to the filter stayed lit when MOD MIX left both sources under the engine's floor, and the mixer stayed lit with every channel off. A block marked `passes` (it makes no signal of its own) is now live only while a live edge or cable from a block with signal feeds it, and routingProblems refuses one that nothing could feed. Also shares WHEEL and the show/when condition test from patch.ts, reads cable jack ids through a null-prototype map, and covers the validation's remaining refusals. Co-Authored-By: Claude Opus 5.5 --- .context/app/synths.md | 26 ++- lib/app/synths/contract.ts | 22 +- lib/app/synths/lib/patch.ts | 10 +- lib/app/synths/lib/routing.ts | 190 ++++++++++++------ lib/app/synths/lib/soundmap.ts | 4 +- lib/app/synths/routing/model-d.ts | 16 +- tests/unit/lib/app/synths/lib/routing.test.ts | 58 +++++- tests/unit/lib/app/synths/routing.test.ts | 39 ++++ 8 files changed, 275 insertions(+), 90 deletions(-) diff --git a/.context/app/synths.md b/.context/app/synths.md index d94321813..8bd46b73c 100644 --- a/.context/app/synths.md +++ b/.context/app/synths.md @@ -208,7 +208,8 @@ How the parts are wired behind the panel, for the routing view: one **`SynthRout definition, not on it. A synth with no routing has no routing view. - **Blocks** are the parts (`osc1`, `mixer`, `filter`), each with a `module` for its colour, a `description` - and the `specs` its hover card lists. **Edges** join them, each with its `signal` (`audio`, `cv`, `gate`) and + and the `specs` its hover card lists; `passes: true` marks one that makes no signal of its own (a mixer, the + mod bus). **Edges** join them, each with its `signal` (`audio`, `cv`, `gate`) and the rule for when it carries signal: `when` (switches, in the definition's control ids), `levels` (controls that scale it) and `breaks` (the input jack whose cable breaks this normal). An edge with none is fixed. `jacks` puts every jack of the definition in a block, so any cable can be drawn. @@ -219,19 +220,28 @@ definition, not on it. A synth with no routing has no routing view. so a generated module would be edited afterwards and could never be regenerated. The test below gives the drift check a script would have. - **`routingState(def, routing, { values, cables, wheel })`** (`lib/routing.ts`) says which blocks, edges and - cables are live. Pure, like `toEngine()`. An edge is enabled while its `when` holds, every level is above zero - and no cable has broken it; a block is live while an enabled edge or cable leads from it to `out`; an edge or - cable is live while it is enabled and its block is. A cable into a jack that breaks a normal takes the + cables are live. Pure, like `toEngine()`. An edge is enabled while its `when` holds, its levels multiply to + more than `ROUTING_FLOOR` (0.0005, the floor below which `toEngine()` drops a mod route) and no cable has + broken it; a block has signal if it makes its own, or if it `passes` and an enabled edge or cable from a block + with signal feeds it; a block is live while it has signal and an enabled edge or cable leads from it to `out` + through blocks with signal; an edge or cable is live while it is enabled, its source has signal and its + target is live. So Model D's mod bus goes dark, lines out and all, when MOD MIX leaves each source under the + floor, and the mixer does with every channel off. A cable into a jack that breaks a normal takes the normal's place, and needs the normal's `when` and `levels` to be heard (MOD SOURCE is ignored with the switch - on LFO). `@wheel` (`ROUTING_WHEEL`) in a level is the mod wheel. -- **Structural, not heard.** A level at zero is judged from the panel: a continuous control at its `min`, or - the `off` value given. The sound map answers a different question by probing the voice, asynchronously and + on LFO). `@wheel` (`WHEEL`, from `lib/patch.ts`) in a level is the mod wheel. +- **Levels are amounts, 0 to 1.** A continuous control's id runs from its `min` to its `max`; `{ id, off }` is 0 + at `off` and rises away from it (a switch is 0 at `off`, 1 elsewhere); `{ id, pow }` curves it as the engine + does (MOD DEPTH squared); `{ sum }` adds. A missing value is 0, as `toEngine()` hears it. Give a level the + engine's whole product where it drops a route below a floor: Model D's source edges carry their MOD MIX share + times the depth, as the engine's `(1 - m) * depth > 0.0005` does. +- **Structural, not heard.** A level at zero is judged from the panel, not from the voice. The sound map answers a different question by probing the voice, asynchronously and only in the browser, so the routing cannot use it and stay pure. The two can disagree: a filter closed to nothing is still on the path. Where the definition says more than the spec (Model D's MOD MIX crossfade), the routing follows the definition. - **`tests/unit/lib/app/synths/routing.test.ts`** holds every registered routing to its definition (`routingProblems`: every control, value and jack exists, a normal is broken only by a jack that is not - `add`, every jack has a block, every block has a way to the output) and to its spec (every module and + `add` and by one edge at most, every jack has a block, every block has a way to the output through edges or + some cable, every `passes` block has an edge or input jack into it) and to its spec (every module and connection is drawn, or listed in `omitted` with the reason). Model D's is the worked example. diff --git a/lib/app/synths/contract.ts b/lib/app/synths/contract.ts index 8699d28a2..4364db164 100644 --- a/lib/app/synths/contract.ts +++ b/lib/app/synths/contract.ts @@ -1262,6 +1262,11 @@ export interface RoutingBlock { description: string; /** What the hover card lists: `{ label: 'Slope', value: '24 dB per octave' }`. Facts from the spec only. */ specs?: { label: string; value: string }[]; + /** + * Makes no signal of its own, only passes on what reaches it (a mixer, the mod bus): live only while a live + * edge or cable feeds it. Absent = it makes signal (an oscillator, a contour), and is live on any path out. + */ + passes?: boolean; } /** What an edge carries. */ @@ -1278,13 +1283,16 @@ export type RoutingWhen = | { any: RoutingWhen[] }; /** - * A control that scales an edge, which carries nothing while the level is at zero: - * - a continuous control's id, at zero at its `min`; - * - `{ id, off }`, at zero at the value `off` (MOD MIX fully right silences source A); - * - `{ sum }`, at zero only while every part is (MOD DEPTH plus the mod wheel). - * The id `@wheel` is the mod wheel. + * A control that scales an edge, as an amount from 0 (carries nothing) to 1: + * - a continuous control's id, 0 at its `min` and 1 at its `max`; + * - `{ id, off }`, 0 at the value `off` and rising away from it (MOD MIX fully right silences source A); a switch + * is 0 at `off` and 1 anywhere else; + * - `{ id, pow }`, a continuous control's amount raised to `pow`, for a knob the engine curves (MOD DEPTH squared); + * - `{ sum }`, the parts added (MOD DEPTH plus the mod wheel). + * The id `@wheel` is the mod wheel. A value that is missing is 0, as `toEngine()` hears it. */ -export type RoutingLevel = string | { id: string; off: ControlValue } | { sum: RoutingLevel[] }; +export type RoutingLevel = + string | { id: string; off?: ControlValue; pow?: number } | { sum: RoutingLevel[] }; /** One connection between two blocks, inside the synth. Cables are not edges: they come from the patch. */ export interface RoutingEdge { @@ -1297,7 +1305,7 @@ export interface RoutingEdge { label?: string; /** A switch it depends on; absent = fixed. */ when?: RoutingWhen; - /** Controls that scale it; all must be above zero. */ + /** Controls that scale it; it carries signal while their product is above `ROUTING_FLOOR`. */ levels?: RoutingLevel[]; /** * A normal: the input jack whose cable breaks this edge. A cable into that jack takes the edge's place diff --git a/lib/app/synths/lib/patch.ts b/lib/app/synths/lib/patch.ts index f17c48b1d..6da022424 100644 --- a/lib/app/synths/lib/patch.ts +++ b/lib/app/synths/lib/patch.ts @@ -20,6 +20,7 @@ import { type Preset, type PresetStep, type PresetTweak, + type ShowCondition, type SynthDef, type SynthModule, } from '@/lib/app/synths/contract'; @@ -51,9 +52,16 @@ export const isRecord = (x: unknown): x is Record => export function isShown(c: Control, values: ControlValues): boolean { if (!c.show) return true; const list = Array.isArray(c.show) ? c.show : [c.show]; - return list.every((w) => ('in' in w ? w.in.includes(values[w.id]) : values[w.id] === w.eq)); + return list.every((w) => conditionHolds(w, values)); } +/** Whether `{ id, eq }` or `{ id, in }` holds: a control's `show`, or a switch in a routing's `when`. */ +export const conditionHolds = (w: ShowCondition, values: ControlValues): boolean => + 'in' in w ? w.in.includes(values[w.id]) : values[w.id] === w.eq; + +/** The mod wheel, where a control id is expected: a sound map door, a routing level. */ +export const WHEEL = '@wheel'; + // The lookups are null-prototype objects: their keys come from untrusted sounds (a saved sound, an AI reply), and // on a plain object an id like `constructor` or `toString` would find an inherited member instead of `undefined`. const byId = (list: readonly T[]): Record => { diff --git a/lib/app/synths/lib/routing.ts b/lib/app/synths/lib/routing.ts index 3a1931282..6ecf6a03a 100644 --- a/lib/app/synths/lib/routing.ts +++ b/lib/app/synths/lib/routing.ts @@ -1,11 +1,18 @@ // Signal routing: which parts of a synth the current patch actually uses, read from its routing (`SynthRouting`). -// Structural, not heard: an edge is live while its switches are set, its levels are above zero and no cable has broken -// it, and a block is live while some live path runs from it to the output. The sound map (`soundmap.ts`) answers the +// Structural, not heard: an edge is live while its switches are set, its levels multiply to more than the engine's +// floor and no cable has broken it, and a block is live while some live path runs from it to the output (and, for a +// block that only passes signal on, while a live edge or cable feeds it). The sound map (`soundmap.ts`) answers the // other question, whether a control changes what is heard, by probing the real voice; the two can disagree (a filter // closed to nothing is still on the path), and the view says which it shows. // // Pure, like `toEngine()`: same definition, routing, values and cables in, same answer out. -import { controlMap, jackMap } from '@/lib/app/synths/lib/patch'; +import { + conditionHolds, + controlMap, + jackMap, + WHEEL, + type CableLike, +} from '@/lib/app/synths/lib/patch'; import type { ControlValues, RoutingEdge, @@ -15,14 +22,11 @@ import type { SynthRouting, } from '@/lib/app/synths/contract'; -/** The mod wheel, in a `RoutingLevel` (the sound map's `WHEEL` uses the same name). */ -export const ROUTING_WHEEL = '@wheel'; - -/** A cable as the panel holds it: output jack to input jack. */ -export interface RoutingCable { - from: string; - to: string; -} +/** + * An edge whose levels multiply to no more than this carries nothing: the floor below which `toEngine()` drops a + * modulation route (Model D's mod bus). A mixer knob under 1/2000 of its travel therefore counts as zero too. + */ +export const ROUTING_FLOOR = 0.0005; /** A cable between two blocks, and whether it is on a live path. */ export interface RoutingCableState { @@ -34,7 +38,7 @@ export interface RoutingCableState { } export interface RoutingState { - /** Every block, true while it is on a live path to the output. */ + /** Every block, true while it has signal (made, or fed to a `passes` block) on a live path to the output. */ blocks: Record; /** Every edge, true while it carries signal to a live block. */ edges: Record; @@ -44,7 +48,7 @@ export interface RoutingState { export interface RoutingInput { values: ControlValues; - cables: RoutingCable[]; + cables: CableLike[]; /** Mod wheel position, 0..1. */ wheel?: number; } @@ -52,24 +56,72 @@ export interface RoutingInput { function holds(when: RoutingWhen, values: ControlValues): boolean { if ('all' in when) return when.all.every((w) => holds(w, values)); if ('any' in when) return when.any.some((w) => holds(w, values)); - if ('in' in when) return when.in.includes(values[when.id]); - return values[when.id] === when.eq; + return conditionHolds(when, values); } -function aboveZero(def: SynthDef, level: RoutingLevel, input: RoutingInput): boolean { - if (typeof level !== 'string') { - if ('sum' in level) return level.sum.some((l) => aboveZero(def, l, input)); - return input.values[level.id] !== level.off; - } - if (level === ROUTING_WHEEL) return (input.wheel ?? 0) > 0; - const c = controlMap(def)[level]; - const v = input.values[level]; - return c?.kind === 'cont' && typeof v === 'number' ? v > c.min : false; +/** A level's amount, 0..1 per control (a `sum` can pass 1). A missing value is 0, as `toEngine()` hears it. */ +function amount(def: SynthDef, level: RoutingLevel, input: RoutingInput): number { + if (typeof level !== 'string' && 'sum' in level) + return level.sum.reduce((t, l) => t + amount(def, l, input), 0); + const { id, off, pow = 1 } = typeof level === 'string' ? { id: level } : level; + if (id === WHEEL) return Math.max(input.wheel ?? 0, 0); + const c = controlMap(def)[id]; + const v = input.values[id]; + if (v === undefined) return 0; + if (c?.kind !== 'cont') return v === off ? 0 : 1; + if (typeof v !== 'number') return 0; + const zero = typeof off === 'number' ? off : c.min; + return Math.pow(Math.abs(v - zero) / (c.max - c.min), pow); } const enabled = (def: SynthDef, edge: RoutingEdge, input: RoutingInput): boolean => (!edge.when || holds(edge.when, input.values)) && - (edge.levels ?? []).every((l) => aboveZero(def, l, input)); + (edge.levels ?? []).reduce((p, l) => p * amount(def, l, input), 1) > ROUTING_FLOOR; + +/** Every block with a path to `out` over `links` (from, to), `out` included. */ +function reachBack(out: string, links: Iterable<[string, string]>): Set { + const into = new Map(); + for (const [from, to] of links) { + const list = into.get(to); + if (list) list.push(from); + else into.set(to, [from]); + } + const reach = new Set([out]); + const queue = [out]; + for (let b = queue.pop(); b !== undefined; b = queue.pop()) + for (const from of into.get(b) ?? []) + if (!reach.has(from)) { + reach.add(from); + queue.push(from); + } + return reach; +} + +/** Every block with signal over `links`: a block that makes its own, and a `passes` block one of them feeds. */ +function fedBlocks(routing: SynthRouting, links: Iterable<[string, string]>): Set { + const outOf = new Map(); + for (const [from, to] of links) { + const list = outOf.get(from); + if (list) list.push(to); + else outOf.set(from, [to]); + } + const fed = new Set(routing.blocks.filter((b) => !b.passes).map((b) => b.id)); + const queue = [...fed]; + for (let b = queue.pop(); b !== undefined; b = queue.pop()) + for (const to of outOf.get(b) ?? []) + if (!fed.has(to)) { + fed.add(to); + queue.push(to); + } + return fed; +} + +/** `routing.jacks` with a null prototype: cable jack ids come from untrusted sounds (see `byId` in `patch.ts`). */ +function jackBlocks(routing: SynthRouting): Record { + const m = Object.create(null) as Record; + for (const [jack, block] of Object.entries(routing.jacks)) m[jack] = block; + return m; +} /** * Which blocks, edges and cables of `routing` the patch uses. A cable into a jack that breaks a normal takes the @@ -86,34 +138,38 @@ export function routingState( const edgeOn = new Map( routing.edges.map((e) => [e.id, enabled(def, e, input) && !(e.breaks && patched.has(e.breaks))]) ); + const jackBlock = jackBlocks(routing); const cables = input.cables.flatMap((c) => { - const fromBlock = routing.jacks[c.from]; - const toBlock = routing.jacks[c.to]; + const fromBlock = jackBlock[c.from]; + const toBlock = jackBlock[c.to]; if (!fromBlock || !toBlock) return []; const normal = normalAt.get(c.to); - return [{ ...c, fromBlock, toBlock, on: !normal || enabled(def, normal, input) }]; + return [ + { from: c.from, to: c.to, fromBlock, toBlock, on: !normal || enabled(def, normal, input) }, + ]; }); - // Walk back from the output over everything that carries signal. - const into = new Map(); - const link = (from: string, to: string) => into.set(to, [...(into.get(to) ?? []), from]); - for (const e of routing.edges) if (edgeOn.get(e.id)) link(e.from, e.to); - for (const c of cables) if (c.on) link(c.fromBlock, c.toBlock); - const live = new Set([routing.out]); - const queue = [routing.out]; - for (let b = queue.pop(); b !== undefined; b = queue.pop()) - for (const from of into.get(b) ?? []) - if (!live.has(from)) { - live.add(from); - queue.push(from); - } + // Walk forward from the blocks that make signal, then back from the output over what carries it. + const links = [ + ...routing.edges.filter((e) => edgeOn.get(e.id)).map((e): [string, string] => [e.from, e.to]), + ...cables.filter((c) => c.on).map((c): [string, string] => [c.fromBlock, c.toBlock]), + ]; + const fed = fedBlocks(routing, links); + const reach = reachBack( + routing.out, + links.filter(([from]) => fed.has(from)) + ); + const live = (b: string) => fed.has(b) && reach.has(b); return { - blocks: Object.fromEntries(routing.blocks.map((b) => [b.id, live.has(b.id)])), + blocks: Object.fromEntries(routing.blocks.map((b) => [b.id, live(b.id)])), edges: Object.fromEntries( - routing.edges.map((e) => [e.id, !!edgeOn.get(e.id) && live.has(e.to)]) + routing.edges.map((e) => [e.id, !!edgeOn.get(e.id) && fed.has(e.from) && live(e.to)]) ), - cables: cables.map(({ on, ...c }) => ({ ...c, live: on && live.has(c.toBlock) })), + cables: cables.map(({ on, ...c }) => ({ + ...c, + live: on && fed.has(c.fromBlock) && live(c.toBlock), + })), }; } @@ -149,17 +205,20 @@ export function routingProblems(def: SynthDef, routing: SynthRouting): string[] if (!legal(x)) problems.push(`${where}: ${JSON.stringify(x)} is not a value of ${w.id}`); }; const checkLevel = (where: string, l: RoutingLevel): void => { - if (typeof l !== 'string') { - if ('sum' in l) return l.sum.forEach((x) => checkLevel(where, x)); - return checkWhen(where, { id: l.id, eq: l.off }); - } - if (l === ROUTING_WHEEL) return; - const c = controls[l]; - if (!c) problems.push(`${where}: no control ${l}`); + if (typeof l !== 'string' && 'sum' in l) return l.sum.forEach((x) => checkLevel(where, x)); + const { id, off, pow } = typeof l === 'string' ? { id: l } : l; + if (id === WHEEL) return; + const c = controls[id]; + if (!c) return void problems.push(`${where}: no control ${id}`); + if (off !== undefined) checkWhen(where, { id, eq: off }); else if (c.kind !== 'cont') - problems.push(`${where}: ${l} is not continuous; give it an off value`); + problems.push(`${where}: ${id} is not continuous; give it an off value`); + if (pow !== undefined && (c.kind !== 'cont' || !(pow > 0))) + problems.push(`${where}: ${id} cannot take pow ${pow}`); }; + const breaker = new Map(); + if (routing.synth !== def.id) problems.push(`routing is for ${routing.synth}, not ${def.id}`); routing.blocks.forEach((b) => once('block', b.id)); block('out', routing.out); @@ -171,6 +230,9 @@ export function routingProblems(def: SynthDef, routing: SynthRouting): string[] if (e.when) checkWhen(where, e.when); e.levels?.forEach((l) => checkLevel(where, l)); if (e.breaks) { + const other = breaker.get(e.breaks); + if (other) problems.push(`${where}: ${e.breaks} already breaks edge ${other}`); + breaker.set(e.breaks, e.id); const j = jacks[e.breaks]; if (!j) problems.push(`${where}: no jack ${e.breaks}`); else if (j.dir !== 'in') problems.push(`${where}: ${e.breaks} is not an input`); @@ -187,19 +249,21 @@ export function routingProblems(def: SynthDef, routing: SynthRouting): string[] for (const j of def.jacks) if (!(j.id in routing.jacks)) problems.push(`jack ${j.id} belongs to no block`); - // Every block has a way to the output, with every switch and cable allowed. - const into = new Map(); - for (const e of routing.edges) into.set(e.to, [...(into.get(e.to) ?? []), e.from]); - const reach = new Set([routing.out]); - const queue = [routing.out]; - for (let b = queue.pop(); b !== undefined; b = queue.pop()) - for (const from of into.get(b) ?? []) - if (!reach.has(from)) { - reach.add(from); - queue.push(from); - } + // Every block has a way to the output, with every switch allowed and a cable from any output jack to any input. + const blockOf = (dir: 'in' | 'out') => + def.jacks.filter((j) => j.dir === dir).flatMap((j) => routing.jacks[j.id] ?? []); + const outs = blockOf('out'); + const reach = reachBack(routing.out, [ + ...routing.edges.map((e): [string, string] => [e.from, e.to]), + ...blockOf('in').flatMap((to) => outs.map((from): [string, string] => [from, to])), + ]); for (const b of routing.blocks) if (!reach.has(b.id)) problems.push(`block ${b.id} has no way to ${routing.out}`); + // And every block that only passes signal on has something that could feed it. + const inBlocks = new Set(blockOf('in')); + for (const b of routing.blocks) + if (b.passes && !inBlocks.has(b.id) && !routing.edges.some((e) => e.to === b.id)) + problems.push(`block ${b.id} passes signal on, but nothing feeds it`); return problems; } diff --git a/lib/app/synths/lib/soundmap.ts b/lib/app/synths/lib/soundmap.ts index 21e87b5b7..d40e5fd62 100644 --- a/lib/app/synths/lib/soundmap.ts +++ b/lib/app/synths/lib/soundmap.ts @@ -7,7 +7,7 @@ // probe Workers from source text embedded at build time (`__PROBE_SRC__`); the app serves the probe worker as a // static file, `PROBE_WORKER_URL` (from the audio module). import { logger } from '@/lib/logging'; -import { cablesToEngine, type CableLike } from '@/lib/app/synths/lib/patch'; +import { cablesToEngine, WHEEL, type CableLike } from '@/lib/app/synths/lib/patch'; import { createProbe, PROBE_WORKER_URL } from '@/lib/app/synths/audio/probe'; import type { Control, @@ -215,7 +215,7 @@ export function buildJobs( // Every (dead control, door) pair is a rendering, so doors are tried nearest first, each dead control has a budget, // and the search for a control stops soon after its first answer (see `stale` in the mapper). -export const WHEEL = '@wheel'; +export { WHEEL }; const SINGLE_BUDGET = 18; const PAIR_BUDGET = 14; diff --git a/lib/app/synths/routing/model-d.ts b/lib/app/synths/routing/model-d.ts index bf92eaef5..5edc8d6b2 100644 --- a/lib/app/synths/routing/model-d.ts +++ b/lib/app/synths/routing/model-d.ts @@ -4,14 +4,16 @@ * (`defs/model-d.ts`). * * Where the definition says more than the spec, it decides: the mod bus's crossfade (MOD MIX fully right silences - * source A, fully left source B) and its depth (MOD DEPTH plus the mod wheel) are read from `toEngine()`. + * source A, fully left source B) and its depth (MOD DEPTH squared, plus the mod wheel) are read from `toEngine()`, + * which drops a source whose share of the depth is under `ROUTING_FLOOR`. So each source edge carries its share + * times the depth, and matches the engine at the floor. */ import type { RoutingLevel, SynthRouting } from '@/lib/app/synths/contract'; -import { ROUTING_WHEEL } from '@/lib/app/synths/lib/routing'; +import { WHEEL } from '@/lib/app/synths/lib/patch'; -const SOURCE_A: RoutingLevel[] = [{ id: 'mod.mix', off: 10 }]; -const SOURCE_B: RoutingLevel[] = ['mod.mix']; -const DEPTH: RoutingLevel[] = [{ sum: ['mod.depth', ROUTING_WHEEL] }]; +const DEPTH: RoutingLevel[] = [{ sum: [{ id: 'mod.depth', pow: 2 }, WHEEL] }]; +const SOURCE_A: RoutingLevel[] = [{ id: 'mod.mix', off: 10 }, ...DEPTH]; +const SOURCE_B: RoutingLevel[] = ['mod.mix', ...DEPTH]; const OSC_SPECS = [ { label: 'Frequency', value: '0.1 Hz to 20 kHz' }, { label: 'Ranges', value: "LO, 32', 16', 8', 4', 2'" }, @@ -39,6 +41,7 @@ export const routing: SynthRouting = { id: 'mod_bus', name: 'Modulation mix', module: 'mod', + passes: true, description: 'Crossfades source A (Oscillator 3 or the filter contour) with source B (noise or the LFO), scales the result by MOD DEPTH and the mod wheel, and sends it to oscillator pitch, filter cutoff or both.', }, @@ -113,6 +116,7 @@ export const routing: SynthRouting = { id: 'mixer', name: 'Mixer', module: 'mixer', + passes: true, description: 'Five channels, each with a level knob and an on/off switch, into the filter.', specs: [{ label: 'Channels', value: 'Oscillators 1–3, external input, noise' }], }, @@ -162,7 +166,7 @@ export const routing: SynthRouting = { edges: [ { id: 'c01', from: 'midi', to: 'pitch_bus', signal: 'cv', label: 'pitch' }, { id: 'c02', from: 'midi', to: 'pitch_bus', signal: 'cv', label: 'pitch bend' }, - { id: 'c03', from: 'midi', to: 'mod_bus', signal: 'cv', label: 'mod wheel' }, + { id: 'c03', from: 'midi', to: 'mod_bus', signal: 'cv', label: 'mod wheel', levels: [WHEEL] }, { id: 'c04', from: 'midi', to: 'filter_contour', signal: 'gate' }, { id: 'c05', from: 'midi', to: 'loudness_contour', signal: 'gate' }, { id: 'c10', from: 'pitch_bus', to: 'osc1', signal: 'cv', label: 'pitch' }, diff --git a/tests/unit/lib/app/synths/lib/routing.test.ts b/tests/unit/lib/app/synths/lib/routing.test.ts index 8f648796f..42caac4c6 100644 --- a/tests/unit/lib/app/synths/lib/routing.test.ts +++ b/tests/unit/lib/app/synths/lib/routing.test.ts @@ -8,10 +8,11 @@ import { describe, expect, it } from 'vitest'; import type { ControlValues } from '@/lib/app/synths/contract'; import modelD from '@/lib/app/synths/defs/model-d'; -import { routingState, type RoutingCable } from '@/lib/app/synths/lib/routing'; +import type { CableLike } from '@/lib/app/synths/lib/patch'; +import { routingState } from '@/lib/app/synths/lib/routing'; import { routing } from '@/lib/app/synths/routing/model-d'; -const state = (set: ControlValues = {}, cables: RoutingCable[] = [], wheel = 0) => +const state = (set: ControlValues = {}, cables: CableLike[] = [], wheel = 0) => routingState(modelD, routing, { values: { ...modelD.init, ...set }, cables, wheel }); /** Oscillator 3 and the LFO both feeding the filter through the mod bus, nothing else on it. */ @@ -69,6 +70,52 @@ describe('routingState on Model D', () => { expect(state(off, [], 0.5).blocks.mod_bus).toBe(true); }); + it('lights the mod wheel edge only while the wheel is up', () => { + expect(state(MOD_TO_FILTER).edges.c03).toBe(false); + expect(state(MOD_TO_FILTER, [], 0.5).edges.c03).toBe(true); + }); + + it('darkens the mod bus where the engine drops the route: MOD DEPTH squared, times the source’s share', () => { + // Depth 0.1 is (0.01)² = 0.0001 of full depth, under the floor; 0.3 is 0.0009, over it. + expect(state({ ...MOD_TO_FILTER, 'mod.depth': 0.1 }).blocks.mod_bus).toBe(false); + expect(state({ ...MOD_TO_FILTER, 'mod.depth': 0.1 }).edges.c33).toBe(false); + expect(state({ ...MOD_TO_FILTER, 'mod.depth': 0.3, 'mod.mix': 0 }).edges.c33).toBe(true); + // Centred, each source carries half of 0.0009: both under the floor, so nothing feeds the bus. + expect(state({ ...MOD_TO_FILTER, 'mod.depth': 0.3 }).edges.c33).toBe(false); + expect(state({ ...MOD_TO_FILTER, 'mod.depth': 0.3 }).blocks.mod_bus).toBe(false); + // Mix 9.9 leaves source A 1% of a depth of 1%: 0.0001, dropped. Source B carries the rest. + const s = state({ ...MOD_TO_FILTER, 'mod.depth': 1, 'mod.mix': 9.9, 'mod.srcB': 'lfo' }); + expect([s.edges.c20, s.edges.c23]).toEqual([false, true]); + }); + + it('treats a missing MOD MIX as the engine does: neither source carries', () => { + const values: ControlValues = { ...modelD.init, ...MOD_TO_FILTER, 'mod.srcB': 'lfo' }; + delete values['mod.mix']; + const s = routingState(modelD, routing, { values, cables: [] }); + expect([s.edges.c20, s.edges.c23, s.blocks.osc3, s.blocks.lfo]).toEqual([ + false, + false, + false, + false, + ]); + // With nothing feeding it, the bus and its way to the filter are dark too, though both are switched on. + expect([s.blocks.mod_bus, s.edges.c33]).toEqual([false, false]); + }); + + it('darkens the mixer, and its way to the filter, with every channel off', () => { + const s = state({ 'mix.osc1On': false }); + expect([s.blocks.mixer, s.edges.c50]).toEqual([false, false]); + // The filter makes signal of its own (it self-oscillates), so it stays on the path. + expect([s.blocks.filter, s.blocks.vca, s.blocks.output]).toEqual([true, true, true]); + }); + + it('feeds the mod bus from the mod wheel alone', () => { + const values: ControlValues = { ...modelD.init, ...MOD_TO_FILTER, 'mod.depth': 0 }; + delete values['mod.mix']; + const s = routingState(modelD, routing, { values, cables: [], wheel: 0.5 }); + expect([s.edges.c03, s.blocks.mod_bus, s.edges.c33]).toEqual([true, true, true]); + }); + it('needs every part of an all-condition: modulation reaches Oscillator 3 only under keyboard control', () => { const on = { ...MOD_TO_FILTER, 'mod.toOsc': true, 'mix.osc3On': true, 'mix.osc3': 5 }; expect(state({ ...on, 'osc3.kbd': true }).edges.c32).toBe(true); @@ -83,7 +130,7 @@ describe('routingState on Model D', () => { describe('cables', () => { const NOISE_B = { ...MOD_TO_FILTER, 'mod.srcB': 'noise' }; - const SQUARE_IN: RoutingCable = { from: 'j.lfoSq', to: 'j.modSrc' }; + const SQUARE_IN: CableLike = { from: 'j.lfoSq', to: 'j.modSrc' }; it('lights the noise normal into the mod bus with no cable in MOD SOURCE', () => { const s = state(NOISE_B); @@ -133,6 +180,11 @@ describe('routingState on Model D', () => { it('skips a cable to a jack the routing does not know', () => { expect(state({}, [{ from: 'j.lfoTri', to: 'j.nope' }]).cables).toEqual([]); }); + + it('skips a cable whose jack ids are names an object inherits', () => { + expect(state({}, [{ from: 'toString', to: 'constructor' }]).cables).toEqual([]); + expect(state({}, [{ from: 'j.lfoTri', to: '__proto__' }]).cables).toEqual([]); + }); }); it('is pure: the same patch gives the same answer, and the input is not changed', () => { diff --git a/tests/unit/lib/app/synths/routing.test.ts b/tests/unit/lib/app/synths/routing.test.ts index f22ee07e6..ffdef685e 100644 --- a/tests/unit/lib/app/synths/routing.test.ts +++ b/tests/unit/lib/app/synths/routing.test.ts @@ -119,6 +119,45 @@ describe('routingProblems', () => { ]); }); + it('refuses two normals broken by the same jack', () => { + expect( + edit((r) => { + r.edges.push({ ...edge(r, 'c22'), id: 'c99', from: 'lfo' }); + }) + ).toEqual(['edge c99: j.modSrc already breaks edge c22']); + }); + + it('refuses a pow on a control that is not continuous, or one that is not above zero', () => { + expect( + edit((r) => { + edge(r, 'c40').levels = [ + { id: 'mix.osc1On', off: false, pow: 2 }, + { id: 'mix.osc1', pow: 0 }, + ]; + }) + ).toEqual(['edge c40: mix.osc1On cannot take pow 2', 'edge c40: mix.osc1 cannot take pow 0']); + }); + + it('counts a cable as a way to the output for a block that has no edge out', () => { + expect( + edit((r) => { + r.edges = r.edges.filter((e) => e.from !== 'lfo'); + }) + ).toEqual([]); + }); + + it('refuses a block that passes signal on with nothing to feed it', () => { + expect( + edit((r) => { + r.blocks.find((b) => b.id === 'lfo')!.passes = true; + delete r.jacks['j.lfoCv']; + }) + ).toEqual([ + 'jack j.lfoCv belongs to no block', + 'block lfo passes signal on, but nothing feeds it', + ]); + }); + it('refuses a jack id the definition does not have, and a definition jack left out', () => { expect( edit((r) => {