# deck — full documentation > Generated from the docs. Site: https://spacedevin.github.io/deck --- # deck Source: README.md **A tiny text language for writing music, and the packages that play it.** [![npm: @spacedevin/deck](https://img.shields.io/npm/v/@spacedevin/deck?label=%40spacedevin%2Fdeck)](https://www.npmjs.com/package/@spacedevin/deck) [![npm: @spacedevin/deck-synths](https://img.shields.io/npm/v/@spacedevin/deck-synths?label=%40spacedevin%2Fdeck-synths)](https://www.npmjs.com/package/@spacedevin/deck-synths) [![npm: @spacedevin/deck-player](https://img.shields.io/npm/v/@spacedevin/deck-player?label=%40spacedevin%2Fdeck-player)](https://www.npmjs.com/package/@spacedevin/deck-player) [![crates.io: deckfile](https://img.shields.io/crates/v/deckfile?label=crates.io%3A%20deckfile)](https://crates.io/crates/deckfile) [![CI](https://github.com/spacedevin/deck/actions/workflows/ci.yml/badge.svg)](https://github.com/spacedevin/deck/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) **▶ [Hear it on the docs site](https://spacedevin.github.io/deck/)** · [Examples](docs/EXAMPLES.md) · [Grammar](docs/DECK_GRAMMAR.md) · [Contributing](CONTRIBUTING.md) ```deck deck 1 bpm 132 track Lead id lead gen gameBoyDmg gen type pulse duty 25 vol 11 note 72 0 0.5 v 110 note 76 0.5 0.5 v 95 note 79 1 1 v 105 note 76 2 0.5 v 100 note 72 2.5 1.5 v 110 track Bass id bass gen gameBoyDmg gen type wave wave_shape saw vol 15 note 36 0 2 v 120 note 43 2 2 v 110 track Kick id kick gen gbaDirectSound gen waveform triangle pitch_drop -14 adsr a 0 d 0.08 s 0 r 0 step_pitch 36 steps x . . . x . . x x . . . x . . . ``` That is a whole song: a tempo, three tracks, and what each one plays — melody as notes on a beat grid, drums as a step pattern. Press play on the [docs site](https://spacedevin.github.io/deck/) and the browser synthesises it while the code lights up: the step under the playhead in each lane, and the lines of every track sounding on it. `.deck` is line-oriented and streamable, so it can be typed, diffed, generated, and sent over a wire a line at a time. Times are in quarter-note beats; one bar is 4 beats or 16 sixteenth steps. The parser is deliberately parse-only — absent optionals stay `null` and nothing is clamped — because defaults and ranges are the host's policy. ## The packages | Package | What it is | Install | |---|---|---| | [`@spacedevin/deck`](.) | The language: tokenize, parse, format, registries, highlight classification. No audio. | `npm i @spacedevin/deck` | | [`@spacedevin/deck-synths`](packages/synths/) | The instrument catalog: 33 Web Audio voices — Game Boy, NES, C64 SID, YM2612, SPC700, FM, drums, hard sync, bowed and plucked models, vocals. | `npm i @spacedevin/deck-synths` | | [`@spacedevin/deck-player`](packages/player/) | The host: Song IR with defaults and clamps, a lookahead transport, offline render, and a `` element. | `npm i @spacedevin/deck-player` | Dependencies point one way — player → synths → deck — so the language stays audio-free and the voices can be reused by any host. The same Tish source also emits a Rust crate, [`deckfile`](https://crates.io/crates/deckfile), checked against the same conformance corpus as the JS build. ## Quick start **Play it in a page.** No framework, no build step: ```html deck 1 bpm 120 track Lead id lead gen gameBoyDmg note 60 0 0.5 v 100 ``` **Play it from code:** ```js import { createDeckPlayer } from '@spacedevin/deck-player' let player = createDeckPlayer() let song = player.load(source) // returns the Song, with errors / substitutions / ignored button.onclick = () => player.play() // an AudioContext needs a user gesture ``` **Parse it only:** ```tish import { parseProgram, registerGeneratorIdAliases, registerGenBlockDialect } from "@spacedevin/deck" registerGeneratorIdAliases({ matrix_fm: "matrixFm" }, { matrixFm: "matrix_fm" }) // registerGenBlockDialect(...) — host supplies patch / matrix_fm parsers let ast = parseProgram(source) ``` **Render it to a WAV.** From a checkout of this repo, with Chrome or Chromium installed: ```bash node scripts/render-wav.mjs song.deck -o song.wav ``` The voices are Web Audio, so the renderer drives a headless Chrome and an `OfflineAudioContext`. It is deterministic and faster than real time. Details and flags in [Rendering](docs/RENDERING.md). ## Docs **[spacedevin.github.io/deck](https://spacedevin.github.io/deck/)** — the same markdown, as a site, with a play button on every complete song. - **[Language grammar](docs/DECK_GRAMMAR.md)** — canonical `.deck` surface - **[Examples](docs/EXAMPLES.md)** — a complete, playable song for every one of the 33 voices - **[Rendering](docs/RENDERING.md)** — the WAV CLI and `renderDeckToBuffer()` - **[AST shape](docs/AST.md)** — what `parseProgram` / `parseTrackBody` return - **[gen_block extensions](docs/DECK_EXTENSION.md)** — dialect registration + common `patch` / `matrix_fm` - **[Host integration](docs/HOST.md)** — boot order, registries, what hosts implement - **[Synths](packages/synths/README.md)** — the voice contract and catalog - **[Player](packages/player/README.md)** — playback API and the element - **[AGENTS.md](AGENTS.md)** — in/out of scope for package edits For LLM readers there is an [llms.txt](https://spacedevin.github.io/deck/llms.txt) and a single-file [llms-full.txt](https://spacedevin.github.io/deck/llms-full.txt), generated from the same pages. npm also exports `./grammar`, `./ast`, `./examples`, `./rendering`, `./extension` and `./host` to those markdown files. ## What the language package covers | Area | API | |------|-----| | Lex / parse | `tokenize`, `isNumberToken`, `parseProgram` | | Track / clip body | `parseBodyLine`, `parseTrackBody`, `parseBoolish` | | Format | `formatTplBeat`, `formatTplFloat` | | Scale | `parseScaleRoot`, `scaleRootNames`, `scaleModeNames`, `scaleIntervals` | | Bar / Euclid | `parseBarSelector`, `barSelectorMatches`, `euclideanPattern` | | Registries | `registerGeneratorIdAliases`, `registerParamKeyAliases`, `paramKeyToCamel`, … | | Host extensions | `registerBodyLineDialect`, `registerTopLevelStatement`, `registerGenBlockDialect` | | Macros | `registerBuiltinMacros`, `lookupMacro`, `expandMacroBody` | | gen_block | `parseGenBlock`, `registerGenBlockDialect` | | Highlight | `classifyLine`, `isKeyword`, `registerHighlightKeywords` | Out of scope for the language package, and owned by hosts: apply/emit to a project IR, sessions, audio engines, instrument catalogs, builtin macro catalogs, highlight CSS, graph editors. Runnable demos of the parse and host-boot API live in [`examples/`](examples/): ```bash npm run examples ``` ## Rust The same `src/index.tish` emits a Rust library crate, so a Rust consumer (tish-gba's build-time bake) parses `.deck` with this parser rather than its own: ```bash npm run build:rust # -> crate/ (crates.io: `deckfile`) npm run test:rust # the same conformance corpus, from Rust ``` ```rust let program = deckfile::parse(src); // typed let ast = deckfile::parseProgram(value); // the raw AST, same shape as JS ``` One source, three targets — Tish, JS, Rust — checked against one corpus. ## Contributing Contributions are welcome, and small ones are a fine place to start. Good first contributions: - **A new example** in [docs/EXAMPLES.md](docs/EXAMPLES.md) — every block there is tested and playable - **A new voice** in [packages/synths/](packages/synths/) — one pure function, one registry entry, one example - **A conformance case** in [conformance/](conformance/) when you find an input the parsers disagree on - **A doc fix** — the site is built from the markdown in this repo, so a PR is the whole change [CONTRIBUTING.md](CONTRIBUTING.md) has the setup, the test commands, the commit conventions, and a recipe for each of those. Bugs and ideas go in [issues](https://github.com/spacedevin/deck/issues); there are templates for a bug, a feature, and a new voice. ## Development ```bash npm install npm test # build + API/grammar suite + conformance + examples + tish and JS smoke npm run test:coverage # c8 on dist/deck.js — 100% lines / functions / statements npm run test:conformance # the cross-implementation corpus npm test -w @spacedevin/deck-player npm run site:serve # the docs site on :4321 ``` **[`conformance/`](conformance/)** is the contract between implementations: the same `.deck` inputs and expected parses are run by the JS build, the Rust crate emitted from the same Tish source, and any restricted host (via a profile). It is what makes drift a test failure rather than a surprise. Branch coverage is lower (~60%) because the Tish→JS emit adds many `?? null` / typeof guards that are defensive noise, not language logic. Line coverage is the gate in CI. ## Releases Versions come from [sem](https://github.com/tishlang/sem): Conventional Commits drive semver (`feat` / `fix` / `perf` / `BREAKING` release; `chore` / `docs` / `ci` do not). A green `main` cuts a **prerelease** carrying all three npm tarballs; promoting it to a full release publishes to npm and crates.io. The [Releases page](https://github.com/spacedevin/deck/releases) is the changelog. ## License MIT — see [LICENSE](LICENSE). --- # deck language Source: docs/DECK_GRAMMAR.md Line-oriented, streamable patch text (`.deck`). This is the **language** reference for `@spacedevin/deck`. **Package responsibilities:** tokenize, `parseProgram` → AST, format helpers, bar selectors, Euclidean step fill, wavetables, scale root/mode vocab, highlight classify, empty registries (generator id / param key / macro / gen_block dialect). **Host responsibilities:** map AST → project IR (apply/emit), audio engines, ownership/skills, co-DJ, UI. Generators, builtin macro catalogs, and `patch` / `matrix_fm` dialect parsers are **host-registered**. Times are in **quarter-note beats**. One bar = 4 beats = **16** sixteenth steps. --- ## Lexical - Lines are statements. Indentation (2+ spaces or tab) nests a body under the current open block (`track`, `clip`, `auto`, `macro`, `song`, `follow`, `gen_block`). - `#` starts a comment to end of line — but **only at column 0 or after whitespace**, so a `#` inside a token is data. That is what makes sharp note names (`scale F# minor`, a track named `C#maj`) work. - Tokens: whitespace-separated; numbers accepted by `isNumberToken`. - Legacy alias: `tpl` ≡ `deck` for the version header only. --- ## Version header ``` deck 1 ``` Recommended first non-comment line. Emit writes `deck 1`. Distinct from track-body routing `deck A|B|C|D`. --- ## Top-level statements These are recognized by `parseProgram`. | Statement | Form | Notes | |-----------|------|--------| | Version | `deck ` / `tpl ` | | | Tempo | `bpm ` | | | Swing | `swing <0..1>` | Off-beat 16th shuffle; `0` = straight | | Scale lock | `scale ` | `root` = note (`C`, `F#`, `Bb`) or pitch-class `0..11`; modes below. `scale off` / `none` / `chromatic` clears (AST root `-1`) | | Launch quant | `launch_quant ` | Scene/clip launch grid (bars), `n ≥ 1` | | Song seed | `song_seed ` | Seeds deterministic randomness (e.g. step probability) | | Wavetable | `wave harmonics …` / `levels ` / `shape [duty ]` / `<32 hex>` | Named PSG wavetable; see below | | Crossfader | `xfade []` | Both `0..1`; if `y` omitted, `y = 0.5` | | Main deck | `main_deck live\|local` | Which booth feeds the main out | | Booth mix | `deck_mix [hi n] [mid n] [lo n] [flt n] [vol n]` | Any subset of keys | | Track | `track id gen [ * ] [ … ]` | Name may be multi-word; anchored on `id` / `gen` | | Remove track | `remove_track ` | Incremental edit; not present in full snapshots | | Macro def | `macro [k=default …]` … `end macro` | Body lines = patch dialect lines | | Automation | `auto …` + indented ` ` | See [Automation](#automation) | | Master mix | `master_mix eq_lo eq_mid eq_hi ` | Keys any order; missing keys unchanged | | Actor mix | `actor_mix …` | `gain`/`trim`, `eq_*`, optional `mute`/`solo` | | Session scenes | `session_scenes ` | `n ≥ 1` | | Session slot | `session_slot ` | `-`/`.` clears | | Clip | `clip channel bars [name …]` + indented body | | | Song | `song` + indented `P [x]` or bare scene index | 1-based `P` | | Follow | `follow` + indented `P [ ]` | 1-based `P` | | Control directive | `@ …` | Collected into `directives[]`; the verb is host-interpreted. See [Control directives](#control-directives-) | ### Scale modes Accepted mode tokens (aliases in parentheses): `major` (`ionian`), `minor` (`aeolian`), `dorian`, `phrygian`, `lydian`, `mixolydian`, `locrian`, `harmonic_minor`, `melodic_minor`, `pentatonic_major` (`penta_major`, `majpenta`), `pentatonic_minor` (`penta_minor`, `minpenta`), `blues`. Package helpers: `parseScaleRoot`, `scaleRootNames`, `scaleModeNames`, `scaleIntervals`. ### Wavetables ``` wave harmonics [a2 a3 …] wave levels wave shape sine|square|saw|triangle|pulse [duty ] wave <32 hex digits> ``` A named PSG wavetable: **32 four-bit levels**, one cycle, in Game Boy wave RAM order. `0` is the bottom of the wave, `f` the top, `8` about the rest line. Select one with `gen type wave wave_shape `; a name matching no `wave` line falls back to the host's built-in shapes. All four spellings are **expanded at parse time**, the way `steps euclid` is: a host reads `levels` and never has to know which one produced it. The source form is kept on the AST node alongside, so an emitter can write back what was written rather than flattening everything to hex. **`harmonics`** gives amplitudes instead of samples — `a1` is the fundamental, `a2` the octave above it, `a3` the twelfth — summed into 32 levels and normalized to fill the range, so only the ratios matter. This is the form to reach for: it says what a timbre *is*. **`levels`** is the same 32 samples in decimal, each a whole number `0..15`. It exists so the set of spellings is exhaustive. `harmonics` can only land on tables whose partials are all in sine phase, so a curve someone tuned a nibble at a time has no additive recipe — without `levels` it would be stuck as hex. Anything hex can say, `levels` can say. **`shape`** names a classic waveform. `duty` is a percent above 0 and below 100, and applies to `pulse`; `square` is `pulse` at 50. These names existed before only as a host-side fallback for `wave_shape`, with a different vocabulary and a different default in each host — resolving them here means every host gets the same levels. Every line below is the same sound: ``` wave organ harmonics 1 0.5 0.33 0.2 wave organ 8beffecbbbbaa9888776554444310014 wave organ levels 8 11 14 15 15 14 12 11 11 11 11 10 10 9 8 8 8 7 7 6 5 5 4 4 4 4 3 1 0 0 1 4 ``` Errors: a hex form that is not exactly 32 hex digits; a `harmonics` form with no numbers, a non-numeric amplitude, or amplitudes that are all zero (silence has no shape to normalize); a `levels` form without exactly 32 samples, or a sample that is not a whole number `0..15`; a `shape` that is not one of the five names, a duty outside `0 < pct < 100`, or trailing tokens that are not `duty `. ### Track header ``` track id gen [ * ] [ … ] ``` - `* N` — **pattern length** in bars (default 1). Channel spans `N × 16` steps and repeats. `* inf` / `* infinite` clears an explicit finite length. - Trailing `key value` pairs — **macro parameter overrides** when `gen` is a macro name. - `* N` and the `key value` pairs may appear in **any order** after `gen `. Emit writes `* N` first; a `*` that names no valid length is an error, never a silently dropped token. - `generatorId` spellings are host-registered (`registerGeneratorIdAliases`). Undeclared ids pass through as-is. --- ## Track / clip body `parseProgram` stores indented body lines as token rows (except `gen_block` collection); **`parseBodyLine` / `parseTrackBody`** turn those rows into typed values. The heads below are the standard language. Body parsing is deliberately **parse-only**: an absent optional is `null` so the host applies its own default, and there is no clamping or range checking — that is host policy, and hosts differ (one clamps an out-of-range lock, another rejects it). Range checks needing track context (`note` start vs `* N`) can't live here at all. An unrecognised head comes back as `kind: "unknown"` so a host dialect can claim it via `registerBodyLineDialect` — see [DECK_EXTENSION.md](DECK_EXTENSION.md). ### Mix ``` mix gain pan [mute <0|1>] [solo <0|1>] [eq_lo ] [eq_mid ] [eq_hi ] ``` Boolish: `1`/`true`/`on` vs `0`/`false`/`off`. ### Pattern length vs play cap | Form | Meaning | |------|---------| | `* N` on track header | Pattern **length** (bars); loops forever | | `loops ` | Finite **play cap** since Play / re-apply; then silent | Compose: `* 4` + `loops 8` = 4-bar pattern played twice, then stops. ### Steps ``` steps x . . . x . . . x . . . x . . . steps euclid ``` - On: `x` `X` `1` · Off: `.` `0` - Euclidean: Bjorklund fill (`euclideanPattern` in this package). Common host constraint: `len = 16`. #### Step lock lanes (after `steps`) Emitted only when a step differs from the default: | Lane | Range (default) | Meaning | |------|-----------------|--------| | `step_vel` | `1..127` (100) | Velocity | | `step_prob` | `0..1` (1) | Hit probability (seeded; peers agree) | | `step_ratchet` | `1..8` (1) | Sub-hits over the step | | `step_nudge` | `-0.5..0.5` (0) | Micro-timing as a fraction of a step | A bare `steps` line resets locks; following lanes restore deviations. Optional host extension: `step_lyric` (emitted by some hosts). ### Step pitch ``` step_pitch [ bar ] ``` Base MIDI for step hits when the channel has **no** `note` lines (default **36**). With `bar `, one line per bar/group for multi-bar patterns. ### Notes (piano roll) ``` note v [ p ] [ r ] [ n ] [ bar ] [ l ] ``` - Beats in quarter notes. For pattern length `N`: `0 ≤ startBeat` and `startBeat + durBeats ≤ N×4`. - Optional locks (non-default only on emit): `p`, `r`, `n` — same semantics as step locks. - `bar `: keep `startBeat < 4`; expand onto matching loop bars. - Host optional: `l ` on notes / step lyric lane. **Steps vs notes:** if a track block contains any `note` lines, steps for that channel are cleared. If it contains `steps` and no `note`s, piano notes are cleared. Playback prefers notes when any exist. Also: `notes_clear` — host edit fragment that clears piano notes. ### Transpose ``` transpose ``` Integer shift applied to collected `note` pitches for that block. ### Generator params (fixed / generic) Hosts typically accept: ``` gen … adsr a d s r ``` plus legacy one-line shapes for specific engines (`noise …`, `fm …`, `osc waveform …`). Snake_case keys map via `paramKeyToCamel` / `registerParamKeyAliases`. ### Channel FX / voice / deck routing ``` fx reverb_send drive lfo_rate lfo_depth cutoff res [filter_type ] voice octave arp chord arprate inversion strum deck [slot ] ``` `fx` also accepts `reverb` as alias for `reverb_send`, and `type` as alias for `filter_type`. ### Heavy generators (`gen_block`) ``` gen_block … end gen_block ``` Core language collects lines until `end gen_block`. `parseGenBlock(id, lines)` returns `{ kind, tplHeaderId, version, raw }` until a host dialect is registered. See [DECK_EXTENSION.md](DECK_EXTENSION.md) for the registration API and common `patch` / `matrix_fm` dialects. --- ## Bar selectors Single token (no spaces). Used after `bar` on `note` / `step_pitch`. Bars are **0-indexed** within the track's `* N` length. | Selector | Matches | |----------|---------| | `even` / `odd` | 0,2,4,… / 1,3,5,… | | `` | that bar only | | `n` / `*` / `all` / `every` | every bar | | `n` | `bar % a == 0` | | `n+` | `bar % a == b` | | `-n+` | first `b` bars (`0 .. b-1`) | | `b0,b1,…` | explicit list | Package: `parseBarSelector`, `barSelectorMatches`. --- ## Macros **Define** (top-level): ``` macro [key=default …] … patch-dialect body with $key … end macro ``` **Use:** `track … gen [key val …]` — expands to a `gen_block patch` at load (when the host registers a patch dialect + builtin/user macros). Package provides `lookupMacro`, `expandMacroBody`, `registerBuiltinMacros` (catalog is empty until the host fills it). --- ## Automation ``` auto master_gain auto gen auto mix auto actor mix auto master mix ``` Indented points are `beat value` pairs. Hosts interpolate on the beat timeline (`beat = globalStep × 0.25` for step playback). --- ## Session / scenes / clips ``` session_scenes session_slot clip channel bars [name ] steps … note … loops … ``` Clip grid length = `bars × 16` steps. Clip notes may span the whole clip (`bars × 4` beats). Same steps-vs-notes rule as tracks. ### Song arrangement ``` song P1 P2 x4 3 ``` 1-based scene refs (`P` or bare index). Optional `xN` repeat. ### Follow actions ``` follow P1 next 1 P2 jump 0.7 stay 0.3 ``` `P [ ]`. Host interprets action tokens. --- ## Control directives (`@ …`) Transient **stream** lines. Most are **not** stored in a static project document; hosts apply them for performance / co-DJ. `parseProgram` collects every `@ …` line into `directives[]` as `{ lineNo, verb, tokens }` and does not interpret the verb — that is host policy. A bare `@` with no verb is an error. Hosts typically understand: | Directive | Typical authority | Effect | |-----------|-------------------|--------| | `@ launch scene ` | master | Arm scene clips | | `@ launch clip ` | track owner | Per-track clip / release / stop | | `@ transport play\|song\|sequence [scene] \| stop` | master | Shared transport | | `@ transport preview` | private | Local preview clock | | `@ cue ` | private | Load into local cue | | `@ throw [scene]` | master | Cue → shared main | | `@ fx on\|off …` | master | Live master FX | | `@ deck on\|off` · `spin` | deck owner / master | Vinyl platter moves | | `@ perf_step ` | — | Schedule surrounding block for perf step `n` | Any other verb is collected too, so a host may define its own without a parser change. --- ## Format helpers Package emit helpers (numeric spelling): - `formatTplBeat` — snap to 1/96 beat, trim zeros - `formatTplFloat` — ≤ 4 decimal places, trimmed --- ## Streaming rules 1. Strip comments; ignore empty lines. 2. A line commits when its newline arrives and any open `gen_block` is closed. 3. Partial trailing lines must not mutate state. 4. Incremental merge is by `channelId` / clip id / automation key (host apply). --- ## What is not language (host-only) - Audio engines and Web Audio graphs - Instrument / preset catalogs and generator default param tables - Builtin macro catalogs (register into the package) - Ownership, skills, co-DJ transport plumbing - HTML highlight styling (`tpl-hl-*`) — classify API only lives here - Project JSON / IR schemas beyond what the AST implies --- ## Golden example ``` deck 1 bpm 118 swing 0.08 scale C minor xfade 0.5 0.5 main_deck live track Kick id c0 gen noise_burst mix gain 0.9 pan 0 eq_lo 0 eq_mid 0 eq_hi 0 deck A step_pitch 36 steps x . . . x . . . x . . . x . . . step_vel 120 100 100 100 70 100 100 100 100 100 100 100 90 100 100 100 track Bass id c3 gen fm * 2 mix gain 0.85 pan 0 voice octave -1 fx cutoff 1200 res 0.4 note 48 0.0 0.5 v 90 note 50 1.0 0.5 v 85 bar even track Lead id c4 gen patch gen_block patch osc o1 sawtooth note filter f1 lowpass q 4 freq 1800 gain a1 0 conn o1 f1 1 conn f1 a1 1 conn a1 out 1 env a1.gain set 0 0 lin 0.01 0.9 lin dur 0 end gen_block note 60 0 1 v 80 session_scenes 4 session_slot c0 0 clip_kick_a clip clip_kick_a channel c0 bars 1 steps x . . . x . . . x . . . x . . . auto master_gain 0 0.85 16 0.9 master_mix eq_lo 0 eq_mid 0 eq_hi 0 ``` --- # Examples Source: docs/EXAMPLES.md Complete, runnable `.deck` songs — one per idea. On the docs site every block here has a **Play** button; in a checkout, drop any of them in a `.deck` file. The grammar reference shows *syntax* (`note …`); this page shows *songs*. Every block on this page plays. The player carries the whole voice catalogue, so nothing here is substituted for a plain oscillator — the chip examples sound the same in a browser as they do on a GBA, and the rest sound like themselves. Press play and the code follows along: the step under the playhead is lit in each lane, and the lines of every track sounding on that step are tinted. Two voices are the exception. `ttsVocal` and `meSpeakVocal` reach outside the audio graph for speech, so they need something from the host and do not appear in an offline render — see [Speech](#speech). ## Steps The step grid is one bar of sixteenths. `x` is a hit, `.` is a rest, and `step_pitch` sets what a hit plays when the channel has no `note` lines. ```deck deck 1 bpm 120 track Kick id kick gen gbaDirectSound gen waveform triangle pitch_drop -14 adsr a 0 d 0.08 s 0 r 0 step_pitch 36 steps x . . . x . . . x . . . x . . . track Hat id hat gen gameBoyDmg gen type noise vol 6 step_pitch 72 steps . . x . . . x . . . x . . . x . ``` ## Step locks A bare `steps` line resets the locks; the lanes after it restore only the steps that differ. `step_vel` is 1–127, `step_prob` is a 0–1 chance, `step_ratchet` fills a step with sub-hits. ```deck deck 1 bpm 128 track Snare id snare gen gbaDirectSound gen waveform square adsr a 0 d 0.12 s 0 r 0 step_pitch 40 steps . . . . x . . . . . . . x . . x step_vel . . . . 127 . . . . . . . 100 . . 70 step_ratchet . . . . 1 . . . . . . . 1 . . 3 step_prob . . . . 1 . . . . . . . 1 . . 0.6 ``` ## Notes Notes are placed by beat, not by step, so they can sit anywhere including off the grid. Beats are quarter notes: one bar is 4 beats. ```deck deck 1 bpm 116 track Lead id lead gen gameBoyDmg gen type pulse duty 25 vol 12 note 72 0 0.5 v 110 note 74 0.5 0.25 v 90 note 76 0.75 0.75 v 105 note 79 1.5 0.5 v 100 note 76 2 1 v 95 note 72 3 1 v 110 track Bass id bass gen gameBoyDmg gen type wave wave_shape saw vol 15 note 36 0 2 v 120 note 43 2 2 v 110 ``` ## Bar selectors `* N` declares an N-bar pattern. A note with `bar ` starts inside its own bar and repeats on every bar the selector matches — `even`, `1`, `0,2`, `-n+2`. ```deck deck 1 bpm 124 track Bass id bass gen gameBoyDmg * 4 gen type wave wave_shape saw vol 14 note 36 0 1 v 120 bar even note 41 0 1 v 115 bar 1 note 43 0 1 v 115 bar 3 note 48 2 0.5 v 90 track Kick id kick gen gbaDirectSound * 4 gen waveform triangle pitch_drop -12 adsr a 0 d 0.09 s 0 r 0 step_pitch 36 steps x . . . x . . . x . . . x . . . ``` ## Euclidean fills `steps euclid ` spreads N hits as evenly as possible over the pattern — the Bjorklund fill. It expands to an ordinary step grid, so the lock lanes still apply. ```deck deck 1 bpm 132 track Perc id perc gen gameBoyDmg gen type noise noise_mode short vol 7 step_pitch 65 steps euclid 7 16 track Kick id kick gen gbaDirectSound gen waveform triangle pitch_drop -14 adsr a 0 d 0.08 s 0 r 0 step_pitch 36 steps euclid 4 16 ``` ## Voice: chords and arpeggios `voice` transforms every trigger on the channel — one note becomes a stack, and `arp` ripples that stack across the note instead of playing it together. ```deck deck 1 bpm 108 track Chords id chords gen gameBoyDmg gen type pulse duty 50 vol 9 voice chord minor arp up arprate 1/16 note 60 0 2 v 100 note 65 2 2 v 100 track Sub id sub gen gameBoyDmg gen type wave wave_shape triangle vol 15 note 36 0 2 v 110 note 41 2 2 v 110 ``` ## Swing and scale `swing` delays every off-beat sixteenth; `scale` snaps every pitch onto a key, so a wrong note becomes the nearest right one. ```deck deck 1 bpm 96 swing 0.4 scale C minor track Keys id keys gen gameBoyDmg gen type pulse duty 12.5 vol 10 note 60 0 0.25 v 100 note 61 0.25 0.25 v 85 note 63 0.5 0.25 v 95 note 66 0.75 0.25 v 90 note 67 1 0.5 v 105 note 63 1.5 0.5 v 90 note 60 2 2 v 110 track Kick id kick gen gbaDirectSound gen waveform triangle pitch_drop -12 adsr a 0 d 0.1 s 0 r 0 step_pitch 36 steps x . . . . . . . x . . . . . . . ``` ## Wavetables `wave` names a 32-sample, 4-bit table — one cycle of a waveform, in Game Boy wave RAM order — and a `type wave` track plays it by name. There are four ways to say one: `harmonics …` for the amplitudes, `levels ` for the samples in decimal, `shape ` for a classic waveform, and the bare 32 hex digits. All four resolve in the parser, so they cost the same and sound the same. Reach for `harmonics`. The hex is what the table *is* — it is never how you should have to say it. These are the same table, so the two bars below are the same sound: ```deck deck 1 bpm 100 wave literal 8beffecbbbbaa9888776554444310014 wave additive harmonics 1 0.5 0.33 0.2 track Written id lit gen gameBoyDmg * 2 gen type wave wave_shape literal vol 14 env_mode adsr adsr a 0.02 d 0.06 s 15 r 0.2 note 48 0 1.5 v 105 note 55 1.5 1.5 v 100 note 60 3 1 v 110 track Summed id add gen gameBoyDmg * 2 gen type wave wave_shape additive vol 14 env_mode adsr adsr a 0.02 d 0.06 s 15 r 0.2 note 48 4 1.5 v 105 note 55 5.5 1.5 v 100 note 60 7 1 v 110 ``` `a1` is the fundamental, `a2` the octave above it, `a3` the twelfth, and so on. Only the ratios matter — the table is normalized to fill the 4-bit range either way — so `1 0.5` and `2 1` are the same wave. ## Spelling a table you cannot sum `harmonics` reaches any timbre you would design as a stack of partials, but not every table is one. A curve tuned a nibble at a time — a bell with a slight ring in the second half of the cycle, say — has no additive recipe, and that is what `levels` is for: the same wave RAM the hex form carries, written in decimal. Anything hex can hold, `levels` can hold. `shape` covers the other end, where the table is a plain waveform and naming it beats spelling it. ```deck deck 1 bpm 92 wave clang levels 8 13 15 15 13 12 11 10 9 8 8 7 7 6 4 5 8 10 11 9 8 8 7 7 6 5 4 3 2 0 0 2 track Bell id b1 gen gameBoyDmg * 2 gen type wave wave_shape clang vol 13 env_mode adsr adsr a 0.005 d 0.6 s 2 r 0.6 note 72 0 1 v 108 note 67 1 1 v 96 note 72 2 2 v 112 track Reed id b2 gen gameBoyDmg * 2 gen type pulse duty 12 vol 10 env_mode adsr adsr a 0.02 d 0.1 s 9 r 0.2 note 48 4 1.5 v 100 note 55 5.5 1.5 v 96 note 60 7 1 v 104 ``` Every level is a whole number `0..15`, and there must be exactly 32 of them — a level is a wave RAM nibble, so there is nothing to round on your behalf. `shape` takes `sine`, `square`, `saw`, `triangle` or `pulse`, with `duty` as a percent; `square` is `pulse duty 50`. A narrow pulse on the wave channel is a reed the two pulse channels cannot make, because their duty is fixed to four settings and this one is not: ```deck deck 1 bpm 104 wave thin shape pulse duty 12.5 track Reed id r1 gen gameBoyDmg * 2 gen type wave wave_shape thin vol 12 env_mode adsr adsr a 0.03 d 0.12 s 10 r 0.2 note 55 0 1 v 100 note 60 1 1 v 96 note 62 2 2 v 104 track Bass id r2 gen gameBoyDmg * 2 gen type pulse duty 50 vol 11 env_mode adsr adsr a 0.01 d 0.05 s 12 r 0.15 note 36 0 2 v 104 note 43 2 2 v 100 ``` ## Designing a timbre Timbre is harmonic content, which is what makes `harmonics` easier to aim than 32 digits. The same phrase, four times, on four tables: ```deck deck 1 bpm 88 wave pure harmonics 1 wave hollow harmonics 1 0 0.4 0 0.2 wave bright harmonics 1 0.5 0.33 0.25 0.2 0.16 wave clang harmonics 1 0 0 0.7 0 0 0.45 track Pure id t1 gen gameBoyDmg * 4 gen type wave wave_shape pure vol 13 env_mode adsr adsr a 0.01 d 0.08 s 12 r 0.3 note 60 0 0.75 v 100 note 64 1 0.75 v 96 note 67 2 1.5 v 104 track Hollow id t2 gen gameBoyDmg * 4 gen type wave wave_shape hollow vol 13 env_mode adsr adsr a 0.01 d 0.08 s 12 r 0.3 note 60 4 0.75 v 100 note 64 5 0.75 v 96 note 67 6 1.5 v 104 track Bright id t3 gen gameBoyDmg * 4 gen type wave wave_shape bright vol 12 env_mode adsr adsr a 0.01 d 0.08 s 12 r 0.3 note 60 8 0.75 v 100 note 64 9 0.75 v 96 note 67 10 1.5 v 104 track Clang id t4 gen gameBoyDmg * 4 gen type wave wave_shape clang vol 12 env_mode adsr adsr a 0.005 d 0.5 s 3 r 0.5 note 60 12 0.75 v 100 note 64 13 0.75 v 96 note 67 14 1.5 v 104 ``` Even harmonics left at zero (`hollow`) reads reedy, like a clarinet or a stopped organ pipe. A long tail at roughly 1/n (`bright`) is heading toward a sawtooth, where bowed strings and brass start. Sparse high harmonics with gaps between them (`clang`) ring like a bell — especially with a plucked envelope under them, which is the other half of the job. ## Instrument voices A wavetable sets the timbre; the envelope decides what is playing it. `env_mode adsr` swaps the DMG's hardware envelope — which can only decay toward silence — for a real attack and sustain, and `vib_rate` / `vib_amt` add the vibrato a player's hand does, in cents. Slow attack, held sustain, gentle vibrato — a string section: ```deck deck 1 bpm 72 wave strings harmonics 1 0.5 0.33 0.25 0.2 0.166 0.142 0.125 track Violin id vln gen gameBoyDmg * 4 gen type wave wave_shape strings vol 11 env_mode adsr vib_rate 5.5 vib_amt 24 adsr a 0.35 d 0.25 s 12 r 0.6 mix gain 0.75 pan 0.3 fx reverb_send 0.4 cutoff 3600 note 65 0 3.8 v 88 note 65 4 3.8 v 96 note 65 8 3.8 v 100 note 64 12 3.8 v 84 track Cello id vc gen gameBoyDmg * 4 gen type pulse duty 12.5 vol 9 env_mode adsr adsr a 0.3 d 0.3 s 11 r 0.7 mix gain 0.7 pan -0.3 fx reverb_send 0.3 cutoff 1400 note 50 0 3.8 v 84 note 50 4 3.8 v 90 note 48 8 3.8 v 94 note 48 12 3.8 v 80 ``` Instant attack and a long decay instead, and the same synth is a plucked string: ```deck deck 1 bpm 92 wave nylon harmonics 1 0 0.4 0 0.2 0 0.1 track Guitar id gtr gen gameBoyDmg * 2 gen type wave wave_shape nylon vol 12 env_mode adsr adsr a 0.004 d 0.9 s 3 r 0.5 voice chord minor strum 22 fx reverb_send 0.3 cutoff 4200 note 57 0 3.5 v 84 note 52 4 3.5 v 88 track Bass id bass gen gameBoyDmg * 2 gen type pulse duty 50 vol 10 env_mode adsr adsr a 0.01 d 0.3 s 7 r 0.2 fx cutoff 900 note 45 0 1 v 100 note 45 2 1 v 84 note 40 4 1 v 100 note 40 6 1 v 84 ``` `voice chord minor strum 22` turns each single note into a chord rolled over 22 ms — a strum rather than a block. ## Beyond the chips `gameBoyDmg` and `gbaDirectSound` are hardware emulations, and everything above is bound by what a Game Boy could do. `basicOsc` is not: it is a plain oscillator with an envelope in seconds, for when you want the language without the console. ```deck deck 1 bpm 76 track Pad id pad gen basicOsc * 2 gen waveform sawtooth adsr a 0.6 d 0.4 s 0.55 r 1.2 mix gain 0.5 pan -0.2 fx cutoff 900 res 6 reverb_send 0.55 voice chord min7 note 48 0 4 v 80 note 46 4 4 v 80 track Bell id bell gen basicOsc * 2 gen waveform sine adsr a 0.002 d 1.4 s 0 r 0.8 mix gain 0.4 pan 0.35 fx reverb_send 0.6 note 84 0 1 v 70 note 79 1.5 1 v 60 note 87 4 1 v 70 note 82 5.5 1 v 60 ``` The channel strip — `mix`, and `fx`'s filter, drive and reverb send — is host-side and applies to every generator, so it works the same on a chip voice as it does here. `basicOsc` is the plainest of thirty-three. The rest of the catalog is in [Instruments](../synths/catalog/); the examples below are one from each family. ### Hard sync A sync voice runs two oscillators and lets the first reset the second's phase. `slave_base` is how far above the note the slave starts, in semitones, and `sweep_amt` is how far it sweeps down — that sweep *is* the sound, so it wants tens of semitones rather than a couple. ```deck deck 1 bpm 124 track Lead id lead gen syncLead * 2 gen slave_base 19 sweep_amt 30 sweep_decay 0.25 cutoff 4200 resonance 3 adsr a 0.02 d 0.2 s 13 r 0.3 mix gain 0.5 pan -0.15 fx reverb_send 0.25 note 69 0 1.5 v 104 note 76 1.5 0.5 v 92 note 74 2 2 v 100 note 72 4 1.5 v 98 note 69 5.5 0.5 v 88 note 64 6 2 v 94 track Choir id choir gen syncChoir * 2 gen vowel_shift 12 morph_rate 0.8 morph_amt 6 ensemble_detune 6 vib_rate 6.5 vib_amt 14 highpass 400 adsr a 0.3 d 0.5 s 12 r 0.8 mix gain 0.3 pan 0.1 fx reverb_send 0.5 note 57 0 3.8 v 74 note 55 4 3.8 v 76 ``` ### The analog rack A 303's character is `env_mod` — a filter envelope in Hz, not a 0–1 amount, so it runs in the thousands. The 808's `punch` is also Hz, and its `drive` is 0–1. ```deck deck 1 bpm 128 song_seed 303 track Acid id acid gen acid303 * 2 gen waveform sawtooth cutoff 480 resonance 20 env_mod 5000 decay 0.25 mix gain 0.4 pan 0.15 note 40 0 0.25 v 118 note 52 0.75 0.25 v 110 note 40 1.5 0.25 v 92 note 47 2.25 0.25 v 104 note 38 4 0.25 v 118 note 50 4.75 0.25 v 108 note 45 5.5 0.25 v 96 note 38 6.5 1 v 100 track Sub id sub gen sub808 * 2 gen punch 48 decay 2 drive 0.15 glide 0.08 mix gain 0.5 note 40 0 2 v 120 note 38 4 2.5 v 112 track Kick id kick gen drumSynth * 1 gen tone sine pitch_env 30 pitch_decay 0.035 decay 0.3 noise 0.05 drive 0.2 mix gain 0.55 step_pitch 36 steps x . . . | x . . . | x . . . | x . . . track Clap id clap gen clap * 1 gen hands 2 spread 0.2 size 0.3 tone 0.6 claps 1 gap 0.2 tail 0.25 body 0.15 mix gain 0.3 pan 0.2 step_pitch 60 steps . . . . | x . . . | . . . . | x . . . ``` ### Atmospheric Long attacks, and nothing competing for the same register. ```deck deck 1 bpm 76 track Pad id pad gen pad * 4 gen wave1 triangle wave2 sine detune 14 cutoff 1200 adsr a 0.6 d 0.8 s 11 r 2 mix gain 0.26 pan -0.3 fx reverb_send 0.5 note 52 0 7.6 v 72 note 50 8 7.6 v 74 track Halo id halo gen halo * 4 gen temper 0.5 ring 0.35 mallet 0.6 bloom 1.2 lows 0.3 adsr a 0.02 d 2.5 s 2 r 1.6 mix gain 0.26 pan 0.3 fx reverb_send 0.6 cutoff 6000 note 79 0 2 v 76 note 84 4 2 v 72 note 81 8 2 v 78 note 88 12 3 v 82 track Bell id bell gen bell * 4 gen partial 3.4 highpass 900 decay 2.6 mix gain 0.3 pan -0.3 fx reverb_send 0.65 note 91 6 2 v 62 note 86 14 2 v 58 ``` ### Other consoles The Game Boy is one of six chip emulations. These are five others, in one bar each. ```deck deck 1 bpm 140 track NES id nes gen nes2a03 * 2 gen type pulse duty 25 vol 12 env_mode adsr vib_rate 5.5 vib_amt 22 adsr a 0.002 d 0.12 s 8 r 0.1 mix gain 0.5 pan -0.3 note 76 0 0.5 v 104 note 79 0.5 0.5 v 92 note 83 1 1 v 100 note 76 2 2 v 96 track SID id sid gen c64sid * 2 gen waveform pulse pulse_width 0.28 filter_type lowpass cutoff 2400 resonance 8 adsr a 0.004 d 0.2 s 7 r 0.15 mix gain 0.45 pan 0.3 note 52 0 1 v 96 note 57 2 2 v 100 note 50 4 1 v 94 note 55 6 2 v 98 track FM id fm gen ym2612 * 2 gen algorithm 4 feedback 5 op1_mul 1 op1_tl 22 op2_mul 3 op2_tl 30 op3_mul 2 op3_tl 26 op4_mul 1 op4_tl 12 mix gain 0.4 note 40 0 2 v 106 note 45 4 2 v 100 track SNES id snes gen spc700 * 2 gen waveform triangle echo_enable 1 echo_delay 0.16 echo_feedback 0.3 adsr a 0.01 d 0.4 s 6 r 0.3 mix gain 0.35 pan -0.15 note 64 2 1 v 82 note 67 6 2 v 86 track PSG id psg gen sn76489 * 1 gen type noise noise_mode white noise_freq 3 vol 8 adsr a 0 d 0.05 s 0 r 0.02 mix gain 0.25 pan 0.15 steps x . . x | . . x . | x . . x | . x . . ``` ### Acoustic models `voice` on `arco` names an instrument — `violin`, `cello`, `fiddle`, `bass` — rather than taking a number. ```deck deck 1 bpm 88 track Cello id vc gen arco * 4 gen voice cello pressure 0.6 bow 0.5 vibrato 0.5 rosin 0.2 body 0.75 adsr a 0.35 d 0.3 s 12 r 0.7 mix gain 0.32 fx cutoff 1600 reverb_send 0.35 note 50 0 3.8 v 88 note 46 4 3.8 v 84 note 53 8 3.8 v 86 note 48 12 3.8 v 82 track Rhodes id tine gen tine * 4 gen bark 0.5 tine 0.65 tremolo 0.3 decay 1.1 drive 0.2 adsr a 0.003 d 0.9 s 3 r 0.5 mix gain 0.26 pan 0.3 fx reverb_send 0.35 cutoff 4600 note 62 1.5 1.2 v 74 note 65 1.5 1.2 v 70 note 58 5.5 1.2 v 74 note 62 5.5 1.2 v 70 note 65 9.5 1.2 v 74 note 69 9.5 1.2 v 70 note 60 13.5 1.2 v 74 note 64 13.5 1.2 v 70 track Nylon id gtr gen guitar * 4 gen tone 0.6 decay 1.4 damping 0.35 drive 0.15 body 0.7 mute 0 mix gain 0.3 pan -0.2 fx reverb_send 0.3 note 69 0 1 v 86 note 74 1 0.75 v 80 note 77 4 1.5 v 88 note 72 8 1 v 84 note 77 10 1.5 v 90 note 76 12 1.5 v 86 note 74 14 1.8 v 82 ``` ### An operator graph `matrixFm` is the one voice whose patch does not fit on a `gen` line, so it takes a `gen_block`: operators, the modulation between them, a filter, and how they route to the output. This is the factory *Supersaw Stack* — three detuned saws cross-modulated into a wide lowpass. ```deck deck 1 bpm 128 track Saws id saw gen matrixFm * 2 gen_block matrix_fm op 1 wave saw ratio 1 op 2 wave saw ratio 1.008 op 3 wave saw ratio 2 env op 1 a 0.006 d 0.22 s 0.72 r 0.32 env op 2 a 0.006 d 0.22 s 0.72 r 0.32 env op 3 a 0.006 d 0.22 s 0.68 r 0.32 mod fm 2 1 0.45 mod fm 3 1 0.28 filter 1 type lp24 cutoff 6800 res 0.32 route op 1 filter 1 0.38 route op 2 filter 1 0.36 route op 3 filter 1 0.34 route filter 1 out 1 end gen_block mix gain 0.34 pan -0.1 fx reverb_send 0.3 note 65 0.5 0.4 v 104 note 68 0.5 0.4 v 98 note 72 0.5 0.4 v 96 note 65 1.5 0.4 v 96 note 68 1.5 0.4 v 90 note 61 4.5 0.4 v 104 note 65 4.5 0.4 v 98 note 68 4.5 0.4 v 96 track Bass id bass gen reeseBass * 2 gen voices 2 detune 15 cutoff 800 wobble 0 decay 1.2 mix gain 0.4 note 41 0 1.5 v 112 note 39 4 1.5 v 108 ``` ### Noise and metal `noiseBurst` is filtered noise with an envelope — a hat, a shaker, a rim. `cymbal`'s `tune` is the frequency of its inharmonic bank in Hz, not a note, so it sits in the hundreds. ```deck deck 1 bpm 132 track Hat id hat gen noiseBurst * 1 gen attack 0.002 decay 0.07 tone 0.45 pitch_follow 0.25 mix gain 0.3 pan 0.1 step_pitch 70 steps x . x . | x . x . | x . x . | x . x x track Crash id crash gen cymbal * 4 gen tune 320 metallic 0.85 decay 1.6 highpass 6000 mix gain 0.22 pan -0.2 fx reverb_send 0.4 note 72 0 2 v 96 track Ride id ride gen cymbal * 1 gen tune 480 metallic 0.6 decay 0.35 highpass 9000 mix gain 0.16 pan 0.25 step_pitch 76 steps x . . x | . . x . | x . . x | . . x . track Kick id kick gen drumSynth * 1 gen tone sine pitch_env 30 pitch_decay 0.035 decay 0.3 drive 0.2 mix gain 0.5 step_pitch 36 steps x . . . | x . . . | x . . . | x . x . ``` ### Two-operator FM `fmTone` is one modulator on one carrier. `ratio` is the modulator's frequency relative to the note and `mod_index` is how hard it pushes — low ratios and a low index give warmth, high ones give bells and clangs. ```deck deck 1 bpm 96 track Keys id keys gen fmTone * 4 gen ratio 2 mod_index 3 carrier_wave sine mod_wave sine adsr a 0.005 d 0.5 s 5 r 0.4 mix gain 0.32 pan -0.15 fx reverb_send 0.3 note 60 0 1 v 88 note 64 0 1 v 82 note 67 0 1 v 80 note 58 4 1 v 88 note 62 4 1 v 82 note 65 4 1 v 80 track Clang id clang gen fmTone * 4 gen ratio 7.03 mod_index 8 carrier_wave sine mod_wave triangle adsr a 0.002 d 1.6 s 1 r 1.2 mix gain 0.2 pan 0.3 fx reverb_send 0.55 cutoff 7000 note 84 2 2 v 70 note 79 10 2 v 66 ``` ### Drift `aether` glides between whatever it is given and swells rather than striking. Long notes and a slow tempo are the point. ```deck deck 1 bpm 64 track Air id air gen aether * 4 gen glide 0.4 waver 0.5 tone 0.3 swell 0.45 air 0.25 mix gain 0.34 pan -0.2 fx reverb_send 0.6 note 64 0 6 v 74 note 67 6 6 v 70 note 71 12 4 v 76 track Low id low gen aether * 4 gen glide 0.7 waver 0.3 tone 0.15 swell 0.6 air 0.1 mix gain 0.3 pan 0.2 fx reverb_send 0.5 cutoff 2200 note 45 0 8 v 66 note 43 8 8 v 68 ``` ### Vowels `formantVocal` shapes a voice with the three formants of a vowel, and takes the vowel from the note's lyric — `l A` for *father*, `l I` for *see*, `l U` for *who*. Thirteen are defined: `I`, `IH`, `EY`, `E`, `AE`, `A`, `O`, `OH`, `OO`, `U`, `UH`, `ER`, `UX`. ```deck deck 1 bpm 84 track Voice id vox gen formantVocal * 4 gen glide 0.1 vib_depth 0.02 vib_rate 5 humanize 0.5 release 0.2 mix gain 0.36 fx reverb_send 0.4 note 64 0 1.5 v 88 l A note 67 1.5 1.5 v 84 l EY note 69 3 1 v 86 l I note 67 4 2 v 82 l OH note 62 6 2 v 80 l U note 64 8 3 v 86 l A note 60 12 4 v 78 l ER track Under id und gen pad * 4 gen wave1 triangle wave2 sine detune 10 cutoff 900 adsr a 0.8 d 0.6 s 10 r 1.6 mix gain 0.2 pan -0.25 fx reverb_send 0.45 note 45 0 7.6 v 64 note 43 8 7.6 v 66 ``` ### One chip, generically `chiptune` is the console-agnostic chip voice: a pulse with adjustable width, optional PWM, an optional arpeggio, and bitcrush and lowpass for grit. Use it when you want the character without committing to a particular machine's quirks. ```deck deck 1 bpm 150 track Lead id lead gen chiptune * 2 gen waveform pulse pulse_width 0.25 pwm_speed 1.5 bitcrush 0 lowpass 0 adsr a 0.005 d 0.25 s 7 r 0.08 mix gain 0.4 pan -0.2 note 72 0 0.5 v 100 note 76 0.5 0.5 v 94 note 79 1 1 v 98 note 77 2 0.5 v 92 note 74 2.5 1.5 v 96 note 72 4 2 v 100 note 67 6 2 v 92 track Arp id arp gen chiptune * 2 gen waveform pulse pulse_width 0.5 arp_rate 16 arp_semis 12 bitcrush 6 adsr a 0.002 d 0.1 s 6 r 0.05 mix gain 0.26 pan 0.25 note 48 0 4 v 84 note 46 4 4 v 84 ``` ### The rest of the sync family `syncLead` sweeps once per note. `obSync` sweeps continuously at `sweep_rate` for a slow pulsing pad, and `laserSync` drops instead of sweeping — `drop_amt` semitones at `drop_rate`. ```deck deck 1 bpm 118 track Sweep id ob gen obSync * 4 gen detune 15 sweep_rate 0.5 sweep_amt 24 cutoff 1200 resonance 2 filter_env 2400 filter_decay 0.8 adsr a 0.1 d 0.4 s 10 r 0.5 mix gain 0.32 pan -0.2 fx reverb_send 0.35 note 52 0 7.6 v 84 note 50 8 7.6 v 86 track Zap id zap gen laserSync * 2 gen drop_rate 0.8 drop_amt 36 slave_base 18 adsr a 0.01 d 0.3 s 2 r 0.2 mix gain 0.3 pan 0.3 note 84 1 0.5 v 104 note 84 5 0.5 v 100 note 88 9 0.5 v 106 note 81 13 0.5 v 98 ``` ### Building a voice out of parts `patch` has no fixed architecture. Its `gen_block` names oscillators, noise, filters, shapers and gains, wires them with `conn`, and drives any parameter with a breakpoint `env`. It is how you write a voice the catalog does not have. ```deck deck 1 bpm 110 track Pluck id pl gen patch * 2 gen_block patch osc o1 sawtooth note osc o2 sawtooth note detune 9 filter f1 lowpass q 6 freq 2400 gain a1 0 conn o1 f1 0.6 conn o2 f1 0.5 conn f1 a1 1 conn a1 out 1 env a1.gain set 0 0 lin 0.004 0.9 exp 0.35 0.001 env f1.frequency set 0 3800 exp 0.3 700 end gen_block mix gain 0.36 pan -0.1 fx reverb_send 0.3 note 57 0 0.5 v 100 note 64 0.5 0.5 v 92 note 69 1 0.5 v 96 note 64 1.5 0.5 v 88 note 55 4 0.5 v 100 note 62 4.5 0.5 v 92 note 67 5 1 v 96 track Hat id ph gen patch * 1 gen_block patch noise n filter f highpass freq 8000 gain a 0 conn n f 1 conn f a 1 conn a out 1 env a.gain set 0 0.25 exp 0.05 0.001 end gen_block mix gain 0.22 pan 0.2 step_pitch 70 steps x . x . | x . x . | x . x . | x . x x ``` ### Speech Two voices sing words rather than vowels, and both need something from the host that the other thirty-one do not. - `ttsVocal` drives the browser's own speech synthesiser through the Web Speech API. It needs a live browser with a voice installed. - `meSpeakVocal` uses the meSpeak engine and needs its worker and voice data served by the host. Because both reach outside the audio graph, neither appears in an offline render — the command-line renderer in [Rendering to audio](RENDERING.md) will produce silence for them. They are written the same way as `formantVocal`, with the lyric carrying a word instead of a vowel: ```deck deck 1 bpm 90 track Words id w gen ttsVocal * 4 gen glide 0.1 mix gain 0.4 note 60 0 1 v 90 l hello note 64 1 1 v 88 l there note 62 2 2 v 86 l friend ``` `meSpeakVocal` takes the same shape, and adds a `voice` naming the meSpeak voice the host has loaded: ```deck deck 1 bpm 90 track Chant id ms gen meSpeakVocal * 4 gen voice en pitch 50 speed 160 mix gain 0.4 fx reverb_send 0.3 note 57 0 1.5 v 92 l one note 60 1.5 1.5 v 88 l two note 64 3 2 v 90 l three ``` ## Mixing and effects `mix` places a track and sets its level; `fx` shapes it. Cutoff and resonance are a filter sweep's worth of character on their own, and `reverb_send` is what puts several tracks in one room. ```deck deck 1 bpm 104 track Wide id wide gen gameBoyDmg * 2 gen type pulse duty 25 vol 12 mix gain 0.8 pan -0.6 eq_hi 3 fx cutoff 2600 res 4 reverb_send 0.25 note 72 0 0.5 v 100 note 76 1 0.5 v 92 note 79 2 0.5 v 100 note 76 3 0.5 v 88 track Narrow id narrow gen gameBoyDmg * 2 gen type pulse duty 50 vol 9 mix gain 0.6 pan 0.6 eq_lo -4 fx cutoff 1500 drive 0.4 reverb_send 0.5 note 60 4 0.5 v 90 note 64 5 0.5 v 84 note 67 6 0.5 v 90 note 64 7 0.5 v 80 track Kick id kick gen gbaDirectSound * 2 gen waveform triangle pitch_drop -14 adsr a 0 d 0.08 s 0 r 0 mix gain 0.9 step_pitch 36 steps x . . . x . . . x . . . x . . . ``` ## A whole song Three voices, four bars, with the DMG's two pulse channels carrying the melody and harmony over the wave-channel bass. ```deck deck 1 bpm 140 track Lead id lead gen gameBoyDmg * 4 gen type pulse duty 25 vol 11 note 76 0 0.5 v 112 note 79 0.5 0.5 v 100 note 83 1 1 v 118 note 79 2 0.5 v 95 note 76 2.5 0.5 v 100 note 74 3 1 v 105 track Harm id harm gen gameBoyDmg * 4 gen type pulse duty 50 vol 7 note 67 0 1 v 80 bar even note 71 1 1 v 78 bar even note 69 0 1 v 80 bar 1 note 72 1 1 v 78 bar 1 track Bass id bass gen gameBoyDmg * 4 gen type wave wave_shape saw vol 15 note 40 0 1 v 120 note 40 1 1 v 100 note 47 2 1 v 115 note 45 3 1 v 105 track Kick id kick gen gbaDirectSound * 4 gen waveform triangle pitch_drop -14 adsr a 0 d 0.07 s 0 r 0 step_pitch 36 steps x . . . x . . x x . . . x . x . ``` --- # Rendering to audio Source: docs/RENDERING.md A `.deck` file can be rendered to a `.wav` from the command line, without opening the site or pressing play on anything. ```bash node scripts/render-wav.mjs song.deck -o song.wav ``` ``` song.wav 5.87s 2 tracks peak -2.0 dBFS rms -8.1 dBFS ``` ## Why it needs a browser The voices are Web Audio. They are built from `OscillatorNode`, `BiquadFilterNode`, `WaveShaperNode`, `DelayNode` and — for the sync oscillators — an `AudioWorklet`. Node has none of these, so there is no pure-Node path from a song to a buffer, and reimplementing the voices for a second runtime is exactly the duplication this repo spent its effort removing. So the renderer drives a headless Chrome instead. It serves `packages/player/dist`, calls the player's own [`renderDeckToBuffer()`](#rendering-from-your-own-code) inside an `OfflineAudioContext`, and copies the samples back out. Nothing is recorded from a sound device: an `OfflineAudioContext` computes the buffer as fast as it can, which is many times quicker than real time, and it is deterministic — the same song renders to the same samples every run. You need Chrome or Chromium installed. The script looks in the usual places; set `CHROME` to override. ## Options | flag | meaning | | --- | --- | | `-o`, `--out ` | output WAV — required | | `--beats ` | how many beats to render. Defaults to the song's own length | | `--gain ` | master gain, `0`–`1`. Default `0.9` | | `--sample-rate ` | default `44100` | | `--no-reverb` | bypass the reverb send | | `--normalize` | scale the result to peak at −1 dBFS | `--beats` is how you render a slice: a two-bar audition of a long song, or one loop of a piece whose `totalBeats` is unset. ```bash node scripts/render-wav.mjs song.deck -o loop.wav --beats 32 --normalize ``` ## Reading the output line `peak` and `rms` are reported so a bad render is visible without opening the file. - **peak at 0.0 dBFS** means it is clipping — lower `--gain`. - **rms below about −30 dBFS** on a dense arrangement usually means most of it never triggered. - **A silent render** (`peak -inf`) means nothing played at all: check the song has notes on the beats you asked for. If any track names a generator with no voice behind it, the substitution is listed: ``` substituted (no voice for these ids): someGeneratorId ``` An empty list is the thing to want — it means every track is being played by its real voice rather than standing in as a plain oscillator. ## Rendering from your own code The CLI is a thin wrapper. In a browser, the player exports the same call: ```js import { renderDeckToBuffer } from '@spacedevin/deck-player' const buffer = await renderDeckToBuffer(source, { beats: 32, sampleRate: 44100, gain: 0.9, reverb: true, }) ``` It returns a rendered `AudioBuffer`, which you can encode, analyse, or play back. ### Sync voices need their processor registered The sync family — `syncLead`, `syncChoir`, `obSync`, `laserSync` — is built on an `AudioWorklet`. A worklet module registers **asynchronously**, and a voice whose processor is not yet registered falls back to a plain oscillator rather than failing, so the symptom is a render that sounds thin instead of one that errors. `renderDeckToBuffer()` handles this: it waits for the module before scheduling a single note. If you are driving `buildAudioGraph()` and `playStep()` yourself, register it first and await it: ```js import { ensureSyncWorklet, buildAudioGraph, playStep } from '@spacedevin/deck-player' await ensureSyncWorklet(ctx) // null when the context has no worklet support const graph = buildAudioGraph(ctx, song, { gain: 0.9 }) ``` `buildAudioGraph()` also kicks registration off on its own, which is enough for live playback — there, the module lands well inside the gap between the user pressing play and the first note. It is not enough for an offline render, which gets no such gap. ## Rendering every example on this site Each fenced block in [Examples](EXAMPLES.md) is a complete song. To render them all: ```bash node -e ' const { readFileSync, writeFileSync, mkdirSync } = require("fs") const md = readFileSync("docs/EXAMPLES.md", "utf8") mkdirSync("out/examples", { recursive: true }) ;[...md.matchAll(/```deck\n([\s\S]*?)```/g)].forEach((m, i) => writeFileSync(`out/examples/${String(i + 1).padStart(2, "0")}.deck`, m[1])) ' for f in out/examples/*.deck; do node scripts/render-wav.mjs "$f" -o "${f%.deck}.wav"; done ``` --- # AST shape Source: docs/AST.md What `parseProgram` and `parseTrackBody` return. This is the contract a host codes against. Two rules run through all of it: - **Never throws.** Malformed lines accumulate in `errors[]` and parsing continues, because a streaming host has to be able to parse a partial program. - **Parse-only.** An absent optional is `null`, never a default, and nothing is clamped or range-checked. Defaults and ranges are host policy and hosts genuinely differ — one clamps an out-of-range lock, another rejects it — and a check like "does this note fit inside `* N`?" needs track context the line doesn't have. See [HOST.md](HOST.md). So `null` means *the source didn't say*, which a host can distinguish from *the source said the default*. Don't collapse the two. ## `parseProgram(source)` One flat object. Every field below is always present. | Field | Shape | |-------|-------| | `tplVersion` | number — `deck 1` / `tpl 1` | | `bpm`, `swing`, `launchQuant`, `songSeed` | number or `null` | | `mainDeck` | `"live"` \| `"local"` \| `null` | | `scaleRoot`, `scaleMode` | pitch class `0..11` (`-1` = scale off) + mode name, or `null` | | `xfade` | `{ x, y }` or `null` | | `deckMix` | `{ A\|B\|C\|D: { hi?, mid?, lo?, flt?, vol? } }` or `null` | | `tracks[]` | see [Track](#track) | | `clipBlocks[]` | `{ clipId, channelId, bars, displayName, body[] }` | | `removeTrackIds[]` | channel ids from `remove_track` | | `macros` | `{ [name]: { params: { k: number\|string }, body: string[] } }` | | `autos[]` | `{ lineNo, header: string[], points: [{ beat, value }] }` | | `masterMixTokens` | `string[]` or `null` — raw, host-interpreted | | `actorMixRows[]` | `{ lineNo, lane, tokens: string[] }` — raw, host-interpreted | | `sessionSceneCount` | int or `null` | | `sessionSlots[]` | `{ channelId, scene, clipId }` | | `song` | `null` or `[{ scene, repeat }]` | | `follow` | `null` or `[{ scene, a, wa, b, wb }]` | | `directives[]` | `{ lineNo, verb, tokens: string[] }` — every `@ …` line | | `hostStatements` | `{ [head]: [{ lineNo, value }] }` from `registerTopLevelStatement` | | `errors[]` | `{ line, msg }` — 1-based line numbers | ### Track ```js { name: "MOS 6581", // may be multi-word; anchored on the id/gen keyword pair id: "c9", generatorId: "fm", // normalizeGeneratorId(raw) — identity until a host registers aliases rawGenId: "fm", // exactly what the source wrote genParams: {}, // trailing `k v` pairs on the header (macro overrides), numbers coerced loopBars: 2, // `* N`; null for `* inf` or unset body: [{ lineNo, tokens, raw }], genBlocks: [{ generatorId, lines: string[] }] } ``` **`body[]` rows are raw token rows.** `parseProgram` does not interpret them — call `parseTrackBody(track.body)` for typed rows. `lineNo` is 1-based throughout. A `gen_block` is only collected inside a `track` body. Inside a `clip` body the clip branch matches first, so such a line stays an ordinary body row. ## `parseTrackBody(bodyRows)` Returns `{ rows, errors }`. Every row carries `kind` and `lineNo`; `kind: "error"` rows are split out into `errors[]` instead. | `kind` | Fields | |--------|--------| | `mix` | `gain`, `pan`, `mute`, `solo`, `eqLo`, `eqMid`, `eqHi` — absent = `null`, boolish → `true`/`false` | | `steps` | `mode: "literal" \| "euclid"`, `on: boolean[]`, plus `hits`/`len` when euclid | | `stepLane` | `lane: "vel" \| "prob" \| "ratchet" \| "nudge" \| "lyric"`, `values: (number \| null)[]` | | `stepPitch` | `midi`, `bar` | | `note` | `midi`, `startBeat`, `durBeats`, `vel`, `prob`, `ratchet`, `nudge`, `bar`, `lyric` | | `notesClear` | — | | `transpose` | `semitones` | | `loops` | `cap` — `null` means `loops inf` | | `gen`, `fx`, `voice` | `params: { camelKey: number \| string }` | | `adsr` | `a`, `d`, `s`, `r` | | `deckRoute` | `lane: "A".."D" \| "live" \| null`, `slot` | | `unknown` | `head`, `tokens` — a head nothing claimed; **not an error**, a dialect may still take it | Real rows: ```js { kind: "note", midi: 61, startBeat: 0, durBeats: 1, vel: 100, prob: null, ratchet: null, nudge: null, bar: null, lyric: null, lineNo: 6 } { kind: "steps", mode: "literal", on: [true, false, false, false, true, false, false, false, …], lineNo: 3 } { kind: "stepLane", lane: "vel", values: [120, 100, 100, 100, 70, 100, …], lineNo: 4 } { kind: "gen", params: { waveShape: "saw", vol: 15, pitchDrop: -12 }, lineNo: 7 } { kind: "adsr", a: 0, d: 0.1, s: 0.5, r: 0.03, lineNo: 8 } { kind: "deckRoute", lane: "A", slot: 2, lineNo: 6 } ``` Two things the parser does for you: - **Euclid is already expanded.** `steps euclid 5 16` arrives as the same `on: boolean[]` grid a literal line produces, with `hits` and `len` alongside. - **Wavetables are already resolved.** `wave x harmonics 1 0.5`, `wave x levels 8 9 …` and `wave x shape square` all arrive as the same `levels` (32 numbers, 0..15) a hex literal produces, with `mode` (`"harmonics"` / `"levels"` / `"shape"` / `"hex"`) and the source `harmonics` / `hex` / `shape` + `duty` alongside. `mode` is a plain string, so treat it as open rather than exhaustive. - **Param keys are camelCased and aliased.** `wave_shape` → `waveShape`, `reverb` → `reverbSend`, `type` → `filterType` on `fx`. Extend with `registerParamKeyAliases`. ## Bar selector The value of `bar` on a `note` or `stepPitch`; `null` means every bar. Evaluate with `barSelectorMatches(sel, bar)` — bars are 0-indexed within `* N`. | Source | Shape | |--------|-------| | `all` | `{ kind: "all" }` | | `2` | `{ kind: "eq", n: 2 }` | | `-n+2` | `{ kind: "first", b: 2 }` | | `0,2,3` | `{ kind: "list", list: [0, 2, 3] }` | | `even` / `2n+1` | `{ kind: "mod", a: 2, b: 0 }` | ## gen_block With no dialect registered, `parseGenBlock(id, lines)` returns the lines verbatim: ```js { kind: "patch", tplHeaderId: "patch", version: 1, raw: ["osc o1 sawtooth note", "filter f1 lowpass q 4 freq 1800", "conn f1 out 1"] } ``` Register a dialect to parse them into a graph — see [DECK_EXTENSION.md](DECK_EXTENSION.md). ## Typed mirrors - **Rust** — `deckfile::parse(src)` returns typed structs. `rust/facade.rs` is the only hand-written Rust in the crate and enumerates every variant above; it is the most precise statement of this shape in the repo. - **TypeScript** — the language package ships no declarations. `@spacedevin/deck-player` has hand-written types for its own Song IR, which is a *host* shape (defaults applied, values clamped), not this one. The [conformance corpus](https://github.com/spacedevin/deck/tree/main/conformance) stores the whole observable parse of each case as JSON, so it doubles as a worked example of every shape here — and is what stops the JS, Rust and Tish targets from drifting apart. --- # Host extensions Source: docs/DECK_EXTENSION.md Three registries let a host add vocabulary **without forking the grammar**. That matters: the one implementation that had no such hook (tish-gba, which needed a top-level `wave` statement and a `layer` body key) ended up a separate grammar rather than a subset of this one. `wave` has since been adopted into the language itself — every host wanted it, which is the signal that a statement is not an extension. Its `shape` names arrived the same way: they lived as a per-host fallback for `wave_shape`, drifted apart (unknown names resolved to a sine in one host and a saw in another), and are now resolved in the parser so there is one answer. | Extension point | Adds | API | |-----------------|------|-----| | Top-level statement | `cue ` | `registerTopLevelStatement(head, fn)` → `ast.hostStatements[head][]` | | Track / clip body head | `layer 2` | `registerBodyLineDialect(heads, fn)` → the row `parseBodyLine` returns | | `gen_block` dialect | `patch`, `matrix_fm` | `registerGenBlockDialect(ids, fn)` | ```tish import { registerTopLevelStatement, registerBodyLineDialect } from "@spacedevin/deck" // A cue point the host jumps to. Core statements are matched first, so a host can add vocabulary // but never shadow the language — registering `wave` here would simply be ignored. registerTopLevelStatement("cue", (head, toks) => ({ name: toks[1], beat: Number(toks[2]) })) // tish-gba: `layer|intensity|min_intensity <0..3>` — one head set, one parser registerBodyLineDialect(["layer", "intensity", "min_intensity"], (head, toks) => { return { kind: "layer", level: Math.floor(Number(toks[1])) } }) ``` Registered heads are checked **after** the built-in ones in both cases, so a host can extend the language but never silently shadow it. `clearBodyLineDialects` and `clearTopLevelStatements` reset both (tests). --- # gen_block extensions Core language collects: ``` gen_block … end gen_block ``` into `raw: string[]` on the track AST. Dialects interpret those lines. ## Registration API (`@spacedevin/deck`) ```tish import { registerGenBlockDialect, parseGenBlock, clearGenBlockDialects } from "@spacedevin/deck" registerGenBlockDialect(["patch", "modular"], (generatorId, lines) => { return { kind: "patch", tplHeaderId: generatorId, version: 1, raw: lines, graph: parsePatchGraph(lines) } }) registerGenBlockDialect(["matrixFm", "matrix_fm"], (generatorId, lines) => { return { kind: "matrixFm", tplHeaderId: generatorId, version: 2, raw: lines, graph: parseMatrixFmGraph(lines) } }) ``` Until registered, `parseGenBlock` returns `{ kind, tplHeaderId, version: 1, raw }` with `kind` = normalized id (or raw id if no alias). Also useful: - `registerGeneratorIdAliases(forward, emit)` — deck spelling ↔ host id - `registerHighlightKeywords({ body: [...] })` — dialect keywords for `classifyLine` - `registerBuiltinMacros(map)` — named patch templates Hosts own the parse/serialize implementations (Deckard: `PatchGraph.tish`, `MatrixFmGraph.tish`). --- ## Common dialect: `patch` Modular graph voice. Typical nodes: | Line | Meaning | |------|---------| | `osc [wave] [note\|] [ratio R] [detune cents] [rand N] [gain G]` | Oscillator / LFO | | `syncosc [note\|] [ratio R] [detune] [rand] [gain]` | Hard-sync style osc | | `noise [gain G]` | Noise source | | `string [note] [tone T] [damping D] [decay P] [mute M] [gain G]` | Karplus–Strong | | `filter [type] [q Q] [freq Hz] [gain G]` | Biquad | | `shaper [amount\|drive A] [curve name] [gain G]` | Waveshaper | | `pan [pos] [gain G]` | Stereo pan (−1…+1) | | `gain [value]` | Gain node | | `conn [vol]` | Wire (`out`, `reverb` are special sinks) | | `env …` | Breakpoint envelope; seg = `set\|lin\|exp ` | | `dur ` | Fixed voice duration | Filter type aliases commonly accepted: `lp`/`lowpass`, `hp`/`highpass`, `bp`/`bandpass`, `notch`, `lowshelf`, `highshelf`, `peaking`/`peak`, `allpass`. Envelope time/value expressions (host evaluate per trigger): numbers, `note`, `dur`, `vel`, products/sums (`note*2`, `dur-0.1`), `max(a,b)` / `min(a,b)`. --- ## Common dialect: `matrix_fm` Operator / modulation matrix: | Line | Meaning | |------|---------| | `op wave [ratio R]` | Operator (`wave noise` omits ratio) | | `env op [a] [d] [s] [r]` | Op ADSR | | `mod ` | Modulation | | `filter type cutoff res ` | Filter (`lp12`, `lp24`, …) | | `env filter [a] [d] [s] [r] [amount Hz]` | Filter env | | `route filter ` | Route into filter | | `route out ` | Route to output | Wave emit spellings are host-defined (e.g. `sawtooth` → `saw`, `triangle` → `tri`). --- ## Highlight Core keywords live in `Highlight.tish`. Dialects should register extras: ```tish registerHighlightKeywords({ body: ["osc", "noise", "string", "syncosc", "filter", "shaper", "gain", "pan", "conn", "env", "dur", "op", "mod", "route"] }) ``` --- # Hosting @spacedevin/deck Source: docs/HOST.md The package parses and classifies `.deck` text. Hosts map the AST into their own IR, register catalogs, and own audio. ## Boot order Call these once at app/test start (idempotent if you gate with a `registered` flag): 1. **`registerGeneratorIdAliases(forward, emit)`** — deck snake_case ↔ host camelCase ids 2. **`registerParamKeyAliases(map)`** — optional extra snake → camel for `gen` keys 3. **`registerGenBlockDialect(ids, parseFn)`** — e.g. `patch`, `matrix_fm` 4. **`registerBodyLineDialect(heads, parseFn)`** — extra track/clip body heads (optional) 5. **`registerTopLevelStatement(head, parseFn)`** — extra top-level statements (optional) 6. **`registerHighlightKeywords({ body: [...] })`** — dialect keywords for `classifyLine` 7. **`registerBuiltinMacros(map)`** — named patch templates (optional) ```tish import { parseProgram, registerGeneratorIdAliases, registerGenBlockDialect, registerHighlightKeywords, registerBuiltinMacros } from "@spacedevin/deck" registerGeneratorIdAliases( { matrix_fm: "matrixFm", noise_burst: "noiseBurst" }, { matrixFm: "matrix_fm", noiseBurst: "noise_burst" } ) registerGenBlockDialect(["patch", "modular"], (generatorId, lines) => { return { kind: "patch", tplHeaderId: generatorId, version: 1, raw: lines, graph: parsePatchGraph(lines) // host-owned } }) registerHighlightKeywords({ body: ["osc", "conn", "env", "op", "mod", "route"] }) registerBuiltinMacros({ /* name → { params, bodyLines } */ }) let ast = parseProgram(source) // host: applyAst(ast) → project IR ``` ## Clear helpers (tests) - `clearGeneratorIdAliases` - `clearParamKeyAliases` - `clearGenBlockDialects` - `clearBodyLineDialects` - `clearTopLevelStatements` - `clearBuiltinMacros` ## What the host must implement | Concern | Typical modules | |---------|-----------------| | Body rows → project IR (clamps, defaults, range checks) | apply | | AST → project | apply / merge | | project → `.deck` text | emit | | Incremental line stream | buffer + re-apply block | | `patch` / `matrix_fm` body lines | graph parse + serialize | | Macro catalog content | register into package | | Instrument / generator defaults | host registry | | Skills / ownership | co-DJ layer | | Highlight CSS | map `classifyLine` classes to themes | ## AST shape `parseProgram` returns one flat object; `parseTrackBody` turns a track's raw body rows into typed ones. Both are documented in full in **[AST.md](AST.md)** — the top-level fields, every body-row `kind`, bar selectors and gen blocks. The two rules that shape everything a host does with it: the parser **never throws** (errors accumulate in `errors[]` so a partial stream still parses), and it is **parse-only** — an absent optional is `null` rather than a default, and nothing is clamped. Applying defaults and ranges is your job, which is what the table above means by "clamps, defaults, range checks". **Control directives.** `@ launch`, `@ transport`, `@ cue`, `@ throw`, `@ fx`, `@ deck`, `@ perf_step` are transient stream lines, not document state, so the parser collects them into `directives[]` verbatim and leaves the meaning to the host. They never produce errors. ## Package docs - [DECK_GRAMMAR.md](DECK_GRAMMAR.md) — language - [AST.md](AST.md) — what the parser returns - [DECK_EXTENSION.md](DECK_EXTENSION.md) — dialects - npm exports: `@spacedevin/deck/grammar`, `@spacedevin/deck/extension` --- # Instruments Source: packages/synths/README.md The instrument catalog for the [`.deck`](https://github.com/spacedevin/deck) language — 33 voices covering chip emulations, FM, drums, hard sync, bowed and plucked models, and vocals. These are the voices [Deckard](https://deckard.lol) plays through. They live here so a second host — a docs-site player, a renderer, a demo — can make the same sounds without a second copy of the code. ```bash npm install @spacedevin/deck-synths ``` ```js import { dispatchPlayNote, ensureDeckGeneratorIds, ensureSyncWorklet } from '@spacedevin/deck-synths' // Teach the language this catalog's generator ids, param aliases and gen_block dialects. // Do this before parsing, or `gen sweep_amt` never reaches the voice as `sweepAmt`. ensureDeckGeneratorIds() await ensureSyncWorklet(ctx) // only needed if a sync voice is used dispatchPlayNote(ctx, bus, t, midi, vel, durSec, channel, bendSemis) ``` ## The contract A voice is a pure function that builds a short-lived Web Audio subgraph and connects its last node to `bus.input`: ``` play(ctx, bus, t, midi, vel, durSec, ch, bendSemis) ``` `dispatchPlayNote` picks one by `ch.generatorId`; an unknown id falls back to `basicOsc`. Patch and envelope come from `ch.generatorParams` — the ADSR lives there, not on the channel root. ## The voices Every id below has a complete, playable song in [Examples](../../docs/EXAMPLES.md). | Family | Generator ids | |---|---| | Chip emulations | `gameBoyDmg` `gbaDirectSound` `nes2a03` `c64sid` `ym2612` `sn76489` `spc700` `chiptune` | | FM and patches | `fmTone` `matrixFm` `patch` `tine` `bell` | | Basic and bass | `basicOsc` `acid303` `sub808` `reeseBass` `pad` | | Drums and hits | `drumSynth` `noiseBurst` `clap` `cymbal` | | Hard sync | `syncLead` `syncChoir` `obSync` `laserSync` | | Bowed, plucked, struck | `arco` `guitar` `halo` `aether` | | Vocal | `formantVocal` `ttsVocal` `meSpeakVocal` | `generatorCatalog()` returns the same list with a label and description per voice, and the default `generatorParams` each one expects. ## Assets `ensureSyncWorklet` registers the hard-sync oscillator from an inlined Blob URL, so `syncLead`, `syncChoir`, `obSync` and `laserSync` need nothing copied into your public directory. `meSpeakVocal` is the exception: it needs a worker and voice data the host serves. The defaults are `/mespeak-worker.js` and `/mespeak`; call `configureMeSpeak({ workerUrl, assetsBaseUrl })` if yours differ. `ttsVocal` needs the Web Speech API. ## Adding a voice One `.tish` file in `src/` exporting a `play` function with the signature above, an entry in `src/Registry.tish` (id, label, description, default params) and `src/Dispatch.tish`, any param aliases or gen_block dialect in `src/DeckIds.tish`, and a song in [docs/EXAMPLES.md](../../docs/EXAMPLES.md) so the example test plays it. The full recipe is in [CONTRIBUTING.md](../../CONTRIBUTING.md). ## Known limits - Two hosts that each bundle their own copy of `@spacedevin/deck` end up with two dialect registries, and a dialect registered against one is invisible to the other. Deduplicate the language package so there is a single instance. --- # Catalog Source: packages/synths/CATALOG.md Every voice in `@spacedevin/deck-synths`, generated from `generatorCatalog()` and `defaultParamsForGeneratorId()` so it cannot drift from the code. Select one with `track id gen `. Parameters are set with `gen ` in snake_case — `sweep_amt 26` reaches the voice as `sweepAmt`. Values below are the defaults. | Voices | Count | |---|---| | Chip emulations | 8 | | Hard sync | 4 | | Analog & electronic | 7 | | Atmospheric | 4 | | Acoustic models | 3 | | Percussion | 4 | | Vocal | 3 | | **Total** | **33** | ## Chip emulations ### `gameBoyDmg` — LR35902 Game Boy DMG APU emulator (100% hardware parity). | Parameter | Default | |---|---| | `type` | `"pulse"` | | `duty` | `"50"` | | `envMode` | `"step"` | | `vol` | `15` | | `sweep` | `0` | | `noiseMode` | `"long"` | | `waveShape` | `"saw"` | | `attack` | `0` | | `decay` | `0` | | `sustain` | `15` | | `release` | `0` | | `pitchDrop` | `0` | | `pitchDec` | `0.05` | | `vibRate` | `0` | | `vibAmt` | `0` | | `arpRate` | `0` | | `arpSemis` | `0` | | `len` | `0` | | `envStep` | `0` | | `envUp` | `false` | | `sweepShift` | `0` | | `sweepPeriod` | `0` | | `sweepDown` | `false` | | `noiseShift` | `null` | | `noiseRatio` | `0` | Factory presets: - **GB Pulse Lead** — `{ channel: "pulse_50", decay: 0.15, sweep: 0 }` - **GB Bass** — `{ channel: "wavetable", waveShape: "sawtooth", decay: 0.3, sweep: 0 }` - **25% Pulse** — `{ type: "pulse", duty: "25", envMode: "step", vol: 15, sweep: 2, attack: 0, decay: 0, sustain: 15, release: 0 }` - **Wavetable Crunch** — `{ type: "wave", waveVol: "100", attack: 0, decay: 0, sustain: 15, release: 0 }` ### `nes2a03` — 2A03 NES APU emulator (100% hardware parity). | Parameter | Default | |---|---| | `type` | `"pulse"` | | `duty` | `"50"` | | `envMode` | `"decay"` | | `vol` | `10` | | `sweep` | `0` | | `noiseMode` | `"long"` | | `attack` | `0` | | `decay` | `0` | | `sustain` | `15` | | `release` | `0` | | `vibRate` | `0` | | `vibAmt` | `0` | | `arpRate` | `0` | | `arpSemis` | `0` | | `pitchDrop` | `0` | | `pitchDec` | `0.05` | | `dutySweep` | `0` | | `dpcmSample` | `"kick"` | Factory presets: - **NES Pulse 50%** — `{ channel: "pulse_50", decay: 0.2, sweep: 0 }` - **NES Pulse 25%** — `{ channel: "pulse_25", decay: 0.2, sweep: 0 }` - **NES Pulse 12.5%** — `{ channel: "pulse_12_5", decay: 0.25, sweep: 0 }` - **NES Triangle** — `{ channel: "triangle", decay: 0.4, sweep: 0 }` - **NES Noise** — `{ channel: "noise", decay: 0.1, sweep: 0 }` - **NES Laser Sweep** — `{ channel: "pulse_50", decay: 0.2, sweep: -12 }` - **Pulse Lead** — `{ type: "pulse", duty: "50", envMode: "decay", vol: 12, attack: 0, decay: 3, sustain: 10, release: 2 }` - **12.5% Pluck** — `{ type: "pulse", duty: "12_5", envMode: "decay", vol: 15, attack: 0, decay: 6, sustain: 0, release: 1 }` - **Triangle Bass** — `{ type: "triangle", vol: 15, attack: 0, decay: 0, sustain: 15, release: 0 }` ### `c64sid` — MOS 6581 Commodore 64 SID chip emulator (100% hardware parity). | Parameter | Default | |---|---| | `waveform` | `"sawtooth"` | | `pulseWidth` | `0.5` | | `filterType` | `"lowpass"` | | `cutoff` | `2000` | | `resonance` | `5` | | `attack` | `0` | | `decay` | `5` | | `sustain` | `15` | | `release` | `6` | | `hardSync` | `false` | | `ringMod` | `false` | | `pitchDrop` | `0` | | `pitchDec` | `0.05` | | `vibRate` | `0` | | `vibAmt` | `0` | | `arpRate` | `0` | | `arpSemis` | `0` | Factory presets: - **C64 SID Lead** — `{ waveform: "sawtooth", pulseWidth: 0.5, filterType: "lowpass", cutoff: 3500, resonance: 6, attack: 0.008, decay: 0.2, sustain: 0.6, release: 0.1 }` - **C64 SID Bass** — `{ waveform: "pulse", pulseWidth: 0.4, filterType: "lowpass", cutoff: 1200, resonance: 8, attack: 0.005, decay: 0.35, sustain: 0.4, release: 0.08 }` - **C64 PWM Pad** — `{ waveform: "pulse", pulseWidth: 0.3, filterType: "lowpass", cutoff: 2000, resonance: 4, attack: 0.08, decay: 0.5, sustain: 0.7, release: 0.3 }` - **SNES Warm Pad** — `{ waveform: "pulse", pulseWidth: 0.5, filterType: "lowpass", cutoff: 1500, resonance: 2, attack: 0.12, decay: 0.5, sustain: 0.65, release: 0.4 }` - **SNES Lead** — `{ waveform: "sawtooth", pulseWidth: 0.5, filterType: "lowpass", cutoff: 2500, resonance: 3, attack: 0.008, decay: 0.25, sustain: 0.6, release: 0.12 }` - **SID Brass** — `{ waveform: "sawtooth", pulseWidth: 0.5, filterType: "lowpass", cutoff: 4000, resonance: 2, attack: 0.03, decay: 0.3, sustain: 0.7, release: 0.15 }` - **Hard Sync Lead** — `{ waveform: "sawtooth", hardSync: true, attack: 2, decay: 5, sustain: 10, release: 6, pitchDrop: -12, pitchDec: 0.2 }` - **Filter Bass** — `{ waveform: "pulse", pulseWidth: 0.5, filterType: "lowpass", cutoff: 600, resonance: 8, attack: 0, decay: 4, sustain: 4, release: 3 }` ### `ym2612` — YM2612 Sega Genesis FM Synth emulator (References: Nuked-OPN2, Genesis Plus GX). | Parameter | Default | |---|---| | `algorithm` | `0` | | `feedback` | `0` | | `op1_mul` | `1` | | `op1_tl` | `0` | | `op1_ar` | `31` | | `op1_dr` | `5` | | `op1_sr` | `5` | | `op1_rr` | `5` | | `op1_sl` | `0` | | `op2_mul` | `1` | | `op2_tl` | `0` | | `op2_ar` | `31` | | `op2_dr` | `5` | | `op2_sr` | `5` | | `op2_rr` | `5` | | `op2_sl` | `0` | | `op3_mul` | `1` | | `op3_tl` | `0` | | `op3_ar` | `31` | | `op3_dr` | `5` | | `op3_sr` | `5` | | `op3_rr` | `5` | | `op3_sl` | `0` | | `op4_mul` | `1` | | `op4_tl` | `0` | | `op4_ar` | `31` | | `op4_dr` | `5` | | `op4_sr` | `5` | | `op4_rr` | `5` | | `op4_sl` | `0` | | `pitchDrop` | `0` | | `pitchDec` | `0.05` | | `vibRate` | `0` | | `vibAmt` | `0` | | `arpRate` | `0` | | `arpSemis` | `0` | Factory presets: - **Genesis E-Piano** — `{ algorithm: 4, feedback: 0, op1_mul: 1, op1_tl: 0, op1_ar: 31, op1_dr: 12, op1_sr: 5, op1_rr: 8, op1_sl: 5, op2_mul: 1, op2_tl: 20, op2_ar: 31, op2_dr: 15, op2_sr: 5, op2_rr: 8, op2_sl: 5, op3_mul: 4, op3_tl: 30, op3_ar: 31, op3_dr: 18, op3_sr: 5, op3_rr: 8, op3_sl: 5, op4_mul: 1, op4_tl: 10, op4_ar: 31, op4_dr: 10, op4_sr: 5, op4_rr: 8, op4_sl: 5 }` - **Genesis Brass** — `{ algorithm: 1, feedback: 5, op1_mul: 1, op1_tl: 0, op1_ar: 20, op1_dr: 15, op1_sr: 5, op1_rr: 10, op1_sl: 2, op2_mul: 2, op2_tl: 15, op2_ar: 22, op2_dr: 12, op2_sr: 5, op2_rr: 10, op2_sl: 2, op3_mul: 1, op3_tl: 5, op3_ar: 18, op3_dr: 10, op3_sr: 5, op3_rr: 10, op3_sl: 2, op4_mul: 1, op4_tl: 0, op4_ar: 24, op4_dr: 8, op4_sr: 5, op4_rr: 10, op4_sl: 2 }` ### `sn76489` — SN76489 Sega Master System / Genesis PSG emulator (References: MAME). | Parameter | Default | |---|---| | `type` | `"square"` | | `noiseMode` | `"white"` | | `noiseFreq` | `0` | | `vol` | `15` | | `attack` | `0` | | `decay` | `0.1` | | `sustain` | `15` | | `release` | `0` | | `pitchDrop` | `0` | | `pitchDec` | `0.05` | | `vibRate` | `0` | | `vibAmt` | `0` | | `arpRate` | `0` | | `arpSemis` | `0` | Factory presets: - **PSG Square Lead** — `{ type: "square", vol: 15, attack: 0, decay: 0.1, sustain: 15, release: 0 }` ### `spc700` — SPC700 Super Nintendo S-DSP emulator (References: bsnes, snes9x). | Parameter | Default | |---|---| | `waveform` | `"strings"` | | `attack` | `0` | | `decay` | `3` | | `sustainLevel` | `7` | | `sustainRate` | `0` | | `echoEnable` | `false` | | `echoDelay` | `4` | | `echoFeedback` | `0` | | `echoFir` | `0` | | `pitchDrop` | `0` | | `pitchDec` | `0.05` | | `vibRate` | `0` | | `vibAmt` | `0` | | `arpRate` | `0` | | `arpSemis` | `0` | Factory presets: - **Echo Strings** — `{ waveform: "strings", attack: 10, decay: 3, sustainLevel: 7, sustainRate: 0, echoEnable: true, echoDelay: 8, echoFeedback: 40 }` - **Warm Brass** — `{ waveform: "brass", attack: 6, decay: 5, sustainLevel: 5, sustainRate: 0, echoEnable: false, echoDelay: 4, echoFeedback: 0 }` ### `gbaDirectSound` — GBA DirectSound Game Boy Advance 8-bit DAC software mixing simulator. | Parameter | Default | |---|---| | `waveform` | `"pulse"` | | `duty` | `"50"` | | `vol` | `15` | | `attack` | `0` | | `decay` | `2` | | `sustain` | `15` | | `release` | `0` | | `bitcrush` | `true` | | `pitchDrop` | `0` | | `pitchDec` | `0.05` | | `vibRate` | `0` | | `vibAmt` | `0` | | `arpRate` | `0` | | `arpSemis` | `0` | Factory presets: - **Software Saw Lead** — `{ waveform: "sawtooth", duty: "50", attack: 0, decay: 1, sustain: 15, release: 0, bitcrush: true }` - **Pulse Chug** — `{ waveform: "pulse", duty: "25", attack: 0, decay: 0.1, sustain: 0, release: 0.2, bitcrush: true }` ### `chiptune` — Chiptune Retro 8-bit pulse-width modulation and decimation crush. | Parameter | Default | |---|---| | `waveform` | `"pulse"` | | `pulseWidth` | `0.5` | | `pwmSpeed` | `0` | | `bitcrush` | `0` | | `lowpass` | `0` | | `arpRate` | `0` | | `arpSemis` | `0` | | `attack` | `0.005` | | `decay` | `0.3` | | `sustain` | `0` | | `release` | `0.05` | ## Hard sync ### `syncLead` — Sync Lead Aggressive true hard sync analog lead with an automated sweep envelope. | Parameter | Default | |---|---| | `masterTune` | `0` | | `slaveBase` | `12` | | `sweepAmt` | `24` | | `sweepDecay` | `0.4` | | `lfoRate` | `0` | | `lfoAmt` | `0` | | `cutoff` | `3000` | | `resonance` | `5` | | `filterEnvAmt` | `0` | | `filterDecay` | `0.4` | | `attack` | `0.05` | | `decay` | `0.3` | | `sustain` | `0.8` | | `release` | `0.5` | Factory presets: - **Sync Screamer** — `{ slaveBase: 19, sweepAmt: 36, sweepDecay: 0.25, cutoff: 5000, resonance: 3, filterEnvAmt: 0, attack: 0.02, decay: 0.2, sustain: 0.9, release: 0.3 }` - **Sync Pluck** — `{ slaveBase: 12, sweepAmt: 24, sweepDecay: 0.15, cutoff: 2400, resonance: 6, filterEnvAmt: 0.3, filterDecay: 0.2, attack: 0.005, decay: 0.15, sustain: 0, release: 0.2 }` - **Sync Talk Box** — `{ slaveBase: 5, sweepAmt: 12, sweepDecay: 0.6, lfoRate: 3.5, lfoAmt: 8, cutoff: 1800, resonance: 8, attack: 0.08, decay: 0.4, sustain: 0.7, release: 0.4 }` ### `syncChoir` — Sync Choir Lush, robotic 80s analog choir built from detuned hard sync formants. | Parameter | Default | |---|---| | `vowelShift` | `24` | | `morphRate` | `0.5` | | `morphAmt` | `12` | | `ensembleDetune` | `15` | | `vibRate` | `5` | | `vibAmt` | `10` | | `highpass` | `300` | | `attack` | `1` | | `decay` | `1` | | `sustain` | `0.8` | | `release` | `1.5` | Factory presets: - **Android Choir** — `{ vowelShift: 24, morphRate: 0.5, morphAmt: 12, ensembleDetune: 15, vibRate: 5.0, vibAmt: 10, highpass: 300, attack: 1.0, decay: 1.0, sustain: 0.8, release: 1.5 }` - **Slow Morph Pad** — `{ vowelShift: 19, morphRate: 0.12, morphAmt: 24, ensembleDetune: 20, vibRate: 4.0, vibAmt: 8, highpass: 200, attack: 2.0, decay: 1.5, sustain: 0.85, release: 2.5 }` - **Tight Ensemble** — `{ vowelShift: 12, morphRate: 0.8, morphAmt: 6, ensembleDetune: 6, vibRate: 6.5, vibAmt: 14, highpass: 400, attack: 0.3, decay: 0.5, sustain: 0.9, release: 0.8 }` ### `obSync` — OB Sync Massive, creamy dual-oscillator hard sync synth with Oberheim-style width and a sweeping filter. | Parameter | Default | |---|---| | `detune` | `15` | | `sweepRate` | `0.5` | | `sweepAmt` | `24` | | `cutoff` | `1200` | | `resonance` | `2` | | `filterEnv` | `2400` | | `filterDecay` | `0.8` | | `attack` | `0.1` | | `decay` | `0.4` | | `sustain` | `0.6` | | `release` | `0.5` | Factory presets: - **Oberheim Jump** — `{ detune: 18, sweepRate: 0.7, sweepAmt: 30, cutoff: 2000, resonance: 3, filterEnv: 3200, filterDecay: 0.5, attack: 0.04, decay: 0.3, sustain: 0.7, release: 0.3 }` - **OB Pad** — `{ detune: 12, sweepRate: 0.2, sweepAmt: 12, cutoff: 800, resonance: 1.5, filterEnv: 1600, filterDecay: 1.2, attack: 0.6, decay: 0.8, sustain: 0.8, release: 1.5 }` ### `laserSync` — Laser Sync Punchy, retro-arcade zap. A rapid pitch-dropping master oscillator ripping through a static sync slave. | Parameter | Default | |---|---| | `dropRate` | `0.8` | | `dropAmt` | `36` | | `slaveBase` | `18` | | `attack` | `0.01` | | `decay` | `0.3` | Factory presets: - **Arcade Zap** — `{ dropRate: 0.9, dropAmt: 48, slaveBase: 24, attack: 0.005, decay: 0.15 }` - **Laser Sweep** — `{ dropRate: 0.3, dropAmt: 24, slaveBase: 12, attack: 0.02, decay: 0.6 }` ## Analog & electronic ### `acid303` — Acid 303 Classic 303-style bassline with resonant filter and envMod sweep. | Parameter | Default | |---|---| | `waveform` | `"sawtooth"` | | `cutoff` | `800` | | `resonance` | `15` | | `envMod` | `4000` | | `decay` | `0.4` | Factory presets: - **Classic Acid** — `{ waveform: "square", cutoff: 600, resonance: 18, envMod: 5000, decay: 0.35 }` - **Acid Squelch** — `{ waveform: "sawtooth", cutoff: 400, resonance: 22, envMod: 6000, decay: 0.2 }` - **Deep Acid** — `{ waveform: "sawtooth", cutoff: 500, resonance: 12, envMod: 3000, decay: 0.6 }` ### `sub808` — Sub 808 Heavy sine wave sub-bass with punch and drive saturation. | Parameter | Default | |---|---| | `punch` | `60` | | `decay` | `1.2` | | `drive` | `0` | | `glide` | `0.05` | Factory presets: - **808 Long Tail** — `{ punch: 48, decay: 2.5, drive: 0.15, glide: 0.08 }` - **808 Distorted** — `{ punch: 72, decay: 1.0, drive: 0.6, glide: 0.03 }` ### `reeseBass` — Reese Bass Thick, multi-oscillator detuned Supersaw bass with filter wobble. | Parameter | Default | |---|---| | `voices` | `3` | | `detune` | `30` | | `cutoff` | `1500` | | `wobble` | `0` | | `decay` | `1` | Factory presets: - **DnB Reese** — `{ voices: 5, detune: 40, cutoff: 1200, wobble: 0.6, decay: 1.5 }` - **Minimal Sub Reese** — `{ voices: 2, detune: 15, cutoff: 800, wobble: 0, decay: 1.2 }` ### `basicOsc` — Basic OSC Single oscillator + ADSR in generator params. | Parameter | Default | |---|---| | `waveform` | `"sine"` | | `attack` | `0.005` | | `decay` | `0.08` | | `sustain` | `0.4` | | `release` | `0.12` | Factory presets: - **Saw Lead** — `{ waveform: "sawtooth", attack: 0.01, decay: 0.15, sustain: 0.7, release: 0.2 }` - **Square Sub** — `{ waveform: "square", attack: 0.005, decay: 0.3, sustain: 0.8, release: 0.15 }` ### `fmTone` — FM tone Two-operator FM + ADSR in generator params. | Parameter | Default | |---|---| | `ratio` | `2` | | `modIndex` | `4` | | `carrierWave` | `"sine"` | | `modWave` | `"sine"` | | `attack` | `0.005` | | `decay` | `0.08` | | `sustain` | `0.4` | | `release` | `0.12` | Factory presets: - **FM E-Piano** — `{ ratio: 1, modIndex: 3, carrierWave: "sine", modWave: "sine", attack: 0.003, decay: 0.8, sustain: 0.15, release: 0.4 }` - **FM Brass** — `{ ratio: 1, modIndex: 6, carrierWave: "sine", modWave: "square", attack: 0.06, decay: 0.3, sustain: 0.7, release: 0.25 }` ### `matrixFm` — Matrix FM Multi-operator FM/RM graph via deck gen_block (Sytrus-style). ### `patch` — Patch Modular synth patch (gen_block patch): osc/noise/filter/shaper/gain + breakpoint envelopes — any voice, written in deck. ## Atmospheric ### `pad` — Pad Detuned triple-osc + lowpass + slow env — ethereal, reverb-friendly. | Parameter | Default | |---|---| | `wave1` | `"sine"` | | `wave2` | `"triangle"` | | `detune` | `9` | | `cutoff` | `2200` | | `attack` | `0.08` | | `decay` | `0.25` | | `sustain` | `0.6` | | `release` | `0.7` | Factory presets: - **Warm Blanket** — `{ wave1: "triangle", wave2: "sine", detune: 14, cutoff: 1200, attack: 0.6, decay: 0.8, sustain: 0.75, release: 2.0 }` - **Glass Shimmer** — `{ wave1: "sawtooth", wave2: "square", detune: 8, cutoff: 4000, attack: 0.3, decay: 0.4, sustain: 0.6, release: 1.2 }` ### `aether` — Aether Theremin — eerie, voice-like heterodyne tone with a portamento swoop into each note, a living two-hand pitch/amplitude waver, and a breathy volume-hand swell. | Parameter | Default | |---|---| | `glide` | `0.4` | | `waver` | `0.5` | | `tone` | `0.3` | | `swell` | `0.45` | | `air` | `0.25` | Factory presets: - **Classic Theremin** — `{ glide: 0.4, waver: 0.5, tone: 0.3, swell: 0.45, air: 0.25 }` - **Sci-Fi Wail** — `{ glide: 0.7, waver: 0.8, tone: 0.5, swell: 0.6, air: 0.4 }` ### `halo` — Halo Hang drum / handpan — lush inharmonic octave + compound-fifth shimmer triad over a long ethereal ring, with a soft fingertip strike and a warm 'gu' body. | Parameter | Default | |---|---| | `temper` | `0.4` | | `ring` | `0.55` | | `mallet` | `0.35` | | `bloom` | `0.5` | | `lows` | `0.45` | Factory presets: - **Meditation Bowl** — `{ temper: 0.3, ring: 0.8, mallet: 0.2, bloom: 0.65, lows: 0.6 }` - **Steel Tongue** — `{ temper: 0.55, ring: 0.35, mallet: 0.6, bloom: 0.3, lows: 0.35 }` ### `bell` — Bell Inharmonic sine partials + highpass + bell decay — metallic shimmer. | Parameter | Default | |---|---| | `partial` | `2.01` | | `highpass` | `800` | | `decay` | `1` | Factory presets: - **Crystal Chime** — `{ partial: 3.01, highpass: 1200, decay: 2.0 }` - **Dark Bell** — `{ partial: 1.41, highpass: 400, decay: 1.8 }` ## Acoustic models ### `arco` — Arco Bowed strings — violin · viola · cello · bass · fiddle. Stick-slip saw through real body-resonance formants, with bow pressure, articulation, vibrato and rosin noise. Only string-player controls. | Parameter | Default | |---|---| | `voice` | `"violin"` | | `pressure` | `0.5` | | `bow` | `0.4` | | `vibrato` | `0.35` | | `rosin` | `0.3` | | `body` | `0.6` | Factory presets: - **Solo Violin** — `{ voice: "violin", pressure: 0.55, bow: 0.45, vibrato: 0.4, rosin: 0.25, body: 0.65 }` - **Cello Legato** — `{ voice: "cello", pressure: 0.6, bow: 0.5, vibrato: 0.5, rosin: 0.2, body: 0.75 }` - **Country Fiddle** — `{ voice: "fiddle", pressure: 0.7, bow: 0.55, vibrato: 0.3, rosin: 0.55, body: 0.5 }` - **Upright Bass** — `{ voice: "bass", pressure: 0.45, bow: 0.35, vibrato: 0.2, rosin: 0.15, body: 0.8 }` ### `tine` — Tine Rhodes-style electric piano — velocity-barked FM tine (hard = metallic bark, soft = mellow bell), a metal tine ping, EP decay, and lush suitcase tremolo. | Parameter | Default | |---|---| | `bark` | `0.55` | | `tine` | `0.6` | | `tremolo` | `0.35` | | `decay` | `0.5` | | `drive` | `0.2` | Factory presets: - **Suitcase Warm** — `{ bark: 0.35, tine: 0.4, tremolo: 0.6, decay: 0.65, drive: 0.15 }` - **Stage Bright** — `{ bark: 0.7, tine: 0.75, tremolo: 0, decay: 0.45, drive: 0.3 }` - **Neo Soul Keys** — `{ bark: 0.45, tine: 0.55, tremolo: 0.25, decay: 0.7, drive: 0.1 }` ### `guitar` — Guitar Karplus-Strong plucked electric guitar — string model + palm mute + drive + body. Pairs with the chord voice for strums. | Parameter | Default | |---|---| | `tone` | `0.5` | | `decay` | `0.6` | | `damping` | `0.4` | | `drive` | `0.25` | | `body` | `3500` | | `mute` | `0` | Factory presets: - **Clean Electric** — `{ tone: 0.55, decay: 0.6, damping: 0.35, drive: 0.1, body: 3800, mute: 0 }` - **Palm Mute Chug** — `{ tone: 0.35, decay: 0.3, damping: 0.55, drive: 0.45, body: 2800, mute: 0.7 }` - **Nylon Acoustic** — `{ tone: 0.72, decay: 0.75, damping: 0.25, drive: 0, body: 4200, mute: 0 }` - **Bass Guitar** — `{ tone: 0.28, decay: 0.55, damping: 0.6, drive: 0.15, body: 1800, mute: 0 }` ## Percussion ### `drumSynth` — Drum Pitch-envelope drum synth — punchy kicks, snares, toms, 808s (+ noise + drive). | Parameter | Default | |---|---| | `tone` | `"sine"` | | `pitchEnv` | `36` | | `pitchDecay` | `0.06` | | `decay` | `0.35` | | `noise` | `0` | | `noiseDecay` | `0.12` | | `noiseHp` | `1500` | | `drive` | `0` | Factory presets: - **TR-909 Kick** — `{ tone: "sine", pitchEnv: 30, pitchDecay: 0.035, decay: 0.3, noise: 0.05, drive: 0.2 }` - **Boom Bap Kick** — `{ tone: "sine", pitchEnv: 38, pitchDecay: 0.07, decay: 0.55, noise: 0, drive: 0.08 }` - **Rim Shot** — `{ tone: "triangle", pitchEnv: 12, pitchDecay: 0.015, decay: 0.06, noise: 0.5, noiseHp: 3000, noiseDecay: 0.04, drive: 0.15 }` - **Tom Low** — `{ tone: "sine", pitchEnv: 24, pitchDecay: 0.04, decay: 0.35, noise: 0.1, drive: 0.05 }` - **Tom High** — `{ tone: "sine", pitchEnv: 18, pitchDecay: 0.03, decay: 0.25, noise: 0.12, drive: 0.05 }` ### `clap` — Clap The Clap — handclap engine: hand count, timing spread, hand size, brightness, room tail, and clusters (single / double / many-hand crowd). | Parameter | Default | |---|---| | `hands` | `3` | | `spread` | `0.4` | | `size` | `0.5` | | `tone` | `0.5` | | `claps` | `1` | | `gap` | `0.35` | | `tail` | `0.4` | | `body` | `0.2` | Factory presets: - **Tight Clap** — `{ hands: 2, spread: 0.2, size: 0.3, tone: 0.6, claps: 1, gap: 0.2, tail: 0.25, body: 0.15 }` - **Crowd Clap** — `{ hands: 8, spread: 0.7, size: 0.7, tone: 0.45, claps: 3, gap: 0.4, tail: 0.6, body: 0.35 }` ### `cymbal` — Cymbal TR-808 style cymbal cluster (6 tuned squares + noise + highpass). | Parameter | Default | |---|---| | `tune` | `300` | | `metallic` | `0.8` | | `decay` | `0.4` | | `highpass` | `7000` | Factory presets: - **Ride Cymbal** — `{ tune: 340, metallic: 0.7, decay: 0.8, highpass: 6000 }` - **Crash** — `{ tune: 280, metallic: 0.9, decay: 1.5, highpass: 5000 }` ### `noiseBurst` — Noise burst Filtered noise; attack + decay shape the hit. | Parameter | Default | |---|---| | `attack` | `0.002` | | `decay` | `0.07` | | `tone` | `0.45` | | `pitchFollow` | `0.25` | ## Vocal ### `formantVocal` — Formant Vocal Expressive formant synthesizer with gliding notes and vibrato. | Parameter | Default | |---|---| | `glide` | `0.1` | | `vibDepth` | `0.02` | | `vibRate` | `5` | | `humanize` | `0.5` | | `release` | `0.2` | ### `ttsVocal` — TTS Vocal Web Speech API Text-to-Speech engine for robotic vocal sequences. | Parameter | Default | |---|---| | `voice` | `0` | | `rate` | `1.5` | ### `meSpeakVocal` — meSpeak Vocal Retro robotic TTS using meSpeak.js with sample-accurate timing. | Parameter | Default | |---|---| | `pitch` | `50` | | `speed` | `175` | | `wordgap` | `0` | | `variant` | `"m1"` | | `amplitude` | `100` | --- # @spacedevin/deck-synths Source: packages/synths/AGENTS.md The **instrument catalog** for `.deck` — the 33 Web Audio voices, and the dispatch that picks one. **Entry:** `src/index.tish` This package exists so that one set of voices serves every host: Deckard, `@spacedevin/deck-player`, the docs site, the WAV renderer. The root [AGENTS.md](../../AGENTS.md) keeps audio out of the language package; the player's [AGENTS.md](../player/AGENTS.md) keeps voice implementations out of the player. Both of those exclusions land here. ## In scope - Voices: one `src/.tish` per generator id, a pure `play(ctx, bus, t, midi, vel, durSec, ch, bendSemis)` that builds a short-lived subgraph, connects it to `bus.input`, and disconnects its nodes once the tail has passed. A voice may instead return `{ stopTime, disconnects }` and let the host prune it per step, which is the path to take when a voice must not lean on a wall-clock timer - `Registry.tish` — the catalog: id, label, description, default `generatorParams` - `Dispatch.tish` — `dispatchPlayNote` by `ch.generatorId`, `basicOsc` fallback for unknown ids - `DeckIds.tish` — teaching the language this catalog's ids, param aliases and gen_block dialects (`ensureDeckGeneratorIds`), via the registries `docs/HOST.md` describes - Shared DSP: `Duty.tish`, `AdsrAmpSchedule.tish`, `Midi.tish`, the `PatchGraph` / `MatrixFmGraph` parsers - `SyncWorklet.tish` — the hard-sync processor from an inline Blob URL - `BuiltinMacros.tish` — the builtin macro *contents* the language package deliberately leaves empty ## Out of scope — do not add here - **Grammar.** New tokens, body heads or statements belong in `../../src/` and its conformance corpus. Register vocabulary through the language's registries; never re-tokenize `.deck` text. - **Sequencing.** Song IR, defaults and clamps, the transport, buses and the master chain are the player's. A voice receives a resolved note; it does not decide when notes happen. - **Assets to copy.** The worklet is inlined for that reason. `meSpeakVocal` is the one exception and is documented as such. ## Notes for editors - **No `class` syntax** in Tish — `tish build` emits JS that doesn't parse. Worklet processors are written as a JS string for that reason. - Deterministic by design: seed anything random so two renders of one song are identical. - Two copies of `@spacedevin/deck` in one page means two dialect registries. Keep it a peer. --- # Playback Source: packages/player/README.md Web Audio playback for **`.deck`** — chip-tune synths, a lookahead transport, and a `` element. [`@spacedevin/deck`](../..) parses the language and [`@spacedevin/deck-synths`](../synths/) holds the voices. This package is the **host**: it applies the defaults and clamps the parser deliberately leaves out, sequences the song, and makes sound through the full 33-voice catalog. ## Install ```bash npm install @spacedevin/deck-player ``` `@spacedevin/deck` and `@spacedevin/deck-synths` are peer dependencies and install alongside it. ## Use ```js import { createDeckPlayer } from '@spacedevin/deck-player' const player = createDeckPlayer() const song = player.load(` deck 1 bpm 120 track Lead id lead gen gameBoyDmg gen type pulse duty 50 vol 12 note 60 0 0.5 v 100 note 64 0.5 0.5 v 90 note 67 1 1 v 100 `) if (song.errors.length) console.warn(song.errors) button.onclick = () => player.play() // an AudioContext needs a user gesture ``` Or drop in the element — no framework, no build step: ```html deck 1 bpm 120 track Lead id lead gen gameBoyDmg note 60 0 0.5 v 100 ``` ### Code lighting If the source inside the element is highlighted HTML rather than plain text, the element will light it as it plays: the step under the playhead in each `steps` lane, and every line of a track that is sounding on that step. It looks for the attributes the site highlighter emits — `data-track` on each line and `data-step` on each step token — so any highlighter that adds those gets the same behaviour. The docs site is the reference: `site/highlight.mjs` emits them, `site/style.css` styles the `dk-now` (lit step) and `dk-live` (sounding line) classes it toggles. ## Hear it A whole song is three kinds of line: a tempo, a track, and some notes. On the docs site this block has a play button — the synths below are doing the work. ```deck deck 1 bpm 132 track Lead id lead gen gameBoyDmg gen type pulse duty 25 vol 11 note 72 0 0.5 v 110 note 76 0.5 0.5 v 95 note 79 1 0.5 v 105 note 76 1.5 0.5 v 90 note 72 2 1 v 110 note 74 3 1 v 95 track Bass id bass gen gameBoyDmg gen type wave wave_shape saw vol 15 note 36 0 1 v 120 note 36 1 1 v 100 note 43 2 1 v 115 note 41 3 1 v 100 track Kick id kick gen gbaDirectSound gen waveform triangle pitch_drop -14 adsr a 0 d 0.08 s 0 r 0 note 36 0 0.25 v 127 note 36 1 0.25 v 110 note 36 2 0.25 v 127 note 36 3 0.25 v 110 ``` ## API | | | |---|---| | `createDeckPlayer(opts?)` | `load` · `play` · `pause` · `stop` · `seek(beat)` · `position()` · `duration()` · `setIntensity(0..3)` · `analyser()` · `on(event, fn)` · `dispose()` | | `renderDeckToBuffer(src, opts?)` | offline render through the same graph → `Promise` | | `parseSong(src)` | `.deck` → Song IR. No AudioContext, no sound | | `stepTriggers(song, step)` | which notes sound at a 16th step. Pure | | `buildAudioGraph` · `createTransport` · `playStep` | the pieces, if you want your own loop | `load()` returns the Song, including three things worth showing a user: - **`errors`** — parse errors plus host errors (a malformed `gen` line, a bad `wave` table) - **`substitutions`** — generator ids the catalog has no voice for, swapped for `basicOsc` so the song still plays - **`ignored`** — language features present in the source that this package doesn't sequence yet: clips/session, `song`/`follow` arrangement, `auto` automation, `master_mix`, `@` directives ## Sound The voices are [`@spacedevin/deck-synths`](../synths/) — all 33 of them, the same catalog [Deckard](https://deckard.lol) plays through, which is itself checked against tish-gba's build-time bake. So a `.deck` sounds the same here as it does in Deckard or on a GBA, and nothing is swapped for a stand-in. The chip voices model real hardware behaviour, not an impression of it: - **`gameBoyDmg`** — the four duty tables in an 8-sample buffer pitched by `playbackRate`; a genuine 15/7-bit LFSR for noise; wave RAM quantized to 4 bits; the 64 Hz / 32 Hz frequency floors; the 15-step volume envelope - **`gbaDirectSound`** — a 32-sample table (so high notes alias like the real software mixer), an 8-bit DAC as a 256-step staircase, and the ~16 kHz mixing roll-off - **`nes2a03`, `c64sid`, `ym2612`, `sn76489`, `spc700`** — and the rest of the chip family, plus FM, drums, hard sync, bowed and plucked models. The full list is in the [synths README](../synths/README.md); [Examples](../../docs/EXAMPLES.md) has a playable song for each - **`wave <32 hex digits>`** / **`wave harmonics …`** — named wave RAM tables, written as samples or as harmonic amplitudes; the language resolves both to the same 32 levels - **`layer`** — stem gating via `setIntensity()` Two voices reach outside the audio graph: `ttsVocal` needs the Web Speech API and `meSpeakVocal` needs a worker the host serves. They play in a page that provides those and are silent in an offline render. ## Notes - **Players are aware of each other.** Starting one stops any other that's playing — two chip songs at once is noise, and a page like this one has several players on it. Pass `{ exclusive: false }` to layer them deliberately. - **One AudioContext per page.** Players share a single lazily-created context unless you pass your own, because a context is a page-level resource and Safari has historically refused past about four. - **No assets to copy.** The clock worklet is compiled from an inline string into a Blob URL, so installing the package is the whole install. - **Deterministic.** Probability locks, arpeggiator shuffles and the reverb impulse are all seeded, so two renders of one song are identical. - **Pause is real pause.** It suspends the AudioContext, so notes and the scheduler resume exactly where they stopped. - Requires `AudioContext`; playback must start from a user gesture. ## Scope See [AGENTS.md](AGENTS.md). Grammar changes belong upstream in `@spacedevin/deck` — never re-tokenize `.deck` text here. ## License MIT — see [LICENSE](LICENSE). --- # Player scope Source: packages/player/AGENTS.md The **host** side of `.deck` — Web Audio playback for programs parsed by `@spacedevin/deck`. **Entry:** `src/index.tish` This package exists so that playback never enters the language package. The root [AGENTS.md](../../AGENTS.md) lists "Audio / Web Audio engines" as out of scope for `@spacedevin/deck`, and that stands: `../../src/` stays audio-free. Everything that rule excludes lives here. ## In scope - AST → Song IR: **defaults, clamps, range checks**. The parser deliberately does none of this (`docs/DECK_GRAMMAR.md`: absent optional = `null`, "host policy, and hosts genuinely differ"), so this package is where `step_vel` becomes 100 and an out-of-range lock gets decided. - Registry boot (`registerGeneratorIdAliases`, dialects, highlight keywords) per `docs/HOST.md` - Web Audio: channel bus, master chain, generators/voices - Transport: lookahead scheduler, play / pause / stop / seek, loop caps - Offline render (`OfflineAudioContext`) - The `` custom element - Tests: Song IR snapshots, pure timing math, a recording fake `AudioContext` for voice schedules ## Out of scope — do not add here - **Grammar changes.** A new body head, top-level statement, or token shape belongs in `../../src/` and its conformance corpus. If you need something the parser doesn't expose, fix it upstream — never re-tokenize `.deck` text here. - **Conformance cases.** `../../conformance/` is the cross-implementation parse contract; adding a case there forces every profile in `profiles.json` to declare its position. This package reads that corpus as test *input* and keeps its own fixtures for playback behaviour. - Session / co-DJ / ownership, DJ mixer crossfading, cue outputs, scratch platters — all dropped from the Deckard port on purpose. - **Voice implementations.** Those live in `@spacedevin/deck-synths`, in this repo under `packages/synths/`. This package owns the IR, the buses, the transport and the master chain — not the instruments. ## Generators The catalog is **`@spacedevin/deck-synths`** (`packages/synths/`), which ships from this repo in lockstep with the language and this package. All 33 voices live there, including `patch` and `matrixFm`; a voice is a pure function (`play*(ctx, bus, t, midi, vel, durSec, ch, bendSemis)`) that connects its last node to `bus.input`. Add a voice there, not here. Voices clean up after themselves: each schedules its own disconnects once its tail has passed. A voice may instead return `{ stopTime, disconnects }` and let this package prune it per step (`pruneVoices` in `src/index.tish`); that path exists for voices that must not lean on a wall-clock timer, since an `OfflineAudioContext` has none. `src/generators/` here holds only `Registry.tish`; there are no local voice copies left. The catalog falls back to `basicOsc` for a generator id it has no voice for, so a song still plays. This package surfaces that in `song.substitutions`. `ttsVocal` and `meSpeakVocal` need the Web Speech API and a `mespeak` worker respectively, so they stay out of scope here regardless. ## Why `element/` is not Tish `element/deck-player-element.js` is hand-written JavaScript, shipped as authored. A custom element must be `class X extends HTMLElement`, and **Tish has no class syntax** — `tish build` parses the declaration as an identifier expression and emits JS that doesn't parse. Everything with behaviour stays in `src/*.tish`; that file is only the DOM shell around it. Don't try to move it back. ## Notes for editors - **Per-instance state only.** Deckard keeps loop counters in module-level maps (`deckfile/LoopState.tish`); here they live on the player instance, because two `` elements can share a page. - The deck package's registries **are** process-wide singletons. Boot is idempotent and runs once. - The clock worklet is loaded from an inline Blob URL, not a file — consumers must not have to copy assets. Keep the `setTimeout` fallback for contexts where `addModule` fails. --- # Synths Source: packages/synths/README.md The instrument catalog for the [`.deck`](https://github.com/spacedevin/deck) language — 33 voices covering chip emulations, FM, drums, hard sync, bowed and plucked models, and vocals. These are the voices [Deckard](https://deckard.lol) plays through. They live here so a second host — a docs-site player, a renderer, a demo — can make the same sounds without a second copy of the code. ```bash npm install @spacedevin/deck-synths ``` ```js import { dispatchPlayNote, ensureDeckGeneratorIds, ensureSyncWorklet } from '@spacedevin/deck-synths' // Teach the language this catalog's generator ids, param aliases and gen_block dialects. // Do this before parsing, or `gen sweep_amt` never reaches the voice as `sweepAmt`. ensureDeckGeneratorIds() await ensureSyncWorklet(ctx) // only needed if a sync voice is used dispatchPlayNote(ctx, bus, t, midi, vel, durSec, channel, bendSemis) ``` ## The contract A voice is a pure function that builds a short-lived Web Audio subgraph and connects its last node to `bus.input`: ``` play(ctx, bus, t, midi, vel, durSec, ch, bendSemis) ``` `dispatchPlayNote` picks one by `ch.generatorId`; an unknown id falls back to `basicOsc`. Patch and envelope come from `ch.generatorParams` — the ADSR lives there, not on the channel root. ## The voices Every id below has a complete, playable song in [Examples](../../docs/EXAMPLES.md). | Family | Generator ids | |---|---| | Chip emulations | `gameBoyDmg` `gbaDirectSound` `nes2a03` `c64sid` `ym2612` `sn76489` `spc700` `chiptune` | | FM and patches | `fmTone` `matrixFm` `patch` `tine` `bell` | | Basic and bass | `basicOsc` `acid303` `sub808` `reeseBass` `pad` | | Drums and hits | `drumSynth` `noiseBurst` `clap` `cymbal` | | Hard sync | `syncLead` `syncChoir` `obSync` `laserSync` | | Bowed, plucked, struck | `arco` `guitar` `halo` `aether` | | Vocal | `formantVocal` `ttsVocal` `meSpeakVocal` | `generatorCatalog()` returns the same list with a label and description per voice, and the default `generatorParams` each one expects. ## Assets `ensureSyncWorklet` registers the hard-sync oscillator from an inlined Blob URL, so `syncLead`, `syncChoir`, `obSync` and `laserSync` need nothing copied into your public directory. `meSpeakVocal` is the exception: it needs a worker and voice data the host serves. The defaults are `/mespeak-worker.js` and `/mespeak`; call `configureMeSpeak({ workerUrl, assetsBaseUrl })` if yours differ. `ttsVocal` needs the Web Speech API. ## Adding a voice One `.tish` file in `src/` exporting a `play` function with the signature above, an entry in `src/Registry.tish` (id, label, description, default params) and `src/Dispatch.tish`, any param aliases or gen_block dialect in `src/DeckIds.tish`, and a song in [docs/EXAMPLES.md](../../docs/EXAMPLES.md) so the example test plays it. The full recipe is in [CONTRIBUTING.md](../../CONTRIBUTING.md). ## Known limits - Two hosts that each bundle their own copy of `@spacedevin/deck` end up with two dialect registries, and a dialect registered against one is invisible to the other. Deduplicate the language package so there is a single instance. --- # Synths scope Source: packages/synths/AGENTS.md The **instrument catalog** for `.deck` — the 33 Web Audio voices, and the dispatch that picks one. **Entry:** `src/index.tish` This package exists so that one set of voices serves every host: Deckard, `@spacedevin/deck-player`, the docs site, the WAV renderer. The root [AGENTS.md](../../AGENTS.md) keeps audio out of the language package; the player's [AGENTS.md](../player/AGENTS.md) keeps voice implementations out of the player. Both of those exclusions land here. ## In scope - Voices: one `src/.tish` per generator id, a pure `play(ctx, bus, t, midi, vel, durSec, ch, bendSemis)` that builds a short-lived subgraph, connects it to `bus.input`, and disconnects its nodes once the tail has passed. A voice may instead return `{ stopTime, disconnects }` and let the host prune it per step, which is the path to take when a voice must not lean on a wall-clock timer - `Registry.tish` — the catalog: id, label, description, default `generatorParams` - `Dispatch.tish` — `dispatchPlayNote` by `ch.generatorId`, `basicOsc` fallback for unknown ids - `DeckIds.tish` — teaching the language this catalog's ids, param aliases and gen_block dialects (`ensureDeckGeneratorIds`), via the registries `docs/HOST.md` describes - Shared DSP: `Duty.tish`, `AdsrAmpSchedule.tish`, `Midi.tish`, the `PatchGraph` / `MatrixFmGraph` parsers - `SyncWorklet.tish` — the hard-sync processor from an inline Blob URL - `BuiltinMacros.tish` — the builtin macro *contents* the language package deliberately leaves empty ## Out of scope — do not add here - **Grammar.** New tokens, body heads or statements belong in `../../src/` and its conformance corpus. Register vocabulary through the language's registries; never re-tokenize `.deck` text. - **Sequencing.** Song IR, defaults and clamps, the transport, buses and the master chain are the player's. A voice receives a resolved note; it does not decide when notes happen. - **Assets to copy.** The worklet is inlined for that reason. `meSpeakVocal` is the one exception and is documented as such. ## Notes for editors - **No `class` syntax** in Tish — `tish build` emits JS that doesn't parse. Worklet processors are written as a JS string for that reason. - Deterministic by design: seed anything random so two renders of one song are identical. - Two copies of `@spacedevin/deck` in one page means two dialect registries. Keep it a peer. --- # Instrument catalog Source: packages/synths/CATALOG.md Every voice in `@spacedevin/deck-synths`, generated from `generatorCatalog()` and `defaultParamsForGeneratorId()` so it cannot drift from the code. Select one with `track id gen `. Parameters are set with `gen ` in snake_case — `sweep_amt 26` reaches the voice as `sweepAmt`. Values below are the defaults. | Voices | Count | |---|---| | Chip emulations | 8 | | Hard sync | 4 | | Analog & electronic | 7 | | Atmospheric | 4 | | Acoustic models | 3 | | Percussion | 4 | | Vocal | 3 | | **Total** | **33** | ## Chip emulations ### `gameBoyDmg` — LR35902 Game Boy DMG APU emulator (100% hardware parity). | Parameter | Default | |---|---| | `type` | `"pulse"` | | `duty` | `"50"` | | `envMode` | `"step"` | | `vol` | `15` | | `sweep` | `0` | | `noiseMode` | `"long"` | | `waveShape` | `"saw"` | | `attack` | `0` | | `decay` | `0` | | `sustain` | `15` | | `release` | `0` | | `pitchDrop` | `0` | | `pitchDec` | `0.05` | | `vibRate` | `0` | | `vibAmt` | `0` | | `arpRate` | `0` | | `arpSemis` | `0` | | `len` | `0` | | `envStep` | `0` | | `envUp` | `false` | | `sweepShift` | `0` | | `sweepPeriod` | `0` | | `sweepDown` | `false` | | `noiseShift` | `null` | | `noiseRatio` | `0` | Factory presets: - **GB Pulse Lead** — `{ channel: "pulse_50", decay: 0.15, sweep: 0 }` - **GB Bass** — `{ channel: "wavetable", waveShape: "sawtooth", decay: 0.3, sweep: 0 }` - **25% Pulse** — `{ type: "pulse", duty: "25", envMode: "step", vol: 15, sweep: 2, attack: 0, decay: 0, sustain: 15, release: 0 }` - **Wavetable Crunch** — `{ type: "wave", waveVol: "100", attack: 0, decay: 0, sustain: 15, release: 0 }` ### `nes2a03` — 2A03 NES APU emulator (100% hardware parity). | Parameter | Default | |---|---| | `type` | `"pulse"` | | `duty` | `"50"` | | `envMode` | `"decay"` | | `vol` | `10` | | `sweep` | `0` | | `noiseMode` | `"long"` | | `attack` | `0` | | `decay` | `0` | | `sustain` | `15` | | `release` | `0` | | `vibRate` | `0` | | `vibAmt` | `0` | | `arpRate` | `0` | | `arpSemis` | `0` | | `pitchDrop` | `0` | | `pitchDec` | `0.05` | | `dutySweep` | `0` | | `dpcmSample` | `"kick"` | Factory presets: - **NES Pulse 50%** — `{ channel: "pulse_50", decay: 0.2, sweep: 0 }` - **NES Pulse 25%** — `{ channel: "pulse_25", decay: 0.2, sweep: 0 }` - **NES Pulse 12.5%** — `{ channel: "pulse_12_5", decay: 0.25, sweep: 0 }` - **NES Triangle** — `{ channel: "triangle", decay: 0.4, sweep: 0 }` - **NES Noise** — `{ channel: "noise", decay: 0.1, sweep: 0 }` - **NES Laser Sweep** — `{ channel: "pulse_50", decay: 0.2, sweep: -12 }` - **Pulse Lead** — `{ type: "pulse", duty: "50", envMode: "decay", vol: 12, attack: 0, decay: 3, sustain: 10, release: 2 }` - **12.5% Pluck** — `{ type: "pulse", duty: "12_5", envMode: "decay", vol: 15, attack: 0, decay: 6, sustain: 0, release: 1 }` - **Triangle Bass** — `{ type: "triangle", vol: 15, attack: 0, decay: 0, sustain: 15, release: 0 }` ### `c64sid` — MOS 6581 Commodore 64 SID chip emulator (100% hardware parity). | Parameter | Default | |---|---| | `waveform` | `"sawtooth"` | | `pulseWidth` | `0.5` | | `filterType` | `"lowpass"` | | `cutoff` | `2000` | | `resonance` | `5` | | `attack` | `0` | | `decay` | `5` | | `sustain` | `15` | | `release` | `6` | | `hardSync` | `false` | | `ringMod` | `false` | | `pitchDrop` | `0` | | `pitchDec` | `0.05` | | `vibRate` | `0` | | `vibAmt` | `0` | | `arpRate` | `0` | | `arpSemis` | `0` | Factory presets: - **C64 SID Lead** — `{ waveform: "sawtooth", pulseWidth: 0.5, filterType: "lowpass", cutoff: 3500, resonance: 6, attack: 0.008, decay: 0.2, sustain: 0.6, release: 0.1 }` - **C64 SID Bass** — `{ waveform: "pulse", pulseWidth: 0.4, filterType: "lowpass", cutoff: 1200, resonance: 8, attack: 0.005, decay: 0.35, sustain: 0.4, release: 0.08 }` - **C64 PWM Pad** — `{ waveform: "pulse", pulseWidth: 0.3, filterType: "lowpass", cutoff: 2000, resonance: 4, attack: 0.08, decay: 0.5, sustain: 0.7, release: 0.3 }` - **SNES Warm Pad** — `{ waveform: "pulse", pulseWidth: 0.5, filterType: "lowpass", cutoff: 1500, resonance: 2, attack: 0.12, decay: 0.5, sustain: 0.65, release: 0.4 }` - **SNES Lead** — `{ waveform: "sawtooth", pulseWidth: 0.5, filterType: "lowpass", cutoff: 2500, resonance: 3, attack: 0.008, decay: 0.25, sustain: 0.6, release: 0.12 }` - **SID Brass** — `{ waveform: "sawtooth", pulseWidth: 0.5, filterType: "lowpass", cutoff: 4000, resonance: 2, attack: 0.03, decay: 0.3, sustain: 0.7, release: 0.15 }` - **Hard Sync Lead** — `{ waveform: "sawtooth", hardSync: true, attack: 2, decay: 5, sustain: 10, release: 6, pitchDrop: -12, pitchDec: 0.2 }` - **Filter Bass** — `{ waveform: "pulse", pulseWidth: 0.5, filterType: "lowpass", cutoff: 600, resonance: 8, attack: 0, decay: 4, sustain: 4, release: 3 }` ### `ym2612` — YM2612 Sega Genesis FM Synth emulator (References: Nuked-OPN2, Genesis Plus GX). | Parameter | Default | |---|---| | `algorithm` | `0` | | `feedback` | `0` | | `op1_mul` | `1` | | `op1_tl` | `0` | | `op1_ar` | `31` | | `op1_dr` | `5` | | `op1_sr` | `5` | | `op1_rr` | `5` | | `op1_sl` | `0` | | `op2_mul` | `1` | | `op2_tl` | `0` | | `op2_ar` | `31` | | `op2_dr` | `5` | | `op2_sr` | `5` | | `op2_rr` | `5` | | `op2_sl` | `0` | | `op3_mul` | `1` | | `op3_tl` | `0` | | `op3_ar` | `31` | | `op3_dr` | `5` | | `op3_sr` | `5` | | `op3_rr` | `5` | | `op3_sl` | `0` | | `op4_mul` | `1` | | `op4_tl` | `0` | | `op4_ar` | `31` | | `op4_dr` | `5` | | `op4_sr` | `5` | | `op4_rr` | `5` | | `op4_sl` | `0` | | `pitchDrop` | `0` | | `pitchDec` | `0.05` | | `vibRate` | `0` | | `vibAmt` | `0` | | `arpRate` | `0` | | `arpSemis` | `0` | Factory presets: - **Genesis E-Piano** — `{ algorithm: 4, feedback: 0, op1_mul: 1, op1_tl: 0, op1_ar: 31, op1_dr: 12, op1_sr: 5, op1_rr: 8, op1_sl: 5, op2_mul: 1, op2_tl: 20, op2_ar: 31, op2_dr: 15, op2_sr: 5, op2_rr: 8, op2_sl: 5, op3_mul: 4, op3_tl: 30, op3_ar: 31, op3_dr: 18, op3_sr: 5, op3_rr: 8, op3_sl: 5, op4_mul: 1, op4_tl: 10, op4_ar: 31, op4_dr: 10, op4_sr: 5, op4_rr: 8, op4_sl: 5 }` - **Genesis Brass** — `{ algorithm: 1, feedback: 5, op1_mul: 1, op1_tl: 0, op1_ar: 20, op1_dr: 15, op1_sr: 5, op1_rr: 10, op1_sl: 2, op2_mul: 2, op2_tl: 15, op2_ar: 22, op2_dr: 12, op2_sr: 5, op2_rr: 10, op2_sl: 2, op3_mul: 1, op3_tl: 5, op3_ar: 18, op3_dr: 10, op3_sr: 5, op3_rr: 10, op3_sl: 2, op4_mul: 1, op4_tl: 0, op4_ar: 24, op4_dr: 8, op4_sr: 5, op4_rr: 10, op4_sl: 2 }` ### `sn76489` — SN76489 Sega Master System / Genesis PSG emulator (References: MAME). | Parameter | Default | |---|---| | `type` | `"square"` | | `noiseMode` | `"white"` | | `noiseFreq` | `0` | | `vol` | `15` | | `attack` | `0` | | `decay` | `0.1` | | `sustain` | `15` | | `release` | `0` | | `pitchDrop` | `0` | | `pitchDec` | `0.05` | | `vibRate` | `0` | | `vibAmt` | `0` | | `arpRate` | `0` | | `arpSemis` | `0` | Factory presets: - **PSG Square Lead** — `{ type: "square", vol: 15, attack: 0, decay: 0.1, sustain: 15, release: 0 }` ### `spc700` — SPC700 Super Nintendo S-DSP emulator (References: bsnes, snes9x). | Parameter | Default | |---|---| | `waveform` | `"strings"` | | `attack` | `0` | | `decay` | `3` | | `sustainLevel` | `7` | | `sustainRate` | `0` | | `echoEnable` | `false` | | `echoDelay` | `4` | | `echoFeedback` | `0` | | `echoFir` | `0` | | `pitchDrop` | `0` | | `pitchDec` | `0.05` | | `vibRate` | `0` | | `vibAmt` | `0` | | `arpRate` | `0` | | `arpSemis` | `0` | Factory presets: - **Echo Strings** — `{ waveform: "strings", attack: 10, decay: 3, sustainLevel: 7, sustainRate: 0, echoEnable: true, echoDelay: 8, echoFeedback: 40 }` - **Warm Brass** — `{ waveform: "brass", attack: 6, decay: 5, sustainLevel: 5, sustainRate: 0, echoEnable: false, echoDelay: 4, echoFeedback: 0 }` ### `gbaDirectSound` — GBA DirectSound Game Boy Advance 8-bit DAC software mixing simulator. | Parameter | Default | |---|---| | `waveform` | `"pulse"` | | `duty` | `"50"` | | `vol` | `15` | | `attack` | `0` | | `decay` | `2` | | `sustain` | `15` | | `release` | `0` | | `bitcrush` | `true` | | `pitchDrop` | `0` | | `pitchDec` | `0.05` | | `vibRate` | `0` | | `vibAmt` | `0` | | `arpRate` | `0` | | `arpSemis` | `0` | Factory presets: - **Software Saw Lead** — `{ waveform: "sawtooth", duty: "50", attack: 0, decay: 1, sustain: 15, release: 0, bitcrush: true }` - **Pulse Chug** — `{ waveform: "pulse", duty: "25", attack: 0, decay: 0.1, sustain: 0, release: 0.2, bitcrush: true }` ### `chiptune` — Chiptune Retro 8-bit pulse-width modulation and decimation crush. | Parameter | Default | |---|---| | `waveform` | `"pulse"` | | `pulseWidth` | `0.5` | | `pwmSpeed` | `0` | | `bitcrush` | `0` | | `lowpass` | `0` | | `arpRate` | `0` | | `arpSemis` | `0` | | `attack` | `0.005` | | `decay` | `0.3` | | `sustain` | `0` | | `release` | `0.05` | ## Hard sync ### `syncLead` — Sync Lead Aggressive true hard sync analog lead with an automated sweep envelope. | Parameter | Default | |---|---| | `masterTune` | `0` | | `slaveBase` | `12` | | `sweepAmt` | `24` | | `sweepDecay` | `0.4` | | `lfoRate` | `0` | | `lfoAmt` | `0` | | `cutoff` | `3000` | | `resonance` | `5` | | `filterEnvAmt` | `0` | | `filterDecay` | `0.4` | | `attack` | `0.05` | | `decay` | `0.3` | | `sustain` | `0.8` | | `release` | `0.5` | Factory presets: - **Sync Screamer** — `{ slaveBase: 19, sweepAmt: 36, sweepDecay: 0.25, cutoff: 5000, resonance: 3, filterEnvAmt: 0, attack: 0.02, decay: 0.2, sustain: 0.9, release: 0.3 }` - **Sync Pluck** — `{ slaveBase: 12, sweepAmt: 24, sweepDecay: 0.15, cutoff: 2400, resonance: 6, filterEnvAmt: 0.3, filterDecay: 0.2, attack: 0.005, decay: 0.15, sustain: 0, release: 0.2 }` - **Sync Talk Box** — `{ slaveBase: 5, sweepAmt: 12, sweepDecay: 0.6, lfoRate: 3.5, lfoAmt: 8, cutoff: 1800, resonance: 8, attack: 0.08, decay: 0.4, sustain: 0.7, release: 0.4 }` ### `syncChoir` — Sync Choir Lush, robotic 80s analog choir built from detuned hard sync formants. | Parameter | Default | |---|---| | `vowelShift` | `24` | | `morphRate` | `0.5` | | `morphAmt` | `12` | | `ensembleDetune` | `15` | | `vibRate` | `5` | | `vibAmt` | `10` | | `highpass` | `300` | | `attack` | `1` | | `decay` | `1` | | `sustain` | `0.8` | | `release` | `1.5` | Factory presets: - **Android Choir** — `{ vowelShift: 24, morphRate: 0.5, morphAmt: 12, ensembleDetune: 15, vibRate: 5.0, vibAmt: 10, highpass: 300, attack: 1.0, decay: 1.0, sustain: 0.8, release: 1.5 }` - **Slow Morph Pad** — `{ vowelShift: 19, morphRate: 0.12, morphAmt: 24, ensembleDetune: 20, vibRate: 4.0, vibAmt: 8, highpass: 200, attack: 2.0, decay: 1.5, sustain: 0.85, release: 2.5 }` - **Tight Ensemble** — `{ vowelShift: 12, morphRate: 0.8, morphAmt: 6, ensembleDetune: 6, vibRate: 6.5, vibAmt: 14, highpass: 400, attack: 0.3, decay: 0.5, sustain: 0.9, release: 0.8 }` ### `obSync` — OB Sync Massive, creamy dual-oscillator hard sync synth with Oberheim-style width and a sweeping filter. | Parameter | Default | |---|---| | `detune` | `15` | | `sweepRate` | `0.5` | | `sweepAmt` | `24` | | `cutoff` | `1200` | | `resonance` | `2` | | `filterEnv` | `2400` | | `filterDecay` | `0.8` | | `attack` | `0.1` | | `decay` | `0.4` | | `sustain` | `0.6` | | `release` | `0.5` | Factory presets: - **Oberheim Jump** — `{ detune: 18, sweepRate: 0.7, sweepAmt: 30, cutoff: 2000, resonance: 3, filterEnv: 3200, filterDecay: 0.5, attack: 0.04, decay: 0.3, sustain: 0.7, release: 0.3 }` - **OB Pad** — `{ detune: 12, sweepRate: 0.2, sweepAmt: 12, cutoff: 800, resonance: 1.5, filterEnv: 1600, filterDecay: 1.2, attack: 0.6, decay: 0.8, sustain: 0.8, release: 1.5 }` ### `laserSync` — Laser Sync Punchy, retro-arcade zap. A rapid pitch-dropping master oscillator ripping through a static sync slave. | Parameter | Default | |---|---| | `dropRate` | `0.8` | | `dropAmt` | `36` | | `slaveBase` | `18` | | `attack` | `0.01` | | `decay` | `0.3` | Factory presets: - **Arcade Zap** — `{ dropRate: 0.9, dropAmt: 48, slaveBase: 24, attack: 0.005, decay: 0.15 }` - **Laser Sweep** — `{ dropRate: 0.3, dropAmt: 24, slaveBase: 12, attack: 0.02, decay: 0.6 }` ## Analog & electronic ### `acid303` — Acid 303 Classic 303-style bassline with resonant filter and envMod sweep. | Parameter | Default | |---|---| | `waveform` | `"sawtooth"` | | `cutoff` | `800` | | `resonance` | `15` | | `envMod` | `4000` | | `decay` | `0.4` | Factory presets: - **Classic Acid** — `{ waveform: "square", cutoff: 600, resonance: 18, envMod: 5000, decay: 0.35 }` - **Acid Squelch** — `{ waveform: "sawtooth", cutoff: 400, resonance: 22, envMod: 6000, decay: 0.2 }` - **Deep Acid** — `{ waveform: "sawtooth", cutoff: 500, resonance: 12, envMod: 3000, decay: 0.6 }` ### `sub808` — Sub 808 Heavy sine wave sub-bass with punch and drive saturation. | Parameter | Default | |---|---| | `punch` | `60` | | `decay` | `1.2` | | `drive` | `0` | | `glide` | `0.05` | Factory presets: - **808 Long Tail** — `{ punch: 48, decay: 2.5, drive: 0.15, glide: 0.08 }` - **808 Distorted** — `{ punch: 72, decay: 1.0, drive: 0.6, glide: 0.03 }` ### `reeseBass` — Reese Bass Thick, multi-oscillator detuned Supersaw bass with filter wobble. | Parameter | Default | |---|---| | `voices` | `3` | | `detune` | `30` | | `cutoff` | `1500` | | `wobble` | `0` | | `decay` | `1` | Factory presets: - **DnB Reese** — `{ voices: 5, detune: 40, cutoff: 1200, wobble: 0.6, decay: 1.5 }` - **Minimal Sub Reese** — `{ voices: 2, detune: 15, cutoff: 800, wobble: 0, decay: 1.2 }` ### `basicOsc` — Basic OSC Single oscillator + ADSR in generator params. | Parameter | Default | |---|---| | `waveform` | `"sine"` | | `attack` | `0.005` | | `decay` | `0.08` | | `sustain` | `0.4` | | `release` | `0.12` | Factory presets: - **Saw Lead** — `{ waveform: "sawtooth", attack: 0.01, decay: 0.15, sustain: 0.7, release: 0.2 }` - **Square Sub** — `{ waveform: "square", attack: 0.005, decay: 0.3, sustain: 0.8, release: 0.15 }` ### `fmTone` — FM tone Two-operator FM + ADSR in generator params. | Parameter | Default | |---|---| | `ratio` | `2` | | `modIndex` | `4` | | `carrierWave` | `"sine"` | | `modWave` | `"sine"` | | `attack` | `0.005` | | `decay` | `0.08` | | `sustain` | `0.4` | | `release` | `0.12` | Factory presets: - **FM E-Piano** — `{ ratio: 1, modIndex: 3, carrierWave: "sine", modWave: "sine", attack: 0.003, decay: 0.8, sustain: 0.15, release: 0.4 }` - **FM Brass** — `{ ratio: 1, modIndex: 6, carrierWave: "sine", modWave: "square", attack: 0.06, decay: 0.3, sustain: 0.7, release: 0.25 }` ### `matrixFm` — Matrix FM Multi-operator FM/RM graph via deck gen_block (Sytrus-style). ### `patch` — Patch Modular synth patch (gen_block patch): osc/noise/filter/shaper/gain + breakpoint envelopes — any voice, written in deck. ## Atmospheric ### `pad` — Pad Detuned triple-osc + lowpass + slow env — ethereal, reverb-friendly. | Parameter | Default | |---|---| | `wave1` | `"sine"` | | `wave2` | `"triangle"` | | `detune` | `9` | | `cutoff` | `2200` | | `attack` | `0.08` | | `decay` | `0.25` | | `sustain` | `0.6` | | `release` | `0.7` | Factory presets: - **Warm Blanket** — `{ wave1: "triangle", wave2: "sine", detune: 14, cutoff: 1200, attack: 0.6, decay: 0.8, sustain: 0.75, release: 2.0 }` - **Glass Shimmer** — `{ wave1: "sawtooth", wave2: "square", detune: 8, cutoff: 4000, attack: 0.3, decay: 0.4, sustain: 0.6, release: 1.2 }` ### `aether` — Aether Theremin — eerie, voice-like heterodyne tone with a portamento swoop into each note, a living two-hand pitch/amplitude waver, and a breathy volume-hand swell. | Parameter | Default | |---|---| | `glide` | `0.4` | | `waver` | `0.5` | | `tone` | `0.3` | | `swell` | `0.45` | | `air` | `0.25` | Factory presets: - **Classic Theremin** — `{ glide: 0.4, waver: 0.5, tone: 0.3, swell: 0.45, air: 0.25 }` - **Sci-Fi Wail** — `{ glide: 0.7, waver: 0.8, tone: 0.5, swell: 0.6, air: 0.4 }` ### `halo` — Halo Hang drum / handpan — lush inharmonic octave + compound-fifth shimmer triad over a long ethereal ring, with a soft fingertip strike and a warm 'gu' body. | Parameter | Default | |---|---| | `temper` | `0.4` | | `ring` | `0.55` | | `mallet` | `0.35` | | `bloom` | `0.5` | | `lows` | `0.45` | Factory presets: - **Meditation Bowl** — `{ temper: 0.3, ring: 0.8, mallet: 0.2, bloom: 0.65, lows: 0.6 }` - **Steel Tongue** — `{ temper: 0.55, ring: 0.35, mallet: 0.6, bloom: 0.3, lows: 0.35 }` ### `bell` — Bell Inharmonic sine partials + highpass + bell decay — metallic shimmer. | Parameter | Default | |---|---| | `partial` | `2.01` | | `highpass` | `800` | | `decay` | `1` | Factory presets: - **Crystal Chime** — `{ partial: 3.01, highpass: 1200, decay: 2.0 }` - **Dark Bell** — `{ partial: 1.41, highpass: 400, decay: 1.8 }` ## Acoustic models ### `arco` — Arco Bowed strings — violin · viola · cello · bass · fiddle. Stick-slip saw through real body-resonance formants, with bow pressure, articulation, vibrato and rosin noise. Only string-player controls. | Parameter | Default | |---|---| | `voice` | `"violin"` | | `pressure` | `0.5` | | `bow` | `0.4` | | `vibrato` | `0.35` | | `rosin` | `0.3` | | `body` | `0.6` | Factory presets: - **Solo Violin** — `{ voice: "violin", pressure: 0.55, bow: 0.45, vibrato: 0.4, rosin: 0.25, body: 0.65 }` - **Cello Legato** — `{ voice: "cello", pressure: 0.6, bow: 0.5, vibrato: 0.5, rosin: 0.2, body: 0.75 }` - **Country Fiddle** — `{ voice: "fiddle", pressure: 0.7, bow: 0.55, vibrato: 0.3, rosin: 0.55, body: 0.5 }` - **Upright Bass** — `{ voice: "bass", pressure: 0.45, bow: 0.35, vibrato: 0.2, rosin: 0.15, body: 0.8 }` ### `tine` — Tine Rhodes-style electric piano — velocity-barked FM tine (hard = metallic bark, soft = mellow bell), a metal tine ping, EP decay, and lush suitcase tremolo. | Parameter | Default | |---|---| | `bark` | `0.55` | | `tine` | `0.6` | | `tremolo` | `0.35` | | `decay` | `0.5` | | `drive` | `0.2` | Factory presets: - **Suitcase Warm** — `{ bark: 0.35, tine: 0.4, tremolo: 0.6, decay: 0.65, drive: 0.15 }` - **Stage Bright** — `{ bark: 0.7, tine: 0.75, tremolo: 0, decay: 0.45, drive: 0.3 }` - **Neo Soul Keys** — `{ bark: 0.45, tine: 0.55, tremolo: 0.25, decay: 0.7, drive: 0.1 }` ### `guitar` — Guitar Karplus-Strong plucked electric guitar — string model + palm mute + drive + body. Pairs with the chord voice for strums. | Parameter | Default | |---|---| | `tone` | `0.5` | | `decay` | `0.6` | | `damping` | `0.4` | | `drive` | `0.25` | | `body` | `3500` | | `mute` | `0` | Factory presets: - **Clean Electric** — `{ tone: 0.55, decay: 0.6, damping: 0.35, drive: 0.1, body: 3800, mute: 0 }` - **Palm Mute Chug** — `{ tone: 0.35, decay: 0.3, damping: 0.55, drive: 0.45, body: 2800, mute: 0.7 }` - **Nylon Acoustic** — `{ tone: 0.72, decay: 0.75, damping: 0.25, drive: 0, body: 4200, mute: 0 }` - **Bass Guitar** — `{ tone: 0.28, decay: 0.55, damping: 0.6, drive: 0.15, body: 1800, mute: 0 }` ## Percussion ### `drumSynth` — Drum Pitch-envelope drum synth — punchy kicks, snares, toms, 808s (+ noise + drive). | Parameter | Default | |---|---| | `tone` | `"sine"` | | `pitchEnv` | `36` | | `pitchDecay` | `0.06` | | `decay` | `0.35` | | `noise` | `0` | | `noiseDecay` | `0.12` | | `noiseHp` | `1500` | | `drive` | `0` | Factory presets: - **TR-909 Kick** — `{ tone: "sine", pitchEnv: 30, pitchDecay: 0.035, decay: 0.3, noise: 0.05, drive: 0.2 }` - **Boom Bap Kick** — `{ tone: "sine", pitchEnv: 38, pitchDecay: 0.07, decay: 0.55, noise: 0, drive: 0.08 }` - **Rim Shot** — `{ tone: "triangle", pitchEnv: 12, pitchDecay: 0.015, decay: 0.06, noise: 0.5, noiseHp: 3000, noiseDecay: 0.04, drive: 0.15 }` - **Tom Low** — `{ tone: "sine", pitchEnv: 24, pitchDecay: 0.04, decay: 0.35, noise: 0.1, drive: 0.05 }` - **Tom High** — `{ tone: "sine", pitchEnv: 18, pitchDecay: 0.03, decay: 0.25, noise: 0.12, drive: 0.05 }` ### `clap` — Clap The Clap — handclap engine: hand count, timing spread, hand size, brightness, room tail, and clusters (single / double / many-hand crowd). | Parameter | Default | |---|---| | `hands` | `3` | | `spread` | `0.4` | | `size` | `0.5` | | `tone` | `0.5` | | `claps` | `1` | | `gap` | `0.35` | | `tail` | `0.4` | | `body` | `0.2` | Factory presets: - **Tight Clap** — `{ hands: 2, spread: 0.2, size: 0.3, tone: 0.6, claps: 1, gap: 0.2, tail: 0.25, body: 0.15 }` - **Crowd Clap** — `{ hands: 8, spread: 0.7, size: 0.7, tone: 0.45, claps: 3, gap: 0.4, tail: 0.6, body: 0.35 }` ### `cymbal` — Cymbal TR-808 style cymbal cluster (6 tuned squares + noise + highpass). | Parameter | Default | |---|---| | `tune` | `300` | | `metallic` | `0.8` | | `decay` | `0.4` | | `highpass` | `7000` | Factory presets: - **Ride Cymbal** — `{ tune: 340, metallic: 0.7, decay: 0.8, highpass: 6000 }` - **Crash** — `{ tune: 280, metallic: 0.9, decay: 1.5, highpass: 5000 }` ### `noiseBurst` — Noise burst Filtered noise; attack + decay shape the hit. | Parameter | Default | |---|---| | `attack` | `0.002` | | `decay` | `0.07` | | `tone` | `0.45` | | `pitchFollow` | `0.25` | ## Vocal ### `formantVocal` — Formant Vocal Expressive formant synthesizer with gliding notes and vibrato. | Parameter | Default | |---|---| | `glide` | `0.1` | | `vibDepth` | `0.02` | | `vibRate` | `5` | | `humanize` | `0.5` | | `release` | `0.2` | ### `ttsVocal` — TTS Vocal Web Speech API Text-to-Speech engine for robotic vocal sequences. | Parameter | Default | |---|---| | `voice` | `0` | | `rate` | `1.5` | ### `meSpeakVocal` — meSpeak Vocal Retro robotic TTS using meSpeak.js with sample-accurate timing. | Parameter | Default | |---|---| | `pitch` | `50` | | `speed` | `175` | | `wordgap` | `0` | | `variant` | `"m1"` | | `amplitude` | `100` | --- # Contributing Source: CONTRIBUTING.md Thanks for looking. This repo is small on purpose, and most useful contributions are small too: a song, a voice, a grammar case, a doc fix. This page is the setup and the recipes. ## Setup You need **Node 22+** and the [Tish](https://github.com/tishlang/tish) compiler, which comes in as a dev dependency. Chrome or Chromium is only needed for the WAV renderer. ```bash git clone https://github.com/spacedevin/deck cd deck npm install npm test ``` `npm test` builds the language package, runs the API and grammar suite with a 100% line-coverage gate, the conformance corpus, every song in `docs/EXAMPLES.md`, and the Tish and JS smoke tests. The workspaces have their own tests: ```bash npm test -w @spacedevin/deck-player npm run build -w @spacedevin/deck-synths ``` And the docs site, which is how you preview any markdown change: ```bash npm run site:serve # http://localhost:4321 ``` ## How the repo is laid out | Path | Package | Owns | |---|---|---| | `/` | `@spacedevin/deck` | the language: tokenize, parse, registries, highlight. **No audio.** | | `packages/synths/` | `@spacedevin/deck-synths` | the 33 voices and the dispatch that picks one | | `packages/player/` | `@spacedevin/deck-player` | Song IR, defaults and clamps, transport, offline render, `` | | `conformance/` | — | the parse contract every implementation is checked against | | `crate/` | `deckfile` | **generated** from `src/` by `npm run build:rust`; never edit by hand | | `site/` | — | the docs-site generator; markdown is read in place from the paths above | Dependencies point one way: player → synths → deck. Each package has an `AGENTS.md` saying what belongs in it and what does not. Read the one for the package you're touching; the boundaries are the thing this repo cares most about. Tish is the source language and it has gotchas: there is **no `class` syntax** (the build emits JS that doesn't parse), and `undefined` is not a value under `tish run`. `packages/player/AGENTS.md` explains why the custom element is plain JS for that reason. ## Commit messages Releases are cut by [sem](https://github.com/tishlang/sem) from Conventional Commits, so the type you pick decides whether a version ships: | Type | Effect | |---|---| | `feat:` | minor release | | `fix:`, `perf:` | patch release | | `feat!:` or a `BREAKING CHANGE:` footer | major release | | `docs:`, `chore:`, `ci:`, `test:`, `refactor:` | no release | Scope with the package when it helps: `feat(synths): …`, `fix(player): …`, `docs(examples): …`. A green `main` cuts a prerelease with all three tarballs; promoting it publishes to npm and crates.io. The PR title becomes the squash commit, so write it as the commit. ## Recipes ### Add a song to the examples 1. Add a `deck` fenced block to `docs/EXAMPLES.md` under the right heading, with a sentence saying what it demonstrates. 2. `npm run test:examples` — every block must parse without errors and produce at least one sounding channel. That is also the rule the site uses to decide whether to show a play button. 3. `npm run site:serve` and press play on it. Grammar-reference snippets with `` belong in `docs/DECK_GRAMMAR.md`; complete songs belong in `docs/EXAMPLES.md`. ### Add a voice 1. Create `packages/synths/src/.tish` exporting `play(ctx, bus, t, midi, vel, durSec, ch, bendSemis)`. Build a short-lived Web Audio subgraph, connect its last node to `bus.input`, and disconnect the nodes once the tail has passed (the existing voices schedule that themselves; look at `GameBoyDmg.tish` for the shape). A voice may instead return `{ stopTime, disconnects }` and let the player prune it per step. 2. Register it: an entry in `src/Registry.tish` (id, label, description, default `generatorParams`) and a case in `src/Dispatch.tish`. Param aliases or a `gen_block` dialect go in `src/DeckIds.tish`. 3. Seed anything random. Two renders of one song must be identical. 4. Add a song for it to `docs/EXAMPLES.md` (recipe above) and a line to the voices table in `packages/synths/README.md`. 5. `npm test` and `npm test -w @spacedevin/deck-player`. ### Change the grammar 1. Change `src/deckfile/*.tish`. The parser is parse-only: no defaults, no clamping, no range checks. Those are host policy and belong in the player. 2. Update `docs/DECK_GRAMMAR.md` — it is the canonical reference — and `docs/AST.md` if the shape changed. 3. Regenerate the corpus with `npm run conformance:update` and **review the diff**. A new case means every profile in `conformance/profiles.json` must say where it stands. 4. `npm run test:rust` to confirm the Rust emit still agrees. 5. Keywords for highlighting live in `src/deckfile/Highlight.tish`; the site picks them up from there. ### Add or fix a doc page The site is a view over the markdown already in the repo. Drop a `.md` under `docs/`, `packages/player/` or `packages/synths/` and it appears in the nav, in `llms.txt` and in `llms-full.txt` on the next build. There is no route to register. Don't add YAML frontmatter to a file that ships in an npm tarball (`README.md`, `AGENTS.md`); npm renders it as a stray heading. Use the per-section title override in `site/build.mjs` instead. ## Pull requests - Keep a PR to one package where you can; the template asks which. - Tests pass, the conformance diff is reviewed if you touched the parser, and the docs say what the code now does. - No CHANGELOG edits: the release notes are generated from the commits. ## Reporting a bug Open an [issue](https://github.com/spacedevin/deck/issues/new/choose). The most useful bug report is the smallest `.deck` that shows it, plus what you expected to hear or parse. If the parsers disagree with each other, that is a conformance case waiting to be written.