Skip to content

Play the whole catalog: site examples, and a CLI to render them - #28

Merged
spacedevin merged 3 commits into
mainfrom
feat/site-plays-catalog
Sep 1, 2026
Merged

Play the whole catalog: site examples, and a CLI to render them#28
spacedevin merged 3 commits into
mainfrom
feat/site-plays-catalog

Conversation

@spacedevin

Copy link
Copy Markdown
Owner

The site demonstrated three voices out of thirty-three. Every example was gameBoyDmg,
gbaDirectSound or basicOsc, and every other generator id resolved to a stand-in oscillator —
because the player carried its own copies of those three voices, a hand-maintained port of
@spacedevin/deck-synths that stalled at three.

The player plays the catalog now, rather than re-porting it

The duplicates are deleted and the catalog is imported. Where the player's copies were genuinely
ahead, the improvement moved into the catalog instead of being lost — duty defaulting to 50%
rather than all-off silence, shared normalizeDuty, wavetable copy-through, and the saw alias
plus explicit square/sine arms in gbaDirectSound.

Duty.tish moves to the catalog as the single home for the duty table, and the player's Registry
derives from generatorCatalog() and exports nothing the catalog also exports. That last part is
load-bearing: tish build inlines its import graph with no tree-shaking, so a name exported by
both packages is a duplicate declaration, not a merge.

deck-synths is declared the way deck already was — a peer on the published range, with a file:
devDependency for local work. tish inlines the voices into dist, so it is a build-time dependency
for anyone compiling from source, not a runtime install.

A real bug this surfaced: the sync worklet was never registered

syncLead, syncChoir, obSync and laserSync build an AudioWorkletNode and fall back to a
plain oscillator
when the processor is missing. Nothing registered it, so the symptom was a thin
render rather than an error — the sync examples would have quietly sounded wrong.

buildAudioGraph now starts registration (enough for live playback, where it lands inside
press-to-first-note latency) and renderDeckToBuffer awaits it, because an offline render gets no
such gap. ensureSyncWorklet is exported for hosts driving the graph themselves.

Measured against a negative control:

AudioWorkletNodes built fell back to oscillator
awaited (this PR) 10 0
unawaited (before) 0 2

Examples

Six new playable sections under Beyond the chips: hard sync, the analog rack, atmospheric pads,
the five non-Game-Boy chip emulations, acoustic models, and a matrixFm operator graph.

Parameters come from the factory presets rather than from what a knob range suggests — acid303's
env_mod is a filter envelope in Hz and runs in the thousands, sub808's punch is Hz, arco's
voice names an instrument, and syncLead's sweep_amt is tens of semitones because that sweep
is the sound.

Rendering to audio from the CLI

node scripts/render-wav.mjs song.deck -o song.wav
song.wav  5.87s  2 tracks  peak -2.0 dBFS  rms -8.1 dBFS

The voices are Web Audio, so there is no pure-Node path to a buffer — and writing a second
implementation for Node is the duplication this PR just removed. It drives a headless Chrome,
calls the player's own renderDeckToBuffer() in an OfflineAudioContext, and copies the samples
back: deterministic, and far faster than real time. It reports peak, rms and any substitutions so a
clipped render, a near-silent one, and a track playing a stand-in all look different on the output
line.

docs/RENDERING.md documents the CLI, its flags, how to read that line, renderDeckToBuffer for
hosts doing their own rendering, and the worklet ordering a host driving buildAudioGraph has to
get right.

Verification

  • 46/46 player tests pass. The harness gained createDelay, which spc700's echo needs and a
    three-voice player never did.
  • 22/22 deck blocks on the Examples page parse, build a graph and play with zero
    substitutions
    .
  • Three of the new examples rendered to real audio through the new CLI: sync peak -2.0 dBFS,
    consoles -3.5, matrixFm -4.7.
  • Deckard builds green against the shared catalog: 516 pass, 0 fail.
  • The batch snippet in RENDERING.md was run in this repo rather than written from memory.

The player carried its own copies of gameBoyDmg, gbaDirectSound and basicOsc — a hand-maintained
port of the same voices in @spacedevin/deck-synths that stalled at three of thirty-three. Every
other id resolved to a stand-in oscillator.

Delete the duplicates and import the catalog instead. Where the player's copies were ahead, the
improvement moves into the catalog rather than being lost: duty defaults to 50% instead of
all-off silence, normalizeDuty is shared, wavetables copy through, and gbaDirectSound keeps the
saw alias, explicit square and sine arms, and unknown-waveform-to-pulse.

Duty.tish moves to the catalog as the single home for the duty table. The player's Registry now
derives from generatorCatalog() and exports nothing the catalog also exports — tish inlines its
import graph without tree-shaking, so a name exported by both packages is a duplicate
declaration, not a merge.

Also register the sync AudioWorklet, which nothing was doing. syncLead, syncChoir, obSync and
laserSync build an AudioWorkletNode and fall back to a plain oscillator when the processor is
missing, so the symptom was a thin render rather than an error. buildAudioGraph now kicks
registration off — enough for live playback, where it lands inside press-to-first-note latency —
and renderDeckToBuffer awaits it, because an offline render gets no such gap. ensureSyncWorklet
is exported so hosts driving the graph themselves can do the same. Verified against a negative
control: awaited, 10 worklet nodes and no fallbacks; unawaited, every sync voice degrades.

The test harness gains createDelay, which spc700's echo needs and a three-voice player never did.

deck-synths is declared the way deck already was: a peer on the published range, with a
file: devDependency for local work. tish inlines the voices into dist, so it is a build-time
dependency for anyone compiling from source rather than a runtime install.

All 33 voices now play, and nothing is substituted.
Every example on the page was gameBoyDmg, gbaDirectSound or basicOsc, so the site demonstrated
three voices out of thirty-three and the other thirty were documented only as a list.

Add six playable sections under 'Beyond the chips': hard sync, the analog rack, atmospheric pads,
the five non-Game-Boy chip emulations, acoustic models, and a matrixFm operator graph.

Parameters come from the factory presets rather than from what a knob range suggests, which is
where the earlier hand-written attempts went wrong — acid303's env_mod is a filter envelope in Hz
and runs in the thousands, sub808's punch is Hz, arco's voice names an instrument, and syncLead's
sweep_amt is tens of semitones because that sweep is the sound.

All 22 blocks on the page now parse, build a graph and play with zero substitutions.
There was no way to hear a song without opening the site and pressing play.

The voices are Web Audio — oscillators, biquads, wave shapers, a delay, and an AudioWorklet for
the sync family — so there is no pure-Node path to a buffer, and writing a second implementation
for Node is the duplication this repo just finished removing. Drive a headless Chrome instead:
serve packages/player/dist, call the player's own renderDeckToBuffer() in an OfflineAudioContext,
and copy the samples back. Nothing is recorded from a sound device, so it is deterministic and
runs far faster than real time.

Reports peak, rms and any substitutions, so a bad render is visible without opening the file —
a clipped render, a near-silent one, and a track playing a stand-in oscillator all look different
on the output line.

docs/RENDERING.md covers the CLI, its flags, how to read that line, renderDeckToBuffer for hosts
doing their own rendering, and the sync-worklet ordering that a host driving buildAudioGraph
directly has to get right.
@spacedevin
spacedevin merged commit b3557f6 into main Sep 1, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant