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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
25 changes: 24 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ One connection from an output jack to an input jack. An input holds at most one;
**Jack**:
A single connection point on a module, either an input or an output, carrying one Signal Kind.

**Arm**:
To hold a Jack as the pending end of a connection the user is making from the keyboard. Arming a
second Jack completes the Cable; Escape cancels. A Jack is armed or it is not — there is never more
than one.
_Avoid_: select, focus (a Jack can be focused without being armed, and usually is)

**Jack Interaction**:
Everything the user does _to_ Jacks and Cables — arming, dragging, unpatching — as distinct from
**Patch**, which is the saved arrangement they end up with. The distinction is load-bearing: the two
words sound alike and mean unrelated things.

**Row**:
A horizontal strip of the rack holding modules side by side, with a width measured in HP.

Expand Down Expand Up @@ -65,8 +76,20 @@ The visible face of a module. Its geometry is computed from the Definition by de
An image placed behind a User Module's panel.
_Avoid_: artwork, panel image

**Screen**:
The recessed area a Panel reserves for something to be drawn in. A module either names a Display to
fill it or reserves it for its own custom UI — never both.

**Display**:
The screen area on a panel — a scope, meter, step grid, envelope, keyboard or text readout.
One of the six kinds of readout the app knows how to draw in a Screen — scope, meter, step grid,
envelope, keyboard or text. Naming a Display is a promise to feed it; a module that names one and
feeds it nothing shows a placeholder saying so, rather than an empty Screen.
_Avoid_: Screen (the area is not the readout), widget

**Draft**:
A module the user is building but has not saved — generated in the builder, or opened from the
library to edit. A Draft is live in the rack for preview, but it is not a User Module yet and does
not appear among the user's saved modules until they save it.

**Attenuverter**:
A knob that scales and can invert the voltage arriving at a CV input, rather than setting a value of its own.
80 changes: 55 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,25 @@ Open the printed URL, click anywhere to start audio, and patch `VCO` into `OUT`.

## Scripts

| script | what it does |
| ------------------- | --------------------------------------------------- |
| `npm run dev` | Vite dev server with HMR |
| `npm run build` | typecheck + production bundle in `dist/` |
| `npm run preview` | serve the production bundle |
| `npm run typecheck` | `tsc --noEmit` (strict, `noUncheckedIndexedAccess`) |
| `npm run lint` | ESLint at `--max-warnings 0` |
| `npm run test` | Vitest (engine, DSP, storage, UI kit) |
| `npm run format` | Prettier |
| script | what it does |
| ---------------------- | --------------------------------------------------- |
| `npm run dev` | Vite dev server with HMR |
| `npm run build` | typecheck + production bundle in `dist/` |
| `npm run preview` | serve the production bundle |
| `npm run typecheck` | `tsc --noEmit` (strict, `noUncheckedIndexedAccess`) |
| `npm run lint` | ESLint at `--max-warnings 0` |
| `npm run test` | Vitest (engine, DSP, storage, UI kit) |
| `npm run format` | Prettier |
| `npm run format:check` | `prettier -c .`, the first step of the CI gate |

## Author a module by hand

A module is a folder under `src/modules/<id>/` with two files. The registry picks them up by glob, so there is nothing to register.
A module is a folder under `src/modules/<id>/` with two files: the definition, and one implementation — a worklet DSP class or a native Web Audio graph. The registry picks them up by glob, so there is nothing to register, and nothing anywhere counts the modules.

`<id>.def.ts` — the data contract:

```ts
import type { ModuleDef } from '../../core/types';
import { att, type ModuleDef } from '../../core/types';

export const def: ModuleDef = {
id: 'myosc',
Expand All @@ -45,37 +46,43 @@ export const def: ModuleDef = {
cat: 'SOURCES',
worklet: 'myosc', // or `native: 'myosc'` for a Web Audio graph module
knobs: [
{ id: 'freq', label: 'FREQ', min: 20, max: 8000, initial: 220, fmt: 'fHz', curve: 'log', cvIn: 'voct' },
{ id: 'freq', label: 'FREQ', min: 20, max: 8000, initial: 220, fmt: 'fHz', curve: 'log', cvIn: 'fm' },
att('fm', 'FM'), // knob id defaults to 'fmA'; pass a third argument to override it
],
sws: [{ id: 'range', label: 'RANGE', options: ['LO', 'HI'], initial: 1 }],
ins: [{ id: 'voct', label: 'V/OCT', kind: 'p' }],
ins: [
{ id: 'voct', label: 'V/OCT', kind: 'p' },
{ id: 'fm', label: 'FM', kind: 'c' },
],
outs: [{ id: 'out', label: 'OUT', kind: 'a' }],
display: undefined, // 'scope' | 'meter' | 'steps' | 'env' | 'piano' | 'text'
};
```

Declaring a `display` is only half of it: each kind reads specific `m.ext` keys or worklet messages, and nothing enforces the pairing. See the display contract table in `docs/system-architecture.md` before you set one.
Jack kinds: `a` audio, `p` pitch (1 V/oct), `g` gate/trigger, `c` control voltage.

**Every knob needs an `fmt`.** It is the knob's unit and, for `fInt`, `fRate`, `fKey`, `fChord` and `fShape`, its quantisation while dragging. `FMT_RANGE` in `src/core/types.ts` bounds the range each name implies, and `checkDef` rejects a knob that leaves it. Pick the one that matches what your DSP stores: `fMs` reads the value as seconds, `fMsec` as milliseconds, and `fHz` is signed, so a negative frequency shift is fine. `fPc` renders `v * 100`.

**Attenuverters.** A CV input gets a bipolar amount knob by declaring `att(jackId, label)` in `knobs`. That is the whole declaration, the fixed ±1 range included. The engine puts a `GainNode` between the patch cable and the module input, so the knob is voltage scaling at the jack: do not scale that input again in DSP, and do not read the knob's id as a param. A jack takes at most one attenuverter; `cvIn` on the modulated knob is the display hint that pairs them.

Jack kinds: `a` audio, `p` pitch (1 V/oct), `g` gate/trigger, `c` control voltage. A knob with `attenuates: '<cvJackId>'` becomes a bipolar attenuverter on that CV input.
**Displays.** Declaring a `display` is only half of it: each kind reads specific `m.ext` keys or worklet messages. `src/modules/display-contract.ts` holds that contract as code, one row per kind saying what the module must provide, and `checkDef` runs it over your def, so a screen you can never fill is a rejected module rather than a blank rectangle. If the feed goes missing at runtime the panel prints `NO <DISPLAY>` and the reason. A built-in whose `<id>.parts.tsx` draws its own screen declares `screen: true` instead of a `display` kind; declaring both is an error.

`<id>.dsp.ts` — one class, runs in the AudioWorklet thread:

```ts
import { Base, ch, clamp, type Params } from '../../engine/dsp-prelude';
import { Base, ch } from '../../engine/dsp-prelude';

class MyOsc extends Base {
private ph = 0;
defaults(): Params {
return { freq: 220, range: 1 };
} // knob and switch ids
process(I: Float32Array[][], O: Float32Array[][]): boolean {
const out = O[0]?.[0];
if (!out) return true;
const voct = ch(I, 0); // inputs ordered as def.ins
const { freq = 220 } = this.p;
const fm = ch(I, 1); // already scaled by the attenuverter
const { freq = 220 } = this.p; // always read with a fallback
for (let i = 0; i < out.length; i++) {
// read .length, never assume 128
const f = freq * Math.pow(2, voct?.[i] ?? 0);
const f = freq * Math.pow(2, (voct?.[i] ?? 0) + (fm?.[i] ?? 0));
this.ph += f / sampleRate;
if (this.ph >= 1) this.ph -= 1;
out[i] = (this.ph * 2 - 1) * 5; // audio is ±5 V
Expand All @@ -86,13 +93,36 @@ class MyOsc extends Base {
registerProcessor('myosc', MyOsc);
```

Signals are volts: audio ±5, gates 0 or 5, pitch 1 V/oct with 0 V = C4. Parameters arrive on `this.p` between blocks. Allocate nothing inside `process()`.
Signals are volts: audio ±5, gates 0 or 5, pitch 1 V/oct with 0 V = C4. Allocate nothing inside `process()`.

**Parameters come from the definition.** `seedParams(def)` in `src/engine/node-factory.ts` builds `this.p` from every knob and switch before the first block — in the rack and in the offline render that verifies a user module — and live changes arrive on `this.p` between blocks. Do not write a `defaults()` method: it could only restate the definition or contradict it, and no built-in has one. Read every param with a fallback anyway (`const { freq = 220 } = this.p`): a bare `this.p.freq` is how a DSP that misses a key renders NaN.

Symbols the prelude puts in worklet scope: `Base`, `ch`, `clamp`, `TP`, `DENORMAL`, `flush`, `blep`, `oscW`, `DL`, `OnePole`, `onePoleCoeff`, `lpCoeff`, `Lcg`, `ClockSync`, `SYNC_DIV`, `readLinear`, plus the scope's own `sampleRate`.

`<id>.native.ts` — instead of a worklet, a Web Audio graph built on the main thread. Only built-ins can be native; user modules are always worklets.

```ts
export const native: NativeSpec = {
audio(m) {
// called once, when the module is placed
const g = getAudioContext().createGain();
(m.natives ??= []).push(g); // teardown disconnects everything pushed here
m.jacks.in.in = { node: g, idx: 0 }; // EVERY declared jack, by id, by hand
for (const j of m.def.outs) m.jacks.out[j.id] = { node: g, idx: 0 };
},
param(m, id, v) {}, // live knob AND switch changes, by id; a switch arrives as its option index
onConnectionChange(m, dir, jack, connected) {}, // optional
dispose(m) {}, // optional; stop sources you started
};
```

`makeNode` throws when `audio()` leaves a declared jack unfilled, because every miss is otherwise silent: a cable to an unfilled jack is drawn and carries nothing, and the attenuverter on an unfilled CV input is skipped. Fill the jacks inside `audio()` — the engine installs attenuverter gains straight afterwards and can only wrap a jack that is already there.

Symbols available in worklet scope, and nothing else: `Base`, `ch`, `clamp`, `TP`, `DENORMAL`, `flush`, `blep`, `oscW`, `DL`, `OnePole`, `onePoleCoeff`, `lpCoeff`, `Lcg`, `ClockSync`, `SYNC_DIV`, `sampleRate`.
**Testing a DSP.** `tests/dsp-harness.ts` gives you `loadProcessor(slug, over?)`: it stubs the worklet globals, loads your `.dsp.ts`, and returns an instance seeded from your def exactly as the rack seeds it, with `over` standing in for a moved control. Your module also joins `tests/dsp-smoke-sweep.test.ts` and `tests/module-contract-sweep.test.ts` by existing: the first runs it unpatched, silent and with signal and fails on NaN or out-of-range output, the second runs `checkDef` and checks that every declared input is read and every declared output written.

**Panel geometry is computed.** `layoutPanel(def)` lays out every panel from the definition alone — there is no `<id>.panel.ts` file. A built-in may author its own normalised 0..1 `PanelLayout` as `ModuleDef.panel`, but only as a documented exception when it can show the computed layout fails (currently only MIX 8); see `docs/adr/0001-panel-geometry-computed-by-default.md`. Node ids are `knob:`, `fader:`, `switch:`, `in:`, `out:`, `led:`, `label:` plus a single `display`.
**Panel geometry is computed.** `src/modules/panel-layout.ts` owns the pixel constants and the layout. `layoutPanel(def)` lays out every panel from the definition alone; there is no `<id>.panel.ts` file, and `checkDef` reports a def too dense for its width as _"use at least N HP"_. A built-in may author its own normalised 0..1 `PanelLayout` as `ModuleDef.panel`, but only as a documented exception when it can show the computed layout fails (currently MIX 8 and TUBE); see `docs/adr/0001-panel-geometry-computed-by-default.md`. Node ids are `knob:`, `fader:`, `switch:`, `in:`, `out:`, `led:`, `label:` plus a single `display:<kind>`.

User modules made in the builder follow the same contract; they are stored in `localStorage` (definition and DSP source) and IndexedDB (faceplate image), and appear in the module browser after a reload.
User modules made in the builder follow the same contract, and not by convention: `checkDef` is the same function the sweep runs over every built-in. They are stored in `localStorage` (definition and DSP source) and IndexedDB (faceplate image), and put back in the registry shortly after the page loads, so they appear in the module browser after a reload. A saved patch references its user modules by id and never carries them — open a patch naming one you do not have installed and it tells you which modules, and their cables, were skipped.

## Bring your own key

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Restoring a stored User Module does not re-verify its DSP

Nothing re-registered stored User Modules at startup, so after a reload the Module Browser listed
none of them and a Patch that named one loaded with the module — and every Cable touching it —
silently dropped. `addModule` returns `null` for an unknown Module Spec, `applySnapshot` skips that
module, its uid never reaches the remap table, and every Cable on either end is discarded without a
word. The stored Patch survived only by luck: `savePatch` always mints a fresh id, so re-saving forks
a new Patch rather than overwriting the good one.

The fix is a restore pass at startup, and it raises one real question: the install pipeline verifies
DSP by rendering it in an `OfflineAudioContext` with a 3 s timeout, so does restore verify too?
It does not. Verification exists to catch authoring mistakes — NaN, out-of-range samples, a processor
that never renders — and a module in storage has already passed it, at install or at import. The only
case re-verification catches is a hand-edited `localStorage` payload, and anything able to write that
is already running on this origin, which the threat model in the README concedes is over. Paying one
`OfflineAudioContext` per module on every page load to defend a position already lost is the wrong
trade. Verification stays on the two paths where the code is new to us: install and import.

The restore itself is non-blocking, fired once the worklet bundle resolves. It can afford to be,
because the rack is not persisted — only patches, settings, API keys and user modules have storage
keys — so no Patch loads at boot and nothing races the restore. Blocking the "Loading audio engine…"
gate on it would charge every user a delay bounded only by how many modules they have written, for a
feature most of them never touch.

## Consequences

A module that wedges the audio thread now wedges it at startup rather than at install, and the remedy
is still a reload. That is the accepted cost of not re-verifying.

Because restore is non-blocking, a Patch loaded in the first moments after boot could in principle
race it. Nothing in the app loads a Patch that early — the rack starts empty and seeded — so this is
a constraint on future code rather than a live bug: anything that auto-loads a Patch at startup must
wait on the restore, or the dropped-Cable failure returns in a form that only shows up under timing.

A Patch naming a module that is genuinely absent — deleted, or authored in someone else's browser —
still cannot be reconstituted. It now says so instead of failing quietly.
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# A Patch references its User Modules, it never carries them

A Patch stores `mtype: 'user:<slug>'` and nothing else: a bare reference to a module that must
already be registered. The obvious alternative is to embed each referenced User Module's Definition
and DSP source in the Patch, which would make a Patch self-contained — portable between browsers, and
carrying enough information to draw a placeholder for a module the reader does not have. It was
rejected.

The first reason is that it smuggles in a feature the project deliberately excludes. The README lists
public module sharing under _Deliberately absent_, and the moment a Patch carries executable DSP,
importing a Patch means running a stranger's code. The Patch importer would inherit the whole
User Module threat model — transpile, forbidden-global scan, offline verification — for a file format
whose entire present job is to name modules and remember where the Cables went.

The second is duplication with no story for which copy wins. A module embedded in five Patches is
five copies; edit the module and every copy is stale. Nothing in the format could say whether loading
a Patch should overwrite the installed module, ignore the copy, or fork it, and every answer is wrong
for some user.

If Patch portability becomes a real want, the honest shape is an exported bundle — the Patch together
with its User Modules as separate, separately validated records — not DSP inlined into the Patch
format.

## Consequences

Loading a Patch that names an absent module cannot restore that module, and no amount of care in the
loader will change that; the Patch does not contain the information. The loader therefore reports
what was missing rather than pretending. Cables attached to an absent module are still lost, because
a Cable needs Jacks to hang from and the Jack list lived in the Definition that was never stored.

Drawing a placeholder module to preserve those Cables is the natural upgrade, and it is blocked on
exactly this decision: it needs a Jack list the Patch does not carry. Anyone reaching for it should
reopen this ADR rather than quietly adding a `defs` field to the Patch format.
Loading