From 9caa3a905aec3922411c75d5aef709bc74ebdffd Mon Sep 17 00:00:00 2001 From: spacedevin Date: Tue, 1 Sep 2026 12:00:15 -0700 Subject: [PATCH 1/3] refactor(player): play the catalog instead of re-porting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package-lock.json | 4 +- packages/player/package.json | 6 +- packages/player/src/audio/Engine.tish | 8 +- .../src/generators/AdsrAmpSchedule.tish | 19 -- packages/player/src/generators/BasicOsc.tish | 83 ----- packages/player/src/generators/Dispatch.tish | 32 -- .../player/src/generators/GameBoyDmg.tish | 321 ------------------ .../player/src/generators/GbaDirectSound.tish | 224 ------------ packages/player/src/generators/Registry.tish | 47 ++- packages/player/src/index.tish | 38 ++- packages/player/src/schedule/Engine.tish | 9 +- packages/player/src/song/Apply.tish | 3 +- packages/player/test/fake-audio.mjs | 9 + packages/player/test/voices.mjs | 31 +- .../src/generators => synths/src}/Duty.tish | 0 packages/synths/src/GameBoyDmg.tish | 7 +- packages/synths/src/GbaDirectSound.tish | 11 +- packages/synths/src/index.tish | 2 + 18 files changed, 108 insertions(+), 746 deletions(-) delete mode 100644 packages/player/src/generators/AdsrAmpSchedule.tish delete mode 100644 packages/player/src/generators/BasicOsc.tish delete mode 100644 packages/player/src/generators/Dispatch.tish delete mode 100644 packages/player/src/generators/GameBoyDmg.tish delete mode 100644 packages/player/src/generators/GbaDirectSound.tish rename packages/{player/src/generators => synths/src}/Duty.tish (100%) diff --git a/package-lock.json b/package-lock.json index d5c9b38..7c0ee7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1031,13 +1031,15 @@ "version": "0.1.0", "license": "MIT", "devDependencies": { - "@spacedevin/deck": "file:../.." + "@spacedevin/deck": "file:../..", + "@spacedevin/deck-synths": "file:../synths" }, "engines": { "node": ">=22" }, "peerDependencies": { "@spacedevin/deck": "^1.8.0", + "@spacedevin/deck-synths": "^1.8.0", "@tishlang/tish": ">=3.2.2" }, "peerDependenciesMeta": { diff --git a/packages/player/package.json b/packages/player/package.json index 653d243..52af703 100644 --- a/packages/player/package.json +++ b/packages/player/package.json @@ -63,7 +63,8 @@ ], "peerDependencies": { "@tishlang/tish": ">=3.2.2", - "@spacedevin/deck": "^1.8.0" + "@spacedevin/deck": "^1.8.0", + "@spacedevin/deck-synths": "^1.8.0" }, "peerDependenciesMeta": { "@tishlang/tish": { @@ -71,6 +72,7 @@ } }, "devDependencies": { - "@spacedevin/deck": "file:../.." + "@spacedevin/deck": "file:../..", + "@spacedevin/deck-synths": "file:../synths" } } diff --git a/packages/player/src/audio/Engine.tish b/packages/player/src/audio/Engine.tish index b8c990d..059176a 100644 --- a/packages/player/src/audio/Engine.tish +++ b/packages/player/src/audio/Engine.tish @@ -10,7 +10,8 @@ // actor mixer lanes. What is left is the per-channel strip and the master chain — the parts that // exist to make a song sound right rather than to run a live set. -import { dispatchPlayNote } from '../generators/Dispatch.tish' +import { dispatchPlayNote } from '@spacedevin/deck-synths' +import { ensureSyncWorklet } from '@spacedevin/deck-synths' fn makeDriveCurve(ctx, amount) { let k = amount * 120 @@ -169,6 +170,11 @@ export fn buildChannelBus(ctx, ch, masterSum, reverbIn) { * difference between hearing a song and rendering it is which node the master chain lands on. */ export fn buildAudioGraph(ctx, song, opts) { + // Start registering the sync processor as early as possible. Sync voices fall back to a plain + // oscillator when it is missing, and registration is async, so kicking it off at graph-build time + // gives it the whole of the user's press-to-first-note latency to land. Idempotent per context. + ensureSyncWorklet(ctx) + let o = opts ? opts : {} let masterGainValue = (o.gain !== null && o.gain !== undefined) ? o.gain : 0.9 let withReverb = o.reverb !== false diff --git a/packages/player/src/generators/AdsrAmpSchedule.tish b/packages/player/src/generators/AdsrAmpSchedule.tish deleted file mode 100644 index 71ba134..0000000 --- a/packages/player/src/generators/AdsrAmpSchedule.tish +++ /dev/null @@ -1,19 +0,0 @@ -// Schedules gain ADSR so AudioParam times never go backwards (short gates + long A/D would throw). -// -// Ported verbatim from Deckard (tish-midi/src/generators/AdsrAmpSchedule.tish). - -export fn scheduleAdsrAmpEnvelope(env, t, peak, slev, a, d, r, durSec) { - let gate = durSec > 0 ? durSec : 0.001 - let tDecayEnd = t + a + d - let susPlateau = Math.max(tDecayEnd, t + gate - r) - let releaseEnd = t + gate + r - if (releaseEnd <= susPlateau) { - releaseEnd = susPlateau + Math.max(r, 0.02) - } - env.gain.setValueAtTime(0, t) - env.gain.linearRampToValueAtTime(peak, t + a) - env.gain.linearRampToValueAtTime(slev, tDecayEnd) - env.gain.setValueAtTime(slev, susPlateau) - env.gain.linearRampToValueAtTime(0, releaseEnd) - return releaseEnd -} diff --git a/packages/player/src/generators/BasicOsc.tish b/packages/player/src/generators/BasicOsc.tish deleted file mode 100644 index aa7ee6a..0000000 --- a/packages/player/src/generators/BasicOsc.tish +++ /dev/null @@ -1,83 +0,0 @@ -// The fallback voice: a plain oscillator + ADSR. Any generator id this package hasn't ported lands -// here, so an unknown `gen` still makes a sound instead of silence. -// -// Ported from Deckard (tish-midi/src/generators/BasicOsc.tish). - -import { midiToHz } from '../schedule/Engine.tish' -import { scheduleAdsrAmpEnvelope } from './AdsrAmpSchedule.tish' - -export fn normalizeBasicOscWaveform(raw) { - if (!raw) { - return "sine" - } - let s = String(raw).toLowerCase() - if (s === "saw" || s === "sawtooth") { - return "sawtooth" - } - if (s === "square" || s === "sqr" || s === "pulse") { - return "square" - } - if (s === "triangle" || s === "tri") { - return "triangle" - } - return "sine" -} - -export fn defaultParamsForBasicOsc() { - return { - waveform: "sine", - attack: 0.005, - decay: 0.08, - sustain: 0.4, - release: 0.12 - } -} - -export fn playBasicOsc(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { - let p = ch.generatorParams - let wave = "sine" - let a = 0.005 - let d = 0.08 - let sus = 0.4 - let r = 0.12 - if (p) { - wave = normalizeBasicOscWaveform(p.waveform) - if (p.attack > 0) { - a = p.attack - } - if (p.decay > 0) { - d = p.decay - } - if (p.sustain >= 0 && p.sustain <= 1) { - sus = p.sustain - } - if (p.release > 0) { - r = p.release - } - } - let osc = ctx.createOscillator() - osc.type = wave - let n = Math.floor(midi + bendSemis) - let hz = 440 - if (n >= 0 && n <= 127) { - hz = midiToHz(n) - } - osc.frequency.value = hz - let env = ctx.createGain() - env.gain.value = 0 - osc.connect(env) - env.connect(bus.input) - let v = vel / 127 - if (v < 0) { - v = 0 - } - if (v > 1) { - v = 1 - } - let peak = v - let slev = sus * v - let tEnd = scheduleAdsrAmpEnvelope(env, t, peak, slev, a, d, r, durSec) - osc.start(t) - osc.stop(tEnd + 0.05) - return { stopTime: tEnd + 0.05, disconnects: [osc, env] } -} diff --git a/packages/player/src/generators/Dispatch.tish b/packages/player/src/generators/Dispatch.tish deleted file mode 100644 index aa6fc58..0000000 --- a/packages/player/src/generators/Dispatch.tish +++ /dev/null @@ -1,32 +0,0 @@ -// Generator id → voice. One flat branch, uniform signature, same as Deckard's Dispatch.tish. -// -// Two differences from Deckard, both because this is a library and not an app: -// - voices RETURN `{stopTime, disconnects}` instead of arming a per-note `setTimeout`. The caller -// decides the cleanup policy, which is what lets an OfflineAudioContext render work at all. -// - an unported id falls back to `basicOsc` and is recorded, so the player can tell the user which -// generators were substituted instead of quietly sounding wrong. - -import { playGameBoyDmg } from './GameBoyDmg.tish' -import { playGbaDirectSound } from './GbaDirectSound.tish' -import { playBasicOsc } from './BasicOsc.tish' - -export fn dispatchPlayNote(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { - if (!bus || !bus.input) { - return null - } - let id = ch.generatorId - if (!id) { - id = "basicOsc" - } - // `voice octave N` shifts the whole channel before the voice sees the pitch. - if (ch.octave !== null && ch.octave !== undefined && ch.octave !== 0) { - midi = midi + ch.octave * 12 - } - if (id === "gameBoyDmg") { - return playGameBoyDmg(ctx, bus, t, midi, vel, durSec, ch, bendSemis) - } - if (id === "gbaDirectSound") { - return playGbaDirectSound(ctx, bus, t, midi, vel, durSec, ch, bendSemis) - } - return playBasicOsc(ctx, bus, t, midi, vel, durSec, ch, bendSemis) -} diff --git a/packages/player/src/generators/GameBoyDmg.tish b/packages/player/src/generators/GameBoyDmg.tish deleted file mode 100644 index 0322395..0000000 --- a/packages/player/src/generators/GameBoyDmg.tish +++ /dev/null @@ -1,321 +0,0 @@ -// Game Boy (LR35902 / DMG PSG) emulation. -// -// Ported from Deckard (tish-midi/src/generators/GameBoyDmg.tish). The waveform tables are baked into -// tiny looping AudioBuffers and pitched with `playbackRate` — that is what gives the aliasing and -// the hard edges a band-limited OscillatorNode would smooth away. -// -// Two things here are real hardware, not approximations: the noise channel is an actual 15/7-bit -// LFSR rendered to a buffer, and the wave channel is quantized to 4 bits (16 levels) like wave RAM. -// -// One deliberate deviation from Deckard: node cleanup is RETURNED rather than done in a per-note -// `setTimeout`. Wall-clock timers are wrong for an OfflineAudioContext render (which runs faster than -// realtime) and untestable in Node. The dispatcher owns the cleanup policy — see Dispatch.tish. The -// scheduled audio is byte-for-byte the same. - -import { normalizeDuty } from './Duty.tish' - -export fn defaultParamsForGameBoyDmg() { - return { - type: "pulse", - duty: "50", - envMode: "step", - vol: 15, - sweep: 0, - noiseMode: "long", - waveShape: "saw", - attack: 0, - decay: 0, - sustain: 15, - release: 0, - pitchDrop: 0, - pitchDec: 0.05, - vibRate: 0, - vibAmt: 0, - arpRate: 0, - arpSemis: 0, - // Hardware surface (round-trips to the GBA bake) - len: 0, - envStep: 0, - envUp: false, - sweepShift: 0, - sweepPeriod: 0, - sweepDown: false, - noiseShift: null, - noiseRatio: 0 - } -} - -fn getDmgPulseBuffer(ctx, duty) { - // `duty` is already canonical (see normalizeDuty), so 50% is the real default rather than the - // all-off sequence Deckard falls through to. - let seq = [0, 1, 1, 1, 1, 0, 0, 0] - if (duty === "12_5") seq = [0, 1, 0, 0, 0, 0, 0, 0] - if (duty === "25") seq = [0, 1, 1, 0, 0, 0, 0, 0] - if (duty === "75") seq = [1, 0, 0, 1, 1, 1, 1, 1] - - let buf = ctx.createBuffer(1, 8, ctx.sampleRate) - let data = buf.getChannelData(0) - let i = 0 - while (i < 8) { - data[i] = (seq[i] === 1) ? 1.0 : -1.0 - i = i + 1 - } - return buf -} - -fn getDmgLfsrBuffer(ctx, mode) { - let steps = mode === 15 ? 32767 : 127 - let buf = ctx.createBuffer(1, steps, ctx.sampleRate) - let data = buf.getChannelData(0) - let reg = 1 - let i = 0 - while (i < steps) { - let bit0 = reg & 1 - let bitOther = (reg >> 1) & 1 - let feedback = bit0 ^ bitOther - reg = (reg >> 1) | (feedback << 14) - if (mode === 7) { - reg = (reg & ~(1 << 6)) | (feedback << 6) - } - data[i] = (reg & 1) === 0 ? 1.0 : -1.0 - i = i + 1 - } - return buf -} - -/// A `wave <32 hex nibbles>` table, already decoded to -1..1 by Apply. Straight into the -/// buffer — it is wave RAM, so it is 4-bit by construction and needs no further quantization. -fn getNamedWaveBuffer(ctx, table) { - let buf = ctx.createBuffer(1, 32, ctx.sampleRate) - let data = buf.getChannelData(0) - let i = 0 - while (i < 32) { - data[i] = table[i] - i = i + 1 - } - return buf -} - -fn getDmgWaveBuffer(ctx, shape) { - let buf = ctx.createBuffer(1, 32, ctx.sampleRate) - let data = buf.getChannelData(0) - let i = 0 - while (i < 32) { - let v = 0 - let phase = i / 32 - if (shape === "saw") { - v = (phase * 2) - 1 - } else if (shape === "square") { - v = phase < 0.5 ? 1 : -1 - } else { - v = Math.sin(phase * Math.PI * 2) - } - // Quantize to 4-bit (16 levels), as wave RAM does. - v = Math.round(v * 7.5) / 7.5 - data[i] = v - i = i + 1 - } - return buf -} - -export fn playGameBoyDmg(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { - let gp = ch.generatorParams - if (!gp) gp = {} - - let type = gp.type ? gp.type : "pulse" - let duty = normalizeDuty(gp.duty) - let envMode = gp.envMode ? gp.envMode : "step" - let vol = gp.vol !== null && gp.vol !== undefined ? Math.round(gp.vol) : 10 - let sweep = gp.sweep !== null && gp.sweep !== undefined ? Math.round(gp.sweep) : 0 - let noiseMode = gp.noiseMode ? gp.noiseMode : "long" - let waveShape = gp.waveShape ? gp.waveShape : "saw" - - let attack = gp.attack !== null && gp.attack !== undefined ? gp.attack : 0 - let decay = gp.decay !== null && gp.decay !== undefined ? gp.decay : 0 - let sustain = gp.sustain !== null && gp.sustain !== undefined ? gp.sustain : 15 - let release = gp.release !== null && gp.release !== undefined ? gp.release : 0 - let pitchDrop = gp.pitchDrop !== null && gp.pitchDrop !== undefined ? Math.round(gp.pitchDrop) : 0 - let pitchDec = gp.pitchDec !== null && gp.pitchDec !== undefined ? gp.pitchDec : 0.05 - let vibRate = gp.vibRate !== null && gp.vibRate !== undefined ? gp.vibRate : 0 - let vibAmt = gp.vibAmt !== null && gp.vibAmt !== undefined ? gp.vibAmt : 0 - let arpRate = gp.arpRate !== null && gp.arpRate !== undefined ? gp.arpRate : 0 - let arpSemis = gp.arpSemis !== null && gp.arpSemis !== undefined ? Math.round(gp.arpSemis) : 0 - let len = gp.len !== null && gp.len !== undefined ? Math.round(gp.len) : 0 - let envStep = gp.envStep !== null && gp.envStep !== undefined ? Math.round(gp.envStep) : 0 - let envUp = gp.envUp === true - let sweepShift = gp.sweepShift !== null && gp.sweepShift !== undefined ? Math.round(gp.sweepShift) : 0 - let sweepPeriod = gp.sweepPeriod !== null && gp.sweepPeriod !== undefined ? Math.round(gp.sweepPeriod) : 0 - let sweepDown = gp.sweepDown === true || sweep < 0 - let noiseShift = gp.noiseShift !== null && gp.noiseShift !== undefined ? Math.round(gp.noiseShift) : null - let noiseRatio = gp.noiseRatio !== null && gp.noiseRatio !== undefined ? Math.round(gp.noiseRatio) : 0 - - let isNoise = (type === "noise") - let isWave = (type === "wave") - let isPulse = (type === "pulse") - - let f0 = 440 * Math.pow(2, (midi + bendSemis - 69) / 12) - // Soft sweep (±semis) or an NR10-ish approximation via sweepShift/sweepPeriod. - let sweepSemis = sweep - if (sweepShift > 0 && sweepPeriod > 0 && isPulse) { - sweepSemis = sweepDown ? -(sweepShift) : sweepShift - } - let f1 = f0 * Math.pow(2, sweepSemis / 12) - - let v = vel / 127 - if (v < 0) v = 0 - if (v > 1) v = 1 - - // Hardware frequency floors. - if (isWave) { - if (f0 < 32.0) f0 = 32.0 - if (f1 < 32.0) f1 = 32.0 - } else if (isPulse) { - if (f0 < 64.0) f0 = 64.0 - if (f1 < 64.0) f1 = 64.0 - } - - let src = ctx.createBufferSource() - src.loop = true - - if (isWave) { - // A named `wave` table wins over the built-in shapes, same precedence as the GBA bake. - src.buffer = ch.waveTable ? getNamedWaveBuffer(ctx, ch.waveTable) : getDmgWaveBuffer(ctx, waveShape) - } else if (isNoise) { - src.buffer = getDmgLfsrBuffer(ctx, noiseMode === "short" ? 7 : 15) - } else { - src.buffer = getDmgPulseBuffer(ctx, duty) - } - - let rate0 = 1.0 - let rate1 = 1.0 - if (isNoise) { - // GB noise lacks the NES's rigid 16-period table (it uses an expanding divider tree), so modern - // trackers treat it continuously. Scale to MIDI; noise_shift/ratio nudge the divider. - let shift = noiseShift !== null ? noiseShift : Math.floor(midi / 8) - let ratioMul = 1 + (noiseRatio / 14) - let pitchScale = Math.pow(2, -(shift - 7) / 4) / ratioMul - rate0 = (f0 * 100 * pitchScale) / ctx.sampleRate - rate1 = (f1 * 100 * pitchScale) / ctx.sampleRate - } else { - rate0 = (f0 * src.buffer.length) / ctx.sampleRate - rate1 = (f1 * src.buffer.length) / ctx.sampleRate - } - - let safeDur = Math.max(durSec, 0.01) - // Hardware length counter approximation: (64-len)/256 s for pulse/noise. - if (len > 0 && !isWave) { - let hwDur = (64 - Math.min(len, 63)) / 256 - if (hwDur < safeDur) safeDur = Math.max(hwDur, 0.01) - } - - // The step envelope decays over 15 steps, max 7/64 s per step. Prefer an authored env_step; else - // map vol (0-15) onto a playable range. - let stepLen = envStep > 0 ? envStep : Math.max(1, Math.floor(vol / 2)) - let decTime = (stepLen / 7) * 1.64 - if (decTime < 0.05) decTime = 0.05 - - let totalTime = safeDur + 0.1 - if (envMode === "adsr") { - let tD = attack + decay + 0.01 - let tOff = Math.max(safeDur, tD) - totalTime = tOff + release + 0.1 - } else if (envMode === "step") { - totalTime = Math.max(safeDur, decTime) + 0.1 - } - - let stopTime = t + totalTime - let disconnects = [] - - src.playbackRate.setValueAtTime(rate0, t) - - if (pitchDrop !== 0) { - let rateDrop = rate0 * Math.pow(2, pitchDrop / 12) - src.playbackRate.setValueAtTime(rateDrop, t) - src.playbackRate.setTargetAtTime(rate0, t, Math.max(pitchDec, 0.001)) - } else if (arpRate > 0 && arpSemis !== 0) { - let arpStep = 1.0 / arpRate - let steps = Math.ceil(totalTime / arpStep) - let ai = 0 - while (ai < steps) { - let semi = (ai % 2 === 1) ? arpSemis : 0 - src.playbackRate.setValueAtTime(rate0 * Math.pow(2, semi / 12), t + ai * arpStep) - ai = ai + 1 - } - } else if (sweep !== 0 && isPulse) { - src.playbackRate.exponentialRampToValueAtTime(rate1, t + decTime) - } - - if (vibRate > 0 && vibAmt > 0 && !isNoise) { - let lfo = ctx.createOscillator() - lfo.type = "sine" - lfo.frequency.value = vibRate - let lfoGain = ctx.createGain() - lfoGain.gain.value = rate0 * (vibAmt / 1200) - lfo.connect(lfoGain) - lfoGain.connect(src.playbackRate) - lfo.start(t) - lfo.stop(stopTime) - disconnects.push(lfo) - disconnects.push(lfoGain) - } - - let maxAmp = (vol / 15.0) * v * 0.8 - if (maxAmp < 0) maxAmp = 0 - if (maxAmp > 0.8) maxAmp = 0.8 - - let finalGain = ctx.createGain() - - if (envMode === "adsr") { - let a = attack > 0 ? attack : 0.005 - let d = decay > 0 ? decay : 0.005 - let s = (sustain / 15.0) * maxAmp - let r = release > 0 ? release : 0.005 - let tA = t + a - let tD = tA + d - let tOff = Math.max(t + safeDur, tD) - - finalGain.gain.setValueAtTime(0, t) - finalGain.gain.linearRampToValueAtTime(maxAmp, tA) - finalGain.gain.linearRampToValueAtTime(s, tD) - finalGain.gain.setValueAtTime(s, tOff) - finalGain.gain.linearRampToValueAtTime(0, tOff + r) - } else { - let tOff = t + safeDur - if (envMode === "step" && envUp) { - // Amplify envelope: start low, climb toward maxAmp over decTime. - finalGain.gain.setValueAtTime(0.001, t) - let tDec = t + 0.005 + decTime - if (tOff < tDec) { - finalGain.gain.linearRampToValueAtTime(maxAmp, tOff) - } else { - finalGain.gain.linearRampToValueAtTime(maxAmp, tDec) - finalGain.gain.setValueAtTime(maxAmp, tOff) - } - finalGain.gain.linearRampToValueAtTime(0, tOff + 0.01) - } else { - finalGain.gain.setValueAtTime(0, t) - finalGain.gain.linearRampToValueAtTime(maxAmp, t + 0.005) - if (envMode === "step") { - let tDec = t + 0.005 + decTime - if (tOff < tDec) { - finalGain.gain.linearRampToValueAtTime(0, tOff) - } else { - finalGain.gain.linearRampToValueAtTime(0, tDec) - } - } else { - finalGain.gain.setValueAtTime(maxAmp, tOff) - finalGain.gain.linearRampToValueAtTime(0, tOff + 0.01) - } - } - } - - src.start(t) - src.stop(stopTime) - disconnects.push(src) - - src.connect(finalGain) - finalGain.connect(bus.input) - disconnects.push(finalGain) - - return { stopTime: stopTime, disconnects: disconnects } -} diff --git a/packages/player/src/generators/GbaDirectSound.tish b/packages/player/src/generators/GbaDirectSound.tish deleted file mode 100644 index 74e47a4..0000000 --- a/packages/player/src/generators/GbaDirectSound.tish +++ /dev/null @@ -1,224 +0,0 @@ -// Game Boy Advance DirectSound. -// -// Ported from Deckard (tish-midi/src/generators/GbaDirectSound.tish). Emulates the GBA's software -// mixer: an 8-bit DAC (a 256-step staircase WaveShaper) feeding a ~16 kHz lowpass, with the source -// itself a 32-sample buffer so high notes alias the way a low mixing rate makes them. -// -// Same cleanup deviation as GameBoyDmg — the voice returns its nodes instead of arming a setTimeout. - -import { normalizeDuty } from './Duty.tish' - -export fn defaultParamsForGbaDirectSound() { - return { - waveform: "pulse", // GBA software synths commonly used pulse/saw - duty: "50", - vol: 15, - attack: 0, - decay: 2, - sustain: 15, - release: 0, - bitcrush: true, // 8-bit DAC simulation - pitchDrop: 0, - pitchDec: 0.05, - vibRate: 0, - vibAmt: 0, - arpRate: 0, - arpSemis: 0 - } -} - -fn getGbaDacCurve(ctx) { - if (ctx.gbaDacCurve) return ctx.gbaDacCurve - // A staircase curve that forces the signal into 8 bits (256 steps). - let steps = 8192 - let curve = new Float32Array(steps) - let i = 0 - while (i < steps) { - let norm = i / (steps - 1) - let quant = Math.round(norm * 255) / 255.0 - curve[i] = quant * 2.0 - 1.0 - i = i + 1 - } - ctx.gbaDacCurve = curve - return curve -} - -fn getGbaBuffer(ctx, shape, duty) { - let cacheKey = "gba_" + shape + "_" + duty - if (ctx[cacheKey]) return ctx[cacheKey] - - // A tiny 32-sample buffer mimics the GBA software mixer's aliasing at high frequencies. - let len = 32 - let buf = ctx.createBuffer(1, len, ctx.sampleRate) - let data = buf.getChannelData(0) - - let dThresh = 0.5 - if (duty === "12_5") dThresh = 0.125 - if (duty === "25") dThresh = 0.25 - if (duty === "75") dThresh = 0.75 - - let i = 0 - while (i < len) { - let phase = i / len - let v = 0 - if (shape === "pulse") { - v = phase < dThresh ? 1.0 : -1.0 - } else if (shape === "sawtooth" || shape === "saw") { - v = (phase * 2.0) - 1.0 - } else if (shape === "triangle") { - v = phase < 0.5 ? (phase * 4.0 - 1.0) : (3.0 - phase * 4.0) - } else if (shape === "square") { - // `square` names a 50% wave, so it ignores `duty` on purpose. - // NOTE: the GBA bake does not agree yet — deckpack.rs `pcm_table` has no `square` arm, so it - // falls through to pulse, which does honour `duty`. `gen waveform square duty 25` is 50% - // here and 25% in the ROM. Tracked on the engine side; do not assume parity for `square`. - v = phase < 0.5 ? 1.0 : -1.0 - } else if (shape === "sine") { - v = Math.sin(phase * Math.PI * 2) - } else { - // An unrecognised name is a pulse, not a sine. `pulse` is already the default for an absent - // `waveform`, and the GBA bake's catch-all arm is a pulse too (deckpack.rs `pcm_table`), so a - // sine here meant `waveform bogus` sounded one way in the browser and another in the ROM. - v = phase < dThresh ? 1.0 : -1.0 - } - data[i] = v - i = i + 1 - } - - ctx[cacheKey] = buf - return buf -} - -export fn playGbaDirectSound(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { - let gp = ch.generatorParams - if (!gp) gp = {} - - let waveform = gp.waveform ? gp.waveform : "pulse" - let duty = normalizeDuty(gp.duty) - let vol = (gp.vol !== null && gp.vol !== undefined) ? Math.round(gp.vol) : 15 - let attack = (gp.attack !== null && gp.attack !== undefined) ? gp.attack : 0 - let decay = (gp.decay !== null && gp.decay !== undefined) ? gp.decay : 2 - let sustain = (gp.sustain !== null && gp.sustain !== undefined) ? gp.sustain : 15 - let release = (gp.release !== null && gp.release !== undefined) ? gp.release : 0 - - // `bitcrush 16bit` reads as "no crush" — matches the GBA bake's truthiness quirk. - let bitcrush = gp.bitcrush !== false && gp.bitcrush !== "16bit" - let pitchDrop = (gp.pitchDrop !== null && gp.pitchDrop !== undefined) ? Math.round(gp.pitchDrop) : 0 - let pitchDec = (gp.pitchDec !== null && gp.pitchDec !== undefined) ? gp.pitchDec : 0.05 - let vibRate = (gp.vibRate !== null && gp.vibRate !== undefined) ? gp.vibRate : 0 - let vibAmt = (gp.vibAmt !== null && gp.vibAmt !== undefined) ? gp.vibAmt : 0 - let arpRate = (gp.arpRate !== null && gp.arpRate !== undefined) ? gp.arpRate : 0 - let arpSemis = (gp.arpSemis !== null && gp.arpSemis !== undefined) ? Math.round(gp.arpSemis) : 0 - - let f0 = 440 * Math.pow(2, (midi + bendSemis - 69) / 12) - let v = vel / 127 - if (v < 0) v = 0 - if (v > 1) v = 1 - - let buf = getGbaBuffer(ctx, waveform, duty) - let src = ctx.createBufferSource() - src.buffer = buf - src.loop = true - - let baseFreq = (ctx.sampleRate / buf.length) - let rate0 = f0 / baseFreq - src.playbackRate.value = rate0 - - let disconnects = [] - - if (pitchDrop !== 0) { - let rateDrop = rate0 * Math.pow(2, pitchDrop / 12) - src.playbackRate.setValueAtTime(rateDrop, t) - src.playbackRate.setTargetAtTime(rate0, t, Math.max(pitchDec, 0.001)) - } else if (arpRate > 0 && arpSemis !== 0) { - let arpStep = 1.0 / arpRate - let steps = Math.ceil((durSec + release + 2.0) / arpStep) - let ai = 0 - src.playbackRate.setValueAtTime(rate0, t) - while (ai < steps) { - let semi = (ai % 2 === 1) ? arpSemis : 0 - src.playbackRate.setValueAtTime(rate0 * Math.pow(2, semi / 12), t + ai * arpStep) - ai = ai + 1 - } - } - - if (vibRate > 0 && vibAmt > 0) { - let lfo = ctx.createOscillator() - lfo.type = "sine" - lfo.frequency.value = vibRate - let lfoGain = ctx.createGain() - lfoGain.gain.value = rate0 * (vibAmt / 1200) - lfo.connect(lfoGain) - lfoGain.connect(src.playbackRate) - lfo.start(t) - lfo.stop(t + durSec + release + 2.0) - disconnects.push(lfo) - disconnects.push(lfoGain) - } - - let maxAmp = (vol / 15.0) * v * 0.8 - - let finalGain = ctx.createGain() - finalGain.gain.value = maxAmp - - let safeDur = Math.max(durSec, 0.01) - let envGain = ctx.createGain() - envGain.gain.value = 0 - - let tA = t + attack - let tD = tA + (decay / 10.0) - let tOff = Math.max(t + safeDur, tD) - - let envSLevel = sustain / 15.0 - if (attack > 0) { - envGain.gain.linearRampToValueAtTime(1.0, tA) - } else { - envGain.gain.setValueAtTime(1.0, tA) - } - - if (decay > 0) { - envGain.gain.setTargetAtTime(envSLevel, tA, decay / 30.0) - } else { - envGain.gain.setValueAtTime(envSLevel, tA) - } - - envGain.gain.setValueAtTime(envSLevel, tOff) - if (release > 0) { - envGain.gain.setTargetAtTime(0, tOff, release / 30.0) - } else { - envGain.gain.setValueAtTime(0, tOff) - } - - let stopTime = tOff + release + 0.1 - src.start(t) - src.stop(stopTime) - - src.connect(envGain) - - disconnects.push(src) - disconnects.push(envGain) - disconnects.push(finalGain) - - if (bitcrush) { - let shaper = ctx.createWaveShaper() - shaper.curve = getGbaDacCurve(ctx) - // No interpolation — the staircase is the point. - shaper.oversample = "none" - - // The software mixer rolled off hard at its Nyquist limit. - let mixFilter = ctx.createBiquadFilter() - mixFilter.type = "lowpass" - mixFilter.frequency.value = 16000 - envGain.connect(shaper) - shaper.connect(mixFilter) - mixFilter.connect(finalGain) - disconnects.push(shaper) - disconnects.push(mixFilter) - } else { - envGain.connect(finalGain) - } - - finalGain.connect(bus.input) - - return { stopTime: tOff + release + 0.5, disconnects: disconnects } -} diff --git a/packages/player/src/generators/Registry.tish b/packages/player/src/generators/Registry.tish index cd13c49..cd1356f 100644 --- a/packages/player/src/generators/Registry.tish +++ b/packages/player/src/generators/Registry.tish @@ -1,28 +1,30 @@ // What this package can play, and what each generator's parameters default to. // -// The tiers here mirror the port state documented in AGENTS.md. `PORTED` is the honest list; anything -// else is substituted with `basicOsc` and reported by `unsupportedGenerators()` rather than silently -// producing the wrong sound. +// Both answers come from @spacedevin/deck-synths now. This package used to carry its own three +// voices and a hand-maintained list of the thirty it did not implement; the catalog is a package, +// so the list is derived from it. +// +// Nothing here re-exports a name the catalog also exports. `tish build` inlines each module and +// emits one top-level binding per exported name, so two modules exporting `defaultParamsForGeneratorId` +// becomes a duplicate declaration in the bundle. Callers get those straight from the catalog. -import { defaultParamsForGameBoyDmg } from './GameBoyDmg.tish' -import { defaultParamsForGbaDirectSound } from './GbaDirectSound.tish' -import { defaultParamsForBasicOsc } from './BasicOsc.tish' +import { generatorCatalog } from '@spacedevin/deck-synths' -/// Generator ids this package synthesizes faithfully. +/// Generator ids this package synthesizes: everything in the catalog. export fn portedGeneratorIds() { - return ["gameBoyDmg", "gbaDirectSound", "basicOsc"] + let cat = generatorCatalog() + let out = [] + let i = 0 + while (i < cat.length) { + out.push(cat[i].id) + i = i + 1 + } + return out } -/// Generator ids that exist in the wider `.deck` ecosystem but are not ported here yet. Kept explicit -/// so `unsupportedGenerators()` can say "known but not ported" rather than "unknown id". +/// Nothing is unported any more. Kept so callers that ask do not have to change. export fn knownUnportedGeneratorIds() { - return [ - "chiptune", "nes2a03", "c64sid", "ym2612", "sn76489", "spc700", - "noiseBurst", "fmTone", "matrixFm", "patch", "pad", "bell", "drumSynth", - "guitar", "clap", "arco", "tine", "aether", "halo", "acid303", "sub808", - "cymbal", "reeseBass", "syncLead", "syncChoir", "obSync", "laserSync", - "formantVocal", "ttsVocal", "meSpeakVocal" - ] + return [] } export fn isPortedGeneratorId(id) { @@ -36,14 +38,3 @@ export fn isPortedGeneratorId(id) { } return false } - -/// Default `gen` params for a generator id. Unknown ids get the fallback voice's defaults. -export fn defaultParamsForGeneratorId(id) { - if (id === "gameBoyDmg") { - return defaultParamsForGameBoyDmg() - } - if (id === "gbaDirectSound") { - return defaultParamsForGbaDirectSound() - } - return defaultParamsForBasicOsc() -} diff --git a/packages/player/src/index.tish b/packages/player/src/index.tish index e80ae5b..38adc5e 100644 --- a/packages/player/src/index.tish +++ b/packages/player/src/index.tish @@ -16,9 +16,11 @@ import { stepTriggers, playStep, songStepCount } from './audio/Playback.tish' import { createTransport } from './audio/Transport.tish' import { secondsPerStep, swingOffsetSec, stepsToScheduleInWindow, underrunsInBatch } from './schedule/Scheduler.tish' import { midiToHz, automationAt, sixteenthSeconds } from './schedule/Engine.tish' -import { portedGeneratorIds, knownUnportedGeneratorIds, isPortedGeneratorId, defaultParamsForGeneratorId } from './generators/Registry.tish' -import { normalizeDuty } from './generators/Duty.tish' -import { dispatchPlayNote } from './generators/Dispatch.tish' +import { portedGeneratorIds, knownUnportedGeneratorIds, isPortedGeneratorId } from './generators/Registry.tish' +import { defaultParamsForGeneratorId } from '@spacedevin/deck-synths' +import { normalizeDuty } from '@spacedevin/deck-synths' +import { dispatchPlayNote } from '@spacedevin/deck-synths' +import { ensureSyncWorklet } from '@spacedevin/deck-synths' import { expandTriggerNotes } from './audio/NoteExpand.tish' import { snapToScale, songSnapPitch } from './song/Scale.tish' @@ -400,19 +402,31 @@ export fn renderDeckToBuffer(source, opts) { // A tail so the last note's release isn't cut off mid-decay. let seconds = steps * secPerStep + 2.0 let ctx = new OfflineAudioContext(2, Math.ceil(seconds * sampleRate), sampleRate) - let graph = buildAudioGraph(ctx, song, { gain: o.gain, reverb: o.reverb }) - - let t0 = 0.05 - let s = 0 - while (s < steps) { - let when = t0 + s * secPerStep + swingOffsetSec(s, secPerStep, song.swing) - playStep(ctx, song, graph, s, when) - s = s + 1 + + // Sync voices construct an AudioWorkletNode and fall back to a plain oscillator when the + // processor was never registered. Registration is async, and an offline render gets no second + // chance once startRendering() has run — so wait for the module before scheduling anything. + let ready = ensureSyncWorklet(ctx) + let schedule = fn () { + let graph = buildAudioGraph(ctx, song, { gain: o.gain, reverb: o.reverb }) + + let t0 = 0.05 + let s = 0 + while (s < steps) { + let when = t0 + s * secPerStep + swingOffsetSec(s, secPerStep, song.swing) + playStep(ctx, song, graph, s, when) + s = s + 1 + } + return ctx.startRendering() + } + if (ready) { + return ready.then(schedule) } - return ctx.startRendering() + return schedule() } export { + ensureSyncWorklet, // Song IR livePlayerCount, parseSong, diff --git a/packages/player/src/schedule/Engine.tish b/packages/player/src/schedule/Engine.tish index 73ded1f..54fa750 100644 --- a/packages/player/src/schedule/Engine.tish +++ b/packages/player/src/schedule/Engine.tish @@ -1,3 +1,9 @@ +// midiToHz lives with the voices that use it, in @spacedevin/deck-synths. Two definitions +// inlined into one program is a duplicate declaration. +import { midiToHz } from '@spacedevin/deck-synths' + +export { midiToHz } + // Project time → playback parameters. Pure math: no DOM, no audio nodes. // // Ported from Deckard (tish-midi/src/schedule/Engine.tish). @@ -29,6 +35,3 @@ export fn automationAt(points, beat) { return points[points.length - 1].value } -export fn midiToHz(midi) { - return 440 * Math.pow(2, (midi - 69) / 12) -} diff --git a/packages/player/src/song/Apply.tish b/packages/player/src/song/Apply.tish index 97dafc3..c7e543e 100644 --- a/packages/player/src/song/Apply.tish +++ b/packages/player/src/song/Apply.tish @@ -10,7 +10,8 @@ // canonical list for the two chip synths. import { parseProgram, parseTrackBody, barSelectorMatches, snakeToCamel, parseGenBlock } from '@spacedevin/deck' -import { defaultParamsForGeneratorId, isPortedGeneratorId, knownUnportedGeneratorIds } from '../generators/Registry.tish' +import { isPortedGeneratorId, knownUnportedGeneratorIds } from '../generators/Registry.tish' +import { defaultParamsForGeneratorId } from '@spacedevin/deck-synths' import { bootDeckRegistries } from './DeckIds.tish' // ── clamps ──────────────────────────────────────────────────────────────────── diff --git a/packages/player/test/fake-audio.mjs b/packages/player/test/fake-audio.mjs index f8bf0f6..0ef16cd 100644 --- a/packages/player/test/fake-audio.mjs +++ b/packages/player/test/fake-audio.mjs @@ -87,6 +87,14 @@ class FakePanner extends FakeNode { constructor (ctx) { super(ctx, 'panner'); this.pan = new FakeParam(this, 'pan', 0) } } +class FakeDelay extends FakeNode { + constructor (ctx, max = 1) { + super(ctx, 'delay') + this.maxDelayTime = max + this.delayTime = new FakeParam(this, 'delayTime', 0) + } +} + class FakeConvolver extends FakeNode { constructor (ctx) { super(ctx, 'convolver'); this.buffer = null } } @@ -133,6 +141,7 @@ export class FakeAudioContext { createBiquadFilter () { return new FakeBiquad(this) } createWaveShaper () { return new FakeWaveShaper(this) } createStereoPanner () { return new FakePanner(this) } + createDelay (max = 1) { return new FakeDelay(this, max) } createConvolver () { return new FakeConvolver(this) } createDynamicsCompressor () { return new FakeCompressor(this) } createAnalyser () { return new FakeAnalyser(this) } diff --git a/packages/player/test/voices.mjs b/packages/player/test/voices.mjs index 31fc25d..df5bacf 100644 --- a/packages/player/test/voices.mjs +++ b/packages/player/test/voices.mjs @@ -9,7 +9,7 @@ import test from 'node:test' import { FakeAudioContext, fakeBus } from './fake-audio.mjs' import { dispatchPlayNote, midiToHz, parseSong, buildAudioGraph, playStep, - createDeckPlayer, livePlayerCount + createDeckPlayer, livePlayerCount, isPortedGeneratorId, knownUnportedGeneratorIds } from '../dist/deck-player.js' const chan = (generatorId, generatorParams) => ({ @@ -146,16 +146,18 @@ test('gbaDirectSound pitch_drop bends down then recovers', () => { assert.equal(set.t, target.t, 'both land at the note start') }) -test('an unported generator falls back to a plain oscillator', () => { +test('every generator in the catalog plays, none are substituted', () => { + // This package used to synthesize three voices and stand basicOsc in for the other thirty. It + // delegates to @spacedevin/deck-synths now, so a generator outside the old three builds its own + // graph and `substitutions` stays empty. const { ctx } = play('matrixFm', { waveform: 'saw' }) - assert.equal(ctx.of('bufferSource').length, 0) - assert.equal(ctx.of('oscillator').length, 1, 'basicOsc stands in') - assert.equal(ctx.of('oscillator')[0].type, 'sawtooth') - // The substitution is reported rather than silent — see Registry.knownUnportedGeneratorIds. + assert.ok(ctx.nodes.length > 0, 'matrixFm builds a real graph rather than standing in') + const song = parseSong('deck 1\ntrack T id t gen matrix_fm\n note 60 0 1 v 100\n') - assert.equal(song.substitutions.length, 1) - assert.equal(song.substitutions[0].generatorId, 'matrixFm') - assert.match(song.substitutions[0].reason, /not ported/) + assert.equal(song.substitutions.length, 0, 'nothing is substituted any more') + assert.ok(isPortedGeneratorId('matrixFm'), 'and the registry agrees it is playable') + assert.ok(isPortedGeneratorId('syncLead')) + assert.equal(knownUnportedGeneratorIds().length, 0) }) test('voice octave shifts the pitch the generator receives', () => { @@ -202,10 +204,13 @@ track Lead id lead gen gameBoyDmg // A step actually reaches the bus input. const before = ctx.nodes.length const voices = playStep(ctx, song, graph, 0, 1.0) - assert.equal(voices.length, 1) - assert.ok(ctx.nodes.length > before) - assert.ok(voices[0].stopTime > 1.0, 'a voice reports when it can be retired') - assert.ok(voices[0].disconnects.length > 0, 'and what to retire') + assert.ok(ctx.nodes.length > before, 'a step reaches the bus input') + // The catalog's voices retire themselves on a timer rather than handing back + // `{ stopTime, disconnects }`, so `pruneVoices` has nothing to collect for them. Audio is + // unaffected — they still call stop() — but nodes are not disconnected early, which costs + // memory on a long OfflineAudioContext render. Retrofitting the catalog to the return contract + // is what would restore early pruning. + assert.equal(voices.length, 0, 'catalog voices manage their own retirement') }) test('starting one player stops the others', () => { diff --git a/packages/player/src/generators/Duty.tish b/packages/synths/src/Duty.tish similarity index 100% rename from packages/player/src/generators/Duty.tish rename to packages/synths/src/Duty.tish diff --git a/packages/synths/src/GameBoyDmg.tish b/packages/synths/src/GameBoyDmg.tish index d6265be..3473ff6 100644 --- a/packages/synths/src/GameBoyDmg.tish +++ b/packages/synths/src/GameBoyDmg.tish @@ -70,12 +70,13 @@ fn getDmgLfsrBuffer(ctx, mode) { /// A named `wave` table: 32 four-bit levels straight into a buffer. Levels are 0..15 about a 7.5 /// rest line, which is exactly what wave RAM holds, so no quantization step is needed — they are /// already on the 16-level grid the built-in shapes get rounded onto. -fn getNamedWaveBuffer(ctx, levels) { +fn getNamedWaveBuffer(ctx, table) { let buf = ctx.createBuffer(1, 32, ctx.sampleRate) let data = buf.getChannelData(0) let i = 0 while (i < 32) { - data[i] = levels[i] / 7.5 - 1 + // Already -1..1: the host decodes wave RAM at the IR boundary. + data[i] = table[i] i = i + 1 } return buf @@ -108,7 +109,7 @@ export fn playGameBoyDmg(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { if (!gp) gp = {} let type = gp.type ? gp.type : "pulse" - let duty = gp.duty ? String(gp.duty) : "50" + let duty = normalizeDuty(gp.duty) let envMode = gp.envMode ? gp.envMode : "step" let vol = gp.vol !== null ? Math.round(gp.vol) : 10 let sweep = gp.sweep !== null ? Math.round(gp.sweep) : 0 diff --git a/packages/synths/src/GbaDirectSound.tish b/packages/synths/src/GbaDirectSound.tish index 35908fc..34bac8d 100644 --- a/packages/synths/src/GbaDirectSound.tish +++ b/packages/synths/src/GbaDirectSound.tish @@ -57,12 +57,17 @@ fn getGbaBuffer(ctx, shape, duty) { let v = 0 if (shape === "pulse") { v = phase < dThresh ? 1.0 : -1.0 - } else if (shape === "sawtooth") { + } else if (shape === "sawtooth" || shape === "saw") { v = (phase * 2.0) - 1.0 } else if (shape === "triangle") { v = phase < 0.5 ? (phase * 4.0 - 1.0) : (3.0 - phase * 4.0) - } else { + } else if (shape === "square") { + v = phase < 0.5 ? 1.0 : -1.0 + } else if (shape === "sine") { v = Math.sin(phase * Math.PI * 2) + } else { + // Unrecognised names are a pulse, matching the GBA bake's catch-all arm. + v = phase < dThresh ? 1.0 : -1.0 } data[i] = v i = i + 1 @@ -77,7 +82,7 @@ export fn playGbaDirectSound(ctx, bus, t, midi, vel, durSec, ch, bendSemis) { if (!gp) gp = {} let waveform = gp.waveform ? gp.waveform : "pulse" - let duty = gp.duty ? String(gp.duty) : "50" + let duty = normalizeDuty(gp.duty) let vol = (gp.vol !== null) ? Math.round(gp.vol) : 15 let attack = (gp.attack !== null) ? gp.attack : 0 let decay = (gp.decay !== null) ? gp.decay : 2 diff --git a/packages/synths/src/index.tish b/packages/synths/src/index.tish index 7aa4638..c2c7412 100644 --- a/packages/synths/src/index.tish +++ b/packages/synths/src/index.tish @@ -39,6 +39,7 @@ import { syncMatrixFmSpecFromGraph } from './MatrixFmGraph.tish' import { ensureSyncWorklet, syncWorkletSource } from './SyncWorklet.tish' +import { normalizeDuty } from './Duty.tish' // The patch expression evaluator. It runs for every `patch` env value at play time, which the // structural graph tests never exercise, so it wants direct coverage — and a consumer cannot reach // it through the package boundary unless it is exported. @@ -69,4 +70,5 @@ export { syncMatrixFmSpecFromGraph } export { ensureSyncWorklet, syncWorkletSource } +export { normalizeDuty } export { resolveExpr } From 69ff2dac4e6b4f2095428409db7170684fcdf752 Mon Sep 17 00:00:00 2001 From: spacedevin Date: Tue, 1 Sep 2026 12:00:40 -0700 Subject: [PATCH 2/3] docs(examples): show the catalog, not just the two chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/EXAMPLES.md | 245 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 2492438..4e0a55a 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -407,6 +407,251 @@ track Bell id bell gen basicOsc * 2 The channel strip — `mix`, and `fx`'s filter, drive and reverb send — is host-side and applies to every generator, so it works the same on a chip voice as it does here. +`basicOsc` is the plainest of thirty-three. The rest of the catalog is in +[Instruments](../synths/catalog/); the examples below are one from each family. + +### Hard sync + +A sync voice runs two oscillators and lets the first reset the second's phase. `slave_base` is how +far above the note the slave starts, in semitones, and `sweep_amt` is how far it sweeps down — that +sweep *is* the sound, so it wants tens of semitones rather than a couple. + +```deck +deck 1 +bpm 124 + +track Lead id lead gen syncLead * 2 + gen slave_base 19 sweep_amt 30 sweep_decay 0.25 cutoff 4200 resonance 3 + adsr a 0.02 d 0.2 s 13 r 0.3 + mix gain 0.5 pan -0.15 + fx reverb_send 0.25 + note 69 0 1.5 v 104 + note 76 1.5 0.5 v 92 + note 74 2 2 v 100 + note 72 4 1.5 v 98 + note 69 5.5 0.5 v 88 + note 64 6 2 v 94 + +track Choir id choir gen syncChoir * 2 + gen vowel_shift 12 morph_rate 0.8 morph_amt 6 ensemble_detune 6 vib_rate 6.5 vib_amt 14 highpass 400 + adsr a 0.3 d 0.5 s 12 r 0.8 + mix gain 0.3 pan 0.1 + fx reverb_send 0.5 + note 57 0 3.8 v 74 + note 55 4 3.8 v 76 +``` + +### The analog rack + +A 303's character is `env_mod` — a filter envelope in Hz, not a 0–1 amount, so it runs in the +thousands. The 808's `punch` is also Hz, and its `drive` is 0–1. + +```deck +deck 1 +bpm 128 +song_seed 303 + +track Acid id acid gen acid303 * 2 + gen waveform sawtooth cutoff 480 resonance 20 env_mod 5000 decay 0.25 + mix gain 0.4 pan 0.15 + note 40 0 0.25 v 118 + note 52 0.75 0.25 v 110 + note 40 1.5 0.25 v 92 + note 47 2.25 0.25 v 104 + note 38 4 0.25 v 118 + note 50 4.75 0.25 v 108 + note 45 5.5 0.25 v 96 + note 38 6.5 1 v 100 + +track Sub id sub gen sub808 * 2 + gen punch 48 decay 2 drive 0.15 glide 0.08 + mix gain 0.5 + note 40 0 2 v 120 + note 38 4 2.5 v 112 + +track Kick id kick gen drumSynth * 1 + gen tone sine pitch_env 30 pitch_decay 0.035 decay 0.3 noise 0.05 drive 0.2 + mix gain 0.55 + step_pitch 36 + steps x . . . | x . . . | x . . . | x . . . + +track Clap id clap gen clap * 1 + gen hands 2 spread 0.2 size 0.3 tone 0.6 claps 1 gap 0.2 tail 0.25 body 0.15 + mix gain 0.3 pan 0.2 + step_pitch 60 + steps . . . . | x . . . | . . . . | x . . . +``` + +### Atmospheric + +Long attacks, and nothing competing for the same register. + +```deck +deck 1 +bpm 76 + +track Pad id pad gen pad * 4 + gen wave1 triangle wave2 sine detune 14 cutoff 1200 + adsr a 0.6 d 0.8 s 11 r 2 + mix gain 0.26 pan -0.3 + fx reverb_send 0.5 + note 52 0 7.6 v 72 + note 50 8 7.6 v 74 + +track Halo id halo gen halo * 4 + gen temper 0.5 ring 0.35 mallet 0.6 bloom 1.2 lows 0.3 + adsr a 0.02 d 2.5 s 2 r 1.6 + mix gain 0.26 pan 0.3 + fx reverb_send 0.6 cutoff 6000 + note 79 0 2 v 76 + note 84 4 2 v 72 + note 81 8 2 v 78 + note 88 12 3 v 82 + +track Bell id bell gen bell * 4 + gen partial 3.4 highpass 900 decay 2.6 + mix gain 0.3 pan -0.3 + fx reverb_send 0.65 + note 91 6 2 v 62 + note 86 14 2 v 58 +``` + +### Other consoles + +The Game Boy is one of six chip emulations. These are five others, in one bar each. + +```deck +deck 1 +bpm 140 + +track NES id nes gen nes2a03 * 2 + gen type pulse duty 25 vol 12 env_mode adsr vib_rate 5.5 vib_amt 22 + adsr a 0.002 d 0.12 s 8 r 0.1 + mix gain 0.5 pan -0.3 + note 76 0 0.5 v 104 + note 79 0.5 0.5 v 92 + note 83 1 1 v 100 + note 76 2 2 v 96 + +track SID id sid gen c64sid * 2 + gen waveform pulse pulse_width 0.28 filter_type lowpass cutoff 2400 resonance 8 + adsr a 0.004 d 0.2 s 7 r 0.15 + mix gain 0.45 pan 0.3 + note 52 0 1 v 96 + note 57 2 2 v 100 + note 50 4 1 v 94 + note 55 6 2 v 98 + +track FM id fm gen ym2612 * 2 + gen algorithm 4 feedback 5 op1_mul 1 op1_tl 22 op2_mul 3 op2_tl 30 op3_mul 2 op3_tl 26 op4_mul 1 op4_tl 12 + mix gain 0.4 + note 40 0 2 v 106 + note 45 4 2 v 100 + +track SNES id snes gen spc700 * 2 + gen waveform triangle echo_enable 1 echo_delay 0.16 echo_feedback 0.3 + adsr a 0.01 d 0.4 s 6 r 0.3 + mix gain 0.35 pan -0.15 + note 64 2 1 v 82 + note 67 6 2 v 86 + +track PSG id psg gen sn76489 * 1 + gen type noise noise_mode white noise_freq 3 vol 8 + adsr a 0 d 0.05 s 0 r 0.02 + mix gain 0.25 pan 0.15 + steps x . . x | . . x . | x . . x | . x . . +``` + +### Acoustic models + +`voice` on `arco` names an instrument — `violin`, `cello`, `fiddle`, `bass` — rather than taking a +number. + +```deck +deck 1 +bpm 88 + +track Cello id vc gen arco * 4 + gen voice cello pressure 0.6 bow 0.5 vibrato 0.5 rosin 0.2 body 0.75 + adsr a 0.35 d 0.3 s 12 r 0.7 + mix gain 0.32 + fx cutoff 1600 reverb_send 0.35 + note 50 0 3.8 v 88 + note 46 4 3.8 v 84 + note 53 8 3.8 v 86 + note 48 12 3.8 v 82 + +track Rhodes id tine gen tine * 4 + gen bark 0.5 tine 0.65 tremolo 0.3 decay 1.1 drive 0.2 + adsr a 0.003 d 0.9 s 3 r 0.5 + mix gain 0.26 pan 0.3 + fx reverb_send 0.35 cutoff 4600 + note 62 1.5 1.2 v 74 + note 65 1.5 1.2 v 70 + note 58 5.5 1.2 v 74 + note 62 5.5 1.2 v 70 + note 65 9.5 1.2 v 74 + note 69 9.5 1.2 v 70 + note 60 13.5 1.2 v 74 + note 64 13.5 1.2 v 70 + +track Nylon id gtr gen guitar * 4 + gen tone 0.6 decay 1.4 damping 0.35 drive 0.15 body 0.7 mute 0 + mix gain 0.3 pan -0.2 + fx reverb_send 0.3 + note 69 0 1 v 86 + note 74 1 0.75 v 80 + note 77 4 1.5 v 88 + note 72 8 1 v 84 + note 77 10 1.5 v 90 + note 76 12 1.5 v 86 + note 74 14 1.8 v 82 +``` + +### An operator graph + +`matrixFm` is the one voice whose patch does not fit on a `gen` line, so it takes a `gen_block`: +operators, the modulation between them, a filter, and how they route to the output. This is the +factory *Supersaw Stack* — three detuned saws cross-modulated into a wide lowpass. + +```deck +deck 1 +bpm 128 + +track Saws id saw gen matrixFm * 2 + gen_block matrix_fm + op 1 wave saw ratio 1 + op 2 wave saw ratio 1.008 + op 3 wave saw ratio 2 + env op 1 a 0.006 d 0.22 s 0.72 r 0.32 + env op 2 a 0.006 d 0.22 s 0.72 r 0.32 + env op 3 a 0.006 d 0.22 s 0.68 r 0.32 + mod fm 2 1 0.45 + mod fm 3 1 0.28 + filter 1 type lp24 cutoff 6800 res 0.32 + route op 1 filter 1 0.38 + route op 2 filter 1 0.36 + route op 3 filter 1 0.34 + route filter 1 out 1 + end gen_block + mix gain 0.34 pan -0.1 + fx reverb_send 0.3 + note 65 0.5 0.4 v 104 + note 68 0.5 0.4 v 98 + note 72 0.5 0.4 v 96 + note 65 1.5 0.4 v 96 + note 68 1.5 0.4 v 90 + note 61 4.5 0.4 v 104 + note 65 4.5 0.4 v 98 + note 68 4.5 0.4 v 96 + +track Bass id bass gen reeseBass * 2 + gen voices 2 detune 15 cutoff 800 wobble 0 decay 1.2 + mix gain 0.4 + note 41 0 1.5 v 112 + note 39 4 1.5 v 108 +``` + ## Mixing and effects `mix` places a track and sets its level; `fx` shapes it. Cutoff and resonance are a filter sweep's From b03518cdcc341fc07cca131f8f035e2667dc7d44 Mon Sep 17 00:00:00 2001 From: spacedevin Date: Tue, 1 Sep 2026 12:00:50 -0700 Subject: [PATCH 3/3] feat(scripts): render a .deck to a .wav from the command line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/RENDERING.md | 118 ++++++++++++++++++++++ scripts/render-wav.mjs | 220 +++++++++++++++++++++++++++++++++++++++++ site/build.mjs | 2 +- 3 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 docs/RENDERING.md create mode 100755 scripts/render-wav.mjs diff --git a/docs/RENDERING.md b/docs/RENDERING.md new file mode 100644 index 0000000..967147d --- /dev/null +++ b/docs/RENDERING.md @@ -0,0 +1,118 @@ +# Rendering to audio + +A `.deck` file can be rendered to a `.wav` from the command line, without opening the site or +pressing play on anything. + +```bash +node scripts/render-wav.mjs song.deck -o song.wav +``` + +``` +song.wav 5.87s 2 tracks peak -2.0 dBFS rms -8.1 dBFS +``` + +## Why it needs a browser + +The voices are Web Audio. They are built from `OscillatorNode`, `BiquadFilterNode`, +`WaveShaperNode`, `DelayNode` and — for the sync oscillators — an `AudioWorklet`. Node has none of +these, so there is no pure-Node path from a song to a buffer, and reimplementing the voices for a +second runtime is exactly the duplication this repo spent its effort removing. + +So the renderer drives a headless Chrome instead. It serves `packages/player/dist`, calls the +player's own [`renderDeckToBuffer()`](#rendering-from-your-own-code) inside an `OfflineAudioContext`, +and copies the samples back out. Nothing is recorded from a sound device: an `OfflineAudioContext` +computes the buffer as fast as it can, which is many times quicker than real time, and it is +deterministic — the same song renders to the same samples every run. + +You need Chrome or Chromium installed. The script looks in the usual places; set `CHROME` to +override. + +## Options + +| flag | meaning | +| --- | --- | +| `-o`, `--out ` | output WAV — required | +| `--beats ` | how many beats to render. Defaults to the song's own length | +| `--gain ` | master gain, `0`–`1`. Default `0.9` | +| `--sample-rate ` | default `44100` | +| `--no-reverb` | bypass the reverb send | +| `--normalize` | scale the result to peak at −1 dBFS | + +`--beats` is how you render a slice: a two-bar audition of a long song, or one loop of a piece whose +`totalBeats` is unset. + +```bash +node scripts/render-wav.mjs song.deck -o loop.wav --beats 32 --normalize +``` + +## Reading the output line + +`peak` and `rms` are reported so a bad render is visible without opening the file. + +- **peak at 0.0 dBFS** means it is clipping — lower `--gain`. +- **rms below about −30 dBFS** on a dense arrangement usually means most of it never triggered. +- **A silent render** (`peak -inf`) means nothing played at all: check the song has notes on the + beats you asked for. + +If any track names a generator with no voice behind it, the substitution is listed: + +``` +substituted (no voice for these ids): + someGeneratorId +``` + +An empty list is the thing to want — it means every track is being played by its real voice rather +than standing in as a plain oscillator. + +## Rendering from your own code + +The CLI is a thin wrapper. In a browser, the player exports the same call: + +```js +import { renderDeckToBuffer } from '@spacedevin/deck-player' + +const buffer = await renderDeckToBuffer(source, { + beats: 32, + sampleRate: 44100, + gain: 0.9, + reverb: true, +}) +``` + +It returns a rendered `AudioBuffer`, which you can encode, analyse, or play back. + +### Sync voices need their processor registered + +The sync family — `syncLead`, `syncChoir`, `obSync`, `laserSync` — is built on an `AudioWorklet`. +A worklet module registers **asynchronously**, and a voice whose processor is not yet registered +falls back to a plain oscillator rather than failing, so the symptom is a render that sounds thin +instead of one that errors. + +`renderDeckToBuffer()` handles this: it waits for the module before scheduling a single note. If you +are driving `buildAudioGraph()` and `playStep()` yourself, register it first and await it: + +```js +import { ensureSyncWorklet, buildAudioGraph, playStep } from '@spacedevin/deck-player' + +await ensureSyncWorklet(ctx) // null when the context has no worklet support +const graph = buildAudioGraph(ctx, song, { gain: 0.9 }) +``` + +`buildAudioGraph()` also kicks registration off on its own, which is enough for live playback — +there, the module lands well inside the gap between the user pressing play and the first note. It is +not enough for an offline render, which gets no such gap. + +## Rendering every example on this site + +Each fenced block in [Examples](EXAMPLES.md) is a complete song. To render them all: + +```bash +node -e ' +const { readFileSync, writeFileSync, mkdirSync } = require("fs") +const md = readFileSync("docs/EXAMPLES.md", "utf8") +mkdirSync("out/examples", { recursive: true }) +;[...md.matchAll(/```deck\n([\s\S]*?)```/g)].forEach((m, i) => + writeFileSync(`out/examples/${String(i + 1).padStart(2, "0")}.deck`, m[1])) +' +for f in out/examples/*.deck; do node scripts/render-wav.mjs "$f" -o "${f%.deck}.wav"; done +``` diff --git a/scripts/render-wav.mjs b/scripts/render-wav.mjs new file mode 100755 index 0000000..e0bb018 --- /dev/null +++ b/scripts/render-wav.mjs @@ -0,0 +1,220 @@ +#!/usr/bin/env node +// Render a .deck file to a .wav from the command line. +// +// The voices are Web Audio — oscillators, biquads, wave shapers and an AudioWorklet for the sync +// oscillators — so there is no pure-Node path to a rendered buffer. This drives a headless Chrome +// over CDP, calls the player's own `renderDeckToBuffer()` inside an OfflineAudioContext, and brings +// the samples back. Offline rendering is deterministic and runs far faster than real time. +// +// node scripts/render-wav.mjs song.deck -o song.wav +// node scripts/render-wav.mjs song.deck -o song.wav --beats 32 --normalize +// +// Set CHROME to override browser discovery. + +import { spawn } from 'node:child_process' +import { createReadStream, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import http from 'node:http' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const PLAYER_DIST = path.join(HERE, '..', 'packages', 'player', 'dist') + +const CHROMES = [ + process.env.CHROME, + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/usr/bin/google-chrome', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', +].filter(Boolean) + +function usage (msg) { + if (msg) console.error(`render-wav: ${msg}\n`) + console.error(`usage: node scripts/render-wav.mjs -o [options] + + -o, --out output WAV (required) + --beats how many beats to render (default: the song's own length) + --gain master gain, 0..1 (default 0.9) + --sample-rate default 44100 + --no-reverb bypass the reverb send + --normalize scale the result to peak at -1 dBFS`) + process.exit(msg ? 2 : 0) +} + +function parseArgs (argv) { + const o = { sampleRate: 44100, reverb: true } + const rest = [] + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (a === '-h' || a === '--help') usage() + else if (a === '-o' || a === '--out') o.out = argv[++i] + else if (a === '--beats') o.beats = Number(argv[++i]) + else if (a === '--gain') o.gain = Number(argv[++i]) + else if (a === '--sample-rate') o.sampleRate = Number(argv[++i]) + else if (a === '--no-reverb') o.reverb = false + else if (a === '--normalize') o.normalize = true + else if (a.startsWith('-')) usage(`unknown option ${a}`) + else rest.push(a) + } + o.input = rest[0] + return o +} + +const MIME = { '.js': 'text/javascript', '.mjs': 'text/javascript', '.html': 'text/html' } + +function serve (dir, port) { + const server = http.createServer((req, res) => { + const rel = decodeURIComponent(req.url.split('?')[0]).replace(/^\/player\/?/, '') + const file = path.join(dir, rel) + if (!file.startsWith(path.resolve(dir)) || !existsSync(file) || !statSync(file).isFile()) { + res.statusCode = 404 + return res.end('not found') + } + res.setHeader('Content-Type', MIME[path.extname(file)] ?? 'application/octet-stream') + createReadStream(file).pipe(res) + }) + return new Promise((r) => server.listen(port, '127.0.0.1', () => r(server))) +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +async function connect (port) { + let url = null + for (let i = 0; i < 80 && !url; i++) { + try { + const tabs = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json() + url = tabs.find((t) => t.type === 'page')?.webSocketDebuggerUrl + } catch { /* not up yet */ } + if (!url) await sleep(250) + } + if (!url) throw new Error('Chrome never opened a debugging port') + const ws = new WebSocket(url) + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej }) + let id = 0 + const pending = new Map() + ws.onmessage = (e) => { + const m = JSON.parse(e.data) + const p = pending.get(m.id) + if (!p) return + pending.delete(m.id) + m.error ? p.rej(new Error(JSON.stringify(m.error))) : p.res(m.result) + } + const send = (method, params = {}) => new Promise((res, rej) => { + const n = ++id + pending.set(n, { res, rej }) + ws.send(JSON.stringify({ id: n, method, params })) + }) + const evaluate = async (expression) => { + const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }) + if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? 'evaluate failed') + return r.result.value + } + return { send, evaluate, close: () => ws.close() } +} + +// Runs in the page: render, measure, and hand back a base64 WAV. +const PAGE = `window.__renderDeck = async (src, opts) => { + const P = await import('/player/deck-player.js') + const song = P.parseSong(src) + if (song.errors && song.errors.length) return { errors: song.errors } + + const buf = await P.renderDeckToBuffer(src, opts) + + const chans = [] + for (let c = 0; c < buf.numberOfChannels; c++) chans.push(buf.getChannelData(c)) + let peak = 0, sum = 0, n = 0 + for (const d of chans) { + for (let i = 0; i < d.length; i++) { const a = Math.abs(d[i]); if (a > peak) peak = a; sum += d[i] * d[i]; n++ } + } + const rms = Math.sqrt(sum / Math.max(1, n)) + + const scale = opts.normalize && peak > 0 ? 0.891 / peak : 1 + const len = buf.length, ch = chans.length, sr = buf.sampleRate + const view = new DataView(new ArrayBuffer(44 + len * ch * 2)) + const str = (o, s) => { for (let i = 0; i < s.length; i++) view.setUint8(o + i, s.charCodeAt(i)) } + str(0, 'RIFF'); view.setUint32(4, 36 + len * ch * 2, true); str(8, 'WAVEfmt ') + view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, ch, true) + view.setUint32(24, sr, true); view.setUint32(28, sr * ch * 2, true) + view.setUint16(32, ch * 2, true); view.setUint16(34, 16, true) + str(36, 'data'); view.setUint32(40, len * ch * 2, true) + let o = 44 + for (let i = 0; i < len; i++) for (let c = 0; c < ch; c++) { + const s = Math.max(-1, Math.min(1, chans[c][i] * scale)) + view.setInt16(o, s < 0 ? s * 0x8000 : s * 0x7fff, true); o += 2 + } + const bytes = new Uint8Array(view.buffer) + let bin = '' + for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000)) + return { + wav: btoa(bin), peak, rms, seconds: len / sr, + substitutions: song.substitutions || [], channels: song.channels.length, + } +}` + +async function main () { + const opts = parseArgs(process.argv.slice(2)) + if (!opts.input) usage('no input file') + if (!opts.out) usage('no -o output path') + if (!existsSync(opts.input)) usage(`${opts.input} does not exist`) + if (!existsSync(path.join(PLAYER_DIST, 'deck-player.js'))) { + usage('packages/player/dist/deck-player.js is missing — run `npm run build` first') + } + + const chrome = CHROMES.find((c) => existsSync(c)) + if (!chrome) { + console.error('render-wav: no Chrome or Chromium found. Set CHROME to its path.') + process.exit(1) + } + + const src = readFileSync(opts.input, 'utf8') + const port = 9500 + (process.pid % 400) + const cdpPort = port + 1 + const server = await serve(PLAYER_DIST, port) + const profile = path.join(os.tmpdir(), `deck-render-${process.pid}`) + const proc = spawn(chrome, [ + '--headless=new', `--remote-debugging-port=${cdpPort}`, `--user-data-dir=${profile}`, + '--no-first-run', '--no-default-browser-check', '--autoplay-policy=no-user-gesture-required', + 'about:blank', + ], { stdio: ['ignore', 'ignore', 'ignore'] }) + + let cdp + try { + cdp = await connect(cdpPort) + await cdp.send('Page.enable') + await cdp.send('Runtime.enable') + await cdp.send('Page.navigate', { url: `http://127.0.0.1:${port}/player/blank.html` }) + // The dist directory has no HTML; a 404 body is a fine origin to import a module from. + await sleep(300) + await cdp.evaluate(PAGE) + + const payload = JSON.stringify({ + beats: opts.beats, gain: opts.gain, reverb: opts.reverb, + sampleRate: opts.sampleRate, normalize: !!opts.normalize, + }) + const out = await cdp.evaluate( + `window.__renderDeck(${JSON.stringify(src)}, ${payload})`, + ) + + if (out.errors) { + console.error('render-wav: the song did not parse') + for (const e of out.errors) console.error(` ${e.line ?? '?'}: ${e.message ?? JSON.stringify(e)}`) + process.exit(1) + } + + writeFileSync(opts.out, Buffer.from(out.wav, 'base64')) + const db = (v) => (v > 0 ? (20 * Math.log10(v)).toFixed(1) : '-inf') + console.log(`${opts.out} ${out.seconds.toFixed(2)}s ${out.channels} tracks peak ${db(out.peak)} dBFS rms ${db(out.rms)} dBFS`) + if (out.substitutions.length) { + console.log('substituted (no voice for these ids):') + for (const s of out.substitutions) console.log(` ${s.generatorId ?? JSON.stringify(s)}`) + } + } finally { + cdp?.close() + proc.kill() + server.close() + } +} + +main().catch((e) => { console.error(`render-wav: ${e.message}`); process.exit(1) }) diff --git a/site/build.mjs b/site/build.mjs index b21a594..c9e6192 100644 --- a/site/build.mjs +++ b/site/build.mjs @@ -44,7 +44,7 @@ const SECTIONS = [ label: 'Language', dir: 'docs', slug: 'docs', - order: ['DECK_GRAMMAR.md', 'EXAMPLES.md', 'AST.md', 'DECK_EXTENSION.md', 'HOST.md'], + order: ['DECK_GRAMMAR.md', 'EXAMPLES.md', 'RENDERING.md', 'AST.md', 'DECK_EXTENSION.md', 'HOST.md'], // Every untagged fence in these files is `.deck` — the grammar reference shows the language it // documents, and the host-facing snippets are all tagged `tish`. Declared rather than guessed. defaultLang: 'deck',