diff --git a/DAWG.md b/DAWG.md index 41a098e9..ca402ce8 100644 --- a/DAWG.md +++ b/DAWG.md @@ -634,6 +634,57 @@ FM operators 2–8 repeat the `fm` rows with a suffix (`fm2`, `fmh2`, `fmattack2 | sample controls `begin`, `end`, `speed`, `unit`, `loop`, `loopBegin`/`loopb`, `loopEnd`/`loope`, `clip`/`legato`, `fit`, `loopAt`, `accelerate`, `squiz`, `cut`, `gain`, `vel`, `rr` | sampler voice fields; `/sample set`, `set_sample` | done (see Samples) | | fitting to tempo (Ableton Repitch/Beats/Tones; Strudel `fit`) | `bpm` `fitmode` `len`; `/fitmode`, `fit_sample` | done (see Fitting samples) | +## Patches + +A patch is a small modular synth or effect stored in the score: nodes (oscillators, filters, envelopes, math, whole engines and effects) joined by cables, with up to four macros as its knobs. A track plays one as its instrument (`instrument: "patch"`) or runs effect patches in its chain (`fx.patch`); `song.ts` can also hold a library of named patches that tracks reference. + +### Patches in song.ts (SDK 1.35.0) + +`patch(name, build)` builds an instrument patch and `fxPatch(name, build)` an effect patch. The build callback receives a factory per node type (`osc`, `svf`, `adsr`, `vca`, `fx.distort`, `engine.modal`, …), the boundaries `voice` (`pitch`, `gate`, `velocity`, …), `song` (`beat`, `bar.phase`, `tempo`) and `input` (the track's notes, its audio and the sidechain), and `macro(id, { min, max, default, curve })`; the first four macros are the knobs, in call order. + +```ts +const acid = patch("acid-bass", ({ voice, osc, svf, adsr, vca, fx, macro }) => { + const cutoff = macro("cutoff", { + min: 80, + max: 4000, + default: 600, + curve: "exp", + }); + const env = adsr({ + attack: 0.002, + decay: 0.2, + sustain: 0, + release: 0.05, + }).gate(voice.gate); + return osc({ wave: "saw" }) + .pitch(voice.pitch) + .to( + svf({ mode: "lp" }) + .cutoff(cutoff.plus(env.times(2400))) + .q(macro("reso")), + ) + .to(vca().gain(env.times(voice.velocity))) + .to(fx.distort({ drive: 3 }).global()); +}); + +export default track({ + name: "acid", + instrument: acid, + mods: { cutoff: sine.range(300, 2400).slow(4), reso: pat("0.5 0.8 0.6 0.9") }, + notes: seq("A1 A1 C2 A1 G1 A1 E2 D2", { step: 0.25 }), +}); +``` + +- `a.to(b)` cables `a`'s main audio out into `b`'s main audio in and returns `b`. A port setter (`.cutoff(x)`, `.gain(x)`) takes a number, a node, a macro or a pattern; `.input("fm")` and `.output("right")` name other ports. Port setters and settings are typed from the node specs, so a notes source into `cutoff`, an unknown port or a wave word a node does not know is a type error. +- `x.plus(y)`, `x.times(y)`, `x.range(lo, hi)` and `x.amount(k)` (an attenuverter, -1..1) add math nodes; `.global()` moves a node after the voice sum, so it runs once for all voices. +- Node and cable ids come from a stable hash of the patch's name and the build, so the same source builds the same patch; pass `{ id }` to a factory to pin one. +- `patch(name, build, { voices, side, at: "post", from })` sets polyphony, the sidechain track, placement after the effect chain (effect patches) and provenance. +- The plain form `patch({ role, name, nodes, cables, macros })` is what `dawg` prints back; `patch({ ref: "acid-bass", macros: { cutoff: 900 } })` plays a patch from `song({ patches: [...] })` with its own macro values. + +### Patterns as signals + +`sine`, `cosine`, `saw`, `isaw`, `tri`, `square`, `rand`, `perlin`, `irand(n)` and `pat("0 0.5 <1 0.8>")` are Strudel's continuous signals, one cycle a bar, with `.range`, `.rangex`, `.slow`, `.fast`, `.segment`, `.add`, `.mul`, `.early`, `.late` and `.every(n, f)`. Values follow Strudel's formulas (`sine` is 0.5 at the bar start and rises; `rand` and `perlin` use its legacy xorshift, seeded per track from the track id). On a track, `mods: { cutoff: sine.range(300, 2400).slow(4) }` drives a patch macro and `lanes: { "synth-lpf": saw.range(400, 3000) }` any automation lane. `song()` bakes each into automation points (at most 16 a bar; stepped signals such as `pat` and `segment` hold their values), and a lane written under `automation` wins. Wiring a pattern straight into a port (`svf().cutoff(sine.range(300, 2400))`) makes a hidden macro driven the same way. + ## Keys (modeled piano) A track whose `instrument` is a piano family (`grand`, `upright`, `felt`, `honkytonk`, `prepared`) and which has a `keys` field plays dawg's modeled piano (`src/audio/keys/`): a felt hammer of the chosen hardness strikes a bank of stretched, inharmonic string modes (two or three detuned unison strings per key, with a fast first stage and a slow aftersound), a soundboard knock, dampers that stop a released key in about a second, and a small body EQ per family. The 0.5 sustain pedal (down, half, up) holds the dampers off. It is built in: nothing downloads and every render is byte-identical. diff --git a/core/sdk/patch-nodes-gen.test.ts b/core/sdk/patch-nodes-gen.test.ts new file mode 100644 index 00000000..8f6161e1 --- /dev/null +++ b/core/sdk/patch-nodes-gen.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; +import { + GEN_PATH, + SDK_PATH, + generatedModule, + syncedSdk, +} from "../../scripts/gen-patch-sdk.ts"; +import { BOUNDARY_SPECS, NODE_SPECS } from "../patch-nodes.ts"; +import { PATCH_NODE_PORTS } from "./v1.ts"; + +describe("generated patch node types", () => { + test("core/sdk/patch-nodes.gen.ts is up to date (bun scripts/gen-patch-sdk.ts)", async () => { + expect(await readFile(GEN_PATH, "utf8")).toBe(await generatedModule()); + }); + + test("the block spliced into core/sdk/v1.ts is up to date", async () => { + const sdk = await readFile(SDK_PATH, "utf8"); + expect(await syncedSdk(sdk)).toBe(sdk); + }); + + test("every node type and boundary has a port row in the SDK", () => { + expect(Object.keys(PATCH_NODE_PORTS).sort()).toEqual( + [...Object.keys(NODE_SPECS), ...Object.keys(BOUNDARY_SPECS)].sort(), + ); + }); +}); diff --git a/core/sdk/patch-nodes.gen.ts b/core/sdk/patch-nodes.gen.ts new file mode 100644 index 00000000..ce8f5dcf --- /dev/null +++ b/core/sdk/patch-nodes.gen.ts @@ -0,0 +1,3291 @@ +/** + * Patch node types for the SDK, generated from NODE_SPECS + * (core/patch-nodes.ts) by scripts/gen-patch-sdk.ts. Do not edit: the same + * block is spliced into core/sdk/v1.ts, which stays a single vendored file. + */ + +// BEGIN patch nodes: generated from core/patch-nodes.ts by scripts/gen-patch-sdk.ts + +/** Port codes: `n` notes, `a` audio, `c` control, `ca` control that also takes audio. */ +export type PatchPortCode = "n" | "a" | "c" | "ca"; + +/** Every patch node type: its main output's port code, settings, input ports and output ports (NODE_SPECS). */ +export type PatchNodeTable = { + /** oscillator: sine, saw, square, triangle or pulse, with linear FM */ + osc: { + main: "a"; + params: { + /** waveform (saw, square and pulse are band-limited with PolyBLEP) (default "saw") */ + wave?: "sine" | "saw" | "square" | "tri" | "pulse"; + /** frequency in Hz (wire voice.pitch to play the note) (0..20000 Hz, default 440) */ + pitch?: number; + /** detune in cents (-1200..1200 ct, default 0) */ + detune?: number; + /** pulse width (pulse wave) (0.01..0.99, default 0.5) */ + pw?: number; + /** output level (0..4, default 1) */ + level?: number; + }; + inputs: { + /** frequency in Hz (wire voice.pitch to play the note) */ + pitch: "ca"; + /** detune in cents */ + detune: "c"; + /** pulse width (pulse wave) */ + pw: "c"; + /** linear FM: Hz added to the pitch, at audio rate */ + fm: "a"; + /** output level */ + level: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** seeded noise: white, pink or brown */ + noise: { + main: "a"; + params: { + /** noise color (default "white") */ + color?: "white" | "pink" | "brown"; + /** output level (0..4, default 1) */ + level?: number; + }; + inputs: { + /** output level */ + level: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** state-variable filter (TPT): low, high, band or notch */ + svf: { + main: "a"; + params: { + /** filter response (default "lp") */ + mode?: "lp" | "hp" | "bp" | "notch"; + /** cutoff frequency in Hz (20..20000 Hz, default 1000) */ + cutoff?: number; + /** resonance 0..1 (0..1, default 0.5) */ + q?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** cutoff frequency in Hz */ + cutoff: "c"; + /** resonance 0..1 */ + q: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** one-pole low- or high-pass (6 dB/oct) */ + onepole: { + main: "a"; + params: { + /** filter response (default "lp") */ + mode?: "lp" | "hp"; + /** cutoff frequency in Hz (20..20000 Hz, default 1000) */ + cutoff?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** cutoff frequency in Hz */ + cutoff: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** attack, decay, sustain, release envelope 0..1, opened by a gate */ + adsr: { + main: "c"; + params: { + /** gate: above 0.5 opens (wire voice.gate) (0..1, default 0) */ + gate?: number; + /** attack time (0..10 s, default 0.01) */ + attack?: number; + /** decay time (0..10 s, default 0.1) */ + decay?: number; + /** sustain level (0..1, default 0.7) */ + sustain?: number; + /** release time (0..10 s, default 0.2) */ + release?: number; + }; + inputs: { + /** gate: above 0.5 opens (wire voice.gate) */ + gate: "c"; + /** attack time */ + attack: "c"; + /** decay time */ + decay: "c"; + /** sustain level */ + sustain: "c"; + /** release time */ + release: "c"; + }; + outputs: { + /** envelope 0..1 */ + out: "c"; + }; + }; + /** attack-release envelope 0..1 that follows a gate */ + ar: { + main: "c"; + params: { + /** gate: above 0.5 opens (0..1, default 0) */ + gate?: number; + /** attack time (0..10 s, default 0.01) */ + attack?: number; + /** release time (0..10 s, default 0.2) */ + release?: number; + }; + inputs: { + /** gate: above 0.5 opens */ + gate: "c"; + /** attack time */ + attack: "c"; + /** release time */ + release: "c"; + }; + outputs: { + /** envelope 0..1 */ + out: "c"; + }; + }; + /** slew limiter: glides a control toward its input */ + slew: { + main: "c"; + params: { + /** input (-1000000..1000000, default 0) */ + in?: number; + /** time to rise by one unit (0..10 s, default 0.05) */ + rise?: number; + /** time to fall by one unit (0..10 s, default 0.05) */ + fall?: number; + }; + inputs: { + /** input */ + in: "c"; + /** time to rise by one unit */ + rise: "c"; + /** time to fall by one unit */ + fall: "c"; + }; + outputs: { + /** slewed value */ + out: "c"; + }; + }; + /** envelope follower: the level of an audio signal as a control */ + follow: { + main: "c"; + params: { + /** attack time (0.0001..1 s, default 0.005) */ + attack?: number; + /** release time (0.001..4 s, default 0.1) */ + release?: number; + }; + inputs: { + /** audio to follow */ + in: "a"; + /** attack time */ + attack: "c"; + /** release time */ + release: "c"; + }; + outputs: { + /** level 0..1 */ + out: "c"; + }; + }; + /** low-frequency oscillator -1..1, free in Hz or synced to beats */ + lfo: { + main: "c"; + params: { + /** wave shape (random is seeded sample-and-hold per cycle) (default "sine") */ + shape?: "sine" | "tri" | "square" | "saw" | "ramp" | "random"; + /** period in beats; 0 follows rate (0..64 beats, default 0) */ + sync?: number; + /** rate in Hz (when sync is 0) (0..100 Hz, default 1) */ + rate?: number; + /** phase offset in cycles (0..1, default 0) */ + phase?: number; + /** output depth (0..1, default 1) */ + depth?: number; + }; + inputs: { + /** rate in Hz (when sync is 0) */ + rate: "c"; + /** phase offset in cycles */ + phase: "c"; + /** output depth */ + depth: "c"; + }; + outputs: { + /** -1..1 */ + out: "c"; + }; + }; + /** sample and hold: latches its input when the trigger rises */ + sh: { + main: "c"; + params: { + /** value to sample (-1000000..1000000, default 0) */ + in?: number; + /** trigger: a rise through 0.5 samples (0..1, default 0) */ + trig?: number; + }; + inputs: { + /** value to sample */ + in: "c"; + /** trigger: a rise through 0.5 samples */ + trig: "c"; + }; + outputs: { + /** held value */ + out: "c"; + }; + }; + /** seeded random value 0..1, drawn per note or per block */ + random: { + main: "c"; + params: { + /** when a new value is drawn (default "note") */ + per?: "note" | "block"; + }; + inputs: {}; + outputs: { + /** 0..1 */ + out: "c"; + }; + }; + /** a constant control value */ + const: { + main: "c"; + params: { + /** the value (-1000000..1000000, default 0) */ + value?: number; + }; + inputs: {}; + outputs: { + /** the value */ + out: "c"; + }; + }; + /** a + b */ + add: { + main: "c"; + params: { + /** first operand (-1000000..1000000, default 0) */ + a?: number; + /** second operand (-1000000..1000000, default 0) */ + b?: number; + }; + inputs: { + /** first operand */ + a: "c"; + /** second operand */ + b: "c"; + }; + outputs: { + /** result */ + out: "c"; + }; + }; + /** a × b */ + mul: { + main: "c"; + params: { + /** first operand (-1000000..1000000, default 0) */ + a?: number; + /** second operand (-1000000..1000000, default 1) */ + b?: number; + }; + inputs: { + /** first operand */ + a: "c"; + /** second operand */ + b: "c"; + }; + outputs: { + /** result */ + out: "c"; + }; + }; + /** the smaller of a and b */ + min: { + main: "c"; + params: { + /** first operand (-1000000..1000000, default 0) */ + a?: number; + /** second operand (-1000000..1000000, default 0) */ + b?: number; + }; + inputs: { + /** first operand */ + a: "c"; + /** second operand */ + b: "c"; + }; + outputs: { + /** result */ + out: "c"; + }; + }; + /** the larger of a and b */ + max: { + main: "c"; + params: { + /** first operand (-1000000..1000000, default 0) */ + a?: number; + /** second operand (-1000000..1000000, default 0) */ + b?: number; + }; + inputs: { + /** first operand */ + a: "c"; + /** second operand */ + b: "c"; + }; + outputs: { + /** result */ + out: "c"; + }; + }; + /** 1 when a > b, else 0 */ + gt: { + main: "c"; + params: { + /** first operand (-1000000..1000000, default 0) */ + a?: number; + /** second operand (-1000000..1000000, default 0) */ + b?: number; + }; + inputs: { + /** first operand */ + a: "c"; + /** second operand */ + b: "c"; + }; + outputs: { + /** result */ + out: "c"; + }; + }; + /** 1 when a < b, else 0 */ + lt: { + main: "c"; + params: { + /** first operand (-1000000..1000000, default 0) */ + a?: number; + /** second operand (-1000000..1000000, default 0) */ + b?: number; + }; + inputs: { + /** first operand */ + a: "c"; + /** second operand */ + b: "c"; + }; + outputs: { + /** result */ + out: "c"; + }; + }; + /** |in| */ + abs: { + main: "c"; + params: { + /** input (-1000000..1000000, default 0) */ + in?: number; + }; + inputs: { + /** input */ + in: "c"; + }; + outputs: { + /** result */ + out: "c"; + }; + }; + /** 1 when in < 0.5, else 0 */ + not: { + main: "c"; + params: { + /** input (-1000000..1000000, default 0) */ + in?: number; + }; + inputs: { + /** input */ + in: "c"; + }; + outputs: { + /** result */ + out: "c"; + }; + }; + /** MIDI note number to Hz (12-TET, A4 = 440) */ + pitch2hz: { + main: "c"; + params: { + /** input (-1000000..1000000, default 0) */ + in?: number; + }; + inputs: { + /** input */ + in: "c"; + }; + outputs: { + /** result */ + out: "c"; + }; + }; + /** decibels to linear gain */ + db2gain: { + main: "c"; + params: { + /** input (-1000000..1000000, default 0) */ + in?: number; + }; + inputs: { + /** input */ + in: "c"; + }; + outputs: { + /** result */ + out: "c"; + }; + }; + /** maps in from [inmin, inmax] to [min, max], linear or exponential */ + scale: { + main: "c"; + params: { + /** lin or exp (exp needs min > 0) (default "lin") */ + curve?: "lin" | "exp"; + /** input (-1000000..1000000, default 0) */ + in?: number; + /** input range low (-1000000..1000000, default -1) */ + inmin?: number; + /** input range high (-1000000..1000000, default 1) */ + inmax?: number; + /** output range low (-1000000..1000000, default 0) */ + min?: number; + /** output range high (-1000000..1000000, default 1) */ + max?: number; + }; + inputs: { + /** input */ + in: "c"; + /** input range low */ + inmin: "c"; + /** input range high */ + inmax: "c"; + /** output range low */ + min: "c"; + /** output range high */ + max: "c"; + }; + outputs: { + /** mapped value */ + out: "c"; + }; + }; + /** limits in to [min, max] */ + clamp: { + main: "c"; + params: { + /** input (-1000000..1000000, default 0) */ + in?: number; + /** low limit (-1000000..1000000, default 0) */ + min?: number; + /** high limit (-1000000..1000000, default 1) */ + max?: number; + }; + inputs: { + /** input */ + in: "c"; + /** low limit */ + min: "c"; + /** high limit */ + max: "c"; + }; + outputs: { + /** clamped value */ + out: "c"; + }; + }; + /** tempo-synced gate: high for the first half of each cycle */ + clock: { + main: "c"; + params: { + /** period in beats (0.0625..64 beats, default 1) */ + beats?: number; + /** high fraction (0.01..0.99, default 0.5) */ + width?: number; + }; + inputs: { + /** high fraction */ + width: "c"; + }; + outputs: { + /** gate 0/1 */ + out: "c"; + }; + }; + /** amplifier: audio times gain (wire an envelope to gain) */ + vca: { + main: "a"; + params: { + /** gain (0..4, default 1) */ + gain?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** gain */ + gain: "ca"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** four-input audio mixer with a level per input */ + mix: { + main: "a"; + params: { + /** level of a (0..4, default 1) */ + la?: number; + /** level of b (0..4, default 1) */ + lb?: number; + /** level of c (0..4, default 1) */ + lc?: number; + /** level of d (0..4, default 1) */ + ld?: number; + }; + inputs: { + /** input a */ + a: "a"; + /** input b */ + b: "a"; + /** input c */ + c: "a"; + /** input d */ + d: "a"; + /** level of a */ + la: "c"; + /** level of b */ + lb: "c"; + /** level of c */ + lc: "c"; + /** level of d */ + ld: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** equal-gain crossfade from a (x = 0) to b (x = 1) */ + xfade: { + main: "a"; + params: { + /** crossfade position (0..1, default 0.5) */ + x?: number; + }; + inputs: { + /** input a */ + a: "a"; + /** input b */ + b: "a"; + /** crossfade position */ + x: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** equal-power pan of a mono signal to left and right */ + pan: { + main: "a"; + params: { + /** -1 left .. 1 right (-1..1, default 0) */ + pan?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** -1 left .. 1 right */ + pan: "c"; + }; + outputs: { + /** left out */ + left: "a"; + /** right out */ + right: "a"; + }; + }; + /** explicit voice sum: every voice's input, summed once for the track */ + voicesum: { + main: "a"; + params: {}; + inputs: { + /** per-voice audio */ + in: "a"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** the whole synth voice (oscillators, FM, unison, filters, envelopes), samplers and wavetables included */ + "engine.synth": { + main: "a"; + params: { + /** which instrument of this engine plays (default "") */ + instrument?: string; + /** amplitude attack: onset to peak (0..10 s, default 0.003) */ + attack?: number; + /** amplitude decay: peak to sustain level (0..10 s, default 0.05) */ + decay?: number; + /** amplitude sustain level held until note-off (0..1, default 1) */ + sustain?: number; + /** amplitude release after note-off (0..10 s, default 0.05) */ + release?: number; + /** voice gain before the effects chain (track volume follows the chain) (0..4, default 1) */ + gain?: number; + /** pink noise mixed into the oscillator (z_* sounds: phase jitter) (0..1, default 0) */ + noise?: number; + /** crackle density (impulses ≈ density·1000/s) (0..1, default 0.03) */ + density?: number; + /** total pitch spread of the unison voices in semitones (0..12 st, default 0.2) */ + detune?: number; + /** stereo spread of the unison voices (0..1, default 0.6) */ + spread?: number; + /** pulse width (pulse sound) (0..1, default 0.5) */ + pw?: number; + /** pulse-width LFO rate (triangle) (0..40 Hz, default 1) */ + pwrate?: number; + /** pulse-width LFO depth (0..1, default 0) */ + pwsweep?: number; + /** vibrato rate; 0 is off (0..64 Hz, default 0) */ + vib?: number; + /** vibrato depth in semitones (0..24 st, default 0.5) */ + vibmod?: number; + /** pitch envelope depth in semitones (negative inverts); 0 is off (-48..48 st, default 0) */ + penv?: number; + /** pitch envelope attack (0..10 s, default 0.2) */ + pattack?: number; + /** pitch envelope decay (0..10 s, default 0) */ + pdecay?: number; + /** pitch envelope sustain level (0..1, default 1) */ + psustain?: number; + /** pitch envelope release (0..10 s, default 0) */ + prelease?: number; + /** per-note low-pass cutoff; unset is no low-pass (20..20000 Hz, default 2000) */ + lpf?: number; + /** resonance as filter Q (0..50; 0.7 is flat, higher rings) (0..50, default 1) */ + lpq?: number; + /** low-pass envelope depth in octaves above (below, negative) the cutoff (-10..10 oct, default 0) */ + lpenv?: number; + /** low-pass envelope attack (0..10 s, default 0.005) */ + lpattack?: number; + /** low-pass envelope decay (0..10 s, default 0.15) */ + lpdecay?: number; + /** low-pass envelope sustain level (0..1, default 0) */ + lpsustain?: number; + /** low-pass envelope release (0..10 s, default 0.1) */ + lprelease?: number; + /** per-note high-pass cutoff; unset is no high-pass (20..20000 Hz, default 200) */ + hpf?: number; + /** resonance as filter Q (0..50; 0.7 is flat, higher rings) (0..50, default 1) */ + hpq?: number; + /** high-pass envelope depth in octaves above (below, negative) the cutoff (-10..10 oct, default 0) */ + hpenv?: number; + /** high-pass envelope attack (0..10 s, default 0.005) */ + hpattack?: number; + /** high-pass envelope decay (0..10 s, default 0.15) */ + hpdecay?: number; + /** high-pass envelope sustain level (0..1, default 0) */ + hpsustain?: number; + /** high-pass envelope release (0..10 s, default 0.1) */ + hprelease?: number; + /** per-note band-pass cutoff; unset is no band-pass (20..20000 Hz, default 1000) */ + bpf?: number; + /** resonance as filter Q (0..50; 0.7 is flat, higher rings) (0..50, default 1) */ + bpq?: number; + /** band-pass envelope depth in octaves above (below, negative) the cutoff (-10..10 oct, default 0) */ + bpenv?: number; + /** band-pass envelope attack (0..10 s, default 0.005) */ + bpattack?: number; + /** band-pass envelope decay (0..10 s, default 0.15) */ + bpdecay?: number; + /** band-pass envelope sustain level (0..1, default 0) */ + bpsustain?: number; + /** band-pass envelope release (0..10 s, default 0.1) */ + bprelease?: number; + /** filter envelope anchor: 0 sweeps up from the cutoff, 1 down to it (0..1, default 0) */ + fanchor?: number; + /** FM modulation index (peak deviation ÷ modulator frequency); 0 is off (0..64, default 0) */ + fm?: number; + /** FM harmonicity: modulator ÷ carrier frequency (integers sound harmonic) (0..32, default 1) */ + fmh?: number; + /** FM envelope attack (0..10 s, default 0) */ + fmattack?: number; + /** FM envelope decay (0..10 s, default 0) */ + fmdecay?: number; + /** FM envelope sustain level (0..1, default 1) */ + fmsustain?: number; + /** FM envelope release (0..10 s, default 0) */ + fmrelease?: number; + /** FM 2 modulation index (peak deviation ÷ modulator frequency); 0 is off (0..64, default 0) */ + fm2?: number; + /** FM 2 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) (0..32, default 1) */ + fmh2?: number; + /** FM 2 envelope attack (0..10 s, default 0) */ + fmattack2?: number; + /** FM 2 envelope decay (0..10 s, default 0) */ + fmdecay2?: number; + /** FM 2 envelope sustain level (0..1, default 1) */ + fmsustain2?: number; + /** FM 2 envelope release (0..10 s, default 0) */ + fmrelease2?: number; + /** FM 3 modulation index (peak deviation ÷ modulator frequency); 0 is off (0..64, default 0) */ + fm3?: number; + /** FM 3 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) (0..32, default 1) */ + fmh3?: number; + /** FM 3 envelope attack (0..10 s, default 0) */ + fmattack3?: number; + /** FM 3 envelope decay (0..10 s, default 0) */ + fmdecay3?: number; + /** FM 3 envelope sustain level (0..1, default 1) */ + fmsustain3?: number; + /** FM 3 envelope release (0..10 s, default 0) */ + fmrelease3?: number; + /** FM 4 modulation index (peak deviation ÷ modulator frequency); 0 is off (0..64, default 0) */ + fm4?: number; + /** FM 4 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) (0..32, default 1) */ + fmh4?: number; + /** FM 4 envelope attack (0..10 s, default 0) */ + fmattack4?: number; + /** FM 4 envelope decay (0..10 s, default 0) */ + fmdecay4?: number; + /** FM 4 envelope sustain level (0..1, default 1) */ + fmsustain4?: number; + /** FM 4 envelope release (0..10 s, default 0) */ + fmrelease4?: number; + /** FM 5 modulation index (peak deviation ÷ modulator frequency); 0 is off (0..64, default 0) */ + fm5?: number; + /** FM 5 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) (0..32, default 1) */ + fmh5?: number; + /** FM 5 envelope attack (0..10 s, default 0) */ + fmattack5?: number; + /** FM 5 envelope decay (0..10 s, default 0) */ + fmdecay5?: number; + /** FM 5 envelope sustain level (0..1, default 1) */ + fmsustain5?: number; + /** FM 5 envelope release (0..10 s, default 0) */ + fmrelease5?: number; + /** FM 6 modulation index (peak deviation ÷ modulator frequency); 0 is off (0..64, default 0) */ + fm6?: number; + /** FM 6 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) (0..32, default 1) */ + fmh6?: number; + /** FM 6 envelope attack (0..10 s, default 0) */ + fmattack6?: number; + /** FM 6 envelope decay (0..10 s, default 0) */ + fmdecay6?: number; + /** FM 6 envelope sustain level (0..1, default 1) */ + fmsustain6?: number; + /** FM 6 envelope release (0..10 s, default 0) */ + fmrelease6?: number; + /** FM 7 modulation index (peak deviation ÷ modulator frequency); 0 is off (0..64, default 0) */ + fm7?: number; + /** FM 7 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) (0..32, default 1) */ + fmh7?: number; + /** FM 7 envelope attack (0..10 s, default 0) */ + fmattack7?: number; + /** FM 7 envelope decay (0..10 s, default 0) */ + fmdecay7?: number; + /** FM 7 envelope sustain level (0..1, default 1) */ + fmsustain7?: number; + /** FM 7 envelope release (0..10 s, default 0) */ + fmrelease7?: number; + /** FM 8 modulation index (peak deviation ÷ modulator frequency); 0 is off (0..64, default 0) */ + fm8?: number; + /** FM 8 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) (0..32, default 1) */ + fmh8?: number; + /** FM 8 envelope attack (0..10 s, default 0) */ + fmattack8?: number; + /** FM 8 envelope decay (0..10 s, default 0) */ + fmdecay8?: number; + /** FM 8 envelope sustain level (0..1, default 1) */ + fmsustain8?: number; + /** FM 8 envelope release (0..10 s, default 0) */ + fmrelease8?: number; + /** z_*: random pitch offset per note, ± fraction (0..1, default 0) */ + zrand?: number; + /** z_*: wave shape exponent (0 squares the wave off, >1 thins it) (0..3, default 1) */ + curve?: number; + /** z_*: pitch slide, 500·slide Hz per second (-20..20, default 0) */ + slide?: number; + /** z_*: slide acceleration, 500·deltaSlide Hz per second² (-20..20, default 0) */ + deltaSlide?: number; + /** z_*: pitch change applied after pitchJumpTime (-2000..2000 Hz, default 0) */ + pitchJump?: number; + /** z_*: time before pitchJump applies (0: never) (0..10 s, default 0) */ + pitchJumpTime?: number; + /** z_*: repeat period: restarts slide and pitchJump, sets the tremolo period (0..10 s, default 0) */ + lfo?: number; + /** z_*: frequency-modulation speed (±50 % depth) (0..1000 Hz, default 0) */ + zmod?: number; + /** z_*: sample-hold bit crush, 0..1 (0..1, default 0) */ + zcrush?: number; + /** z_*: one echo this many seconds later, half level (0..1 s, default 0) */ + zdelay?: number; + /** z_*: volume modulation amount at the lfo period (0..1, default 0) */ + tremolo?: number; + /** engine settings, as the track field holds them */ + settings?: Readonly>; + }; + inputs: { + /** the notes to play (wire in.notes) */ + notes: "n"; + /** amplitude attack: onset to peak */ + attack: "c"; + /** amplitude decay: peak to sustain level */ + decay: "c"; + /** amplitude sustain level held until note-off */ + sustain: "c"; + /** amplitude release after note-off */ + release: "c"; + /** voice gain before the effects chain (track volume follows the chain) */ + gain: "c"; + /** pink noise mixed into the oscillator (z_* sounds: phase jitter) */ + noise: "c"; + /** crackle density (impulses ≈ density·1000/s) */ + density: "c"; + /** total pitch spread of the unison voices in semitones */ + detune: "c"; + /** stereo spread of the unison voices */ + spread: "c"; + /** pulse width (pulse sound) */ + pw: "c"; + /** pulse-width LFO rate (triangle) */ + pwrate: "c"; + /** pulse-width LFO depth */ + pwsweep: "c"; + /** vibrato rate; 0 is off */ + vib: "c"; + /** vibrato depth in semitones */ + vibmod: "c"; + /** pitch envelope depth in semitones (negative inverts); 0 is off */ + penv: "c"; + /** pitch envelope attack */ + pattack: "c"; + /** pitch envelope decay */ + pdecay: "c"; + /** pitch envelope sustain level */ + psustain: "c"; + /** pitch envelope release */ + prelease: "c"; + /** per-note low-pass cutoff; unset is no low-pass */ + lpf: "c"; + /** resonance as filter Q (0..50; 0.7 is flat, higher rings) */ + lpq: "c"; + /** low-pass envelope depth in octaves above (below, negative) the cutoff */ + lpenv: "c"; + /** low-pass envelope attack */ + lpattack: "c"; + /** low-pass envelope decay */ + lpdecay: "c"; + /** low-pass envelope sustain level */ + lpsustain: "c"; + /** low-pass envelope release */ + lprelease: "c"; + /** per-note high-pass cutoff; unset is no high-pass */ + hpf: "c"; + /** resonance as filter Q (0..50; 0.7 is flat, higher rings) */ + hpq: "c"; + /** high-pass envelope depth in octaves above (below, negative) the cutoff */ + hpenv: "c"; + /** high-pass envelope attack */ + hpattack: "c"; + /** high-pass envelope decay */ + hpdecay: "c"; + /** high-pass envelope sustain level */ + hpsustain: "c"; + /** high-pass envelope release */ + hprelease: "c"; + /** per-note band-pass cutoff; unset is no band-pass */ + bpf: "c"; + /** resonance as filter Q (0..50; 0.7 is flat, higher rings) */ + bpq: "c"; + /** band-pass envelope depth in octaves above (below, negative) the cutoff */ + bpenv: "c"; + /** band-pass envelope attack */ + bpattack: "c"; + /** band-pass envelope decay */ + bpdecay: "c"; + /** band-pass envelope sustain level */ + bpsustain: "c"; + /** band-pass envelope release */ + bprelease: "c"; + /** filter envelope anchor: 0 sweeps up from the cutoff, 1 down to it */ + fanchor: "c"; + /** FM modulation index (peak deviation ÷ modulator frequency); 0 is off */ + fm: "c"; + /** FM harmonicity: modulator ÷ carrier frequency (integers sound harmonic) */ + fmh: "c"; + /** FM envelope attack */ + fmattack: "c"; + /** FM envelope decay */ + fmdecay: "c"; + /** FM envelope sustain level */ + fmsustain: "c"; + /** FM envelope release */ + fmrelease: "c"; + /** FM 2 modulation index (peak deviation ÷ modulator frequency); 0 is off */ + fm2: "c"; + /** FM 2 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) */ + fmh2: "c"; + /** FM 2 envelope attack */ + fmattack2: "c"; + /** FM 2 envelope decay */ + fmdecay2: "c"; + /** FM 2 envelope sustain level */ + fmsustain2: "c"; + /** FM 2 envelope release */ + fmrelease2: "c"; + /** FM 3 modulation index (peak deviation ÷ modulator frequency); 0 is off */ + fm3: "c"; + /** FM 3 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) */ + fmh3: "c"; + /** FM 3 envelope attack */ + fmattack3: "c"; + /** FM 3 envelope decay */ + fmdecay3: "c"; + /** FM 3 envelope sustain level */ + fmsustain3: "c"; + /** FM 3 envelope release */ + fmrelease3: "c"; + /** FM 4 modulation index (peak deviation ÷ modulator frequency); 0 is off */ + fm4: "c"; + /** FM 4 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) */ + fmh4: "c"; + /** FM 4 envelope attack */ + fmattack4: "c"; + /** FM 4 envelope decay */ + fmdecay4: "c"; + /** FM 4 envelope sustain level */ + fmsustain4: "c"; + /** FM 4 envelope release */ + fmrelease4: "c"; + /** FM 5 modulation index (peak deviation ÷ modulator frequency); 0 is off */ + fm5: "c"; + /** FM 5 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) */ + fmh5: "c"; + /** FM 5 envelope attack */ + fmattack5: "c"; + /** FM 5 envelope decay */ + fmdecay5: "c"; + /** FM 5 envelope sustain level */ + fmsustain5: "c"; + /** FM 5 envelope release */ + fmrelease5: "c"; + /** FM 6 modulation index (peak deviation ÷ modulator frequency); 0 is off */ + fm6: "c"; + /** FM 6 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) */ + fmh6: "c"; + /** FM 6 envelope attack */ + fmattack6: "c"; + /** FM 6 envelope decay */ + fmdecay6: "c"; + /** FM 6 envelope sustain level */ + fmsustain6: "c"; + /** FM 6 envelope release */ + fmrelease6: "c"; + /** FM 7 modulation index (peak deviation ÷ modulator frequency); 0 is off */ + fm7: "c"; + /** FM 7 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) */ + fmh7: "c"; + /** FM 7 envelope attack */ + fmattack7: "c"; + /** FM 7 envelope decay */ + fmdecay7: "c"; + /** FM 7 envelope sustain level */ + fmsustain7: "c"; + /** FM 7 envelope release */ + fmrelease7: "c"; + /** FM 8 modulation index (peak deviation ÷ modulator frequency); 0 is off */ + fm8: "c"; + /** FM 8 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) */ + fmh8: "c"; + /** FM 8 envelope attack */ + fmattack8: "c"; + /** FM 8 envelope decay */ + fmdecay8: "c"; + /** FM 8 envelope sustain level */ + fmsustain8: "c"; + /** FM 8 envelope release */ + fmrelease8: "c"; + /** z_*: random pitch offset per note, ± fraction */ + zrand: "c"; + /** z_*: wave shape exponent (0 squares the wave off, >1 thins it) */ + curve: "c"; + /** z_*: pitch slide, 500·slide Hz per second */ + slide: "c"; + /** z_*: slide acceleration, 500·deltaSlide Hz per second² */ + deltaSlide: "c"; + /** z_*: pitch change applied after pitchJumpTime */ + pitchJump: "c"; + /** z_*: time before pitchJump applies (0: never) */ + pitchJumpTime: "c"; + /** z_*: repeat period: restarts slide and pitchJump, sets the tremolo period */ + lfo: "c"; + /** z_*: frequency-modulation speed (±50 % depth) */ + zmod: "c"; + /** z_*: sample-hold bit crush, 0..1 */ + zcrush: "c"; + /** z_*: one echo this many seconds later, half level */ + zdelay: "c"; + /** z_*: volume modulation amount at the lfo period */ + tremolo: "c"; + }; + outputs: { + /** audio out (left when stereo) */ + out: "a"; + /** right out (equals out for a mono engine) */ + right: "a"; + }; + }; + /** modal resonators: mallets, bars, bells and plates */ + "engine.modal": { + main: "a"; + params: { + /** which instrument of this engine plays (default "modal") */ + instrument?: "modal"; + /** mallet hardness: 0 yarn/felt, 1 brass (0..1, default 0.4) */ + hardness?: number; + /** strike point: 0 end or edge, 0.5 center (0..1, default 0.42) */ + position?: number; + /** ring time (T60) at middle C (0.05..30 s, default 1.6) */ + ring?: number; + /** how much faster high modes and notes decay (0..2, default 0.9) */ + tilt?: number; + /** damping at note-off: 0 rings on, 1 chokes (0..1, default 0) */ + damp?: number; + /** motor tremolo depth (0..1, default 0) */ + motordepth?: number; + /** buzzer or jingle amount (0..1, default 0) */ + buzz?: number; + /** mallet contact click (0..1, default 0) */ + click?: number; + /** level (0..2, default 0.8) */ + gain?: number; + /** engine settings, as the track field holds them */ + settings?: Readonly>; + }; + inputs: { + /** the notes to play (wire in.notes) */ + notes: "n"; + /** mallet hardness: 0 yarn/felt, 1 brass */ + hardness: "c"; + /** strike point: 0 end or edge, 0.5 center */ + position: "c"; + /** ring time (T60) at middle C */ + ring: "c"; + /** how much faster high modes and notes decay */ + tilt: "c"; + /** damping at note-off: 0 rings on, 1 chokes */ + damp: "c"; + /** motor tremolo depth */ + motordepth: "c"; + /** buzzer or jingle amount */ + buzz: "c"; + /** mallet contact click */ + click: "c"; + /** level */ + gain: "c"; + }; + outputs: { + /** audio out (left when stereo) */ + out: "a"; + /** right out (equals out for a mono engine) */ + right: "a"; + }; + }; + /** physically modeled plucked and bowed strings */ + "engine.string": { + main: "a"; + params: { + /** which instrument of this engine plays (default "string") */ + instrument?: "string"; + /** how long a note rings (T60 at C4) (0.05..60 s, default 3) */ + ring?: number; + /** high-frequency loss (dark, dead strings at 1) (0..1, default 0.4) */ + damp?: number; + /** pluck or strike position from the bridge (0.5 round, 0.04 nasal) (0.02..0.5, default 0.15) */ + pos?: number; + /** excitation brightness at full velocity (0..1, default 0.6) */ + bright?: number; + /** palm mute: shortens the ring and darkens the string (0..1, default 0) */ + mute?: number; + /** bridge buzz (sitar, tanpura jawari); 0 off (0..1, default 0) */ + buzz?: number; + /** vibrato rate (a note's own vibrato overrides it) (0..12 Hz, default 0) */ + vib?: number; + /** vibrato depth in semitones (0..2 st, default 0.18) */ + vibmod?: number; + /** output level (presets carry a calibrated trim) (0..2, default 1) */ + gain?: number; + /** bow force within the playable range: flautando at 0, gritty at 1 (0..1, default 0.5) */ + pressure?: number; + /** bow speed at full dynamics (loudness) (0..1, default 0.6) */ + speed?: number; + /** con sordino: the practice mute, darker and softer (0..1, default 0) */ + sord?: number; + /** dynamics on top of velocity, drives bow speed and pressure (swells) (0..1, default 1) */ + dyn?: number; + /** engine settings, as the track field holds them */ + settings?: Readonly>; + }; + inputs: { + /** the notes to play (wire in.notes) */ + notes: "n"; + /** how long a note rings (T60 at C4) */ + ring: "c"; + /** high-frequency loss (dark, dead strings at 1) */ + damp: "c"; + /** pluck or strike position from the bridge (0.5 round, 0.04 nasal) */ + pos: "c"; + /** excitation brightness at full velocity */ + bright: "c"; + /** palm mute: shortens the ring and darkens the string */ + mute: "c"; + /** bridge buzz (sitar, tanpura jawari); 0 off */ + buzz: "c"; + /** vibrato rate (a note's own vibrato overrides it) */ + vib: "c"; + /** vibrato depth in semitones */ + vibmod: "c"; + /** output level (presets carry a calibrated trim) */ + gain: "c"; + /** bow force within the playable range: flautando at 0, gritty at 1 */ + pressure: "c"; + /** bow speed at full dynamics (loudness) */ + speed: "c"; + /** con sordino: the practice mute, darker and softer */ + sord: "c"; + /** dynamics on top of velocity, drives bow speed and pressure (swells) */ + dyn: "c"; + }; + outputs: { + /** audio out (left when stereo) */ + out: "a"; + /** right out (equals out for a mono engine) */ + right: "a"; + }; + }; + /** physically modeled winds and brass */ + "engine.wind": { + main: "a"; + params: { + /** which instrument of this engine plays (default "wind") */ + instrument?: "wind"; + /** blowing pressure: swells, louder and fuller (0..1, default 0.6) */ + breath?: number; + /** breath noise (0..1, default 0.08) */ + noise?: number; + /** plunger opening: 0 closed, 1 open (0..1, default 1) */ + wah?: number; + /** hum into the horn: rough, raspy (0..1, default 0) */ + growl?: number; + /** flutter tongue (rolled r) (0..1, default 0) */ + flutter?: number; + /** engine settings, as the track field holds them */ + settings?: Readonly>; + }; + inputs: { + /** the notes to play (wire in.notes) */ + notes: "n"; + /** blowing pressure: swells, louder and fuller */ + breath: "c"; + /** breath noise */ + noise: "c"; + /** plunger opening: 0 closed, 1 open */ + wah: "c"; + /** hum into the horn: rough, raspy */ + growl: "c"; + /** flutter tongue (rolled r) */ + flutter: "c"; + }; + outputs: { + /** audio out (left when stereo) */ + out: "a"; + /** right out (equals out for a mono engine) */ + right: "a"; + }; + }; + /** the singing voice (lyrics, formants, choir) */ + "engine.sing": { + main: "a"; + params: { + /** which instrument of this engine plays (default "sing") */ + instrument?: "sing"; + /** how far an a>o vowel travels by the note's end (0..1, default 1) */ + morph?: number; + /** throat size: formant shift, keeps pitch (-12..12 st, default 0) */ + formant?: number; + /** voice quality: breathy and dark .. pressed (0..1, default 0.5) */ + bright?: number; + /** aspiration noise (0..1, default 0.12) */ + breath?: number; + /** vibrato depth (0..1 st, default 0.3) */ + vibmod?: number; + /** singer's formant (bright 3 kHz ring) (0..1, default 0) */ + ring?: number; + /** overtone filter sharpness and level (0..1, default 0) */ + overtone?: number; + /** kargyraa subharmonic (an octave below) (0..1, default 0) */ + sub?: number; + /** engine settings, as the track field holds them */ + settings?: Readonly>; + }; + inputs: { + /** the notes to play (wire in.notes) */ + notes: "n"; + /** how far an a>o vowel travels by the note's end */ + morph: "c"; + /** throat size: formant shift, keeps pitch */ + formant: "c"; + /** voice quality: breathy and dark .. pressed */ + bright: "c"; + /** aspiration noise */ + breath: "c"; + /** vibrato depth */ + vibmod: "c"; + /** singer's formant (bright 3 kHz ring) */ + ring: "c"; + /** overtone filter sharpness and level */ + overtone: "c"; + /** kargyraa subharmonic (an octave below) */ + sub: "c"; + }; + outputs: { + /** audio out (left when stereo) */ + out: "a"; + /** right out (equals out for a mono engine) */ + right: "a"; + }; + }; + /** granular player over a sample or the track's own source */ + "engine.granular": { + main: "a"; + params: { + /** which instrument of this engine plays (default "granular") */ + instrument?: "granular"; + /** where the head starts inside the region (0..1, default 0) */ + pos?: number; + /** head speed: 1 the source's own speed, 0 held, negative backwards (-4..4 x, default 1) */ + scan?: number; + /** grain length (0.005..2 s, default 0.08) */ + grain?: number; + /** grains sounding at once (density = overlap / grain) (0.05..32, default 4) */ + overlap?: number; + /** onset randomness, fraction of the grain period (0..1, default 0.25) */ + jitter?: number; + /** random offset of each grain's read position (0..2 s, default 0.01) */ + spray?: number; + /** semitones on top of the note (-48..48 st, default 0) */ + pitch?: number; + /** random per-grain pitch spread (± half) (0..24 st, default 0) */ + detune?: number; + /** chance a grain plays shimint semitones up (0..1, default 0) */ + shimmer?: number; + /** stereo spread of the grains (0..1, default 0.3) */ + spread?: number; + /** chance a grain plays backwards (0..1, default 0) */ + reverse?: number; + /** chance a grain step latches the head (beat repeat) (0..1, default 0) */ + repeat?: number; + /** slow random walk of the head (depth) (0..1, default 0) */ + drift?: number; + /** engine settings, as the track field holds them */ + settings?: Readonly>; + }; + inputs: { + /** the notes to play (wire in.notes) */ + notes: "n"; + /** where the head starts inside the region */ + pos: "c"; + /** head speed: 1 the source's own speed, 0 held, negative backwards */ + scan: "c"; + /** grain length */ + grain: "c"; + /** grains sounding at once (density = overlap / grain) */ + overlap: "c"; + /** onset randomness, fraction of the grain period */ + jitter: "c"; + /** random offset of each grain's read position */ + spray: "c"; + /** semitones on top of the note */ + pitch: "c"; + /** random per-grain pitch spread (± half) */ + detune: "c"; + /** chance a grain plays shimint semitones up */ + shimmer: "c"; + /** stereo spread of the grains */ + spread: "c"; + /** chance a grain plays backwards */ + reverse: "c"; + /** chance a grain step latches the head (beat repeat) */ + repeat: "c"; + /** slow random walk of the head (depth) */ + drift: "c"; + }; + outputs: { + /** audio out (left when stereo) */ + out: "a"; + /** right out (equals out for a mono engine) */ + right: "a"; + }; + }; + /** modeled pianos, electric keys and organs */ + "engine.keys": { + main: "a"; + params: { + /** which instrument of this engine plays (default "grand") */ + instrument?: + | "grand" + | "upright" + | "felt" + | "honkytonk" + | "prepared" + | "epiano" + | "wurli" + | "clav" + | "tonewheel" + | "combo" + | "pipe"; + /** hammer felt hardness: brightness at a given velocity (0..1, default 0.5) */ + hardness?: number; + /** velocity sensitivity (0 plays every note at 0.8) (0..1, default 1) */ + touch?: number; + /** sustain time multiplier (0.1..4 x, default 1) */ + decay?: number; + /** damper time multiplier (how fast a released key stops) (0.1..4 x, default 1) */ + release?: number; + /** soundboard knock and hammer thump (0..1, default 0.5) */ + knock?: number; + /** key-off and damper mechanics (0..1, default 0.25) */ + noise?: number; + /** felt strip between hammers and strings (0..1, default 0) */ + felt?: number; + /** output low-pass of the electric keys; 0 is off (0..12000 Hz, default 0) */ + tone?: number; + /** suitcase stereo vibrato depth: antiphase left/right pan (epiano) (0..1, default 0) */ + vibe?: number; + /** tremolo depth: reed piano at 5.6 Hz (wurli), pipe tremulant (pipe) (0..1, default 0) */ + trem?: number; + /** organ preamp overdrive (0..1, default 0.15) */ + drive?: number; + /** rotary speed: 0 stop, 1 slow, 2 fast (0..2, default 1) */ + rotary?: number; + /** engine settings, as the track field holds them */ + settings?: Readonly>; + }; + inputs: { + /** the notes to play (wire in.notes) */ + notes: "n"; + /** hammer felt hardness: brightness at a given velocity */ + hardness: "c"; + /** velocity sensitivity (0 plays every note at 0.8) */ + touch: "c"; + /** sustain time multiplier */ + decay: "c"; + /** damper time multiplier (how fast a released key stops) */ + release: "c"; + /** soundboard knock and hammer thump */ + knock: "c"; + /** key-off and damper mechanics */ + noise: "c"; + /** felt strip between hammers and strings */ + felt: "c"; + /** output low-pass of the electric keys; 0 is off */ + tone: "c"; + /** suitcase stereo vibrato depth: antiphase left/right pan (epiano) */ + vibe: "c"; + /** tremolo depth: reed piano at 5.6 Hz (wurli), pipe tremulant (pipe) */ + trem: "c"; + /** organ preamp overdrive */ + drive: "c"; + /** rotary speed: 0 stop, 1 slow, 2 fast */ + rotary: "c"; + }; + outputs: { + /** audio out (left when stereo) */ + out: "a"; + /** right out (equals out for a mono engine) */ + right: "a"; + }; + }; + /** resonant biquad: low-pass, high-pass or band-pass */ + "fx.filter": { + main: "a"; + params: { + /** lpf low-pass (default), hpf high-pass, bpf band-pass (default "lpf") */ + type?: "lpf" | "hpf" | "bpf"; + /** slope: 12db biquad (default), 24db two biquads, ladder 4-pole (low-pass only) (default "12db") */ + ftype?: "12db" | "24db" | "ladder"; + /** cutoff (lpf/hpf) or center (bpf) frequency (20..20000 Hz, default 2000) */ + cutoff?: number; + /** resonance 0..1 (Q 0.707..8) (0..1, default 0) */ + resonance?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** cutoff (lpf/hpf) or center (bpf) frequency */ + cutoff: "c"; + /** resonance 0..1 (Q 0.707..8) */ + resonance: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** one-knob filter: below 0.5 low-pass, above 0.5 high-pass, 0.5 open */ + "fx.djf": { + main: "a"; + params: { + /** 0 dark (20 Hz low-pass) … 0.5 open … 1 thin (10 kHz high-pass) (0..1, default 0.5) */ + value?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** 0 dark (20 Hz low-pass) … 0.5 open … 1 thin (10 kHz high-pass) */ + value: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** filter whose cutoff an LFO sweeps around a center, optionally opened by the input level */ + "fx.autofilter": { + main: "a"; + params: { + /** filter type (default "lpf") */ + type?: "lpf" | "hpf" | "bpf"; + /** LFO shape; random is sample-and-hold, one step per cycle (default "sine") */ + shape?: "sine" | "tri" | "square" | "saw" | "ramp" | "random"; + /** center cutoff (20..20000 Hz, default 1200) */ + cutoff?: number; + /** resonance 0..1 (Q 0.707..8) (0..1, default 0.3) */ + resonance?: number; + /** sweep width in octaves around the center (0..6 oct, default 2) */ + depth?: number; + /** LFO period in beats (tempo-synced); 0 uses rate in Hz (0..64 beats, default 4) */ + sync?: number; + /** LFO rate in Hz (used when sync is 0) (0.01..40 Hz, default 0.5) */ + rate?: number; + /** LFO start phase in cycles (0..1, default 0) */ + phase?: number; + /** envelope follower: octaves the cutoff moves at full input level (-6..6 oct, default 0) */ + follow?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** center cutoff */ + cutoff: "c"; + /** resonance 0..1 (Q 0.707..8) */ + resonance: "c"; + /** sweep width in octaves around the center */ + depth: "c"; + /** LFO period in beats (tempo-synced); 0 uses rate in Hz */ + sync: "c"; + /** LFO rate in Hz (used when sync is 0) */ + rate: "c"; + /** LFO start phase in cycles */ + phase: "c"; + /** envelope follower: octaves the cutoff moves at full input level */ + follow: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** formant shift at constant pitch: moves the spectral envelope (throat or gender knob), any source */ + "fx.formant": { + main: "a"; + params: { + /** semitones the formants move (negative deeper, positive smaller); pitch stays (-12..12 st, default 0) */ + shift?: number; + /** wet/dry balance 0..1 (0..1, default 1) */ + mix?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** semitones the formants move (negative deeper, positive smaller); pitch stays */ + shift: "c"; + /** wet/dry balance 0..1 */ + mix: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** formant filter bank: five band-passes per vowel */ + "fx.vowel": { + main: "a"; + params: { + /** vowel formants (a e i o u plus the extended set) (default "a") */ + vowel?: + | "a" + | "e" + | "i" + | "o" + | "u" + | "ae" + | "aa" + | "oe" + | "ue" + | "y" + | "uh" + | "un" + | "en" + | "an" + | "on"; + /** vowel to morph toward (absent: no morph) (default "a") */ + to?: + | "a" + | "e" + | "i" + | "o" + | "u" + | "ae" + | "aa" + | "oe" + | "ue" + | "y" + | "uh" + | "un" + | "en" + | "an" + | "on"; + /** wet/dry balance 0..1 (0..1, default 1) */ + mix?: number; + /** position between vowel (0) and to (1), log-frequency formant morph (0..1, default 0) */ + morph?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** wet/dry balance 0..1 */ + mix: "c"; + /** position between vowel (0) and to (1), log-frequency formant morph */ + morph: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** bit-depth and sample-rate reduction */ + "fx.crush": { + main: "a"; + params: { + /** bit depth: 1 heavy .. 16 nearly clean (1..16, default 8) */ + bits?: number; + /** sample-and-hold factor: 1 off, 2 half rate, 3 a third… (1..64, default 1) */ + coarse?: number; + /** wet/dry balance 0..1 (0..1, default 1) */ + mix?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** bit depth: 1 heavy .. 16 nearly clean */ + bits: "c"; + /** sample-and-hold factor: 1 off, 2 half rate, 3 a third… */ + coarse: "c"; + /** wet/dry balance 0..1 */ + mix: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** waveshaper with drive, post-tone and automatic gain compensation */ + "fx.distort": { + main: "a"; + params: { + /** curve: soft (tanh), hard clip, cubic, diode, asym, fold, sinefold, chebyshev, scurve, shape (Strudel shape's curve) (default "soft") */ + type?: + | "soft" + | "hard" + | "cubic" + | "diode" + | "asym" + | "fold" + | "sinefold" + | "chebyshev" + | "scurve" + | "shape"; + /** drive 0..10 (Strudel distort amount) (0..10, default 2) */ + drive?: number; + /** low-pass after the shaper; tames fizz (200..20000 Hz, default 8000) */ + tone?: number; + /** wet/dry balance 0..1 (0..1, default 1) */ + mix?: number; + /** linear gain after compensation (Strudel distort postgain) (0..2, default 1) */ + postgain?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** drive 0..10 (Strudel distort amount) */ + drive: "c"; + /** low-pass after the shaper; tames fizz */ + tone: "c"; + /** wet/dry balance 0..1 */ + mix: "c"; + /** linear gain after compensation (Strudel distort postgain) */ + postgain: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** guitar pedal before the amp: fuzz (Big Muff), face (Fuzz Face), od (Tube Screamer), rat, octave (Octavia); oversampled */ + "fx.stomp": { + main: "a"; + params: { + /** circuit: fuzz (Big Muff), face (Fuzz Face), od (Tube Screamer), rat (RAT), octave (Octavia) (default "od") */ + type?: "fuzz" | "face" | "od" | "rat" | "octave"; + /** the pedal's gain / sustain / distortion knob, 0..10 (0..10, default 5) */ + gain?: number; + /** dark 0 .. bright 1 (0..1, default 0.5) */ + tone?: number; + /** trim over a level-matched pedal (0 = bypass loudness) (-24..12 dB, default 0) */ + level?: number; + /** octave: blend of the octave-up (rectified) path (0..1, default 0.7) */ + octave?: number; + /** wet/dry balance 0..1 (0..1, default 1) */ + mix?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** the pedal's gain / sustain / distortion knob, 0..10 */ + gain: "c"; + /** dark 0 .. bright 1 */ + tone: "c"; + /** trim over a level-matched pedal (0 = bypass loudness) */ + level: "c"; + /** octave: blend of the octave-up (rectified) path */ + octave: "c"; + /** wet/dry balance 0..1 */ + mix: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** guitar amp: preamp stages, Yeh-Smith tone stack, power amp with sag, level-matched across types; optional noise gate */ + "fx.head": { + main: "a"; + params: { + /** clean (blackface), chime (AC30), crunch (plexi), lead (JCM800), high (modern high gain), solid (JC-120), bass (SVT) (default "crunch") */ + type?: "clean" | "chime" | "crunch" | "lead" | "high" | "solid" | "bass"; + /** preamp gain 0..10 (level-matched: more gain, not more volume) (0..10, default 5) */ + gain?: number; + /** tone stack bass 0..10 (0..10, default 5) */ + bass?: number; + /** tone stack mid 0..10 (0..10, default 5) */ + mid?: number; + /** tone stack treble 0..10 (0..10, default 5) */ + treble?: number; + /** power amp presence 0..10 (5 flat) (0..10, default 5) */ + presence?: number; + /** power amp drive 0..10 (0..10, default 5) */ + master?: number; + /** power supply sag 0..1 (absent: the head type's own) (0..1, default 0.3) */ + sag?: number; + /** noise gate threshold before the amp (absent: no gate; 0 dB gates all) (-96..0 dB, default -60) */ + gate?: number; + /** output level after level matching (-24..12 dB, default 0) */ + level?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** preamp gain 0..10 (level-matched: more gain, not more volume) */ + gain: "c"; + /** tone stack bass 0..10 */ + bass: "c"; + /** tone stack mid 0..10 */ + mid: "c"; + /** tone stack treble 0..10 */ + treble: "c"; + /** power amp presence 0..10 (5 flat) */ + presence: "c"; + /** power amp drive 0..10 */ + master: "c"; + /** power supply sag 0..1 (absent: the head type's own) */ + sag: "c"; + /** noise gate threshold before the amp (absent: no gate; 0 dB gates all) */ + gate: "c"; + /** output level after level matching */ + level: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** speaker cabinet and microphone (biquad model, no impulse response) */ + "fx.cab": { + main: "a"; + params: { + /** 1x12, 2x12, 4x12, 1x10, open back, 8x10 and 1x15 bass, di (no speaker) (default "2x12") */ + type?: + "1x12" | "2x12" | "4x12" | "1x10" | "open" | "8x10" | "1x15" | "di"; + /** microphone position: 0 center (bright) .. 1 edge (dark) (0..1, default 0.3) */ + mic?: number; + /** wet/dry balance 0..1 (0..1, default 1) */ + mix?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** microphone position: 0 center (bright) .. 1 edge (dark) */ + mic: "c"; + /** wet/dry balance 0..1 */ + mix: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** held tremolo arm: slow seeded pitch wow and flutter of the whole track */ + "fx.wobble": { + main: "a"; + params: { + /** peak pitch deviation in cents (0..100 c, default 20) */ + depth?: number; + /** wobble rate (0.05..8 Hz, default 0.5) */ + rate?: number; + /** 0 periodic .. 1 wandering (seeded, so every render matches) (0..1, default 0.3) */ + drift?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** peak pitch deviation in cents */ + depth: "c"; + /** wobble rate */ + rate: "c"; + /** 0 periodic .. 1 wandering (seeded, so every render matches) */ + drift: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** feedback bloom: the top held note grows a singing harmonic */ + "fx.bloom": { + main: "a"; + params: { + /** level of the feedback partial (0..1, default 0.5) */ + amount?: number; + /** harmonic that feeds back: 1 the note, 2 its octave, 3 the fifth above (1..4, default 2) */ + harm?: number; + /** how long a note is held before it starts to feed back (0..4 s, default 0.6) */ + delay?: number; + /** how fast the feedback grows (0.05..4 s, default 1) */ + time?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** level of the feedback partial */ + amount: "c"; + /** harmonic that feeds back: 1 the note, 2 its octave, 3 the fifth above */ + harm: "c"; + /** how long a note is held before it starts to feed back */ + delay: "c"; + /** how fast the feedback grows */ + time: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** volume swell: each strum fades in, no pick attack */ + "fx.swell": { + main: "a"; + params: { + /** rise time after each onset (0.01..4 s, default 0.4) */ + time?: number; + /** wet/dry balance 0..1 (0..1, default 1) */ + mix?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** rise time after each onset */ + time: "c"; + /** wet/dry balance 0..1 */ + mix: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** volume modulation */ + "fx.tremolo": { + main: "a"; + params: { + /** LFO shape (default "sine") */ + shape?: "sine" | "tri" | "square" | "saw" | "ramp"; + /** LFO period in beats (tempo-synced); 0 uses rate in Hz (0..64 beats, default 0.5) */ + sync?: number; + /** LFO rate in Hz (used when sync is 0) (0.01..40 Hz, default 4) */ + rate?: number; + /** how far the level dips, 0..1 (0..1, default 0.5) */ + depth?: number; + /** where in the cycle the peak falls (0.5 symmetric) (0..1, default 0.5) */ + skew?: number; + /** start phase in cycles (0..1, default 0) */ + phase?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** LFO period in beats (tempo-synced); 0 uses rate in Hz */ + sync: "c"; + /** LFO rate in Hz (used when sync is 0) */ + rate: "c"; + /** how far the level dips, 0..1 */ + depth: "c"; + /** where in the cycle the peak falls (0.5 symmetric) */ + skew: "c"; + /** start phase in cycles */ + phase: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** feed-forward RMS-ish compressor with soft knee and make-up gain */ + "fx.compressor": { + main: "a"; + params: { + /** level where compression starts (-60..0 dB, default -18) */ + threshold?: number; + /** input:output above threshold (1..20, default 4) */ + ratio?: number; + /** soft-knee width (0..24 dB, default 6) */ + knee?: number; + /** attack time (0.0001..1 s, default 0.01) */ + attack?: number; + /** release time (0.01..2 s, default 0.15) */ + release?: number; + /** gain after compression (0..24 dB, default 5) */ + makeup?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** level where compression starts */ + threshold: "c"; + /** input:output above threshold */ + ratio: "c"; + /** soft-knee width */ + knee: "c"; + /** attack time */ + attack: "c"; + /** release time */ + release: "c"; + /** gain after compression */ + makeup: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** automatic double tracking: a seeded second take, spread left and right */ + "fx.double": { + main: "a"; + params: { + /** how late the second take plays (5..60 ms, default 22) */ + time?: number; + /** how far the second take's timing wanders (0..10 ms, default 3) */ + drift?: number; + /** 0 centered .. 1 the two takes hard left and right (0..1, default 0.6) */ + width?: number; + }; + inputs: { + /** left in */ + left: "a"; + /** right in */ + right: "a"; + /** how late the second take plays */ + time: "c"; + /** how far the second take's timing wanders */ + drift: "c"; + /** 0 centered .. 1 the two takes hard left and right */ + width: "c"; + }; + outputs: { + /** left out */ + left: "a"; + /** right out */ + right: "a"; + }; + }; + /** four-stage all-pass phaser */ + "fx.phaser": { + main: "a"; + params: { + /** LFO rate in Hz (used when sync is 0) (0.01..40 Hz, default 0.5) */ + rate?: number; + /** LFO period in beats (tempo-synced); 0 uses rate in Hz (0..64 beats, default 0) */ + sync?: number; + /** notch depth (wet amount) (0..1, default 0.75) */ + depth?: number; + /** sweep center (100..10000 Hz, default 1000) */ + center?: number; + /** sweep range (0..8000 Hz, default 2000) */ + sweep?: number; + }; + inputs: { + /** left in */ + left: "a"; + /** right in */ + right: "a"; + /** LFO rate in Hz (used when sync is 0) */ + rate: "c"; + /** LFO period in beats (tempo-synced); 0 uses rate in Hz */ + sync: "c"; + /** notch depth (wet amount) */ + depth: "c"; + /** sweep center */ + center: "c"; + /** sweep range */ + sweep: "c"; + }; + outputs: { + /** left out */ + left: "a"; + /** right out */ + right: "a"; + }; + }; + /** stereo chorus: two modulated delay taps in quadrature */ + "fx.chorus": { + main: "a"; + params: { + /** LFO rate in Hz (used when sync is 0) (0.01..40 Hz, default 0.8) */ + rate?: number; + /** modulation depth (0..1 → 0..6 ms) (0..1, default 0.4) */ + depth?: number; + /** wet/dry balance 0..1 (0..1, default 0.5) */ + mix?: number; + }; + inputs: { + /** left in */ + left: "a"; + /** right in */ + right: "a"; + /** LFO rate in Hz (used when sync is 0) */ + rate: "c"; + /** modulation depth (0..1 → 0..6 ms) */ + depth: "c"; + /** wet/dry balance 0..1 */ + mix: "c"; + }; + outputs: { + /** left out */ + left: "a"; + /** right out */ + right: "a"; + }; + }; + /** rotary speaker: Doppler vibrato plus left/right amplitude rotation */ + "fx.leslie": { + main: "a"; + params: { + /** wet/dry balance 0..1 (0..1, default 1) */ + mix?: number; + /** rotation in Hz: 6.7 fast, 0.7 slow (0.01..40 Hz, default 6.7) */ + rate?: number; + /** cabinet size: Doppler (pitch warble) amount (0..1, default 0.5) */ + size?: number; + }; + inputs: { + /** left in */ + left: "a"; + /** right in */ + right: "a"; + /** wet/dry balance 0..1 */ + mix: "c"; + /** rotation in Hz: 6.7 fast, 0.7 slow */ + rate: "c"; + /** cabinet size: Doppler (pitch warble) amount */ + size: "c"; + }; + outputs: { + /** left out */ + left: "a"; + /** right out */ + right: "a"; + }; + }; + /** linear gain after every insert, before the delay and reverb sends */ + "fx.postgain": { + main: "a"; + params: { + /** linear gain 0..4 (0..4, default 1) */ + gain?: number; + }; + inputs: { + /** left in */ + left: "a"; + /** right in */ + right: "a"; + /** linear gain 0..4 */ + gain: "c"; + }; + outputs: { + /** left out */ + left: "a"; + /** right out */ + right: "a"; + }; + }; + /** tempo-synced stereo delay send; ping-pong with a high-cut on the repeats by default */ + "fx.delay": { + main: "a"; + params: { + /** repeats alternate left/right (absent: the original cross-fed stereo) (default true) */ + pingpong?: boolean; + /** delay time in beats (0.75 = dotted eighth) (0.0625..4 beats, default 0.75) */ + beats?: number; + /** fraction of each echo fed back (0..0.9, default 0.35) */ + feedback?: number; + /** wet level (0..1, default 0.25) */ + mix?: number; + /** delay time in seconds; 0 or absent uses beats (0..4 s, default 0) */ + time?: number; + /** low-pass inside the feedback loop so repeats darken (500..20000 Hz, default 5000) */ + highcut?: number; + }; + inputs: { + /** left in */ + left: "a"; + /** right in */ + right: "a"; + /** delay time in beats (0.75 = dotted eighth) */ + beats: "c"; + /** fraction of each echo fed back */ + feedback: "c"; + /** wet level */ + mix: "c"; + /** delay time in seconds; 0 or absent uses beats */ + time: "c"; + /** low-pass inside the feedback loop so repeats darken */ + highcut: "c"; + }; + outputs: { + /** left out */ + left: "a"; + /** right out */ + right: "a"; + }; + }; + /** algorithmic stereo reverb send (eight combs, four allpasses per side) */ + "fx.reverb": { + main: "a"; + params: { + /** wet level (0..1, default 0.3) */ + mix?: number; + /** room size (Strudel roomsize 0..10 = size·10) (0..1, default 0.5) */ + size?: number; + /** decay time to -60 dB; overrides the size-derived decay (0.1..20 s, default 2) */ + fade?: number; + /** low-pass on the reverb input (200..20000 Hz, default 8000) */ + lowpass?: number; + /** damping: the tail darkens toward this frequency as it decays (200..20000 Hz, default 3000) */ + dim?: number; + /** gap before the tail starts (0..0.5 s, default 0.02) */ + predelay?: number; + }; + inputs: { + /** left in */ + left: "a"; + /** right in */ + right: "a"; + /** wet level */ + mix: "c"; + /** room size (Strudel roomsize 0..10 = size·10) */ + size: "c"; + /** decay time to -60 dB; overrides the size-derived decay */ + fade: "c"; + /** low-pass on the reverb input */ + lowpass: "c"; + /** damping: the tail darkens toward this frequency as it decays */ + dim: "c"; + /** gap before the tail starts */ + predelay: "c"; + }; + outputs: { + /** left out */ + left: "a"; + /** right out */ + right: "a"; + }; + }; +}; + +/** The boundary nodes every patch has: `in`, `out`, `voice` and `song`. */ +export type PatchBoundaryTable = { + /** the patch's inputs from the track */ + in: { + main: "n"; + params: {}; + inputs: {}; + outputs: { + /** the track's notes (instrument) */ + notes: "n"; + /** the track's audio (effect patch) */ + audio: "a"; + /** the track's right channel (stereo effect patch) */ + right: "a"; + /** the sidechain track's audio (patch `side`) */ + side: "a"; + }; + }; + /** the patch's output to the track */ + out: { + main: "none"; + params: {}; + inputs: { + /** audio out (mono, or left when right is wired) */ + audio: "a"; + /** right channel; unwired, audio plays on both */ + right: "a"; + }; + outputs: {}; + }; + /** per-voice sources, one set per playing note */ + voice: { + main: "c"; + params: {}; + inputs: {}; + outputs: { + /** the note's frequency in Hz (tuned, with glide) */ + pitch: "c"; + /** the note's MIDI number */ + note: "c"; + /** 1 while the note is held, then 0 */ + gate: "c"; + /** velocity 0..1 */ + velocity: "c"; + /** phase 0..1 per cycle of the note's pitch */ + phase: "c"; + /** seeded random 0..1, fixed for the note */ + random: "c"; + /** voice slot index */ + index: "c"; + /** seconds since the note began */ + age: "c"; + }; + }; + /** global time and the track's automation */ + song: { + main: "c"; + params: {}; + inputs: {}; + outputs: { + /** song position in beats */ + beat: "c"; + /** position in the bar 0..1 */ + "bar.phase": "c"; + /** tempo in BPM */ + tempo: "c"; + }; + }; +}; + +type PatchPortRow = readonly [string, PatchPortCode, number?, number?, number?]; + +/** Ports by node type, for wiring and checks at run time. */ +export const PATCH_NODE_PORTS: Readonly< + Record< + string, + Readonly<{ + rate: string; + params: readonly string[]; + inputs: readonly PatchPortRow[]; + outputs: readonly PatchPortRow[]; + engine?: readonly string[]; + }> + > +> = { + osc: { + rate: "any", + params: ["wave", "pitch", "detune", "pw", "level"], + inputs: [ + ["pitch", "ca", 0, 20000, 440], + ["detune", "c", -1200, 1200, 0], + ["pw", "c", 0.01, 0.99, 0.5], + ["fm", "a"], + ["level", "c", 0, 4, 1], + ], + outputs: [["out", "a"]], + }, + noise: { + rate: "any", + params: ["color", "level"], + inputs: [["level", "c", 0, 4, 1]], + outputs: [["out", "a"]], + }, + svf: { + rate: "any", + params: ["mode", "cutoff", "q"], + inputs: [ + ["in", "a"], + ["cutoff", "c", 20, 20000, 1000], + ["q", "c", 0, 1, 0.5], + ], + outputs: [["out", "a"]], + }, + onepole: { + rate: "any", + params: ["mode", "cutoff"], + inputs: [ + ["in", "a"], + ["cutoff", "c", 20, 20000, 1000], + ], + outputs: [["out", "a"]], + }, + adsr: { + rate: "any", + params: ["gate", "attack", "decay", "sustain", "release"], + inputs: [ + ["gate", "c", 0, 1, 0], + ["attack", "c", 0, 10, 0.01], + ["decay", "c", 0, 10, 0.1], + ["sustain", "c", 0, 1, 0.7], + ["release", "c", 0, 10, 0.2], + ], + outputs: [["out", "c"]], + }, + ar: { + rate: "any", + params: ["gate", "attack", "release"], + inputs: [ + ["gate", "c", 0, 1, 0], + ["attack", "c", 0, 10, 0.01], + ["release", "c", 0, 10, 0.2], + ], + outputs: [["out", "c"]], + }, + slew: { + rate: "any", + params: ["in", "rise", "fall"], + inputs: [ + ["in", "c", -1000000, 1000000, 0], + ["rise", "c", 0, 10, 0.05], + ["fall", "c", 0, 10, 0.05], + ], + outputs: [["out", "c"]], + }, + follow: { + rate: "any", + params: ["attack", "release"], + inputs: [ + ["in", "a"], + ["attack", "c", 0.0001, 1, 0.005], + ["release", "c", 0.001, 4, 0.1], + ], + outputs: [["out", "c"]], + }, + lfo: { + rate: "any", + params: ["shape", "sync", "rate", "phase", "depth"], + inputs: [ + ["rate", "c", 0, 100, 1], + ["phase", "c", 0, 1, 0], + ["depth", "c", 0, 1, 1], + ], + outputs: [["out", "c", -1, 1]], + }, + sh: { + rate: "any", + params: ["in", "trig"], + inputs: [ + ["in", "c", -1000000, 1000000, 0], + ["trig", "c", 0, 1, 0], + ], + outputs: [["out", "c"]], + }, + random: { rate: "any", params: ["per"], inputs: [], outputs: [["out", "c"]] }, + const: { + rate: "any", + params: ["value"], + inputs: [], + outputs: [["out", "c"]], + }, + add: { + rate: "any", + params: ["a", "b"], + inputs: [ + ["a", "c", -1000000, 1000000, 0], + ["b", "c", -1000000, 1000000, 0], + ], + outputs: [["out", "c"]], + }, + mul: { + rate: "any", + params: ["a", "b"], + inputs: [ + ["a", "c", -1000000, 1000000, 0], + ["b", "c", -1000000, 1000000, 1], + ], + outputs: [["out", "c"]], + }, + min: { + rate: "any", + params: ["a", "b"], + inputs: [ + ["a", "c", -1000000, 1000000, 0], + ["b", "c", -1000000, 1000000, 0], + ], + outputs: [["out", "c"]], + }, + max: { + rate: "any", + params: ["a", "b"], + inputs: [ + ["a", "c", -1000000, 1000000, 0], + ["b", "c", -1000000, 1000000, 0], + ], + outputs: [["out", "c"]], + }, + gt: { + rate: "any", + params: ["a", "b"], + inputs: [ + ["a", "c", -1000000, 1000000, 0], + ["b", "c", -1000000, 1000000, 0], + ], + outputs: [["out", "c"]], + }, + lt: { + rate: "any", + params: ["a", "b"], + inputs: [ + ["a", "c", -1000000, 1000000, 0], + ["b", "c", -1000000, 1000000, 0], + ], + outputs: [["out", "c"]], + }, + abs: { + rate: "any", + params: ["in"], + inputs: [["in", "c", -1000000, 1000000, 0]], + outputs: [["out", "c"]], + }, + not: { + rate: "any", + params: ["in"], + inputs: [["in", "c", -1000000, 1000000, 0]], + outputs: [["out", "c"]], + }, + pitch2hz: { + rate: "any", + params: ["in"], + inputs: [["in", "c", -1000000, 1000000, 0]], + outputs: [["out", "c"]], + }, + db2gain: { + rate: "any", + params: ["in"], + inputs: [["in", "c", -1000000, 1000000, 0]], + outputs: [["out", "c"]], + }, + scale: { + rate: "any", + params: ["curve", "in", "inmin", "inmax", "min", "max"], + inputs: [ + ["in", "c", -1000000, 1000000, 0], + ["inmin", "c", -1000000, 1000000, -1], + ["inmax", "c", -1000000, 1000000, 1], + ["min", "c", -1000000, 1000000, 0], + ["max", "c", -1000000, 1000000, 1], + ], + outputs: [["out", "c"]], + }, + clamp: { + rate: "any", + params: ["in", "min", "max"], + inputs: [ + ["in", "c", -1000000, 1000000, 0], + ["min", "c", -1000000, 1000000, 0], + ["max", "c", -1000000, 1000000, 1], + ], + outputs: [["out", "c"]], + }, + clock: { + rate: "global", + params: ["beats", "width"], + inputs: [["width", "c", 0.01, 0.99, 0.5]], + outputs: [["out", "c"]], + }, + vca: { + rate: "any", + params: ["gain"], + inputs: [ + ["in", "a"], + ["gain", "ca", 0, 4, 1], + ], + outputs: [["out", "a"]], + }, + mix: { + rate: "any", + params: ["la", "lb", "lc", "ld"], + inputs: [ + ["a", "a"], + ["b", "a"], + ["c", "a"], + ["d", "a"], + ["la", "c", 0, 4, 1], + ["lb", "c", 0, 4, 1], + ["lc", "c", 0, 4, 1], + ["ld", "c", 0, 4, 1], + ], + outputs: [["out", "a"]], + }, + xfade: { + rate: "any", + params: ["x"], + inputs: [ + ["a", "a"], + ["b", "a"], + ["x", "c", 0, 1, 0.5], + ], + outputs: [["out", "a"]], + }, + pan: { + rate: "any", + params: ["pan"], + inputs: [ + ["in", "a"], + ["pan", "c", -1, 1, 0], + ], + outputs: [ + ["left", "a"], + ["right", "a"], + ], + }, + voicesum: { + rate: "global", + params: [], + inputs: [["in", "a"]], + outputs: [["out", "a"]], + }, + "engine.synth": { + rate: "global", + params: [ + "instrument", + "attack", + "decay", + "sustain", + "release", + "gain", + "noise", + "density", + "detune", + "spread", + "pw", + "pwrate", + "pwsweep", + "vib", + "vibmod", + "penv", + "pattack", + "pdecay", + "psustain", + "prelease", + "lpf", + "lpq", + "lpenv", + "lpattack", + "lpdecay", + "lpsustain", + "lprelease", + "hpf", + "hpq", + "hpenv", + "hpattack", + "hpdecay", + "hpsustain", + "hprelease", + "bpf", + "bpq", + "bpenv", + "bpattack", + "bpdecay", + "bpsustain", + "bprelease", + "fanchor", + "fm", + "fmh", + "fmattack", + "fmdecay", + "fmsustain", + "fmrelease", + "fm2", + "fmh2", + "fmattack2", + "fmdecay2", + "fmsustain2", + "fmrelease2", + "fm3", + "fmh3", + "fmattack3", + "fmdecay3", + "fmsustain3", + "fmrelease3", + "fm4", + "fmh4", + "fmattack4", + "fmdecay4", + "fmsustain4", + "fmrelease4", + "fm5", + "fmh5", + "fmattack5", + "fmdecay5", + "fmsustain5", + "fmrelease5", + "fm6", + "fmh6", + "fmattack6", + "fmdecay6", + "fmsustain6", + "fmrelease6", + "fm7", + "fmh7", + "fmattack7", + "fmdecay7", + "fmsustain7", + "fmrelease7", + "fm8", + "fmh8", + "fmattack8", + "fmdecay8", + "fmsustain8", + "fmrelease8", + "zrand", + "curve", + "slide", + "deltaSlide", + "pitchJump", + "pitchJumpTime", + "lfo", + "zmod", + "zcrush", + "zdelay", + "tremolo", + ], + inputs: [ + ["notes", "n"], + ["attack", "c", 0, 10, 0.003], + ["decay", "c", 0, 10, 0.05], + ["sustain", "c", 0, 1, 1], + ["release", "c", 0, 10, 0.05], + ["gain", "c", 0, 4, 1], + ["noise", "c", 0, 1, 0], + ["density", "c", 0, 1, 0.03], + ["detune", "c", 0, 12, 0.2], + ["spread", "c", 0, 1, 0.6], + ["pw", "c", 0, 1, 0.5], + ["pwrate", "c", 0, 40, 1], + ["pwsweep", "c", 0, 1, 0], + ["vib", "c", 0, 64, 0], + ["vibmod", "c", 0, 24, 0.5], + ["penv", "c", -48, 48, 0], + ["pattack", "c", 0, 10, 0.2], + ["pdecay", "c", 0, 10, 0], + ["psustain", "c", 0, 1, 1], + ["prelease", "c", 0, 10, 0], + ["lpf", "c", 20, 20000, 2000], + ["lpq", "c", 0, 50, 1], + ["lpenv", "c", -10, 10, 0], + ["lpattack", "c", 0, 10, 0.005], + ["lpdecay", "c", 0, 10, 0.15], + ["lpsustain", "c", 0, 1, 0], + ["lprelease", "c", 0, 10, 0.1], + ["hpf", "c", 20, 20000, 200], + ["hpq", "c", 0, 50, 1], + ["hpenv", "c", -10, 10, 0], + ["hpattack", "c", 0, 10, 0.005], + ["hpdecay", "c", 0, 10, 0.15], + ["hpsustain", "c", 0, 1, 0], + ["hprelease", "c", 0, 10, 0.1], + ["bpf", "c", 20, 20000, 1000], + ["bpq", "c", 0, 50, 1], + ["bpenv", "c", -10, 10, 0], + ["bpattack", "c", 0, 10, 0.005], + ["bpdecay", "c", 0, 10, 0.15], + ["bpsustain", "c", 0, 1, 0], + ["bprelease", "c", 0, 10, 0.1], + ["fanchor", "c", 0, 1, 0], + ["fm", "c", 0, 64, 0], + ["fmh", "c", 0, 32, 1], + ["fmattack", "c", 0, 10, 0], + ["fmdecay", "c", 0, 10, 0], + ["fmsustain", "c", 0, 1, 1], + ["fmrelease", "c", 0, 10, 0], + ["fm2", "c", 0, 64, 0], + ["fmh2", "c", 0, 32, 1], + ["fmattack2", "c", 0, 10, 0], + ["fmdecay2", "c", 0, 10, 0], + ["fmsustain2", "c", 0, 1, 1], + ["fmrelease2", "c", 0, 10, 0], + ["fm3", "c", 0, 64, 0], + ["fmh3", "c", 0, 32, 1], + ["fmattack3", "c", 0, 10, 0], + ["fmdecay3", "c", 0, 10, 0], + ["fmsustain3", "c", 0, 1, 1], + ["fmrelease3", "c", 0, 10, 0], + ["fm4", "c", 0, 64, 0], + ["fmh4", "c", 0, 32, 1], + ["fmattack4", "c", 0, 10, 0], + ["fmdecay4", "c", 0, 10, 0], + ["fmsustain4", "c", 0, 1, 1], + ["fmrelease4", "c", 0, 10, 0], + ["fm5", "c", 0, 64, 0], + ["fmh5", "c", 0, 32, 1], + ["fmattack5", "c", 0, 10, 0], + ["fmdecay5", "c", 0, 10, 0], + ["fmsustain5", "c", 0, 1, 1], + ["fmrelease5", "c", 0, 10, 0], + ["fm6", "c", 0, 64, 0], + ["fmh6", "c", 0, 32, 1], + ["fmattack6", "c", 0, 10, 0], + ["fmdecay6", "c", 0, 10, 0], + ["fmsustain6", "c", 0, 1, 1], + ["fmrelease6", "c", 0, 10, 0], + ["fm7", "c", 0, 64, 0], + ["fmh7", "c", 0, 32, 1], + ["fmattack7", "c", 0, 10, 0], + ["fmdecay7", "c", 0, 10, 0], + ["fmsustain7", "c", 0, 1, 1], + ["fmrelease7", "c", 0, 10, 0], + ["fm8", "c", 0, 64, 0], + ["fmh8", "c", 0, 32, 1], + ["fmattack8", "c", 0, 10, 0], + ["fmdecay8", "c", 0, 10, 0], + ["fmsustain8", "c", 0, 1, 1], + ["fmrelease8", "c", 0, 10, 0], + ["zrand", "c", 0, 1, 0], + ["curve", "c", 0, 3, 1], + ["slide", "c", -20, 20, 0], + ["deltaSlide", "c", -20, 20, 0], + ["pitchJump", "c", -2000, 2000, 0], + ["pitchJumpTime", "c", 0, 10, 0], + ["lfo", "c", 0, 10, 0], + ["zmod", "c", 0, 1000, 0], + ["zcrush", "c", 0, 1, 0], + ["zdelay", "c", 0, 1, 0], + ["tremolo", "c", 0, 1, 0], + ], + outputs: [ + ["out", "a"], + ["right", "a"], + ], + engine: ["synth", "wavetable", "sampler"], + }, + "engine.modal": { + rate: "global", + params: [ + "instrument", + "hardness", + "position", + "ring", + "tilt", + "damp", + "motordepth", + "buzz", + "click", + "gain", + ], + inputs: [ + ["notes", "n"], + ["hardness", "c", 0, 1, 0.4], + ["position", "c", 0, 1, 0.42], + ["ring", "c", 0.05, 30, 1.6], + ["tilt", "c", 0, 2, 0.9], + ["damp", "c", 0, 1, 0], + ["motordepth", "c", 0, 1, 0], + ["buzz", "c", 0, 1, 0], + ["click", "c", 0, 1, 0], + ["gain", "c", 0, 2, 0.8], + ], + outputs: [ + ["out", "a"], + ["right", "a"], + ], + engine: ["modal"], + }, + "engine.string": { + rate: "global", + params: [ + "instrument", + "ring", + "damp", + "pos", + "bright", + "mute", + "buzz", + "vib", + "vibmod", + "gain", + "pressure", + "speed", + "sord", + "dyn", + ], + inputs: [ + ["notes", "n"], + ["ring", "c", 0.05, 60, 3], + ["damp", "c", 0, 1, 0.4], + ["pos", "c", 0.02, 0.5, 0.15], + ["bright", "c", 0, 1, 0.6], + ["mute", "c", 0, 1, 0], + ["buzz", "c", 0, 1, 0], + ["vib", "c", 0, 12, 0], + ["vibmod", "c", 0, 2, 0.18], + ["gain", "c", 0, 2, 1], + ["pressure", "c", 0, 1, 0.5], + ["speed", "c", 0, 1, 0.6], + ["sord", "c", 0, 1, 0], + ["dyn", "c", 0, 1, 1], + ], + outputs: [ + ["out", "a"], + ["right", "a"], + ], + engine: ["string"], + }, + "engine.wind": { + rate: "global", + params: ["instrument", "breath", "noise", "wah", "growl", "flutter"], + inputs: [ + ["notes", "n"], + ["breath", "c", 0, 1, 0.6], + ["noise", "c", 0, 1, 0.08], + ["wah", "c", 0, 1, 1], + ["growl", "c", 0, 1, 0], + ["flutter", "c", 0, 1, 0], + ], + outputs: [ + ["out", "a"], + ["right", "a"], + ], + engine: ["wind"], + }, + "engine.sing": { + rate: "global", + params: [ + "instrument", + "morph", + "formant", + "bright", + "breath", + "vibmod", + "ring", + "overtone", + "sub", + ], + inputs: [ + ["notes", "n"], + ["morph", "c", 0, 1, 1], + ["formant", "c", -12, 12, 0], + ["bright", "c", 0, 1, 0.5], + ["breath", "c", 0, 1, 0.12], + ["vibmod", "c", 0, 1, 0.3], + ["ring", "c", 0, 1, 0], + ["overtone", "c", 0, 1, 0], + ["sub", "c", 0, 1, 0], + ], + outputs: [ + ["out", "a"], + ["right", "a"], + ], + engine: ["sing"], + }, + "engine.granular": { + rate: "global", + params: [ + "instrument", + "pos", + "scan", + "grain", + "overlap", + "jitter", + "spray", + "pitch", + "detune", + "shimmer", + "spread", + "reverse", + "repeat", + "drift", + ], + inputs: [ + ["notes", "n"], + ["pos", "c", 0, 1, 0], + ["scan", "c", -4, 4, 1], + ["grain", "c", 0.005, 2, 0.08], + ["overlap", "c", 0.05, 32, 4], + ["jitter", "c", 0, 1, 0.25], + ["spray", "c", 0, 2, 0.01], + ["pitch", "c", -48, 48, 0], + ["detune", "c", 0, 24, 0], + ["shimmer", "c", 0, 1, 0], + ["spread", "c", 0, 1, 0.3], + ["reverse", "c", 0, 1, 0], + ["repeat", "c", 0, 1, 0], + ["drift", "c", 0, 1, 0], + ], + outputs: [ + ["out", "a"], + ["right", "a"], + ], + engine: ["granular"], + }, + "engine.keys": { + rate: "global", + params: [ + "instrument", + "hardness", + "touch", + "decay", + "release", + "knock", + "noise", + "felt", + "tone", + "vibe", + "trem", + "drive", + "rotary", + ], + inputs: [ + ["notes", "n"], + ["hardness", "c", 0, 1, 0.5], + ["touch", "c", 0, 1, 1], + ["decay", "c", 0.1, 4, 1], + ["release", "c", 0.1, 4, 1], + ["knock", "c", 0, 1, 0.5], + ["noise", "c", 0, 1, 0.25], + ["felt", "c", 0, 1, 0], + ["tone", "c", 0, 12000, 0], + ["vibe", "c", 0, 1, 0], + ["trem", "c", 0, 1, 0], + ["drive", "c", 0, 1, 0.15], + ["rotary", "c", 0, 2, 1], + ], + outputs: [ + ["out", "a"], + ["right", "a"], + ], + engine: ["keys"], + }, + "fx.filter": { + rate: "global", + params: ["type", "ftype", "cutoff", "resonance"], + inputs: [ + ["in", "a"], + ["cutoff", "c", 20, 20000, 2000], + ["resonance", "c", 0, 1, 0], + ], + outputs: [["out", "a"]], + }, + "fx.djf": { + rate: "global", + params: ["value"], + inputs: [ + ["in", "a"], + ["value", "c", 0, 1, 0.5], + ], + outputs: [["out", "a"]], + }, + "fx.autofilter": { + rate: "global", + params: [ + "type", + "shape", + "cutoff", + "resonance", + "depth", + "sync", + "rate", + "phase", + "follow", + ], + inputs: [ + ["in", "a"], + ["cutoff", "c", 20, 20000, 1200], + ["resonance", "c", 0, 1, 0.3], + ["depth", "c", 0, 6, 2], + ["sync", "c", 0, 64, 4], + ["rate", "c", 0.01, 40, 0.5], + ["phase", "c", 0, 1, 0], + ["follow", "c", -6, 6, 0], + ], + outputs: [["out", "a"]], + }, + "fx.formant": { + rate: "global", + params: ["shift", "mix"], + inputs: [ + ["in", "a"], + ["shift", "c", -12, 12, 0], + ["mix", "c", 0, 1, 1], + ], + outputs: [["out", "a"]], + }, + "fx.vowel": { + rate: "global", + params: ["vowel", "to", "mix", "morph"], + inputs: [ + ["in", "a"], + ["mix", "c", 0, 1, 1], + ["morph", "c", 0, 1, 0], + ], + outputs: [["out", "a"]], + }, + "fx.crush": { + rate: "global", + params: ["bits", "coarse", "mix"], + inputs: [ + ["in", "a"], + ["bits", "c", 1, 16, 8], + ["coarse", "c", 1, 64, 1], + ["mix", "c", 0, 1, 1], + ], + outputs: [["out", "a"]], + }, + "fx.distort": { + rate: "global", + params: ["type", "drive", "tone", "mix", "postgain"], + inputs: [ + ["in", "a"], + ["drive", "c", 0, 10, 2], + ["tone", "c", 200, 20000, 8000], + ["mix", "c", 0, 1, 1], + ["postgain", "c", 0, 2, 1], + ], + outputs: [["out", "a"]], + }, + "fx.stomp": { + rate: "global", + params: ["type", "gain", "tone", "level", "octave", "mix"], + inputs: [ + ["in", "a"], + ["gain", "c", 0, 10, 5], + ["tone", "c", 0, 1, 0.5], + ["level", "c", -24, 12, 0], + ["octave", "c", 0, 1, 0.7], + ["mix", "c", 0, 1, 1], + ], + outputs: [["out", "a"]], + }, + "fx.head": { + rate: "global", + params: [ + "type", + "gain", + "bass", + "mid", + "treble", + "presence", + "master", + "sag", + "gate", + "level", + ], + inputs: [ + ["in", "a"], + ["gain", "c", 0, 10, 5], + ["bass", "c", 0, 10, 5], + ["mid", "c", 0, 10, 5], + ["treble", "c", 0, 10, 5], + ["presence", "c", 0, 10, 5], + ["master", "c", 0, 10, 5], + ["sag", "c", 0, 1, 0.3], + ["gate", "c", -96, 0, -60], + ["level", "c", -24, 12, 0], + ], + outputs: [["out", "a"]], + }, + "fx.cab": { + rate: "global", + params: ["type", "mic", "mix"], + inputs: [ + ["in", "a"], + ["mic", "c", 0, 1, 0.3], + ["mix", "c", 0, 1, 1], + ], + outputs: [["out", "a"]], + }, + "fx.wobble": { + rate: "global", + params: ["depth", "rate", "drift"], + inputs: [ + ["in", "a"], + ["depth", "c", 0, 100, 20], + ["rate", "c", 0.05, 8, 0.5], + ["drift", "c", 0, 1, 0.3], + ], + outputs: [["out", "a"]], + }, + "fx.bloom": { + rate: "global", + params: ["amount", "harm", "delay", "time"], + inputs: [ + ["in", "a"], + ["amount", "c", 0, 1, 0.5], + ["harm", "c", 1, 4, 2], + ["delay", "c", 0, 4, 0.6], + ["time", "c", 0.05, 4, 1], + ], + outputs: [["out", "a"]], + }, + "fx.swell": { + rate: "global", + params: ["time", "mix"], + inputs: [ + ["in", "a"], + ["time", "c", 0.01, 4, 0.4], + ["mix", "c", 0, 1, 1], + ], + outputs: [["out", "a"]], + }, + "fx.tremolo": { + rate: "global", + params: ["shape", "sync", "rate", "depth", "skew", "phase"], + inputs: [ + ["in", "a"], + ["sync", "c", 0, 64, 0.5], + ["rate", "c", 0.01, 40, 4], + ["depth", "c", 0, 1, 0.5], + ["skew", "c", 0, 1, 0.5], + ["phase", "c", 0, 1, 0], + ], + outputs: [["out", "a"]], + }, + "fx.compressor": { + rate: "global", + params: ["threshold", "ratio", "knee", "attack", "release", "makeup"], + inputs: [ + ["in", "a"], + ["threshold", "c", -60, 0, -18], + ["ratio", "c", 1, 20, 4], + ["knee", "c", 0, 24, 6], + ["attack", "c", 0.0001, 1, 0.01], + ["release", "c", 0.01, 2, 0.15], + ["makeup", "c", 0, 24, 5], + ], + outputs: [["out", "a"]], + }, + "fx.double": { + rate: "global", + params: ["time", "drift", "width"], + inputs: [ + ["left", "a"], + ["right", "a"], + ["time", "c", 5, 60, 22], + ["drift", "c", 0, 10, 3], + ["width", "c", 0, 1, 0.6], + ], + outputs: [ + ["left", "a"], + ["right", "a"], + ], + }, + "fx.phaser": { + rate: "global", + params: ["rate", "sync", "depth", "center", "sweep"], + inputs: [ + ["left", "a"], + ["right", "a"], + ["rate", "c", 0.01, 40, 0.5], + ["sync", "c", 0, 64, 0], + ["depth", "c", 0, 1, 0.75], + ["center", "c", 100, 10000, 1000], + ["sweep", "c", 0, 8000, 2000], + ], + outputs: [ + ["left", "a"], + ["right", "a"], + ], + }, + "fx.chorus": { + rate: "global", + params: ["rate", "depth", "mix"], + inputs: [ + ["left", "a"], + ["right", "a"], + ["rate", "c", 0.01, 40, 0.8], + ["depth", "c", 0, 1, 0.4], + ["mix", "c", 0, 1, 0.5], + ], + outputs: [ + ["left", "a"], + ["right", "a"], + ], + }, + "fx.leslie": { + rate: "global", + params: ["mix", "rate", "size"], + inputs: [ + ["left", "a"], + ["right", "a"], + ["mix", "c", 0, 1, 1], + ["rate", "c", 0.01, 40, 6.7], + ["size", "c", 0, 1, 0.5], + ], + outputs: [ + ["left", "a"], + ["right", "a"], + ], + }, + "fx.postgain": { + rate: "global", + params: ["gain"], + inputs: [ + ["left", "a"], + ["right", "a"], + ["gain", "c", 0, 4, 1], + ], + outputs: [ + ["left", "a"], + ["right", "a"], + ], + }, + "fx.delay": { + rate: "global", + params: ["pingpong", "beats", "feedback", "mix", "time", "highcut"], + inputs: [ + ["left", "a"], + ["right", "a"], + ["beats", "c", 0.0625, 4, 0.75], + ["feedback", "c", 0, 0.9, 0.35], + ["mix", "c", 0, 1, 0.25], + ["time", "c", 0, 4, 0], + ["highcut", "c", 500, 20000, 5000], + ], + outputs: [ + ["left", "a"], + ["right", "a"], + ], + }, + "fx.reverb": { + rate: "global", + params: ["mix", "size", "fade", "lowpass", "dim", "predelay"], + inputs: [ + ["left", "a"], + ["right", "a"], + ["mix", "c", 0, 1, 0.3], + ["size", "c", 0, 1, 0.5], + ["fade", "c", 0.1, 20, 2], + ["lowpass", "c", 200, 20000, 8000], + ["dim", "c", 200, 20000, 3000], + ["predelay", "c", 0, 0.5, 0.02], + ], + outputs: [ + ["left", "a"], + ["right", "a"], + ], + }, + in: { + rate: "global", + params: [], + inputs: [], + outputs: [ + ["notes", "n"], + ["audio", "a"], + ["right", "a"], + ["side", "a"], + ], + }, + out: { + rate: "global", + params: [], + inputs: [ + ["audio", "a"], + ["right", "a"], + ], + outputs: [], + }, + voice: { + rate: "voice", + params: [], + inputs: [], + outputs: [ + ["pitch", "c"], + ["note", "c"], + ["gate", "c"], + ["velocity", "c"], + ["phase", "c"], + ["random", "c"], + ["index", "c"], + ["age", "c"], + ], + }, + song: { + rate: "global", + params: [], + inputs: [], + outputs: [ + ["beat", "c"], + ["bar.phase", "c"], + ["tempo", "c"], + ], + }, +}; + +// END patch nodes diff --git a/core/sdk/patch-types.fixture.ts b/core/sdk/patch-types.fixture.ts new file mode 100644 index 00000000..a1182e6f --- /dev/null +++ b/core/sdk/patch-types.fixture.ts @@ -0,0 +1,21 @@ +/** + * A tsc negative fixture for the patch builder (patcher, SDK 1.35.0): the + * repo typecheck fails if any `@ts-expect-error` line below stops being an + * error. Nothing imports this file. + */ +import { patch } from "./v1.ts"; + +export const fixture = patch("types", ({ input, voice, osc, svf, macro }) => { + const cutoff = macro("cutoff", { min: 80, max: 4000 }); + const filter = svf({ mode: "lp" }).cutoff(cutoff).q(0.5); + // A notes source is not a control source. + // @ts-expect-error notes cannot drive cutoff + filter.cutoff(input.notes); + // @ts-expect-error osc has no port colour + osc().colour(1); + // @ts-expect-error svf has no setting width + svf({ width: 2 }); + // @ts-expect-error a wave word that osc does not know + osc({ wave: "organ" }); + return osc().pitch(voice.pitch).to(filter); +}); diff --git a/core/sdk/patch.test.ts b/core/sdk/patch.test.ts new file mode 100644 index 00000000..b1a35051 --- /dev/null +++ b/core/sdk/patch.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import prettier from "prettier"; +import { initProject, writeAtomic } from "../../src/project/init.ts"; +import { decodeLoopDocument } from "../loop.ts"; +import { evaluateProject } from "./eval.ts"; +import { printProject } from "./print.ts"; +import { + fxPatch, + pat, + patch, + saw, + seq, + sine, + song, + track, + type PatchSpec, +} from "./v1.ts"; + +/** The §5.2 acid example (distort's knob is `drive` in NODE_SPECS). */ +function acid(): PatchSpec { + return patch("acid-bass", ({ voice, osc, svf, adsr, vca, fx, macro }) => { + const cutoff = macro("cutoff", { + min: 80, + max: 4000, + default: 600, + curve: "exp", + }); + const reso = macro("reso", { min: 0, max: 1, default: 0.7 }); + const env = adsr({ + attack: 0.002, + decay: 0.18, + sustain: 0, + release: 0.05, + }).gate(voice.gate); + const amp = adsr({ + attack: 0.001, + decay: 0.3, + sustain: 0.6, + release: 0.08, + }).gate(voice.gate); + const tone = osc({ wave: "saw" }).pitch(voice.pitch); + return tone + .to( + svf({ mode: "lp" }) + .cutoff(cutoff.plus(env.times(2400))) + .q(reso), + ) + .to(vca().gain(amp.times(voice.velocity))) + .to(fx.distort({ drive: 0.25 }).global()); + }); +} + +const wideCrush = () => + fxPatch("wide-crush", ({ input, fx, lfo }) => + input.to(fx.crush({ bits: 6 }).bits(lfo({ rate: 0.25 }).range(4, 12))), + ); + +/** `type` of the node a port belongs to (boundaries name themselves). */ +function typeOf(p: PatchSpec, port: string): string { + const id = port.slice(0, port.lastIndexOf(".")); + const name = port.slice(port.lastIndexOf(".") + 1); + const node = p.nodes.find((n) => n.id === id); + return `${node ? node.type : id}.${name}`; +} + +describe("patch(name, build)", () => { + test("the acid example builds the expected plain data", () => { + const p = acid(); + expect(p.kind).toBe("patch"); + expect(p.role).toBe("instrument"); + expect(p.name).toBe("acid-bass"); + expect(p.nodes.map((n) => n.type).sort()).toEqual( + ["adsr", "adsr", "osc", "svf", "mul", "add", "vca", "mul", "fx.distort"] + .slice() + .sort(), + ); + const byType = (type: string) => p.nodes.filter((n) => n.type === type); + expect(byType("osc")[0]!.params).toEqual({ wave: "saw" }); + expect(byType("svf")[0]!.params).toEqual({ mode: "lp" }); + expect(byType("fx.distort")[0]).toMatchObject({ + params: { drive: 0.25 }, + rate: "global", + }); + expect(byType("adsr").map((n) => n.params)).toContainEqual({ + attack: 0.002, + decay: 0.18, + sustain: 0, + release: 0.05, + }); + // `env.times(2400)` folds the constant into the mul node's param. + expect(byType("mul").map((n) => n.params ?? {})).toContainEqual({ + b: 2400, + }); + const edges = p.cables + .map((c) => `${typeOf(p, c.from)} -> ${typeOf(p, c.to)}`) + .sort(); + expect(edges).toEqual( + [ + "voice.gate -> adsr.gate", + "voice.gate -> adsr.gate", + "voice.pitch -> osc.pitch", + "osc.out -> svf.in", + "adsr.out -> mul.a", + "adsr.out -> mul.a", + "mul.out -> add.b", + "add.out -> svf.cutoff", + "voice.velocity -> mul.b", + "mul.out -> vca.gain", + "svf.out -> vca.in", + "vca.out -> fx.distort.in", + "fx.distort.out -> out.audio", + "fx.distort.out -> out.right", + ].sort(), + ); + expect( + p.macros.map(({ id, min, max, default: d, curve, to }) => ({ + id, + min, + max, + d, + curve, + to: to.map((t) => typeOf(p, t.port)), + })), + ).toEqual([ + { + id: "cutoff", + min: 80, + max: 4000, + d: 600, + curve: "exp", + to: ["add.a"], + }, + { + id: "reso", + min: 0, + max: 1, + d: 0.7, + curve: undefined, + to: ["svf.q"], + }, + ]); + expect(Object.isFrozen(p)).toBe(true); + expect(Object.isFrozen(p.nodes[0])).toBe(true); + }); + + test("auto ids are stable and shaped like newId()", () => { + const a = acid(); + const b = acid(); + expect(a).toEqual(b); + for (const node of a.nodes) expect(node.id).toMatch(/^[a-z]+-[a-z2-7]{8}$/); + for (const cable of a.cables) expect(cable.id).toMatch(/^c-[a-z2-7]{8}$/); + expect(new Set(a.nodes.map((n) => n.id)).size).toBe(a.nodes.length); + // A pinned id is kept as given. + const pinned = patch("pin", ({ voice, osc }) => + osc({ id: "tone" }).pitch(voice.pitch), + ); + expect(pinned.nodes[0]!.id).toBe("tone"); + }); + + test("fxPatch builds an effect patch from the track's audio", () => { + const p = wideCrush(); + expect(p.role).toBe("effect"); + expect(p.nodes.map((n) => n.type).sort()).toEqual([ + "fx.crush", + "lfo", + "scale", + ]); + expect(p.cables.map((c) => typeOf(p, c.from)).sort()).toContain("in.audio"); + }); + + test("the plain-data overload accepts the stored shape", () => { + const built = acid(); + const plain = patch({ + name: built.name, + nodes: built.nodes, + cables: built.cables, + macros: built.macros, + }); + expect(plain).toEqual(built); + expect(patch({ ref: "acid-bass", macros: { cutoff: 900 } })).toEqual({ + kind: "patch", + ref: "acid-bass", + macros: { cutoff: 900 }, + }); + }); + + test("bad wiring throws a plain-language error", () => { + expect(() => + patch("bad", ({ osc }) => (osc() as never as { to: () => never }).to()), + ).toThrow(); + expect(() => track({ name: "t", instrument: wideCrush() })).toThrow( + /effect patch/, + ); + expect(() => + track({ name: "t", fx: { patch: [acid()] } as never }), + ).toThrow(/fx.patch takes effect patches/); + }); +}); + +describe("patches in songs", () => { + test("mods and node signals bake into patch lanes; lanes: into fx lanes", () => { + const s = song({ + bars: 2, + tracks: [ + track({ + name: "acid", + instrument: acid(), + mods: { + cutoff: sine.range(300, 2400).slow(4), + reso: pat("0.5 0.8 0.6 0.9"), + }, + lanes: { "synth-lpf": saw.range(400, 3000) }, + notes: seq("A1 A1 C2 A1 G1 A1 E2 D2", { step: 0.25 }), + }), + ], + }); + const t = s.tracks[0]!; + expect(t.instrument).toBe("patch"); + const lanes = (t as { fxAutomation?: Record }) + .fxAutomation!; + expect(Object.keys(lanes).sort()).toEqual([ + "patch-cutoff", + "patch-reso", + "synth-lpf", + ]); + // pat("0.5 0.8 0.6 0.9") steps a quarter bar at a time, as knob positions. + expect(lanes["patch-reso"]!.slice(0, 3)).toEqual([ + { tick: 0, value: 0.5 }, + { tick: 479, value: 0.5 }, + { tick: 480, value: 0.8 }, + ]); + // Written automation wins over a signal on the same lane. + const w = song({ + tracks: [ + track({ + name: "acid", + instrument: acid(), + mods: { cutoff: sine }, + automation: { fx: { "patch-cutoff": [[0, 0.25]] } }, + }), + ], + }); + expect( + (w.tracks[0] as { fxAutomation?: Record }).fxAutomation![ + "patch-cutoff" + ], + ).toEqual([{ tick: 0, value: 0.25 }]); + expect(() => + song({ + tracks: [ + track({ name: "a", instrument: acid(), mods: { nope: sine } }), + ], + }), + ).toThrow(/mods.nope names no macro/); + }); + + test("song({ patches }) is a library tracks play by name", () => { + const s = song({ + patches: [acid(), wideCrush()], + tracks: [ + track({ + name: "lib", + instrument: patch({ ref: "acid-bass", macros: { cutoff: 900 } }), + }), + ], + }); + expect(Object.keys(s.patches!)).toEqual(["acid-bass", "wide-crush"]); + const score = decodeLoopDocument(JSON.parse(JSON.stringify(s))); + expect(Object.keys(score.patches)).toEqual(["acid-bass", "wide-crush"]); + expect(score.tracks[0]!.patch).toEqual({ + kind: "patch", + ref: "acid-bass", + macros: { cutoff: 900 }, + }); + expect(() => + song({ + tracks: [track({ name: "x", instrument: patch({ ref: "gone" }) })], + }), + ).toThrow(/no patch "gone"/); + }); + + test("the plain form round-trips through print and eval byte-for-byte", async () => { + const built = song({ + tempo: 120, + bars: 2, + patches: [acid(), wideCrush()], + tracks: [ + track({ + name: "acid", + instrument: acid(), + fx: { chorus: {}, patch: [wideCrush()] }, + mods: { cutoff: sine.range(300, 2400).slow(4) }, + notes: seq("A1 C2", { step: 0.5 }), + }), + track({ + name: "lib", + instrument: patch({ ref: "acid-bass", macros: { cutoff: 900 } }), + }), + track({ + name: "keys", + instrument: "piano", + fx: { patch: [wideCrush()] }, + }), + ], + }); + const score = decodeLoopDocument(JSON.parse(JSON.stringify(built))); + const printed = printProject(score).files; + const acidFile = printed.find((f) => f.path === "tracks/acid/track.ts")!; + expect(acidFile.text).toContain( + 'import { track, note, patch, fxPatch } from "dawg";', + ); + expect(acidFile.text).toContain("instrument: patch({\n"); + expect(acidFile.text).toContain('"patch-cutoff": ['); + expect(printed[0]!.text).toContain("patches: [\n patch({"); + for (const file of printed) + expect(await prettier.format(file.text, { parser: "typescript" })).toBe( + file.text, + ); + const dir = await mkdtemp(join(tmpdir(), "dawg-print-patch-")); + try { + await initProject(dir); + for (const file of printed) + await writeAtomic(join(dir, file.path), file.text); + const evaluated = await evaluateProject(dir); + if (!evaluated.ok) throw new Error(JSON.stringify(evaluated.diagnostics)); + expect(evaluated.score.tracks).toEqual(score.tracks); + expect(evaluated.score.patches).toEqual(score.patches); + expect(printProject(evaluated.score).files).toEqual(printed); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/core/sdk/print.ts b/core/sdk/print.ts index a0f6c92d..221652b5 100644 --- a/core/sdk/print.ts +++ b/core/sdk/print.ts @@ -62,6 +62,7 @@ import { type Take, type Track, } from "../score.ts"; +import type { TrackPatchValue } from "../patch.ts"; import { trackSlug } from "../slug.ts"; import { barStartTick, timeWithinSong } from "../tempo.ts"; import type { Tuning } from "../tuning.ts"; @@ -198,7 +199,9 @@ export function trackIdentifiers( if ( !/^[a-z_]/.test(base) || RESERVED.has(base) || - (score.time && TIME_HELPERS.includes(base)) + (score.time && TIME_HELPERS.includes(base)) || + (Object.keys(score.patches).length > 0 && + (base === "patch" || base === "fxPatch")) ) base = `track_${base}`; let name = base; @@ -220,6 +223,7 @@ export function printSong( "song", ...TIME_HELPERS.filter((name) => time.used.has(name)), ...(score.style ? ["style"] : []), + ...libraryHelpers(score), ]; const lines: string[] = [`import { ${helpers.join(", ")} } from "dawg";`]; for (const track of score.tracks) @@ -275,12 +279,31 @@ export function printSong( else if (score.loopSection !== undefined) entries.push(`loop: ${str(score.loopSection)}`); if (score.calibration) entries.push(`calibration: ${num(score.calibration)}`); + const library = Object.values(score.patches).map((p) => + printPatch( + p, + p.role === "effect" ? "fxPatch" : "patch", + INDENT + INDENT, + 0, + ), + ); + if (library.length > 0) + entries.push(`patches: ${list(library, INDENT, "patches: ".length, 1)}`); lines.push("export default song({"); for (const entry of entries) lines.push(`${INDENT}${entry},`); lines.push("});", ""); return lines.join("\n"); } +/** `patch` and `fxPatch` when the song has a patch library, in that order. */ +function libraryHelpers(score: TrackScore): string[] { + const roles = new Set(Object.values(score.patches).map((p) => p.role)); + return [ + ...(roles.has("instrument") ? ["patch"] : []), + ...(roles.has("effect") ? ["fxPatch"] : []), + ]; +} + /** SDK time helpers `song.ts` may import, in import order. */ const TIME_HELPERS: readonly string[] = ["tempo", "ramp", "meter", "fermata"]; @@ -494,6 +517,11 @@ export function printTrack(score: TrackScore, track: Track): string { entries.push(`instrument: ${singCall}`); } else if (vocoderCall && track.instrument === VOCODER_INSTRUMENT) { entries.push(`instrument: ${vocoderCall}`); + } else if (track.patch) { + used.add("patch"); + entries.push( + `instrument: ${printPatch(track.patch, "patch", INDENT, "instrument: ".length)}`, + ); } else entries.push(`instrument: ${str(track.instrument)}`); if (vocoderCall && track.instrument !== VOCODER_INSTRUMENT) entries.push(`vocoder: ${vocoderCall}`); @@ -599,6 +627,14 @@ export function printTrack(score: TrackScore, track: Track): string { 1, )}`, ); + const fxPatches = (track.fxPatch ?? []).map((stage) => + printPatch(stage, "fxPatch", INDENT + INDENT + INDENT, 0), + ); + if (fxPatches.length > 0) used.add("fxPatch"); + const fxPatchLine = + fxPatches.length > 0 + ? `${INDENT + INDENT}patch: ${list(fxPatches, INDENT + INDENT, "patch: ".length, 1)},` + : undefined; if (track.fx) { // A whole rig preset prints as `...rig("crunch")` (SDK 1.22.0). const rigName = rigPresetOf(track.fx); @@ -625,8 +661,9 @@ export function printTrack(score: TrackScore, track: Track): string { return `${inner}${effect}: ${params.length === 0 ? "{}" : obj(params, inner, `${effect}: `.length, 1)},`; }), ); + if (fxPatchLine) body.push(fxPatchLine); entries.push(`fx: {\n${body.join("\n")}\n${INDENT}}`); - } + } else if (fxPatchLine) entries.push(`fx: {\n${fxPatchLine}\n${INDENT}}`); if (track.synth) { const params = Object.entries(track.synth).map( ([key, v]) => @@ -670,9 +707,33 @@ export function printTrack(score: TrackScore, track: Track): string { ["wt", track.wtAutomation], ]; const automation = lanes.filter(([, points]) => points && points.length > 0); - const fxLanes = FX_LANES.filter( - ({ lane }) => (track.fxAutomation?.[lane]?.length ?? 0) > 0, - ).map(({ lane }) => [lane, track.fxAutomation![lane]!] as const); + const fxLanes: (readonly [string, readonly AutomationPoint[]])[] = + FX_LANES.filter(({ lane }) => (track.fxAutomation?.[lane]?.length ?? 0) > 0) + .map(({ lane }): readonly [string, readonly AutomationPoint[]] => [ + lane, + track.fxAutomation![lane]!, + ]) + .concat( + Object.keys(track.fxAutomation ?? {}) + .filter( + (lane) => + lane.startsWith("patch-") && + (( + track.fxAutomation as Readonly< + Record + > + )[lane]?.length ?? 0) > 0, + ) + .sort() + .map((lane): readonly [string, readonly AutomationPoint[]] => [ + lane, + ( + track.fxAutomation as Readonly< + Record + > + )[lane]!, + ]), + ); const laneLine = ( name: string, points: readonly AutomationPoint[], @@ -754,6 +815,8 @@ export function printTrack(score: TrackScore, track: Track): string { "grid", "audio", "take", + "patch", + "fxPatch", ] .filter((name) => used.has(name)) .join(", "); @@ -1518,6 +1581,74 @@ function printMaster(master: SongMaster): string { return `master: {\n${body.join("\n")}\n${INDENT}}`; } +/** + * A patch in its plain form (patcher, SDK 1.35.0): `patch({ name, role, + * nodes, cables, macros })`, one node per line; a library reference + * prints as `patch({ ref, macros })`. The stored data is already + * normalized (cables sorted, defaults dropped), so it prints as it is. + */ +export function printPatch( + value: TrackPatchValue, + call: "patch" | "fxPatch", + indent: string, + prefix: number, +): string { + const fields = Object.entries(value).filter( + ([key, v]) => + v !== undefined && + key !== "kind" && + !(call === "fxPatch" && key === "role"), + ); + const entries = fields.map( + ([key, v]) => + [ + propertyKey(key), + literal(v, indent + INDENT, propertyKey(key).length + 2, 1), + ] as const, + ); + return `${call}(${obj(entries, indent, prefix + call.length + 1, 2)})`; +} + +/** An object key as prettier prints it: bare when it is an identifier. */ +function propertyKey(key: string): string { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : str(key); +} + +/** Any plain JSON value as a prettier-stable literal. */ +function literal( + v: unknown, + indent: string, + prefix: number, + trailing: number, +): string { + if (typeof v === "number") return num(v); + if (typeof v === "string") return str(v); + if (typeof v === "boolean") return `${v}`; + if (v === null) return "null"; + if (Array.isArray(v)) { + const items = v.map((item) => literal(item, indent + INDENT, 0, 1)); + // Prettier breaks an array of 2+ objects (or arrays) that each hold 2+. + const busy = (item: unknown) => + Array.isArray(item) + ? item.length > 1 + : typeof item === "object" && + item !== null && + Object.keys(item).length > 1; + return list(items, indent, prefix, trailing, v.length > 1 && v.every(busy)); + } + const entries = Object.entries(v as Record) + .filter(([, item]) => item !== undefined) + .map( + ([key, item]) => + [ + propertyKey(key), + literal(item, indent + INDENT, propertyKey(key).length + 2, 1), + ] as const, + ); + if (entries.length === 0) return "{}"; + return obj(entries, indent, prefix, trailing); +} + /** Object literal: inline when the line fits, expanded otherwise (prettier keeps both). */ function obj( entries: readonly (readonly [string, string])[], diff --git a/core/sdk/range.test.ts b/core/sdk/range.test.ts index cf2cc87e..d0a645c6 100644 --- a/core/sdk/range.test.ts +++ b/core/sdk/range.test.ts @@ -27,7 +27,7 @@ describe("song loop (SDK 1.34.0)", () => { const lead = track({ name: "lead", notes: [note("C4", 0)] }); test("bars and a section name", () => { - expect(SDK_VERSION).toBe("1.34.0"); + expect(SDK_VERSION).toBe("1.35.0"); expect(song({ bars: 8, tracks: [lead], loop: "5-6" }).loop).toEqual({ startBar: 4, bars: 2, diff --git a/core/sdk/signal.test.ts b/core/sdk/signal.test.ts new file mode 100644 index 00000000..6b9132a9 --- /dev/null +++ b/core/sdk/signal.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, test } from "bun:test"; +import { + bakeSignal, + cosine, + irand, + isaw, + pat, + perlin, + rand, + saw, + signalAt, + signalFromData, + signalSeed, + sine, + square, + tri, + type PatternSignal, +} from "./v1.ts"; + +// Strudel's documented formulas (packages/core/signal.mjs, legacy RNG), +// written out here independently of the SDK's implementation. +const mod = (n: number, m: number) => ((n % m) + m) % m; +const strudel = { + saw: (t: number) => mod(t, 1), + isaw: (t: number) => 1 - mod(t, 1), + sine: (t: number) => (Math.sin(Math.PI * 2 * t) + 1) / 2, + cosine: (t: number) => (Math.sin(Math.PI * 2 * (t + 0.25)) + 1) / 2, + square: (t: number) => Math.floor(mod(t * 2, 2)), + // fastcat(saw, isaw): each half squeezes a whole cycle of its signal. + tri: (t: number) => { + const c = Math.floor(t); + const local = (t - c) * 2; + return local < 1 ? mod(c + local, 1) : 1 - mod(c + local - 1, 1); + }, + rand: (t: number) => { + const xorwise = (x: number) => { + const a = (x << 13) ^ x; + const b = (a >> 17) ^ a; + return (b << 5) ^ b; + }; + const frac = (x: number) => x - Math.trunc(x); + const seed = xorwise(Math.trunc(frac(t / 300) * 536870912)); + return Math.abs((seed % 536870912) / 536870912); + }, + perlin: (t: number) => { + const ta = Math.floor(t); + const x = t - ta; + const smoother = 6 * x ** 5 - 15 * x ** 4 + 10 * x ** 3; + const a = strudel.rand(ta); + return a + smoother * (strudel.rand(ta + 1) - a); + }, +}; + +const CYCLES = [0, 0.1, 0.25, 0.4, 0.5, 0.625, 0.75, 0.9, 1, 1.3, 2.75, 7.2]; + +describe("continuous signals match Strudel", () => { + const cases: [string, PatternSignal, (t: number) => number][] = [ + ["saw", saw, strudel.saw], + ["isaw", isaw, strudel.isaw], + ["sine", sine, strudel.sine], + ["cosine", cosine, strudel.cosine], + ["square", square, strudel.square], + ["tri", tri, strudel.tri], + ["rand", rand, strudel.rand], + ["perlin", perlin, strudel.perlin], + ]; + for (const [name, signal, formula] of cases) + test(`${name} at fixed cycles`, () => { + for (const t of CYCLES) + expect(signalAt(signal, t)).toBeCloseTo(formula(t), 12); + }); + + test("a few values by hand", () => { + expect(signalAt(sine, 0)).toBeCloseTo(0.5, 12); + expect(signalAt(sine, 0.25)).toBeCloseTo(1, 12); + expect(signalAt(cosine, 0)).toBeCloseTo(1, 12); + expect(signalAt(saw, 0.75)).toBe(0.75); + expect(signalAt(square, 0.49)).toBe(0); + expect(signalAt(square, 0.5)).toBe(1); + expect(signalAt(tri, 0.25)).toBe(0.5); + expect(signalAt(tri, 0.5)).toBe(1); + }); + + test("irand(n) is trunc(rand · n)", () => { + for (const t of CYCLES) + expect(signalAt(irand(8), t)).toBe(Math.trunc(strudel.rand(t) * 8)); + }); +}); + +describe("transforms follow Strudel's definitions", () => { + test("range, rangex, slow, fast, early, late", () => { + for (const t of CYCLES) { + expect(signalAt(sine.range(200, 800), t)).toBeCloseTo( + strudel.sine(t) * 600 + 200, + 9, + ); + const lo = Math.log(100); + expect(signalAt(saw.rangex(100, 10000), t)).toBeCloseTo( + Math.exp(strudel.saw(t) * (Math.log(10000) - lo) + lo), + 6, + ); + expect(signalAt(sine.slow(4), t)).toBeCloseTo(strudel.sine(t / 4), 12); + expect(signalAt(sine.fast(2), t)).toBeCloseTo(strudel.sine(t * 2), 12); + expect(signalAt(saw.early(0.25), t)).toBeCloseTo( + strudel.saw(t + 0.25), + 12, + ); + expect(signalAt(saw.late(0.25), t)).toBeCloseTo( + strudel.saw(t - 0.25), + 12, + ); + } + }); + + test("segment samples each segment at its start; add and mul combine", () => { + expect(signalAt(saw.segment(4), 0.3)).toBe(0.25); + expect(signalAt(saw.segment(4), 0.99)).toBe(0.75); + expect(signalAt(saw.add(0.5), 0.25)).toBe(0.75); + expect(signalAt(saw.mul(2), 0.25)).toBe(0.5); + expect(signalAt(saw.add(sine), 0.25)).toBeCloseTo(1.25, 12); + // A range on top of a sum maps the sum. + expect(signalAt(saw.add(0.5).range(0, 10), 0.25)).toBeCloseTo(7.5, 12); + }); + + test("every(n, f) applies f on bars 0, n, 2n, …", () => { + const s = saw.every(2, (x) => x.fast(2)); + expect(signalAt(s, 0.25)).toBe(0.5); + expect(signalAt(s, 1.25)).toBe(0.25); + expect(signalAt(s, 2.25)).toBe(0.5); + }); + + test("pat() reads mini-notation as stepped numbers", () => { + const p = pat("0 0.5 1"); + expect([0, 0.34, 0.67, 1.1].map((t) => signalAt(p, t))).toEqual([ + 0, 0.5, 1, 0, + ]); + const alt = pat("<1 2> [3 4]"); + expect([0, 0.5, 0.75, 1, 1.5].map((t) => signalAt(alt, t))).toEqual([ + 1, 3, 4, 2, 3, + ]); + expect(signalAt(pat("1*2 5"), 0.3)).toBe(1); + expect(signalAt(pat("1!2 5"), 0.5)).toBe(1); + expect(signalAt(pat("1!2 5"), 0.7)).toBe(5); + expect(() => pat("0 [1")).toThrow(); + }); +}); + +describe("seeded randomness", () => { + test("a seed shifts rand in time, as Strudel's legacy randSeed", () => { + for (const t of CYCLES) { + expect(signalAt(rand, t, 7)).toBe(strudel.rand(t + 7)); + expect(signalAt(rand, t, 7)).toBe(signalAt(rand, t, 7)); + } + }); + + test("a track's seed is stable and differs between tracks", () => { + expect(signalSeed("bass")).toBe(signalSeed("bass")); + expect(signalSeed("bass")).not.toBe(signalSeed("keys")); + expect(Number.isInteger(signalSeed("bass"))).toBe(true); + expect(signalSeed("bass")).toBeGreaterThanOrEqual(0); + expect(signalSeed("bass")).toBeLessThan(300); + const lane = (id: string) => + bakeSignal(rand, { bars: 2, ticksPerBar: 1920, seed: signalSeed(id) }); + expect(lane("bass")).toEqual(lane("bass")); + expect(lane("bass")).not.toEqual(lane("keys")); + }); +}); + +describe("signals are plain data", () => { + test("a signal survives JSON and rebuilds to the same values", () => { + const s = sine + .range(300, 2400) + .slow(4) + .add(pat("0 100")) + .every(2, (x) => x.fast(2)); + const back = signalFromData(JSON.parse(JSON.stringify(s))); + expect(JSON.stringify(back)).toBe(JSON.stringify(s)); + for (const t of CYCLES) expect(signalAt(back, t)).toBe(signalAt(s, t)); + }); + + test("bakeSignal ramps continuous signals and steps stepped ones", () => { + const ramp = bakeSignal(saw, { bars: 1, ticksPerBar: 1600, seed: 0 }); + expect(ramp.length).toBe(16); + expect(ramp[1]).toEqual({ tick: 100, value: 0.0625 }); + const steps = bakeSignal(pat("0 1"), { + bars: 1, + ticksPerBar: 1600, + seed: 0, + }); + expect(steps).toEqual([ + { tick: 0, value: 0 }, + { tick: 799, value: 0 }, + { tick: 800, value: 1 }, + ]); + }); +}); diff --git a/core/sdk/v1.ts b/core/sdk/v1.ts index 65bb69c4..85c0cdfd 100644 --- a/core/sdk/v1.ts +++ b/core/sdk/v1.ts @@ -27,7 +27,7 @@ */ /** SDK release; dawg refreshes the vendored copy when its own is newer. */ -export const SDK_VERSION = "1.34.0"; +export const SDK_VERSION = "1.35.0"; /** Major of `SDK_VERSION`; `dawg.json` records it as `sdk`. */ export const SDK_MAJOR = 1; @@ -3080,6 +3080,12 @@ export type EffectParams = Readonly>; */ export type FxInput = Readonly>; +/** A track's `fx`: effects by name, plus effect patches under `patch`. */ +export type TrackFxInput = Readonly<{ + patch?: readonly PatchSpec[]; + [effect: string]: EffectParams | readonly PatchSpec[] | undefined; +}>; + /** * Synth voice parameters by Strudel name; only the ones given are stored * and the rest take dawg's defaults. Lanes go under `automation.fx` as @@ -3281,7 +3287,9 @@ export type TrackInput = Readonly<{ | ModalSpec | WindSpec | SingSpec - | VocoderSpec; + | VocoderSpec + | PatchSpec + | PatchRefSpec; /** * A vocoder on this track (SDK 1.32.0): `vocoder({ src: "vox" })` lets * the vox track's voice shape this track's sound; `null` removes it. @@ -3363,9 +3371,25 @@ export type TrackInput = Readonly<{ delay?: DelayInput | null; /** Stereo reverb send. */ reverb?: ReverbInput | null; - /** Insert effects by name (`{ distort: { drive: 3 }, chorus: {} }`). */ - fx?: FxInput; + /** + * Insert effects by name (`{ distort: { drive: 3 }, chorus: {} }`), and + * effect patches under `patch` (SDK 1.35.0): `fx: { patch: [wideCrush] }`. + */ + fx?: TrackFxInput; automation?: AutomationInput; + /** + * Signals that turn a patch's macros (SDK 1.35.0), by macro id: + * `mods: { cutoff: sine.range(300, 2400).slow(4) }`. `song()` bakes each + * into the macro's automation lane (`patch-`); a lane written + * under `automation.fx` wins. + */ + mods?: Readonly>; + /** + * Signals that write automation lanes (SDK 1.35.0), by lane name as + * `automation.fx` takes them: `lanes: { "synth-lpf": sine.range(400, 3000) }`. + * `song()` bakes them into points; a lane written under `automation.fx` wins. + */ + lanes?: Readonly>; /** * Glide between notes (SDK 1.15.0): seconds (`glide: 0.08`, TB-303 style * legato) or `{ time, mode }`. Mode `legato` glides only into a note that @@ -3565,6 +3589,13 @@ export type TrackSpec = Readonly<{ vocoder?: Readonly<{ preset?: VocoderPresetName } & VocoderParams>; /** Pitch correction (SDK 1.32.0); present only when set. */ autotune?: AutotuneSettings; + /** The patch this track plays (SDK 1.35.0); instrument is then `"patch"`. */ + patch?: PatchSpec | PatchRefSpec; + /** Effect patches (SDK 1.35.0), in order. */ + fxPatch?: readonly PatchSpec[]; + /** Signals on macros and lanes (SDK 1.35.0); `song()` bakes them. */ + mods?: Readonly>; + lanes?: Readonly>; }>; export type GlideMode = "legato" | "mono" | "poly"; @@ -3815,6 +3846,19 @@ export function track(input: TrackInput): TrackSpec { const id = input.id ?? slug; if (typeof id !== "string" || id.length === 0 || id.length > 64) throw new DawgSdkError(`track ${name}: id must be 1..64 characters`); + // A patch (SDK 1.35.0) plays as the "patch" instrument. + const patchValue = trackPatchValue(input.instrument, name); + const fxPatches = trackFxPatches(input.fx, name); + if (patchValue || fxPatches) { + const { patch: _patch, ...effects } = ( + isRecord(input.fx) ? input.fx : {} + ) as Record; + input = { + ...input, + ...(patchValue ? { instrument: PATCH_INSTRUMENT_WORD } : {}), + ...(fxPatches ? { fx: effects as FxInput } : {}), + }; + } const rawInstrument = engineField(input, name) ?? input.instrument ?? "sine"; // A granular track may keep the sampler it grains (`grain off` goes back). const keptSampler = @@ -4117,9 +4161,71 @@ export function track(input: TrackInput): TrackSpec { ...(input.autotune !== undefined ? { autotune: autotuneInput(input.autotune, `track ${name}`) } : {}), + ...(patchValue ? { patch: patchValue } : {}), + ...(fxPatches ? { fxPatch: fxPatches } : {}), + ...trackSignals(input, name), }); } +const PATCH_INSTRUMENT_WORD = "patch"; + +function trackPatchValue( + value: unknown, + name: string, +): PatchSpec | PatchRefSpec | null { + if (!isPatchValue(value)) return null; + if ("ref" in value) return value; + if (value.role !== "instrument") + throw new DawgSdkError( + `track ${name}: ${value.name} is an effect patch; place it with fx: { patch: [...] }`, + ); + return value; +} + +function trackFxPatches( + fx: unknown, + name: string, +): readonly PatchSpec[] | null { + if (!isRecord(fx) || fx.patch === undefined) return null; + if (!Array.isArray(fx.patch) || fx.patch.length > 8) + throw new DawgSdkError( + `track ${name}: fx.patch must be a list of at most 8 effect patches`, + ); + for (const value of fx.patch) + if (!isPatchValue(value) || "ref" in value || value.role !== "effect") + throw new DawgSdkError( + `track ${name}: fx.patch takes effect patches from fxPatch()`, + ); + return fx.patch.length > 0 ? Object.freeze([...fx.patch]) : null; +} + +function signalRecord( + input: unknown, + label: string, +): Readonly> | null { + if (input === undefined || input === null) return null; + if (!isRecord(input)) throw new DawgSdkError(`${label} must be an object`); + const out: Record = {}; + for (const [key, value] of Object.entries(input)) { + if (value === undefined) continue; + if (!isPatternSignal(value)) + throw new DawgSdkError( + `${label}.${key} must be a signal (sine, saw, pat("…"), …)`, + ); + out[key] = value instanceof PatternSignal ? value : signalFromData(value); + } + return Object.keys(out).length > 0 ? Object.freeze(out) : null; +} + +function trackSignals( + input: TrackInput, + name: string, +): Partial> { + const mods = signalRecord(input.mods, `track ${name}: mods`); + const lanes = signalRecord(input.lanes, `track ${name}: lanes`); + return { ...(mods ? { mods } : {}), ...(lanes ? { lanes } : {}) }; +} + /** * Effects the keys presets set with the voice: a copy of `KEYS_PRESETS` * filter, fx and reverb in core/keys.ts (core/keys.test.ts checks they @@ -4578,6 +4684,11 @@ export type SongInput = Readonly<{ * `dawg init` writes the latest. */ calibration?: number; + /** + * The project patch library (SDK 1.35.0): patches tracks play by name + * with `patch({ ref: "acid-bass", macros: { cutoff: 900 } })`. + */ + patches?: readonly PatchSpec[]; }>; /** A song section (SDK 1.18.0); bars are 0-based like beats. */ @@ -4780,6 +4891,8 @@ export type Song = Readonly<{ loop?: Readonly<{ startBar: number; bars: number }>; /** Present only when the song sets one (SDK 1.32.0). */ calibration?: number; + /** Present only when the song has a patch library (SDK 1.35.0), by name. */ + patches?: Readonly>; }>; /** A song's style provenance (SDK 1.33.0); see `style()`. */ @@ -5327,6 +5440,8 @@ export function song(input: SongInput): Song { const seen = new Set(); const tracks: ScoreTrack[] = []; const notes: ScoreNote[] = []; + const library = songPatches(input.patches); + const ticksPerBar = Math.round(beatsPerBar * ticksPerBeat); input.tracks.forEach((spec, index) => { if (!isRecord(spec) || spec.kind !== "track") throw new DawgSdkError(`song tracks[${index}] must come from track()`); @@ -5362,9 +5477,16 @@ export function song(input: SongInput): Song { if (t.reverb) stored.reverb = t.reverb; if (t.fx) stored.fx = t.fx; if (t.synth) stored.synth = t.synth; - const fxLaneEntries = Object.entries(t.automation.fx ?? {}) + if (t.patch) stored.patch = t.patch; + if (t.fxPatch) stored.fxPatch = t.fxPatch; + // Signals bake into lanes; a lane written under automation.fx wins. + const written = Object.entries(t.automation.fx ?? {}) .map(([key, lane]) => [key, points(lane)] as const) .filter(([, lane]) => lane.length > 0); + const baked = Object.entries( + bakeTrackSignals(t, library, { bars, ticksPerBar }), + ).filter(([key]) => !written.some(([lane]) => lane === key)); + const fxLaneEntries = [...written, ...baked]; if (fxLaneEntries.length > 0) stored.fxAutomation = Object.freeze(Object.fromEntries(fxLaneEntries)); if (t.wavetable) { @@ -5555,9 +5677,88 @@ export function song(input: SongInput): Song { ...songStyle(input.style), ...arrangement, ...(calibration ? { calibration } : {}), + ...(Object.keys(library).length > 0 + ? { patches: Object.freeze(library) } + : {}), }); } +/** `song({ patches })` keyed by name. */ +function songPatches(input: unknown): Record { + const out: Record = {}; + if (input === undefined) return out; + if (!Array.isArray(input) || input.length > 32) + throw new DawgSdkError("song patches must be a list of at most 32 patch()"); + for (const value of input) { + if (!isPatchValue(value) || "ref" in value) + throw new DawgSdkError( + "song patches must come from patch() or fxPatch()", + ); + if (out[value.name]) + throw new DawgSdkError(`song has two patches named ${value.name}`); + out[value.name] = value; + } + return out; +} + +/** A macro's knob position 0..1 for `value` (lin, or exp for a ratio). */ +function knobPosition(macro: PatchMacroData, value: number): number { + const v = Math.min(macro.max, Math.max(macro.min, value)); + const p = + macro.curve === "exp" + ? Math.log(v / macro.min) / Math.log(macro.max / macro.min) + : (v - macro.min) / (macro.max - macro.min); + return Number.isFinite(p) ? Math.min(1, Math.max(0, p)) : 0; +} + +/** + * A track's signals as lanes: hidden patch macros and `mods` as knob + * positions on `patch-`, `lanes` as values. + */ +function bakeTrackSignals( + t: TrackSpec, + library: Readonly>, + grid: Readonly<{ bars: number; ticksPerBar: number }>, +): Record { + const out: Record = {}; + if (!t.patch && !t.fxPatch && !t.mods && !t.lanes) return out; + const seed = signalSeed(t.id); + const bake = (signal: PatternSignal, map?: (value: number) => number) => + bakeSignal(signal, { ...grid, seed, ...(map ? { map } : {}) }); + const played: PatchSpec[] = []; + if (t.patch) { + if ("ref" in t.patch) { + const found = library[t.patch.ref]; + if (!found) + throw new DawgSdkError( + `track ${t.name}: no patch "${t.patch.ref}" in song({ patches })`, + ); + played.push(found); + } else played.push(t.patch); + } + played.push(...(t.fxPatch ?? [])); + const macros = new Map(); + for (const p of played) + for (const macro of p.macros) + if (!macros.has(macro.id)) macros.set(macro.id, macro); + const signals: (readonly [string, PatternSignal])[] = []; + for (const p of played) signals.push(...patchSignals(p)); + for (const [id, signal] of Object.entries(t.mods ?? {})) { + if (!macros.has(id)) + throw new DawgSdkError( + `track ${t.name}: mods.${id} names no macro of its patches (${[...macros.keys()].join(" ") || "none"})`, + ); + signals.push([id, signal]); + } + for (const [id, signal] of signals) { + const macro = macros.get(id)!; + out[`patch-${id}`] = bake(signal, (value) => knobPosition(macro, value)); + } + for (const [lane, signal] of Object.entries(t.lanes ?? {})) + out[lane] = bake(signal); + return out; +} + // --------------------------------------------------------------------------- // Time (SDK 1.14.0) @@ -10306,3 +10507,4827 @@ function hash64(text: string): string { } return a.toString(16).padStart(8, "0") + b.toString(16).padStart(8, "0"); } + +// --------------------------------------------------------------------------- +// Continuous signals (SDK 1.35.0): Strudel's `sine`, `saw`, `rand`, `pat`, … +// as a small serializable AST, evaluated as f(cycle) → number. One cycle is +// one bar. They drive patch macros (`mods:`), patch inputs and any track's +// automation lanes (`lanes:`); `song()` bakes them into automation points. +// --------------------------------------------------------------------------- + +/** The signal sources: Strudel's continuous patterns, `pat()` and a constant. */ +export type SignalOp = + | "sine" + | "cosine" + | "saw" + | "isaw" + | "tri" + | "square" + | "rand" + | "perlin" + | "irand" + | "seq" + | "const" + | "self"; + +/** One transform, applied in order after the source. */ +export type SignalTransform = + | readonly ["range" | "rangex", number, number] + | readonly ["slow" | "fast" | "segment" | "early" | "late", number] + | readonly ["add" | "mul", number | PatternSignal] + | readonly ["every", number, readonly SignalTransform[]]; + +/** + * A continuous signal as data: a source (`op` and `args`) and transforms + * (`xf`). Values follow Strudel's documented formulas; `rand`, `perlin` + * and `irand` are seeded from the track id, so renders are deterministic. + */ +export class PatternSignal { + readonly kind = "signal" as const; + constructor( + readonly op: SignalOp, + readonly args: readonly (number | string)[] = [], + readonly xf: readonly SignalTransform[] = [], + ) { + Object.freeze(this.args); + Object.freeze(this.xf); + Object.freeze(this); + } + private with(transform: SignalTransform): PatternSignal { + return new PatternSignal(this.op, this.args, [...this.xf, transform]); + } + /** Maps 0..1 onto `lo..hi`, as Strudel's `.range`. */ + range(lo: number, hi: number): PatternSignal { + return this.with(["range", finite(lo, "range lo"), finite(hi, "range hi")]); + } + /** Maps 0..1 onto `lo..hi` exponentially (both > 0), as `.rangex`. */ + rangex(lo: number, hi: number): PatternSignal { + return this.with([ + "rangex", + positive(lo, "rangex lo"), + positive(hi, "rangex hi"), + ]); + } + /** `n` times slower: one period spans `n` bars. */ + slow(n: number): PatternSignal { + return this.with(["slow", positive(n, "slow")]); + } + /** `n` times faster. */ + fast(n: number): PatternSignal { + return this.with(["fast", positive(n, "fast")]); + } + /** Holds `n` values per bar, each sampled at its segment's start. */ + segment(n: number): PatternSignal { + return this.with(["segment", positive(n, "segment")]); + } + /** Adds a number or another signal. */ + add(value: number | PatternSignal): PatternSignal { + return this.with(["add", signalOperand(value, "add")]); + } + /** Multiplies by a number or another signal. */ + mul(value: number | PatternSignal): PatternSignal { + return this.with(["mul", signalOperand(value, "mul")]); + } + /** Shifts `bars` earlier in time. */ + early(bars: number): PatternSignal { + return this.with(["early", finite(bars, "early")]); + } + /** Shifts `bars` later in time. */ + late(bars: number): PatternSignal { + return this.with(["late", finite(bars, "late")]); + } + /** + * Applies `transform` on every `n`th bar, starting with the first, as + * Strudel's `every` (`firstOf`): `sine.every(4, (s) => s.fast(2))`. + */ + every( + n: number, + transform: (signal: PatternSignal) => PatternSignal, + ): PatternSignal { + const count = finite(n, "every"); + if (!Number.isInteger(count) || count < 1) + throw new DawgSdkError("every needs a whole number of bars ≥ 1"); + const applied = transform(new PatternSignal("self")); + if (!(applied instanceof PatternSignal) || applied.op !== "self") + throw new DawgSdkError( + "every(n, f): f must return its argument with transforms, like (s) => s.fast(2)", + ); + return this.with(["every", count, applied.xf]); + } +} + +function signalOperand(value: unknown, label: string): number | PatternSignal { + if (value instanceof PatternSignal) return value; + return finite(value, label); +} + +/** True for a `PatternSignal`, including one rebuilt from plain data. */ +export function isPatternSignal(value: unknown): value is PatternSignal { + return ( + value instanceof PatternSignal || + (isRecord(value) && value.kind === "signal" && typeof value.op === "string") + ); +} + +/** A sine 0..1 starting at 0.5 and rising (Strudel `sine`). */ +export const sine = new PatternSignal("sine"); +/** A cosine 0..1: `sine` a quarter bar early (Strudel `cosine`). */ +export const cosine = new PatternSignal("cosine"); +/** A ramp 0..1 each bar (Strudel `saw`). */ +export const saw = new PatternSignal("saw"); +/** A falling ramp 1..0 each bar (Strudel `isaw`). */ +export const isaw = new PatternSignal("isaw"); +/** A triangle 0..1..0 each bar (Strudel `tri`). */ +export const tri = new PatternSignal("tri"); +/** 0 for the first half of each bar, 1 for the second (Strudel `square`). */ +export const square = new PatternSignal("square"); +/** Seeded random 0..1, a fresh value at every instant (Strudel `rand`). */ +export const rand = new PatternSignal("rand"); +/** Seeded smooth noise 0..1 (Strudel `perlin`). */ +export const perlin = new PatternSignal("perlin"); + +/** Seeded random whole numbers `0..n-1` (Strudel `irand(n)`). */ +export function irand(n: number): PatternSignal { + const count = finite(n, "irand"); + if (!Number.isInteger(count) || count < 1) + throw new DawgSdkError("irand needs a whole number ≥ 1"); + return new PatternSignal("irand", [count]); +} + +/** + * Numbers in mini-notation, one bar per cycle: `pat("0 0.5 1")` steps + * through three values a bar; `[a b]` subdivides a step, `` takes one + * per bar, `x*2` repeats inside its step and `x!2` adds steps. + */ +export function pat(text: string): PatternSignal { + if (typeof text !== "string" || text.length > 512) + throw new DawgSdkError( + "pat needs a mini-notation string (≤ 512 characters)", + ); + parseMini(text); + return new PatternSignal("seq", [text]); +} + +/** A constant signal. */ +export function steady(value: number): PatternSignal { + return new PatternSignal("const", [finite(value, "steady")]); +} + +type MiniNode = + | Readonly<{ t: "num"; v: number }> + | Readonly<{ t: "seq"; c: readonly MiniNode[] }> + | Readonly<{ t: "alt"; c: readonly MiniNode[] }> + | Readonly<{ t: "fast"; n: number; c: MiniNode }>; + +const miniCache = new Map(); + +function parseMini(text: string): MiniNode { + const cached = miniCache.get(text); + if (cached) return cached; + const tokens = text.match(/\[|\]|<|>|\*|!|[^\s[\]<>*!]+/g) ?? []; + let at = 0; + const fail = (why: string): never => { + throw new DawgSdkError(`pat("${text.slice(0, 40)}"): ${why}`); + }; + const steps = (close: string | undefined): MiniNode[] => { + const out: MiniNode[] = []; + while (at < tokens.length && tokens[at] !== close) { + const token = tokens[at]!; + at += 1; + let node: MiniNode; + if (token === "[" || token === "<") { + const inner = steps(token === "[" ? "]" : ">"); + if (tokens[at] !== (token === "[" ? "]" : ">")) + fail(`unclosed ${token}`); + at += 1; + if (inner.length === 0) fail(`empty ${token}`); + node = token === "[" ? { t: "seq", c: inner } : { t: "alt", c: inner }; + } else if (/^-?(\d+\.?\d*|\.\d+)(e-?\d+)?$/.test(token)) + node = { t: "num", v: Number(token) }; + else fail(`"${token}" is not a number`); + while (tokens[at] === "*" || tokens[at] === "!") { + const op = tokens[at]!; + const count = Number(tokens[at + 1]); + if ( + !Number.isFinite(count) || + count <= 0 || + (op === "!" && !Number.isInteger(count)) + ) + fail(`${op} needs a positive number`); + at += 2; + if (op === "*") node = { t: "fast", n: count, c: node! }; + else for (let i = 1; i < count; i += 1) out.push(node!); + } + out.push(node!); + } + return out; + }; + const top = steps(undefined); + if (at < tokens.length) fail(`unexpected ${tokens[at]}`); + if (top.length === 0) fail("no values"); + const root: MiniNode = { t: "seq", c: top }; + if (miniCache.size > 256) miniCache.clear(); + miniCache.set(text, root); + return root; +} + +/** Strudel's slowcat time rule: child `m mod n` sees its own cycles in a row. */ +function catTime(t: number, n: number): [number, number] { + const m = Math.floor(t); + return [((m % n) + n) % n, t - (m - Math.floor(m / n))]; +} + +function miniAt(node: MiniNode, t: number): number { + switch (node.t) { + case "num": + return node.v; + case "fast": + return miniAt(node.c, t * node.n); + case "alt": { + const [index, local] = catTime(t, node.c.length); + return miniAt(node.c[index]!, local); + } + case "seq": { + // fastcat = slowcat(...).fast(n) + const [index, local] = catTime(t * node.c.length, node.c.length); + return miniAt(node.c[index]!, local); + } + } +} + +// Strudel's legacy random: xorshift over 300 cycles (signal.mjs). +function xorwise(x: number): number { + const a = (x << 13) ^ x; + const b = (a >> 17) ^ a; + return (b << 5) ^ b; +} + +function randAt(t: number): number { + const fraction = (x: number) => x - Math.trunc(x); + const seed = xorwise(Math.trunc(fraction(t / 300) * 536870912)); + return Math.abs((seed % 536870912) / 536870912); +} + +function perlinAt(t: number, seed: number): number { + const ta = Math.floor(t); + const x = t - ta; + const smoother = 6 * x ** 5 - 15 * x ** 4 + 10 * x ** 3; + const a = randAt(ta + seed); + const b = randAt(ta + 1 + seed); + return a + smoother * (b - a); +} + +function sourceAt(signal: PatternSignal, t: number, seed: number): number { + const frac = t - Math.floor(t); + switch (signal.op) { + case "sine": + return (Math.sin(Math.PI * 2 * t) + 1) / 2; + case "cosine": + return (Math.sin(Math.PI * 2 * (t + 0.25)) + 1) / 2; + case "saw": + return frac; + case "isaw": + return 1 - frac; + case "tri": + return frac < 0.5 ? frac * 2 : 2 - frac * 2; + case "square": + return Math.floor((((t * 2) % 2) + 2) % 2); + case "rand": + return randAt(t + seed); + case "perlin": + return perlinAt(t, seed); + case "irand": + return Math.trunc(randAt(t + seed) * Number(signal.args[0])); + case "seq": + return miniAt(parseMini(String(signal.args[0])), t); + case "const": + return Number(signal.args[0]); + case "self": + throw new DawgSdkError("a signal from every(n, f) cannot stand alone"); + } +} + +function chainAt( + signal: PatternSignal, + xf: readonly SignalTransform[], + count: number, + t: number, + seed: number, +): number { + if (count === 0) return sourceAt(signal, t, seed); + const step = xf[count - 1]!; + const inner = (time: number) => chainAt(signal, xf, count - 1, time, seed); + const operand = (value: number | PatternSignal) => + typeof value === "number" ? value : signalAt(value, t, seed); + switch (step[0]) { + case "range": + return inner(t) * (step[2] - step[1]) + step[1]; + case "rangex": { + const lo = Math.log(step[1]); + return Math.exp(inner(t) * (Math.log(step[2]) - lo) + lo); + } + case "slow": + return inner(t / step[1]); + case "fast": + return inner(t * step[1]); + case "segment": + return inner(Math.floor(t * step[1]) / step[1]); + case "early": + return inner(t + step[1]); + case "late": + return inner(t - step[1]); + case "add": + return inner(t) + operand(step[1]); + case "mul": + return inner(t) * operand(step[1]); + case "every": { + const cycle = Math.floor(t); + if (((cycle % step[1]) + step[1]) % step[1] !== 0) return inner(t); + const nested = [...xf.slice(0, count - 1), ...step[2]]; + return chainAt(signal, nested, nested.length, t, seed); + } + } +} + +/** + * A signal's value at `cycle` (bars from the song start). `seed` offsets + * `rand`, `perlin` and `irand` in time, as Strudel's legacy `randSeed`; + * `signalSeed(trackId)` is the seed `song()` uses. + */ +export function signalAt( + signal: PatternSignal, + cycle: number, + seed = 0, +): number { + return chainAt(signal, signal.xf, signal.xf.length, cycle, seed); +} + +/** The seed a track's signals use: a whole number of cycles from its id. */ +export function signalSeed(trackId: string): number { + return Number.parseInt(hash64(`signal|${trackId}`).slice(0, 8), 16) % 300; +} + +/** True when the signal steps between held values (baked as steps, not ramps). */ +function signalSteps(signal: PatternSignal): boolean { + const stepped = (xf: readonly SignalTransform[]): boolean => + xf.some( + (step) => + step[0] === "segment" || + (step[0] === "every" && stepped(step[2])) || + ((step[0] === "add" || step[0] === "mul") && + step[1] instanceof PatternSignal && + signalSteps(step[1])), + ); + return ( + ["square", "rand", "irand", "seq", "const"].includes(signal.op) || + stepped(signal.xf) + ); +} + +/** + * Automation points for a signal over a song: `[tick, value]` sampled on a + * grid of at most 16 points a bar and 256 in all. Ramping signals keep + * one point per sample (automation ramps between them); stepping signals + * hold each value until one tick before the next. + */ +export function bakeSignal( + signal: PatternSignal, + options: Readonly<{ + bars: number; + ticksPerBar: number; + seed: number; + map?: (value: number) => number; + }>, +): readonly Readonly<{ tick: number; value: number }>[] { + const steps = signalSteps(signal); + const budget = steps ? 128 : 256; + const perBar = Math.max( + 1, + Math.min(16, Math.floor(budget / Math.max(1, options.bars))), + ); + const count = Math.max(1, Math.min(budget, Math.ceil(options.bars * perBar))); + const map = options.map ?? ((value: number) => value); + const out: { tick: number; value: number }[] = []; + const push = (tick: number, value: number) => { + const last = out[out.length - 1]; + if (last && last.tick >= tick) return; + out.push({ tick, value }); + }; + const ticksAt = (k: number) => Math.round((k * options.ticksPerBar) / perBar); + for (let k = 0; k < count; k += 1) { + // Six decimals: below any audible step, and the points print clean. + const value = + Math.round(map(signalAt(signal, k / perBar, options.seed)) * 1e6) / 1e6 || + 0; + if (!Number.isFinite(value)) + throw new DawgSdkError("a signal produced a value that is not finite"); + const tick = ticksAt(k); + if (steps) { + const previous = out[out.length - 1]; + if (previous && previous.value === value) continue; + if (previous && ticksAt(k) - 1 > previous.tick) + push(tick - 1, previous.value); + push(tick, value); + } else push(tick, value); + } + // Drop the middle of three equal points: they say nothing. + const lean = out.filter( + (point, index) => + index === 0 || + index === out.length - 1 || + !( + out[index - 1]!.value === point.value && + out[index + 1]!.value === point.value + ), + ); + return Object.freeze(lean.map((point) => Object.freeze(point))); +} + +/** + * The lowest and highest values a signal takes over its first 16 bars, + * sampled 64 times a bar; patches size a signal's hidden macro from it. + */ +export function signalBounds( + signal: PatternSignal, + seed = 0, +): readonly [number, number] { + let lo = Number.POSITIVE_INFINITY; + let hi = Number.NEGATIVE_INFINITY; + for (let k = 0; k < 16 * 64; k += 1) { + const value = signalAt(signal, k / 64, seed); + if (!Number.isFinite(value)) + throw new DawgSdkError("a signal produced a value that is not finite"); + if (value < lo) lo = value; + if (value > hi) hi = value; + } + // Round outward to 1e-6 so float noise (0.8999999999999999) prints clean. + const down = Math.floor(lo * 1e6 + 1e-6) / 1e6; + const up = Math.ceil(hi * 1e6 - 1e-6) / 1e6; + return [down, Math.max(up, down)]; +} + +const SIGNAL_OPS: readonly SignalOp[] = [ + "sine", + "cosine", + "saw", + "isaw", + "tri", + "square", + "rand", + "perlin", + "irand", + "seq", + "const", +]; + +/** Rebuilds a signal from its plain data (`{ kind: "signal", op, args, xf }`), checking every field. */ +export function signalFromData(input: unknown, depth = 0): PatternSignal { + if (input instanceof PatternSignal) return input; + if (!isRecord(input) || input.kind !== "signal") + throw new DawgSdkError("a signal must come from sine, saw, pat(), …"); + if (depth > 8) throw new DawgSdkError("signals nest at most 8 deep"); + const op = input.op as SignalOp; + if (!SIGNAL_OPS.includes(op)) + throw new DawgSdkError(`unknown signal "${String(input.op).slice(0, 32)}"`); + const args = Array.isArray(input.args) ? input.args : []; + let base: PatternSignal; + if (op === "irand") base = irand(args[0] as number); + else if (op === "seq") base = pat(args[0] as string); + else if (op === "const") base = steady(args[0] as number); + else base = new PatternSignal(op); + const xf = Array.isArray(input.xf) ? input.xf : []; + if (xf.length > 32) + throw new DawgSdkError("a signal holds at most 32 transforms"); + const apply = ( + signal: PatternSignal, + steps: readonly unknown[], + ): PatternSignal => + steps.reduce((acc, raw) => { + if (!Array.isArray(raw)) + throw new DawgSdkError("a signal transform must be a list"); + const [name, a, b] = raw as [string, unknown, unknown]; + switch (name) { + case "range": + case "rangex": + return acc[name](a as number, b as number); + case "slow": + case "fast": + case "segment": + case "early": + case "late": + return acc[name](a as number); + case "add": + case "mul": + return acc[name]( + typeof a === "number" ? a : signalFromData(a, depth + 1), + ); + case "every": { + if (!Array.isArray(b) || b.length > 32) + throw new DawgSdkError("every holds at most 32 transforms"); + return acc.every(a as number, (self) => apply(self, b)); + } + default: + throw new DawgSdkError( + `unknown signal transform "${String(name).slice(0, 32)}"`, + ); + } + }, signal); + return apply(base, xf); +} + +// --------------------------------------------------------------------------- +// Modular patches (SDK 1.35.0): `patch(name, build)` records a small typed +// graph of nodes, cables and macros as the plain data dawg stores +// (`core/patch.ts`). dawg validates it when it loads the song. +// --------------------------------------------------------------------------- + +// BEGIN patch nodes: generated from core/patch-nodes.ts by scripts/gen-patch-sdk.ts + +/** Port codes: `n` notes, `a` audio, `c` control, `ca` control that also takes audio. */ +export type PatchPortCode = "n" | "a" | "c" | "ca"; + +/** Every patch node type: its main output's port code, settings, input ports and output ports (NODE_SPECS). */ +export type PatchNodeTable = { + /** oscillator: sine, saw, square, triangle or pulse, with linear FM */ + osc: { + main: "a"; + params: { + /** waveform (saw, square and pulse are band-limited with PolyBLEP) (default "saw") */ + wave?: "sine" | "saw" | "square" | "tri" | "pulse"; + /** frequency in Hz (wire voice.pitch to play the note) (0..20000 Hz, default 440) */ + pitch?: number; + /** detune in cents (-1200..1200 ct, default 0) */ + detune?: number; + /** pulse width (pulse wave) (0.01..0.99, default 0.5) */ + pw?: number; + /** output level (0..4, default 1) */ + level?: number; + }; + inputs: { + /** frequency in Hz (wire voice.pitch to play the note) */ + pitch: "ca"; + /** detune in cents */ + detune: "c"; + /** pulse width (pulse wave) */ + pw: "c"; + /** linear FM: Hz added to the pitch, at audio rate */ + fm: "a"; + /** output level */ + level: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** seeded noise: white, pink or brown */ + noise: { + main: "a"; + params: { + /** noise color (default "white") */ + color?: "white" | "pink" | "brown"; + /** output level (0..4, default 1) */ + level?: number; + }; + inputs: { + /** output level */ + level: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** state-variable filter (TPT): low, high, band or notch */ + svf: { + main: "a"; + params: { + /** filter response (default "lp") */ + mode?: "lp" | "hp" | "bp" | "notch"; + /** cutoff frequency in Hz (20..20000 Hz, default 1000) */ + cutoff?: number; + /** resonance 0..1 (0..1, default 0.5) */ + q?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** cutoff frequency in Hz */ + cutoff: "c"; + /** resonance 0..1 */ + q: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** one-pole low- or high-pass (6 dB/oct) */ + onepole: { + main: "a"; + params: { + /** filter response (default "lp") */ + mode?: "lp" | "hp"; + /** cutoff frequency in Hz (20..20000 Hz, default 1000) */ + cutoff?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** cutoff frequency in Hz */ + cutoff: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** attack, decay, sustain, release envelope 0..1, opened by a gate */ + adsr: { + main: "c"; + params: { + /** gate: above 0.5 opens (wire voice.gate) (0..1, default 0) */ + gate?: number; + /** attack time (0..10 s, default 0.01) */ + attack?: number; + /** decay time (0..10 s, default 0.1) */ + decay?: number; + /** sustain level (0..1, default 0.7) */ + sustain?: number; + /** release time (0..10 s, default 0.2) */ + release?: number; + }; + inputs: { + /** gate: above 0.5 opens (wire voice.gate) */ + gate: "c"; + /** attack time */ + attack: "c"; + /** decay time */ + decay: "c"; + /** sustain level */ + sustain: "c"; + /** release time */ + release: "c"; + }; + outputs: { + /** envelope 0..1 */ + out: "c"; + }; + }; + /** attack-release envelope 0..1 that follows a gate */ + ar: { + main: "c"; + params: { + /** gate: above 0.5 opens (0..1, default 0) */ + gate?: number; + /** attack time (0..10 s, default 0.01) */ + attack?: number; + /** release time (0..10 s, default 0.2) */ + release?: number; + }; + inputs: { + /** gate: above 0.5 opens */ + gate: "c"; + /** attack time */ + attack: "c"; + /** release time */ + release: "c"; + }; + outputs: { + /** envelope 0..1 */ + out: "c"; + }; + }; + /** slew limiter: glides a control toward its input */ + slew: { + main: "c"; + params: { + /** input (-1000000..1000000, default 0) */ + in?: number; + /** time to rise by one unit (0..10 s, default 0.05) */ + rise?: number; + /** time to fall by one unit (0..10 s, default 0.05) */ + fall?: number; + }; + inputs: { + /** input */ + in: "c"; + /** time to rise by one unit */ + rise: "c"; + /** time to fall by one unit */ + fall: "c"; + }; + outputs: { + /** slewed value */ + out: "c"; + }; + }; + /** envelope follower: the level of an audio signal as a control */ + follow: { + main: "c"; + params: { + /** attack time (0.0001..1 s, default 0.005) */ + attack?: number; + /** release time (0.001..4 s, default 0.1) */ + release?: number; + }; + inputs: { + /** audio to follow */ + in: "a"; + /** attack time */ + attack: "c"; + /** release time */ + release: "c"; + }; + outputs: { + /** level 0..1 */ + out: "c"; + }; + }; + /** low-frequency oscillator -1..1, free in Hz or synced to beats */ + lfo: { + main: "c"; + params: { + /** wave shape (random is seeded sample-and-hold per cycle) (default "sine") */ + shape?: "sine" | "tri" | "square" | "saw" | "ramp" | "random"; + /** period in beats; 0 follows rate (0..64 beats, default 0) */ + sync?: number; + /** rate in Hz (when sync is 0) (0..100 Hz, default 1) */ + rate?: number; + /** phase offset in cycles (0..1, default 0) */ + phase?: number; + /** output depth (0..1, default 1) */ + depth?: number; + }; + inputs: { + /** rate in Hz (when sync is 0) */ + rate: "c"; + /** phase offset in cycles */ + phase: "c"; + /** output depth */ + depth: "c"; + }; + outputs: { + /** -1..1 */ + out: "c"; + }; + }; + /** sample and hold: latches its input when the trigger rises */ + sh: { + main: "c"; + params: { + /** value to sample (-1000000..1000000, default 0) */ + in?: number; + /** trigger: a rise through 0.5 samples (0..1, default 0) */ + trig?: number; + }; + inputs: { + /** value to sample */ + in: "c"; + /** trigger: a rise through 0.5 samples */ + trig: "c"; + }; + outputs: { + /** held value */ + out: "c"; + }; + }; + /** seeded random value 0..1, drawn per note or per block */ + random: { + main: "c"; + params: { + /** when a new value is drawn (default "note") */ + per?: "note" | "block"; + }; + inputs: {}; + outputs: { + /** 0..1 */ + out: "c"; + }; + }; + /** a constant control value */ + const: { + main: "c"; + params: { + /** the value (-1000000..1000000, default 0) */ + value?: number; + }; + inputs: {}; + outputs: { + /** the value */ + out: "c"; + }; + }; + /** a + b */ + add: { + main: "c"; + params: { + /** first operand (-1000000..1000000, default 0) */ + a?: number; + /** second operand (-1000000..1000000, default 0) */ + b?: number; + }; + inputs: { + /** first operand */ + a: "c"; + /** second operand */ + b: "c"; + }; + outputs: { + /** result */ + out: "c"; + }; + }; + /** a × b */ + mul: { + main: "c"; + params: { + /** first operand (-1000000..1000000, default 0) */ + a?: number; + /** second operand (-1000000..1000000, default 1) */ + b?: number; + }; + inputs: { + /** first operand */ + a: "c"; + /** second operand */ + b: "c"; + }; + outputs: { + /** result */ + out: "c"; + }; + }; + /** the smaller of a and b */ + min: { + main: "c"; + params: { + /** first operand (-1000000..1000000, default 0) */ + a?: number; + /** second operand (-1000000..1000000, default 0) */ + b?: number; + }; + inputs: { + /** first operand */ + a: "c"; + /** second operand */ + b: "c"; + }; + outputs: { + /** result */ + out: "c"; + }; + }; + /** the larger of a and b */ + max: { + main: "c"; + params: { + /** first operand (-1000000..1000000, default 0) */ + a?: number; + /** second operand (-1000000..1000000, default 0) */ + b?: number; + }; + inputs: { + /** first operand */ + a: "c"; + /** second operand */ + b: "c"; + }; + outputs: { + /** result */ + out: "c"; + }; + }; + /** 1 when a > b, else 0 */ + gt: { + main: "c"; + params: { + /** first operand (-1000000..1000000, default 0) */ + a?: number; + /** second operand (-1000000..1000000, default 0) */ + b?: number; + }; + inputs: { + /** first operand */ + a: "c"; + /** second operand */ + b: "c"; + }; + outputs: { + /** result */ + out: "c"; + }; + }; + /** 1 when a < b, else 0 */ + lt: { + main: "c"; + params: { + /** first operand (-1000000..1000000, default 0) */ + a?: number; + /** second operand (-1000000..1000000, default 0) */ + b?: number; + }; + inputs: { + /** first operand */ + a: "c"; + /** second operand */ + b: "c"; + }; + outputs: { + /** result */ + out: "c"; + }; + }; + /** |in| */ + abs: { + main: "c"; + params: { + /** input (-1000000..1000000, default 0) */ + in?: number; + }; + inputs: { + /** input */ + in: "c"; + }; + outputs: { + /** result */ + out: "c"; + }; + }; + /** 1 when in < 0.5, else 0 */ + not: { + main: "c"; + params: { + /** input (-1000000..1000000, default 0) */ + in?: number; + }; + inputs: { + /** input */ + in: "c"; + }; + outputs: { + /** result */ + out: "c"; + }; + }; + /** MIDI note number to Hz (12-TET, A4 = 440) */ + pitch2hz: { + main: "c"; + params: { + /** input (-1000000..1000000, default 0) */ + in?: number; + }; + inputs: { + /** input */ + in: "c"; + }; + outputs: { + /** result */ + out: "c"; + }; + }; + /** decibels to linear gain */ + db2gain: { + main: "c"; + params: { + /** input (-1000000..1000000, default 0) */ + in?: number; + }; + inputs: { + /** input */ + in: "c"; + }; + outputs: { + /** result */ + out: "c"; + }; + }; + /** maps in from [inmin, inmax] to [min, max], linear or exponential */ + scale: { + main: "c"; + params: { + /** lin or exp (exp needs min > 0) (default "lin") */ + curve?: "lin" | "exp"; + /** input (-1000000..1000000, default 0) */ + in?: number; + /** input range low (-1000000..1000000, default -1) */ + inmin?: number; + /** input range high (-1000000..1000000, default 1) */ + inmax?: number; + /** output range low (-1000000..1000000, default 0) */ + min?: number; + /** output range high (-1000000..1000000, default 1) */ + max?: number; + }; + inputs: { + /** input */ + in: "c"; + /** input range low */ + inmin: "c"; + /** input range high */ + inmax: "c"; + /** output range low */ + min: "c"; + /** output range high */ + max: "c"; + }; + outputs: { + /** mapped value */ + out: "c"; + }; + }; + /** limits in to [min, max] */ + clamp: { + main: "c"; + params: { + /** input (-1000000..1000000, default 0) */ + in?: number; + /** low limit (-1000000..1000000, default 0) */ + min?: number; + /** high limit (-1000000..1000000, default 1) */ + max?: number; + }; + inputs: { + /** input */ + in: "c"; + /** low limit */ + min: "c"; + /** high limit */ + max: "c"; + }; + outputs: { + /** clamped value */ + out: "c"; + }; + }; + /** tempo-synced gate: high for the first half of each cycle */ + clock: { + main: "c"; + params: { + /** period in beats (0.0625..64 beats, default 1) */ + beats?: number; + /** high fraction (0.01..0.99, default 0.5) */ + width?: number; + }; + inputs: { + /** high fraction */ + width: "c"; + }; + outputs: { + /** gate 0/1 */ + out: "c"; + }; + }; + /** amplifier: audio times gain (wire an envelope to gain) */ + vca: { + main: "a"; + params: { + /** gain (0..4, default 1) */ + gain?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** gain */ + gain: "ca"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** four-input audio mixer with a level per input */ + mix: { + main: "a"; + params: { + /** level of a (0..4, default 1) */ + la?: number; + /** level of b (0..4, default 1) */ + lb?: number; + /** level of c (0..4, default 1) */ + lc?: number; + /** level of d (0..4, default 1) */ + ld?: number; + }; + inputs: { + /** input a */ + a: "a"; + /** input b */ + b: "a"; + /** input c */ + c: "a"; + /** input d */ + d: "a"; + /** level of a */ + la: "c"; + /** level of b */ + lb: "c"; + /** level of c */ + lc: "c"; + /** level of d */ + ld: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** equal-gain crossfade from a (x = 0) to b (x = 1) */ + xfade: { + main: "a"; + params: { + /** crossfade position (0..1, default 0.5) */ + x?: number; + }; + inputs: { + /** input a */ + a: "a"; + /** input b */ + b: "a"; + /** crossfade position */ + x: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** equal-power pan of a mono signal to left and right */ + pan: { + main: "a"; + params: { + /** -1 left .. 1 right (-1..1, default 0) */ + pan?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** -1 left .. 1 right */ + pan: "c"; + }; + outputs: { + /** left out */ + left: "a"; + /** right out */ + right: "a"; + }; + }; + /** explicit voice sum: every voice's input, summed once for the track */ + voicesum: { + main: "a"; + params: {}; + inputs: { + /** per-voice audio */ + in: "a"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** the whole synth voice (oscillators, FM, unison, filters, envelopes), samplers and wavetables included */ + "engine.synth": { + main: "a"; + params: { + /** which instrument of this engine plays (default "") */ + instrument?: string; + /** amplitude attack: onset to peak (0..10 s, default 0.003) */ + attack?: number; + /** amplitude decay: peak to sustain level (0..10 s, default 0.05) */ + decay?: number; + /** amplitude sustain level held until note-off (0..1, default 1) */ + sustain?: number; + /** amplitude release after note-off (0..10 s, default 0.05) */ + release?: number; + /** voice gain before the effects chain (track volume follows the chain) (0..4, default 1) */ + gain?: number; + /** pink noise mixed into the oscillator (z_* sounds: phase jitter) (0..1, default 0) */ + noise?: number; + /** crackle density (impulses ≈ density·1000/s) (0..1, default 0.03) */ + density?: number; + /** total pitch spread of the unison voices in semitones (0..12 st, default 0.2) */ + detune?: number; + /** stereo spread of the unison voices (0..1, default 0.6) */ + spread?: number; + /** pulse width (pulse sound) (0..1, default 0.5) */ + pw?: number; + /** pulse-width LFO rate (triangle) (0..40 Hz, default 1) */ + pwrate?: number; + /** pulse-width LFO depth (0..1, default 0) */ + pwsweep?: number; + /** vibrato rate; 0 is off (0..64 Hz, default 0) */ + vib?: number; + /** vibrato depth in semitones (0..24 st, default 0.5) */ + vibmod?: number; + /** pitch envelope depth in semitones (negative inverts); 0 is off (-48..48 st, default 0) */ + penv?: number; + /** pitch envelope attack (0..10 s, default 0.2) */ + pattack?: number; + /** pitch envelope decay (0..10 s, default 0) */ + pdecay?: number; + /** pitch envelope sustain level (0..1, default 1) */ + psustain?: number; + /** pitch envelope release (0..10 s, default 0) */ + prelease?: number; + /** per-note low-pass cutoff; unset is no low-pass (20..20000 Hz, default 2000) */ + lpf?: number; + /** resonance as filter Q (0..50; 0.7 is flat, higher rings) (0..50, default 1) */ + lpq?: number; + /** low-pass envelope depth in octaves above (below, negative) the cutoff (-10..10 oct, default 0) */ + lpenv?: number; + /** low-pass envelope attack (0..10 s, default 0.005) */ + lpattack?: number; + /** low-pass envelope decay (0..10 s, default 0.15) */ + lpdecay?: number; + /** low-pass envelope sustain level (0..1, default 0) */ + lpsustain?: number; + /** low-pass envelope release (0..10 s, default 0.1) */ + lprelease?: number; + /** per-note high-pass cutoff; unset is no high-pass (20..20000 Hz, default 200) */ + hpf?: number; + /** resonance as filter Q (0..50; 0.7 is flat, higher rings) (0..50, default 1) */ + hpq?: number; + /** high-pass envelope depth in octaves above (below, negative) the cutoff (-10..10 oct, default 0) */ + hpenv?: number; + /** high-pass envelope attack (0..10 s, default 0.005) */ + hpattack?: number; + /** high-pass envelope decay (0..10 s, default 0.15) */ + hpdecay?: number; + /** high-pass envelope sustain level (0..1, default 0) */ + hpsustain?: number; + /** high-pass envelope release (0..10 s, default 0.1) */ + hprelease?: number; + /** per-note band-pass cutoff; unset is no band-pass (20..20000 Hz, default 1000) */ + bpf?: number; + /** resonance as filter Q (0..50; 0.7 is flat, higher rings) (0..50, default 1) */ + bpq?: number; + /** band-pass envelope depth in octaves above (below, negative) the cutoff (-10..10 oct, default 0) */ + bpenv?: number; + /** band-pass envelope attack (0..10 s, default 0.005) */ + bpattack?: number; + /** band-pass envelope decay (0..10 s, default 0.15) */ + bpdecay?: number; + /** band-pass envelope sustain level (0..1, default 0) */ + bpsustain?: number; + /** band-pass envelope release (0..10 s, default 0.1) */ + bprelease?: number; + /** filter envelope anchor: 0 sweeps up from the cutoff, 1 down to it (0..1, default 0) */ + fanchor?: number; + /** FM modulation index (peak deviation ÷ modulator frequency); 0 is off (0..64, default 0) */ + fm?: number; + /** FM harmonicity: modulator ÷ carrier frequency (integers sound harmonic) (0..32, default 1) */ + fmh?: number; + /** FM envelope attack (0..10 s, default 0) */ + fmattack?: number; + /** FM envelope decay (0..10 s, default 0) */ + fmdecay?: number; + /** FM envelope sustain level (0..1, default 1) */ + fmsustain?: number; + /** FM envelope release (0..10 s, default 0) */ + fmrelease?: number; + /** FM 2 modulation index (peak deviation ÷ modulator frequency); 0 is off (0..64, default 0) */ + fm2?: number; + /** FM 2 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) (0..32, default 1) */ + fmh2?: number; + /** FM 2 envelope attack (0..10 s, default 0) */ + fmattack2?: number; + /** FM 2 envelope decay (0..10 s, default 0) */ + fmdecay2?: number; + /** FM 2 envelope sustain level (0..1, default 1) */ + fmsustain2?: number; + /** FM 2 envelope release (0..10 s, default 0) */ + fmrelease2?: number; + /** FM 3 modulation index (peak deviation ÷ modulator frequency); 0 is off (0..64, default 0) */ + fm3?: number; + /** FM 3 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) (0..32, default 1) */ + fmh3?: number; + /** FM 3 envelope attack (0..10 s, default 0) */ + fmattack3?: number; + /** FM 3 envelope decay (0..10 s, default 0) */ + fmdecay3?: number; + /** FM 3 envelope sustain level (0..1, default 1) */ + fmsustain3?: number; + /** FM 3 envelope release (0..10 s, default 0) */ + fmrelease3?: number; + /** FM 4 modulation index (peak deviation ÷ modulator frequency); 0 is off (0..64, default 0) */ + fm4?: number; + /** FM 4 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) (0..32, default 1) */ + fmh4?: number; + /** FM 4 envelope attack (0..10 s, default 0) */ + fmattack4?: number; + /** FM 4 envelope decay (0..10 s, default 0) */ + fmdecay4?: number; + /** FM 4 envelope sustain level (0..1, default 1) */ + fmsustain4?: number; + /** FM 4 envelope release (0..10 s, default 0) */ + fmrelease4?: number; + /** FM 5 modulation index (peak deviation ÷ modulator frequency); 0 is off (0..64, default 0) */ + fm5?: number; + /** FM 5 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) (0..32, default 1) */ + fmh5?: number; + /** FM 5 envelope attack (0..10 s, default 0) */ + fmattack5?: number; + /** FM 5 envelope decay (0..10 s, default 0) */ + fmdecay5?: number; + /** FM 5 envelope sustain level (0..1, default 1) */ + fmsustain5?: number; + /** FM 5 envelope release (0..10 s, default 0) */ + fmrelease5?: number; + /** FM 6 modulation index (peak deviation ÷ modulator frequency); 0 is off (0..64, default 0) */ + fm6?: number; + /** FM 6 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) (0..32, default 1) */ + fmh6?: number; + /** FM 6 envelope attack (0..10 s, default 0) */ + fmattack6?: number; + /** FM 6 envelope decay (0..10 s, default 0) */ + fmdecay6?: number; + /** FM 6 envelope sustain level (0..1, default 1) */ + fmsustain6?: number; + /** FM 6 envelope release (0..10 s, default 0) */ + fmrelease6?: number; + /** FM 7 modulation index (peak deviation ÷ modulator frequency); 0 is off (0..64, default 0) */ + fm7?: number; + /** FM 7 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) (0..32, default 1) */ + fmh7?: number; + /** FM 7 envelope attack (0..10 s, default 0) */ + fmattack7?: number; + /** FM 7 envelope decay (0..10 s, default 0) */ + fmdecay7?: number; + /** FM 7 envelope sustain level (0..1, default 1) */ + fmsustain7?: number; + /** FM 7 envelope release (0..10 s, default 0) */ + fmrelease7?: number; + /** FM 8 modulation index (peak deviation ÷ modulator frequency); 0 is off (0..64, default 0) */ + fm8?: number; + /** FM 8 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) (0..32, default 1) */ + fmh8?: number; + /** FM 8 envelope attack (0..10 s, default 0) */ + fmattack8?: number; + /** FM 8 envelope decay (0..10 s, default 0) */ + fmdecay8?: number; + /** FM 8 envelope sustain level (0..1, default 1) */ + fmsustain8?: number; + /** FM 8 envelope release (0..10 s, default 0) */ + fmrelease8?: number; + /** z_*: random pitch offset per note, ± fraction (0..1, default 0) */ + zrand?: number; + /** z_*: wave shape exponent (0 squares the wave off, >1 thins it) (0..3, default 1) */ + curve?: number; + /** z_*: pitch slide, 500·slide Hz per second (-20..20, default 0) */ + slide?: number; + /** z_*: slide acceleration, 500·deltaSlide Hz per second² (-20..20, default 0) */ + deltaSlide?: number; + /** z_*: pitch change applied after pitchJumpTime (-2000..2000 Hz, default 0) */ + pitchJump?: number; + /** z_*: time before pitchJump applies (0: never) (0..10 s, default 0) */ + pitchJumpTime?: number; + /** z_*: repeat period: restarts slide and pitchJump, sets the tremolo period (0..10 s, default 0) */ + lfo?: number; + /** z_*: frequency-modulation speed (±50 % depth) (0..1000 Hz, default 0) */ + zmod?: number; + /** z_*: sample-hold bit crush, 0..1 (0..1, default 0) */ + zcrush?: number; + /** z_*: one echo this many seconds later, half level (0..1 s, default 0) */ + zdelay?: number; + /** z_*: volume modulation amount at the lfo period (0..1, default 0) */ + tremolo?: number; + /** engine settings, as the track field holds them */ + settings?: Readonly>; + }; + inputs: { + /** the notes to play (wire in.notes) */ + notes: "n"; + /** amplitude attack: onset to peak */ + attack: "c"; + /** amplitude decay: peak to sustain level */ + decay: "c"; + /** amplitude sustain level held until note-off */ + sustain: "c"; + /** amplitude release after note-off */ + release: "c"; + /** voice gain before the effects chain (track volume follows the chain) */ + gain: "c"; + /** pink noise mixed into the oscillator (z_* sounds: phase jitter) */ + noise: "c"; + /** crackle density (impulses ≈ density·1000/s) */ + density: "c"; + /** total pitch spread of the unison voices in semitones */ + detune: "c"; + /** stereo spread of the unison voices */ + spread: "c"; + /** pulse width (pulse sound) */ + pw: "c"; + /** pulse-width LFO rate (triangle) */ + pwrate: "c"; + /** pulse-width LFO depth */ + pwsweep: "c"; + /** vibrato rate; 0 is off */ + vib: "c"; + /** vibrato depth in semitones */ + vibmod: "c"; + /** pitch envelope depth in semitones (negative inverts); 0 is off */ + penv: "c"; + /** pitch envelope attack */ + pattack: "c"; + /** pitch envelope decay */ + pdecay: "c"; + /** pitch envelope sustain level */ + psustain: "c"; + /** pitch envelope release */ + prelease: "c"; + /** per-note low-pass cutoff; unset is no low-pass */ + lpf: "c"; + /** resonance as filter Q (0..50; 0.7 is flat, higher rings) */ + lpq: "c"; + /** low-pass envelope depth in octaves above (below, negative) the cutoff */ + lpenv: "c"; + /** low-pass envelope attack */ + lpattack: "c"; + /** low-pass envelope decay */ + lpdecay: "c"; + /** low-pass envelope sustain level */ + lpsustain: "c"; + /** low-pass envelope release */ + lprelease: "c"; + /** per-note high-pass cutoff; unset is no high-pass */ + hpf: "c"; + /** resonance as filter Q (0..50; 0.7 is flat, higher rings) */ + hpq: "c"; + /** high-pass envelope depth in octaves above (below, negative) the cutoff */ + hpenv: "c"; + /** high-pass envelope attack */ + hpattack: "c"; + /** high-pass envelope decay */ + hpdecay: "c"; + /** high-pass envelope sustain level */ + hpsustain: "c"; + /** high-pass envelope release */ + hprelease: "c"; + /** per-note band-pass cutoff; unset is no band-pass */ + bpf: "c"; + /** resonance as filter Q (0..50; 0.7 is flat, higher rings) */ + bpq: "c"; + /** band-pass envelope depth in octaves above (below, negative) the cutoff */ + bpenv: "c"; + /** band-pass envelope attack */ + bpattack: "c"; + /** band-pass envelope decay */ + bpdecay: "c"; + /** band-pass envelope sustain level */ + bpsustain: "c"; + /** band-pass envelope release */ + bprelease: "c"; + /** filter envelope anchor: 0 sweeps up from the cutoff, 1 down to it */ + fanchor: "c"; + /** FM modulation index (peak deviation ÷ modulator frequency); 0 is off */ + fm: "c"; + /** FM harmonicity: modulator ÷ carrier frequency (integers sound harmonic) */ + fmh: "c"; + /** FM envelope attack */ + fmattack: "c"; + /** FM envelope decay */ + fmdecay: "c"; + /** FM envelope sustain level */ + fmsustain: "c"; + /** FM envelope release */ + fmrelease: "c"; + /** FM 2 modulation index (peak deviation ÷ modulator frequency); 0 is off */ + fm2: "c"; + /** FM 2 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) */ + fmh2: "c"; + /** FM 2 envelope attack */ + fmattack2: "c"; + /** FM 2 envelope decay */ + fmdecay2: "c"; + /** FM 2 envelope sustain level */ + fmsustain2: "c"; + /** FM 2 envelope release */ + fmrelease2: "c"; + /** FM 3 modulation index (peak deviation ÷ modulator frequency); 0 is off */ + fm3: "c"; + /** FM 3 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) */ + fmh3: "c"; + /** FM 3 envelope attack */ + fmattack3: "c"; + /** FM 3 envelope decay */ + fmdecay3: "c"; + /** FM 3 envelope sustain level */ + fmsustain3: "c"; + /** FM 3 envelope release */ + fmrelease3: "c"; + /** FM 4 modulation index (peak deviation ÷ modulator frequency); 0 is off */ + fm4: "c"; + /** FM 4 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) */ + fmh4: "c"; + /** FM 4 envelope attack */ + fmattack4: "c"; + /** FM 4 envelope decay */ + fmdecay4: "c"; + /** FM 4 envelope sustain level */ + fmsustain4: "c"; + /** FM 4 envelope release */ + fmrelease4: "c"; + /** FM 5 modulation index (peak deviation ÷ modulator frequency); 0 is off */ + fm5: "c"; + /** FM 5 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) */ + fmh5: "c"; + /** FM 5 envelope attack */ + fmattack5: "c"; + /** FM 5 envelope decay */ + fmdecay5: "c"; + /** FM 5 envelope sustain level */ + fmsustain5: "c"; + /** FM 5 envelope release */ + fmrelease5: "c"; + /** FM 6 modulation index (peak deviation ÷ modulator frequency); 0 is off */ + fm6: "c"; + /** FM 6 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) */ + fmh6: "c"; + /** FM 6 envelope attack */ + fmattack6: "c"; + /** FM 6 envelope decay */ + fmdecay6: "c"; + /** FM 6 envelope sustain level */ + fmsustain6: "c"; + /** FM 6 envelope release */ + fmrelease6: "c"; + /** FM 7 modulation index (peak deviation ÷ modulator frequency); 0 is off */ + fm7: "c"; + /** FM 7 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) */ + fmh7: "c"; + /** FM 7 envelope attack */ + fmattack7: "c"; + /** FM 7 envelope decay */ + fmdecay7: "c"; + /** FM 7 envelope sustain level */ + fmsustain7: "c"; + /** FM 7 envelope release */ + fmrelease7: "c"; + /** FM 8 modulation index (peak deviation ÷ modulator frequency); 0 is off */ + fm8: "c"; + /** FM 8 harmonicity: modulator ÷ carrier frequency (integers sound harmonic) */ + fmh8: "c"; + /** FM 8 envelope attack */ + fmattack8: "c"; + /** FM 8 envelope decay */ + fmdecay8: "c"; + /** FM 8 envelope sustain level */ + fmsustain8: "c"; + /** FM 8 envelope release */ + fmrelease8: "c"; + /** z_*: random pitch offset per note, ± fraction */ + zrand: "c"; + /** z_*: wave shape exponent (0 squares the wave off, >1 thins it) */ + curve: "c"; + /** z_*: pitch slide, 500·slide Hz per second */ + slide: "c"; + /** z_*: slide acceleration, 500·deltaSlide Hz per second² */ + deltaSlide: "c"; + /** z_*: pitch change applied after pitchJumpTime */ + pitchJump: "c"; + /** z_*: time before pitchJump applies (0: never) */ + pitchJumpTime: "c"; + /** z_*: repeat period: restarts slide and pitchJump, sets the tremolo period */ + lfo: "c"; + /** z_*: frequency-modulation speed (±50 % depth) */ + zmod: "c"; + /** z_*: sample-hold bit crush, 0..1 */ + zcrush: "c"; + /** z_*: one echo this many seconds later, half level */ + zdelay: "c"; + /** z_*: volume modulation amount at the lfo period */ + tremolo: "c"; + }; + outputs: { + /** audio out (left when stereo) */ + out: "a"; + /** right out (equals out for a mono engine) */ + right: "a"; + }; + }; + /** modal resonators: mallets, bars, bells and plates */ + "engine.modal": { + main: "a"; + params: { + /** which instrument of this engine plays (default "modal") */ + instrument?: "modal"; + /** mallet hardness: 0 yarn/felt, 1 brass (0..1, default 0.4) */ + hardness?: number; + /** strike point: 0 end or edge, 0.5 center (0..1, default 0.42) */ + position?: number; + /** ring time (T60) at middle C (0.05..30 s, default 1.6) */ + ring?: number; + /** how much faster high modes and notes decay (0..2, default 0.9) */ + tilt?: number; + /** damping at note-off: 0 rings on, 1 chokes (0..1, default 0) */ + damp?: number; + /** motor tremolo depth (0..1, default 0) */ + motordepth?: number; + /** buzzer or jingle amount (0..1, default 0) */ + buzz?: number; + /** mallet contact click (0..1, default 0) */ + click?: number; + /** level (0..2, default 0.8) */ + gain?: number; + /** engine settings, as the track field holds them */ + settings?: Readonly>; + }; + inputs: { + /** the notes to play (wire in.notes) */ + notes: "n"; + /** mallet hardness: 0 yarn/felt, 1 brass */ + hardness: "c"; + /** strike point: 0 end or edge, 0.5 center */ + position: "c"; + /** ring time (T60) at middle C */ + ring: "c"; + /** how much faster high modes and notes decay */ + tilt: "c"; + /** damping at note-off: 0 rings on, 1 chokes */ + damp: "c"; + /** motor tremolo depth */ + motordepth: "c"; + /** buzzer or jingle amount */ + buzz: "c"; + /** mallet contact click */ + click: "c"; + /** level */ + gain: "c"; + }; + outputs: { + /** audio out (left when stereo) */ + out: "a"; + /** right out (equals out for a mono engine) */ + right: "a"; + }; + }; + /** physically modeled plucked and bowed strings */ + "engine.string": { + main: "a"; + params: { + /** which instrument of this engine plays (default "string") */ + instrument?: "string"; + /** how long a note rings (T60 at C4) (0.05..60 s, default 3) */ + ring?: number; + /** high-frequency loss (dark, dead strings at 1) (0..1, default 0.4) */ + damp?: number; + /** pluck or strike position from the bridge (0.5 round, 0.04 nasal) (0.02..0.5, default 0.15) */ + pos?: number; + /** excitation brightness at full velocity (0..1, default 0.6) */ + bright?: number; + /** palm mute: shortens the ring and darkens the string (0..1, default 0) */ + mute?: number; + /** bridge buzz (sitar, tanpura jawari); 0 off (0..1, default 0) */ + buzz?: number; + /** vibrato rate (a note's own vibrato overrides it) (0..12 Hz, default 0) */ + vib?: number; + /** vibrato depth in semitones (0..2 st, default 0.18) */ + vibmod?: number; + /** output level (presets carry a calibrated trim) (0..2, default 1) */ + gain?: number; + /** bow force within the playable range: flautando at 0, gritty at 1 (0..1, default 0.5) */ + pressure?: number; + /** bow speed at full dynamics (loudness) (0..1, default 0.6) */ + speed?: number; + /** con sordino: the practice mute, darker and softer (0..1, default 0) */ + sord?: number; + /** dynamics on top of velocity, drives bow speed and pressure (swells) (0..1, default 1) */ + dyn?: number; + /** engine settings, as the track field holds them */ + settings?: Readonly>; + }; + inputs: { + /** the notes to play (wire in.notes) */ + notes: "n"; + /** how long a note rings (T60 at C4) */ + ring: "c"; + /** high-frequency loss (dark, dead strings at 1) */ + damp: "c"; + /** pluck or strike position from the bridge (0.5 round, 0.04 nasal) */ + pos: "c"; + /** excitation brightness at full velocity */ + bright: "c"; + /** palm mute: shortens the ring and darkens the string */ + mute: "c"; + /** bridge buzz (sitar, tanpura jawari); 0 off */ + buzz: "c"; + /** vibrato rate (a note's own vibrato overrides it) */ + vib: "c"; + /** vibrato depth in semitones */ + vibmod: "c"; + /** output level (presets carry a calibrated trim) */ + gain: "c"; + /** bow force within the playable range: flautando at 0, gritty at 1 */ + pressure: "c"; + /** bow speed at full dynamics (loudness) */ + speed: "c"; + /** con sordino: the practice mute, darker and softer */ + sord: "c"; + /** dynamics on top of velocity, drives bow speed and pressure (swells) */ + dyn: "c"; + }; + outputs: { + /** audio out (left when stereo) */ + out: "a"; + /** right out (equals out for a mono engine) */ + right: "a"; + }; + }; + /** physically modeled winds and brass */ + "engine.wind": { + main: "a"; + params: { + /** which instrument of this engine plays (default "wind") */ + instrument?: "wind"; + /** blowing pressure: swells, louder and fuller (0..1, default 0.6) */ + breath?: number; + /** breath noise (0..1, default 0.08) */ + noise?: number; + /** plunger opening: 0 closed, 1 open (0..1, default 1) */ + wah?: number; + /** hum into the horn: rough, raspy (0..1, default 0) */ + growl?: number; + /** flutter tongue (rolled r) (0..1, default 0) */ + flutter?: number; + /** engine settings, as the track field holds them */ + settings?: Readonly>; + }; + inputs: { + /** the notes to play (wire in.notes) */ + notes: "n"; + /** blowing pressure: swells, louder and fuller */ + breath: "c"; + /** breath noise */ + noise: "c"; + /** plunger opening: 0 closed, 1 open */ + wah: "c"; + /** hum into the horn: rough, raspy */ + growl: "c"; + /** flutter tongue (rolled r) */ + flutter: "c"; + }; + outputs: { + /** audio out (left when stereo) */ + out: "a"; + /** right out (equals out for a mono engine) */ + right: "a"; + }; + }; + /** the singing voice (lyrics, formants, choir) */ + "engine.sing": { + main: "a"; + params: { + /** which instrument of this engine plays (default "sing") */ + instrument?: "sing"; + /** how far an a>o vowel travels by the note's end (0..1, default 1) */ + morph?: number; + /** throat size: formant shift, keeps pitch (-12..12 st, default 0) */ + formant?: number; + /** voice quality: breathy and dark .. pressed (0..1, default 0.5) */ + bright?: number; + /** aspiration noise (0..1, default 0.12) */ + breath?: number; + /** vibrato depth (0..1 st, default 0.3) */ + vibmod?: number; + /** singer's formant (bright 3 kHz ring) (0..1, default 0) */ + ring?: number; + /** overtone filter sharpness and level (0..1, default 0) */ + overtone?: number; + /** kargyraa subharmonic (an octave below) (0..1, default 0) */ + sub?: number; + /** engine settings, as the track field holds them */ + settings?: Readonly>; + }; + inputs: { + /** the notes to play (wire in.notes) */ + notes: "n"; + /** how far an a>o vowel travels by the note's end */ + morph: "c"; + /** throat size: formant shift, keeps pitch */ + formant: "c"; + /** voice quality: breathy and dark .. pressed */ + bright: "c"; + /** aspiration noise */ + breath: "c"; + /** vibrato depth */ + vibmod: "c"; + /** singer's formant (bright 3 kHz ring) */ + ring: "c"; + /** overtone filter sharpness and level */ + overtone: "c"; + /** kargyraa subharmonic (an octave below) */ + sub: "c"; + }; + outputs: { + /** audio out (left when stereo) */ + out: "a"; + /** right out (equals out for a mono engine) */ + right: "a"; + }; + }; + /** granular player over a sample or the track's own source */ + "engine.granular": { + main: "a"; + params: { + /** which instrument of this engine plays (default "granular") */ + instrument?: "granular"; + /** where the head starts inside the region (0..1, default 0) */ + pos?: number; + /** head speed: 1 the source's own speed, 0 held, negative backwards (-4..4 x, default 1) */ + scan?: number; + /** grain length (0.005..2 s, default 0.08) */ + grain?: number; + /** grains sounding at once (density = overlap / grain) (0.05..32, default 4) */ + overlap?: number; + /** onset randomness, fraction of the grain period (0..1, default 0.25) */ + jitter?: number; + /** random offset of each grain's read position (0..2 s, default 0.01) */ + spray?: number; + /** semitones on top of the note (-48..48 st, default 0) */ + pitch?: number; + /** random per-grain pitch spread (± half) (0..24 st, default 0) */ + detune?: number; + /** chance a grain plays shimint semitones up (0..1, default 0) */ + shimmer?: number; + /** stereo spread of the grains (0..1, default 0.3) */ + spread?: number; + /** chance a grain plays backwards (0..1, default 0) */ + reverse?: number; + /** chance a grain step latches the head (beat repeat) (0..1, default 0) */ + repeat?: number; + /** slow random walk of the head (depth) (0..1, default 0) */ + drift?: number; + /** engine settings, as the track field holds them */ + settings?: Readonly>; + }; + inputs: { + /** the notes to play (wire in.notes) */ + notes: "n"; + /** where the head starts inside the region */ + pos: "c"; + /** head speed: 1 the source's own speed, 0 held, negative backwards */ + scan: "c"; + /** grain length */ + grain: "c"; + /** grains sounding at once (density = overlap / grain) */ + overlap: "c"; + /** onset randomness, fraction of the grain period */ + jitter: "c"; + /** random offset of each grain's read position */ + spray: "c"; + /** semitones on top of the note */ + pitch: "c"; + /** random per-grain pitch spread (± half) */ + detune: "c"; + /** chance a grain plays shimint semitones up */ + shimmer: "c"; + /** stereo spread of the grains */ + spread: "c"; + /** chance a grain plays backwards */ + reverse: "c"; + /** chance a grain step latches the head (beat repeat) */ + repeat: "c"; + /** slow random walk of the head (depth) */ + drift: "c"; + }; + outputs: { + /** audio out (left when stereo) */ + out: "a"; + /** right out (equals out for a mono engine) */ + right: "a"; + }; + }; + /** modeled pianos, electric keys and organs */ + "engine.keys": { + main: "a"; + params: { + /** which instrument of this engine plays (default "grand") */ + instrument?: + | "grand" + | "upright" + | "felt" + | "honkytonk" + | "prepared" + | "epiano" + | "wurli" + | "clav" + | "tonewheel" + | "combo" + | "pipe"; + /** hammer felt hardness: brightness at a given velocity (0..1, default 0.5) */ + hardness?: number; + /** velocity sensitivity (0 plays every note at 0.8) (0..1, default 1) */ + touch?: number; + /** sustain time multiplier (0.1..4 x, default 1) */ + decay?: number; + /** damper time multiplier (how fast a released key stops) (0.1..4 x, default 1) */ + release?: number; + /** soundboard knock and hammer thump (0..1, default 0.5) */ + knock?: number; + /** key-off and damper mechanics (0..1, default 0.25) */ + noise?: number; + /** felt strip between hammers and strings (0..1, default 0) */ + felt?: number; + /** output low-pass of the electric keys; 0 is off (0..12000 Hz, default 0) */ + tone?: number; + /** suitcase stereo vibrato depth: antiphase left/right pan (epiano) (0..1, default 0) */ + vibe?: number; + /** tremolo depth: reed piano at 5.6 Hz (wurli), pipe tremulant (pipe) (0..1, default 0) */ + trem?: number; + /** organ preamp overdrive (0..1, default 0.15) */ + drive?: number; + /** rotary speed: 0 stop, 1 slow, 2 fast (0..2, default 1) */ + rotary?: number; + /** engine settings, as the track field holds them */ + settings?: Readonly>; + }; + inputs: { + /** the notes to play (wire in.notes) */ + notes: "n"; + /** hammer felt hardness: brightness at a given velocity */ + hardness: "c"; + /** velocity sensitivity (0 plays every note at 0.8) */ + touch: "c"; + /** sustain time multiplier */ + decay: "c"; + /** damper time multiplier (how fast a released key stops) */ + release: "c"; + /** soundboard knock and hammer thump */ + knock: "c"; + /** key-off and damper mechanics */ + noise: "c"; + /** felt strip between hammers and strings */ + felt: "c"; + /** output low-pass of the electric keys; 0 is off */ + tone: "c"; + /** suitcase stereo vibrato depth: antiphase left/right pan (epiano) */ + vibe: "c"; + /** tremolo depth: reed piano at 5.6 Hz (wurli), pipe tremulant (pipe) */ + trem: "c"; + /** organ preamp overdrive */ + drive: "c"; + /** rotary speed: 0 stop, 1 slow, 2 fast */ + rotary: "c"; + }; + outputs: { + /** audio out (left when stereo) */ + out: "a"; + /** right out (equals out for a mono engine) */ + right: "a"; + }; + }; + /** resonant biquad: low-pass, high-pass or band-pass */ + "fx.filter": { + main: "a"; + params: { + /** lpf low-pass (default), hpf high-pass, bpf band-pass (default "lpf") */ + type?: "lpf" | "hpf" | "bpf"; + /** slope: 12db biquad (default), 24db two biquads, ladder 4-pole (low-pass only) (default "12db") */ + ftype?: "12db" | "24db" | "ladder"; + /** cutoff (lpf/hpf) or center (bpf) frequency (20..20000 Hz, default 2000) */ + cutoff?: number; + /** resonance 0..1 (Q 0.707..8) (0..1, default 0) */ + resonance?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** cutoff (lpf/hpf) or center (bpf) frequency */ + cutoff: "c"; + /** resonance 0..1 (Q 0.707..8) */ + resonance: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** one-knob filter: below 0.5 low-pass, above 0.5 high-pass, 0.5 open */ + "fx.djf": { + main: "a"; + params: { + /** 0 dark (20 Hz low-pass) … 0.5 open … 1 thin (10 kHz high-pass) (0..1, default 0.5) */ + value?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** 0 dark (20 Hz low-pass) … 0.5 open … 1 thin (10 kHz high-pass) */ + value: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** filter whose cutoff an LFO sweeps around a center, optionally opened by the input level */ + "fx.autofilter": { + main: "a"; + params: { + /** filter type (default "lpf") */ + type?: "lpf" | "hpf" | "bpf"; + /** LFO shape; random is sample-and-hold, one step per cycle (default "sine") */ + shape?: "sine" | "tri" | "square" | "saw" | "ramp" | "random"; + /** center cutoff (20..20000 Hz, default 1200) */ + cutoff?: number; + /** resonance 0..1 (Q 0.707..8) (0..1, default 0.3) */ + resonance?: number; + /** sweep width in octaves around the center (0..6 oct, default 2) */ + depth?: number; + /** LFO period in beats (tempo-synced); 0 uses rate in Hz (0..64 beats, default 4) */ + sync?: number; + /** LFO rate in Hz (used when sync is 0) (0.01..40 Hz, default 0.5) */ + rate?: number; + /** LFO start phase in cycles (0..1, default 0) */ + phase?: number; + /** envelope follower: octaves the cutoff moves at full input level (-6..6 oct, default 0) */ + follow?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** center cutoff */ + cutoff: "c"; + /** resonance 0..1 (Q 0.707..8) */ + resonance: "c"; + /** sweep width in octaves around the center */ + depth: "c"; + /** LFO period in beats (tempo-synced); 0 uses rate in Hz */ + sync: "c"; + /** LFO rate in Hz (used when sync is 0) */ + rate: "c"; + /** LFO start phase in cycles */ + phase: "c"; + /** envelope follower: octaves the cutoff moves at full input level */ + follow: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** formant shift at constant pitch: moves the spectral envelope (throat or gender knob), any source */ + "fx.formant": { + main: "a"; + params: { + /** semitones the formants move (negative deeper, positive smaller); pitch stays (-12..12 st, default 0) */ + shift?: number; + /** wet/dry balance 0..1 (0..1, default 1) */ + mix?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** semitones the formants move (negative deeper, positive smaller); pitch stays */ + shift: "c"; + /** wet/dry balance 0..1 */ + mix: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** formant filter bank: five band-passes per vowel */ + "fx.vowel": { + main: "a"; + params: { + /** vowel formants (a e i o u plus the extended set) (default "a") */ + vowel?: + | "a" + | "e" + | "i" + | "o" + | "u" + | "ae" + | "aa" + | "oe" + | "ue" + | "y" + | "uh" + | "un" + | "en" + | "an" + | "on"; + /** vowel to morph toward (absent: no morph) (default "a") */ + to?: + | "a" + | "e" + | "i" + | "o" + | "u" + | "ae" + | "aa" + | "oe" + | "ue" + | "y" + | "uh" + | "un" + | "en" + | "an" + | "on"; + /** wet/dry balance 0..1 (0..1, default 1) */ + mix?: number; + /** position between vowel (0) and to (1), log-frequency formant morph (0..1, default 0) */ + morph?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** wet/dry balance 0..1 */ + mix: "c"; + /** position between vowel (0) and to (1), log-frequency formant morph */ + morph: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** bit-depth and sample-rate reduction */ + "fx.crush": { + main: "a"; + params: { + /** bit depth: 1 heavy .. 16 nearly clean (1..16, default 8) */ + bits?: number; + /** sample-and-hold factor: 1 off, 2 half rate, 3 a third… (1..64, default 1) */ + coarse?: number; + /** wet/dry balance 0..1 (0..1, default 1) */ + mix?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** bit depth: 1 heavy .. 16 nearly clean */ + bits: "c"; + /** sample-and-hold factor: 1 off, 2 half rate, 3 a third… */ + coarse: "c"; + /** wet/dry balance 0..1 */ + mix: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** waveshaper with drive, post-tone and automatic gain compensation */ + "fx.distort": { + main: "a"; + params: { + /** curve: soft (tanh), hard clip, cubic, diode, asym, fold, sinefold, chebyshev, scurve, shape (Strudel shape's curve) (default "soft") */ + type?: + | "soft" + | "hard" + | "cubic" + | "diode" + | "asym" + | "fold" + | "sinefold" + | "chebyshev" + | "scurve" + | "shape"; + /** drive 0..10 (Strudel distort amount) (0..10, default 2) */ + drive?: number; + /** low-pass after the shaper; tames fizz (200..20000 Hz, default 8000) */ + tone?: number; + /** wet/dry balance 0..1 (0..1, default 1) */ + mix?: number; + /** linear gain after compensation (Strudel distort postgain) (0..2, default 1) */ + postgain?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** drive 0..10 (Strudel distort amount) */ + drive: "c"; + /** low-pass after the shaper; tames fizz */ + tone: "c"; + /** wet/dry balance 0..1 */ + mix: "c"; + /** linear gain after compensation (Strudel distort postgain) */ + postgain: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** guitar pedal before the amp: fuzz (Big Muff), face (Fuzz Face), od (Tube Screamer), rat, octave (Octavia); oversampled */ + "fx.stomp": { + main: "a"; + params: { + /** circuit: fuzz (Big Muff), face (Fuzz Face), od (Tube Screamer), rat (RAT), octave (Octavia) (default "od") */ + type?: "fuzz" | "face" | "od" | "rat" | "octave"; + /** the pedal's gain / sustain / distortion knob, 0..10 (0..10, default 5) */ + gain?: number; + /** dark 0 .. bright 1 (0..1, default 0.5) */ + tone?: number; + /** trim over a level-matched pedal (0 = bypass loudness) (-24..12 dB, default 0) */ + level?: number; + /** octave: blend of the octave-up (rectified) path (0..1, default 0.7) */ + octave?: number; + /** wet/dry balance 0..1 (0..1, default 1) */ + mix?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** the pedal's gain / sustain / distortion knob, 0..10 */ + gain: "c"; + /** dark 0 .. bright 1 */ + tone: "c"; + /** trim over a level-matched pedal (0 = bypass loudness) */ + level: "c"; + /** octave: blend of the octave-up (rectified) path */ + octave: "c"; + /** wet/dry balance 0..1 */ + mix: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** guitar amp: preamp stages, Yeh-Smith tone stack, power amp with sag, level-matched across types; optional noise gate */ + "fx.head": { + main: "a"; + params: { + /** clean (blackface), chime (AC30), crunch (plexi), lead (JCM800), high (modern high gain), solid (JC-120), bass (SVT) (default "crunch") */ + type?: "clean" | "chime" | "crunch" | "lead" | "high" | "solid" | "bass"; + /** preamp gain 0..10 (level-matched: more gain, not more volume) (0..10, default 5) */ + gain?: number; + /** tone stack bass 0..10 (0..10, default 5) */ + bass?: number; + /** tone stack mid 0..10 (0..10, default 5) */ + mid?: number; + /** tone stack treble 0..10 (0..10, default 5) */ + treble?: number; + /** power amp presence 0..10 (5 flat) (0..10, default 5) */ + presence?: number; + /** power amp drive 0..10 (0..10, default 5) */ + master?: number; + /** power supply sag 0..1 (absent: the head type's own) (0..1, default 0.3) */ + sag?: number; + /** noise gate threshold before the amp (absent: no gate; 0 dB gates all) (-96..0 dB, default -60) */ + gate?: number; + /** output level after level matching (-24..12 dB, default 0) */ + level?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** preamp gain 0..10 (level-matched: more gain, not more volume) */ + gain: "c"; + /** tone stack bass 0..10 */ + bass: "c"; + /** tone stack mid 0..10 */ + mid: "c"; + /** tone stack treble 0..10 */ + treble: "c"; + /** power amp presence 0..10 (5 flat) */ + presence: "c"; + /** power amp drive 0..10 */ + master: "c"; + /** power supply sag 0..1 (absent: the head type's own) */ + sag: "c"; + /** noise gate threshold before the amp (absent: no gate; 0 dB gates all) */ + gate: "c"; + /** output level after level matching */ + level: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** speaker cabinet and microphone (biquad model, no impulse response) */ + "fx.cab": { + main: "a"; + params: { + /** 1x12, 2x12, 4x12, 1x10, open back, 8x10 and 1x15 bass, di (no speaker) (default "2x12") */ + type?: + "1x12" | "2x12" | "4x12" | "1x10" | "open" | "8x10" | "1x15" | "di"; + /** microphone position: 0 center (bright) .. 1 edge (dark) (0..1, default 0.3) */ + mic?: number; + /** wet/dry balance 0..1 (0..1, default 1) */ + mix?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** microphone position: 0 center (bright) .. 1 edge (dark) */ + mic: "c"; + /** wet/dry balance 0..1 */ + mix: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** held tremolo arm: slow seeded pitch wow and flutter of the whole track */ + "fx.wobble": { + main: "a"; + params: { + /** peak pitch deviation in cents (0..100 c, default 20) */ + depth?: number; + /** wobble rate (0.05..8 Hz, default 0.5) */ + rate?: number; + /** 0 periodic .. 1 wandering (seeded, so every render matches) (0..1, default 0.3) */ + drift?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** peak pitch deviation in cents */ + depth: "c"; + /** wobble rate */ + rate: "c"; + /** 0 periodic .. 1 wandering (seeded, so every render matches) */ + drift: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** feedback bloom: the top held note grows a singing harmonic */ + "fx.bloom": { + main: "a"; + params: { + /** level of the feedback partial (0..1, default 0.5) */ + amount?: number; + /** harmonic that feeds back: 1 the note, 2 its octave, 3 the fifth above (1..4, default 2) */ + harm?: number; + /** how long a note is held before it starts to feed back (0..4 s, default 0.6) */ + delay?: number; + /** how fast the feedback grows (0.05..4 s, default 1) */ + time?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** level of the feedback partial */ + amount: "c"; + /** harmonic that feeds back: 1 the note, 2 its octave, 3 the fifth above */ + harm: "c"; + /** how long a note is held before it starts to feed back */ + delay: "c"; + /** how fast the feedback grows */ + time: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** volume swell: each strum fades in, no pick attack */ + "fx.swell": { + main: "a"; + params: { + /** rise time after each onset (0.01..4 s, default 0.4) */ + time?: number; + /** wet/dry balance 0..1 (0..1, default 1) */ + mix?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** rise time after each onset */ + time: "c"; + /** wet/dry balance 0..1 */ + mix: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** volume modulation */ + "fx.tremolo": { + main: "a"; + params: { + /** LFO shape (default "sine") */ + shape?: "sine" | "tri" | "square" | "saw" | "ramp"; + /** LFO period in beats (tempo-synced); 0 uses rate in Hz (0..64 beats, default 0.5) */ + sync?: number; + /** LFO rate in Hz (used when sync is 0) (0.01..40 Hz, default 4) */ + rate?: number; + /** how far the level dips, 0..1 (0..1, default 0.5) */ + depth?: number; + /** where in the cycle the peak falls (0.5 symmetric) (0..1, default 0.5) */ + skew?: number; + /** start phase in cycles (0..1, default 0) */ + phase?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** LFO period in beats (tempo-synced); 0 uses rate in Hz */ + sync: "c"; + /** LFO rate in Hz (used when sync is 0) */ + rate: "c"; + /** how far the level dips, 0..1 */ + depth: "c"; + /** where in the cycle the peak falls (0.5 symmetric) */ + skew: "c"; + /** start phase in cycles */ + phase: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** feed-forward RMS-ish compressor with soft knee and make-up gain */ + "fx.compressor": { + main: "a"; + params: { + /** level where compression starts (-60..0 dB, default -18) */ + threshold?: number; + /** input:output above threshold (1..20, default 4) */ + ratio?: number; + /** soft-knee width (0..24 dB, default 6) */ + knee?: number; + /** attack time (0.0001..1 s, default 0.01) */ + attack?: number; + /** release time (0.01..2 s, default 0.15) */ + release?: number; + /** gain after compression (0..24 dB, default 5) */ + makeup?: number; + }; + inputs: { + /** audio in */ + in: "a"; + /** level where compression starts */ + threshold: "c"; + /** input:output above threshold */ + ratio: "c"; + /** soft-knee width */ + knee: "c"; + /** attack time */ + attack: "c"; + /** release time */ + release: "c"; + /** gain after compression */ + makeup: "c"; + }; + outputs: { + /** audio out */ + out: "a"; + }; + }; + /** automatic double tracking: a seeded second take, spread left and right */ + "fx.double": { + main: "a"; + params: { + /** how late the second take plays (5..60 ms, default 22) */ + time?: number; + /** how far the second take's timing wanders (0..10 ms, default 3) */ + drift?: number; + /** 0 centered .. 1 the two takes hard left and right (0..1, default 0.6) */ + width?: number; + }; + inputs: { + /** left in */ + left: "a"; + /** right in */ + right: "a"; + /** how late the second take plays */ + time: "c"; + /** how far the second take's timing wanders */ + drift: "c"; + /** 0 centered .. 1 the two takes hard left and right */ + width: "c"; + }; + outputs: { + /** left out */ + left: "a"; + /** right out */ + right: "a"; + }; + }; + /** four-stage all-pass phaser */ + "fx.phaser": { + main: "a"; + params: { + /** LFO rate in Hz (used when sync is 0) (0.01..40 Hz, default 0.5) */ + rate?: number; + /** LFO period in beats (tempo-synced); 0 uses rate in Hz (0..64 beats, default 0) */ + sync?: number; + /** notch depth (wet amount) (0..1, default 0.75) */ + depth?: number; + /** sweep center (100..10000 Hz, default 1000) */ + center?: number; + /** sweep range (0..8000 Hz, default 2000) */ + sweep?: number; + }; + inputs: { + /** left in */ + left: "a"; + /** right in */ + right: "a"; + /** LFO rate in Hz (used when sync is 0) */ + rate: "c"; + /** LFO period in beats (tempo-synced); 0 uses rate in Hz */ + sync: "c"; + /** notch depth (wet amount) */ + depth: "c"; + /** sweep center */ + center: "c"; + /** sweep range */ + sweep: "c"; + }; + outputs: { + /** left out */ + left: "a"; + /** right out */ + right: "a"; + }; + }; + /** stereo chorus: two modulated delay taps in quadrature */ + "fx.chorus": { + main: "a"; + params: { + /** LFO rate in Hz (used when sync is 0) (0.01..40 Hz, default 0.8) */ + rate?: number; + /** modulation depth (0..1 → 0..6 ms) (0..1, default 0.4) */ + depth?: number; + /** wet/dry balance 0..1 (0..1, default 0.5) */ + mix?: number; + }; + inputs: { + /** left in */ + left: "a"; + /** right in */ + right: "a"; + /** LFO rate in Hz (used when sync is 0) */ + rate: "c"; + /** modulation depth (0..1 → 0..6 ms) */ + depth: "c"; + /** wet/dry balance 0..1 */ + mix: "c"; + }; + outputs: { + /** left out */ + left: "a"; + /** right out */ + right: "a"; + }; + }; + /** rotary speaker: Doppler vibrato plus left/right amplitude rotation */ + "fx.leslie": { + main: "a"; + params: { + /** wet/dry balance 0..1 (0..1, default 1) */ + mix?: number; + /** rotation in Hz: 6.7 fast, 0.7 slow (0.01..40 Hz, default 6.7) */ + rate?: number; + /** cabinet size: Doppler (pitch warble) amount (0..1, default 0.5) */ + size?: number; + }; + inputs: { + /** left in */ + left: "a"; + /** right in */ + right: "a"; + /** wet/dry balance 0..1 */ + mix: "c"; + /** rotation in Hz: 6.7 fast, 0.7 slow */ + rate: "c"; + /** cabinet size: Doppler (pitch warble) amount */ + size: "c"; + }; + outputs: { + /** left out */ + left: "a"; + /** right out */ + right: "a"; + }; + }; + /** linear gain after every insert, before the delay and reverb sends */ + "fx.postgain": { + main: "a"; + params: { + /** linear gain 0..4 (0..4, default 1) */ + gain?: number; + }; + inputs: { + /** left in */ + left: "a"; + /** right in */ + right: "a"; + /** linear gain 0..4 */ + gain: "c"; + }; + outputs: { + /** left out */ + left: "a"; + /** right out */ + right: "a"; + }; + }; + /** tempo-synced stereo delay send; ping-pong with a high-cut on the repeats by default */ + "fx.delay": { + main: "a"; + params: { + /** repeats alternate left/right (absent: the original cross-fed stereo) (default true) */ + pingpong?: boolean; + /** delay time in beats (0.75 = dotted eighth) (0.0625..4 beats, default 0.75) */ + beats?: number; + /** fraction of each echo fed back (0..0.9, default 0.35) */ + feedback?: number; + /** wet level (0..1, default 0.25) */ + mix?: number; + /** delay time in seconds; 0 or absent uses beats (0..4 s, default 0) */ + time?: number; + /** low-pass inside the feedback loop so repeats darken (500..20000 Hz, default 5000) */ + highcut?: number; + }; + inputs: { + /** left in */ + left: "a"; + /** right in */ + right: "a"; + /** delay time in beats (0.75 = dotted eighth) */ + beats: "c"; + /** fraction of each echo fed back */ + feedback: "c"; + /** wet level */ + mix: "c"; + /** delay time in seconds; 0 or absent uses beats */ + time: "c"; + /** low-pass inside the feedback loop so repeats darken */ + highcut: "c"; + }; + outputs: { + /** left out */ + left: "a"; + /** right out */ + right: "a"; + }; + }; + /** algorithmic stereo reverb send (eight combs, four allpasses per side) */ + "fx.reverb": { + main: "a"; + params: { + /** wet level (0..1, default 0.3) */ + mix?: number; + /** room size (Strudel roomsize 0..10 = size·10) (0..1, default 0.5) */ + size?: number; + /** decay time to -60 dB; overrides the size-derived decay (0.1..20 s, default 2) */ + fade?: number; + /** low-pass on the reverb input (200..20000 Hz, default 8000) */ + lowpass?: number; + /** damping: the tail darkens toward this frequency as it decays (200..20000 Hz, default 3000) */ + dim?: number; + /** gap before the tail starts (0..0.5 s, default 0.02) */ + predelay?: number; + }; + inputs: { + /** left in */ + left: "a"; + /** right in */ + right: "a"; + /** wet level */ + mix: "c"; + /** room size (Strudel roomsize 0..10 = size·10) */ + size: "c"; + /** decay time to -60 dB; overrides the size-derived decay */ + fade: "c"; + /** low-pass on the reverb input */ + lowpass: "c"; + /** damping: the tail darkens toward this frequency as it decays */ + dim: "c"; + /** gap before the tail starts */ + predelay: "c"; + }; + outputs: { + /** left out */ + left: "a"; + /** right out */ + right: "a"; + }; + }; +}; + +/** The boundary nodes every patch has: `in`, `out`, `voice` and `song`. */ +export type PatchBoundaryTable = { + /** the patch's inputs from the track */ + in: { + main: "n"; + params: {}; + inputs: {}; + outputs: { + /** the track's notes (instrument) */ + notes: "n"; + /** the track's audio (effect patch) */ + audio: "a"; + /** the track's right channel (stereo effect patch) */ + right: "a"; + /** the sidechain track's audio (patch `side`) */ + side: "a"; + }; + }; + /** the patch's output to the track */ + out: { + main: "none"; + params: {}; + inputs: { + /** audio out (mono, or left when right is wired) */ + audio: "a"; + /** right channel; unwired, audio plays on both */ + right: "a"; + }; + outputs: {}; + }; + /** per-voice sources, one set per playing note */ + voice: { + main: "c"; + params: {}; + inputs: {}; + outputs: { + /** the note's frequency in Hz (tuned, with glide) */ + pitch: "c"; + /** the note's MIDI number */ + note: "c"; + /** 1 while the note is held, then 0 */ + gate: "c"; + /** velocity 0..1 */ + velocity: "c"; + /** phase 0..1 per cycle of the note's pitch */ + phase: "c"; + /** seeded random 0..1, fixed for the note */ + random: "c"; + /** voice slot index */ + index: "c"; + /** seconds since the note began */ + age: "c"; + }; + }; + /** global time and the track's automation */ + song: { + main: "c"; + params: {}; + inputs: {}; + outputs: { + /** song position in beats */ + beat: "c"; + /** position in the bar 0..1 */ + "bar.phase": "c"; + /** tempo in BPM */ + tempo: "c"; + }; + }; +}; + +type PatchPortRow = readonly [string, PatchPortCode, number?, number?, number?]; + +/** Ports by node type, for wiring and checks at run time. */ +export const PATCH_NODE_PORTS: Readonly< + Record< + string, + Readonly<{ + rate: string; + params: readonly string[]; + inputs: readonly PatchPortRow[]; + outputs: readonly PatchPortRow[]; + engine?: readonly string[]; + }> + > +> = { + osc: { + rate: "any", + params: ["wave", "pitch", "detune", "pw", "level"], + inputs: [ + ["pitch", "ca", 0, 20000, 440], + ["detune", "c", -1200, 1200, 0], + ["pw", "c", 0.01, 0.99, 0.5], + ["fm", "a"], + ["level", "c", 0, 4, 1], + ], + outputs: [["out", "a"]], + }, + noise: { + rate: "any", + params: ["color", "level"], + inputs: [["level", "c", 0, 4, 1]], + outputs: [["out", "a"]], + }, + svf: { + rate: "any", + params: ["mode", "cutoff", "q"], + inputs: [ + ["in", "a"], + ["cutoff", "c", 20, 20000, 1000], + ["q", "c", 0, 1, 0.5], + ], + outputs: [["out", "a"]], + }, + onepole: { + rate: "any", + params: ["mode", "cutoff"], + inputs: [ + ["in", "a"], + ["cutoff", "c", 20, 20000, 1000], + ], + outputs: [["out", "a"]], + }, + adsr: { + rate: "any", + params: ["gate", "attack", "decay", "sustain", "release"], + inputs: [ + ["gate", "c", 0, 1, 0], + ["attack", "c", 0, 10, 0.01], + ["decay", "c", 0, 10, 0.1], + ["sustain", "c", 0, 1, 0.7], + ["release", "c", 0, 10, 0.2], + ], + outputs: [["out", "c"]], + }, + ar: { + rate: "any", + params: ["gate", "attack", "release"], + inputs: [ + ["gate", "c", 0, 1, 0], + ["attack", "c", 0, 10, 0.01], + ["release", "c", 0, 10, 0.2], + ], + outputs: [["out", "c"]], + }, + slew: { + rate: "any", + params: ["in", "rise", "fall"], + inputs: [ + ["in", "c", -1000000, 1000000, 0], + ["rise", "c", 0, 10, 0.05], + ["fall", "c", 0, 10, 0.05], + ], + outputs: [["out", "c"]], + }, + follow: { + rate: "any", + params: ["attack", "release"], + inputs: [ + ["in", "a"], + ["attack", "c", 0.0001, 1, 0.005], + ["release", "c", 0.001, 4, 0.1], + ], + outputs: [["out", "c"]], + }, + lfo: { + rate: "any", + params: ["shape", "sync", "rate", "phase", "depth"], + inputs: [ + ["rate", "c", 0, 100, 1], + ["phase", "c", 0, 1, 0], + ["depth", "c", 0, 1, 1], + ], + outputs: [["out", "c", -1, 1]], + }, + sh: { + rate: "any", + params: ["in", "trig"], + inputs: [ + ["in", "c", -1000000, 1000000, 0], + ["trig", "c", 0, 1, 0], + ], + outputs: [["out", "c"]], + }, + random: { rate: "any", params: ["per"], inputs: [], outputs: [["out", "c"]] }, + const: { + rate: "any", + params: ["value"], + inputs: [], + outputs: [["out", "c"]], + }, + add: { + rate: "any", + params: ["a", "b"], + inputs: [ + ["a", "c", -1000000, 1000000, 0], + ["b", "c", -1000000, 1000000, 0], + ], + outputs: [["out", "c"]], + }, + mul: { + rate: "any", + params: ["a", "b"], + inputs: [ + ["a", "c", -1000000, 1000000, 0], + ["b", "c", -1000000, 1000000, 1], + ], + outputs: [["out", "c"]], + }, + min: { + rate: "any", + params: ["a", "b"], + inputs: [ + ["a", "c", -1000000, 1000000, 0], + ["b", "c", -1000000, 1000000, 0], + ], + outputs: [["out", "c"]], + }, + max: { + rate: "any", + params: ["a", "b"], + inputs: [ + ["a", "c", -1000000, 1000000, 0], + ["b", "c", -1000000, 1000000, 0], + ], + outputs: [["out", "c"]], + }, + gt: { + rate: "any", + params: ["a", "b"], + inputs: [ + ["a", "c", -1000000, 1000000, 0], + ["b", "c", -1000000, 1000000, 0], + ], + outputs: [["out", "c"]], + }, + lt: { + rate: "any", + params: ["a", "b"], + inputs: [ + ["a", "c", -1000000, 1000000, 0], + ["b", "c", -1000000, 1000000, 0], + ], + outputs: [["out", "c"]], + }, + abs: { + rate: "any", + params: ["in"], + inputs: [["in", "c", -1000000, 1000000, 0]], + outputs: [["out", "c"]], + }, + not: { + rate: "any", + params: ["in"], + inputs: [["in", "c", -1000000, 1000000, 0]], + outputs: [["out", "c"]], + }, + pitch2hz: { + rate: "any", + params: ["in"], + inputs: [["in", "c", -1000000, 1000000, 0]], + outputs: [["out", "c"]], + }, + db2gain: { + rate: "any", + params: ["in"], + inputs: [["in", "c", -1000000, 1000000, 0]], + outputs: [["out", "c"]], + }, + scale: { + rate: "any", + params: ["curve", "in", "inmin", "inmax", "min", "max"], + inputs: [ + ["in", "c", -1000000, 1000000, 0], + ["inmin", "c", -1000000, 1000000, -1], + ["inmax", "c", -1000000, 1000000, 1], + ["min", "c", -1000000, 1000000, 0], + ["max", "c", -1000000, 1000000, 1], + ], + outputs: [["out", "c"]], + }, + clamp: { + rate: "any", + params: ["in", "min", "max"], + inputs: [ + ["in", "c", -1000000, 1000000, 0], + ["min", "c", -1000000, 1000000, 0], + ["max", "c", -1000000, 1000000, 1], + ], + outputs: [["out", "c"]], + }, + clock: { + rate: "global", + params: ["beats", "width"], + inputs: [["width", "c", 0.01, 0.99, 0.5]], + outputs: [["out", "c"]], + }, + vca: { + rate: "any", + params: ["gain"], + inputs: [ + ["in", "a"], + ["gain", "ca", 0, 4, 1], + ], + outputs: [["out", "a"]], + }, + mix: { + rate: "any", + params: ["la", "lb", "lc", "ld"], + inputs: [ + ["a", "a"], + ["b", "a"], + ["c", "a"], + ["d", "a"], + ["la", "c", 0, 4, 1], + ["lb", "c", 0, 4, 1], + ["lc", "c", 0, 4, 1], + ["ld", "c", 0, 4, 1], + ], + outputs: [["out", "a"]], + }, + xfade: { + rate: "any", + params: ["x"], + inputs: [ + ["a", "a"], + ["b", "a"], + ["x", "c", 0, 1, 0.5], + ], + outputs: [["out", "a"]], + }, + pan: { + rate: "any", + params: ["pan"], + inputs: [ + ["in", "a"], + ["pan", "c", -1, 1, 0], + ], + outputs: [ + ["left", "a"], + ["right", "a"], + ], + }, + voicesum: { + rate: "global", + params: [], + inputs: [["in", "a"]], + outputs: [["out", "a"]], + }, + "engine.synth": { + rate: "global", + params: [ + "instrument", + "attack", + "decay", + "sustain", + "release", + "gain", + "noise", + "density", + "detune", + "spread", + "pw", + "pwrate", + "pwsweep", + "vib", + "vibmod", + "penv", + "pattack", + "pdecay", + "psustain", + "prelease", + "lpf", + "lpq", + "lpenv", + "lpattack", + "lpdecay", + "lpsustain", + "lprelease", + "hpf", + "hpq", + "hpenv", + "hpattack", + "hpdecay", + "hpsustain", + "hprelease", + "bpf", + "bpq", + "bpenv", + "bpattack", + "bpdecay", + "bpsustain", + "bprelease", + "fanchor", + "fm", + "fmh", + "fmattack", + "fmdecay", + "fmsustain", + "fmrelease", + "fm2", + "fmh2", + "fmattack2", + "fmdecay2", + "fmsustain2", + "fmrelease2", + "fm3", + "fmh3", + "fmattack3", + "fmdecay3", + "fmsustain3", + "fmrelease3", + "fm4", + "fmh4", + "fmattack4", + "fmdecay4", + "fmsustain4", + "fmrelease4", + "fm5", + "fmh5", + "fmattack5", + "fmdecay5", + "fmsustain5", + "fmrelease5", + "fm6", + "fmh6", + "fmattack6", + "fmdecay6", + "fmsustain6", + "fmrelease6", + "fm7", + "fmh7", + "fmattack7", + "fmdecay7", + "fmsustain7", + "fmrelease7", + "fm8", + "fmh8", + "fmattack8", + "fmdecay8", + "fmsustain8", + "fmrelease8", + "zrand", + "curve", + "slide", + "deltaSlide", + "pitchJump", + "pitchJumpTime", + "lfo", + "zmod", + "zcrush", + "zdelay", + "tremolo", + ], + inputs: [ + ["notes", "n"], + ["attack", "c", 0, 10, 0.003], + ["decay", "c", 0, 10, 0.05], + ["sustain", "c", 0, 1, 1], + ["release", "c", 0, 10, 0.05], + ["gain", "c", 0, 4, 1], + ["noise", "c", 0, 1, 0], + ["density", "c", 0, 1, 0.03], + ["detune", "c", 0, 12, 0.2], + ["spread", "c", 0, 1, 0.6], + ["pw", "c", 0, 1, 0.5], + ["pwrate", "c", 0, 40, 1], + ["pwsweep", "c", 0, 1, 0], + ["vib", "c", 0, 64, 0], + ["vibmod", "c", 0, 24, 0.5], + ["penv", "c", -48, 48, 0], + ["pattack", "c", 0, 10, 0.2], + ["pdecay", "c", 0, 10, 0], + ["psustain", "c", 0, 1, 1], + ["prelease", "c", 0, 10, 0], + ["lpf", "c", 20, 20000, 2000], + ["lpq", "c", 0, 50, 1], + ["lpenv", "c", -10, 10, 0], + ["lpattack", "c", 0, 10, 0.005], + ["lpdecay", "c", 0, 10, 0.15], + ["lpsustain", "c", 0, 1, 0], + ["lprelease", "c", 0, 10, 0.1], + ["hpf", "c", 20, 20000, 200], + ["hpq", "c", 0, 50, 1], + ["hpenv", "c", -10, 10, 0], + ["hpattack", "c", 0, 10, 0.005], + ["hpdecay", "c", 0, 10, 0.15], + ["hpsustain", "c", 0, 1, 0], + ["hprelease", "c", 0, 10, 0.1], + ["bpf", "c", 20, 20000, 1000], + ["bpq", "c", 0, 50, 1], + ["bpenv", "c", -10, 10, 0], + ["bpattack", "c", 0, 10, 0.005], + ["bpdecay", "c", 0, 10, 0.15], + ["bpsustain", "c", 0, 1, 0], + ["bprelease", "c", 0, 10, 0.1], + ["fanchor", "c", 0, 1, 0], + ["fm", "c", 0, 64, 0], + ["fmh", "c", 0, 32, 1], + ["fmattack", "c", 0, 10, 0], + ["fmdecay", "c", 0, 10, 0], + ["fmsustain", "c", 0, 1, 1], + ["fmrelease", "c", 0, 10, 0], + ["fm2", "c", 0, 64, 0], + ["fmh2", "c", 0, 32, 1], + ["fmattack2", "c", 0, 10, 0], + ["fmdecay2", "c", 0, 10, 0], + ["fmsustain2", "c", 0, 1, 1], + ["fmrelease2", "c", 0, 10, 0], + ["fm3", "c", 0, 64, 0], + ["fmh3", "c", 0, 32, 1], + ["fmattack3", "c", 0, 10, 0], + ["fmdecay3", "c", 0, 10, 0], + ["fmsustain3", "c", 0, 1, 1], + ["fmrelease3", "c", 0, 10, 0], + ["fm4", "c", 0, 64, 0], + ["fmh4", "c", 0, 32, 1], + ["fmattack4", "c", 0, 10, 0], + ["fmdecay4", "c", 0, 10, 0], + ["fmsustain4", "c", 0, 1, 1], + ["fmrelease4", "c", 0, 10, 0], + ["fm5", "c", 0, 64, 0], + ["fmh5", "c", 0, 32, 1], + ["fmattack5", "c", 0, 10, 0], + ["fmdecay5", "c", 0, 10, 0], + ["fmsustain5", "c", 0, 1, 1], + ["fmrelease5", "c", 0, 10, 0], + ["fm6", "c", 0, 64, 0], + ["fmh6", "c", 0, 32, 1], + ["fmattack6", "c", 0, 10, 0], + ["fmdecay6", "c", 0, 10, 0], + ["fmsustain6", "c", 0, 1, 1], + ["fmrelease6", "c", 0, 10, 0], + ["fm7", "c", 0, 64, 0], + ["fmh7", "c", 0, 32, 1], + ["fmattack7", "c", 0, 10, 0], + ["fmdecay7", "c", 0, 10, 0], + ["fmsustain7", "c", 0, 1, 1], + ["fmrelease7", "c", 0, 10, 0], + ["fm8", "c", 0, 64, 0], + ["fmh8", "c", 0, 32, 1], + ["fmattack8", "c", 0, 10, 0], + ["fmdecay8", "c", 0, 10, 0], + ["fmsustain8", "c", 0, 1, 1], + ["fmrelease8", "c", 0, 10, 0], + ["zrand", "c", 0, 1, 0], + ["curve", "c", 0, 3, 1], + ["slide", "c", -20, 20, 0], + ["deltaSlide", "c", -20, 20, 0], + ["pitchJump", "c", -2000, 2000, 0], + ["pitchJumpTime", "c", 0, 10, 0], + ["lfo", "c", 0, 10, 0], + ["zmod", "c", 0, 1000, 0], + ["zcrush", "c", 0, 1, 0], + ["zdelay", "c", 0, 1, 0], + ["tremolo", "c", 0, 1, 0], + ], + outputs: [ + ["out", "a"], + ["right", "a"], + ], + engine: ["synth", "wavetable", "sampler"], + }, + "engine.modal": { + rate: "global", + params: [ + "instrument", + "hardness", + "position", + "ring", + "tilt", + "damp", + "motordepth", + "buzz", + "click", + "gain", + ], + inputs: [ + ["notes", "n"], + ["hardness", "c", 0, 1, 0.4], + ["position", "c", 0, 1, 0.42], + ["ring", "c", 0.05, 30, 1.6], + ["tilt", "c", 0, 2, 0.9], + ["damp", "c", 0, 1, 0], + ["motordepth", "c", 0, 1, 0], + ["buzz", "c", 0, 1, 0], + ["click", "c", 0, 1, 0], + ["gain", "c", 0, 2, 0.8], + ], + outputs: [ + ["out", "a"], + ["right", "a"], + ], + engine: ["modal"], + }, + "engine.string": { + rate: "global", + params: [ + "instrument", + "ring", + "damp", + "pos", + "bright", + "mute", + "buzz", + "vib", + "vibmod", + "gain", + "pressure", + "speed", + "sord", + "dyn", + ], + inputs: [ + ["notes", "n"], + ["ring", "c", 0.05, 60, 3], + ["damp", "c", 0, 1, 0.4], + ["pos", "c", 0.02, 0.5, 0.15], + ["bright", "c", 0, 1, 0.6], + ["mute", "c", 0, 1, 0], + ["buzz", "c", 0, 1, 0], + ["vib", "c", 0, 12, 0], + ["vibmod", "c", 0, 2, 0.18], + ["gain", "c", 0, 2, 1], + ["pressure", "c", 0, 1, 0.5], + ["speed", "c", 0, 1, 0.6], + ["sord", "c", 0, 1, 0], + ["dyn", "c", 0, 1, 1], + ], + outputs: [ + ["out", "a"], + ["right", "a"], + ], + engine: ["string"], + }, + "engine.wind": { + rate: "global", + params: ["instrument", "breath", "noise", "wah", "growl", "flutter"], + inputs: [ + ["notes", "n"], + ["breath", "c", 0, 1, 0.6], + ["noise", "c", 0, 1, 0.08], + ["wah", "c", 0, 1, 1], + ["growl", "c", 0, 1, 0], + ["flutter", "c", 0, 1, 0], + ], + outputs: [ + ["out", "a"], + ["right", "a"], + ], + engine: ["wind"], + }, + "engine.sing": { + rate: "global", + params: [ + "instrument", + "morph", + "formant", + "bright", + "breath", + "vibmod", + "ring", + "overtone", + "sub", + ], + inputs: [ + ["notes", "n"], + ["morph", "c", 0, 1, 1], + ["formant", "c", -12, 12, 0], + ["bright", "c", 0, 1, 0.5], + ["breath", "c", 0, 1, 0.12], + ["vibmod", "c", 0, 1, 0.3], + ["ring", "c", 0, 1, 0], + ["overtone", "c", 0, 1, 0], + ["sub", "c", 0, 1, 0], + ], + outputs: [ + ["out", "a"], + ["right", "a"], + ], + engine: ["sing"], + }, + "engine.granular": { + rate: "global", + params: [ + "instrument", + "pos", + "scan", + "grain", + "overlap", + "jitter", + "spray", + "pitch", + "detune", + "shimmer", + "spread", + "reverse", + "repeat", + "drift", + ], + inputs: [ + ["notes", "n"], + ["pos", "c", 0, 1, 0], + ["scan", "c", -4, 4, 1], + ["grain", "c", 0.005, 2, 0.08], + ["overlap", "c", 0.05, 32, 4], + ["jitter", "c", 0, 1, 0.25], + ["spray", "c", 0, 2, 0.01], + ["pitch", "c", -48, 48, 0], + ["detune", "c", 0, 24, 0], + ["shimmer", "c", 0, 1, 0], + ["spread", "c", 0, 1, 0.3], + ["reverse", "c", 0, 1, 0], + ["repeat", "c", 0, 1, 0], + ["drift", "c", 0, 1, 0], + ], + outputs: [ + ["out", "a"], + ["right", "a"], + ], + engine: ["granular"], + }, + "engine.keys": { + rate: "global", + params: [ + "instrument", + "hardness", + "touch", + "decay", + "release", + "knock", + "noise", + "felt", + "tone", + "vibe", + "trem", + "drive", + "rotary", + ], + inputs: [ + ["notes", "n"], + ["hardness", "c", 0, 1, 0.5], + ["touch", "c", 0, 1, 1], + ["decay", "c", 0.1, 4, 1], + ["release", "c", 0.1, 4, 1], + ["knock", "c", 0, 1, 0.5], + ["noise", "c", 0, 1, 0.25], + ["felt", "c", 0, 1, 0], + ["tone", "c", 0, 12000, 0], + ["vibe", "c", 0, 1, 0], + ["trem", "c", 0, 1, 0], + ["drive", "c", 0, 1, 0.15], + ["rotary", "c", 0, 2, 1], + ], + outputs: [ + ["out", "a"], + ["right", "a"], + ], + engine: ["keys"], + }, + "fx.filter": { + rate: "global", + params: ["type", "ftype", "cutoff", "resonance"], + inputs: [ + ["in", "a"], + ["cutoff", "c", 20, 20000, 2000], + ["resonance", "c", 0, 1, 0], + ], + outputs: [["out", "a"]], + }, + "fx.djf": { + rate: "global", + params: ["value"], + inputs: [ + ["in", "a"], + ["value", "c", 0, 1, 0.5], + ], + outputs: [["out", "a"]], + }, + "fx.autofilter": { + rate: "global", + params: [ + "type", + "shape", + "cutoff", + "resonance", + "depth", + "sync", + "rate", + "phase", + "follow", + ], + inputs: [ + ["in", "a"], + ["cutoff", "c", 20, 20000, 1200], + ["resonance", "c", 0, 1, 0.3], + ["depth", "c", 0, 6, 2], + ["sync", "c", 0, 64, 4], + ["rate", "c", 0.01, 40, 0.5], + ["phase", "c", 0, 1, 0], + ["follow", "c", -6, 6, 0], + ], + outputs: [["out", "a"]], + }, + "fx.formant": { + rate: "global", + params: ["shift", "mix"], + inputs: [ + ["in", "a"], + ["shift", "c", -12, 12, 0], + ["mix", "c", 0, 1, 1], + ], + outputs: [["out", "a"]], + }, + "fx.vowel": { + rate: "global", + params: ["vowel", "to", "mix", "morph"], + inputs: [ + ["in", "a"], + ["mix", "c", 0, 1, 1], + ["morph", "c", 0, 1, 0], + ], + outputs: [["out", "a"]], + }, + "fx.crush": { + rate: "global", + params: ["bits", "coarse", "mix"], + inputs: [ + ["in", "a"], + ["bits", "c", 1, 16, 8], + ["coarse", "c", 1, 64, 1], + ["mix", "c", 0, 1, 1], + ], + outputs: [["out", "a"]], + }, + "fx.distort": { + rate: "global", + params: ["type", "drive", "tone", "mix", "postgain"], + inputs: [ + ["in", "a"], + ["drive", "c", 0, 10, 2], + ["tone", "c", 200, 20000, 8000], + ["mix", "c", 0, 1, 1], + ["postgain", "c", 0, 2, 1], + ], + outputs: [["out", "a"]], + }, + "fx.stomp": { + rate: "global", + params: ["type", "gain", "tone", "level", "octave", "mix"], + inputs: [ + ["in", "a"], + ["gain", "c", 0, 10, 5], + ["tone", "c", 0, 1, 0.5], + ["level", "c", -24, 12, 0], + ["octave", "c", 0, 1, 0.7], + ["mix", "c", 0, 1, 1], + ], + outputs: [["out", "a"]], + }, + "fx.head": { + rate: "global", + params: [ + "type", + "gain", + "bass", + "mid", + "treble", + "presence", + "master", + "sag", + "gate", + "level", + ], + inputs: [ + ["in", "a"], + ["gain", "c", 0, 10, 5], + ["bass", "c", 0, 10, 5], + ["mid", "c", 0, 10, 5], + ["treble", "c", 0, 10, 5], + ["presence", "c", 0, 10, 5], + ["master", "c", 0, 10, 5], + ["sag", "c", 0, 1, 0.3], + ["gate", "c", -96, 0, -60], + ["level", "c", -24, 12, 0], + ], + outputs: [["out", "a"]], + }, + "fx.cab": { + rate: "global", + params: ["type", "mic", "mix"], + inputs: [ + ["in", "a"], + ["mic", "c", 0, 1, 0.3], + ["mix", "c", 0, 1, 1], + ], + outputs: [["out", "a"]], + }, + "fx.wobble": { + rate: "global", + params: ["depth", "rate", "drift"], + inputs: [ + ["in", "a"], + ["depth", "c", 0, 100, 20], + ["rate", "c", 0.05, 8, 0.5], + ["drift", "c", 0, 1, 0.3], + ], + outputs: [["out", "a"]], + }, + "fx.bloom": { + rate: "global", + params: ["amount", "harm", "delay", "time"], + inputs: [ + ["in", "a"], + ["amount", "c", 0, 1, 0.5], + ["harm", "c", 1, 4, 2], + ["delay", "c", 0, 4, 0.6], + ["time", "c", 0.05, 4, 1], + ], + outputs: [["out", "a"]], + }, + "fx.swell": { + rate: "global", + params: ["time", "mix"], + inputs: [ + ["in", "a"], + ["time", "c", 0.01, 4, 0.4], + ["mix", "c", 0, 1, 1], + ], + outputs: [["out", "a"]], + }, + "fx.tremolo": { + rate: "global", + params: ["shape", "sync", "rate", "depth", "skew", "phase"], + inputs: [ + ["in", "a"], + ["sync", "c", 0, 64, 0.5], + ["rate", "c", 0.01, 40, 4], + ["depth", "c", 0, 1, 0.5], + ["skew", "c", 0, 1, 0.5], + ["phase", "c", 0, 1, 0], + ], + outputs: [["out", "a"]], + }, + "fx.compressor": { + rate: "global", + params: ["threshold", "ratio", "knee", "attack", "release", "makeup"], + inputs: [ + ["in", "a"], + ["threshold", "c", -60, 0, -18], + ["ratio", "c", 1, 20, 4], + ["knee", "c", 0, 24, 6], + ["attack", "c", 0.0001, 1, 0.01], + ["release", "c", 0.01, 2, 0.15], + ["makeup", "c", 0, 24, 5], + ], + outputs: [["out", "a"]], + }, + "fx.double": { + rate: "global", + params: ["time", "drift", "width"], + inputs: [ + ["left", "a"], + ["right", "a"], + ["time", "c", 5, 60, 22], + ["drift", "c", 0, 10, 3], + ["width", "c", 0, 1, 0.6], + ], + outputs: [ + ["left", "a"], + ["right", "a"], + ], + }, + "fx.phaser": { + rate: "global", + params: ["rate", "sync", "depth", "center", "sweep"], + inputs: [ + ["left", "a"], + ["right", "a"], + ["rate", "c", 0.01, 40, 0.5], + ["sync", "c", 0, 64, 0], + ["depth", "c", 0, 1, 0.75], + ["center", "c", 100, 10000, 1000], + ["sweep", "c", 0, 8000, 2000], + ], + outputs: [ + ["left", "a"], + ["right", "a"], + ], + }, + "fx.chorus": { + rate: "global", + params: ["rate", "depth", "mix"], + inputs: [ + ["left", "a"], + ["right", "a"], + ["rate", "c", 0.01, 40, 0.8], + ["depth", "c", 0, 1, 0.4], + ["mix", "c", 0, 1, 0.5], + ], + outputs: [ + ["left", "a"], + ["right", "a"], + ], + }, + "fx.leslie": { + rate: "global", + params: ["mix", "rate", "size"], + inputs: [ + ["left", "a"], + ["right", "a"], + ["mix", "c", 0, 1, 1], + ["rate", "c", 0.01, 40, 6.7], + ["size", "c", 0, 1, 0.5], + ], + outputs: [ + ["left", "a"], + ["right", "a"], + ], + }, + "fx.postgain": { + rate: "global", + params: ["gain"], + inputs: [ + ["left", "a"], + ["right", "a"], + ["gain", "c", 0, 4, 1], + ], + outputs: [ + ["left", "a"], + ["right", "a"], + ], + }, + "fx.delay": { + rate: "global", + params: ["pingpong", "beats", "feedback", "mix", "time", "highcut"], + inputs: [ + ["left", "a"], + ["right", "a"], + ["beats", "c", 0.0625, 4, 0.75], + ["feedback", "c", 0, 0.9, 0.35], + ["mix", "c", 0, 1, 0.25], + ["time", "c", 0, 4, 0], + ["highcut", "c", 500, 20000, 5000], + ], + outputs: [ + ["left", "a"], + ["right", "a"], + ], + }, + "fx.reverb": { + rate: "global", + params: ["mix", "size", "fade", "lowpass", "dim", "predelay"], + inputs: [ + ["left", "a"], + ["right", "a"], + ["mix", "c", 0, 1, 0.3], + ["size", "c", 0, 1, 0.5], + ["fade", "c", 0.1, 20, 2], + ["lowpass", "c", 200, 20000, 8000], + ["dim", "c", 200, 20000, 3000], + ["predelay", "c", 0, 0.5, 0.02], + ], + outputs: [ + ["left", "a"], + ["right", "a"], + ], + }, + in: { + rate: "global", + params: [], + inputs: [], + outputs: [ + ["notes", "n"], + ["audio", "a"], + ["right", "a"], + ["side", "a"], + ], + }, + out: { + rate: "global", + params: [], + inputs: [ + ["audio", "a"], + ["right", "a"], + ], + outputs: [], + }, + voice: { + rate: "voice", + params: [], + inputs: [], + outputs: [ + ["pitch", "c"], + ["note", "c"], + ["gate", "c"], + ["velocity", "c"], + ["phase", "c"], + ["random", "c"], + ["index", "c"], + ["age", "c"], + ], + }, + song: { + rate: "global", + params: [], + inputs: [], + outputs: [ + ["beat", "c"], + ["bar.phase", "c"], + ["tempo", "c"], + ], + }, +}; + +// END patch nodes + +/** A node as stored: `{ id, type, params?, rate?, label? }`. */ +export type PatchNodeData = Readonly<{ + id: string; + type: string; + params?: Readonly>; + rate?: "global"; + label?: string; +}>; + +/** A cable from an output to an input (`"vcf.out"` → `"vca.in"`). */ +export type PatchCableData = Readonly<{ + id: string; + from: string; + to: string; + /** Into a control port: an attenuverter -1..1 on the cord (absent is 1). */ + amount?: number; +}>; + +/** A macro: one knob that sets control inputs or number params. */ +export type PatchMacroData = Readonly<{ + id: string; + label?: string; + min: number; + max: number; + default: number; + curve?: "lin" | "exp"; + to: readonly Readonly<{ port: string; min?: number; max?: number }>[]; +}>; + +/** A patch as dawg stores it; `print` writes this form back. */ +export type PatchSpec = Readonly<{ + kind: "patch"; + role: "instrument" | "effect"; + name: string; + nodes: readonly PatchNodeData[]; + cables: readonly PatchCableData[]; + /** At most 16; the first four are the knobs, in this order. */ + macros: readonly PatchMacroData[]; + /** Polyphony cap (default 16). */ + voices?: number; + /** Sidechain track id read through `input.side`. */ + side?: string; + /** Effect patches: `post` runs after pan on the stereo pair. */ + at?: "post"; + /** Provenance, e.g. `pack:dawg/acid-bass@3`. */ + from?: string; +}>; + +/** A track that plays a patch from `song({ patches })` with its own knob values. */ +export type PatchRefSpec = Readonly<{ + kind: "patch"; + ref: string; + /** Macro values by id, in macro units. */ + macros?: Readonly>; + side?: string; +}>; + +/** The plain form `patch({ ... })` takes; `kind` and `role` may be left out. */ +export type PatchData = Readonly< + Omit & { + kind?: "patch"; + role?: "instrument" | "effect"; + cables?: readonly PatchCableData[]; + macros?: readonly PatchMacroData[]; + } +>; + +/** The plain form of a reference: `patch({ ref: "acid-bass", macros: { cutoff: 900 } })`. */ +export type PatchRefData = Readonly< + Omit & { kind?: "patch" } +>; + +/** An output port: `a` audio, `c` control, `n` notes. */ +export interface PatchSource { + readonly kind: K; + /** `node.port`, or `macro:` for a macro. */ + readonly port: string; +} + +/** What a control input takes: a number, a control output, a macro or a signal. */ +export type ControlValue = number | PatchSource<"c"> | PatternSignal; + +/** A control output; its math creates `add`, `mul` and `scale` nodes. */ +export interface ControlSource extends PatchSource<"c"> { + /** An `add` node: this plus `value` (Strudel `.add`). */ + plus(value: ControlValue): ControlSource; + /** A `mul` node: this times `value` (Strudel `.mul`). */ + times(value: ControlValue): ControlSource; + /** A `scale` node mapping this output's range (0..1, or -1..1 for an LFO) onto `lo..hi`. */ + range(lo: ControlValue, hi: ControlValue): ControlSource; + /** The same output through a cable attenuverter `k` (-1..1). */ + amount(k: number): PatchSource<"c">; +} + +/** An audio output. */ +export interface AudioSource extends PatchSource<"a"> { + /** + * Wires this output into `node`'s audio input (a stereo pair into a + * stereo pair) and returns `node`, so chains read left to right. + */ + to(node: B): B; +} + +type PatchOut = C extends "a" + ? AudioSource + : C extends "n" + ? PatchSource<"n"> + : ControlSource; + +type PatchArg = C extends "n" + ? PatchSource<"n"> + : C extends "a" + ? PatchSource<"a"> + : C extends "ca" + ? ControlValue | PatchSource<"a"> + : ControlValue; + +type PatchAnyTable = PatchNodeTable & PatchBoundaryTable; + +/** A node in a patch being built: a setter per input port, and its main output. */ +export type PatchNode = { + [P in keyof PatchAnyTable[T]["inputs"]]: ( + value: PatchArg, + ) => PatchNode; +} & Omit< + PatchOut, + keyof PatchAnyTable[T]["inputs"] +> & { + /** The node id: pinned with `{ id }`, else derived from the patch's name. */ + readonly id: string; + /** Runs this node once per track, after the voices are summed. */ + global(): PatchNode; + /** Another output port (`pan().output("right")`). */ + output

( + name: P, + ): PatchOut; + }; + +type PatchNodeOptions = + PatchNodeTable[T]["params"] & Readonly<{ id?: string; label?: string }>; + +type PatchFactory = ( + params?: PatchNodeOptions, +) => PatchNode; + +type PatchBoundary = { + readonly [P in keyof PatchBoundaryTable[B]["outputs"]]: PatchOut< + PatchBoundaryTable[B]["outputs"][P] + >; +}; + +/** A macro's range: `{ min: 80, max: 4000, default: 600, curve: "exp" }`. */ +export type PatchMacroInput = Readonly<{ + min?: number; + max?: number; + default?: number; + curve?: "lin" | "exp"; + label?: string; +}>; + +/** + * What a patch's build callback receives: a factory per node type + * (`osc`, `svf`, `fx.distort`, `engine.modal`), the boundary (`voice`, + * `song`, `input`) and `macro`. + */ +export type PatchKit = { + [ + T in keyof PatchNodeTable as T extends `${string}.${string}` ? never : T + ]: PatchFactory; +} & { + /** Effects as nodes (buffer rate, once per track). */ + fx: { + [ + T in keyof PatchNodeTable as T extends `fx.${infer S}` ? S : never + ]: PatchFactory; + }; + /** + * Instrument engines as nodes: `engine.modal(modal("vibes"))` wraps an + * engine spec, `engine.synth({ instrument: "pluck" })` names a word. + */ + engine: { + [T in keyof PatchNodeTable as T extends `engine.${infer S}` ? S : never]: ( + params?: PatchNodeOptions | Readonly<{ kind: string }>, + ) => PatchNode; + }; + /** Per-voice values: `pitch`, `gate`, `velocity`, … */ + voice: PatchBoundary<"voice"> & ControlSource; + /** Song time: `beat`, `bar.phase`, `tempo`. */ + song: PatchBoundary<"song"> & ControlSource; + /** The track's notes, its audio (effect patches) and the sidechain. */ + input: PatchBoundary<"in"> & Pick & PatchSource<"n">; + /** Declares a macro; the first four are the knobs, in call order. */ + macro(id: string, spec?: PatchMacroInput): ControlSource; +}; + +/** `patch(name, build, options)`: polyphony, sidechain, placement, provenance. */ +export type PatchOptions = Readonly<{ + voices?: number; + side?: string; + at?: "post"; + from?: string; +}>; + +type PatchSourceData = Readonly<{ + kind: "a" | "c" | "n"; + /** `node.port`, absent for a macro. */ + from?: string; + macro?: string; + amount?: number; + /** The output swings -1..1 (an LFO), so `.range()` maps from -1. */ + bipolar?: boolean; +}>; + +type PatchRecorder = { + name: string; + role: "instrument" | "effect"; + nodes: { + id: string; + type: string; + params: Record; + rate?: "global"; + label?: string; + }[]; + cables: { id: string; from: string; to: string; amount?: number }[]; + macros: { + id: string; + label?: string; + min: number; + max: number; + default: number; + curve?: "exp"; + to: { port: string }[]; + }[]; + pending: { port: string; signal: PatternSignal }[]; + ordinals: Map; +}; + +const PATCH_SOURCES = new WeakMap(); +/** Signals wired into a patch's inputs, by the hidden macro they drive. */ +const PATCH_SIGNALS = new WeakMap< + object, + readonly (readonly [string, PatternSignal])[] +>(); +const PATCH_ID = /^[a-z][a-z0-9-]*$/; +const PATCH_NAME = /^[a-z0-9][a-z0-9-]*$/; +const BASE32 = "abcdefghijklmnopqrstuvwxyz234567"; + +/** Eight base32 characters of a stable hash, in `newId`'s alphabet. */ +function patchHash(text: string): string { + const hex = hash64(text); + let a = Number.parseInt(hex.slice(0, 8), 16); + let b = Number.parseInt(hex.slice(8), 16); + let out = ""; + for (let index = 0; index < 8; index += 1) { + const word = index < 4 ? a : b; + out += BASE32[word & 31]; + if (index < 4) a = Math.floor(a / 32); + else b = Math.floor(b / 32); + } + return out; +} + +function patchPorts(type: string) { + const row = PATCH_NODE_PORTS[type]; + if (!row) throw new DawgSdkError(`unknown patch node type "${type}"`); + return row; +} + +function patchInput(type: string, port: string): PatchPortRow { + const row = patchPorts(type).inputs.find(([name]) => name === port); + if (!row) throw new DawgSdkError(`no port ${port} on ${type}`); + return row; +} + +function nodeTypeOf(rec: PatchRecorder, id: string): string { + if (id === "in" || id === "out" || id === "voice" || id === "song") return id; + const node = rec.nodes.find((candidate) => candidate.id === id); + if (!node) throw new DawgSdkError(`${rec.name}: no node "${id}"`); + return node.type; +} + +function addNode( + rec: PatchRecorder, + type: string, + input: unknown, +): PatchRecorder["nodes"][number] { + const row = patchPorts(type); + if (input !== undefined && !isRecord(input)) + throw new DawgSdkError(`${rec.name}: ${type}() takes an object`); + const { + id: pinned, + label, + ...rest + } = (input ?? {}) as Record; + let id: string; + if (pinned !== undefined) { + if ( + typeof pinned !== "string" || + pinned.length > 32 || + !PATCH_ID.test(pinned) + ) + throw new DawgSdkError( + `${rec.name}: node id must be [a-z][a-z0-9-]*, at most 32 characters`, + ); + id = pinned; + } else { + const head = + type + .replace(/^(fx|engine|patch)\./, "") + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^[^a-z]+/, "") + .slice(0, 20) || "node"; + let ordinal = rec.ordinals.get(type) ?? 0; + do { + ordinal += 1; + id = `${head}-${patchHash(`${rec.name}|${type}|${ordinal}`)}`; + } while (rec.nodes.some((node) => node.id === id)); + rec.ordinals.set(type, ordinal); + } + if ( + ["in", "out", "voice", "song"].includes(id) || + rec.nodes.some((node) => node.id === id) + ) + throw new DawgSdkError(`${rec.name}: node id "${id}" is taken`); + if ( + label !== undefined && + (typeof label !== "string" || label.length === 0 || label.length > 48) + ) + throw new DawgSdkError( + `${rec.name}: node ${id} label must be 1..48 characters`, + ); + const allowed = row.engine ? [...row.params, "settings"] : row.params; + for (const key of Object.keys(rest)) + if (!allowed.includes(key)) + throw new DawgSdkError(`${rec.name}: ${type} has no param "${key}"`); + const params: Record = {}; + for (const key of allowed) + if (rest[key] !== undefined) params[key] = rest[key]; + const node = { + id, + type, + params, + ...(label !== undefined ? { label: label as string } : {}), + }; + rec.nodes.push(node); + return node; +} + +/** Wraps an engine spec (`modal("vibes")`) as an engine node's params. */ +function engineParams(type: string, value: unknown): unknown { + if (!isRecord(value) || typeof value.kind !== "string") return value; + const spec = track({ + name: "engine", + instrument: value as TrackInput["instrument"], + }); + const fields = patchPorts(type).engine ?? []; + const settings: Record = {}; + for (const field of fields) { + const setting = (spec as Record)[field]; + if (isRecord(setting)) { + const { kind: _kind, ...rest } = setting; + settings[field] = rest; + } + } + return { + instrument: spec.instrument, + ...(Object.keys(settings).length > 0 ? { settings } : {}), + }; +} + +function patchSource(rec: PatchRecorder, data: PatchSourceData): object { + const port = data.from ?? `macro:${data.macro}`; + const source: Record = { kind: data.kind, port }; + if (data.kind === "c") + Object.assign( + source, + controlOps(rec, () => data), + ); + if (data.kind === "a") + source.to = (target: unknown) => connect(rec, data, target); + PATCH_SOURCES.set(source, data); + return Object.freeze(source); +} + +function controlOps(rec: PatchRecorder, data: () => PatchSourceData) { + const math = (type: string, a: string, b: string, value: unknown) => { + const node = addNode(rec, type, undefined); + wire(rec, `${node.id}.${a}`, patchSource(rec, data())); + wire(rec, `${node.id}.${b}`, value); + return nodeHandle(rec, node); + }; + return { + plus: (value: unknown) => math("add", "a", "b", value), + times: (value: unknown) => math("mul", "a", "b", value), + range: (lo: unknown, hi: unknown) => { + const source = data(); + const macro = source.macro + ? rec.macros.find((candidate) => candidate.id === source.macro) + : undefined; + const node = addNode(rec, "scale", { + inmin: macro ? macro.min : source.bipolar ? -1 : 0, + inmax: macro ? macro.max : 1, + }); + wire(rec, `${node.id}.in`, patchSource(rec, source)); + wire(rec, `${node.id}.min`, lo); + wire(rec, `${node.id}.max`, hi); + return nodeHandle(rec, node); + }, + amount: (k: unknown) => { + const source = data(); + const amount = finite(k, `${rec.name}: amount`); + if (amount < -1 || amount > 1) + throw new DawgSdkError(`${rec.name}: amount must be -1..1`); + if (!source.from) + throw new DawgSdkError( + `${rec.name}: amount() works on a node output, not a macro`, + ); + return patchSource(rec, { ...source, amount }); + }, + }; +} + +/** Wires `value` into the input `target` (`node.port`). */ +function wire(rec: PatchRecorder, target: string, value: unknown): void { + const dot = target.indexOf("."); + const id = target.slice(0, dot); + const port = target.slice(dot + 1); + const type = nodeTypeOf(rec, id); + const [, code, min, max] = patchInput(type, port); + const node = rec.nodes.find((candidate) => candidate.id === id); + const label = `${rec.name}: ${target}`; + const control = code === "c" || code === "ca"; + if (typeof value === "number") { + if (!control) + throw new DawgSdkError( + `${label} takes ${code === "a" ? "audio" : "notes"}, not a number`, + ); + if (node && patchPorts(type).params.includes(port)) + node.params[port] = finite(value, label); + else { + const constant = addNode(rec, "const", { value: finite(value, label) }); + wire( + rec, + target, + patchSource(rec, { kind: "c", from: `${constant.id}.out` }), + ); + } + return; + } + if (isPatternSignal(value)) { + if (!control) + throw new DawgSdkError( + `${label} takes ${code === "a" ? "audio" : "notes"}, not a signal`, + ); + const signal = + value instanceof PatternSignal ? value : signalFromData(value); + if ( + min === undefined && + !(node && patchPorts(type).params.includes(port)) + ) { + const constant = addNode(rec, "const", undefined); + rec.pending.push({ port: `${constant.id}.value`, signal }); + wire( + rec, + target, + patchSource(rec, { kind: "c", from: `${constant.id}.out` }), + ); + } else rec.pending.push({ port: target, signal }); + void max; + return; + } + const data = + isRecord(value) || typeof value === "function" + ? PATCH_SOURCES.get(value as object) + : undefined; + if (!data) + throw new DawgSdkError( + `${label}: wire a number, a node, a macro or a signal`, + ); + const fits = + data.kind === code || + (code === "ca" && (data.kind === "c" || data.kind === "a")); + if (!fits) { + const word = { + a: "audio", + c: "control", + n: "notes", + ca: "control", + } as const; + throw new DawgSdkError( + `${label} takes ${word[code]}, not ${word[data.kind]}`, + ); + } + if (data.macro) { + const macro = rec.macros.find((candidate) => candidate.id === data.macro)!; + if ( + min === undefined && + !(node && patchPorts(type).params.includes(port)) + ) { + const constant = addNode(rec, "const", undefined); + macro.to.push({ port: `${constant.id}.value` }); + wire( + rec, + target, + patchSource(rec, { kind: "c", from: `${constant.id}.out` }), + ); + } else { + if (macro.to.some((existing) => existing.port === target)) + throw new DawgSdkError( + `${rec.name}: macro ${macro.id} already sets ${target}`, + ); + macro.to.push({ port: target }); + } + return; + } + const from = data.from!; + if (rec.cables.some((cable) => cable.from === from && cable.to === target)) + throw new DawgSdkError( + `${rec.name}: ${from} is already wired to ${target}`, + ); + rec.cables.push({ + id: `c-${patchHash(`${rec.name}|${from}|${target}`)}`, + from, + to: target, + ...(data.amount !== undefined && data.amount !== 1 + ? { amount: data.amount } + : {}), + }); +} + +/** The audio outputs `.to()` uses: the first, and `right` when it has one. */ +function audioPair(rows: readonly PatchPortRow[]): string[] { + const audio = rows.filter(([name, code]) => code === "a" && name !== "side"); + const first = audio[0]?.[0]; + if (first === undefined) return []; + const right = audio.find(([name]) => name === "right" && name !== first); + return right ? [first, right[0]] : [first]; +} + +function connect( + rec: PatchRecorder, + source: PatchSourceData, + target: unknown, +): unknown { + const id = isRecord(target) ? PATCH_TARGETS.get(target) : undefined; + if (id === undefined) + throw new DawgSdkError(`${rec.name}: .to() needs a node`); + connectTo(rec, source, id); + return target; +} + +function connectTo( + rec: PatchRecorder, + source: PatchSourceData, + id: string, +): void { + const inputs = audioPair(patchPorts(nodeTypeOf(rec, id)).inputs); + if (inputs.length === 0) + throw new DawgSdkError(`${rec.name}: ${id} has no audio input`); + const [node, port] = splitRef(source.from ?? ""); + const outputs = + port === audioPair(patchPorts(nodeTypeOf(rec, node)).outputs)[0] + ? audioPair(patchPorts(nodeTypeOf(rec, node)).outputs) + : [port]; + outputs + .slice(0, inputs.length) + .forEach((out, index) => + wire( + rec, + `${id}.${inputs[index]}`, + patchSource(rec, { kind: "a", from: `${node}.${out}` }), + ), + ); + if (outputs.length === 1 && inputs.length === 2) + wire( + rec, + `${id}.${inputs[1]}`, + patchSource(rec, { kind: "a", from: `${node}.${outputs[0]}` }), + ); +} + +function splitRef(ref: string): [string, string] { + const dot = ref.indexOf("."); + return [ref.slice(0, dot), ref.slice(dot + 1)]; +} + +/** Node handles by object, to their node id (for `.to()`). */ +const PATCH_TARGETS = new WeakMap(); + +function nodeHandle( + rec: PatchRecorder, + node: PatchRecorder["nodes"][number], +): unknown { + const row = patchPorts(node.type); + const main = row.outputs[0]; + const data = (port: string): PatchSourceData => { + const out = row.outputs.find(([name]) => name === port); + if (!out) throw new DawgSdkError(`no output ${port} on ${node.type}`); + return { + kind: out[1] === "ca" ? "c" : (out[1] as "a" | "c" | "n"), + from: `${node.id}.${port}`, + ...(out[2] === -1 ? { bipolar: true } : {}), + }; + }; + const handle: Record = { + id: node.id, + ...(main + ? { kind: data(main[0]).kind, port: `${node.id}.${main[0]}` } + : {}), + }; + if (main && data(main[0]).kind === "c") + Object.assign( + handle, + controlOps(rec, () => data(main[0])), + ); + if (main && audioPair(row.outputs).length > 0) + handle.to = (target: unknown) => + connect(rec, data(audioPair(row.outputs)[0]!), target); + handle.global = () => { + node.rate = "global"; + return handle; + }; + handle.output = (port: string) => patchSource(rec, data(port)); + for (const [name] of row.inputs) + handle[name] = (value: unknown) => { + wire(rec, `${node.id}.${name}`, value); + return handle; + }; + if (main) PATCH_SOURCES.set(handle, data(main[0])); + PATCH_TARGETS.set(handle, node.id); + return handle; +} + +function boundary(rec: PatchRecorder, id: "voice" | "song" | "in"): unknown { + const row = patchPorts(id); + const out: Record = {}; + for (const [name, code] of row.outputs) + out[name] = patchSource(rec, { + kind: code === "ca" ? "c" : (code as "a" | "c" | "n"), + from: `${id}.${name}`, + }); + const main = row.outputs[0]!; + const data = PATCH_SOURCES.get(out[main[0]] as object)!; + const handle: Record = { + ...out, + kind: data.kind, + port: data.from, + }; + if (data.kind === "c") + Object.assign( + handle, + controlOps(rec, () => data), + ); + if (id === "in") + handle.to = (target: unknown) => + connect(rec, { kind: "a", from: "in.audio" }, target); + PATCH_SOURCES.set(handle, data); + return Object.freeze(handle); +} + +function patchKit(rec: PatchRecorder): PatchKit { + const kit: Record = { fx: {}, engine: {} }; + for (const type of Object.keys(PATCH_NODE_PORTS)) { + if (["in", "out", "voice", "song"].includes(type)) continue; + const make = (input?: unknown) => + nodeHandle( + rec, + addNode( + rec, + type, + type.startsWith("engine.") ? engineParams(type, input) : input, + ), + ); + const [family, name] = type.includes(".") ? splitRef(type) : ["", type]; + if (family) (kit[family] as Record)[name] = make; + else kit[type] = make; + } + kit.voice = boundary(rec, "voice"); + kit.song = boundary(rec, "song"); + kit.input = boundary(rec, "in"); + kit.macro = (id: unknown, spec: unknown = {}) => { + if (typeof id !== "string" || id.length > 32 || !PATCH_ID.test(id)) + throw new DawgSdkError( + `${rec.name}: macro id must be [a-z][a-z0-9-]*, at most 32 characters`, + ); + if (rec.macros.some((macro) => macro.id === id)) + throw new DawgSdkError(`${rec.name}: two macros named ${id}`); + if (!isRecord(spec)) + throw new DawgSdkError(`${rec.name}: macro ${id} takes an object`); + const min = finite(spec.min ?? 0, `macro ${id} min`); + const max = finite(spec.max ?? 1, `macro ${id} max`); + if (!(max > min)) + throw new DawgSdkError(`${rec.name}: macro ${id} max must be above min`); + if ( + spec.curve !== undefined && + spec.curve !== "lin" && + spec.curve !== "exp" + ) + throw new DawgSdkError( + `${rec.name}: macro ${id} curve must be lin or exp`, + ); + if (spec.curve === "exp" && min <= 0) + throw new DawgSdkError( + `${rec.name}: macro ${id}: an exp curve needs min above 0`, + ); + const value = Math.min( + max, + Math.max(min, finite(spec.default ?? min, `macro ${id} default`)), + ); + if ( + spec.label !== undefined && + (typeof spec.label !== "string" || + spec.label.length === 0 || + spec.label.length > 48) + ) + throw new DawgSdkError( + `${rec.name}: macro ${id} label must be 1..48 characters`, + ); + rec.macros.push({ + id, + ...(spec.label !== undefined ? { label: spec.label as string } : {}), + min, + max, + default: value, + ...(spec.curve === "exp" ? { curve: "exp" as const } : {}), + to: [], + }); + return patchSource(rec, { kind: "c", macro: id }); + }; + return kit as PatchKit; +} + +function compareCableData(a: PatchCableData, b: PatchCableData): number { + const order = (x: string, y: string) => (x < y ? -1 : x > y ? 1 : 0); + return order(a.to, b.to) || order(a.from, b.from) || order(a.id, b.id); +} + +function buildPatch( + role: "instrument" | "effect", + name: unknown, + build: (kit: PatchKit) => unknown, + options: PatchOptions = {}, +): PatchSpec { + if ( + typeof name !== "string" || + name.length === 0 || + name.length > 64 || + !PATCH_NAME.test(name) + ) + throw new DawgSdkError( + "a patch's name must be lowercase letters, digits and dashes, at most 64 characters", + ); + if (typeof build !== "function") + throw new DawgSdkError(`patch ${name}: build must be a function`); + const rec: PatchRecorder = { + name, + role, + nodes: [], + cables: [], + macros: [], + pending: [], + ordinals: new Map(), + }; + const result = build(patchKit(rec)); + const data = + isRecord(result) || typeof result === "function" + ? PATCH_SOURCES.get(result as object) + : undefined; + if (!data || data.kind !== "a" || !data.from) + throw new DawgSdkError(`patch ${name}: build must return the audio output`); + connectTo(rec, data, "out"); + // Signals wired into inputs drive hidden macros after the declared ones. + const signals: (readonly [string, PatternSignal])[] = []; + for (const { port, signal } of rec.pending) { + const [id, input] = splitRef(port); + const [, , low, high] = patchInput(nodeTypeOf(rec, id), input); + const [lo, hi] = signalBounds(signal); + const min = low === undefined ? lo : Math.max(low, Math.min(high!, lo)); + const max = high === undefined ? hi : Math.max(low!, Math.min(high, hi)); + const node = rec.nodes.find((candidate) => candidate.id === id)!; + if (!(max > min)) { + node.params[input] = min; + continue; + } + const base = + input + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^[^a-z]+/, "") + .slice(0, 28) || "signal"; + let macroId = base; + for (let n = 2; rec.macros.some((macro) => macro.id === macroId); n += 1) + macroId = `${base}-${n}`; + rec.macros.push({ + id: macroId, + min, + max, + default: Math.min(max, Math.max(min, signalAt(signal, 0))), + to: [{ port }], + }); + signals.push([macroId, signal]); + } + const patch = Object.freeze({ + kind: "patch" as const, + role, + name, + nodes: Object.freeze( + rec.nodes.map((node) => + Object.freeze({ + id: node.id, + type: node.type, + ...(Object.keys(node.params).length > 0 + ? { params: Object.freeze({ ...node.params }) } + : {}), + ...(node.rate ? { rate: node.rate } : {}), + ...(node.label !== undefined ? { label: node.label } : {}), + }), + ), + ), + cables: Object.freeze( + rec.cables + .map((cable) => Object.freeze({ ...cable })) + .sort(compareCableData), + ), + macros: Object.freeze( + rec.macros.map((macro) => + Object.freeze({ + ...macro, + to: Object.freeze( + macro.to.map((target) => Object.freeze({ ...target })), + ), + }), + ), + ), + ...patchOptions(name, options, role), + }); + if (patch.macros.length > 16) + throw new DawgSdkError(`patch ${name} holds at most 16 macros`); + if (signals.length > 0) PATCH_SIGNALS.set(patch, Object.freeze(signals)); + return patch; +} + +function patchOptions( + name: string, + options: unknown, + role: "instrument" | "effect", +): Partial> { + if (!isRecord(options)) + throw new DawgSdkError(`patch ${name}: options must be an object`); + const out: Record = {}; + if (options.voices !== undefined) { + const voices = finite(options.voices, `patch ${name} voices`); + if (!Number.isInteger(voices) || voices < 1 || voices > 32) + throw new DawgSdkError(`patch ${name} voices must be an integer 1..32`); + out.voices = voices; + } + if (options.side !== undefined) { + if (typeof options.side !== "string" || options.side.length === 0) + throw new DawgSdkError(`patch ${name} side must name a track`); + out.side = options.side; + } + if (options.at !== undefined) { + if (options.at !== "post" || role !== "effect") + throw new DawgSdkError( + `patch ${name} at must be "post" (effect patches only)`, + ); + out.at = "post"; + } + if (options.from !== undefined) { + if ( + typeof options.from !== "string" || + options.from.length === 0 || + options.from.length > 128 + ) + throw new DawgSdkError(`patch ${name} from must be 1..128 characters`); + out.from = options.from; + } + return out; +} + +/** The plain form: kept as given (dawg validates and orders it). */ +function plainPatch( + input: Record, + role?: "effect", +): PatchSpec | PatchRefSpec { + if (input.ref !== undefined) { + const { kind: _kind, ...rest } = input; + if (typeof input.ref !== "string" || !PATCH_NAME.test(input.ref)) + throw new DawgSdkError( + "patch ref must name a patch in song({ patches })", + ); + for (const key of Object.keys(rest)) + if (!["ref", "macros", "side"].includes(key)) + throw new DawgSdkError(`patch reference has no field "${key}"`); + if (rest.macros !== undefined && !isRecord(rest.macros)) + throw new DawgSdkError(`patch ${input.ref} macros must be an object`); + return Object.freeze({ kind: "patch" as const, ...rest }) as PatchRefSpec; + } + const { + kind: _kind, + role: given, + name, + nodes, + cables, + macros, + ...rest + } = input; + if (typeof name !== "string" || !PATCH_NAME.test(name)) + throw new DawgSdkError( + "a patch's name must be lowercase letters, digits and dashes, at most 64 characters", + ); + if (!Array.isArray(nodes)) + throw new DawgSdkError(`patch ${name}: nodes must be a list`); + if (cables !== undefined && !Array.isArray(cables)) + throw new DawgSdkError(`patch ${name}: cables must be a list`); + if (macros !== undefined && !Array.isArray(macros)) + throw new DawgSdkError(`patch ${name}: macros must be a list`); + const kind = given ?? role ?? "instrument"; + if (kind !== "instrument" && kind !== "effect") + throw new DawgSdkError(`patch ${name} role must be instrument or effect`); + return Object.freeze({ + kind: "patch" as const, + role: kind, + name, + nodes: Object.freeze([...nodes]), + cables: Object.freeze([...((cables as unknown[]) ?? [])]), + macros: Object.freeze([...((macros as unknown[]) ?? [])]), + ...patchOptions(name, rest, kind), + }) as PatchSpec; +} + +/** + * A modular patch. The builder form records a graph: + * + * patch("acid-bass", ({ voice, osc, svf, adsr, vca, fx, macro }) => { + * const cutoff = macro("cutoff", { min: 80, max: 4000, default: 600, curve: "exp" }); + * const env = adsr({ decay: 0.18, sustain: 0 }).gate(voice.gate); + * return osc({ wave: "saw" }).pitch(voice.pitch) + * .to(svf({ mode: "lp" }).cutoff(cutoff.plus(env.times(2400)))) + * .to(fx.distort({ drive: 2 }).global()); + * }) + * + * The plain form `patch({ name, nodes, cables, macros })` takes the stored + * shape, which is what dawg prints; `patch({ ref: "acid-bass" })` plays a + * patch from `song({ patches })`. Play one with `track({ instrument })`. + */ +export function patch( + name: string, + build: (kit: PatchKit) => PatchSource<"a">, + options?: PatchOptions, +): PatchSpec; +export function patch(data: PatchData): PatchSpec; +export function patch(data: PatchRefData): PatchRefSpec; +export function patch( + name: string | PatchData | PatchRefData, + build?: (kit: PatchKit) => PatchSource<"a">, + options?: PatchOptions, +): PatchSpec | PatchRefSpec { + if (isRecord(name)) return plainPatch(name); + return buildPatch( + "instrument", + name, + build as (kit: PatchKit) => unknown, + options, + ); +} + +/** + * An effect patch, placed with `fx: { patch: [wideCrush] }`: + * `fxPatch("wide-crush", ({ input, fx, lfo }) => + * input.to(fx.crush().bits(lfo({ rate: 0.25 }).range(4, 12))))`. + */ +export function fxPatch( + name: string, + build: (kit: PatchKit) => PatchSource<"a">, + options?: PatchOptions, +): PatchSpec; +export function fxPatch(data: PatchData): PatchSpec; +export function fxPatch( + name: string | PatchData, + build?: (kit: PatchKit) => PatchSource<"a">, + options?: PatchOptions, +): PatchSpec { + if (isRecord(name)) return plainPatch(name, "effect") as PatchSpec; + return buildPatch( + "effect", + name, + build as (kit: PatchKit) => unknown, + options, + ); +} + +/** True for a patch or a patch reference. */ +export function isPatchValue( + value: unknown, +): value is PatchSpec | PatchRefSpec { + return isRecord(value) && value.kind === "patch"; +} + +/** The signals a built patch wired into its inputs, by hidden macro id. */ +export function patchSignals( + value: PatchSpec | PatchRefSpec, +): readonly (readonly [string, PatternSignal])[] { + return PATCH_SIGNALS.get(value) ?? []; +} diff --git a/docs/project-format.md b/docs/project-format.md index e43ca945..25c79407 100644 --- a/docs/project-format.md +++ b/docs/project-format.md @@ -109,6 +109,7 @@ export default song({ - Pitch analysis cache (0.7, no score field): `.dawg/analysis/..v.f0` holds a pitch curve for an audio file (DWF0: magic `DWF0`, format version, frame count, hop and start in seconds, a checksum, then per frame a float32 f0 in Hz, 0 when unvoiced, and the voicing probability and aperiodicity in 1/255 steps). It is a cache: files are written through a temporary name and a rename, any file that fails validation is ignored and rebuilt, the directory is pruned oldest first above 64 MB, and deleting it is always safe. - Vocoder (additive, SDK 1.32.0; `core/vocoder.ts`): `track({ instrument: vocoder("talkbox", { src: "lead-vox", formant: 2 }) })` stores `instrument: "vocoder"` (the built-in carrier) and `vocoder: { preset: "talkbox", src: "", formant: 2 }`; on any other instrument, `track({ instrument: "supersaw", vocoder: vocoder({ src: "t-vox" }) })` vocodes that track's own sound. The field holds an optional `src` (the modulator track: an id, or a name slug the SDK resolves to the id; never the track itself), an optional `preset` (`classic robot talkbox choir glass whisper smear lofi`) and any `VOCODER_PARAMS` overrides (`tap`, `mode`, `carrier`, `follow`, `root`, `spread`, `bands` 4..40, `lo`, `hi`, `width`, `attack`, `release`, `formant` ±24 (talkbox ±12), `unvoiced`, `sens`, `hiss`, `gate` -90..0 or `"auto"`, `enhance`, `depth`, `freeze`, `mix`, `gain`, `seed`), printed in that order. `{}` is kept and means the default preset. `src` is an audio edge in `core/routing.ts`: a cycle through vocoder sources is rejected, at most 8 tracks carry a vocoder, and removing the source track drops `src` (the carrier then plays dry). Lanes `vocoder-` under `automation.fx` for `spread width release formant unvoiced hiss depth freeze mix gain`. Tracks without the field load, print and render byte-identically. - Autotune (additive, SDK 1.33.0; `core/autotune.ts`): a track may carry `autotune`, an object whose every key is optional: `preset` (one of `hard robot warble trap pop natural gentle guided locked`; absent fields resolve on top of `pop`), `to` (`scale`, `chromatic`, `chord`, `notes`), `from` (another track's id, for `to: "notes"`; absent means the track's own notes; it may not name the track itself and is dropped when that track is removed, leaving `to` and `preset` as they were), `key` (a key name such as `"D minor"` or `"D bayati"`), `speed` 0..400 ms, `relax` 0..1, `hold` 50..1000 ms, `flex` 0..100, `glide` 0..0.5 s (the command and menu show ms), `amount` 0..1, `vib` 0..12 Hz, `vibmod` 0..1 semitones, `center` 0..1, `drift` 0..1 and `voice` (`auto`, `bass`, `tenor`, `alto`, `soprano`). The SDK prints a preset alone as `autotune: "hard"` and otherwise `autotune("pop", { speed: 40 })`. A note expression may carry `drift` 0..1. `null` in a track patch clears `autotune`. Tracks without it render byte-identically. +- Patches (additive, SDK 1.35.0; `core/patch.ts`, `core/patch-nodes.ts`): `patch(name, build, options?)` and `fxPatch(name, build, options?)` build a `TrackPatch` from node factories typed by `NODE_SPECS` (generated into `core/sdk/patch-nodes.gen.ts` and spliced into `v1.ts` between marker lines by `scripts/gen-patch-sdk.ts`; a test fails while either copy is stale), the `voice`, `song` and `input` boundaries and `macro()`. Node ids are the node type plus 8 base32 characters (the `newId` alphabet) hashed from the patch's name, type and per-type ordinal (`adsr-dxpvu7n3`), and cable ids are `c-` plus a hash of the patch's name and both ends, so the same source builds the same ids; `{ id }` on a factory pins one. The plain overloads `patch({ role, name, nodes, cables, macros, voices, side, at, from })` and `patch({ ref, macros })` take the stored shapes and are what the printer writes. A track takes `instrument: patch(...)` (stored as `instrument: "patch"` plus `patch`), `fx: { patch: [fxPatch(...)] }` and `song({ patches: [...] })` holds the library that `ref` names. Signals (`sine cosine saw isaw tri square rand perlin irand(n) pat("…")` with `.range .rangex .slow .fast .segment .add .mul .early .late .every`) are plain data (`{ kind: "signal", op, args, xf }`) evaluated per cycle (one bar) with Strudel's formulas and legacy RNG, seeded per track by `signalSeed(trackId)`. `track({ mods, lanes })` and a signal wired into a port are baked by `song()` into `automation.fx` points (`patch-` for macros; at most 16 points a bar and 256 in all, values rounded to 1e-6; stepped signals hold until one tick before the next value); a lane written under `automation` wins. The printer writes baked points, not the signal. Projects without patches print and render byte-identically. - Modeled piano (additive, SDK 1.24.0; `core/keys.ts`): `track({ instrument: "grand", keys: { preset: "ballad", hardness: 0.3 } })`. The instrument is a piano family (`grand`, `upright`, `felt`, `honkytonk`, `prepared`); `keys` holds an optional `preset` (`grand`, `ballad`, `upright`, `felt`, `lofi`, `honkytonk`, `prepared`) and parameter overrides from `KEYS_PARAMS` (tabled under **Keys (modeled piano)** in DAWG.md), stored in table order with `preset` last; `keys: {}` is the family's own sound. In the SDK a piano family or preset word alone (`instrument: "grand"`, `"lofi"`) implies `keys: { preset: }` and, as at the prompt, adds that preset's effects (`filter`, `fx`, `reverb`) unless the input sets them; a family word given with explicit `keys` stores exactly those keys. Switching presets or `keys reset` at the prompt removes the previous preset's effects while they still hold the preset's values. The engine runs only when the instrument is a piano family and `keys` is present, so a stored `instrument: "piano"` stays the legacy tone and prints unchanged; new writes of the word `piano` in the prompt, menu and agent store `grand` with `keys`. Automatable parameters have lanes under `automation.fx` as `"keys-hardness"`, `"keys-touch"`, `"keys-decay"`, `"keys-release"`, `"keys-knock"`, `"keys-noise"` and `"keys-felt"`, read at note onsets. The 0.5 track `pedal` drives the dampers. Scores without `keys` render byte-identically. - Electric keys (additive, 0.6.1; `core/keys.ts`, `src/audio/keys/electric.ts`): the instrument families `epiano`, `wurli` and `clav` (`ELECTRIC_FAMILIES`) use the same `keys` field: `track({ instrument: "epiano", keys: { preset: "suitcase", vibe: 0.5 } })`. Presets `epiano`, `suitcase`, `dyno` (epiano), `wurli` and `clav`, `funkclav` (clav); the preset word alone implies `keys: { preset }`, and `rhodes`, `wurlitzer`, `clavinet` store the family. Their `KEYS_PARAMS` rows (`bark`, `bell`, `tone`, `pickup` = `neck` | `bridge` | `both` | `out`, `mute`, `vibe`, `vibehz`, `trem`) are tagged by family in `KEYS_PARAM_FAMILIES`, and the piano rows `hardness`, `touch`, `decay`, `release`, `width`, `vib`, `vibmod` apply too. Lanes `"keys-tone"`, `"keys-vibe"`, `"keys-trem"`. The engine runs only when the instrument is an electric family and `keys` is present; the legacy word `keys` is unchanged. - Piano pedals (additive, SDK 1.26.0; `core/expression.ts`): a track takes `softPedal: [[beat, "down" | "half" | "up"], ...]` (una corda) and `sostenuto: [[beat, "down" | "up"], ...]`, stored like `pedal` as `{tick, state}[]` (one event per tick, at most 1024; sostenuto rejects `half`). The modeled piano reads `softPedal` at each note's onset (fewer unison strings, a softer hammer spectrum, a lower level); `sostenuto` is applied at render with the sustain pedal: notes whose key is held when it goes down are extended to its next `up` (the same key struck again while that sostenuto is down also rings to the lift; the earlier instance stops at the restrike; a key held through a lift and a new press is caught again). The piano parameter `keys.sym` (0..1, default 0) adds sympathetic string resonance while the sustain pedal is down. Tracks without these fields render byte-identically. diff --git a/scripts/gen-patch-sdk.ts b/scripts/gen-patch-sdk.ts new file mode 100644 index 00000000..6abc6a9b --- /dev/null +++ b/scripts/gen-patch-sdk.ts @@ -0,0 +1,181 @@ +/** + * The SDK's patch node factories are typed from `NODE_SPECS` + * (core/patch-nodes.ts), but the vendored SDK is one file with no imports. + * This script generates the table and its types as + * `core/sdk/patch-nodes.gen.ts` and splices the same block into + * `core/sdk/v1.ts` between two marker lines. Run + * `bun scripts/gen-patch-sdk.ts` after changing a node spec; + * `core/sdk/patch-nodes-gen.test.ts` fails while either copy is stale. + */ + +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { format } from "prettier"; +import type { ParamSpec } from "../core/params.ts"; +import { + BOUNDARY_SPECS, + NODE_SPECS, + type NodeSpec, + type PortSpec, +} from "../core/patch-nodes.ts"; + +export const BEGIN_MARKER = + "// BEGIN patch nodes: generated from core/patch-nodes.ts by scripts/gen-patch-sdk.ts"; +export const END_MARKER = "// END patch nodes"; + +const ROOT = join(import.meta.dir, ".."); +export const GEN_PATH = join(ROOT, "core", "sdk", "patch-nodes.gen.ts"); +export const SDK_PATH = join(ROOT, "core", "sdk", "v1.ts"); + +const GEN_HEADER = `/** + * Patch node types for the SDK, generated from NODE_SPECS + * (core/patch-nodes.ts) by scripts/gen-patch-sdk.ts. Do not edit: the same + * block is spliced into core/sdk/v1.ts, which stays a single vendored file. + */ +`; + +function key(name: string): string { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name); +} + +function comment(text: string): string { + return `/** ${text.replace(/\*\//g, "* /")} */`; +} + +function range(spec: ParamSpec): string { + if (spec.kind === "number") { + const unit = spec.unit ? ` ${spec.unit}` : ""; + return `${spec.min}..${spec.max}${unit}, default ${spec.default}`; + } + if (spec.kind === "enum") return `default "${spec.default}"`; + return `default ${spec.default}`; +} + +function paramType(spec: ParamSpec): string { + if (spec.kind === "number") return "number"; + if (spec.kind === "boolean") return "boolean"; + // An engine that plays any instrument word lists no values. + if (spec.values.length === 0) return "string"; + return spec.values.map((value) => JSON.stringify(value)).join(" | "); +} + +/** `n` notes, `a` audio, `c` control, `ca` control that also takes audio. */ +function portCode(port: PortSpec): string { + if (port.kind === "notes") return "n"; + if (port.kind === "audio") return "a"; + return port.audioRate ? "ca" : "c"; +} + +function nodeType(spec: NodeSpec): string { + const params = Object.entries(spec.params).map(([name, param]) => { + const port = spec.inputs.find((input) => input.name === name); + const doc = port?.doc ?? param.doc; + return `${comment(`${doc} (${range(param)})`)}\n${key(name)}?: ${paramType(param)};`; + }); + if (spec.engine) + params.push( + `${comment("engine settings, as the track field holds them")}\nsettings?: Readonly>;`, + ); + const inputs = spec.inputs.map( + (port) => `${comment(port.doc)}\n${key(port.name)}: "${portCode(port)}";`, + ); + const outputs = spec.outputs.map( + (port) => `${comment(port.doc)}\n${key(port.name)}: "${portCode(port)}";`, + ); + const main = spec.outputs[0] ? portCode(spec.outputs[0]) : "none"; + return `${comment(spec.doc)}\n${key(spec.type)}: {\nmain: "${main === "ca" ? "c" : main}";\nparams: {\n${params.join("\n")}\n};\ninputs: {\n${inputs.join("\n")}\n};\noutputs: {\n${outputs.join("\n")}\n};\n};`; +} + +/** + * Runtime table: per type, its rate, param names in canonical order, inputs + * `[name, code, min, max, default]`, outputs `[name, code]` (bipolar control + * outputs add `-1, 1`) and an engine's track fields. + */ +function nodeRow(spec: NodeSpec): string { + const inputs = spec.inputs.map((port) => { + const s = port.spec; + return s + ? `[${JSON.stringify(port.name)}, "${portCode(port)}", ${s.min}, ${s.max}, ${s.default}]` + : `[${JSON.stringify(port.name)}, "${portCode(port)}"]`; + }); + // A control output documented as `-1..1` is bipolar: `.range()` maps from -1. + const outputs = spec.outputs.map((port) => + port.kind === "control" && port.doc.startsWith("-1..1") + ? `[${JSON.stringify(port.name)}, "c", -1, 1]` + : `[${JSON.stringify(port.name)}, "${portCode(port)}"]`, + ); + const params = Object.keys(spec.params).map((name) => JSON.stringify(name)); + const engine = spec.engine + ? `, engine: [${spec.engine.fields.map((field) => JSON.stringify(field)).join(", ")}]` + : ""; + return `${key(spec.type)}: { rate: "${spec.rate}", params: [${params.join(", ")}], inputs: [${inputs.join(", ")}], outputs: [${outputs.join(", ")}]${engine} },`; +} + +/** The generated block, markers included, unformatted. */ +export function patchNodesBlock(): string { + const specs = Object.values(NODE_SPECS); + const boundary = Object.values(BOUNDARY_SPECS); + return [ + BEGIN_MARKER, + "", + "/** Port codes: `n` notes, `a` audio, `c` control, `ca` control that also takes audio. */", + 'export type PatchPortCode = "n" | "a" | "c" | "ca";', + "", + "/** Every patch node type: its main output's port code, settings, input ports and output ports (NODE_SPECS). */", + "export type PatchNodeTable = {", + ...specs.map(nodeType), + "};", + "", + "/** The boundary nodes every patch has: `in`, `out`, `voice` and `song`. */", + "export type PatchBoundaryTable = {", + ...boundary.map(nodeType), + "};", + "", + "type PatchPortRow = readonly [string, PatchPortCode, number?, number?, number?];", + "", + "/** Ports by node type, for wiring and checks at run time. */", + "export const PATCH_NODE_PORTS: Readonly>> = {", + ...[...specs, ...boundary].map(nodeRow), + "};", + "", + END_MARKER, + ].join("\n"); +} + +async function formatted(source: string): Promise { + return format(source, { parser: "typescript", filepath: "x.ts" }); +} + +/** The generated module's full text. */ +export async function generatedModule(): Promise { + return formatted(`${GEN_HEADER}\n${patchNodesBlock()}\n`); +} + +/** `v1.ts` with the generated block spliced in (or replaced). */ +export async function syncedSdk(sdk: string): Promise { + const block = (await formatted(patchNodesBlock())).trimEnd(); + const start = sdk.indexOf(BEGIN_MARKER); + const end = sdk.indexOf(END_MARKER); + if (start < 0 || end < start) + throw new Error(`v1.ts has no ${BEGIN_MARKER} … ${END_MARKER} block`); + return `${sdk.slice(0, start)}${block}${sdk.slice(end + END_MARKER.length)}`; +} + +if (import.meta.main) { + const sdk = await readFile(SDK_PATH, "utf8"); + const next = await syncedSdk(sdk); + const gen = await generatedModule(); + let current = ""; + try { + current = await readFile(GEN_PATH, "utf8"); + } catch { + // first run + } + if (next !== sdk) await writeFile(SDK_PATH, next); + if (gen !== current) await writeFile(GEN_PATH, gen); + console.log( + next === sdk && gen === current + ? "patch node types up to date" + : "patch node types updated", + ); +}