From 76a6b7f72225df9c2699038a4b78844891a956e8 Mon Sep 17 00:00:00 2001 From: spacedevin Date: Thu, 27 Aug 2026 16:02:44 -0700 Subject: [PATCH 1/2] feat: adopt `wave` into the language, with a generative `harmonics` form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wave <32 hex nibbles>` was never part of the grammar. It was a host-registered top-level statement, absent from DECK_GRAMMAR.md and from Highlight's TOP_KEYS, and every host that needed it registered its own copy — tish-gba (which it came from), and deck-player. Parser.tish already named the consequence: the hook exists because, without it, "the GBA implementation ended up a separate grammar rather than a subset". A statement every host implements is a language feature living in the wrong place. The corpus that exists to stop that drift never saw a `wave` line, since conformance parses with no host registrations. So `wave` is core now, and profiles.json no longer carries it as a GBA extension. On top of that, the hex is a poor way to say what a table is: timbre is harmonic content, and the digits had to be computed somewhere else and pasted in. `wave harmonics …` takes amplitudes — fundamental first — and sums them into the same 32 levels, normalized so only the ratios matter. Expanded at parse time, exactly as `steps euclid` is, and for the same reason: a host reads `levels` and never learns which spelling produced it. The source form is kept alongside so an emitter can write `harmonics` back rather than flattening it to hex. 013-wave pins that the two spellings resolve to identical levels, in JS, in the Tish VM, and in the generated Rust crate. The clamps in the additive path are gone rather than covered: normalizing by the peak lands every sample in 0..15 by construction. What is guarded instead is the case that was actually reachable — a non-finite amplitude, which would have put NaN in the table. --- AGENTS.md | 2 +- .../001-comments-and-sharps.expected.json | 1 + conformance/002-track-header.expected.json | 1 + conformance/003-steps-and-locks.expected.json | 1 + conformance/004-notes.expected.json | 1 + .../005-mix-fx-voice-deck.expected.json | 1 + conformance/006-globals.expected.json | 1 + .../007-session-song-follow.expected.json | 1 + conformance/008-macros-genblock.expected.json | 1 + conformance/009-directives.expected.json | 1 + conformance/010-errors.expected.json | 1 + conformance/011-golden.expected.json | 1 + conformance/012-gba-subset.expected.json | 1 + conformance/013-wave.deck | 18 + conformance/013-wave.expected.json | 339 ++++++++++++++++++ conformance/profiles.json | 7 +- docs/AST.md | 2 + docs/DECK_EXTENSION.md | 11 +- docs/DECK_GRAMMAR.md | 27 +- packages/player/README.md | 5 +- packages/player/src/song/Apply.tish | 40 +-- packages/player/src/song/DeckIds.tish | 15 +- packages/player/test/song.mjs | 23 +- rust/conformance.rs | 23 ++ rust/facade.rs | 34 ++ src/deckfile/Highlight.tish | 1 + src/deckfile/Parser.tish | 52 +++ src/deckfile/Wavetable.tish | 91 +++++ src/index.tish | 1 + test/coverage.mjs | 61 +++- 30 files changed, 704 insertions(+), 60 deletions(-) create mode 100644 conformance/013-wave.deck create mode 100644 conformance/013-wave.expected.json create mode 100644 src/deckfile/Wavetable.tish diff --git a/AGENTS.md b/AGENTS.md index 84424dc..e84beb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ audio-free; everything that list excludes lives in `packages/player/`, which has clamping, no defaults, no range checks. Those are host policy and the hosts genuinely differ. - Format helpers (`formatTplBeat`, `formatTplFloat`) - Scale root/mode vocabulary -- Bar selectors + Euclidean step fill +- Bar selectors + Euclidean step fill + wavetables (`wave`, including the `harmonics` fill) - Empty registries: generator id aliases, param key aliases, macros, gen_block dialects, body-line dialects, host top-level statements - Highlight classification (`classifyLine` / keyword sets) diff --git a/conformance/001-comments-and-sharps.expected.json b/conformance/001-comments-and-sharps.expected.json index 5676136..b305ce7 100644 --- a/conformance/001-comments-and-sharps.expected.json +++ b/conformance/001-comments-and-sharps.expected.json @@ -46,6 +46,7 @@ "scaleMode": "minor", "deckMix": null, "directives": [], + "waves": [], "hostStatements": {}, "errors": [] }, diff --git a/conformance/002-track-header.expected.json b/conformance/002-track-header.expected.json index db6e21d..a970a4f 100644 --- a/conformance/002-track-header.expected.json +++ b/conformance/002-track-header.expected.json @@ -153,6 +153,7 @@ "scaleMode": null, "deckMix": null, "directives": [], + "waves": [], "hostStatements": {}, "errors": [] }, diff --git a/conformance/003-steps-and-locks.expected.json b/conformance/003-steps-and-locks.expected.json index 6cc3d39..20835ee 100644 --- a/conformance/003-steps-and-locks.expected.json +++ b/conformance/003-steps-and-locks.expected.json @@ -211,6 +211,7 @@ "scaleMode": null, "deckMix": null, "directives": [], + "waves": [], "hostStatements": {}, "errors": [] }, diff --git a/conformance/004-notes.expected.json b/conformance/004-notes.expected.json index 3279caa..99221f8 100644 --- a/conformance/004-notes.expected.json +++ b/conformance/004-notes.expected.json @@ -123,6 +123,7 @@ "scaleMode": null, "deckMix": null, "directives": [], + "waves": [], "hostStatements": {}, "errors": [] }, diff --git a/conformance/005-mix-fx-voice-deck.expected.json b/conformance/005-mix-fx-voice-deck.expected.json index 240dafc..dc4951a 100644 --- a/conformance/005-mix-fx-voice-deck.expected.json +++ b/conformance/005-mix-fx-voice-deck.expected.json @@ -133,6 +133,7 @@ "scaleMode": null, "deckMix": null, "directives": [], + "waves": [], "hostStatements": {}, "errors": [] }, diff --git a/conformance/006-globals.expected.json b/conformance/006-globals.expected.json index d918616..7bbaac8 100644 --- a/conformance/006-globals.expected.json +++ b/conformance/006-globals.expected.json @@ -54,6 +54,7 @@ } }, "directives": [], + "waves": [], "hostStatements": {}, "errors": [] }, diff --git a/conformance/007-session-song-follow.expected.json b/conformance/007-session-song-follow.expected.json index 0aa7ec8..2cd1fdc 100644 --- a/conformance/007-session-song-follow.expected.json +++ b/conformance/007-session-song-follow.expected.json @@ -148,6 +148,7 @@ "scaleMode": null, "deckMix": null, "directives": [], + "waves": [], "hostStatements": {}, "errors": [] }, diff --git a/conformance/008-macros-genblock.expected.json b/conformance/008-macros-genblock.expected.json index 633bef0..35eda29 100644 --- a/conformance/008-macros-genblock.expected.json +++ b/conformance/008-macros-genblock.expected.json @@ -90,6 +90,7 @@ "scaleMode": null, "deckMix": null, "directives": [], + "waves": [], "hostStatements": {}, "errors": [] }, diff --git a/conformance/009-directives.expected.json b/conformance/009-directives.expected.json index ecb037b..f873b3f 100644 --- a/conformance/009-directives.expected.json +++ b/conformance/009-directives.expected.json @@ -83,6 +83,7 @@ ] } ], + "waves": [], "hostStatements": {}, "errors": [] }, diff --git a/conformance/010-errors.expected.json b/conformance/010-errors.expected.json index e49abea..8743ee6 100644 --- a/conformance/010-errors.expected.json +++ b/conformance/010-errors.expected.json @@ -41,6 +41,7 @@ "scaleMode": null, "deckMix": null, "directives": [], + "waves": [], "hostStatements": {}, "errors": [ { diff --git a/conformance/011-golden.expected.json b/conformance/011-golden.expected.json index 0448589..5478dcf 100644 --- a/conformance/011-golden.expected.json +++ b/conformance/011-golden.expected.json @@ -369,6 +369,7 @@ ] } ], + "waves": [], "hostStatements": {}, "errors": [] }, diff --git a/conformance/012-gba-subset.expected.json b/conformance/012-gba-subset.expected.json index 374b9aa..e13e947 100644 --- a/conformance/012-gba-subset.expected.json +++ b/conformance/012-gba-subset.expected.json @@ -208,6 +208,7 @@ "scaleMode": null, "deckMix": null, "directives": [], + "waves": [], "hostStatements": {}, "errors": [] }, diff --git a/conformance/013-wave.deck b/conformance/013-wave.deck new file mode 100644 index 0000000..983cf26 --- /dev/null +++ b/conformance/013-wave.deck @@ -0,0 +1,18 @@ +# Named PSG wavetables, both spellings. `organ_hex` and `organ_harm` MUST resolve to identical +# `levels` — that equality is the point of the case: the generative form is sugar over the literal +# one, never a second sound. `harmonics` is kept alongside so an emitter can write it back. +deck 1 +bpm 110 + +wave organ_hex 8beffecbbbbaa9888776554444310014 +wave organ_harm harmonics 1 0.5 0.33 0.2 +wave pure harmonics 1 +wave odd harmonics 1 0 0.4 0 0.2 + +track Bass id bass gen gameBoyDmg + gen type wave wave_shape organ_harm vol 15 + note 36 0 2 v 110 + +track Lead id lead gen gameBoyDmg + gen type wave wave_shape pure vol 12 + note 60 0 1 v 100 diff --git a/conformance/013-wave.expected.json b/conformance/013-wave.expected.json new file mode 100644 index 0000000..a09ba15 --- /dev/null +++ b/conformance/013-wave.expected.json @@ -0,0 +1,339 @@ +{ + "program": { + "tplVersion": 1, + "bpm": 110, + "tracks": [ + { + "name": "Bass", + "id": "bass", + "generatorId": "gameBoyDmg", + "rawGenId": "gameBoyDmg", + "genParams": {}, + "loopBars": null, + "body": [ + { + "lineNo": 13, + "tokens": [ + "gen", + "type", + "wave", + "wave_shape", + "organ_harm", + "vol", + "15" + ], + "raw": "gen type wave wave_shape organ_harm vol 15" + }, + { + "lineNo": 14, + "tokens": [ + "note", + "36", + "0", + "2", + "v", + "110" + ], + "raw": "note 36 0 2 v 110" + } + ], + "genBlocks": [] + }, + { + "name": "Lead", + "id": "lead", + "generatorId": "gameBoyDmg", + "rawGenId": "gameBoyDmg", + "genParams": {}, + "loopBars": null, + "body": [ + { + "lineNo": 17, + "tokens": [ + "gen", + "type", + "wave", + "wave_shape", + "pure", + "vol", + "12" + ], + "raw": "gen type wave wave_shape pure vol 12" + }, + { + "lineNo": 18, + "tokens": [ + "note", + "60", + "0", + "1", + "v", + "100" + ], + "raw": "note 60 0 1 v 100" + } + ], + "genBlocks": [] + } + ], + "removeTrackIds": [], + "macros": {}, + "autos": [], + "masterMixTokens": null, + "actorMixRows": [], + "clipBlocks": [], + "sessionSceneCount": null, + "sessionSlots": [], + "song": null, + "follow": null, + "launchQuant": null, + "songSeed": null, + "xfade": null, + "mainDeck": null, + "swing": null, + "scaleRoot": null, + "scaleMode": null, + "deckMix": null, + "directives": [], + "waves": [ + { + "lineNo": 7, + "name": "organ_hex", + "mode": "hex", + "harmonics": null, + "hex": "8beffecbbbbaa9888776554444310014", + "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 + ] + }, + { + "lineNo": 8, + "name": "organ_harm", + "mode": "harmonics", + "harmonics": [ + 1, + 0.5, + 0.33, + 0.2 + ], + "hex": null, + "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 + ] + }, + { + "lineNo": 9, + "name": "pure", + "mode": "harmonics", + "harmonics": [ + 1 + ], + "hex": null, + "levels": [ + 8, + 9, + 10, + 12, + 13, + 14, + 14, + 15, + 15, + 15, + 14, + 14, + 13, + 12, + 10, + 9, + 8, + 6, + 5, + 3, + 2, + 1, + 1, + 0, + 0, + 0, + 1, + 1, + 2, + 3, + 5, + 6 + ] + }, + { + "lineNo": 10, + "name": "odd", + "mode": "harmonics", + "harmonics": [ + 1, + 0, + 0.4, + 0, + 0.2 + ], + "hex": null, + "levels": [ + 8, + 12, + 15, + 15, + 14, + 13, + 13, + 13, + 14, + 13, + 13, + 13, + 14, + 15, + 15, + 12, + 8, + 3, + 0, + 0, + 1, + 2, + 2, + 2, + 1, + 2, + 2, + 2, + 1, + 0, + 0, + 3 + ] + } + ], + "hostStatements": {}, + "errors": [] + }, + "trackBodies": [ + { + "id": "bass", + "rows": [ + { + "kind": "gen", + "params": { + "type": "wave", + "waveShape": "organ_harm", + "vol": 15 + }, + "lineNo": 13 + }, + { + "kind": "note", + "midi": 36, + "startBeat": 0, + "durBeats": 2, + "vel": 110, + "prob": null, + "ratchet": null, + "nudge": null, + "bar": null, + "lyric": null, + "lineNo": 14 + } + ], + "errors": [] + }, + { + "id": "lead", + "rows": [ + { + "kind": "gen", + "params": { + "type": "wave", + "waveShape": "pure", + "vol": 12 + }, + "lineNo": 17 + }, + { + "kind": "note", + "midi": 60, + "startBeat": 0, + "durBeats": 1, + "vel": 100, + "prob": null, + "ratchet": null, + "nudge": null, + "bar": null, + "lyric": null, + "lineNo": 18 + } + ], + "errors": [] + } + ], + "clipBodies": [] +} diff --git a/conformance/profiles.json b/conformance/profiles.json index 3b636a0..48a4d2f 100644 --- a/conformance/profiles.json +++ b/conformance/profiles.json @@ -22,12 +22,11 @@ "004-notes": "uses `transpose`, applied host-side" }, "mustAccept": [ - "012-gba-subset" + "012-gba-subset", + "013-wave" ], "extensions": { - "topLevel": [ - "wave" - ], + "topLevel": [], "body": [ "layer", "intensity", diff --git a/docs/AST.md b/docs/AST.md index 4db3c9f..3fc70ba 100644 --- a/docs/AST.md +++ b/docs/AST.md @@ -105,6 +105,8 @@ 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` arrives as the same `levels` + (32 numbers, 0..15) a hex literal produces, with `mode` and the source `harmonics` / `hex` alongside. - **Param keys are camelCased and aliased.** `wave_shape` → `waveShape`, `reverb` → `reverbSend`, `type` → `filterType` on `fx`. Extend with `registerParamKeyAliases`. diff --git a/docs/DECK_EXTENSION.md b/docs/DECK_EXTENSION.md index 085b6c6..07ff1f4 100644 --- a/docs/DECK_EXTENSION.md +++ b/docs/DECK_EXTENSION.md @@ -2,19 +2,22 @@ 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. +`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. | Extension point | Adds | API | |-----------------|------|-----| -| Top-level statement | `wave ` | `registerTopLevelStatement(head, fn)` → `ast.hostStatements[head][]` | +| 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" -// tish-gba: `wave <32 hex nibbles>` -registerTopLevelStatement("wave", (head, toks) => ({ name: toks[1], hex: toks[2] })) +// 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) => { diff --git a/docs/DECK_GRAMMAR.md b/docs/DECK_GRAMMAR.md index ad66f4e..e175124 100644 --- a/docs/DECK_GRAMMAR.md +++ b/docs/DECK_GRAMMAR.md @@ -2,7 +2,7 @@ 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, scale root/mode vocab, highlight classify, empty registries (generator id / param key / macro / gen_block dialect). +**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**. @@ -41,6 +41,7 @@ These are recognized by `parseProgram`. | 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 <32 hex>` / `wave harmonics …` | 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 | @@ -64,6 +65,30 @@ Accepted mode tokens (aliases in parentheses): Package helpers: `parseScaleRoot`, `scaleRootNames`, `scaleModeNames`, `scaleIntervals`. +### Wavetables + +``` +wave <32 hex digits> +wave harmonics [a2 a3 …] +``` + +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. + +The `harmonics` form gives amplitudes instead of samples — `a1` is the fundamental, `a2` the octave +above it, `a3` the twelfth — and the parser sums them into the same 32 levels, normalized to fill the +range, so only the ratios matter. Like `steps euclid`, it is **expanded at parse time**: a host reads +`levels` and never has to know which spelling produced it. These two lines are the same sound: + +``` +wave organ 8beffecbbbbaa9888776554444310014 +wave organ harmonics 1 0.5 0.33 0.2 +``` + +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). + ### Track header ``` diff --git a/packages/player/README.md b/packages/player/README.md index e5ee345..b87ade0 100644 --- a/packages/player/README.md +++ b/packages/player/README.md @@ -110,8 +110,9 @@ That means real hardware behaviour, not an impression of it: 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 -- **`wave <32 hex nibbles>`** — named wave RAM tables, and `layer` stem gating via - `setIntensity()` +- **`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()` Everything else — `matrixFm`, `patch`, `nes2a03`, `c64sid`, and the rest — falls back to a plain oscillator so a song still plays, and says so in `song.substitutions`. `ttsVocal` / `meSpeakVocal` diff --git a/packages/player/src/song/Apply.tish b/packages/player/src/song/Apply.tish index 841764b..03f4389 100644 --- a/packages/player/src/song/Apply.tish +++ b/packages/player/src/song/Apply.tish @@ -333,50 +333,34 @@ fn expandNoteRow(row, declaredBars, errors) { } /** - * `wave <32 hex nibbles>` → 32 samples in -1..1. + * A parsed `wave` statement's 4-bit levels → 32 samples in -1..1. * - * Each nibble is a 4-bit wave RAM level, so 0 → -1, 7.5 → 0, 15 → +1. This is the inverse of the - * bake's `wave_nibbles` (deckpack.rs), which writes `(phase * 15).round()` for a saw — so a table - * decoded here and a table baked to a ROM describe the same waveform. + * The parser hands over levels 0..15, whichever spelling wrote them, so 0 → -1, 7.5 → 0, 15 → +1. + * That is the inverse of the bake's `wave_nibbles` (deckpack.rs), which writes `(phase * 15).round()` + * for a saw — so a table decoded here and a table baked to a ROM describe the same waveform. */ -fn decodeWaveTable(hex) { - let s = String(hex) - if (s.length !== 32) { - return null - } +fn levelsToSamples(levels) { let out = [] let i = 0 - while (i < 32) { - let n = parseInt(s.charAt(i), 16) - if (n !== n) { - return null - } - out.push(n / 7.5 - 1) + while (i < levels.length) { + out.push(levels[i] / 7.5 - 1) i = i + 1 } return out } +/// `wave` is core grammar: the parser validates both spellings and resolves each to 32 levels, so +/// this only has to scale them into the range the audio graph wants. Malformed lines are already in +/// `ast.errors` before we get here. fn collectWaveTables(ast, errors) { let tables = {} - let entries = ast.hostStatements ? ast.hostStatements.wave : null + let entries = ast.waves if (!entries) { return tables } let i = 0 while (i < entries.length) { - let toks = entries[i].value - if (toks && toks.length >= 3) { - let name = String(toks[1]) - let table = decodeWaveTable(toks[2]) - if (table) { - tables[name] = table - } else { - errors.push({ line: entries[i].lineNo, msg: "wave `" + name + "` needs 32 hex digits" }) - } - } else { - errors.push({ line: entries[i].lineNo, msg: "wave needs a name and 32 hex digits" }) - } + tables[String(entries[i].name)] = levelsToSamples(entries[i].levels) i = i + 1 } return tables diff --git a/packages/player/src/song/DeckIds.tish b/packages/player/src/song/DeckIds.tish index c4090f5..acb4062 100644 --- a/packages/player/src/song/DeckIds.tish +++ b/packages/player/src/song/DeckIds.tish @@ -14,7 +14,6 @@ import { registerGeneratorIdAliases, registerParamKeyAliases, registerBodyLineDialect, - registerTopLevelStatement, registerHighlightKeywords, paramKeyToCamel } from '@spacedevin/deck' @@ -34,11 +33,6 @@ fn parseLegacyGenLine(head, toks) { return { kind: "gen", params: params } } -/// `wave <32 hex nibbles>` — a named PSG wavetable. Kept as raw tokens; Apply validates. -fn parseWaveStatement(head, toks) { - return toks -} - /// `layer|intensity|min_intensity <0..3>` — stem gating. A track sounds only at or above its level. fn parseLayerLine(head, toks) { return { kind: "layer", level: toks[1] } @@ -115,15 +109,12 @@ export fn bootDeckRegistries() { // re-scanned in Apply. registerBodyLineDialect(["noise", "fm", "osc"], parseLegacyGenLine) - // The GBA host extensions. `conformance/profiles.json` declares them as the `gba` profile's - // extensions (topLevel: ["wave"], body: ["layer","intensity","min_intensity"]), and 10 of the 62 - // songs in the tish-gba corpus use them — without these, a sixth of the real corpus reports - // "unexpected top-level: wave" and its wave channels play a generic saw instead of their own table. - registerTopLevelStatement("wave", parseWaveStatement) + // Stem gating stays a host extension — `conformance/profiles.json` declares it as the `gba` + // profile's body extension. `wave` used to be registered here too, but it is core grammar now: + // the parser resolves both spellings onto `ast.waves`, so registering it would be ignored. registerBodyLineDialect(["layer", "intensity", "min_intensity"], parseLayerLine) registerHighlightKeywords({ - top: ["wave"], body: ["noise", "fm", "osc", "layer", "intensity", "min_intensity"] }) } diff --git a/packages/player/test/song.mjs b/packages/player/test/song.mjs index 4de2d9b..84f0462 100644 --- a/packages/player/test/song.mjs +++ b/packages/player/test/song.mjs @@ -162,7 +162,7 @@ track T id t gen gameBoyDmg * 4 assert.equal(spans.channels[0].pianoNotes.length, 2) }) -test('the GBA host extensions: named wave tables and layer gating', () => { +test('core wave tables and the layer host extension', () => { const song = parseSong(`deck 1 bpm 120 wave round 8acdefffffedba988765421000001235 @@ -196,6 +196,27 @@ track Extra id extra gen gameBoyDmg assert.match(bad.errors[0].msg, /32 hex digits/) }) +test('a harmonics wave resolves to the same samples as its hex literal', () => { + // `wave` is core grammar, so the player never sees the spelling — the parser hands it 32 levels + // either way. This is the player-side half of the language's equality guarantee. + const song = parseSong(`deck 1 +bpm 120 +wave lit 8beffecbbbbaa9888776554444310014 +wave gen harmonics 1 0.5 0.33 0.2 +track Bass id bass gen gameBoyDmg + gen type wave wave_shape gen + note 36 0 1 v 100 +`) + assert.deepEqual(song.errors, []) + assert.deepEqual(song.waveTables.gen, song.waveTables.lit) + assert.equal(song.waveTables.gen.length, 32) + assert.ok(song.channels[0].waveTable, 'a harmonics table binds like any other') + + const bad = parseSong('deck 1\nwave a harmonics 0 0\n') + assert.equal(bad.errors.length, 1) + assert.match(bad.errors[0].msg, /every harmonic is zero/) +}) + test('a malformed gen line is reported instead of merging junk params', () => { // `gen adsr attack 0 decay 0.1` pairs positionally, so `0` becomes a key. Silently merging that // leaves the envelope at its defaults and the author with no idea why. diff --git a/rust/conformance.rs b/rust/conformance.rs index 55fe317..5e43c7b 100644 --- a/rust/conformance.rs +++ b/rust/conformance.rs @@ -195,3 +195,26 @@ fn registries_persist_between_calls() { "clear*() reassigns module state and the parser must see it" ); } + +/// `wave` is core grammar, and the two spellings must describe one sound: a `harmonics` line and the +/// hex literal it resolves to have to produce identical levels, here as well as in JS. +#[test] +fn wave_harmonics_matches_hex_literal() { + let p = deckfile::facade::parse( + "deck 1\nwave a 8beffecbbbbaa9888776554444310014\nwave b harmonics 1 0.5 0.33 0.2\n", + ); + assert!(p.errors.is_empty(), "unexpected errors: {:?}", p.errors); + assert_eq!(p.waves.len(), 2); + + assert_eq!(p.waves[0].mode, "hex"); + assert_eq!(p.waves[0].hex.as_deref(), Some("8beffecbbbbaa9888776554444310014")); + assert_eq!(p.waves[0].harmonics, None); + + assert_eq!(p.waves[1].mode, "harmonics"); + assert_eq!(p.waves[1].harmonics, Some(vec![1.0, 0.5, 0.33, 0.2])); + assert_eq!(p.waves[1].hex, None); + + assert_eq!(p.waves[0].levels.len(), 32); + assert_eq!(p.waves[0].levels, p.waves[1].levels); + assert!(p.waves[0].levels.iter().all(|n| (0..=15).contains(n))); +} diff --git a/rust/facade.rs b/rust/facade.rs index 24a4f21..cd3e78e 100644 --- a/rust/facade.rs +++ b/rust/facade.rs @@ -90,6 +90,23 @@ pub struct Directive { pub tokens: Vec, } +/// A named PSG wavetable. `levels` is already resolved — a `harmonics` line arrives as the same 32 +/// levels a hex literal produces, with `harmonics` alongside so an emitter can write the source +/// spelling back instead of flattening it. +#[derive(Debug, Clone, PartialEq)] +pub struct Wave { + pub line: i64, + pub name: String, + /// `"hex"` or `"harmonics"`. + pub mode: String, + /// The 32 hex digits as written, when `mode == "hex"`. + pub hex: Option, + /// The amplitudes as written, fundamental first, when `mode == "harmonics"`. + pub harmonics: Option>, + /// 32 four-bit levels, 0..15. Game Boy wave RAM order. + pub levels: Vec, +} + /// Whether a `note` / `step_pitch` applies to a bar. `None` = every bar. #[derive(Debug, Clone, PartialEq)] pub struct BarSelector { @@ -252,6 +269,7 @@ pub struct DeckProgram { pub tracks: Vec, pub clips: Vec, pub directives: Vec, + pub waves: Vec, pub errors: Vec, /// Values returned by `registerTopLevelStatement` handlers, keyed by head. pub host_statements: Value, @@ -456,6 +474,21 @@ pub fn parse(src: &str) -> DeckProgram { }) .collect(); + let waves = items(&field(&raw, "waves")) + .iter() + .map(|w| Wave { + line: int_field(w, "lineNo").unwrap_or(0), + name: str_field(w, "name").unwrap_or_default(), + mode: str_field(w, "mode").unwrap_or_default(), + hex: str_field(w, "hex"), + harmonics: match field(w, "harmonics") { + Value::Null => None, + h => Some(items(&h).iter().filter_map(as_f64).collect()), + }, + levels: items(&field(w, "levels")).iter().filter_map(as_f64).map(|n| n as i64).collect(), + }) + .collect(); + DeckProgram { version: int_field(&raw, "tplVersion").unwrap_or(0), bpm: num_field(&raw, "bpm"), @@ -465,6 +498,7 @@ pub fn parse(src: &str) -> DeckProgram { tracks, clips, directives, + waves, errors: errors_from(&field(&raw, "errors")), host_statements: field(&raw, "hostStatements"), raw, diff --git a/src/deckfile/Highlight.tish b/src/deckfile/Highlight.tish index 157b06f..d58c749 100644 --- a/src/deckfile/Highlight.tish +++ b/src/deckfile/Highlight.tish @@ -23,6 +23,7 @@ TOP_KEYS["session_slot"] = true TOP_KEYS["clip"] = true TOP_KEYS["master_mix"] = true TOP_KEYS["actor_mix"] = true +TOP_KEYS["wave"] = true TOP_KEYS["end"] = true // Track / clip body heads that are core language (Apply interprets; gen_block lines stay opaque here). diff --git a/src/deckfile/Parser.tish b/src/deckfile/Parser.tish index f0713d4..a92bfb2 100644 --- a/src/deckfile/Parser.tish +++ b/src/deckfile/Parser.tish @@ -1,5 +1,6 @@ import { normalizeGeneratorId } from './RegistryMap.tish' import { parseScaleRoot } from '../ScaleRoot.tish' +import { harmonicTable, decodeWaveHex } from './Wavetable.tish' let topLevelStatements = {} @@ -78,6 +79,7 @@ export fn parseProgram(source) { let errors = [] let directives = [] let hostStatements = {} + let waves = [] let tplVersion = 0 let bpm = null let tracks = [] @@ -277,6 +279,55 @@ export fn parseProgram(source) { } continue } + // Named PSG wavetable: `wave <32 hex digits>`, or `wave harmonics …`. + // Both forms resolve to the same 32 levels here, the way `steps euclid` resolves to a step grid, + // so a host reads `levels` and never has to know which spelling produced them. `harmonics` is + // kept alongside so an emitter can write the source form back rather than flattening it to hex. + if (head === "wave") { + if (toks.length < 3) { + errors.push({ line: i, msg: "wave: expected `wave <32 hex digits>` or `wave harmonics …`" }) + continue + } + let waveName = toks[1] + if (toks[2] === "harmonics") { + let amps = [] + let badToken = null + let ai = 3 + while (ai < toks.length) { + let amp = Number(toks[ai]) + if (amp !== amp) { + if (badToken === null) { + badToken = toks[ai] + } + } else { + amps.push(amp) + } + ai = ai + 1 + } + if (badToken !== null) { + errors.push({ line: i, msg: "wave `" + waveName + "`: harmonics takes numbers, got `" + badToken + "`" }) + continue + } + if (amps.length === 0) { + errors.push({ line: i, msg: "wave `" + waveName + "`: harmonics needs one or more numbers" }) + continue + } + let built = harmonicTable(amps) + if (built === null) { + errors.push({ line: i, msg: "wave `" + waveName + "`: every harmonic is zero" }) + continue + } + waves.push({ lineNo: i, name: waveName, mode: "harmonics", harmonics: amps, hex: null, levels: built }) + continue + } + let decoded = decodeWaveHex(toks[2]) + if (decoded === null) { + errors.push({ line: i, msg: "wave `" + waveName + "`: needs 32 hex digits" }) + continue + } + waves.push({ lineNo: i, name: waveName, mode: "hex", harmonics: null, hex: toks[2], levels: decoded }) + continue + } // Crossfader position: `xfade ` — both 0..1 (X = deck A↔B, Y = top↔bottom corner). if (head === "xfade") { if (toks.length >= 3) { @@ -577,6 +628,7 @@ export fn parseProgram(source) { scaleMode: scaleMode, deckMix: deckMix, directives: directives, + waves: waves, hostStatements: hostStatements, errors: errors } diff --git a/src/deckfile/Wavetable.tish b/src/deckfile/Wavetable.tish new file mode 100644 index 0000000..0547079 --- /dev/null +++ b/src/deckfile/Wavetable.tish @@ -0,0 +1,91 @@ +// PSG wavetables: 32 four-bit samples, the Game Boy's wave RAM laid out as one cycle. +// +// `wave <32 hex digits>` writes the samples directly. `wave harmonics …` +// says how loud each harmonic is and lets this module land on the samples — the same relationship +// `steps euclid ` has to a literal step grid, and expanded the same way, at parse time, +// so a host only ever sees resolved samples. +// +// Level 0 is the bottom of the wave, 15 the top, 7.5 the rest line. A host maps that to its own +// range (the Web Audio player uses `n / 7.5 - 1`); the GBA bake packs two levels per byte and copies +// them to WAVE_RAM untouched, which is why these are levels and not floats. + +/// Additive synthesis: harmonic amplitudes -> 32 levels in 0..15. +/// +/// Amplitude 1 is the fundamental, 2 the octave above it, 3 the twelfth. The result is normalized to +/// fill the 4-bit range, so only the ratios between amplitudes matter. Returns null when every +/// harmonic is zero, which would otherwise be a divide by a zero peak. +export fn harmonicTable(harmonics) { + if (!harmonics || harmonics.length === 0) { + return null + } + + let raw = [] + let i = 0 + while (i < 32) { + let phase = i / 32 + let v = 0 + let h = 0 + while (h < harmonics.length) { + v = v + harmonics[h] * Math.sin(2 * Math.PI * (h + 1) * phase) + h = h + 1 + } + raw.push(v) + i = i + 1 + } + + let peak = 0 + let k = 0 + while (k < raw.length) { + let a = Math.abs(raw[k]) + if (a > peak) { + peak = a + } + k = k + 1 + } + // Silence has no shape to normalize, and a non-finite peak (an infinite amplitude, or a NaN that + // made every comparison false and left the peak at zero) would put NaN in the table. Both are the + // caller passing something that is not a waveform, so both are null rather than a bad table. + if (!(peak > 0) || (peak - peak) !== 0) { + return null + } + + // Dividing by the peak puts every sample in -1..1, so scaling by 7.5 about the 7.5 rest line lands + // in 0..15 by construction — there is nothing left to clamp. + let out = [] + let j = 0 + while (j < raw.length) { + out.push(Math.round((raw[j] / peak) * 7.5 + 7.5)) + j = j + 1 + } + return out +} + +/// `<32 hex digits>` -> 32 levels in 0..15. Null if the length is wrong or a digit is not hex. +export fn decodeWaveHex(hex) { + let s = String(hex) + if (s.length !== 32) { + return null + } + let out = [] + let i = 0 + while (i < 32) { + let n = parseInt(s.charAt(i), 16) + if (n !== n) { + return null + } + out.push(n) + i = i + 1 + } + return out +} + +/// 32 levels -> the 32 hex digits a `wave` line carries. The inverse of `decodeWaveHex`. +export fn encodeWaveHex(levels) { + let s = "" + let i = 0 + while (i < levels.length) { + s = s + levels[i].toString(16) + i = i + 1 + } + return s +} diff --git a/src/index.tish b/src/index.tish index 3a10a24..29d9c2a 100644 --- a/src/index.tish +++ b/src/index.tish @@ -29,6 +29,7 @@ export { } from "./deckfile/RegistryMap.tish" export { parseBarSelector, barSelectorMatches } from "./deckfile/BarSelector.tish" export { euclideanPattern } from "./deckfile/Euclidean.tish" +export { harmonicTable, decodeWaveHex, encodeWaveHex } from "./deckfile/Wavetable.tish" export { parseGenBlock, registerGenBlockDialect, diff --git a/test/coverage.mjs b/test/coverage.mjs index 5599eb4..f75eb30 100644 --- a/test/coverage.mjs +++ b/test/coverage.mjs @@ -44,7 +44,10 @@ import { registerBodyLineDialect, clearBodyLineDialects, registerTopLevelStatement, - clearTopLevelStatements + clearTopLevelStatements, + harmonicTable, + decodeWaveHex, + encodeWaveHex } from "../dist/deck.js" const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") @@ -518,16 +521,60 @@ check("body dialect bad heads ignored", body("42").kind === "unknown") clearBodyLineDialects() check("body dialect cleared", body("layer 2").kind === "unknown") -registerTopLevelStatement("wave", (head, toks) => ({ name: toks[1], hex: toks[2] })) -let hs = parseProgram("deck 1\nwave saw abcdef\nwave sq 012345\n") -check("host stmt collected", hs.hostStatements.wave.length === 2) -check("host stmt value", hs.hostStatements.wave[0].value.name === "saw") -check("host stmt lineNo", hs.hostStatements.wave[0].lineNo === 2) +// `wave` used to be the example here, because it used to be a host statement. It is core now, so +// this exercises the mechanism with a head the language genuinely does not own. +registerTopLevelStatement("cue", (head, toks) => ({ name: toks[1], at: toks[2] })) +let hs = parseProgram("deck 1\ncue drop 32\ncue break 64\n") +check("host stmt collected", hs.hostStatements.cue.length === 2) +check("host stmt value", hs.hostStatements.cue[0].value.name === "drop") +check("host stmt lineNo", hs.hostStatements.cue[0].lineNo === 2) check("host stmt no error", hs.errors.length === 0) registerTopLevelStatement("nope", null) check("host stmt null fn ignored", parseProgram("nope 1\n").errors.length === 1) clearTopLevelStatements() -check("host stmt cleared", parseProgram("wave saw abcdef\n").errors.length === 1) +check("host stmt cleared", parseProgram("cue drop 32\n").errors.length === 1) + +// A host registering `wave` must not shadow the core statement — core is matched first. +registerTopLevelStatement("wave", () => ({ hijacked: true })) +let shadow = parseProgram("deck 1\nwave organ harmonics 1 0.5\n") +check("core wave wins over host", shadow.waves.length === 1 && !shadow.hostStatements.wave) +clearTopLevelStatements() + +// wave: both spellings, and the equality that makes `harmonics` sugar rather than a second sound. +let wv = parseProgram( + "deck 1\nwave a 8beffecbbbbaa9888776554444310014\nwave b harmonics 1 0.5 0.33 0.2\n" +) +check("wave no error", wv.errors.length === 0) +check("wave count", wv.waves.length === 2) +check("wave hex mode", wv.waves[0].mode === "hex") +check("wave hex retained", wv.waves[0].hex === "8beffecbbbbaa9888776554444310014") +check("wave hex 32 levels", wv.waves[0].levels.length === 32) +check("wave harmonics mode", wv.waves[1].mode === "harmonics") +check("wave harmonics retained", wv.waves[1].harmonics.join(",") === "1,0.5,0.33,0.2") +check("wave lineNo", wv.waves[0].lineNo === 2) +check( + "wave harmonics equals hex literal", + wv.waves[0].levels.join(",") === wv.waves[1].levels.join(",") +) +check("wave short hex errors", parseProgram("deck 1\nwave a abc\n").errors.length === 1) +check("wave bad hex errors", parseProgram("deck 1\nwave a " + "z".repeat(32) + "\n").errors.length === 1) +check("wave missing args errors", parseProgram("deck 1\nwave a\n").errors.length === 1) +check("wave bare errors", parseProgram("deck 1\nwave\n").errors.length === 1) +check("wave empty harmonics errors", parseProgram("deck 1\nwave a harmonics\n").errors.length === 1) +check("wave zero harmonics errors", parseProgram("deck 1\nwave a harmonics 0 0\n").errors.length === 1) +check("wave nan harmonics errors", parseProgram("deck 1\nwave a harmonics 1 x\n").errors.length === 1) + +check("harmonicTable 32 levels", harmonicTable([1]).length === 32) +check("harmonicTable in range", harmonicTable([1, 0.5]).every((n) => n >= 0 && n <= 15)) +check("harmonicTable ratios only", encodeWaveHex(harmonicTable([1, 0.5])) === encodeWaveHex(harmonicTable([2, 1]))) +check("harmonicTable zero is null", harmonicTable([0]) === null) +check("harmonicTable empty is null", harmonicTable([]) === null) +check("harmonicTable null is null", harmonicTable(null) === null) +check("harmonicTable infinite is null", harmonicTable([1 / 0]) === null) +check("harmonicTable nan is null", harmonicTable([0 / 0]) === null) +check("decodeWaveHex round trip", encodeWaveHex(decodeWaveHex("8beffecbbbbaa9888776554444310014")) === "8beffecbbbbaa9888776554444310014") +check("decodeWaveHex short is null", decodeWaveHex("abc") === null) +check("decodeWaveHex bad digit is null", decodeWaveHex("g".repeat(32)) === null) if (failed > 0) { console.log(failed + " FAILED") From 761648ec33e72e86189ba26e9ec9a25ecedc0a26 Mon Sep 17 00:00:00 2001 From: spacedevin Date: Thu, 27 Aug 2026 16:10:28 -0700 Subject: [PATCH 2/2] docs: cover wavetables, timbre and basicOsc in the examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The examples page was eight blocks, every one of them a Game Boy chip song, and not one of them used a `wave` table — so the page never showed the feature this branch just adopted, and read as if the language were GBA-only. Five sections added: wavetables (both spellings, side by side, so you can hear they are one sound), designing a timbre from harmonics, instrument voices, and `basicOsc` for when you want the language without the console. Plus mix and fx, which no example demonstrated at all despite the channel strip applying to every generator. Everything stays inside the three generators this player synthesizes faithfully. Deckard's other thirty parse fine and then get substituted for a plain oscillator, which on a page where every block has a Play button would be a demo that lies about what you are hearing. The intro now says so. test/examples.mjs is what keeps that true: it parses every block on the page and fails on a substitution or an ignored feature, not just on a parse error. --- conformance/013-wave.deck | 5 +- conformance/013-wave.expected.json | 36 ++--- docs/EXAMPLES.md | 223 ++++++++++++++++++++++++++++- package.json | 5 +- test/examples.mjs | 37 +++++ 5 files changed, 283 insertions(+), 23 deletions(-) create mode 100644 test/examples.mjs diff --git a/conformance/013-wave.deck b/conformance/013-wave.deck index 983cf26..66f4387 100644 --- a/conformance/013-wave.deck +++ b/conformance/013-wave.deck @@ -1,6 +1,9 @@ # Named PSG wavetables, both spellings. `organ_hex` and `organ_harm` MUST resolve to identical # `levels` — that equality is the point of the case: the generative form is sugar over the literal # one, never a second sound. `harmonics` is kept alongside so an emitter can write it back. +# +# One wave track only: the LR35902 has a single wave channel, and the GBA profile lists this case as +# mustAccept, so it has to stay inside that subset. deck 1 bpm 110 @@ -14,5 +17,5 @@ track Bass id bass gen gameBoyDmg note 36 0 2 v 110 track Lead id lead gen gameBoyDmg - gen type wave wave_shape pure vol 12 + gen type pulse duty 25 vol 12 note 60 0 1 v 100 diff --git a/conformance/013-wave.expected.json b/conformance/013-wave.expected.json index a09ba15..e9ce6bb 100644 --- a/conformance/013-wave.expected.json +++ b/conformance/013-wave.expected.json @@ -12,7 +12,7 @@ "loopBars": null, "body": [ { - "lineNo": 13, + "lineNo": 16, "tokens": [ "gen", "type", @@ -25,7 +25,7 @@ "raw": "gen type wave wave_shape organ_harm vol 15" }, { - "lineNo": 14, + "lineNo": 17, "tokens": [ "note", "36", @@ -48,20 +48,20 @@ "loopBars": null, "body": [ { - "lineNo": 17, + "lineNo": 20, "tokens": [ "gen", "type", - "wave", - "wave_shape", - "pure", + "pulse", + "duty", + "25", "vol", "12" ], - "raw": "gen type wave wave_shape pure vol 12" + "raw": "gen type pulse duty 25 vol 12" }, { - "lineNo": 18, + "lineNo": 21, "tokens": [ "note", "60", @@ -97,7 +97,7 @@ "directives": [], "waves": [ { - "lineNo": 7, + "lineNo": 10, "name": "organ_hex", "mode": "hex", "harmonics": null, @@ -138,7 +138,7 @@ ] }, { - "lineNo": 8, + "lineNo": 11, "name": "organ_harm", "mode": "harmonics", "harmonics": [ @@ -184,7 +184,7 @@ ] }, { - "lineNo": 9, + "lineNo": 12, "name": "pure", "mode": "harmonics", "harmonics": [ @@ -227,7 +227,7 @@ ] }, { - "lineNo": 10, + "lineNo": 13, "name": "odd", "mode": "harmonics", "harmonics": [ @@ -288,7 +288,7 @@ "waveShape": "organ_harm", "vol": 15 }, - "lineNo": 13 + "lineNo": 16 }, { "kind": "note", @@ -301,7 +301,7 @@ "nudge": null, "bar": null, "lyric": null, - "lineNo": 14 + "lineNo": 17 } ], "errors": [] @@ -312,11 +312,11 @@ { "kind": "gen", "params": { - "type": "wave", - "waveShape": "pure", + "type": "pulse", + "duty": 25, "vol": 12 }, - "lineNo": 17 + "lineNo": 20 }, { "kind": "note", @@ -329,7 +329,7 @@ "nudge": null, "bar": null, "lyric": null, - "lineNo": 18 + "lineNo": 21 } ], "errors": [] diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 56b4556..d985b35 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -3,8 +3,13 @@ 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*. Each one -uses only `gameBoyDmg` and `gbaDirectSound`, so it sounds the same in a browser as it does on a GBA. +The grammar reference shows *syntax* (`note …`); this page shows *songs*. + +Everything here uses `gameBoyDmg`, `gbaDirectSound` or `basicOsc` — the three generators this player +synthesizes faithfully, so a Play button is not an approximation. The chip examples sound the same in +a browser as they do on a GBA; `basicOsc` is the way out of the console when you want one. Other +generator ids parse fine, but the player substitutes a plain oscillator and says so in +`song.substitutions`. ## Steps @@ -163,6 +168,220 @@ track Kick id kick gen gbaDirectSound 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. You can write the samples as hex digits, or give the harmonic +amplitudes and let the language sum them. + +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. + +## 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. + +## 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 diff --git a/package.json b/package.json index efd6777..459d324 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "scripts": { "build": "tish build src/index.tish -o dist/deck.js --target js && node scripts/append-exports.mjs", "tishversion": "tish -V", - "test": "npm run build && node test/coverage.mjs && node test/conformance.mjs && npm run test:tish && npm run test:js-smoke", + "test": "npm run build && node test/coverage.mjs && node test/conformance.mjs && npm run test:examples && npm run test:tish && npm run test:js-smoke", "test:conformance": "npm run build && node test/conformance.mjs", "conformance:update": "npm run build && node test/conformance.mjs --update", "test:coverage": "npm run build && c8 --check-coverage --lines 100 --functions 100 --statements 100 --include 'dist/deck.js' node test/coverage.mjs", @@ -66,7 +66,8 @@ "site": "npm run build && npm run build -w @spacedevin/deck-player && node site/build.mjs", "site:serve": "npm run site --silent && SITE_BASE=/ node site/build.mjs && npx --yes serve site/out", "test:tish": "tish test", - "test:js-smoke": "tish build --target js test/smoke.tish -o /tmp/spacedevin-deck-smoke.js && node /tmp/spacedevin-deck-smoke.js" + "test:js-smoke": "tish build --target js test/smoke.tish -o /tmp/spacedevin-deck-smoke.js && node /tmp/spacedevin-deck-smoke.js", + "test:examples": "npm run build -w @spacedevin/deck-player && node test/examples.mjs docs/EXAMPLES.md" }, "c8": { "reporter": [ diff --git a/test/examples.mjs b/test/examples.mjs new file mode 100644 index 0000000..656ed01 --- /dev/null +++ b/test/examples.mjs @@ -0,0 +1,37 @@ +// Verify every ```deck block in a docs page actually plays +// +// Every block on the examples page renders a Play button, so a block that parses but does not SOUND +// right is worse than one that fails: the reader hears a plain oscillator and believes it is the +// instrument. Substitutions and ignored features are therefore failures here, not warnings.: no parse errors, no generator quietly +// substituted for a plain oscillator, no language feature the player ignores, no silent track. +import { parseSong } from "../packages/player/dist/deck-player.js" +import fs from "node:fs" + +const file = process.argv[2] +const src = fs.readFileSync(file, "utf8") +const re = /```deck\n([\s\S]*?)```/g +let m, + n = 0, + bad = 0 +while ((m = re.exec(src))) { + n++ + const line = src.slice(0, m.index).split("\n").length + const song = parseSong(m[1]) + const probs = [] + for (const e of song.errors) probs.push(`ERROR line ${e.line}: ${e.msg}`) + for (const s of song.substitutions) probs.push(`SUBSTITUTED ${s.trackId} (${s.generatorId}): ${s.reason}`) + for (const i of song.ignored) probs.push(`IGNORED: ${i}`) + for (const c of song.channels) { + const cnt = c.pianoNotes.length || (c.steps ? c.steps.filter((s) => s.on).length : 0) + if (cnt === 0) probs.push(`track ${c.id} has no notes/steps`) + } + const beats = song.totalBeats ?? song.loopBeats + if (probs.length) bad++ + console.log( + `${probs.length ? "FAIL" : "ok "} block ${String(n).padStart(2)} (line ${String(line).padStart(4)}) ` + + `${song.channels.length} trk ${song.bpm}bpm ${beats}beats waves=${song.channels.filter((c) => c.waveTable).length}` + ) + for (const p of probs) console.log(" " + p) +} +console.log(bad ? `\n${bad}/${n} blocks have problems` : `\nall ${n} blocks clean`) +process.exit(bad ? 1 : 0)