anvilsign in

collin/mahjong · 51bd1b32

Sound on by default, and don't let a stale preference bury it

Collin Richards · 2026-08-07 09:25 UTC · 51bd1b32a04354194241950c143f586d520cee5d · parent 2f620211 · browse files

modifiedREADME.md+4 −2
⋯ 62 unchanged lines
6363 Snapshots live outside the saved state, so an undo does not survive a
6464 refresh: what you can take back is what happened while everyone was still
6565 watching it happen.
66-- **Sound** — off by default. A dry clack as a tile goes down, and a distinct
66+- **Sound** — on by default, muted from the 🔊 button in the middle of the
67+ table or from settings. A dry clack as a tile goes down, and a distinct
6768 two-note chime when a claim window opens, which is how a slow player notices
6869 their 碰 is available before the next player draws it shut. Everything is
6970 synthesised with a few oscillators (`src/game/sound.ts`) rather than sampled,
7071 so there is nothing to load and the cue lands on the same frame as the tile.
71- Toggle it from the 🔊 button next to 復原, or from the settings screen.
72+ Browsers refuse to start audio before the page has been clicked, so the first
73+ sound anyone hears is the deal — triggered by the button that starts it.
7274 - **報牌 (voice)** — a second switch under sound calls the game out loud: 碰,
7375 吃, 槓, 胡了, 自摸, and the name of every tile as it is discarded — 三條,
7476 五萬, 東風. A flower says 補花 and then which one. A new call cuts off one
⋯ 124 unchanged lines
addedsrc/game/prefs.test.ts+57 −0
1+import { beforeEach, describe, expect, it } from 'vitest';
2+import { DEFAULT_PREFS, loadPrefs, savePrefs } from './prefs';
3+
4+const KEY = 'taiwanese-mahjong/prefs';
5+
6+/** Just enough localStorage to exercise the load/save path under node. */
7+function stubStorage() {
8+ const map = new Map<string, string>();
9+ Object.defineProperty(globalThis, 'localStorage', {
10+ configurable: true,
11+ value: {
12+ getItem: (k: string) => map.get(k) ?? null,
13+ setItem: (k: string, v: string) => void map.set(k, v),
14+ removeItem: (k: string) => void map.delete(k),
15+ },
16+ });
17+ return map;
18+}
19+
20+describe('preferences', () => {
21+ let store: Map<string, string>;
22+ beforeEach(() => {
23+ store = stubStorage();
24+ });
25+
26+ it('round-trips what was saved', () => {
27+ const mine = { ...DEFAULT_PREFS, sound: false, voice: false, volume: 0.2, names: ['a', 'b', 'c', 'd'] };
28+ savePrefs(mine);
29+ expect(loadPrefs()).toEqual(mine);
30+ });
31+
32+ it('starts a stored blob from before the voice pack on the new audio defaults', () => {
33+ // What an older build wrote: no audioGeneration, and voice off.
34+ store.set(
35+ KEY,
36+ JSON.stringify({
37+ sound: false,
38+ voice: false,
39+ volume: 0.9,
40+ names: ['甲', '乙', '丙', '丁'],
41+ rules: { ...DEFAULT_PREFS.rules, base: 5 },
42+ }),
43+ );
44+ const p = loadPrefs();
45+ expect(p.sound).toBe(DEFAULT_PREFS.sound);
46+ expect(p.voice).toBe(DEFAULT_PREFS.voice);
47+ // ...but what somebody actually chose is theirs, and survives.
48+ expect(p.names).toEqual(['甲', '乙', '丙', '丁']);
49+ expect(p.rules.base).toBe(5);
50+ expect(p.volume).toBe(0.9);
51+ });
52+
53+ it('falls back to defaults on junk', () => {
54+ store.set(KEY, '{not json');
55+ expect(loadPrefs()).toEqual(DEFAULT_PREFS);
56+ });
57+});
modifiedsrc/game/prefs.ts+20 −4
⋯ 2 unchanged lines
33 const KEY = 'taiwanese-mahjong/prefs';
44
55 /**
6+ * Bump when a new switch should start from its default rather than from
7+ * whatever an older build happened to write. Preferences are saved on load, so
8+ * a switch that shipped off and was then turned on by default would otherwise
9+ * stay off forever for anyone who had already opened the game once.
10+ *
11+ * Only the audio settings are reset; names and house rules are things somebody
12+ * actually chose, and they carry across.
13+ */
14+const AUDIO_GENERATION = 2;
15+
16+/**
617 * Preferences belong to the table, not to a hand: they outlive a save, survive
718 * 開新局, and are what a fresh `Game` is built from.
819 */
920 export interface Prefs {
10- /** Master mute. Off by default — a shared screen should not surprise a room. */
21+ /** Which set of audio defaults this was last saved against. */
22+ audioGeneration: number;
23+ /** Master mute. */
1124 sound: boolean;
1225 /** 0..1 */
1326 volume: number;
⋯ 4 unchanged lines
1831 }
1932
2033 export const DEFAULT_PREFS: Prefs = {
21- sound: false,
34+ audioGeneration: AUDIO_GENERATION,
35+ sound: true,
2236 volume: 0.7,
2337 voice: true,
2438 names: DEFAULT_NAMES,
⋯ 5 unchanged lines
3044 const raw = localStorage.getItem(KEY);
3145 if (!raw) return DEFAULT_PREFS;
3246 const p = JSON.parse(raw) as Partial<Prefs>;
47+ const stale = p.audioGeneration !== AUDIO_GENERATION;
3348 return {
34- sound: typeof p.sound === 'boolean' ? p.sound : DEFAULT_PREFS.sound,
49+ audioGeneration: AUDIO_GENERATION,
50+ sound: !stale && typeof p.sound === 'boolean' ? p.sound : DEFAULT_PREFS.sound,
3551 volume: clamp(typeof p.volume === 'number' ? p.volume : DEFAULT_PREFS.volume, 0, 1),
36- voice: typeof p.voice === 'boolean' ? p.voice : DEFAULT_PREFS.voice,
52+ voice: !stale && typeof p.voice === 'boolean' ? p.voice : DEFAULT_PREFS.voice,
3753 names:
3854 Array.isArray(p.names) && p.names.length === 4 && p.names.every((n) => typeof n === 'string')
3955 ? p.names
⋯ 17 unchanged lines
modifiedsrc/game/sound.ts+42 −14
⋯ 68 unchanged lines
6969 };
7070
7171 const VOICE_DIR = `${import.meta.env.BASE_URL}voice/`;
72+const encoded = new Map<string, ArrayBuffer>();
7273 const clips = new Map<string, AudioBuffer>();
7374 let voiceOn = false;
74-let loading: Promise<void> | null = null;
75+let fetching: Promise<void> | null = null;
76+let decoding = false;
7577 let voiceGain: GainNode | null = null;
7678 let speaking: AudioBufferSourceNode[] = [];
7779
78-/** Fetch and decode the whole pack. Small enough (~300 kB) to do in one go. */
79-function loadVoicePack(): Promise<void> {
80- if (loading) return loading;
81- loading = (async () => {
82- const c = audio();
83- if (!c) return;
80+/**
81+ * Fetching needs no AudioContext, which matters: sound is on from the start,
82+ * but a browser will not let the audio hardware wake until the page has been
83+ * clicked. So the bytes come down straight away and are decoded later, on the
84+ * first gesture — by which time the whole pack (~330 kB) is already in hand.
85+ */
86+function fetchVoicePack(): Promise<void> {
87+ if (fetching) return fetching;
88+ fetching = (async () => {
8489 const names: string[] = await fetch(`${VOICE_DIR}manifest.json`).then((r) => r.json());
8590 await Promise.all(
8691 names.map(async (name) => {
8792 try {
88- const buf = await fetch(`${VOICE_DIR}${name}.mp3`).then((r) => r.arrayBuffer());
89- clips.set(name, await c.decodeAudioData(buf));
93+ encoded.set(name, await fetch(`${VOICE_DIR}${name}.mp3`).then((r) => r.arrayBuffer()));
9094 } catch {
9195 // One missing clip just means that one call stays silent.
9296 }
9397 }),
9498 );
9599 })().catch(() => {
96- loading = null; // let a later toggle try again
100+ fetching = null; // let a later toggle try again
97101 });
98- return loading;
102+ return fetching;
103+}
104+
105+/** Decode what was fetched. Runs once, the first time there is a live context. */
106+function decodeVoicePack(c: AudioContext) {
107+ if (decoding || clips.size || !encoded.size) return;
108+ decoding = true;
109+ for (const [name, bytes] of encoded) {
110+ c.decodeAudioData(bytes)
111+ .then((buf) => clips.set(name, buf))
112+ .catch(() => {});
113+ }
114+ encoded.clear(); // decodeAudioData takes ownership of the buffers
99115 }
100116
101117 export function setVoiceEnabled(on: boolean) {
102118 voiceOn = on;
103- if (on) void loadVoicePack();
119+ if (on) void fetchVoicePack();
104120 }
105121
106122 /**
⋯ 4 unchanged lines
111127 function say(...names: string[]) {
112128 const c = audio();
113129 if (!voiceOn || !enabled || !c || !master) return;
114- if (!clips.size) return void loadVoicePack();
130+ if (!clips.size) {
131+ // Nothing to say yet: either still coming down the wire, or waiting for
132+ // this very gesture to be allowed to decode.
133+ decodeVoicePack(c);
134+ void fetchVoicePack();
135+ return;
136+ }
115137
116138 for (const src of speaking) {
117139 try {
⋯ 26 unchanged lines
144166
145167 export function setSoundEnabled(on: boolean) {
146168 enabled = on;
147- if (on) audio(); // unlock now, while we are still inside the click that toggled it
169+ // Reach for the hardware only once the page has been interacted with —
170+ // sound is on from the start, and a context built before that is refused
171+ // anyway. When this runs from the click that toggled it, it unlocks there.
172+ if (on && navigator.userActivation?.hasBeenActive !== false) {
173+ const c = audio();
174+ if (c) decodeVoicePack(c);
175+ }
148176 }
149177
150178 export function setVolume(v: number) {
⋯ 130 unchanged lines