anvilsign in

collin/mahjong · 2f620211

Call the game out loud with a pre-rendered voice pack

Collin Richards · 2026-08-07 09:21 UTC · 2f6202111219072d88fe8d15f1760f80e6e62648 · parent 70c6c51e · browse files

modifiedREADME.md+36 −0
⋯ 68 unchanged lines
6969 synthesised with a few oscillators (`src/game/sound.ts`) rather than sampled,
7070 so there is nothing to load and the cue lands on the same frame as the tile.
7171 Toggle it from the 🔊 button next to 復原, or from the settings screen.
72+- **報牌 (voice)** — a second switch under sound calls the game out loud: 碰,
73+ 吃, 槓, 胡了, 自摸, and the name of every tile as it is discarded — 三條,
74+ 五萬, 東風. A flower says 補花 and then which one. A new call cuts off one
75+ still being spoken; at table pace the newest is the only one that matters.
76+ See [the voice pack](#the-voice-pack) for where the audio comes from.
7277 - **Settings** — 設定 from the lobby, or from the game-over panel: player names,
7378 底 / 台 / starting chips, the house-rule switches below, and sound. Kept in
7479 `localStorage` separately from the save, so they carry over to the next game.
⋯ 59 unchanged lines
134139 House rules vary a lot; the tai table and payments are plain data/functions in
135140 `tai.ts` and `types.ts` if yours differ.
136141
142+## The voice pack
143+
144+A mahjong table only ever says about fifty things — 42 tile names and a handful
145+of calls — so the whole vocabulary is rendered ahead of time into
146+`public/voice/` (~330 kB of mp3) rather than left to whatever speech synthesis
147+the browser happens to have. On Linux that is usually espeak-ng, which is
148+intelligible but sounds like a modem; and on a machine with no Chinese voice at
149+all the feature would silently do nothing. Shipping the audio makes playback
150+instant, identical everywhere, and lets a call be cut off mid-word when the
151+next one lands, all through the same Web Audio graph as the chimes.
152+
153+`scripts/voice.mjs` is the authoring step, not part of `npm run build`. It needs
154+[piper](https://github.com/OHF-Voice/piper1-gpl) and ffmpeg on PATH:
155+
156+```
157+node scripts/voice.mjs # → public/voice/*.mp3 + manifest.json
158+PIPER_MODEL=/path/to/voice.onnx node scripts/voice.mjs # a different voice
159+```
160+
161+The wording lives in that script, and some of it is deliberately not the bare
162+tile character: 東 alone is a direction where 東風 is the tile, and the dragons
163+are 紅中 / 發財 / 白板 the way they are actually called — which also gives the
164+phonemiser enough context to get the tone right, since 中 on its own is as
165+likely to come out zhòng.
166+
167+The clips here were rendered with piper's `zh_CN-huayan-medium`. If you
168+redistribute this app, check that voice's model card in
169+[piper-voices](https://huggingface.co/rhasspy/piper-voices) for the terms
170+attached to it, the same way you would the tile art below — or re-render the
171+pack with a voice whose terms suit you, which is a single command.
172+
137173 ## Tile art
138174
139175 `public/tiles/tiles.svg` is the **postmodern** tileset from
⋯ 23 unchanged lines
addedpublic/voice/chow.mp3binary
Binary file
addedpublic/voice/drawGame.mp3binary
Binary file
addedpublic/voice/flower.mp3binary
Binary file
addedpublic/voice/hu.mp3binary
Binary file
addedpublic/voice/kong.mp3binary
Binary file
addedpublic/voice/manifest.json+1 −0
1+["t0","t9","t18","t1","t10","t19","t2","t11","t20","t3","t12","t21","t4","t13","t22","t5","t14","t23","t6","t15","t24","t7","t16","t25","t8","t17","t26","t27","t28","t29","t30","t31","t32","t33","t34","t35","t36","t37","t38","t39","t40","t41","pung","chow","kong","hu","selfDraw","drawGame","flower"]
addedpublic/voice/pung.mp3binary
Binary file
addedpublic/voice/selfDraw.mp3binary
Binary file
addedpublic/voice/t0.mp3binary
Binary file
addedpublic/voice/t1.mp3binary
Binary file
addedpublic/voice/t10.mp3binary
Binary file
addedpublic/voice/t11.mp3binary
Binary file
addedpublic/voice/t12.mp3binary
Binary file
addedpublic/voice/t13.mp3binary
Binary file
addedpublic/voice/t14.mp3binary
Binary file
addedpublic/voice/t15.mp3binary
Binary file
addedpublic/voice/t16.mp3binary
Binary file
addedpublic/voice/t17.mp3binary
Binary file
addedpublic/voice/t18.mp3binary
Binary file
addedpublic/voice/t19.mp3binary
Binary file
addedpublic/voice/t2.mp3binary
Binary file
addedpublic/voice/t20.mp3binary
Binary file
addedpublic/voice/t21.mp3binary
Binary file
addedpublic/voice/t22.mp3binary
Binary file
addedpublic/voice/t23.mp3binary
Binary file
addedpublic/voice/t24.mp3binary
Binary file
addedpublic/voice/t25.mp3binary
Binary file
addedpublic/voice/t26.mp3binary
Binary file
addedpublic/voice/t27.mp3binary
Binary file
addedpublic/voice/t28.mp3binary
Binary file
addedpublic/voice/t29.mp3binary
Binary file
addedpublic/voice/t3.mp3binary
Binary file
addedpublic/voice/t30.mp3binary
Binary file
addedpublic/voice/t31.mp3binary
Binary file
addedpublic/voice/t32.mp3binary
Binary file
addedpublic/voice/t33.mp3binary
Binary file
addedpublic/voice/t34.mp3binary
Binary file
addedpublic/voice/t35.mp3binary
Binary file
addedpublic/voice/t36.mp3binary
Binary file
addedpublic/voice/t37.mp3binary
Binary file
addedpublic/voice/t38.mp3binary
Binary file
addedpublic/voice/t39.mp3binary
Binary file
addedpublic/voice/t4.mp3binary
Binary file
addedpublic/voice/t40.mp3binary
Binary file
addedpublic/voice/t41.mp3binary
Binary file
addedpublic/voice/t5.mp3binary
Binary file
addedpublic/voice/t6.mp3binary
Binary file
addedpublic/voice/t7.mp3binary
Binary file
addedpublic/voice/t8.mp3binary
Binary file
addedpublic/voice/t9.mp3binary
Binary file
addedscripts/voice.mjs+94 −0
1+#!/usr/bin/env node
2+/**
3+ * Renders the voice pack in `public/voice/`.
4+ *
5+ * A mahjong table only ever says about fifty things — the 42 tile names and a
6+ * handful of calls — so rather than depend on whatever speech synthesis a
7+ * browser happens to have installed (on Linux, usually espeak-ng, which sounds
8+ * like a modem), the whole vocabulary is rendered once, here, with a proper
9+ * neural voice and shipped as audio. Playback is then instant, identical on
10+ * every machine, and needs nothing but the Web Audio graph the chimes already
11+ * use.
12+ *
13+ * This is an authoring step, not part of `npm run build`. Re-run it only to
14+ * change the voice or the wording:
15+ *
16+ * piper --version # https://github.com/OHF-Voice/piper1-gpl
17+ * node scripts/voice.mjs # writes public/voice/*.mp3
18+ *
19+ * Set PIPER_MODEL to use a different voice.
20+ */
21+import { execFileSync } from 'node:child_process';
22+import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
23+import { homedir } from 'node:os';
24+import { join } from 'node:path';
25+
26+const MODEL =
27+ process.env.PIPER_MODEL ??
28+ join(homedir(), '.local/share/piper-voices/zh_CN/zh_CN-huayan-medium.onnx');
29+const OUT = 'public/voice';
30+const TMP = join(process.env.TMPDIR ?? '/tmp', 'mahjong-voice');
31+
32+const DIGITS = ['一', '二', '三', '四', '五', '六', '七', '八', '九'];
33+
34+/**
35+ * What each clip says. Tile clips are named by tile code so the runtime can
36+ * find them without knowing any of this.
37+ *
38+ * Several tiles are deliberately not read as the bare character: 東 alone is a
39+ * direction, 東風 is the tile, and the dragons are 紅中 / 發財 / 白板 the way
40+ * they are actually called. It also gives the phonemiser enough context to get
41+ * the tone right — 中 on its own is as likely to come out zhòng.
42+ */
43+const CLIPS = {};
44+for (let i = 0; i < 9; i++) {
45+ CLIPS[`t${i}`] = `${DIGITS[i]}萬`;
46+ CLIPS[`t${i + 9}`] = `${DIGITS[i]}條`;
47+ CLIPS[`t${i + 18}`] = `${DIGITS[i]}筒`;
48+}
49+['東風', '南風', '西風', '北風', '紅中', '發財', '白板'].forEach((w, i) => {
50+ CLIPS[`t${27 + i}`] = w;
51+});
52+['春', '夏', '秋', '冬', '梅', '蘭', '菊', '竹'].forEach((w, i) => {
53+ CLIPS[`t${34 + i}`] = w;
54+});
55+
56+// The calls, as they are shouted.
57+Object.assign(CLIPS, {
58+ pung: '碰',
59+ chow: '吃',
60+ kong: '槓',
61+ hu: '胡了',
62+ selfDraw: '自摸',
63+ drawGame: '流局',
64+ flower: '補花',
65+});
66+
67+mkdirSync(OUT, { recursive: true });
68+mkdirSync(TMP, { recursive: true });
69+
70+for (const [name, text] of Object.entries(CLIPS)) {
71+ const wav = join(TMP, `${name}.wav`);
72+ execFileSync('piper', ['-m', MODEL, '-f', wav], { input: text });
73+ // Piper pads both ends with silence; a call has to land on the beat, so trim
74+ // it off and normalise so no clip is noticeably louder than its neighbours.
75+ execFileSync(
76+ 'ffmpeg',
77+ [
78+ '-hide_banner', '-loglevel', 'error', '-y', '-i', wav,
79+ '-af',
80+ 'silenceremove=start_periods=1:start_threshold=-45dB:start_silence=0.02,' +
81+ 'areverse,silenceremove=start_periods=1:start_threshold=-45dB:start_silence=0.04,areverse,' +
82+ 'loudnorm=I=-18:TP=-2:LRA=7',
83+ '-ac', '1', '-ar', '22050', '-b:a', '48k',
84+ join(OUT, `${name}.mp3`),
85+ ],
86+ { stdio: ['ignore', 'ignore', 'inherit'] },
87+ );
88+ process.stdout.write(`${name} ${text}\n`);
89+}
90+
91+// Shipped alongside so the runtime knows what it can ask for without a 404.
92+writeFileSync(join(OUT, 'manifest.json'), `${JSON.stringify(Object.keys(CLIPS))}\n`);
93+rmSync(TMP, { recursive: true, force: true });
94+console.log(`\n${Object.keys(CLIPS).length} clips → ${OUT}`);
modifiedsrc/App.tsx+3 −1
⋯ 1 unchanged line
22 import { Game } from './game/engine';
33 import { loadPrefs, savePrefs, type Prefs } from './game/prefs';
44 import { loadSave } from './game/save';
5-import { play, setSoundEnabled, setVolume } from './game/sound';
5+import { play, setSoundEnabled, setVoiceEnabled, setVolume } from './game/sound';
66 import { WIND_EN, WIND_NAMES } from './game/tiles';
77 import type { SeatId } from './game/types';
88 import { Center } from './ui/Center';
⋯ 28 unchanged lines
3737 // put the audio back the way it was.
3838 const cancelSettings = () => {
3939 setSoundEnabled(prefs.sound);
40+ setVoiceEnabled(prefs.voice);
4041 setVolume(prefs.volume);
4142 setSettingsOpen(false);
4243 };
⋯ 10 unchanged lines
5354 useEffect(() => {
5455 savePrefs(prefs);
5556 setSoundEnabled(prefs.sound);
57+ setVoiceEnabled(prefs.voice);
5658 setVolume(prefs.volume);
5759 }, [prefs]);
5860 useEffect(() => game.onSound(play), [game]);
⋯ 73 unchanged lines
modifiedsrc/game/engine.test.ts+18 −0
⋯ 224 unchanged lines
225225 expect(s.turn).toBe(victim);
226226 });
227227
228+ it('names the tile on the cues that are spoken aloud', () => {
229+ const game = new Game();
230+ const cues: { kind: string; tile?: number }[] = [];
231+ game.onSound((c) => cues.push(c));
232+ game.startHand(7);
233+ const s = game.state;
234+ const victim = ((s.dealer + 2) % 4) as SeatId;
235+ const tile = s.players[s.dealer].hand[0];
236+ s.players[victim].hand = [tile, tile, ...s.players[victim].hand.slice(2)];
237+
238+ game.discard(s.dealer, tile);
239+ expect(cues).toContainEqual({ kind: 'discard', tile });
240+
241+ game.respond(victim, { type: 'pung' });
242+ game.resolveNow();
243+ expect(cues).toContainEqual({ kind: 'pung', tile });
244+ });
245+
228246 it('takes back a mis-tapped discard', () => {
229247 const game = new Game();
230248 game.startHand(11);
⋯ 290 unchanged lines
modifiedsrc/game/engine.ts+23 −13
⋯ 26 unchanged lines
2727 | 'deal'
2828 | 'discard'
2929 | 'claimWindow'
30- | 'claim'
30+ // The four calls, kept distinct because they are things a player shouts:
31+ // with voice turned on the UI says them out loud.
32+ | 'pung'
33+ | 'chow'
3134 | 'kong'
35+ | 'hu'
36+ | 'selfDraw'
3237 | 'flower'
33- | 'win'
3438 | 'drawGame'
3539 | 'undo';
3640
41+/** A cue and, where there is one, the tile it is about. */
42+export interface SoundCue {
43+ kind: SoundEvent;
44+ tile?: Tile;
45+}
46+
3747 export type ClaimType = 'hu' | 'kong' | 'pung' | 'chow';
3848
3949 export interface ClaimOption {
⋯ 155 unchanged lines
195205 this.emit();
196206 }
197207
198- private soundListeners = new Set<(e: SoundEvent) => void>();
208+ private soundListeners = new Set<(cue: SoundCue) => void>();
199209 /** Sound is a side channel, not state: it is never saved and never replayed. */
200- onSound = (fn: (e: SoundEvent) => void) => {
210+ onSound = (fn: (cue: SoundCue) => void) => {
201211 this.soundListeners.add(fn);
202212 return () => {
203213 this.soundListeners.delete(fn);
204214 };
205215 };
206- private sfx(e: SoundEvent) {
207- for (const l of this.soundListeners) l(e);
216+ private sfx(kind: SoundEvent, tile?: Tile) {
217+ for (const l of this.soundListeners) l({ kind, tile });
208218 }
209219
210220 /**
⋯ 169 unchanged lines
380390 if (isFlower(t)) {
381391 drawn = this.replaceFlowers(seat).last;
382392 this.say(`${s.players[seat].name} 補花 flower`);
383- this.sfx('flower');
393+ this.sfx('flower', t); // the flower itself, not the tile that replaced it
384394 }
385395 s.turn = seat;
386396 this.setDrawn(seat, drawn);
⋯ 63 unchanged lines
450460 s.lastDiscard = { tile, from: seat };
451461 s.responses = {};
452462 s.options = this.claimOptionsFor(tile, seat);
453- this.sfx('discard');
463+ this.sfx('discard', tile);
454464
455465 if (Object.keys(s.options).length === 0) {
456466 this.afterDiscardResolved();
⋯ 169 unchanged lines
626636 // stricter ones make you wait for your own draw.
627637 if (s.rules.sacredClearedByClaim) s.sacred[seat] = [];
628638 this.say(`${p.name} ${claimName(claim.type)} ${labelOf(tile)}`);
629- this.sfx(claim.type === 'kong' ? 'kong' : 'claim');
639+ this.sfx(claim.type, tile);
630640
631641 s.lastDiscard = null;
632642 s.options = {};
⋯ 35 unchanged lines
668678 for (let k = 0; k < 4; k++) p.hand.splice(p.hand.indexOf(tile), 1);
669679 p.melds.push({ kind: 'ankong', tiles: [tile, tile, tile, tile] });
670680 this.say(`${p.name} 暗槓 concealed kong`);
671- this.sfx('kong');
681+ this.sfx('kong', tile);
672682 this.kongReplacement(seat);
673683 this.emit();
674684 }
⋯ 24 unchanged lines
699709 s.responses = {};
700710 s.phase = 'robkong';
701711 this.say(`${p.name} 加槓 ${labelOf(tile)} — 可搶槓 robbable`);
702- this.sfx('kong');
712+ this.sfx('kong', tile);
703713 this.sfx('claimWindow');
704714 } else {
705715 this.say(`${p.name} 加槓 added kong`);
706- this.sfx('kong');
716+ this.sfx('kong', tile);
707717 this.finishAddKong();
708718 }
709719 this.emit();
⋯ 139 unchanged lines
849859 this.say(`${s.players[r.seat].name} 胡牌 wins ${r.tai} 台`);
850860 if (r.liable !== null) this.say(`${s.players[r.liable].name} 包牌 pays it all`);
851861 }
852- this.sfx('win');
862+ this.sfx(results.some((r) => r.selfDraw) ? 'selfDraw' : 'hu', results[0].winTile);
853863 this.emit();
854864 }
855865
⋯ 73 unchanged lines
modifiedsrc/game/prefs.ts+4 −0
⋯ 10 unchanged lines
1111 sound: boolean;
1212 /** 0..1 */
1313 volume: number;
14+ /** 報牌 — say the calls and the discarded tiles out loud. Needs `sound`. */
15+ voice: boolean;
1416 names: string[];
1517 rules: Rules;
1618 }
⋯ 1 unchanged line
1820 export const DEFAULT_PREFS: Prefs = {
1921 sound: false,
2022 volume: 0.7,
23+ voice: true,
2124 names: DEFAULT_NAMES,
2225 rules: DEFAULT_RULES,
2326 };
⋯ 6 unchanged lines
3033 return {
3134 sound: typeof p.sound === 'boolean' ? p.sound : DEFAULT_PREFS.sound,
3235 volume: clamp(typeof p.volume === 'number' ? p.volume : DEFAULT_PREFS.volume, 0, 1),
36+ voice: typeof p.voice === 'boolean' ? p.voice : DEFAULT_PREFS.voice,
3337 names:
3438 Array.isArray(p.names) && p.names.length === 4 && p.names.every((n) => typeof n === 'string')
3539 ? p.names
⋯ 17 unchanged lines
modifiedsrc/game/sound.ts+118 −6
1-import type { SoundEvent } from './engine';
1+import type { SoundCue, SoundEvent } from './engine';
22
33 /**
44 * Table noise, synthesised rather than sampled — a handful of oscillators is
⋯ 40 unchanged lines
4545 return noise;
4646 }
4747
48+// ---- voice ---------------------------------------------------------------
49+/**
50+ * 報牌 — the calls said out loud, the way they are shouted at a table: 碰, 吃,
51+ * 槓, 胡了, and the name of every tile as it is discarded (三條, 五萬, 東風…).
52+ *
53+ * A table only ever says about fifty things, so the whole vocabulary is
54+ * rendered ahead of time by `scripts/voice.mjs` into `public/voice/` and
55+ * shipped as audio, rather than left to whatever speech synthesis the browser
56+ * has lying around — which on Linux is usually espeak-ng, and sounds like it.
57+ * Playback then goes through the same Web Audio graph as the chimes: instant,
58+ * identical everywhere, and it can be cut off mid-word when the next call
59+ * lands.
60+ */
61+const CALL_CLIP: Partial<Record<SoundEvent, string>> = {
62+ pung: 'pung',
63+ chow: 'chow',
64+ kong: 'kong',
65+ hu: 'hu',
66+ selfDraw: 'selfDraw',
67+ drawGame: 'drawGame',
68+ flower: 'flower',
69+};
70+
71+const VOICE_DIR = `${import.meta.env.BASE_URL}voice/`;
72+const clips = new Map<string, AudioBuffer>();
73+let voiceOn = false;
74+let loading: Promise<void> | null = null;
75+let voiceGain: GainNode | null = null;
76+let speaking: AudioBufferSourceNode[] = [];
77+
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;
84+ const names: string[] = await fetch(`${VOICE_DIR}manifest.json`).then((r) => r.json());
85+ await Promise.all(
86+ names.map(async (name) => {
87+ try {
88+ const buf = await fetch(`${VOICE_DIR}${name}.mp3`).then((r) => r.arrayBuffer());
89+ clips.set(name, await c.decodeAudioData(buf));
90+ } catch {
91+ // One missing clip just means that one call stays silent.
92+ }
93+ }),
94+ );
95+ })().catch(() => {
96+ loading = null; // let a later toggle try again
97+ });
98+ return loading;
99+}
100+
101+export function setVoiceEnabled(on: boolean) {
102+ voiceOn = on;
103+ if (on) void loadVoicePack();
104+}
105+
106+/**
107+ * Speak one or more clips back to back. Calls land on top of each other at
108+ * table pace, so anything still talking is cut off: the newest call is the
109+ * only one that matters.
110+ */
111+function say(...names: string[]) {
112+ const c = audio();
113+ if (!voiceOn || !enabled || !c || !master) return;
114+ if (!clips.size) return void loadVoicePack();
115+
116+ for (const src of speaking) {
117+ try {
118+ src.stop();
119+ } catch {
120+ // already finished
121+ }
122+ }
123+ speaking = [];
124+
125+ if (!voiceGain) {
126+ // A little above the chimes: the chime is a nudge, the call is the message.
127+ voiceGain = c.createGain();
128+ voiceGain.gain.value = 1.4;
129+ voiceGain.connect(master);
130+ }
131+
132+ let at = c.currentTime + 0.02;
133+ for (const name of names) {
134+ const buf = clips.get(name);
135+ if (!buf) continue;
136+ const src = c.createBufferSource();
137+ src.buffer = buf;
138+ src.connect(voiceGain);
139+ src.start(at);
140+ speaking.push(src);
141+ at += buf.duration + 0.05;
142+ }
143+}
144+
48145 export function setSoundEnabled(on: boolean) {
49146 enabled = on;
50147 if (on) audio(); // unlock now, while we are still inside the click that toggled it
⋯ 61 unchanged lines
112209 * Each cue has its own shape so it is identifiable without looking up — the
113210 * whole reason for the feature is a player noticing across the table.
114211 */
115-export function play(e: SoundEvent) {
212+export function play(cue: SoundCue | SoundEvent) {
213+ const { kind, tile } = typeof cue === 'string' ? { kind: cue, tile: undefined } : cue;
116214 if (!enabled) return;
215+
216+ // A discard is announced by name; a claim by what was called; a flower by
217+ // both, since "春" on its own tells you nothing about why you heard it.
218+ if (kind === 'discard') {
219+ if (tile !== undefined) say(`t${tile}`);
220+ } else if (kind === 'flower' && tile !== undefined) {
221+ say('flower', `t${tile}`);
222+ } else if (CALL_CLIP[kind]) {
223+ say(CALL_CLIP[kind]);
224+ }
225+
117226 const c = audio();
118227 if (!c || !master) return;
119228
120- switch (e) {
229+ switch (kind) {
121230 // A tile laid down. Dry and short so it never masks the chime after it.
122231 case 'discard':
123232 clack(c, 0);
⋯ 4 unchanged lines
128237 bell(c, 784, 0.06, 1, 0.4); // G5
129238 bell(c, 1047, 0.19, 0.9, 0.55); // C6
130239 break;
131- case 'claim':
240+ // 碰 / 吃 — the tile going down alongside the pair it joins.
241+ case 'pung':
242+ case 'chow':
132243 clack(c, 0, 0.8);
133244 clack(c, 0.055, 0.9);
134- bell(c, 660, 0.02, 0.5, 0.3);
245+ bell(c, kind === 'pung' ? 660 : 587, 0.02, 0.5, 0.3);
135246 break;
136247 // Four tiles going down, plus a replacement off the back of the wall.
137248 case 'kong':
⋯ 7 unchanged lines
145256 bell(c, 1319, 0, 0.45, 0.35);
146257 break;
147258 // 胡牌 — a rising figure, the only cue that takes its time.
148- case 'win':
259+ case 'hu':
260+ case 'selfDraw':
149261 bell(c, 523, 0, 1, 0.5);
150262 bell(c, 659, 0.11, 1, 0.5);
151263 bell(c, 784, 0.22, 1, 0.6);
⋯ 17 unchanged lines
modifiedsrc/ui/Settings.tsx+22 −1
11 import { useState } from 'react';
22 import { clamp, type Prefs } from '../game/prefs';
3-import { play, setSoundEnabled, setVolume } from '../game/sound';
3+import { play, setSoundEnabled, setVoiceEnabled, setVolume } from '../game/sound';
44 import { DEFAULT_NAMES, type Rules } from '../game/types';
55
66 interface Props {
⋯ 174 unchanged lines
181181 A click as a tile goes down, and a chime when someone's claim window opens.
182182 </span>
183183 </label>
184+ <label className={`switch ${draft.sound ? '' : 'dead'}`}>
185+ <input
186+ type="checkbox"
187+ checked={draft.voice}
188+ disabled={!draft.sound}
189+ onChange={(e) => {
190+ setVoiceEnabled(e.target.checked);
191+ setDraft({ ...draft, voice: e.target.checked });
192+ // Give it a beat to fetch the pack before the sample.
193+ if (e.target.checked) setTimeout(() => play({ kind: 'pung' }), 350);
194+ }}
195+ />
196+ <span className="sw-name">
197+ 報牌
198+ <em>Call the tiles</em>
199+ </span>
200+ <span className="note">
201+ 碰 · 吃 · 槓 · 胡了 called out loud, and every tile named as it is discarded —
202+ 三條, 五萬, 東風.
203+ </span>
204+ </label>
184205 <label className="field">
185206 <span className="f-label">
186207 音量
⋯ 23 unchanged lines