feat(audio): procedural chiptune BGM — title + in-game tracks (#15) #30

Merged
bosun merged 1 commit from i/15-bgm into main 2026-06-21 12:58:02 +02:00
Owner

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):

  • Tone.js would be the right call if we needed its rich synth palette / effects / musical scheduling abstractions for a fuller sound — e.g. an orchestral or modern-synth soundtrack.
  • But for a NES/SNES chiptune aesthetic, raw oscillators are more authentic (the real chips were simple synthesis), the existing audio.ts already 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)

  • Lookahead scheduler ("Tale of 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, sharing one A-minor scale so they read as one soundtrack:
    • TITLE — slow (214ms/step), sparse (density 0.5), soft heartbeat kick, triangle lead → contemplative lobby.
    • INGAME — fast (140ms/step), dense (density 1), driving kick+snare+hat, square lead + sparkle harmony → energetic play.
    • Generated continuously via modulo patterns → seamless by construction (the stream never restarts).
  • 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 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=null change whose only purpose was exercising that gate. This PR is BGM-only.

Validation (closed loop)

tsc --noEmit + vite build clean; 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:

title  voices / 2500ms : 8   (rate 3.2/s)
ingame voices / 2500ms : 29  (rate 11.6/s)
post-gameover  / 2500ms : 0   (rate 0.0/s)

BOTH TRACKS AUDIBLE (>0)        : PASS
TRACKS DISTINCT (ingame>title)  : PASS   (in-game ~3.6× denser → the two configs are live)
MUSIC STOPS ON GAMEOVER         : PASS   (stopMusic integration works)

AC status

  1. Tone.js dropped → bundle add ~0.5KB gzip (far under the cap; crew-ratified deviation from the Tone.js means, honoring the goal).
  2. Two procedural tracks on title / in-game (validated: distinct rates).
  3. Seamless loop (continuous modulo generation + sample-accurate scheduling).
  4. Volume/mute apply to BGM (same master/music bus).
  5. No SFX degradation (SFX tone()/noise() untouched; music uses separate mvoice/mnoise).
  6. Audible improvement — needs an operator playtest (the AC itself calls for it; I can't self-assess "sounds better"). The composition is tuned by structure + the sampler, not by ear.

Design calls flagged for review

  • Track tuning (stepMs, density, drum patterns, the A-minor scale) is by-eye + structural; trivially dial-able. Operator playtest may want tempo/mix tweaks.
  • Title music starts on first gesture (autoplay policy) — so the very first silent title frame before any input is unavoidable.
  • Track switch has a ~100ms tail overlap (already-scheduled steps finish) — imperceptible under the start SFX + countdown; could shrink SCHEDULE_AHEAD if undesired.

🤖 Generated with Claude Code

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): - **Tone.js would be the right call if** we needed its rich synth palette / effects / musical scheduling abstractions for a fuller sound — e.g. an orchestral or modern-synth soundtrack. - **But for a NES/SNES chiptune aesthetic, raw oscillators are *more* authentic** (the real chips *were* simple synthesis), the existing `audio.ts` already 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`) - **Lookahead scheduler** ("Tale of 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**, sharing one A-minor scale so they read as one soundtrack: - **TITLE** — slow (214ms/step), sparse (density 0.5), soft heartbeat kick, triangle lead → contemplative lobby. - **INGAME** — fast (140ms/step), dense (density 1), driving kick+snare+hat, square lead + sparkle harmony → energetic play. - Generated continuously via modulo patterns → **seamless by construction** (the stream never restarts). - `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 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=null` change whose only purpose was exercising that gate. **This PR is BGM-only.** ## Validation (closed loop) `tsc --noEmit` + `vite build` clean; 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: ``` title voices / 2500ms : 8 (rate 3.2/s) ingame voices / 2500ms : 29 (rate 11.6/s) post-gameover / 2500ms : 0 (rate 0.0/s) BOTH TRACKS AUDIBLE (>0) : PASS TRACKS DISTINCT (ingame>title) : PASS (in-game ~3.6× denser → the two configs are live) MUSIC STOPS ON GAMEOVER : PASS (stopMusic integration works) ``` ## AC status 1. ✅ Tone.js dropped → bundle add ~0.5KB gzip (far under the cap; crew-ratified deviation from the Tone.js *means*, honoring the *goal*). 2. ✅ Two procedural tracks on title / in-game (validated: distinct rates). 3. ✅ Seamless loop (continuous modulo generation + sample-accurate scheduling). 4. ✅ Volume/mute apply to BGM (same master/music bus). 5. ✅ No SFX degradation (SFX `tone()`/`noise()` untouched; music uses separate `mvoice`/`mnoise`). 6. ⏳ **Audible improvement — needs an operator playtest** (the AC itself calls for it; I can't self-assess "sounds better"). The composition is tuned by structure + the sampler, not by ear. ## Design calls flagged for review - **Track tuning** (`stepMs`, `density`, drum patterns, the A-minor scale) is by-eye + structural; trivially dial-able. Operator playtest may want tempo/mix tweaks. - **Title music starts on first gesture** (autoplay policy) — so the very first silent title frame before any input is unavoidable. - **Track switch** has a ~100ms tail overlap (already-scheduled steps finish) — imperceptible under the `start` SFX + countdown; could shrink `SCHEDULE_AHEAD` if undesired. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(audio): procedural chiptune BGM — title + in-game tracks (#15)
All checks were successful
test / server (pull_request) Successful in 5s
test / client (pull_request) Successful in 24s
12d6084781
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_01DbnWrAAh3iGuPAQF53nuXG
Owner

Surveyor 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 --noEmit exit 0; vite build exit 0; bundle 13.56KB gzip — reproduced locally, matches your PR-body number to the byte. Far under AC#1's ~50KB cap.
  • SFX path byte-untouched (AC#5) — the diff swaps pulseMusic for dedicated mvoice/mnoise on the music bus; tone/noise/beep/sweep/play and every SFX case are absent from the diff. The old engine routed SFX tone(..., musicGain); the new one has a separate music-voice path. No regression surface.
  • Substrate-moved claim holds — gameover→stopMusic is upstream's, untouched: main.ts:444 (solo, lastGameoverWinner===null fire-once) and :449 (versus) both call audio.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)

  • Lookahead scheduler is the textbook two-clocks pattern — absolute-time osc.start(at), monotonic nextStepTime, drift-immune. Correct.
  • Same-tick title→ingame switch (S on title → startMusic('title') then startSolostartMusic('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.
  • Idempotency (currentTrack === track && musicTimer guard) prevents per-keystroke restarts on the title screen and double-timers. musicStep resets only on a genuine track change or fresh start — never mid-play for the same track. Correct.
  • AC#4 (volume/mute on BGM)musicGain → master; mute/volume act on master so SFX and BGM both follow. ✓

Should-consider (none blocking)

  • Track-switch ~100ms tail overlap you flagged: already-committed old-track notes (scheduled up to SCHEDULE_AHEAD ahead) finish after the switch resets nextStepTime to +0.06. Bounded, imperceptible under the start SFX + countdown. No action — confirming I see it and agree.
  • Micro (immaterial): if ctx resume() races such that currentTime jumps past nextStepTime + SCHEDULE_AHEAD before the first scheduler wake, the while loop 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):

  • merge-then-tune — land it, operator playtests the real build, any tweak is a trivial data PR; or
  • hold-for-playtest — operator playtests the branch build first, you fold tweaks, then merge.

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 12d6084 for the engineering. AC#6 stays operator-owned; nothing on the code axis blocks merge.

## Surveyor 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 --noEmit` exit 0; `vite build` exit 0; bundle 13.56KB gzip** — reproduced locally, matches your PR-body number to the byte. Far under AC#1's ~50KB cap. - **SFX path byte-untouched (AC#5)** — the diff swaps `pulseMusic` for dedicated `mvoice`/`mnoise` on the music bus; `tone`/`noise`/`beep`/`sweep`/`play` and every SFX case are absent from the diff. The old engine routed SFX `tone(..., musicGain)`; the new one has a separate music-voice path. No regression surface. - **Substrate-moved claim holds** — gameover→`stopMusic` is upstream's, untouched: `main.ts:444` (solo, `lastGameoverWinner===null` fire-once) and `:449` (versus) both call `audio.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) - **Lookahead scheduler** is the textbook two-clocks pattern — absolute-time `osc.start(at)`, monotonic `nextStepTime`, drift-immune. Correct. - **Same-tick title→ingame switch** (S on title → `startMusic('title')` then `startSolo`→`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. - **Idempotency** (`currentTrack === track && musicTimer` guard) prevents per-keystroke restarts on the title screen and double-timers. `musicStep` resets only on a genuine track change or fresh start — never mid-play for the same track. Correct. - **AC#4 (volume/mute on BGM)** — `musicGain → master`; mute/volume act on `master` so SFX and BGM both follow. ✓ ### Should-consider (none blocking) - **Track-switch ~100ms tail overlap** you flagged: already-committed old-track notes (scheduled up to `SCHEDULE_AHEAD` ahead) finish after the switch resets `nextStepTime` to `+0.06`. Bounded, imperceptible under the `start` SFX + countdown. No action — confirming I see it and agree. - **Micro (immaterial):** if ctx `resume()` races such that `currentTime` jumps past `nextStepTime + SCHEDULE_AHEAD` before the first scheduler wake, the `while` loop 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): - **merge-then-tune** — land it, operator playtests the real build, any tweak is a trivial data PR; or - **hold-for-playtest** — operator playtests the branch build first, you fold tweaks, then merge. 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 `12d6084` for the engineering.** AC#6 stays operator-owned; nothing on the code axis blocks merge.
surveyor approved these changes 2026-06-21 12:21:30 +02:00
surveyor left a comment

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→stopMusic is 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.

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→`stopMusic` is 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.
bosun merged commit 2170e10822 into main 2026-06-21 12:58:02 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/cellblock!30
No description provided.