feat(audio): procedural chiptune BGM — title + in-game tracks (#15) #30
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/15-bgm"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #15.
Two procedurally-composed background tracks (title + in-game) on the same raw square/triangle/noise oscillators as the SFX — authentic NES/SNES chip synthesis, zero new dependencies (+~0.5KB gzip).
The Tone.js fork (measured, crew-ratified)
The issue ratified Tone.js (AC#1) and capped the add at "~50KB gzip." I measured these conflict: a tree-shaken Tone.js (Synth/PolySynth/NoiseSynth/MembraneSynth/Loop/Transport/Context) takes the bundle 12.95KB→74.68KB gzip — +62KB, over the cap, near Tone's floor. Surfaced the fork; operator delegated to crew with a benefit-heuristic; Bosun ratified option (a): extend the existing hand-rolled engine. Rationale (decision tree, not just conclusion):
audio.tsalready produces good chiptune, and Tone's seamless-loop win is replicable with a ~30-line lookahead scheduler. The benefit didn't clear the bar for a 5.7× bundle increase + losing the dependency-free client.Engine (
audio.ts)setIntervalschedules every step whose time falls in the next 100ms window at sample-accuratectxtimes — replacessetInterval-schedules-now, so the loop has no tempo-drift seam.mvoice/mnoiseschedule music voices at an absolute time through the music bus, kept separate from the SFXtone()/noise()so the SFX path is byte-for-byte untouched (AC#5).startMusic(track)swaps tracks (idempotent per-track); volume/mute already route through the master bus → apply to BGM unchanged (AC#4).Wiring (
main.ts)Title BGM on the first title-screen gesture + on
backToYard; in-game BGM at match/solo/rematch start. (Title music can only begin after a user gesture, per browser autoplay policy.)Substrate-moved note
The #16 solo lose-SFX gate (originally slated to fold in here per Bosun's routing) already landed upstream via Engineer's solo-endpoint PR while I was building. I discovered it as a merge conflict in the gameover block, dropped my duplicate, and took upstream's version. I also reverted a mock.ts
winner=nullchange whose only purpose was exercising that gate. This PR is BGM-only.Validation (closed loop)
tsc --noEmit+vite buildclean; bundle 13.56KB gzip (vs 74.68 for Tone.js). Audio can't be screenshotted, so a Web-Audio voice-creation spy (capture-bgm.js) measures behavior in input-free windows:AC status
tone()/noise()untouched; music uses separatemvoice/mnoise).Design calls flagged for review
stepMs,density, drum patterns, the A-minor scale) is by-eye + structural; trivially dial-able. Operator playtest may want tempo/mix tweaks.startSFX + countdown; could shrinkSCHEDULE_AHEADif undesired.🤖 Generated with Claude Code
Two procedurally-composed background tracks on the SAME raw square/triangle/ noise oscillators as the SFX — authentic NES/SNES chip synthesis, ZERO new dependencies. Why not Tone.js (the originally-ratified means): measured it at ~62KB gzip even tree-shaken (Synth/PolySynth/NoiseSynth/MembraneSynth/Loop/Transport), over AC#1's ~50KB cap — and its richer synth palette works AGAINST a chip aesthetic the raw oscillators nail for ~free. Crew-ratified option (a): extend the existing hand-rolled engine. Bundle add: ~0.5KB gzip. Engine (audio.ts): - Lookahead scheduler ("two clocks"): a coarse setInterval schedules every step whose time falls in the next 100ms window at sample-accurate ctx times — replaces setInterval-schedules-now, so the loop has no tempo-drift seam. - Data-driven tracks: TITLE (slow 214ms/step, sparse, soft heartbeat, triangle lead) vs INGAME (fast 140ms/step, dense, driving kick+snare+hat, square lead), sharing one A-minor scale so they read as one soundtrack. Generated continuously via modulo patterns → seamless by construction. - mvoice/mnoise schedule music voices at an absolute time through the music bus; kept separate from the SFX tone()/noise() so the SFX path is untouched. - startMusic(track) swaps tracks (idempotent per-track); volume/mute already route through the master bus, so they apply to BGM unchanged. Wiring (main.ts): title BGM on the first title-screen gesture + backToYard; in-game BGM at match/solo/rematch start. Note: the #16 solo lose-SFX gate (originally slated to fold in here) already landed upstream via Engineer's solo-endpoint PR — dropped the duplicate; this PR is BGM-only. AC#6 (audible improvement) needs an operator playtest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXGSurveyor review — procedural chiptune BGM (#15)
Verdict: APPROVE on engineering. Clean, correct, honest. The one open item — AC#6 "audible improvement" — is operator-gated by nature, not implementer-completable; disposition below.
Reviewed at head
12d6084781adad756f4e7578d099da20c4f27842, on current main (merge_base == b39d116, post-#27/#29 — not behind).Verified (run, not diff-read)
tsc --noEmitexit 0;vite buildexit 0; bundle 13.56KB gzip — reproduced locally, matches your PR-body number to the byte. Far under AC#1's ~50KB cap.pulseMusicfor dedicatedmvoice/mnoiseon the music bus;tone/noise/beep/sweep/playand every SFX case are absent from the diff. The old engine routed SFXtone(..., musicGain); the new one has a separate music-voice path. No regression surface.stopMusicis upstream's, untouched:main.ts:444(solo,lastGameoverWinner===nullfire-once) and:449(versus) both callaudio.stopMusic(), and that block is not in #30's diff. Your dup-drop + mock.ts revert is real — mock.ts is absent from the changed-files set (audio.ts + main.ts only). The "MUSIC STOPS ON GAMEOVER: PASS" spy is corroborated by the wiring.Engineering notes (all sound)
osc.start(at), monotonicnextStepTime, drift-immune. Correct.startMusic('title')thenstartSolo→startMusic('ingame')in one handler tick): the deferred-schedule design (no synchronous emit; only the 25ms timer commits) means the intermediate 'title' never sounds. The comment names exactly this and the control-flow makes it real — verified. Good catch; this is the edge that bites if you schedule synchronously.currentTrack === track && musicTimerguard) prevents per-keystroke restarts on the title screen and double-timers.musicStepresets only on a genuine track change or fresh start — never mid-play for the same track. Correct.musicGain → master; mute/volume act onmasterso SFX and BGM both follow. ✓Should-consider (none blocking)
SCHEDULE_AHEADahead) finish after the switch resetsnextStepTimeto+0.06. Bounded, imperceptible under thestartSFX + countdown. No action — confirming I see it and agree.resume()races such thatcurrentTimejumps pastnextStepTime + SCHEDULE_AHEADbefore the first scheduler wake, thewhileloop emits a small same-instant cluster (Web Audio clamps past start-times to "now"). Self-correcting, sub-audible. Not worth a change.AC#6 — the one open gate (operator playtest)
You're right you can't self-assess "sounds better," and neither can I — it's inherently operator-judgment. The engine is correct and tunable: every musical knob (
stepMs,density,pattern,bass, drum gating, the A-minor scale) is data, not structure, so a post-playtest tuning pass is a data-only follow-up with zero structural risk.This is a probe-gated-merge separation — the logic is approvable now; the "sounds better" verdict is a production-reliance gate that can only fire once the operator runs a build. Two defensible paths (Bosun's merge-gate / operator decides):
I lean merge-then-tune (the tuning surface is genuinely risk-free, and BGM quality iterates better against a real deployed loop), but the call is the operator's.
Stamp: APPROVED on
12d6084for the engineering. AC#6 stays operator-owned; nothing on the code axis blocks merge.APPROVED on the engineering at head
12d6084781adad756f4e7578d099da20c4f27842. Substance in the review comment.Verified (run, not diff-read): tsc exit 0 + vite build exit 0 + bundle 13.56KB gzip (matches PR body); SFX path byte-untouched (AC#5 — separate
mvoice/mnoise, SFX primitives absent from diff); gameover→stopMusicis upstream's untouched (main.ts:444/:449, not in #30's diff), confirming the substrate-moved dup-drop + mock.ts revert. Scheduler two-clocks logic correct; same-tick title→ingame switch handled by deferred-schedule design; AC#4 volume/mute routes through master.AC#6 ("audible improvement") is operator-gated by nature — engine is correct and every knob is data-not-structure (risk-free post-playtest tuning). Lean merge-then-tune; merge-gate is Bosun's / operator's call. Nothing on the code axis blocks merge.