anvilsign in

collin/mahjong

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 */
21import { execFileSync } from 'node:child_process';
22import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
23import { homedir } from 'node:os';
24import { join } from 'node:path';
25
26const MODEL =
27 process.env.PIPER_MODEL ??
28 join(homedir(), '.local/share/piper-voices/zh_CN/zh_CN-huayan-medium.onnx');
29const OUT = 'public/voice';
30const TMP = join(process.env.TMPDIR ?? '/tmp', 'mahjong-voice');
31
32const 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 */
43const CLIPS = {};
44for (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.
57Object.assign(CLIPS, {
58 pung: '碰',
59 chow: '吃',
60 kong: '槓',
61 hu: '胡了',
62 selfDraw: '自摸',
63 drawGame: '流局',
64 flower: '補花',
65 // Said to whoever threw a tile hard enough to come off the pool and into
66 // your tiles. Two of them, so a rough table does not sound like a parrot.
67 watchIt: '喂,小心點',
68 watchIt2: '輕一點啦',
69});
70
71mkdirSync(OUT, { recursive: true });
72mkdirSync(TMP, { recursive: true });
73
74for (const [name, text] of Object.entries(CLIPS)) {
75 const wav = join(TMP, `${name}.wav`);
76 execFileSync('piper', ['-m', MODEL, '-f', wav], { input: text });
77 // Piper pads both ends with silence; a call has to land on the beat, so trim
78 // it off and normalise so no clip is noticeably louder than its neighbours.
79 execFileSync(
80 'ffmpeg',
81 [
82 '-hide_banner', '-loglevel', 'error', '-y', '-i', wav,
83 '-af',
84 'silenceremove=start_periods=1:start_threshold=-45dB:start_silence=0.02,' +
85 'areverse,silenceremove=start_periods=1:start_threshold=-45dB:start_silence=0.04,areverse,' +
86 'loudnorm=I=-18:TP=-2:LRA=7',
87 '-ac', '1', '-ar', '22050', '-b:a', '48k',
88 join(OUT, `${name}.mp3`),
89 ],
90 { stdio: ['ignore', 'ignore', 'inherit'] },
91 );
92 process.stdout.write(`${name} ${text}\n`);
93}
94
95// Shipped alongside so the runtime knows what it can ask for without a 404.
96writeFileSync(join(OUT, 'manifest.json'), `${JSON.stringify(Object.keys(CLIPS))}\n`);
97rmSync(TMP, { recursive: true, force: true });
98console.log(`\n${Object.keys(CLIPS).length} clips → ${OUT}`);