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 | |||
| 69 | 69 | synthesised with a few oscillators (`src/game/sound.ts`) rather than sampled, | |
| 70 | 70 | so there is nothing to load and the cue lands on the same frame as the tile. | |
| 71 | 71 | 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. | |
| 72 | 77 | - **Settings** — 設定 from the lobby, or from the game-over panel: player names, | |
| 73 | 78 | 底 / 台 / starting chips, the house-rule switches below, and sound. Kept in | |
| 74 | 79 | `localStorage` separately from the save, so they carry over to the next game. | |
| ⋯ 59 unchanged lines | |||
| 134 | 139 | House rules vary a lot; the tai table and payments are plain data/functions in | |
| 135 | 140 | `tai.ts` and `types.ts` if yours differ. | |
| 136 | 141 | ||
| 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 | + | ||
| 137 | 173 | ## Tile art | |
| 138 | 174 | ||
| 139 | 175 | `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 | |||
| 2 | 2 | import { Game } from './game/engine'; | |
| 3 | 3 | import { loadPrefs, savePrefs, type Prefs } from './game/prefs'; | |
| 4 | 4 | import { loadSave } from './game/save'; | |
| 5 | - | import { play, setSoundEnabled, setVolume } from './game/sound'; | |
| 5 | + | import { play, setSoundEnabled, setVoiceEnabled, setVolume } from './game/sound'; | |
| 6 | 6 | import { WIND_EN, WIND_NAMES } from './game/tiles'; | |
| 7 | 7 | import type { SeatId } from './game/types'; | |
| 8 | 8 | import { Center } from './ui/Center'; | |
| ⋯ 28 unchanged lines | |||
| 37 | 37 | // put the audio back the way it was. | |
| 38 | 38 | const cancelSettings = () => { | |
| 39 | 39 | setSoundEnabled(prefs.sound); | |
| 40 | + | setVoiceEnabled(prefs.voice); | |
| 40 | 41 | setVolume(prefs.volume); | |
| 41 | 42 | setSettingsOpen(false); | |
| 42 | 43 | }; | |
| ⋯ 10 unchanged lines | |||
| 53 | 54 | useEffect(() => { | |
| 54 | 55 | savePrefs(prefs); | |
| 55 | 56 | setSoundEnabled(prefs.sound); | |
| 57 | + | setVoiceEnabled(prefs.voice); | |
| 56 | 58 | setVolume(prefs.volume); | |
| 57 | 59 | }, [prefs]); | |
| 58 | 60 | useEffect(() => game.onSound(play), [game]); | |
| ⋯ 73 unchanged lines | |||
modifiedsrc/game/engine.test.ts+18 −0
| ⋯ 224 unchanged lines | |||
| 225 | 225 | expect(s.turn).toBe(victim); | |
| 226 | 226 | }); | |
| 227 | 227 | ||
| 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 | + | ||
| 228 | 246 | it('takes back a mis-tapped discard', () => { | |
| 229 | 247 | const game = new Game(); | |
| 230 | 248 | game.startHand(11); | |
| ⋯ 290 unchanged lines | |||
modifiedsrc/game/engine.ts+23 −13
| ⋯ 26 unchanged lines | |||
| 27 | 27 | | 'deal' | |
| 28 | 28 | | 'discard' | |
| 29 | 29 | | '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' | |
| 31 | 34 | | 'kong' | |
| 35 | + | | 'hu' | |
| 36 | + | | 'selfDraw' | |
| 32 | 37 | | 'flower' | |
| 33 | - | | 'win' | |
| 34 | 38 | | 'drawGame' | |
| 35 | 39 | | 'undo'; | |
| 36 | 40 | ||
| 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 | + | ||
| 37 | 47 | export type ClaimType = 'hu' | 'kong' | 'pung' | 'chow'; | |
| 38 | 48 | ||
| 39 | 49 | export interface ClaimOption { | |
| ⋯ 155 unchanged lines | |||
| 195 | 205 | this.emit(); | |
| 196 | 206 | } | |
| 197 | 207 | ||
| 198 | - | private soundListeners = new Set<(e: SoundEvent) => void>(); | |
| 208 | + | private soundListeners = new Set<(cue: SoundCue) => void>(); | |
| 199 | 209 | /** 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) => { | |
| 201 | 211 | this.soundListeners.add(fn); | |
| 202 | 212 | return () => { | |
| 203 | 213 | this.soundListeners.delete(fn); | |
| 204 | 214 | }; | |
| 205 | 215 | }; | |
| 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 }); | |
| 208 | 218 | } | |
| 209 | 219 | ||
| 210 | 220 | /** | |
| ⋯ 169 unchanged lines | |||
| 380 | 390 | if (isFlower(t)) { | |
| 381 | 391 | drawn = this.replaceFlowers(seat).last; | |
| 382 | 392 | 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 | |
| 384 | 394 | } | |
| 385 | 395 | s.turn = seat; | |
| 386 | 396 | this.setDrawn(seat, drawn); | |
| ⋯ 63 unchanged lines | |||
| 450 | 460 | s.lastDiscard = { tile, from: seat }; | |
| 451 | 461 | s.responses = {}; | |
| 452 | 462 | s.options = this.claimOptionsFor(tile, seat); | |
| 453 | - | this.sfx('discard'); | |
| 463 | + | this.sfx('discard', tile); | |
| 454 | 464 | ||
| 455 | 465 | if (Object.keys(s.options).length === 0) { | |
| 456 | 466 | this.afterDiscardResolved(); | |
| ⋯ 169 unchanged lines | |||
| 626 | 636 | // stricter ones make you wait for your own draw. | |
| 627 | 637 | if (s.rules.sacredClearedByClaim) s.sacred[seat] = []; | |
| 628 | 638 | this.say(`${p.name} ${claimName(claim.type)} ${labelOf(tile)}`); | |
| 629 | - | this.sfx(claim.type === 'kong' ? 'kong' : 'claim'); | |
| 639 | + | this.sfx(claim.type, tile); | |
| 630 | 640 | ||
| 631 | 641 | s.lastDiscard = null; | |
| 632 | 642 | s.options = {}; | |
| ⋯ 35 unchanged lines | |||
| 668 | 678 | for (let k = 0; k < 4; k++) p.hand.splice(p.hand.indexOf(tile), 1); | |
| 669 | 679 | p.melds.push({ kind: 'ankong', tiles: [tile, tile, tile, tile] }); | |
| 670 | 680 | this.say(`${p.name} 暗槓 concealed kong`); | |
| 671 | - | this.sfx('kong'); | |
| 681 | + | this.sfx('kong', tile); | |
| 672 | 682 | this.kongReplacement(seat); | |
| 673 | 683 | this.emit(); | |
| 674 | 684 | } | |
| ⋯ 24 unchanged lines | |||
| 699 | 709 | s.responses = {}; | |
| 700 | 710 | s.phase = 'robkong'; | |
| 701 | 711 | this.say(`${p.name} 加槓 ${labelOf(tile)} — 可搶槓 robbable`); | |
| 702 | - | this.sfx('kong'); | |
| 712 | + | this.sfx('kong', tile); | |
| 703 | 713 | this.sfx('claimWindow'); | |
| 704 | 714 | } else { | |
| 705 | 715 | this.say(`${p.name} 加槓 added kong`); | |
| 706 | - | this.sfx('kong'); | |
| 716 | + | this.sfx('kong', tile); | |
| 707 | 717 | this.finishAddKong(); | |
| 708 | 718 | } | |
| 709 | 719 | this.emit(); | |
| ⋯ 139 unchanged lines | |||
| 849 | 859 | this.say(`${s.players[r.seat].name} 胡牌 wins ${r.tai} 台`); | |
| 850 | 860 | if (r.liable !== null) this.say(`${s.players[r.liable].name} 包牌 pays it all`); | |
| 851 | 861 | } | |
| 852 | - | this.sfx('win'); | |
| 862 | + | this.sfx(results.some((r) => r.selfDraw) ? 'selfDraw' : 'hu', results[0].winTile); | |
| 853 | 863 | this.emit(); | |
| 854 | 864 | } | |
| 855 | 865 | ||
| ⋯ 73 unchanged lines | |||
modifiedsrc/game/prefs.ts+4 −0
| ⋯ 10 unchanged lines | |||
| 11 | 11 | sound: boolean; | |
| 12 | 12 | /** 0..1 */ | |
| 13 | 13 | volume: number; | |
| 14 | + | /** 報牌 — say the calls and the discarded tiles out loud. Needs `sound`. */ | |
| 15 | + | voice: boolean; | |
| 14 | 16 | names: string[]; | |
| 15 | 17 | rules: Rules; | |
| 16 | 18 | } | |
| ⋯ 1 unchanged line | |||
| 18 | 20 | export const DEFAULT_PREFS: Prefs = { | |
| 19 | 21 | sound: false, | |
| 20 | 22 | volume: 0.7, | |
| 23 | + | voice: true, | |
| 21 | 24 | names: DEFAULT_NAMES, | |
| 22 | 25 | rules: DEFAULT_RULES, | |
| 23 | 26 | }; | |
| ⋯ 6 unchanged lines | |||
| 30 | 33 | return { | |
| 31 | 34 | sound: typeof p.sound === 'boolean' ? p.sound : DEFAULT_PREFS.sound, | |
| 32 | 35 | volume: clamp(typeof p.volume === 'number' ? p.volume : DEFAULT_PREFS.volume, 0, 1), | |
| 36 | + | voice: typeof p.voice === 'boolean' ? p.voice : DEFAULT_PREFS.voice, | |
| 33 | 37 | names: | |
| 34 | 38 | Array.isArray(p.names) && p.names.length === 4 && p.names.every((n) => typeof n === 'string') | |
| 35 | 39 | ? p.names | |
| ⋯ 17 unchanged lines | |||
modifiedsrc/game/sound.ts+118 −6
| 1 | - | import type { SoundEvent } from './engine'; | |
| 1 | + | import type { SoundCue, SoundEvent } from './engine'; | |
| 2 | 2 | ||
| 3 | 3 | /** | |
| 4 | 4 | * Table noise, synthesised rather than sampled — a handful of oscillators is | |
| ⋯ 40 unchanged lines | |||
| 45 | 45 | return noise; | |
| 46 | 46 | } | |
| 47 | 47 | ||
| 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 | + | ||
| 48 | 145 | export function setSoundEnabled(on: boolean) { | |
| 49 | 146 | enabled = on; | |
| 50 | 147 | if (on) audio(); // unlock now, while we are still inside the click that toggled it | |
| ⋯ 61 unchanged lines | |||
| 112 | 209 | * Each cue has its own shape so it is identifiable without looking up — the | |
| 113 | 210 | * whole reason for the feature is a player noticing across the table. | |
| 114 | 211 | */ | |
| 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; | |
| 116 | 214 | 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 | + | ||
| 117 | 226 | const c = audio(); | |
| 118 | 227 | if (!c || !master) return; | |
| 119 | 228 | ||
| 120 | - | switch (e) { | |
| 229 | + | switch (kind) { | |
| 121 | 230 | // A tile laid down. Dry and short so it never masks the chime after it. | |
| 122 | 231 | case 'discard': | |
| 123 | 232 | clack(c, 0); | |
| ⋯ 4 unchanged lines | |||
| 128 | 237 | bell(c, 784, 0.06, 1, 0.4); // G5 | |
| 129 | 238 | bell(c, 1047, 0.19, 0.9, 0.55); // C6 | |
| 130 | 239 | break; | |
| 131 | - | case 'claim': | |
| 240 | + | // 碰 / 吃 — the tile going down alongside the pair it joins. | |
| 241 | + | case 'pung': | |
| 242 | + | case 'chow': | |
| 132 | 243 | clack(c, 0, 0.8); | |
| 133 | 244 | 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); | |
| 135 | 246 | break; | |
| 136 | 247 | // Four tiles going down, plus a replacement off the back of the wall. | |
| 137 | 248 | case 'kong': | |
| ⋯ 7 unchanged lines | |||
| 145 | 256 | bell(c, 1319, 0, 0.45, 0.35); | |
| 146 | 257 | break; | |
| 147 | 258 | // 胡牌 — a rising figure, the only cue that takes its time. | |
| 148 | - | case 'win': | |
| 259 | + | case 'hu': | |
| 260 | + | case 'selfDraw': | |
| 149 | 261 | bell(c, 523, 0, 1, 0.5); | |
| 150 | 262 | bell(c, 659, 0.11, 1, 0.5); | |
| 151 | 263 | bell(c, 784, 0.22, 1, 0.6); | |
| ⋯ 17 unchanged lines | |||
modifiedsrc/ui/Settings.tsx+22 −1
| 1 | 1 | import { useState } from 'react'; | |
| 2 | 2 | import { clamp, type Prefs } from '../game/prefs'; | |
| 3 | - | import { play, setSoundEnabled, setVolume } from '../game/sound'; | |
| 3 | + | import { play, setSoundEnabled, setVoiceEnabled, setVolume } from '../game/sound'; | |
| 4 | 4 | import { DEFAULT_NAMES, type Rules } from '../game/types'; | |
| 5 | 5 | ||
| 6 | 6 | interface Props { | |
| ⋯ 174 unchanged lines | |||
| 181 | 181 | A click as a tile goes down, and a chime when someone's claim window opens. | |
| 182 | 182 | </span> | |
| 183 | 183 | </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> | |
| 184 | 205 | <label className="field"> | |
| 185 | 206 | <span className="f-label"> | |
| 186 | 207 | 音量 | |
| ⋯ 23 unchanged lines | |||