mirror of
https://github.com/tiennm99/vngeoguessr.git
synced 2026-10-15 06:16:23 +00:00
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:
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?
|
||||
Reference in new issue
Block a user