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..66f4387 --- /dev/null +++ b/conformance/013-wave.deck @@ -0,0 +1,21 @@ +# 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 + +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 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 new file mode 100644 index 0000000..e9ce6bb --- /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": 16, + "tokens": [ + "gen", + "type", + "wave", + "wave_shape", + "organ_harm", + "vol", + "15" + ], + "raw": "gen type wave wave_shape organ_harm vol 15" + }, + { + "lineNo": 17, + "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": 20, + "tokens": [ + "gen", + "type", + "pulse", + "duty", + "25", + "vol", + "12" + ], + "raw": "gen type pulse duty 25 vol 12" + }, + { + "lineNo": 21, + "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": 10, + "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": 11, + "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": 12, + "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": 13, + "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": 16 + }, + { + "kind": "note", + "midi": 36, + "startBeat": 0, + "durBeats": 2, + "vel": 110, + "prob": null, + "ratchet": null, + "nudge": null, + "bar": null, + "lyric": null, + "lineNo": 17 + } + ], + "errors": [] + }, + { + "id": "lead", + "rows": [ + { + "kind": "gen", + "params": { + "type": "pulse", + "duty": 25, + "vol": 12 + }, + "lineNo": 20 + }, + { + "kind": "note", + "midi": 60, + "startBeat": 0, + "durBeats": 1, + "vel": 100, + "prob": null, + "ratchet": null, + "nudge": null, + "bar": null, + "lyric": null, + "lineNo": 21 + } + ], + "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/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/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") 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)