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 `