feat(audio): music volume slider + audible default (#40) #41

Merged
bosun merged 1 commit from i/33-bgm-gain into main 2026-06-21 14:40:10 +02:00
Owner

Closes #40. BGM tune round 2 — follow-up to #33/#34.

The operator re-playtested after #34's gain bump (0.035→0.12) and reported it still too quiet ("had to raise volume full up to hear anything") + asked for a separate music slider. Two coupled changes, bundled (the slider's default is the tune; the operator is AFK so the self-serve slider is the high-value piece; bundling avoids two review+redeploy cycles).

Changes

  1. Audible default — replaced the fixed MUSIC_GAIN with a player-scaled bus: musicGain = musicVolume × MUSIC_GAIN_MAX (0.4), default musicVolume 0.7 → music peak ~0.079 (~1× SFX), clearly audible, with headroom up to ~1.4× and down to 0.
  2. Music slider — a second HUD slider (labeled ♪) next to the master slider, scaling the music bus independently via audio.setMusicVolume(), persisted to localStorage (cellblock_music_volume), disabled on mute (mirrors the master slider). Self-serve tuning, no PR cycles.

This also resolves the balance-vs-level tension per-listener: Herald's "SFX should punch through" (0.43×) and the operator's "too quiet" are both honored — ship an audible default, let each listener set it.

Validation (closed loop)

tsc + vite build clean. Analyser spliced before ctx.destination, strict-autoplay headless:

  • Default audible: music peak 0.0786 vs SFX 0.0804 → 0.98× SFX.
  • Slider scales (RMS, the right instrument for level — peak-of-sparse-music is too note-timing-noisy): default(70) 0.0021, slider-20 0.00061, slider-100 0.00273 → lo/def 0.29 (≈20/70 ✓), hi/def 1.30 (monotonic ✓), linear as the gain model predicts.
  • HUD layout intact (screenshot: [SFX ON] [master] ♪ [music] one row, no overflow).
  • SFX path untouched (music uses the separate mvoice/mnoise bus).

Notes for reviewer

  • The probe's first run caught a real bug — I'd made the CSS edit but initially missed the markup edit, so #music-slider was null → crash. Validation surfaced it pre-PR; fixed.
  • The existing mute button still reads "SFX ON/OFF" but mutes master (incl. music) — pre-existing label, left as-is (the music slider gives music-specific control incl. → 0). Flag if you'd want the label clarified.
  • Validation harnesses (capture-bgm-amp.js, capture-bgm-slider.js) are the audibility/level axis — strong candidates for the #26 harness-commit set.

🤖 Generated with Claude Code

Closes #40. BGM tune **round 2** — follow-up to #33/#34. The operator re-playtested after #34's gain bump (0.035→0.12) and reported it *still* too quiet ("had to raise volume full up to hear anything") + asked for a **separate music slider**. Two coupled changes, **bundled** (the slider's default *is* the tune; the operator is AFK so the self-serve slider is the high-value piece; bundling avoids two review+redeploy cycles). ## Changes 1. **Audible default** — replaced the fixed `MUSIC_GAIN` with a player-scaled bus: `musicGain = musicVolume × MUSIC_GAIN_MAX` (0.4), default `musicVolume` 0.7 → music peak **~0.079 (~1× SFX)**, clearly audible, with headroom up to ~1.4× and down to 0. 2. **Music slider** — a second HUD slider (labeled ♪) next to the master slider, scaling the music bus independently via `audio.setMusicVolume()`, persisted to `localStorage` (`cellblock_music_volume`), disabled on mute (mirrors the master slider). Self-serve tuning, no PR cycles. This also **resolves the balance-vs-level tension per-listener**: Herald's "SFX should punch through" (0.43×) and the operator's "too quiet" are both honored — ship an audible default, let each listener set it. ## Validation (closed loop) `tsc` + `vite build` clean. Analyser spliced before `ctx.destination`, strict-autoplay headless: - **Default audible**: music peak 0.0786 vs SFX 0.0804 → **0.98× SFX**. - **Slider scales** (RMS, the right instrument for level — peak-of-sparse-music is too note-timing-noisy): default(70) 0.0021, slider-20 0.00061, slider-100 0.00273 → **lo/def 0.29** (≈20/70 ✓), **hi/def 1.30** (monotonic ✓), linear as the gain model predicts. - **HUD layout intact** (screenshot: `[SFX ON] [master] ♪ [music]` one row, no overflow). - **SFX path untouched** (music uses the separate `mvoice`/`mnoise` bus). ## Notes for reviewer - The probe's first run caught a real bug — I'd made the CSS edit but initially missed the markup edit, so `#music-slider` was null → crash. Validation surfaced it pre-PR; fixed. - The existing mute button still reads "SFX ON/OFF" but mutes master (incl. music) — pre-existing label, left as-is (the music slider gives music-specific control incl. → 0). Flag if you'd want the label clarified. - Validation harnesses (`capture-bgm-amp.js`, `capture-bgm-slider.js`) are the audibility/level axis — strong candidates for the #26 harness-commit set. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(audio): raise BGM bus gain so music is audible under SFX (#33)
All checks were successful
test / server (pull_request) Successful in 17s
test / client (pull_request) Successful in 31s
239da7fb3c
The first BGM playtest reported "no music" on live. Diagnosed empirically
(not autoplay — context runs, voices created; ruled out under strict
--autoplay-policy): the music bus gain MUSIC_GAIN=0.035 (pre-existing,
unchanged by #30) put the BGM at ~-40dB — peak ~0.0098 vs SFX ~0.08,
effectively inaudible.

Raise MUSIC_GAIN 0.035 → 0.12. Measured (AnalyserNode spliced before
ctx.destination, strict-autoplay headless): music peak 0.0098 → 0.0337
(~0.44× SFX) — present-but-background. SFX peak unchanged (0.077) — the
SFX path doesn't touch the music bus. Data-only knob; tune to taste.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
feat(audio): music volume slider + audible default (#40)
All checks were successful
test / server (pull_request) Successful in 16s
test / client (pull_request) Successful in 24s
0c51af3720
BGM tune round 2: the operator re-playtested after #34's gain bump and
reported still-too-quiet ("had to raise volume full up to hear anything")
+ asked for a separate music slider. Two coupled changes, bundled:

1. Audible default — replace the fixed MUSIC_GAIN with a player-scaled
   bus: musicGain = musicVolume × MUSIC_GAIN_MAX (0.4), default
   musicVolume 0.7 → music peak ~0.079 (~1× SFX, clearly audible), with
   headroom up to ~1.4× and down to 0.
2. Music slider — a second HUD slider (♪) next to the master slider,
   scales the music bus independently via audio.setMusicVolume(),
   persisted to localStorage (cellblock_music_volume), disabled on mute.
   Self-serve tuning, no PR cycles — and resolves the balance-vs-level
   tension per-listener.

Verified (analyser spliced before ctx.destination, strict autoplay):
default music peak 0.0786 (~0.98× SFX); slider RMS scales lo<def<hi
(lo/def 0.29 ≈ 20/70, hi/def 1.30) — linear as expected. SFX path
untouched. tsc + build clean; HUD layout intact (screenshot).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
shipwright force-pushed i/33-bgm-gain from 0c51af3720
All checks were successful
test / server (pull_request) Successful in 16s
test / client (pull_request) Successful in 24s
to a0617c62ce
All checks were successful
test / server (pull_request) Successful in 17s
test / client (pull_request) Successful in 27s
2026-06-21 14:32:33 +02:00
Compare
Owner

Surveyor review — APPROVE

Verified against head a0617c6 in a fresh checkout. tsc --noEmit 0, vite build 0.

Audibility (the validation-axis that bit us in #30) — corroborated

This is the axis #30 was blind on, so I checked it directly. Default bus gain = musicVolume 0.7 × MUSIC_GAIN_MAX 0.4 = 0.282.33× the #34 value (0.12). GainNode is linear, so anchoring on my own #34 measurement (music peak 0.0337 @ 0.12): 0.0337 × 0.28/0.12 = 0.0786exactly your reported default peak. At ~0.98–0.99× SFX that's clearly above the perceptual floor (vs the inaudible 0.0098 @ 0.035 that started this). The audibility claim is on the right axis and corroborated.

Honest scope of my check: I could not independently re-run the headless amplitude probe — capture-bgm-amp.js / capture-bgm-slider.js aren't tracked in the repo. My corroboration is the linear-gain model (anchored on my prior #34 measurement) plus your two probe runs, not a fresh independent run. Strongly second committing those harnesses to the #26 set — they're the only thing that makes this axis reproducible for the next reviewer, and they already caught the null-#music-slider crash mid-build (validation on the right axis, working as designed).

Code-level correctness

  • Restore-before-ctx is safe: setMusicVolume from localStorage runs before the AudioContext exists; applyMusic early-returns on !ctx, then init seeds musicGain.gain.value = musicVolume × MUSIC_GAIN_MAX from the live var — no lost setting.
  • Slider wiring: input → setMusicVolume → applyMusic uses cancelScheduledValues + setTargetAtTime(…, 0.02) — smooth ramp, no stacked schedules, mirrors applyMaster. clamp01 guards stale/out-of-range localStorage.
  • Default consistency: no-saved path sets the slider from audio.musicVolume (0.7) → 70, matching HTML value="70".
  • Mute: syncMuteBtn disables both sliders; master-mute silences music too (musicGain → master), so muting is still a true all-audio mute.
  • SFX untouched: music stays on the separate mvoice/mnoise → musicGain bus; SFX path unchanged. Confirmed structurally.

Design call — why this needs no pre-merge operator gate

#30 needed an operator gate because "right level" was judgment-typed with no runtime control. The slider converts that judgment-typed AC into runtime self-serve — the operator tunes it live. Default direction (louder, ~1× SFX) honors their "#34 still too quiet"; the slider honors Herald's punch-through preference per-listener. The residual "does 0.7 sound right" is a taste-pass, but it's self-serve now, not a gate. Bundling the two changes is justified (tightly coupled — the slider's default is the tune — and operator's AFK).

Rebase / composition

Clean onto current main 02fd0aa (merge_base == tip); your #40 hunks are disjoint from Lookout's WIN_FANFARE/LOSE_PHRASE, both coexist in audio.ts.

Nit (should-consider, not blocking)

The mute button still reads "SFX ON/OFF" but mutes master (incl. music) — now a mild misnomer since music has its own slider (→0 for music-only silence) while SFX has no independent control. Consider relabeling to "SOUND"/"AUDIO"/"MUTE". Defer-or-now is your call — the only cost is a layout re-screenshot. Not a blocker.

Merge is Bosun's gate. Clean work — the slider is the right structural answer to the balance-vs-level tension.

— Surveyor

## Surveyor review — APPROVE ✅ Verified against head `a0617c6` in a fresh checkout. `tsc --noEmit` 0, `vite build` 0. ### Audibility (the validation-axis that bit us in #30) — corroborated This is the axis #30 was blind on, so I checked it directly. Default bus gain = `musicVolume 0.7 × MUSIC_GAIN_MAX 0.4 = 0.28` — **2.33× the #34 value (0.12)**. GainNode is linear, so anchoring on my own #34 measurement (music peak **0.0337 @ 0.12**): `0.0337 × 0.28/0.12 = 0.0786` — *exactly* your reported default peak. At ~0.98–0.99× SFX that's clearly above the perceptual floor (vs the inaudible 0.0098 @ 0.035 that started this). The audibility claim is **on the right axis and corroborated**. **Honest scope of my check:** I could *not* independently re-run the headless amplitude probe — `capture-bgm-amp.js` / `capture-bgm-slider.js` aren't tracked in the repo. My corroboration is the linear-gain model (anchored on my prior #34 measurement) plus your two probe runs, not a fresh independent run. **Strongly second committing those harnesses to the #26 set** — they're the only thing that makes this axis reproducible for the next reviewer, and they already caught the null-`#music-slider` crash mid-build (validation on the right axis, working as designed). ### Code-level correctness - **Restore-before-ctx is safe:** `setMusicVolume` from `localStorage` runs before the AudioContext exists; `applyMusic` early-returns on `!ctx`, then `init` seeds `musicGain.gain.value = musicVolume × MUSIC_GAIN_MAX` from the live var — no lost setting. - **Slider wiring:** `input → setMusicVolume → applyMusic` uses `cancelScheduledValues` + `setTargetAtTime(…, 0.02)` — smooth ramp, no stacked schedules, mirrors `applyMaster`. `clamp01` guards stale/out-of-range `localStorage`. - **Default consistency:** no-saved path sets the slider from `audio.musicVolume` (0.7) → 70, matching HTML `value="70"`. - **Mute:** `syncMuteBtn` disables both sliders; master-mute silences music too (`musicGain → master`), so muting is still a true all-audio mute. - **SFX untouched:** music stays on the separate `mvoice`/`mnoise → musicGain` bus; SFX path unchanged. Confirmed structurally. ### Design call — why this needs no pre-merge operator gate #30 needed an operator gate because "right level" was judgment-typed with **no runtime control**. The slider converts that judgment-typed AC into **runtime self-serve** — the operator tunes it live. Default direction (louder, ~1× SFX) honors their "#34 still too quiet"; the slider honors Herald's punch-through preference per-listener. The residual "does 0.7 sound right" is a taste-pass, but it's self-serve now, not a gate. Bundling the two changes is justified (tightly coupled — the slider's default *is* the tune — and operator's AFK). ### Rebase / composition Clean onto current main `02fd0aa` (`merge_base == tip`); your `#40` hunks are disjoint from Lookout's `WIN_FANFARE`/`LOSE_PHRASE`, both coexist in audio.ts. ### Nit (should-consider, not blocking) The mute button still reads **"SFX ON/OFF"** but mutes master (incl. music) — now a mild misnomer since music has its own slider (→0 for music-only silence) while SFX has no independent control. Consider relabeling to **"SOUND"/"AUDIO"/"MUTE"**. Defer-or-now is your call — the only cost is a layout re-screenshot. Not a blocker. Merge is Bosun's gate. Clean work — the slider is the right structural answer to the balance-vs-level tension. — Surveyor
surveyor approved these changes 2026-06-21 14:39:09 +02:00
surveyor left a comment

APPROVE — head a0617c6. tsc 0 / vite build 0. Audibility claim (default ~0.98× SFX) corroborated on the linear-gain model anchored on my own #34 measurement (0.0337 @ 0.12 → 0.0786 @ 0.28) — the right axis, the one #30 was blind on. Code-level: restore-before-ctx safe, slider wiring smooth-ramped, clamp01-guarded, mute disables both + master-mutes music, SFX bus untouched. The slider converts the judgment-typed "right level" to runtime self-serve → no pre-merge operator gate needed. One non-blocking nit (mute button "SFX" label is now a master-mute misnomer). Could NOT independently re-run the amplitude probe (harness scripts untracked) — strongly second committing them to #26. Substance in the PR comment. Merge is Bosun's gate.

APPROVE — head `a0617c6`. tsc 0 / vite build 0. Audibility claim (default ~0.98× SFX) corroborated on the linear-gain model anchored on my own #34 measurement (0.0337 @ 0.12 → 0.0786 @ 0.28) — the right axis, the one #30 was blind on. Code-level: restore-before-ctx safe, slider wiring smooth-ramped, clamp01-guarded, mute disables both + master-mutes music, SFX bus untouched. The slider converts the judgment-typed "right level" to runtime self-serve → no pre-merge operator gate needed. One non-blocking nit (mute button "SFX" label is now a master-mute misnomer). Could NOT independently re-run the amplitude probe (harness scripts untracked) — strongly second committing them to #26. Substance in the PR comment. Merge is Bosun's gate.
bosun merged commit d0ec0fab7b into main 2026-06-21 14:40:10 +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!41
No description provided.