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.
This commit is contained in:
tiennm99 committed 2026-09-09 11:14:58 +07:00
1 parent bf4b3ae465
commit 5406b38a5c
8 files changed
+984

No files matched your search

@@ -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.
@@ -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.
@@ -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 `<SoundToggle className="sm:hidden" compact />` and `<SoundToggle className="hidden sm:inline-flex" />`.
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.
@@ -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.
@@ -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)
└── <MusicPlayer /> 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 `<audio loop>`:** MP3 carries encoder padding at
both ends, so an `<audio>` element loops with an audible gap. `decodeAudioData`
drops the container framing and `AudioBufferSourceNode.loop` returns to sample
zero, so the seam is sample-accurate (D6).
**Format pick:** probe once with a throwaway element —
```js
// Safari decoded Opus-in-WebM only from 18.4; the MP3 is for everything older.
const canWebm = document.createElement('audio')
.canPlayType('audio/webm; codecs="opus"') !== '';
const src = canWebm ? '/audio/music.webm' : '/audio/music.mp3';
```
**Stop/start semantics.** An `AudioBufferSourceNode` is single-use: once
stopped it cannot restart. Unmuting therefore builds a fresh source from the
cached buffer (the buffer itself is decoded once and kept). Muting stops the
node and drops the reference. No pause/resume — the loop restarts from the top,
which is inaudible on an ambient bed and far simpler than tracking offsets.
## Related Code Files
- Create: `src/app/components/MusicPlayer.js`
- Modify: `src/app/layout.js` — mount `<MusicPlayer />` inside `<body>`, alongside `<AppBackground />`
- Modify: `src/lib/audio.js` only if `loadAudioBuffer` needs to be reachable from the component (it is already exported in Phase 2)
## Implementation Steps
1. Write `MusicPlayer` as a `"use client"` component returning `null`. All state lives in refs — a music node is not render state and re-rendering the root layout for it would be wasteful.
2. On mount, subscribe to `watchUnlock` and `watchMusicPreference`; unsubscribe on unmount. Do not read preferences during render (hydration).
3. `startMusic()`: bail if already playing, locked, or the preference is off. `await loadAudioBuffer(src)`, then build `BufferSource(loop = true) → Gain(MUSIC_VOLUME) → destination` and `start()`. Guard against the mount unmounting mid-await.
4. `stopMusic()`: `stop()` the node, disconnect, clear the ref. Never touch the cached buffer.
5. Wire the two subscriptions: unlock → `startMusic()`; preference change → `startMusic()` or `stopMusic()`.
6. Mount in `src/app/layout.js` next to `<AppBackground />`. Note in a comment that its position is load-bearing: inside `<body>` but outside the `flex min-h-dvh flex-col` wrapper, so a null-rendering component can never become a flex row — the same reasoning the existing comment gives for keeping Radix portals out of that column.
7. Test navigation: home → region → play a round → Back → home. The loop must not restart at any step. Confirm by ear on a track with an obvious phrase start.
8. Test the mute path: mute mid-loop (silence is immediate), reload (still muted), unmute (loop restarts from the top).
9. Confirm in DevTools Network that neither music file is requested until the first click.
## Todo
- [x] `MusicPlayer` written, renders null, refs only
- [x] WebM/Opus with MP3 fallback via `canPlayType`
- [x] Mounted in the root layout with a comment on why it lives there
- [ ] Starts on unlock, stops/starts on preference change
- [ ] Loop seam verified by ear
- [x] No music fetch before the first gesture
## Success Criteria
- [ ] Music begins within a second of the first click and never before it
- [ ] `/` → `/game/[region]` → Back plays continuously with no restart or gap
- [ ] The loop point is inaudible
- [ ] Mute stops it instantly; the choice survives a reload; unmute restarts it
- [ ] SFX remain audible with music muted, and vice versa
- [ ] Safari (or a `canPlayType` stub) takes the MP3 path and plays
- [ ] No console errors on mount, unmount, or rapid mute/unmute clicking
> **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
- **Rapid mute/unmute clicking creates overlapping nodes**, and the loop plays twice against itself. Signal: the bed thickens and phases. Response: `startMusic` returns early when a node ref exists; `stopMusic` clears it synchronously before any await.
- **The component unmounts while `loadAudioBuffer` is in flight**, and `start()` runs on a dead component. Signal: music plays with nothing controlling it. Response: an `alive` ref checked after the await, cleared in the cleanup.
- **The loop is audibly not seamless** despite Phase 1's check. Signal: a click or a breath at the seam. Response: trim to a zero crossing, or set `loopStart`/`loopEnd` inside the sustained body of the track.
- **In-round music turns out to be a mistake** (it competes with the panorama — the reason most GeoGuessr-likes play silent). Signal: it is distracting after two rounds, or testers reach for the mute. Response: flip the music default to off, or scope playback to the menu — both are small, localized changes, and this phase is deliberately last so that call is cheap.
- **React 19 Strict Mode double-mounts in dev**, starting two loops locally. Signal: doubled audio in dev only. Response: the cleanup must genuinely stop the node; if it does, Strict Mode is a correctness test that passes, not a bug to work around.
@@ -0,0 +1,125 @@
---
title: "Phase 6: Verification and docs"
status: in-progress
priority: P2
effort: "2h"
dependencies: [1, 2, 3, 4, 5]
---
# Phase 6: Verification and docs
## Overview
Prove the audio layer works, breaks safely, and is written down.
## Requirements
- Functional: automated gates green; a manual pass over every acceptance criterion in `plan.md`; graceful degradation confirmed under blocked storage and missing files.
- Non-functional: docs updated only where user-visible behaviour or structure actually changed.
## Architecture
Unit tests cover the preference helpers; jsdom has no Web Audio implementation,
so a unit test of playback would assert a mock. Everything the browser *can*
honestly answer moved into `tests/e2e/audio.spec.js` instead: what is fetched
and when, whether the toggles persist, whether the header fits a phone,
and whether a 404 on every sound leaves the page working. What remains is
genuinely audible-only and belongs to a person with speakers.
## Related Code Files
- Create: `tests/e2e/audio.spec.js`
- Modify: `docs/features.md` — a Sound & Music section
- Modify: `docs/project-structure.md` — `src/lib/audio.js`, `SoundToggle.js`, `MusicPlayer.js`, `public/audio/`
## Implementation Steps
1. `npm test` — unit suite including `tests/audio.test.js`.
2. `npm run lint`.
3. `npm run build` — catches a server/client boundary violation that dev mode tolerates.
4. Verify the client-safety rule by grep: no `src/app/api/**` or server lib imports `src/lib/audio.js`.
5. Write `tests/e2e/audio.spec.js` and run `npm run test:e2e`.
6. Update the docs.
7. Hand the manual checklist below to the user.
## Todo
- [x] `npm test` green — 292 tests, 20 files
- [x] `npm run lint` green — 0 errors (20 pre-existing warnings, one of them the
`set-state-in-effect` warning `ThemeToggle` already carries)
- [x] `npm run build` green
- [x] No server module imports `src/lib/audio.js`
- [x] `tests/e2e/audio.spec.js` written; full e2e suite green at 36 tests
- [x] `docs/features.md` and `docs/project-structure.md` updated
- [x] Asset size reported — 847 KB total: 61.5 KB of effects, 785.9 KB of music
- [x] Code review run; eight real findings fixed
- [ ] Manual pass below completed by the user
## Automated coverage
`tests/e2e/audio.spec.js` proves, in a real browser:
- No audio file is requested before the first user gesture.
- Music and effects persist independently; an untouched preference stays
unwritten and reads as its default.
- A choice made on the menu is still in force inside a round.
- The game header fits 320px and 360px with zero horizontal overflow, showing
the single mute switch and keeping the split pair out of the accessibility
tree.
- The compact switch silences both channels at once.
- A 404 on every audio file leaves the page working and throws nothing.
That last group caught a real defect: `hidden` and `inline-flex` set the same
CSS property, so passing the breakpoint class straight to `SoundToggle` left
both variants visible on a phone. The class now goes on a wrapper element.
## Code review response
A `code-reviewer` pass over the whole diff returned twelve findings. Eight were
real; all eight are fixed, each with a regression test where one was possible.
| # | Finding | Resolution |
|---|---------|------------|
| 1 | Both toggles inert when localStorage is blocked — every playback decision re-read storage, so a refused write meant a mute that never applied | D9. Preference held in a module variable; storage demoted to a mirror. Covered by *stays muted in a private window* |
| 2 | The AudioContext was never resumed after the browser suspended it, contradicting the comment claiming otherwise | D10. Listeners stay registered; `visibilitychange` also resumes; `resume()` and the constructor are both guarded. Covered by *resumes a context the browser suspended* |
| 3 | The two mounted `SoundToggle`s desynced — rotating past `sm` showed mount-time state and the first press went the wrong way | Added `watchSfxPreference`; both variants now subscribe. Covered by the breakpoint e2e test |
| 4 | `playSound('error')` sat above the epoch guard, so an abandoned round's viewer could sound an error over a healthy one | Moved below the guard |
| 5 | The result jingle fired after an `await` with no liveness check — Back stays live during submit, so a win jingle could play over the menu | `mountedRef` guards all three post-await sounds |
| 6 | The `mounted` gate painted every load as muted before flipping, for a feature that defaults to on | Dropped. `useState(true)` already matches the server render, so there was no mismatch to guard against |
| 7 | The unit suite never opened a context, so the cache, its eviction and the playback guards had no coverage; one test was vacuous | Rewritten against a fake context, loading a fresh module per case. 12 tests to 24 |
| 8 | The 404 e2e test muted effects in the same gesture that unlocked audio, so it only exercised the music path, and `pageerror` cannot see unhandled rejections | Now drops a real pin with effects on, waits for the request, and records `unhandledrejection` from inside the page |
| 9 | `resultSound`'s insertion orphaned `fetchNewRound`'s JSDoc | Reordered |
| 10 | Gain nodes were never disconnected | `source.onended` releases both. Covered by *releases its nodes when the sound ends* |
| 11 | Docs overstated `click` and `error` coverage | Docs narrowed to what actually fires, with the reason |
| 12 | Music plays on `/credits` and `/debug/*`, beyond the menu-and-round wording of D2 | Recorded as D11 rather than changed: scoping it per-route is what restarts the loop |
## Manual pass (needs speakers)
- [ ] Play a full round: each of the nine effects is audible on its trigger
- [ ] Nothing double-fires, especially the jingle on the result reveal
- [ ] The three result jingles suit their tiers — `great` reads as a win,
`poor` as a miss. These were picked by spectral measurement, not by ear,
and are the likeliest thing in the plan to be wrong
- [ ] Music starts on the first click and never before it
- [ ] `/` → `/game/[region]` → Back plays continuously, with no restart
- [ ] The loop point is inaudible
- [ ] Muting music stops it instantly; unmuting restarts it; rapid clicking
never stacks two loops
- [ ] Effects stay audible with music muted, and the reverse
- [ ] Levels sit right against each other and against the panorama
- [ ] Keyboard: both switches are tab-reachable with a visible focus ring
- [ ] No React hydration warning in the console on any page
- [ ] In a private window, sound still plays and nothing throws
## Success Criteria
- [x] All four automated gates pass
- [x] Nothing under `public/audio/` lacks a `SOURCES.md` row
- [x] Docs describe the audio layer accurately, with no claim not backed by the code
- [ ] Every checkbox in `plan.md`'s Success Criteria is ticked against observed behaviour
## Risk Assessment
- **The manual pass gets skipped because the automated gates are green.** Signal: acceptance criteria ticked with no described observation. Response: the by-ear boxes in phases 3-5 were deliberately reopened after `ak plan check` ticked them wholesale; they stay open until someone listens.
- **Docs drift into aspiration** for things the plan explicitly does not build. Response: the non-goals in `plan.md` stay non-goals; the docs describe shipped behaviour only.
- **e2e flake from audio timing.** Signal: intermittent failures. Response: the spec asserts localStorage, DOM and network state only — never playback. If it flakes, it is asserting the wrong thing.
@@ -0,0 +1,137 @@
---
title: "Game audio: music and SFX"
description: "Add CC0-sourced sound effects and a background music loop to the play flow, with split music/SFX toggles and a gesture-gated Web Audio core"
status: in-progress
priority: P2
effort: "2d"
tags: [audio, ui-ux, assets]
created: 2026-09-09
---
# Game audio: music and SFX
## Overview
Give VNGeoGuessr a voice: ~9 one-shot sound effects on the play flow's key
moments, plus a background music loop that runs on the menu **and** during the
round. Both default on, both independently mutable, both persisted like the
theme.
Source research:
[`plans/reports/research-260909-0917-game-audio-free-sources.md`](../reports/research-260909-0917-game-audio-free-sources.md).
**Contract**
- **Outcome:** the game makes sound. SFX confirm every interaction; music plays
continuously across menu and round; a player can silence either one in two
clicks and the choice sticks.
- **Constraints:** JavaScript only, individual function parameters; no new npm
dependency; audio code is client-only; `src/lib/` changes run `npm test`;
binary assets go in `public/audio/` with a per-file license manifest;
everything ships CC0 or Pixabay-Content-License (commercial, no attribution).
- **Non-goals:** volume sliders, spatial audio, dynamic music layering, an audio
settings modal, voice-over, per-region music.
- **Acceptance:** see [Success Criteria](#success-criteria).
## Decisions
| # | Decision | Rationale |
|---|----------|-----------|
| D1 | No audio library. Own `src/lib/audio.js` on the Web Audio API | 9 one-shots + 1 loop. Howler's ~9 KB gz buys sprites, fades and spatial audio we do not use. Revisit past ~15 sounds or when crossfades are wanted |
| D2 | Music plays on menu **and** in-round, default **on**, at low gain (0.15 vs 0.40 for SFX) | User decision 2026-09-09. Low gain is the mitigation for music competing with the "read the scene" task |
| D3 | Split music/SFX toggles, two localStorage keys, mirroring `src/lib/theme.js` | User decision 2026-09-09. Reuses an established, tested preference pattern |
| D4 | Music lives in a client component mounted in the root layout | `/` → `/game/[region]` is a client navigation; anything mounted per-page restarts the loop on every route change |
| D5 | Unlock the AudioContext from a one-shot document-level `pointerdown`/`keydown` listener | Music is default-on and the first gesture can be the username modal, the region picker, or Play — no single handler owns it |
| D6 | MP3 for SFX; WebM/Opus with MP3 fallback for the music loop, looped through a decoded Web Audio buffer | MP3 encoder padding makes an audible gap on loop; a decoded buffer with `loop = true` is gapless regardless. Safari decodes Opus-in-WebM only from 18.4, hence the fallback |
| D7 | CC0-first sourcing, with a committed `public/audio/SOURCES.md` manifest and a Credits entry | Licenses on Freesound/OpenGameArt/Pixabay are per-file, not per-site. The manifest is the audit trail; the Credits card matches how every other asset in this repo is treated |
| D9 | Preferences live in a module variable, mirrored into localStorage rather than read back from it | A private window throws on both read and write. Reading the flag back before every sound made a mute that could not be persisted a mute that never happened — the player had no way to silence the game. `theme.js` can read on every call because it drives a class name the browser then owns; here the value gates playback |
| D10 | The gesture listeners stay registered for the life of the page | Backgrounding a tab suspends the AudioContext on mobile and it does not resume on its own. Removing the listeners after the first gesture left the game silent for the rest of a session once the phone had been locked |
| D11 | Music is mounted app-wide, so it also plays on `/credits` and `/debug/*` | A consequence of D4, recorded rather than worked around: scoping it to the menu and game routes would mean mounting it per-route, which is exactly what restarts the loop on navigation |
| D8 | Below `sm`, the game header shows one combined mute button; `sm+` shows the split pair | The game header already carries Back + badges + ThemeToggle (132px) + Beer. Two more 44px buttons overflow a 360px viewport. Split control stays available at every size on the home header |
## Goals
| # | Goal | Priority |
|---|------|----------|
| 1 | Legally-clean CC0/Pixabay audio assets committed with a license manifest | P1 |
| 2 | A client-only audio core: gesture unlock, lazy decode, two persisted preferences | P1 |
| 3 | Split music/SFX controls in the app chrome, on every header | P1 |
| 4 | SFX on the nine play-flow moments | P1 |
| 5 | A gapless background loop that survives client navigation | P1 |
| 6 | Tests, lint, build and docs green | P2 |
## Phases
| # | Phase | Status |
|---|-------|--------|
| 1 | [Phase 1: Audio assets and licensing](./phase-01-audio-assets.md) | Done |
| 2 | [Phase 2: Audio core module](./phase-02-audio-core-module.md) | Done |
| 3 | [Phase 3: Sound controls](./phase-03-sound-controls.md) | Done |
| 4 | [Phase 4: SFX wiring](./phase-04-sfx-wiring.md) | Done |
| 5 | [Phase 5: Background music](./phase-05-background-music.md) | Done |
| 6 | [Phase 6: Verification and docs](./phase-06-verification-and-docs.md) | Manual pass open |
Dependencies: 2 needs 1 (filenames fixed). 3 needs 2. 4 needs 2 and 3. 5 needs
2 and 3. 6 needs everything.
## Sound map
| Key | Trigger | Character |
|-----|---------|-----------|
| `click` | Any header/menu button, region pick | short dry UI click |
| `pin` | `handleMapClick` — guess marker dropped | soft pop |
| `submit` | `handleSubmitGuess` | rising whoosh |
| `great` | Result reveal, score >= 4 (<= 100 m) | bright 2-note jingle |
| `good` | Result reveal, score 1-3 | neutral chime |
| `poor` | Result reveal, score 0 | soft descending tone |
| `next` | `handleNextRound` | light transition swoosh |
| `skip` | `handleSkipGuess` | flat neutral blip |
| `error` | `handlePanoramaError`, failed submit | low error tone |
| `music` | Loop, menu + in-round | calm ambient, ~60-90 s, seamless |
Score tiers come from `SCORE_BANDS` in `src/lib/game.js` (5/4/3/2/1 points at
50/100/200/500/1000 m).
## Risks
| Risk | Signal it is happening | Response |
|------|------------------------|----------|
| Default-on music annoys players and they mute permanently | Anecdotal feedback; the mute toggle is the first thing touched | Ship at 0.15 gain, keep the toggle one click away. If it lands badly, flip the music default to off — a one-constant change in `src/lib/audio.js` |
| iOS Safari refuses to start music until a gesture | Music silent on iOS until the first tap | Accepted and unavoidable — no browser allows unmuted autoplay. The unlock listener starts it on the first tap |
| Header overflows on small phones | Horizontal scroll or wrapped chrome at 360px | D8: combined mute button below `sm` |
| ~1.2 MB of binary assets in a git repo | Clone/checkout time | Accepted. Budget: SFX total <= 200 KB, music <= 1 MB. Exceeding it means re-encoding, not adding a CDN |
| Music restarts on every route change | Audible re-start walking `/` → `/game/x` → back | D4: mounted in the root layout, not per-page |
| A sourced file turns out to be CC-BY-NC or mislabelled | Manifest review finds a license that is not CC0/Pixabay | Replace the file. The manifest exists precisely so this is caught before shipping |
## Success Criteria
- [x] Every file in `public/audio/` is CC0 or Pixabay Content License, recorded in `public/audio/SOURCES.md` with source URL, author and license
- [x] `npm test` passes, including new tests for the audio preference helpers
- [x] `npm run lint` and `npm run build` pass
- [ ] All nine SFX fire on their triggers; none fires more than once per event.
Narrowed 2026-09-09: `click` fires on Play and Back only — on every button
in the chrome it is noise. `error` fires when the panorama viewer fails to
construct, not on `panorama-error`, which falls back to a flat image and
leaves the round playable, so a failure tone would be a lie
- [ ] Music starts on the first user gesture, plays through `/` → `/game/[region]` → back without restarting, and loops with no audible gap
- [x] Music and SFX toggle independently; both choices survive a reload
- [ ] With site data blocked (private window), the app plays sound and does not throw
- [x] No horizontal overflow in any header at 320px and 360px width
- [x] No audio request fires before the first user gesture (verify in DevTools Network)
- [x] A missing or corrupt audio file degrades to silence, never to a broken screen
- [x] `/credits` lists the audio sources
- [x] `docs/features.md` and `docs/project-structure.md` mention the audio layer
The four criteria still open — the nine effects firing once each, the music
playing unbroken across a navigation and looping without a gap, and sound
surviving a private window — need speakers and a real session. Phase 6 carries
them as a checklist.
## Open questions
- ~~Music track character: neutral ambient, or Vietnamese instrumentation?~~
**Resolved 2026-09-09 in Phase 1.** OpenGameArt's CC0 catalogue holds no
Vietnamese or broadly Southeast Asian instrumentation — a licence-filtered
search returns two Japanese-themed tracks and nothing else. The loop is
neutral ambient. Swapping it later needs no code change.
<!-- slug: game-audio-music-and-sfx -->
@@ -0,0 +1,218 @@
# Research Report: Music & SFX for VNGeoGuessr from Free Sources
Conducted 2026-09-09 09:17 (+07). Scope: where to legally get free audio, which licenses are safe to ship, and how to wire audio into this Next.js 16 / React 19 codebase.
## Table of Contents
- [Executive Summary](#executive-summary)
- [Methodology](#methodology)
- [1. Source Catalogue & Licenses](#1-source-catalogue--licenses)
- [2. Licensing Pitfalls](#2-licensing-pitfalls)
- [3. Sound Design for This Game](#3-sound-design-for-this-game)
- [4. Technical Implementation](#4-technical-implementation)
- [5. Formats, Size, Delivery](#5-formats-size-delivery)
- [6. Recommendations](#6-recommendations)
- [7. Common Pitfalls](#7-common-pitfalls)
- [References](#references)
- [Unresolved Questions](#unresolved-questions)
## Executive Summary
Use **Kenney (CC0)** for all UI/SFX and **Pixabay Music** or a **CC0 OpenGameArt loop** for background music. Both allow commercial use with **no attribution**, which keeps `/credits` unchanged and avoids per-file license tracking. Avoid Incompetech/CC-BY unless you want a permanent credit obligation, and avoid Freesound's mixed pool unless you filter strictly to CC0.
Implementation: **no new dependency needed**. The requirement is ~8 one-shot SFX plus one optional music loop — a ~70-line `src/lib/audio.js` (Web Audio buffers + a first-gesture unlock) mirrors the existing `src/lib/theme.js` preference pattern and stays KISS. Howler.js (~9 KB gz) only pays for itself if you later need sprites, spatial audio, or fades; it does bundle autoplay-unlock and HTML5 fallback for free.
Hard constraint: **every browser blocks audio before a user gesture**. iOS Safari is strictest and offers no user opt-out. Since the game already starts from a region-picker click, unlock the `AudioContext` in that first click handler — no "tap to start" gate needed.
## Methodology
- Sources consulted: 5 web searches + repo inspection (`package.json`, `src/app/components/`, `src/lib/`, `src/app/credits/page.js`)
- Date range: MDN/Chrome autoplay docs (evergreen) through 2026 asset-library round-ups
- Search terms: free CC0 game music/SFX 2026; Pixabay content license commercial restrictions; Howler.js vs Web Audio in React/Next; browser autoplay unlock iOS Safari; opus/webm vs mp3 loop size
## 1. Source Catalogue & Licenses
| Source | License | Attribution | Best for | Risk |
|---|---|---|---|---|
| [Kenney](https://kenney.nl/assets?q=audio) | CC0 1.0 | No | UI clicks, interface beeps, 85-track *Music Jingles* pack | None. Top pick |
| [OpenGameArt](https://opengameart.org/) | Per-asset (filter CC0) | Depends | Ambient loops, game-specific music | Must check each asset |
| [Pixabay Music / SFX](https://pixabay.com/music/) | Pixabay Content License | No | Background music loops | Cannot resell standalone; license changed 2019 & 2023-04-17 |
| [Freesound](https://freesound.org/) | Per-sound: CC0 / CC-BY / CC-BY-NC | Depends | Field recordings, oddities | **Per-file** licenses; NC files unusable commercially |
| [Sonniss GDC Bundle](https://sonniss.com/gameaudiogdc) | Royalty-free commercial | No | Pro SFX (200 GB+) | Huge download; no standalone redistribution |
| [Incompetech](https://incompetech.com/music/royalty-free/) | CC BY | **Yes** | 2000+ tracks | Permanent credit line required |
| YouTube Audio Library | Mixed, some CC-BY | Depends | Music | Tied to YouTube ToS; weakest paper trail |
Bottom line: **CC0 first, Pixabay second, CC-BY only if the track is worth a credits entry.**
## 2. Licensing Pitfalls
1. **Per-file, not per-site.** Freesound and OpenGameArt host CC0, CC-BY and CC-BY-NC side by side. The site is not the license — the download page is.
2. **Pixabay has three license eras** (CC0 pre-2019-01-09, Pixabay License 2019 → 2023-04, Content License 2023-04-17 → now). Anything grabbed from an old blog mirror may not carry the terms you think. Download fresh, from Pixabay.
3. **"Free" != "redistributable as an asset."** Pixabay and Sonniss both forbid reselling/redistributing the file *as stock media*. Shipping it inside the game is fine; publishing `public/audio/` as a downloadable asset pack is not. This repo is public on GitHub, so the files will be browsable — CC0 sidesteps the question entirely.
4. **Keep a manifest.** Since licenses are per-file, commit `public/audio/SOURCES.md` mapping each file → download URL → license → author. Cheap insurance, ~15 lines.
5. **CC-BY obligations land on `/credits`.** `src/app/credits/page.js` already uses a card-per-source layout — a new "Audio" card is the natural home, next to the Mapillary CC-BY-SA card.
## 3. Sound Design for This Game
Events already available in `GameClient.js` (line refs from current `main`):
| Event | Hook | Suggested sound |
|---|---|---|
| Region picked / button press | `RegionPicker`, any `<Button onClick>` | short UI click (Kenney *Interface Sounds*) |
| Pin dropped on map | `handleMapClick` (:239) | soft "pop" / marker thud |
| Guess submitted | `handleSubmitGuess` (:260) | rising whoosh |
| Result reveal, tiered by score | `setResult` / `RoundResultDialog` | 3 variants: great / ok / poor (Kenney *Music Jingles*) |
| Score count-up | `src/lib/use-count-up.js` | ticking blip, throttled >= 60 ms — **easy to make obnoxious** |
| Next round | `handleNextRound` (:308) | soft transition swoosh |
| Skip | `handleSkipGuess` (:341) | neutral/negative blip |
| Load error | `handlePanoramaError` (:223) | low error tone |
| Menu / region select background | `/` and `/game/[region]` idle | ambient loop, **off during the round** |
Deliberate call: GeoGuessr-likes run **silent during play** — music competes with the "read the scene" task. Default music off or menu-only; default SFX on at ~0.4 gain.
## 4. Technical Implementation
### Autoplay reality
An `AudioContext` created before a user gesture starts `suspended`; you must call `resume()` inside a gesture handler. iOS Safari blocks all unmuted autoplay with **no user override**. This project's flow already begins with a click (region pick), so unlock there — no extra "tap to start" screen.
```
first user click ──► ctx.resume() ──► fetch + decode buffers (once) ──► play()
│
└── all later plays are free
```
### Recommended: thin module, no dependency
Mirrors the `theme.js` shape already in `src/lib/` (localStorage preference + try/catch, individual params per CLAUDE.md).
```js
// src/lib/audio.js — client-only. Web Audio buffers; unlocked on first gesture.
export const AUDIO_STORAGE_KEY = 'vngeoguessr_audio';
const SOUNDS = {
click: '/audio/click.mp3',
pin: '/audio/pin.mp3',
submit: '/audio/submit.mp3',
great: '/audio/great.mp3',
poor: '/audio/poor.mp3',
};
let ctx = null;
const buffers = new Map();
/**
* Read the stored on/off choice.
* @returns {boolean} True when sound should play.
*/
export function getStoredAudioEnabled() {
if (typeof window === 'undefined') return true;
try {
return localStorage.getItem(AUDIO_STORAGE_KEY) !== 'off';
} catch {
// Private browsing and blocked site data both throw here; sound on is a
// fine answer when the choice cannot be read.
return true;
}
}
/**
* Persist the on/off choice.
* @param {boolean} enabled Whether sound should play.
* @returns {void}
*/
export function setStoredAudioEnabled(enabled) {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(AUDIO_STORAGE_KEY, enabled ? 'on' : 'off');
} catch {
// Not being able to remember the choice must not break changing it.
}
}
/**
* Open the audio context. Must be called from a real user gesture; safe to
* call repeatedly.
* @returns {void}
*/
export function unlockAudio() {
if (typeof window === 'undefined') return;
if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)();
if (ctx.state === 'suspended') ctx.resume();
}
async function loadBuffer(name) {
if (buffers.has(name)) return buffers.get(name);
const response = await fetch(SOUNDS[name]);
const buffer = await ctx.decodeAudioData(await response.arrayBuffer());
buffers.set(name, buffer);
return buffer;
}
/**
* Fire-and-forget one-shot. Never throws into the caller.
* @param {string} name Key in SOUNDS.
* @param {number} volume Gain, 0 to 1.
* @returns {Promise<void>}
*/
export async function playSound(name, volume = 0.4) {
if (!ctx || !SOUNDS[name] || !getStoredAudioEnabled()) return;
try {
const source = ctx.createBufferSource();
source.buffer = await loadBuffer(name);
const gain = ctx.createGain();
gain.gain.value = volume;
source.connect(gain).connect(ctx.destination);
source.start();
} catch {
// A missing or undecodable file must not break gameplay.
}
}
```
Wire-up: call `unlockAudio()` in the region-picker click, `playSound('pin')` in `handleMapClick`, and add a speaker toggle beside `ThemeToggle` in the game header.
### Alternative: Howler.js
`howler@2.x`, ~9 KB gz, wraps Web Audio + HTML5 Audio, handles the unlock, and supports **audio sprites** (all SFX in one file, one decode, minimal latency). Take it if you expect to grow past ~10 sounds, or want fades/ducking/spatial audio. `react-howler` is unnecessary — call Howler directly from handlers. Skip it for the current scope: the wrapper is bigger than the problem.
## 5. Formats, Size, Delivery
- **SFX: MP3, mono, 96–128 kbps.** Universal support; each file 5–20 KB. Multi-format is not worth it at this size.
- **Music loop: WebM/Opus with an MP3 fallback.** Opus @96 kbps matches or beats MP3 @128 kbps at ~75% the size (30 s loop: ~180 KB vs ~240 KB). Safari only decodes Opus-in-WebM from **18.4**; older Safari needs the MP3.
- **Gapless looping:** MP3 carries encoder padding and loops with an audible gap. For a seamless loop use Ogg/WebM-Opus, or decode into a Web Audio buffer and set `loop` / `loopStart` / `loopEnd`.
- **Delivery:** `public/audio/`, served by Next.js with long-lived caching. **Do not preload on page load** — fetch and decode lazily after unlock so audio never blocks first paint. Keep the SFX set under ~200 KB total, music under ~1 MB.
## 6. Recommendations
Order of work:
1. Pick assets: Kenney *Interface Sounds* + *Music Jingles* (CC0) for all SFX; one Pixabay or CC0 OpenGameArt ambient loop for the menu.
2. Commit `public/audio/SOURCES.md` (file → URL → license → author) alongside the files.
3. Add `src/lib/audio.js` per above, with vitest coverage for `getStoredAudioEnabled` / `setStoredAudioEnabled` including the localStorage-throws path — `src/lib/` changes require `npm test` per CLAUDE.md.
4. Unlock in the first click; wire the five highest-value events (click, pin, submit, result tier, next).
5. Add a mute toggle next to `ThemeToggle`; persist like the theme.
6. Music last, menu-only, default off. Ship SFX first and see whether music is even wanted.
7. Update `/credits` **only if** a chosen asset is CC-BY.
Deliberately out of scope unless asked: spatial audio, per-sound volume sliders, dynamic music layering, an audio-settings modal.
## 7. Common Pitfalls
- Creating a `new Audio()` per playback — leaks elements and adds latency. Reuse buffers (or one Howl per asset).
- Calling `resume()` outside a gesture handler — silently stays suspended, no error thrown.
- Firing the count-up tick every animation frame — unbearable. Throttle, or play a single sweep instead.
- Shipping a CC-BY-NC Freesound file. It is not commercial-usable, and "my game is free" does not reliably make the use non-commercial.
- Assuming a whole site shares one license (Freesound, OpenGameArt and Pixabay all mix eras and licenses).
- Loud defaults. Start SFX at ~0.4 gain; loud is the top reason players mute permanently.
## References
- [Kenney assets (CC0)](https://kenney.nl/assets?q=audio)
- [OpenGameArt](https://opengameart.org/)
- [Pixabay Music](https://pixabay.com/music/) · [Pixabay Terms](https://pixabay.com/service/terms/) · [Pixabay commercial risk profile](https://picdefense.io/resources/source-intel/pixabay/)
- [Freesound](https://freesound.org/) · [Sonniss GDC bundle](https://sonniss.com/gameaudiogdc) · [Incompetech](https://incompetech.com/music/royalty-free/)
- [Game asset licenses explained](https://app.cinevva.com/guides/game-asset-licenses) · [Free SFX & music for games 2026](https://app.cinevva.com/guides/free-sound-effects-music) · [25 free game audio libraries](https://gamineai.com/resources/25-free-game-sound-effects-music-libraries-updated-2026)
- [MDN: Autoplay guide](https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Autoplay) · [Chrome autoplay policy](https://developer.chrome.com/blog/autoplay) · [Unlock Web Audio in Safari](https://www.mattmontag.com/web/unlock-web-audio-in-safari-for-ios-and-macos)
- [MDN: Web Audio API best practices](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Best_practices) · [howler.js](https://howlerjs.com/) · [howler.js repo](https://github.com/goldfire/howler.js/)
- [MDN: Web audio codec guide](https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats/Audio_codecs) · [Opus browser support](https://www.testmuai.com/learning-hub/opus-audio-codec-browser-support/)
## Unresolved Questions
1. Music during the round, or menu-only? Recommendation: menu-only, default off — but it is a product call.
2. Mute toggle as a single on/off (KISS), or split music/SFX?
3. Vietnamese-flavoured music (đàn tranh, bamboo flute) or neutral ambient? CC0 Vietnamese instrumentation is scarce — likely Pixabay, or generated.
4. Is committing ~1 MB of audio to this repo acceptable, or should it go to a CDN/blob store?
5. Any existing audio assets or a preferred composer not visible in the repo?