diff --git a/public/audio/SOURCES.md b/public/audio/SOURCES.md
new file mode 100644
index 0000000..2a062d7
--- /dev/null
+++ b/public/audio/SOURCES.md
@@ -0,0 +1,55 @@
+# Audio sources and licenses
+
+Every file in this directory is **CC0 1.0 Universal** (public domain
+dedication). No attribution is legally required; the Credits page names the
+authors anyway, as it does for every other asset in this project.
+
+Licenses on asset sites are per file, not per site. This table is the audit
+trail: one row per shipped file, naming the exact upstream source. Add a row
+before adding a file.
+
+Downloaded 2026-09-09.
+
+## Sound effects
+
+Kenney's packs ship as 44.1 kHz stereo Ogg Vorbis. Each file here is
+RMS-normalised to −18 dBFS, limited to −1 dBFS peak, downmixed to mono and
+encoded as 112 kbps MP3 — the raw sources span 16 dB, which would leave the
+click inaudible beside the submit sound.
+
+| File | Role | Upstream file | Pack | Author | License | Source |
+|------|------|---------------|------|--------|---------|--------|
+| `click.mp3` | Navigational buttons | `click2.ogg` | UI Audio | Kenney | CC0 1.0 | https://kenney.nl/assets/ui-audio |
+| `pin.mp3` | Guess marker dropped | `drop_002.ogg` | Interface Sounds | Kenney | CC0 1.0 | https://kenney.nl/assets/interface-sounds |
+| `submit.mp3` | Guess submitted | `maximize_002.ogg` | Interface Sounds | Kenney | CC0 1.0 | https://kenney.nl/assets/interface-sounds |
+| `skip.mp3` | Round skipped | `minimize_002.ogg` | Interface Sounds | Kenney | CC0 1.0 | https://kenney.nl/assets/interface-sounds |
+| `next.mp3` | Next round | `open_001.ogg` | Interface Sounds | Kenney | CC0 1.0 | https://kenney.nl/assets/interface-sounds |
+| `error.mp3` | Panorama or submit failure | `error_004.ogg` | Interface Sounds | Kenney | CC0 1.0 | https://kenney.nl/assets/interface-sounds |
+| `great.mp3` | Result, 4-5 points | `jingles_PIZZI02.ogg` | Music Jingles | Kenney | CC0 1.0 | https://kenney.nl/assets/music-jingles |
+| `good.mp3` | Result, 1-3 points | `jingles_PIZZI12.ogg` | Music Jingles | Kenney | CC0 1.0 | https://kenney.nl/assets/music-jingles |
+| `poor.mp3` | Result, 0 points | `jingles_PIZZI14.ogg` | Music Jingles | Kenney | CC0 1.0 | https://kenney.nl/assets/music-jingles |
+
+`submit.mp3` and `skip.mp3` are the same sound in opposite directions —
+Kenney's `maximize_002` rises, `minimize_002` falls. Sending a guess and
+abandoning one are mirror actions, so they get mirror sounds.
+
+## Music
+
+| File | Track | Author | License | Source |
+|------|-------|--------|---------|--------|
+| `music.webm` | Ambient Relaxing Loop | isaiah658 | CC0 1.0 | https://opengameart.org/content/ambient-relaxing-loop |
+| `music.mp3` | Ambient Relaxing Loop | isaiah658 | CC0 1.0 | https://opengameart.org/content/ambient-relaxing-loop |
+
+One 24.5-second loop, shipped twice: Opus in WebM for current browsers, MP3 for
+Safari below 18.4, which cannot decode Opus in WebM. Both are encoded from the
+upstream WAV with no level change — the app plays the bed at 0.15 gain.
+
+The track was chosen on a measurement rather than a hunch: its first and last
+250 ms sit within 0.5 dB of each other, so the loop point carries continuous
+energy. The two longer OpenGameArt candidates that were also auditioned
+(Contemplation, Daydream) fade in from and out to silence — 29 dB and 74 dB
+apart across the seam — and would drop out audibly on every repeat.
+
+**MP3 loop caveat:** MP3 carries encoder padding that `decodeAudioData` renders
+as silence, so the fallback path can have a short gap at the loop point. Only
+Safari below 18.4 takes that path.
diff --git a/public/audio/click.mp3 b/public/audio/click.mp3
new file mode 100644
index 0000000..00a7fba
Binary files /dev/null and b/public/audio/click.mp3 differ
diff --git a/public/audio/error.mp3 b/public/audio/error.mp3
new file mode 100644
index 0000000..351da84
Binary files /dev/null and b/public/audio/error.mp3 differ
diff --git a/public/audio/good.mp3 b/public/audio/good.mp3
new file mode 100644
index 0000000..b2184cb
Binary files /dev/null and b/public/audio/good.mp3 differ
diff --git a/public/audio/great.mp3 b/public/audio/great.mp3
new file mode 100644
index 0000000..252bd03
Binary files /dev/null and b/public/audio/great.mp3 differ
diff --git a/public/audio/music.mp3 b/public/audio/music.mp3
new file mode 100644
index 0000000..12d1469
Binary files /dev/null and b/public/audio/music.mp3 differ
diff --git a/public/audio/music.webm b/public/audio/music.webm
new file mode 100644
index 0000000..bab35b5
Binary files /dev/null and b/public/audio/music.webm differ
diff --git a/public/audio/next.mp3 b/public/audio/next.mp3
new file mode 100644
index 0000000..669ecce
Binary files /dev/null and b/public/audio/next.mp3 differ
diff --git a/public/audio/pin.mp3 b/public/audio/pin.mp3
new file mode 100644
index 0000000..dc02f8e
Binary files /dev/null and b/public/audio/pin.mp3 differ
diff --git a/public/audio/poor.mp3 b/public/audio/poor.mp3
new file mode 100644
index 0000000..6405cb8
Binary files /dev/null and b/public/audio/poor.mp3 differ
diff --git a/public/audio/skip.mp3 b/public/audio/skip.mp3
new file mode 100644
index 0000000..56d9349
Binary files /dev/null and b/public/audio/skip.mp3 differ
diff --git a/public/audio/submit.mp3 b/public/audio/submit.mp3
new file mode 100644
index 0000000..9e1cd28
Binary files /dev/null and b/public/audio/submit.mp3 differ
diff --git a/src/app/components/GameClient.js b/src/app/components/GameClient.js
index 4b820c8..25aebf9 100644
--- a/src/app/components/GameClient.js
+++ b/src/app/components/GameClient.js
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import { ArrowLeft, Beer } from 'lucide-react';
import PanoramaViewer from './PanoramaViewer';
import ThemeToggle from './ThemeToggle';
+import SoundToggle from './SoundToggle';
import DonateQRModal from './DonateQRModal';
import FirstRoundHint from './FirstRoundHint';
import GuessMapPanel from './GuessMapPanel';
@@ -13,11 +14,24 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { generateRandomUsername, getUsername, setUsername } from '../../lib/username';
import { setLastRegion } from '../../lib/last-region';
+import { playSound } from '../../lib/audio';
// The revealed path comes from /api/guess (the RESOLVED district), not from
// regionPath(pickedRegion) -- computing it client-side from what the player
// chose would make the reveal meaningless for a country round.
import { getRegion, isRegion } from '../../lib/regions';
+/**
+ * The result sound for a score, following the scoring ladder in lib/game.js:
+ * 4-5 points is a guess within 100m and worth celebrating, 1-3 is a hit, 0 is
+ * a miss.
+ * @param {number} score Points awarded, 0 to 5.
+ * @returns {string} Key of a sound in lib/audio.js.
+ */
+function resultSound(score) {
+ if (score >= 4) return 'great';
+ return score > 0 ? 'good' : 'poor';
+}
+
/**
* Ask the server for a new round. Throws on failure; touches no state, so the
* result-screen prefetch can call it without disturbing the round on screen.
@@ -108,6 +122,12 @@ export default function GameClient({ region }) {
const roundEpochRef = useRef(0);
// The epoch of the round currently applied to the screen; see applyRound.
const appliedEpochRef = useRef(0);
+ // Back stays live while a guess is in flight, so a submit can resolve after
+ // the player has already left for the menu. State updates after that are
+ // harmless no-ops; a victory jingle over the home screen is not.
+ const mountedRef = useRef(true);
+
+ useEffect(() => () => { mountedRef.current = false; }, []);
const applyRound = useCallback((data) => {
setSessionId(data.sessionId);
@@ -223,6 +243,7 @@ export default function GameClient({ region }) {
const handlePanoramaError = useCallback((error) => {
console.error('Panorama error:', error);
if (appliedEpochRef.current !== roundEpochRef.current) return;
+ playSound('error');
setRoundLoading(false);
}, []);
@@ -237,6 +258,9 @@ export default function GameClient({ region }) {
}, [roundLoading]);
const handleMapClick = (coordinates) => {
+ // Before the state update, so the pin lands with the sound rather than
+ // after React has re-rendered the map.
+ playSound('pin');
setGuessCoordinates([coordinates.lat, coordinates.lng]);
};
@@ -259,6 +283,8 @@ export default function GameClient({ region }) {
const handleSubmitGuess = async () => {
if (!guessCoordinates || !imageData || submitting) return;
+ // After the guard: a blocked submit stays silent.
+ playSound('submit');
setSubmitting(true);
const currentSession = sessionId;
@@ -278,10 +304,12 @@ export default function GameClient({ region }) {
resolvedPath: submitted.region?.path ?? null,
leaderboardMessage: submitted.leaderboard?.message ?? '',
});
+ if (mountedRef.current) playSound(resultSound(submitted.score ?? 0));
} else {
// The guess did not record. Say so instead of rendering a 99999m round,
// which reads as a real miss and is indistinguishable from one.
setResult({ failed: true });
+ if (mountedRef.current) playSound('error');
}
} catch (error) {
// A throw here means the same thing as a null result: nothing was
@@ -289,6 +317,7 @@ export default function GameClient({ region }) {
// confident 99999m miss for a round the server never saw.
console.error('Error submitting guess:', error);
setResult({ failed: true });
+ if (mountedRef.current) playSound('error');
}
setSubmitting(false);
@@ -309,6 +338,7 @@ export default function GameClient({ region }) {
// Radix keeps the dialog interactive through its exit animation, so a
// double-click would issue a second fetch and burn a session.
if (roundLoading) return;
+ playSound('next');
roundEpochRef.current += 1;
const epoch = roundEpochRef.current;
setShowResult(false);
@@ -340,6 +370,7 @@ export default function GameClient({ region }) {
const handleSkipGuess = async () => {
if (!imageData && !loadError) return;
+ playSound('skip');
try {
if (sessionId) {
@@ -375,6 +406,7 @@ export default function GameClient({ region }) {
};
const handleGoBack = () => {
+ playSound('click');
router.push('/');
};
@@ -429,6 +461,22 @@ export default function GameClient({ region }) {
+ {/* Two variants, swapped by breakpoint rather than by a resize
+ listener: below sm the header has no room for a second pair of
+ 44px cells beside ThemeToggle's three, so sound collapses to one
+ mute-everything switch.
+ The breakpoint class goes on a wrapper, not on the control: the
+ control's own class list already sets inline-flex, and `hidden`
+ fights it for the same property -- whichever Tailwind emits last
+ wins, which is how both variants ended up visible at once. A
+ wrapper that is display:none also takes its child out of the
+ accessibility tree, so nothing is announced twice. */}
+
+
+
+
+
+
setShowDonate(true)}
variant="ghost"
diff --git a/src/app/components/MusicPlayer.js b/src/app/components/MusicPlayer.js
new file mode 100644
index 0000000..7819711
--- /dev/null
+++ b/src/app/components/MusicPlayer.js
@@ -0,0 +1,108 @@
+"use client";
+
+import { useEffect, useRef } from 'react';
+import {
+ MUSIC_VOLUME,
+ getStoredMusicEnabled,
+ isUnlocked,
+ getAudioContext,
+ loadAudioBuffer,
+ watchMusicPreference,
+ watchUnlock,
+} from '../../lib/audio';
+
+/**
+ * Pick the loop the browser can actually decode.
+ *
+ * Safari only decodes Opus in WebM from 18.4, so an MP3 of the same loop ships
+ * beside it. MP3 carries encoder padding that decodeAudioData renders as
+ * silence, which puts a short gap at the loop point -- only the browsers that
+ * cannot take the WebM pay for it.
+ * @returns {string} Path to the loop file.
+ */
+function pickSource() {
+ const probe = document.createElement('audio');
+ return probe.canPlayType('audio/webm; codecs="opus"') !== ''
+ ? '/audio/music.webm'
+ : '/audio/music.mp3';
+}
+
+/**
+ * The background loop. Renders nothing, and is mounted once in the root layout
+ * rather than per page: `/` to `/game/[region]` is a client navigation, so a
+ * player mounted inside either page would tear down and restart the loop on
+ * every move between them.
+ * @returns {null} Nothing to render.
+ */
+export default function MusicPlayer() {
+ // A playing audio node is not render state, so none of this lives in
+ // useState -- re-rendering the root layout to track a loop would be waste.
+ const sourceRef = useRef(null);
+ const aliveRef = useRef(true);
+
+ useEffect(() => {
+ aliveRef.current = true;
+
+ const stop = () => {
+ const source = sourceRef.current;
+ // Cleared before stop(), so a start() racing this cannot see a node that
+ // is on its way out and decide one is already playing.
+ sourceRef.current = null;
+ if (!source) return;
+ try {
+ source.stop();
+ source.disconnect();
+ } catch {
+ // Already stopped; nothing left to do.
+ }
+ };
+
+ const start = async () => {
+ if (sourceRef.current || !isUnlocked() || !getStoredMusicEnabled()) return;
+ const context = getAudioContext();
+ if (!context) return;
+
+ try {
+ const buffer = await loadAudioBuffer(pickSource());
+ // The decode can outlive the mount, or the player can be muted while
+ // it runs; either way the node must not reach the speakers.
+ if (!aliveRef.current || sourceRef.current || !getStoredMusicEnabled()) return;
+
+ const source = context.createBufferSource();
+ source.buffer = buffer;
+ // Looping a decoded buffer returns to sample zero, so the seam is
+ // sample-accurate -- which an element cannot promise
+ // because the container framing rides along with it.
+ source.loop = true;
+ const gain = context.createGain();
+ gain.gain.value = MUSIC_VOLUME;
+ source.connect(gain).connect(context.destination);
+ source.start();
+ sourceRef.current = source;
+ } catch {
+ // A missing or undecodable loop leaves the game silent, not broken.
+ }
+ };
+
+ // Two ways in: the context opening on the first gesture, and the player
+ // flipping the switch afterwards.
+ const unwatchUnlock = watchUnlock(start);
+ const unwatchPreference = watchMusicPreference((enabled) => {
+ if (enabled) start();
+ else stop();
+ });
+
+ // A gesture may already have landed before this mounted -- a click on the
+ // name prompt, say, on a page that then navigated here.
+ start();
+
+ return () => {
+ aliveRef.current = false;
+ unwatchUnlock();
+ unwatchPreference();
+ stop();
+ };
+ }, []);
+
+ return null;
+}
diff --git a/src/app/components/SoundToggle.js b/src/app/components/SoundToggle.js
new file mode 100644
index 0000000..ac0a9a3
--- /dev/null
+++ b/src/app/components/SoundToggle.js
@@ -0,0 +1,126 @@
+"use client";
+
+import { useEffect, useState } from 'react';
+import { Music, Volume2, VolumeX } from 'lucide-react';
+import {
+ getStoredMusicEnabled,
+ getStoredSfxEnabled,
+ setStoredMusicEnabled,
+ setStoredSfxEnabled,
+ unlockAudio,
+ watchMusicPreference,
+ watchSfxPreference,
+} from '../../lib/audio';
+
+/**
+ * Music and sound-effect switches, styled as a sibling of ThemeToggle so the
+ * header chrome reads as one system.
+ *
+ * The game header runs out of room below the `sm` breakpoint -- Back, the
+ * region badges, ThemeToggle's three cells and the donate button already fill
+ * a 360px viewport -- so it renders the compact variant there and the pair
+ * above it. Both are mounted at once, one of them display:none, which is why
+ * this subscribes to the preferences rather than reading them once: without
+ * that, rotating a phone past the breakpoint revealed a control still showing
+ * its mount-time state, and the first press on it went the wrong way.
+ *
+ * @param {string} className Extra classes for the caller's layout.
+ * @param {boolean} compact True for the one-button mute-all variant.
+ * @returns {JSX.Element} The control.
+ */
+export default function SoundToggle({ className = '', compact = false }) {
+ // Seeded with the same defaults the server renders, so the first client
+ // render matches the HTML and the effect below only ever corrects a player
+ // who has actually chosen otherwise. No `mounted` gate: with a default of
+ // on, gating would paint every load as muted and then flip.
+ const [musicOn, setMusicOn] = useState(true);
+ const [sfxOn, setSfxOn] = useState(true);
+
+ useEffect(() => {
+ setMusicOn(getStoredMusicEnabled());
+ setSfxOn(getStoredSfxEnabled());
+ const unwatchMusic = watchMusicPreference(setMusicOn);
+ const unwatchSfx = watchSfxPreference(setSfxOn);
+ return () => {
+ unwatchMusic();
+ unwatchSfx();
+ };
+ }, []);
+
+ // A click here is a real user gesture, which is the one thing the audio
+ // context needs -- so unmuting is audible immediately rather than on the
+ // next interaction. The preference setters notify every mounted control,
+ // including this one.
+ const applyMusic = (enabled) => {
+ unlockAudio();
+ setStoredMusicEnabled(enabled);
+ };
+
+ const applySfx = (enabled) => {
+ unlockAudio();
+ setStoredSfxEnabled(enabled);
+ };
+
+ const groupClass =
+ `inline-flex h-11 items-center rounded-lg border border-border bg-card ${className}`;
+
+ const cellClass = (on) =>
+ `flex h-11 w-11 items-center justify-center rounded-lg outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 ${
+ on
+ ? 'bg-brand text-brand-foreground shadow-sm'
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground'
+ }`;
+
+ if (compact) {
+ // Anything audible counts as on, so one press always silences everything.
+ const anyOn = musicOn || sfxOn;
+ const Icon = anyOn ? Volume2 : VolumeX;
+ return (
+
+ {
+ applyMusic(!anyOn);
+ applySfx(!anyOn);
+ }}
+ className={cellClass(anyOn)}
+ >
+
+
+
+ );
+ }
+
+ const SfxIcon = sfxOn ? Volume2 : VolumeX;
+
+ return (
+ // A plain group of toggle buttons rather than an ARIA radiogroup, matching
+ // ThemeToggle: aria-pressed carries the state without obliging roving
+ // tabindex and arrow-key navigation.
+
+ applyMusic(!musicOn)}
+ className={cellClass(musicOn)}
+ >
+
+
+ applySfx(!sfxOn)}
+ className={cellClass(sfxOn)}
+ >
+
+
+
+ );
+}
diff --git a/src/app/credits/page.js b/src/app/credits/page.js
index 228428e..8433efa 100644
--- a/src/app/credits/page.js
+++ b/src/app/credits/page.js
@@ -2,6 +2,7 @@ import Link from 'next/link';
import Image from 'next/image';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import ThemeToggle from '../components/ThemeToggle';
+import SoundToggle from '../components/SoundToggle';
export const metadata = {
title: 'Credits — VNGeoGuessr',
@@ -32,6 +33,16 @@ const LIBRARIES = [
{ name: 'Lucide', license: 'ISC', href: 'https://lucide.dev/' },
];
+// The shipped audio, by upstream pack rather than by file: nine effects come
+// from three Kenney packs and the loop from one OpenGameArt track. Per-file
+// provenance lives in public/audio/SOURCES.md.
+const AUDIO_SOURCES = [
+ { name: 'Interface Sounds', author: 'Kenney', href: 'https://kenney.nl/assets/interface-sounds' },
+ { name: 'UI Audio', author: 'Kenney', href: 'https://kenney.nl/assets/ui-audio' },
+ { name: 'Music Jingles', author: 'Kenney', href: 'https://kenney.nl/assets/music-jingles' },
+ { name: 'Ambient Relaxing Loop', author: 'isaiah658', href: 'https://opengameart.org/content/ambient-relaxing-loop' },
+];
+
export default function CreditsPage() {
return (
@@ -42,6 +53,7 @@ export default function CreditsPage() {
+
← Home
@@ -105,6 +117,27 @@ export default function CreditsPage() {
+
+
+ Sound & music
+
+
+
+ Every sound in the game is dedicated to the public domain under{' '}
+ CC0 1.0 ,
+ so none of it has to be credited. It is credited anyway.
+
+
+ {AUDIO_SOURCES.map((source) => (
+
+ {source.name}
+ {source.author}
+
+ ))}
+
+
+
+
Open-source software
diff --git a/src/app/debug/layout.js b/src/app/debug/layout.js
index 9a8c2ca..a1a855f 100644
--- a/src/app/debug/layout.js
+++ b/src/app/debug/layout.js
@@ -2,6 +2,7 @@ import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import ThemeToggle from '../components/ThemeToggle';
+import SoundToggle from '../components/SoundToggle';
import DebugNav from './DebugNav';
// One shell for every debug page, styled like the game screen's app bar, so
@@ -35,6 +36,7 @@ export default function DebugLayout({ children }) {
+
{children}
diff --git a/src/app/layout.js b/src/app/layout.js
index 5cf4c5a..fc32a00 100644
--- a/src/app/layout.js
+++ b/src/app/layout.js
@@ -1,5 +1,6 @@
import { THEME_STORAGE_KEY } from '../lib/theme';
import AppBackground from './components/AppBackground';
+import MusicPlayer from './components/MusicPlayer';
import InlineScript from './components/InlineScript';
import DebugFooter from './components/DebugFooter';
import { Analytics } from '@vercel/analytics/next';
@@ -59,6 +60,12 @@ export default function RootLayout({ children }) {
paints over the body background and under every page. */}
+ {/* Renders nothing, and its position is load-bearing: inside
+ but outside the flex column below, so the loop survives every
+ client navigation between the menu and a round, and a null-
+ rendering component can never become a row in that column. */}
+
+
{/* Sticky-footer column: pages fill the viewport via flex-1 and the
footer keeps its own strip below them, so it can never overlap the
game's panorama, map, or action bar — and nothing covers it. The
diff --git a/src/app/page.js b/src/app/page.js
index 81ae11e..2f15319 100644
--- a/src/app/page.js
+++ b/src/app/page.js
@@ -7,11 +7,13 @@ import React, { useState, useEffect } from "react";
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import ThemeToggle from './components/ThemeToggle';
+import SoundToggle from './components/SoundToggle';
import UsernameModal from './components/UsernameModal';
import DonateQRModal from './components/DonateQRModal';
import LeaderboardModal from './components/LeaderboardModal';
import RegionPicker from './components/RegionPicker';
import { generateRandomUsername, getUsername, setUsername } from '../lib/username';
+import { playSound } from '../lib/audio';
import { SCORE_BANDS, formatDistance } from '../lib/game';
const STEP_LABELS = [
@@ -87,6 +89,7 @@ export default function Home() {
// Returns true when the click is intercepted: no saved name yet, so the
// prompt opens and navigation resumes after save/skip.
const handlePlayClick = (href) => {
+ playSound('click');
if (getUsername()) return false;
setPendingHref(href);
setShowUsernameModal(true);
@@ -104,6 +107,7 @@ export default function Home() {
+
{/* One chip whether or not a name exists: the only way to fix a
typo'd name is reopening this modal, so the entry point must
always be visible -- including on phones, where it truncates
diff --git a/src/lib/audio.js b/src/lib/audio.js
new file mode 100644
index 0000000..72656cd
--- /dev/null
+++ b/src/lib/audio.js
@@ -0,0 +1,280 @@
+// Game sound: one AudioContext, one buffer cache, two preferences.
+//
+// Client-only. Nothing here may be imported from a route handler or from the
+// server-side pano modules -- it reaches for window, document and Web Audio.
+//
+// Playback never throws into a caller. A missing file, a decode failure or a
+// browser without Web Audio all mean the same thing to the game: silence.
+
+export const MUSIC_STORAGE_KEY = 'vngeoguessr_music';
+export const SFX_STORAGE_KEY = 'vngeoguessr_sfx';
+
+// Effects sit forward; the music is a bed under a panorama the player is
+// reading, so it stays well below them.
+export const SFX_VOLUME = 0.4;
+export const MUSIC_VOLUME = 0.15;
+
+// One file per sound. Nine of them decode once and stay in the cache, which is
+// why there is no sprite sheet -- sprites buy a latency win this count does
+// not need, at the cost of an offset table to keep in step with the assets.
+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',
+};
+
+let context = null;
+// url -> AudioBuffer, or the in-flight promise for one. Caching the promise is
+// what stops two simultaneous plays of the same sound decoding it twice.
+const buffers = new Map();
+const unlockListeners = new Set();
+const musicListeners = new Set();
+const sfxListeners = new Set();
+
+// The live answer to "should this play", held in memory and only mirrored into
+// localStorage. Storage is a persistence hint, not the source of truth: it
+// throws on both read and write in a private window, and a mute read back out
+// of a store that refused the write is a mute that never happens.
+// theme.js can read on every call because it drives a class name the browser
+// then owns; here the value gates playback on every single sound.
+let musicEnabled = null;
+let sfxEnabled = null;
+
+/**
+ * Read a stored on/off flag.
+ * @param {string} key Storage key.
+ * @returns {boolean} True unless the stored value is exactly 'off'.
+ */
+function readEnabled(key) {
+ if (typeof window === 'undefined') return true;
+ try {
+ return localStorage.getItem(key) !== 'off';
+ } catch {
+ // Private browsing and blocked site data both throw here; sound on is the
+ // right answer when the choice cannot be read.
+ return true;
+ }
+}
+
+/**
+ * Mirror an on/off flag into storage. Best effort by design.
+ * @param {string} key Storage key.
+ * @param {boolean} enabled Whether the sound should play.
+ * @returns {void}
+ */
+function writeEnabled(key, enabled) {
+ if (typeof window === 'undefined') return;
+ try {
+ localStorage.setItem(key, enabled ? 'on' : 'off');
+ } catch {
+ // The choice will not survive a reload, but it still applies to this
+ // session -- the in-memory flag above is what playback actually reads.
+ }
+}
+
+/**
+ * Whether background music should play.
+ * @returns {boolean} True when music is enabled.
+ */
+export function getStoredMusicEnabled() {
+ if (musicEnabled === null) musicEnabled = readEnabled(MUSIC_STORAGE_KEY);
+ return musicEnabled;
+}
+
+/**
+ * Set the music choice, persist it if the browser allows, and tell the player.
+ * @param {boolean} enabled Whether music should play.
+ * @returns {void}
+ */
+export function setStoredMusicEnabled(enabled) {
+ musicEnabled = enabled;
+ writeEnabled(MUSIC_STORAGE_KEY, enabled);
+ for (const listener of musicListeners) listener(enabled);
+}
+
+/**
+ * Whether sound effects should play.
+ * @returns {boolean} True when effects are enabled.
+ */
+export function getStoredSfxEnabled() {
+ if (sfxEnabled === null) sfxEnabled = readEnabled(SFX_STORAGE_KEY);
+ return sfxEnabled;
+}
+
+/**
+ * Set the sound-effects choice, persist it if the browser allows, and tell
+ * every mounted control.
+ * @param {boolean} enabled Whether effects should play.
+ * @returns {void}
+ */
+export function setStoredSfxEnabled(enabled) {
+ sfxEnabled = enabled;
+ writeEnabled(SFX_STORAGE_KEY, enabled);
+ for (const listener of sfxListeners) listener(enabled);
+}
+
+/**
+ * Watch the music preference. The toggle lives in the header and the player
+ * lives in the root layout, so they need a channel that is not React state --
+ * and the game header mounts two toggles at once, only one of them visible.
+ * @param {Function} onChange Called with the new value on every change.
+ * @returns {Function} Unsubscribe.
+ */
+export function watchMusicPreference(onChange) {
+ musicListeners.add(onChange);
+ return () => musicListeners.delete(onChange);
+}
+
+/**
+ * Watch the sound-effects preference.
+ * @param {Function} onChange Called with the new value on every change.
+ * @returns {Function} Unsubscribe.
+ */
+export function watchSfxPreference(onChange) {
+ sfxListeners.add(onChange);
+ return () => sfxListeners.delete(onChange);
+}
+
+/**
+ * Watch for the audio context opening. The music player mounts before the
+ * first gesture, so it cannot start the loop itself -- it waits for this.
+ * @param {Function} onUnlock Called once the context is running.
+ * @returns {Function} Unsubscribe.
+ */
+export function watchUnlock(onUnlock) {
+ unlockListeners.add(onUnlock);
+ return () => unlockListeners.delete(onUnlock);
+}
+
+/**
+ * Whether audio can play yet.
+ * @returns {boolean} True once a user gesture has opened the context.
+ */
+export function isUnlocked() {
+ return context !== null;
+}
+
+/**
+ * The shared audio context, or null before the first gesture.
+ * @returns {AudioContext|null} The context.
+ */
+export function getAudioContext() {
+ return context;
+}
+
+/**
+ * Open the audio context, or resume one the browser has suspended. Every
+ * browser creates it suspended until a user gesture, so the first call must
+ * come from inside a real event handler. Idempotent and cheap: the listeners
+ * below call it on every gesture for the life of the page.
+ * @returns {void}
+ */
+export function unlockAudio() {
+ if (typeof window === 'undefined') return;
+
+ if (!context) {
+ const Ctor = window.AudioContext || window.webkitAudioContext;
+ // A browser with no Web Audio leaves the game silent rather than broken.
+ if (!Ctor) return;
+ try {
+ context = new Ctor();
+ } catch {
+ // Constructing one can throw where audio is disabled outright. This runs
+ // inside a capture-phase document listener, where an escaping error
+ // would surface as an uncaught page error.
+ return;
+ }
+ for (const listener of unlockListeners) listener();
+ }
+
+ // Backgrounding a tab suspends the context on mobile, and it does not come
+ // back on its own -- without this, locking the phone mid-round would leave
+ // the game silent for the rest of the session.
+ if (context.state === 'suspended') {
+ // Safari rejects this when it is not called from a gesture.
+ Promise.resolve(context.resume()).catch(() => {});
+ }
+}
+
+/**
+ * Fetch and decode an audio file once, then serve it from memory.
+ * @param {string} url Path under /public.
+ * @returns {Promise
} The decoded buffer.
+ */
+export function loadAudioBuffer(url) {
+ const cached = buffers.get(url);
+ if (cached) return cached;
+
+ const pending = fetch(url)
+ .then((response) => {
+ if (!response.ok) throw new Error(`audio ${response.status} for ${url}`);
+ return response.arrayBuffer();
+ })
+ .then((data) => context.decodeAudioData(data))
+ .catch((error) => {
+ // Drop the rejected promise, or one bad response would keep this sound
+ // broken for the rest of the session.
+ buffers.delete(url);
+ throw error;
+ });
+
+ buffers.set(url, pending);
+ return pending;
+}
+
+/**
+ * Play a one-shot. Fire and forget: callers on the game path must never await
+ * this, and it resolves whether or not a sound was actually heard.
+ * @param {string} name Key of SOUNDS.
+ * @param {number} volume Gain, 0 to 1.
+ * @returns {Promise} Resolves once the sound has been started, or given up on.
+ */
+export async function playSound(name, volume = SFX_VOLUME) {
+ const url = SOUNDS[name];
+ if (!url || !context || !getStoredSfxEnabled()) return;
+
+ try {
+ const buffer = await loadAudioBuffer(url);
+ // The decode can outlive the choice that allowed it.
+ if (!getStoredSfxEnabled()) return;
+
+ const source = context.createBufferSource();
+ source.buffer = buffer;
+ const gain = context.createGain();
+ gain.gain.value = volume;
+ source.connect(gain).connect(context.destination);
+ // Nothing else releases these: a long session would otherwise leave one
+ // dangling node pair on the destination per sound played.
+ source.onended = () => {
+ source.disconnect();
+ gain.disconnect();
+ };
+ source.start();
+ } catch {
+ // A missing or undecodable file must not break gameplay.
+ }
+}
+
+// Unlock on the first gesture anywhere in the document, and keep listening.
+// Music is on by default and the first thing a player touches might be the
+// name prompt, the region picker or Play, so no single handler can own the
+// opening. The listeners stay registered afterwards because unlockAudio is
+// also how a context the browser suspended gets resumed -- removing them after
+// the first gesture is what silenced the game for the rest of a session once
+// the phone had been locked. Capture phase, so a handler that stops
+// propagation cannot swallow it.
+if (typeof document !== 'undefined') {
+ document.addEventListener('pointerdown', unlockAudio, true);
+ document.addEventListener('keydown', unlockAudio, true);
+ // Coming back to a backgrounded tab is not a gesture, but Chrome resumes on
+ // it and Safari at least stops refusing.
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible' && context) unlockAudio();
+ });
+}
diff --git a/tests/audio.test.js b/tests/audio.test.js
new file mode 100644
index 0000000..002cd4f
--- /dev/null
+++ b/tests/audio.test.js
@@ -0,0 +1,379 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+// The suite runs in node, so lib/audio.js sees no document at import time and
+// registers no gesture listener -- which is itself part of the client-safety
+// rule and is asserted below.
+//
+// lib/audio.js holds each preference in a module-level variable once resolved,
+// because storage is unreadable in a private window and playback cannot depend
+// on reading it back. That makes the module stateful, so every case loads a
+// fresh copy of it rather than sharing one across tests.
+
+// A localStorage stand-in whose reads and writes can be made to throw, which
+// is what a private window or blocked site data actually does.
+function fakeStorage(failing = null) {
+ const store = new Map();
+ return {
+ getItem(key) {
+ if (failing === 'read' || failing === 'both') throw new Error('blocked');
+ return store.has(key) ? store.get(key) : null;
+ },
+ setItem(key, value) {
+ if (failing === 'write' || failing === 'both') throw new Error('blocked');
+ store.set(key, String(value));
+ },
+ };
+}
+
+// Enough of the Web Audio API to drive our own control flow: the cache, the
+// eviction on failure, the preference gates and the node wiring. The browser's
+// implementation is not under test, but the code around it is, and none of it
+// runs without something to stand in for a context.
+function fakeAudio() {
+ const started = [];
+ const disconnected = [];
+
+ class FakeContext {
+ constructor() {
+ this.state = 'suspended';
+ this.destination = { name: 'destination' };
+ this.resumes = 0;
+ }
+
+ resume() {
+ this.resumes += 1;
+ this.state = 'running';
+ return Promise.resolve();
+ }
+
+ decodeAudioData() {
+ return Promise.resolve({ duration: 1 });
+ }
+
+ createBufferSource() {
+ const source = {
+ buffer: null,
+ loop: false,
+ onended: null,
+ connect: (next) => next,
+ disconnect: () => disconnected.push('source'),
+ start: () => started.push(source),
+ };
+ return source;
+ }
+
+ createGain() {
+ return {
+ gain: { value: null },
+ connect: (next) => next,
+ disconnect: () => disconnected.push('gain'),
+ };
+ }
+ }
+
+ return { FakeContext, started, disconnected };
+}
+
+function okResponse() {
+ return { ok: true, arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)) };
+}
+
+/**
+ * Load a fresh copy of lib/audio.js against a given browser environment.
+ * @param {Object} storage localStorage stand-in.
+ * @param {Function|null} AudioContextCtor Constructor to expose on window, or null.
+ * @returns {Promise} The module's exports.
+ */
+async function loadAudio(storage, AudioContextCtor = null) {
+ vi.resetModules();
+ globalThis.window = AudioContextCtor ? { AudioContext: AudioContextCtor } : {};
+ globalThis.localStorage = storage;
+ return import('../src/lib/audio.js');
+}
+
+afterEach(() => {
+ delete globalThis.window;
+ delete globalThis.localStorage;
+ vi.unstubAllGlobals();
+});
+
+describe('sound preferences', () => {
+ it('defaults both to on when nothing is stored', async () => {
+ const audio = await loadAudio(fakeStorage());
+ expect(audio.getStoredMusicEnabled()).toBe(true);
+ expect(audio.getStoredSfxEnabled()).toBe(true);
+ });
+
+ it('round-trips a choice through storage', async () => {
+ const audio = await loadAudio(fakeStorage());
+
+ audio.setStoredMusicEnabled(false);
+ audio.setStoredSfxEnabled(false);
+ expect(audio.getStoredMusicEnabled()).toBe(false);
+ expect(audio.getStoredSfxEnabled()).toBe(false);
+
+ audio.setStoredMusicEnabled(true);
+ audio.setStoredSfxEnabled(true);
+ expect(audio.getStoredMusicEnabled()).toBe(true);
+ expect(audio.getStoredSfxEnabled()).toBe(true);
+ });
+
+ it('keeps the two choices independent', async () => {
+ const audio = await loadAudio(fakeStorage());
+ audio.setStoredMusicEnabled(false);
+ expect(audio.getStoredSfxEnabled()).toBe(true);
+ });
+
+ it('stores the exact values a later page load reads back', async () => {
+ const storage = fakeStorage();
+ const audio = await loadAudio(storage);
+
+ audio.setStoredMusicEnabled(false);
+ audio.setStoredSfxEnabled(true);
+ expect(storage.getItem(audio.MUSIC_STORAGE_KEY)).toBe('off');
+ expect(storage.getItem(audio.SFX_STORAGE_KEY)).toBe('on');
+ });
+
+ it('treats any value other than off as on', async () => {
+ const storage = fakeStorage();
+ storage.setItem('vngeoguessr_music', 'garbage');
+ const audio = await loadAudio(storage);
+ expect(audio.getStoredMusicEnabled()).toBe(true);
+ });
+
+ it('falls back to on when reading throws', async () => {
+ const audio = await loadAudio(fakeStorage('read'));
+ expect(audio.getStoredMusicEnabled()).toBe(true);
+ expect(audio.getStoredSfxEnabled()).toBe(true);
+ });
+
+ it('reports on when there is no window at all', async () => {
+ vi.resetModules();
+ delete globalThis.window;
+ delete globalThis.localStorage;
+ const audio = await import('../src/lib/audio.js');
+ expect(audio.getStoredMusicEnabled()).toBe(true);
+ expect(audio.getStoredSfxEnabled()).toBe(true);
+ });
+
+ // The whole reason the module holds these in memory: a private window
+ // refuses the write, and reading the flag back would report the mute never
+ // happened, leaving the player with no way to silence the game.
+ it('honours a choice this session even when the write is refused', async () => {
+ const audio = await loadAudio(fakeStorage('both'));
+
+ expect(() => audio.setStoredMusicEnabled(false)).not.toThrow();
+ expect(() => audio.setStoredSfxEnabled(false)).not.toThrow();
+ expect(audio.getStoredMusicEnabled()).toBe(false);
+ expect(audio.getStoredSfxEnabled()).toBe(false);
+ });
+});
+
+describe('preference subscriptions', () => {
+ it('notifies music and effects watchers with the new value', async () => {
+ const audio = await loadAudio(fakeStorage());
+ const music = [];
+ const sfx = [];
+ const unwatchMusic = audio.watchMusicPreference((on) => music.push(on));
+ const unwatchSfx = audio.watchSfxPreference((on) => sfx.push(on));
+
+ audio.setStoredMusicEnabled(false);
+ audio.setStoredSfxEnabled(false);
+ audio.setStoredMusicEnabled(true);
+ expect(music).toEqual([false, true]);
+ expect(sfx).toEqual([false]);
+
+ unwatchMusic();
+ unwatchSfx();
+ audio.setStoredMusicEnabled(false);
+ audio.setStoredSfxEnabled(true);
+ expect(music).toEqual([false, true]);
+ expect(sfx).toEqual([false]);
+ });
+
+ // Two SoundToggles are mounted at once in the game header, one of them
+ // display:none. Both must hear about a change the other made.
+ it('notifies every watcher, not just the first', async () => {
+ const audio = await loadAudio(fakeStorage());
+ const seen = [];
+ audio.watchSfxPreference(() => seen.push('a'));
+ audio.watchSfxPreference(() => seen.push('b'));
+
+ audio.setStoredSfxEnabled(false);
+ expect(seen).toEqual(['a', 'b']);
+ });
+
+ it('notifies even when the write is refused', async () => {
+ const audio = await loadAudio(fakeStorage('write'));
+ const seen = [];
+ audio.watchMusicPreference((on) => seen.push(on));
+
+ audio.setStoredMusicEnabled(false);
+ expect(seen).toEqual([false]);
+ });
+});
+
+describe('unlockAudio', () => {
+ it('stays locked and requests nothing before a gesture', async () => {
+ const audio = await loadAudio(fakeStorage(), fakeAudio().FakeContext);
+ const fetchSpy = vi.fn();
+ vi.stubGlobal('fetch', fetchSpy);
+
+ expect(audio.isUnlocked()).toBe(false);
+ await audio.playSound('click');
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it('opens the context once and tells its watchers', async () => {
+ const { FakeContext } = fakeAudio();
+ const audio = await loadAudio(fakeStorage(), FakeContext);
+ let opened = 0;
+ audio.watchUnlock(() => { opened += 1; });
+
+ audio.unlockAudio();
+ const first = audio.getAudioContext();
+ audio.unlockAudio();
+
+ expect(audio.isUnlocked()).toBe(true);
+ expect(audio.getAudioContext()).toBe(first);
+ expect(opened).toBe(1);
+ });
+
+ // A backgrounded tab suspends the context on mobile and it does not come
+ // back on its own, so every later gesture has to try again.
+ it('resumes a context the browser suspended', async () => {
+ const { FakeContext } = fakeAudio();
+ const audio = await loadAudio(fakeStorage(), FakeContext);
+
+ audio.unlockAudio();
+ const context = audio.getAudioContext();
+ expect(context.resumes).toBe(1);
+
+ context.state = 'suspended';
+ audio.unlockAudio();
+ expect(context.resumes).toBe(2);
+ });
+
+ it('stays silent rather than throwing when the context cannot be built', async () => {
+ class Hostile {
+ constructor() {
+ throw new Error('audio disabled');
+ }
+ }
+ const audio = await loadAudio(fakeStorage(), Hostile);
+
+ expect(() => audio.unlockAudio()).not.toThrow();
+ expect(audio.isUnlocked()).toBe(false);
+ });
+
+ it('stays silent on a browser with no Web Audio', async () => {
+ const audio = await loadAudio(fakeStorage(), null);
+ expect(() => audio.unlockAudio()).not.toThrow();
+ expect(audio.isUnlocked()).toBe(false);
+ });
+});
+
+describe('playSound', () => {
+ it('fetches and decodes a sound once, however often it plays', async () => {
+ const { FakeContext, started } = fakeAudio();
+ const audio = await loadAudio(fakeStorage(), FakeContext);
+ const fetchSpy = vi.fn(() => Promise.resolve(okResponse()));
+ vi.stubGlobal('fetch', fetchSpy);
+ audio.unlockAudio();
+
+ await audio.playSound('pin');
+ await audio.playSound('pin');
+
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ expect(fetchSpy.mock.calls[0][0]).toBe('/audio/pin.mp3');
+ expect(started).toHaveLength(2);
+ });
+
+ it('releases its nodes when the sound ends', async () => {
+ const { FakeContext, started, disconnected } = fakeAudio();
+ const audio = await loadAudio(fakeStorage(), FakeContext);
+ vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(okResponse())));
+ audio.unlockAudio();
+
+ await audio.playSound('click');
+ started[0].onended();
+ expect(disconnected).toEqual(['source', 'gain']);
+ });
+
+ // A cached rejection would keep one sound broken for the whole session.
+ it('retries a sound whose first fetch failed', async () => {
+ const { FakeContext, started } = fakeAudio();
+ const audio = await loadAudio(fakeStorage(), FakeContext);
+ const fetchSpy = vi
+ .fn()
+ .mockResolvedValueOnce({ ok: false, status: 404 })
+ .mockResolvedValue(okResponse());
+ vi.stubGlobal('fetch', fetchSpy);
+ audio.unlockAudio();
+
+ await audio.playSound('error');
+ expect(started).toHaveLength(0);
+
+ await audio.playSound('error');
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
+ expect(started).toHaveLength(1);
+ });
+
+ it('swallows a fetch that rejects outright', async () => {
+ const { FakeContext } = fakeAudio();
+ const audio = await loadAudio(fakeStorage(), FakeContext);
+ vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('offline'))));
+ audio.unlockAudio();
+
+ await expect(audio.playSound('next')).resolves.toBeUndefined();
+ });
+
+ it('plays nothing while effects are muted', async () => {
+ const { FakeContext, started } = fakeAudio();
+ const audio = await loadAudio(fakeStorage(), FakeContext);
+ const fetchSpy = vi.fn(() => Promise.resolve(okResponse()));
+ vi.stubGlobal('fetch', fetchSpy);
+ audio.unlockAudio();
+ audio.setStoredSfxEnabled(false);
+
+ await audio.playSound('submit');
+ expect(fetchSpy).not.toHaveBeenCalled();
+ expect(started).toHaveLength(0);
+ });
+
+ // The regression that finding #1 described: with storage refusing writes, a
+ // mute used to be unreadable and every sound kept playing.
+ it('stays muted in a private window', async () => {
+ const { FakeContext, started } = fakeAudio();
+ const audio = await loadAudio(fakeStorage('both'), FakeContext);
+ vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(okResponse())));
+ audio.unlockAudio();
+ audio.setStoredSfxEnabled(false);
+
+ await audio.playSound('great');
+ expect(started).toHaveLength(0);
+ });
+
+ it('ignores a name that is not a sound, even once unlocked', async () => {
+ const { FakeContext, started } = fakeAudio();
+ const audio = await loadAudio(fakeStorage(), FakeContext);
+ const fetchSpy = vi.fn(() => Promise.resolve(okResponse()));
+ vi.stubGlobal('fetch', fetchSpy);
+ audio.unlockAudio();
+
+ await expect(audio.playSound('not-a-sound')).resolves.toBeUndefined();
+ expect(fetchSpy).not.toHaveBeenCalled();
+ expect(started).toHaveLength(0);
+ });
+
+ it('plays every sound the game wires up', async () => {
+ const { FakeContext, started } = fakeAudio();
+ const audio = await loadAudio(fakeStorage(), FakeContext);
+ vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(okResponse())));
+ audio.unlockAudio();
+
+ const names = ['click', 'pin', 'submit', 'great', 'good', 'poor', 'next', 'skip', 'error'];
+ for (const name of names) await audio.playSound(name);
+ expect(started).toHaveLength(names.length);
+ });
+});
diff --git a/tests/e2e/audio.spec.js b/tests/e2e/audio.spec.js
new file mode 100644
index 0000000..8df8610
--- /dev/null
+++ b/tests/e2e/audio.spec.js
@@ -0,0 +1,154 @@
+import { test, expect } from '@playwright/test';
+import { stubGameApis, seedUsername, seedHintSeen } from './helpers.js';
+
+// The audio layer, from the browser's side. What a headless run can honestly
+// prove is state and wiring: that nothing is fetched before a gesture, that
+// the toggles persist, and that the game header still fits a small phone with
+// the extra control in it. Whether the sounds are any good, whether the loop
+// seam is audible, and whether anything double-fires are questions for ears,
+// and the plan leaves those to a manual pass.
+
+const MUSIC_KEY = 'vngeoguessr_music';
+const SFX_KEY = 'vngeoguessr_sfx';
+
+test.beforeEach(async ({ page }) => {
+ await seedUsername(page, 'e2e-player');
+ await seedHintSeen(page);
+ await stubGameApis(page, 'e2e-player');
+});
+
+test('requests no audio before the first user gesture', async ({ page }) => {
+ const requested = [];
+ await page.route('**/audio/**', async (route) => {
+ requested.push(new URL(route.request().url()).pathname);
+ await route.fulfill({ status: 404, body: '' });
+ });
+
+ await page.goto('/');
+ await expect(page.getByRole('group', { name: 'Sound' }).first()).toBeVisible();
+ // Every browser blocks audio until a gesture, so fetching a file before one
+ // would be bandwidth spent on something that cannot play.
+ expect(requested).toEqual([]);
+});
+
+test('remembers music and sound-effect choices independently', async ({ page }) => {
+ await page.goto('/');
+ const music = page.getByRole('button', { name: 'Music' });
+ const sfx = page.getByRole('button', { name: 'Sound effects' });
+
+ // Both default on: a first-time player hears the game.
+ await expect(music).toHaveAttribute('aria-pressed', 'true');
+ await expect(sfx).toHaveAttribute('aria-pressed', 'true');
+
+ await music.click();
+ await expect(music).toHaveAttribute('aria-pressed', 'false');
+ await expect(sfx).toHaveAttribute('aria-pressed', 'true');
+
+ expect(await page.evaluate((key) => localStorage.getItem(key), MUSIC_KEY)).toBe('off');
+ // Never written, because it was never touched -- and an absent key reads as
+ // the default, which is on.
+ expect(await page.evaluate((key) => localStorage.getItem(key), SFX_KEY)).toBeNull();
+
+ await page.reload();
+ await expect(page.getByRole('button', { name: 'Music' })).toHaveAttribute('aria-pressed', 'false');
+ await expect(page.getByRole('button', { name: 'Sound effects' })).toHaveAttribute('aria-pressed', 'true');
+});
+
+test('carries the choice from the menu into a round', async ({ page }) => {
+ await page.goto('/');
+ await page.getByRole('button', { name: 'Sound effects' }).click();
+
+ await page.getByRole('link', { name: /Play anywhere in Vietnam/ }).click();
+ await expect(page).toHaveURL(/\/game\/vn$/);
+
+ // The game header shows the split pair at desktop widths.
+ await expect(page.getByRole('button', { name: 'Sound effects' })).toHaveAttribute('aria-pressed', 'false');
+ await expect(page.getByRole('button', { name: 'Music' })).toHaveAttribute('aria-pressed', 'true');
+});
+
+// The game header carries Back, the region badges, ThemeToggle's three cells
+// and the donate button before sound is added at all, which is why it collapses
+// to a single mute switch below `sm`.
+for (const width of [320, 360]) {
+ test(`game header fits a ${width}px viewport`, async ({ page }) => {
+ await page.setViewportSize({ width, height: 720 });
+ await page.goto('/game/tphcm');
+ await expect(page.getByText('Ho Chi Minh', { exact: true })).toBeVisible();
+
+ // One combined switch, not the pair: the pair is display:none here, which
+ // also keeps it out of the accessibility tree.
+ await expect(page.getByRole('button', { name: 'Sound', exact: true })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Music' })).toHaveCount(0);
+
+ const overflow = await page.evaluate(
+ () => document.documentElement.scrollWidth - document.documentElement.clientWidth
+ );
+ expect(overflow).toBe(0);
+ });
+}
+
+test('mutes everything from the compact switch on a phone', async ({ page }) => {
+ await page.setViewportSize({ width: 360, height: 720 });
+ await page.goto('/game/tphcm');
+ await expect(page.getByText('Ho Chi Minh', { exact: true })).toBeVisible();
+
+ const mute = page.getByRole('button', { name: 'Sound', exact: true });
+ await expect(mute).toHaveAttribute('aria-pressed', 'true');
+ await mute.click();
+
+ await expect(mute).toHaveAttribute('aria-pressed', 'false');
+ expect(await page.evaluate((key) => localStorage.getItem(key), MUSIC_KEY)).toBe('off');
+ expect(await page.evaluate((key) => localStorage.getItem(key), SFX_KEY)).toBe('off');
+});
+
+test('plays on with audio files missing', async ({ page }) => {
+ // A 404 on every sound must cost the player nothing but silence. The failure
+ // this guards against is an unhandled rejection out of fetch/decode, which
+ // `pageerror` does not report -- so the page records them itself.
+ await page.addInitScript(() => {
+ window.__rejections = [];
+ addEventListener('unhandledrejection', (event) => {
+ window.__rejections.push(String(event.reason));
+ });
+ });
+ await page.route('**/audio/**', (route) => route.fulfill({ status: 404, body: '' }));
+ const errors = [];
+ page.on('pageerror', (error) => errors.push(error.message));
+
+ await page.goto('/game/tphcm');
+ await expect(page.getByText('Ho Chi Minh', { exact: true })).toBeVisible();
+
+ // Effects stay ON here: muting them is what made an earlier version of this
+ // test exercise only the music path.
+ const pinRequest = page.waitForRequest((request) => request.url().includes('/audio/pin.mp3'));
+ const map = page.locator('.leaflet-container').first();
+ await map.waitFor();
+ await map.click({ position: { x: 200, y: 150 } });
+ await pinRequest;
+
+ // The round is still playable: the guess registered despite the dead sound.
+ await expect(page.getByRole('button', { name: 'Submit Guess' })).toBeEnabled();
+ expect(errors).toEqual([]);
+ expect(await page.evaluate(() => window.__rejections)).toEqual([]);
+});
+
+// Both header variants are mounted at once, one of them display:none, so each
+// has to hear about a change the other made -- otherwise rotating a phone past
+// the breakpoint revealed a control showing its mount-time state, and the first
+// press on it went the wrong way.
+test('keeps both header variants in step across the breakpoint', async ({ page }) => {
+ await page.setViewportSize({ width: 360, height: 720 });
+ await page.goto('/game/tphcm');
+ await expect(page.getByText('Ho Chi Minh', { exact: true })).toBeVisible();
+
+ await page.getByRole('button', { name: 'Sound', exact: true }).click();
+
+ await page.setViewportSize({ width: 900, height: 720 });
+ await expect(page.getByRole('button', { name: 'Music' })).toHaveAttribute('aria-pressed', 'false');
+ await expect(page.getByRole('button', { name: 'Sound effects' })).toHaveAttribute('aria-pressed', 'false');
+
+ // And back the other way: unmute on the pair, then check the compact switch.
+ await page.getByRole('button', { name: 'Music' }).click();
+ await page.setViewportSize({ width: 360, height: 720 });
+ await expect(page.getByRole('button', { name: 'Sound', exact: true })).toHaveAttribute('aria-pressed', 'true');
+});