Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .context/app/adding-a-synth.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ them. `goldens.test.ts` then holds the definition to them. Write
`tests/unit/lib/app/synths/defs/<id>.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/<id>.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
Expand Down
46 changes: 46 additions & 0 deletions .context/app/synths.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ from the definition.
| The shapes (source of truth) | `lib/app/synths/contract.ts` |
| The definitions | `lib/app/synths/defs/<id>.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/<id>.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`) |
Expand Down Expand Up @@ -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/<id>.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/<id>/<id>.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
Expand Down
88 changes: 88 additions & 0 deletions lib/app/synths/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>.ts`), and projected by hand from the
* synth's research spec (`.context/app/synth-data/<id>/<id>.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<string, string>;
/** Spec modules and connections the routing leaves out, by spec id, with the reason. */
omitted?: Record<string, string>;
}

/** 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. */
Expand Down
10 changes: 9 additions & 1 deletion lib/app/synths/lib/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type Preset,
type PresetStep,
type PresetTweak,
type ShowCondition,
type SynthDef,
type SynthModule,
} from '@/lib/app/synths/contract';
Expand Down Expand Up @@ -51,9 +52,16 @@ export const isRecord = (x: unknown): x is Record<string, unknown> =>
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 = <T extends { id: string }>(list: readonly T[]): Record<string, T | undefined> => {
Expand Down
Loading
Loading