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..8bd46b73c 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,51 @@ 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; `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. +- **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, 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` (`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` 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. + ## Sounds A `Preset` is built step by step: each step's `set` and `cables` apply cumulatively on top of diff --git a/lib/app/synths/contract.ts b/lib/app/synths/contract.ts index cd674fbb4..4364db164 100644 --- a/lib/app/synths/contract.ts +++ b/lib/app/synths/contract.ts @@ -1226,6 +1226,94 @@ 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 }[]; + /** + * 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. */ +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, 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; pow?: number } | { 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; 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 + * 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/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 new file mode 100644 index 000000000..6ecf6a03a --- /dev/null +++ b/lib/app/synths/lib/routing.ts @@ -0,0 +1,269 @@ +// 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 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 { + conditionHolds, + controlMap, + jackMap, + WHEEL, + type CableLike, +} from '@/lib/app/synths/lib/patch'; +import type { + ControlValues, + RoutingEdge, + RoutingLevel, + RoutingWhen, + SynthDef, + SynthRouting, +} from '@/lib/app/synths/contract'; + +/** + * 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 { + from: string; + to: string; + fromBlock: string; + toBlock: string; + live: boolean; +} + +export interface RoutingState { + /** 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; + /** Every cable between two of the routing's jacks, in patch order. */ + cables: RoutingCableState[]; +} + +export interface RoutingInput { + values: ControlValues; + cables: CableLike[]; + /** 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)); + return conditionHolds(when, values); +} + +/** 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 ?? []).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 + * 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 jackBlock = jackBlocks(routing); + const cables = input.cables.flatMap((c) => { + const fromBlock = jackBlock[c.from]; + const toBlock = jackBlock[c.to]; + if (!fromBlock || !toBlock) return []; + const normal = normalAt.get(c.to); + return [ + { from: c.from, to: c.to, fromBlock, toBlock, on: !normal || enabled(def, normal, input) }, + ]; + }); + + // 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(b.id)])), + edges: Object.fromEntries( + 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 && fed.has(c.fromBlock) && live(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' && '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}: ${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); + 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 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`); + 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 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/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..5edc8d6b2 --- /dev/null +++ b/lib/app/synths/routing/model-d.ts @@ -0,0 +1,335 @@ +/** + * 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 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 { WHEEL } from '@/lib/app/synths/lib/patch'; + +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'" }, +]; + +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', + 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.', + }, + { + 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', + 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' }], + }, + { + 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', 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' }, + { 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..42caac4c6 --- /dev/null +++ b/tests/unit/lib/app/synths/lib/routing.test.ts @@ -0,0 +1,199 @@ +/** + * 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 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: 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. */ +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('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); + 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: 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); + 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('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', () => { + 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..ffdef685e --- /dev/null +++ b/tests/unit/lib/app/synths/routing.test.ts @@ -0,0 +1,200 @@ +/** + * 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 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) => { + 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`), + ]); + }); +});