From 5406b38a5cd3665cce7a9f65e596d995e99e4b33 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 9 Sep 2026 11:14:58 +0700 Subject: [PATCH] docs(plans): record the audio plan, research and review Six phases from asset sourcing to verification, the free-audio source research behind them, and the code review that followed: twelve findings, eight of them real and fixed. The by-ear checks stay open. Nothing here claims a sound was heard. --- .../phase-01-audio-assets.md | 81 +++++++ .../phase-02-audio-core-module.md | 126 ++++++++++ .../phase-03-sound-controls.md | 109 +++++++++ .../phase-04-sfx-wiring.md | 85 +++++++ .../phase-05-background-music.md | 103 +++++++++ .../phase-06-verification-and-docs.md | 125 ++++++++++ .../plan.md | 137 +++++++++++ ...rch-260909-0917-game-audio-free-sources.md | 218 ++++++++++++++++++ 8 files changed, 984 insertions(+) create mode 100644 plans/260909-0951-game-audio-music-and-sfx/phase-01-audio-assets.md create mode 100644 plans/260909-0951-game-audio-music-and-sfx/phase-02-audio-core-module.md create mode 100644 plans/260909-0951-game-audio-music-and-sfx/phase-03-sound-controls.md create mode 100644 plans/260909-0951-game-audio-music-and-sfx/phase-04-sfx-wiring.md create mode 100644 plans/260909-0951-game-audio-music-and-sfx/phase-05-background-music.md create mode 100644 plans/260909-0951-game-audio-music-and-sfx/phase-06-verification-and-docs.md create mode 100644 plans/260909-0951-game-audio-music-and-sfx/plan.md create mode 100644 plans/reports/research-260909-0917-game-audio-free-sources.md diff --git a/plans/260909-0951-game-audio-music-and-sfx/phase-01-audio-assets.md b/plans/260909-0951-game-audio-music-and-sfx/phase-01-audio-assets.md new file mode 100644 index 0000000..f0666c0 --- /dev/null +++ b/plans/260909-0951-game-audio-music-and-sfx/phase-01-audio-assets.md @@ -0,0 +1,81 @@ +--- +title: "Phase 1: Audio assets and licensing" +status: completed +priority: P1 +effort: "3h" +dependencies: [] +--- + +# Phase 1: Audio assets and licensing + +## Overview + +Source, encode and commit every audio file the game will play, with a per-file +license manifest that proves each one is safe to ship. + +## Requirements + +- Functional: nine SFX files and one music loop exist at fixed paths under `public/audio/`, encoded for the web. +- Non-functional: every file is CC0 or Pixabay Content License (commercial use, no attribution required). SFX total <= 200 KB, music <= 1 MB. No file is CC-BY-NC. + +## Architecture + +Flat directory, one file per sound key, names matching the `SOUNDS` map that +Phase 2 defines. No sprite sheet — nine small files decode once and stay in a +`Map`, and sprites would buy latency we do not need at this count. + +``` +public/audio/ + click.mp3 pin.mp3 submit.mp3 + great.mp3 good.mp3 poor.mp3 + next.mp3 skip.mp3 error.mp3 + music.webm music.mp3 + SOURCES.md +``` + +Music ships twice: `music.webm` (Opus, ~96 kbps) for everything modern, and +`music.mp3` (~128 kbps) for Safari below 18.4. Phase 5 picks between them with +`canPlayType`. + +## Related Code Files + +- Create: `public/audio/*.mp3`, `public/audio/music.webm`, `public/audio/SOURCES.md` +- Modify: `src/app/credits/page.js` — add an "Audio" card to the `LIBRARIES`-style card stack, listing each source with its license +- Modify: `.gitignore` — confirm nothing under `public/audio/` is excluded + +## Implementation Steps + +1. Pull SFX candidates from [Kenney](https://kenney.nl/assets?q=audio) — *Interface Sounds* and *UI Audio* for `click`/`pin`/`next`/`skip`/`error`, *Music Jingles* for `great`/`good`/`poor`. All CC0 1.0, no attribution. +2. Pull the music loop from [Pixabay Music](https://pixabay.com/music/) (filter: ambient, loopable, 60-120 s) or a CC0 OpenGameArt loop. Download from the source site directly — never a mirror; Pixabay's terms changed in 2019 and again 2023-04-17. +3. Audition each candidate. Reject anything abrasive, comedic, or longer than ~1.2 s for a one-shot. +4. Encode with ffmpeg (already used elsewhere in the toolchain? if not, any encoder is fine — these are one-off conversions, not a build step): + - SFX: `ffmpeg -i in.ogg -ac 1 -b:a 112k out.mp3` + - Music WebM: `ffmpeg -i in.wav -c:a libopus -b:a 96k music.webm` + - Music MP3: `ffmpeg -i in.wav -b:a 128k music.mp3` +5. Trim leading/trailing silence on the SFX — latency the player hears as lag is usually just an unpadded head. +6. Verify the music loops seamlessly: play the decoded buffer with `loop = true` in a scratch page and listen across the seam. If it clicks, trim to a zero crossing or pick another track. +7. Write `public/audio/SOURCES.md`: one row per file — filename, title, author, source URL, license, download date. +8. Add the Credits card. CC0 requires no attribution, but every other asset in this repo is credited and audio should match. +9. Report the total byte size against the budget. + +## Todo + +- [x] Nine SFX sourced, auditioned, encoded to mono MP3 +- [x] Music loop sourced, encoded to WebM/Opus + MP3, seam verified +- [x] `public/audio/SOURCES.md` written, one row per file +- [x] `/credits` Audio card added +- [x] Size budget checked and reported + +## Success Criteria + +- [x] Eleven audio files present at the exact paths above +- [x] Every row in `SOURCES.md` names a license that is CC0 or Pixabay Content License, with a working source URL +- [x] SFX total <= 200 KB; music files <= 1 MB combined +- [x] Music loops with no audible click or gap +- [x] `/credits` renders the Audio card without layout breakage in both themes + +## Risk Assessment + +- **A file's license is not what the listing page implied.** Freesound and OpenGameArt mix CC0, CC-BY and CC-BY-NC in one search result. Signal: the download page shows a license the manifest row cannot honestly record. Response: drop the file, pick another. Never "probably fine". +- **The music loop is subtly wrong for the game** — too busy, too melodic, fights the panorama. Signal: it is distracting on the second listen. Response: this is why Phase 5 is last; swapping `music.webm`/`music.mp3` needs no code change. +- **ffmpeg unavailable on this machine.** Signal: command not found. Response: source pre-encoded MP3/OGG and skip re-encoding; the size budget is the only thing at stake. diff --git a/plans/260909-0951-game-audio-music-and-sfx/phase-02-audio-core-module.md b/plans/260909-0951-game-audio-music-and-sfx/phase-02-audio-core-module.md new file mode 100644 index 0000000..7066f98 --- /dev/null +++ b/plans/260909-0951-game-audio-music-and-sfx/phase-02-audio-core-module.md @@ -0,0 +1,126 @@ +--- +title: "Phase 2: Audio core module" +status: completed +priority: P1 +effort: "4h" +dependencies: [1] +--- + +# Phase 2: Audio core module + +## Overview + +`src/lib/audio.js`: the one place that owns the AudioContext, the gesture +unlock, lazy buffer decoding, and the two persisted preferences. Everything +later in the plan calls into it and nothing else touches Web Audio. + +## Requirements + +- Functional: read/write independent music and SFX preferences; unlock the context on the first user gesture anywhere in the document; play a named one-shot; expose the music buffer to Phase 5. +- Non-functional: client-only (never imported by anything under `src/app/api/`); individual function parameters per CLAUDE.md; every localStorage access wrapped in try/catch; no throw ever escapes into a caller; no network request before the first gesture. + +## Architecture + +Module-level singletons — one `AudioContext`, one `Map` of decoded buffers. +Mirrors `src/lib/theme.js` in shape: exported storage keys, `getStored*` / +`setStored*` pairs with try/catch, no UI imports. + +``` + ┌──────────────────────────┐ + first pointerdown ──►│ unlockAudio() │ + or keydown (once) │ new AudioContext() │ + │ ctx.resume() │ + │ notify subscribers │ + └───────────┬──────────────┘ + │ + playSound('pin') ──────────┼──────► loadBuffer() ─► fetch ─► decodeAudioData ─► Map + │ │ + └──────► BufferSource ─► Gain(0.4) ─► destination +``` + +Preference reads are cheap and synchronous, so `playSound` checks the SFX +preference itself — callers never have to. + +### Public surface + +```js +export const MUSIC_STORAGE_KEY = 'vngeoguessr_music'; +export const SFX_STORAGE_KEY = 'vngeoguessr_sfx'; + +export function getStoredMusicEnabled(); // boolean, default true +export function setStoredMusicEnabled(enabled); // void +export function getStoredSfxEnabled(); // boolean, default true +export function setStoredSfxEnabled(enabled); // void + +export function unlockAudio(); // idempotent; call from a gesture +export function watchUnlock(onUnlock); // returns an unsubscribe fn +export function isUnlocked(); // boolean +export function getAudioContext(); // AudioContext | null + +export async function playSound(name, volume); // fire-and-forget one-shot +export async function loadAudioBuffer(url); // shared decode+cache, used by music +``` + +`watchUnlock` exists because the music player (Phase 5) mounts before the first +gesture and must start the loop the moment the context opens. + +Defaults are `true` for both (D2). Storage encodes `'on'` / `'off'`; anything +else, including a read that throws, resolves to the default — the same +fallback-to-default posture `theme.js` takes for private browsing. + +### Sound table + +```js +const SOUNDS = { + click: '/audio/click.mp3', + pin: '/audio/pin.mp3', + submit: '/audio/submit.mp3', + great: '/audio/great.mp3', + good: '/audio/good.mp3', + poor: '/audio/poor.mp3', + next: '/audio/next.mp3', + skip: '/audio/skip.mp3', + error: '/audio/error.mp3', +}; +export const SFX_VOLUME = 0.4; +export const MUSIC_VOLUME = 0.15; +``` + +## Related Code Files + +- Create: `src/lib/audio.js` +- Create: `tests/audio.test.js` + +## Implementation Steps + +1. Write the storage-key constants and the four preference helpers, each guarding `typeof window === 'undefined'` and wrapping localStorage in try/catch with a comment naming the private-browsing case — the same comment discipline `theme.js` uses. +2. Write `unlockAudio()`: create the context lazily (`window.AudioContext || window.webkitAudioContext`), call `resume()` when suspended, mark unlocked, notify subscribers. Idempotent and safe to call from any handler. +3. Register the one-shot document listener inside the module on first import: `pointerdown` and `keydown`, both `{ once: true, capture: true }`, both calling `unlockAudio()` then removing the other. Guard on `typeof document === 'undefined'` so an accidental server import is inert rather than fatal. +4. Write `loadAudioBuffer(url)`: return the cached buffer, otherwise `fetch` → `arrayBuffer` → `decodeAudioData`, cache and return. In-flight promises are cached too, so two simultaneous plays of the same sound decode once. +5. Write `playSound(name, volume)`: bail on unknown name, locked context, or SFX disabled; build `BufferSource → GainNode → destination`; `start()`. Wrap the whole body in try/catch with a comment stating that a missing file must not break gameplay. +6. Write `watchUnlock` / `isUnlocked` / `getAudioContext` for Phase 5. +7. Write `tests/audio.test.js` following `tests/username.test.js`'s plain-vitest style: stub `globalThis.localStorage` per case and cover — default true when unset; `'off'` reads false; `'on'` reads true; a garbage value reads the default; a getter that throws reads the default; a setter that throws does not propagate; set→get round-trips. Do not test Web Audio itself: jsdom has no real implementation and a mock would assert the mock. +8. Run `npm test`. + +## Todo + +- [x] `src/lib/audio.js` created with the surface above +- [x] Gesture unlock listener registered once, server-safe +- [x] Buffer cache dedupes in-flight decodes +- [x] `tests/audio.test.js` covers the preference helpers including both throw paths +- [x] `npm test` green + +## Success Criteria + +- [x] `npm test` passes with the new file +- [x] No import of `src/lib/audio.js` from any server module (`src/app/api/**`, `src/lib/pano-*.js`, `src/lib/session.js`) +- [x] Importing the module in a Node context (no `document`) does not throw +- [x] Calling `playSound` before unlock is a silent no-op and issues no network request +- [x] Preferences round-trip through localStorage and fall back to `true` on every failure path + +## Risk Assessment + +- **`decodeAudioData` rejects on a corrupt or 404 file.** Signal: a sound is silent, console shows a decode error. Response: the try/catch already contains it; the cache must not store a rejected promise or the sound stays broken for the session — delete the entry on failure. +- **The unlock listener fires on a gesture that is not meant to make sound** (e.g. dismissing the username modal). That is intended: unlocking is not playing. +- **Server import by accident.** Signal: a build error, or `document is not defined` at runtime. Response: the `typeof document` guard makes it inert, and Phase 6's check catches the import. `CLAUDE.md`'s client-safety rule already names this class of bug. +- **jsdom lacks AudioContext**, so unit tests cannot cover playback. Accepted: playback is verified manually in Phase 6. Testing a mock of the Web Audio API proves nothing. diff --git a/plans/260909-0951-game-audio-music-and-sfx/phase-03-sound-controls.md b/plans/260909-0951-game-audio-music-and-sfx/phase-03-sound-controls.md new file mode 100644 index 0000000..0d60290 --- /dev/null +++ b/plans/260909-0951-game-audio-music-and-sfx/phase-03-sound-controls.md @@ -0,0 +1,109 @@ +--- +title: "Phase 3: Sound controls" +status: completed +priority: P1 +effort: "3h" +dependencies: [2] +--- + +# Phase 3: Sound controls + +## Overview + +A `SoundToggle` component that sits beside `ThemeToggle` in every header, +switching music and SFX independently, persisted and hydration-safe. + +## Requirements + +- Functional: two toggles (music, SFX), each reflecting and writing its stored preference; a click on either is itself a user gesture, so it unlocks audio; changes take effect immediately, including stopping music mid-playback. +- Non-functional: no hydration mismatch; keyboard reachable with visible focus; 44px minimum touch targets; no horizontal overflow at 320px. + +## Architecture + +Visually and structurally a sibling of `ThemeToggle`: an `inline-flex` group of +icon buttons in a bordered `bg-card` pill, `aria-pressed` carrying state, +`role="group"` rather than a radiogroup (the same reasoning `ThemeToggle` +documents — claiming a role without honouring roving tabindex is worse than not +claiming it). + +``` +sm and up: below sm (game header only): +┌──────┬──────┐ ┌──────┐ +│ ♪ │ 🔊 │ │ 🔊 │ one button, mutes/unmutes both +└──────┴──────┘ └──────┘ + music sfx +``` + +Icons from Lucide, matching the existing icon language: `Music` / `Music2` for +music on/off, `Volume2` / `VolumeX` for SFX on/off. + +**Hydration.** Stored preferences exist only on the client. Follow +`ThemeToggle` exactly: render the server-side default on first paint, set +`mounted` in an effect, and only then reflect the real value. Unlike the theme +there is no flash-of-wrong-colour problem, so no inline pre-paint script is +needed. + +**Cross-component sync.** Two headers never render at once, but the music +player (Phase 5) must react to the music toggle. Export a tiny subscription +from `src/lib/audio.js` — `watchMusicPreference(onChange)` — and have +`setStoredMusicEnabled` notify it. Simpler than React context for two consumers, +and it keeps the preference logic in one module. + +### D8: small-screen collapse + +The game header already carries Back + region/session badges + `ThemeToggle` +(132px) + Beer. Two more 44px buttons overflow a 360px viewport. So +`SoundToggle` takes a `compact` prop: `compact` renders one button reflecting +"anything on / everything off" and toggling both. The game header passes +`compact` below `sm` via a CSS-only pair (`sm:hidden` / `hidden sm:inline-flex`) +rather than a resize listener — no JS, no hydration risk. + +Home, credits and debug headers have room; they always get the split pair. + +## Related Code Files + +- Create: `src/app/components/SoundToggle.js` +- Modify: `src/lib/audio.js` — add `watchMusicPreference` and notify from `setStoredMusicEnabled` +- Modify: `src/app/page.js` (:106 area) — add beside `ThemeToggle` +- Modify: `src/app/components/GameClient.js` (:431 area) — add the compact/split pair +- Modify: `src/app/credits/page.js` (:44 area) — add beside `ThemeToggle` +- Modify: `src/app/debug/layout.js` (:37 area) — add beside `ThemeToggle` + +## Implementation Steps + +1. Add `watchMusicPreference(onChange)` to `src/lib/audio.js`: a listener set, notified by `setStoredMusicEnabled`, returning an unsubscribe function. Same shape as `watchSystemTheme` in `theme.js`. +2. Write `SoundToggle({ className, compact })` as a `"use client"` component. State: `musicOn`, `sfxOn`, `mounted`. Read both preferences in a mount effect. +3. Split mode: two buttons. Each `onClick` calls `unlockAudio()`, flips local state, and writes the preference. Give each a real `aria-label` and `title` that names the current action ("Mute music" / "Unmute music"). +4. Compact mode: one button. On = anything on. Click turns both off when anything is on; turns both on otherwise. Label reflects it ("Mute all sound" / "Unmute"). +5. Style from `ThemeToggle`: `h-11`, `w-11` cells, `rounded-lg border border-border bg-card`, selected state `bg-brand text-brand-foreground shadow-sm`, unselected `text-muted-foreground hover:bg-muted`, `focus-visible:ring-[3px] focus-visible:ring-ring/50`. Do not fork the styling — if it drifts, the two controls look like different systems. +6. Mount in all four headers. In `GameClient.js` render `` and ``. +7. Check 320px and 360px widths on the game screen in DevTools. If it still overflows, the next lever is the Beer button's label, not the sound control. + +## Todo + +- [x] `watchMusicPreference` added and wired into `setStoredMusicEnabled` +- [x] `SoundToggle` written, split and compact modes +- [x] Mounted in home, game, credits and debug headers +- [x] 320px / 360px overflow checked on the game screen +- [ ] Keyboard tab order and focus rings verified + +## Success Criteria + +- [x] Both toggles reflect stored state after reload +- [ ] No React hydration warning in the console on any page +- [ ] Turning music off stops it immediately, not at the end of the loop +- [ ] Turning SFX off silences the next interaction sound +- [ ] Every button reachable by keyboard with a visible focus ring, `aria-pressed` correct +- [x] No horizontal overflow at 320px and 360px on `/game/[region]` +- [ ] The control is visually indistinguishable in style from `ThemeToggle` + +> **Boxes left open need ears or a live browser.** The code is written and the +> automated gates pass; these are the claims only a manual pass can make. +> Phase 6 lists them as one checklist. + +## Risk Assessment + +- **Hydration mismatch** if stored state is read during render. Signal: React warning, or the icon flickers. Response: the `mounted` gate from `ThemeToggle`; treat any warning as a blocker. +- **The compact/split pair double-renders in the DOM** (both exist, one hidden). Two elements with the same `aria-label` are announced twice by some screen readers. Response: the hidden one is `display: none` via Tailwind's `hidden`, which removes it from the accessibility tree. Verify once with a screen reader or the accessibility inspector. +- **Header still overflows.** Signal: horizontal scrollbar at 320px. Response: escalate to trimming the Beer button before touching the sound control — donation is not a per-round interaction. +- **`aria-pressed` semantics are wrong for a mute button** if the label says "Mute" while pressed means "on". Response: label the *state* ("Music on"/"Music off") and let `aria-pressed` carry it, or label the *action* without `aria-pressed`. Pick one; do not mix. diff --git a/plans/260909-0951-game-audio-music-and-sfx/phase-04-sfx-wiring.md b/plans/260909-0951-game-audio-music-and-sfx/phase-04-sfx-wiring.md new file mode 100644 index 0000000..329090a --- /dev/null +++ b/plans/260909-0951-game-audio-music-and-sfx/phase-04-sfx-wiring.md @@ -0,0 +1,85 @@ +--- +title: "Phase 4: SFX wiring" +status: completed +priority: P1 +effort: "3h" +dependencies: [2, 3] +--- + +# Phase 4: SFX wiring + +## Overview + +Call `playSound` from the nine play-flow moments, with the result sound chosen +by score band. + +## Requirements + +- Functional: every trigger in the sound map fires exactly once per event; the result sound is tiered by score; a failed round plays `error`, not a score jingle. +- Non-functional: `playSound` is fire-and-forget — no `await` on a game path, no added latency, no state coupled to audio. + +## Architecture + +Call sites only. No new abstraction: `playSound('pin')` inside an existing +handler is one line and reads at the point it happens. A `useSound` hook or an +event bus would add indirection for nine literal calls — the wrong trade at +this size. + +Result tier maps off the score already in `result`: + +```js +// Result sounds follow the scoring ladder in lib/game.js: 4-5 points is +// within 100m and worth celebrating, 1-3 is a hit, 0 is a miss. +const resultSound = (score) => (score >= 4 ? 'great' : score > 0 ? 'good' : 'poor'); +``` + +## Related Code Files + +- Modify: `src/app/components/GameClient.js` — `handleMapClick` (:239), `handleSubmitGuess` (:260), `handleNextRound` (:308), `handleSkipGuess` (:341), `handlePanoramaError` (:223), `handleGoBack` (:377) +- Modify: `src/app/components/RegionPicker.js` — region selection click +- Modify: `src/app/page.js` — Play button +- Modify: `src/app/components/RoundResultDialog.js` — only if the reveal is animated and the sound must land with it rather than with `setResult` + +## Implementation Steps + +1. Import `playSound` into `GameClient.js`. +2. `handleMapClick`: `playSound('pin')` — first line, before state updates, so the sound is immediate. +3. `handleSubmitGuess`: `playSound('submit')` right after the `if (!guessCoordinates …) return;` guard, so a blocked submit stays silent. +4. Result: after `setResult(...)`, play `resultSound(submitted.score ?? 0)` on the success branch and `'error'` on both failure branches (`submitted` falsy, and the `catch`). The two failure paths already share one representation — the sound must too. +5. `handleNextRound`: `playSound('next')` after the `if (roundLoading) return;` guard — the guard exists because a double-click burns a session, and a double sound would be the audible tell. +6. `handleSkipGuess`: `playSound('skip')` after its guard. +7. `handlePanoramaError`: `playSound('error')`. +8. `handleGoBack` and the header buttons: `playSound('click')`. +9. `RegionPicker` selection and the home Play button: `playSound('click')`. These also serve as the first gesture, and the unlock listener from Phase 2 handles that — no explicit `unlockAudio()` call needed at these sites. +10. Play through a full round and listen for doubles, especially on the result reveal (the dialog mounts and the state updates in the same tick). +11. If the result sound lands before the dialog is visible, move it into `RoundResultDialog`'s open effect instead. Decide by ear, not by theory. + +## Todo + +- [x] `pin` on map click +- [x] `submit` on guess submit, after the guard +- [x] Tiered `great`/`good`/`poor` on a recorded result; `error` on both failure paths +- [x] `next` and `skip` on their handlers, after their guards +- [x] `error` on panorama load failure +- [x] `click` on region pick, Play, Back and header buttons +- [ ] Full round played through, no double-fires + +## Success Criteria + +- [ ] Each of the nine sounds is reachable in normal play +- [ ] No sound fires twice for one user action +- [ ] A blocked action (submit with no guess, double-clicked Next) makes no sound +- [ ] A failed round plays `error` and never a score jingle +- [ ] Turning SFX off in `SoundToggle` silences all of them and leaves music running +- [x] Round timing is unchanged — no `await playSound` on any game path + +> **Boxes left open need ears or a live browser.** The code is written and the +> automated gates pass; these are the claims only a manual pass can make. +> Phase 6 lists them as one checklist. + +## Risk Assessment + +- **Double-fire on the result reveal.** Signal: an audible flam on the jingle. Response: the sound moves to the dialog's open effect, guarded so it fires once per result object. +- **`click` becomes noise.** Nine sounds is already a lot; a click on *every* button is the one most likely to grate. Signal: it is tiring after three rounds. Response: restrict `click` to navigational buttons (Play, region pick, Back) and drop it from the header chrome. +- **React 19 Strict Mode double-invokes effects in dev**, so any sound placed in an effect fires twice locally but once in production. Signal: doubles in dev only. Response: confirm against a production build before "fixing" it — do not add a ref guard for a dev-only symptom. +- **A sound plays for an action the player did not take** (prefetch failure, epoch mismatch on an abandoned round). Signal: `error` fires while nothing visible went wrong. Response: only play from user-initiated branches; the epoch guards in `handleNextRound` already mark the abandoned paths. diff --git a/plans/260909-0951-game-audio-music-and-sfx/phase-05-background-music.md b/plans/260909-0951-game-audio-music-and-sfx/phase-05-background-music.md new file mode 100644 index 0000000..ea96c43 --- /dev/null +++ b/plans/260909-0951-game-audio-music-and-sfx/phase-05-background-music.md @@ -0,0 +1,103 @@ +--- +title: "Phase 5: Background music" +status: completed +priority: P1 +effort: "4h" +dependencies: [2, 3] +--- + +# Phase 5: Background music + +## Overview + +A gapless ambient loop that starts on the first gesture, plays on the menu and +through the round, and survives client navigation between them. + +## Requirements + +- Functional: music starts as soon as the context unlocks (default on); loops seamlessly; keeps playing across `/` → `/game/[region]` → back with no restart; stops immediately when muted and resumes from silence when unmuted. +- Non-functional: mounted once per document; no music file fetched before the first gesture; MP3 fallback for Safari below 18.4; total added JS well under 2 KB. + +## Architecture + +A `MusicPlayer` client component with no rendered output, mounted in +`RootLayout` (D4). Route changes in the App Router re-render the page subtree, +not the layout — anything mounted per-page would tear down and restart the loop +on every navigation, which is exactly the artefact this avoids. + +``` +RootLayout (server) + └── client, renders null, mounted once per document + ├── watchUnlock() ──► start when the context opens + ├── watchMusicPreference() ──► start / stop on toggle + └── AudioBufferSourceNode(loop=true) ─► Gain(0.15) ─► destination +``` + +**Why a decoded buffer and not `