anvilsign in

collin/mahjong

1import type { SoundCue, SoundEvent } from './engine';
2
3/**
4 * Table noise, synthesised rather than sampled — a handful of oscillators is
5 * smaller than one .wav and needs no loading, which matters when the whole
6 * point is that the cue lands the instant the tile hits the table.
7 *
8 * Nothing here touches the audio hardware until `play` is first called, so the
9 * module is safe to import in tests and safe to load before the user has
10 * gestured at the page (browsers refuse to start audio before that).
11 */
12
13let ctx: AudioContext | null = null;
14let master: GainNode | null = null;
15let noise: AudioBuffer | null = null;
16let enabled = false;
17let volume = 0.7;
18
19function audio(): AudioContext | null {
20 if (typeof window === 'undefined') return null;
21 if (!ctx) {
22 const Ctor = window.AudioContext ?? (window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
23 if (!Ctor) return null;
24 try {
25 ctx = new Ctor();
26 } catch {
27 return null;
28 }
29 master = ctx.createGain();
30 master.gain.value = volume;
31 master.connect(ctx.destination);
32 }
33 // Chrome parks the context until a gesture, and again when the tab sleeps.
34 if (ctx.state === 'suspended') void ctx.resume();
35 return ctx;
36}
37
38/** One second of white noise, reused by every clack. */
39function noiseBuffer(c: AudioContext): AudioBuffer {
40 if (!noise) {
41 noise = c.createBuffer(1, c.sampleRate, c.sampleRate);
42 const d = noise.getChannelData(0);
43 for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
44 }
45 return noise;
46}
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 */
61const 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
71const VOICE_DIR = `${import.meta.env.BASE_URL}voice/`;
72const clips = new Map<string, AudioBuffer>();
73let voiceOn = false;
74let loading: Promise<void> | null = null;
75let voiceGain: GainNode | null = null;
76let speaking: AudioBufferSourceNode[] = [];
77
78/** Fetch and decode the whole pack. Small enough (~300 kB) to do in one go. */
79function 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
101export 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 */
111function 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
145export function setSoundEnabled(on: boolean) {
146 enabled = on;
147 if (on) audio(); // unlock now, while we are still inside the click that toggled it
148}
149
150export function setVolume(v: number) {
151 volume = v;
152 if (master && ctx) master.gain.setTargetAtTime(v, ctx.currentTime, 0.01);
153}
154
155/** Envelope helper: a tone that starts at `gain` and decays away over `dur`. */
156function tone(
157 c: AudioContext,
158 opts: {
159 freq: number;
160 at: number;
161 dur: number;
162 gain: number;
163 type?: OscillatorType;
164 /** Slide to this frequency over the life of the note. */
165 to?: number;
166 },
167) {
168 const o = c.createOscillator();
169 const g = c.createGain();
170 o.type = opts.type ?? 'sine';
171 const t = c.currentTime + opts.at;
172 o.frequency.setValueAtTime(opts.freq, t);
173 if (opts.to !== undefined) o.frequency.exponentialRampToValueAtTime(opts.to, t + opts.dur);
174 g.gain.setValueAtTime(0.0001, t);
175 g.gain.exponentialRampToValueAtTime(opts.gain, t + 0.004);
176 g.gain.exponentialRampToValueAtTime(0.0001, t + opts.dur);
177 o.connect(g).connect(master!);
178 o.start(t);
179 o.stop(t + opts.dur + 0.02);
180}
181
182/** Bamboo-on-bamboo: a filtered noise burst with a short woody body under it. */
183function clack(c: AudioContext, at: number, gain = 1) {
184 const t = c.currentTime + at;
185 const src = c.createBufferSource();
186 src.buffer = noiseBuffer(c);
187 src.playbackRate.value = 1;
188 const bp = c.createBiquadFilter();
189 bp.type = 'bandpass';
190 bp.frequency.value = 2100;
191 bp.Q.value = 1.1;
192 const g = c.createGain();
193 g.gain.setValueAtTime(0.55 * gain, t);
194 g.gain.exponentialRampToValueAtTime(0.0001, t + 0.075);
195 src.connect(bp).connect(g).connect(master!);
196 src.start(t, Math.random() * 0.5);
197 src.stop(t + 0.1);
198 tone(c, { freq: 320, to: 160, at, dur: 0.09, gain: 0.28 * gain, type: 'triangle' });
199}
200
201/** Struck-metal chime: a fundamental with two inharmonic partials over it. */
202function bell(c: AudioContext, freq: number, at: number, gain = 1, dur = 0.55) {
203 tone(c, { freq, at, dur, gain: 0.3 * gain, type: 'sine' });
204 tone(c, { freq: freq * 2.02, at, dur: dur * 0.7, gain: 0.13 * gain, type: 'sine' });
205 tone(c, { freq: freq * 3.01, at, dur: dur * 0.4, gain: 0.06 * gain, type: 'sine' });
206}
207
208/**
209 * Each cue has its own shape so it is identifiable without looking up — the
210 * whole reason for the feature is a player noticing across the table.
211 */
212export function play(cue: SoundCue | SoundEvent) {
213 const { kind, tile } = typeof cue === 'string' ? { kind: cue, tile: undefined } : cue;
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
226 const c = audio();
227 if (!c || !master) return;
228
229 switch (kind) {
230 // A tile laid down. Dry and short so it never masks the chime after it.
231 case 'discard':
232 clack(c, 0);
233 break;
234 // Somebody's claim window is open. Rising two-note bell, easy to hear over
235 // talk, and deliberately unlike the clack that precedes it.
236 case 'claimWindow':
237 bell(c, 784, 0.06, 1, 0.4); // G5
238 bell(c, 1047, 0.19, 0.9, 0.55); // C6
239 break;
240 // 碰 / 吃 — the tile going down alongside the pair it joins.
241 case 'pung':
242 case 'chow':
243 clack(c, 0, 0.8);
244 clack(c, 0.055, 0.9);
245 bell(c, kind === 'pung' ? 660 : 587, 0.02, 0.5, 0.3);
246 break;
247 // Four tiles going down, plus a replacement off the back of the wall.
248 case 'kong':
249 clack(c, 0, 0.7);
250 clack(c, 0.05, 0.8);
251 clack(c, 0.1, 0.9);
252 clack(c, 0.15, 1);
253 bell(c, 523, 0.16, 0.55, 0.5);
254 break;
255 case 'flower':
256 bell(c, 1319, 0, 0.45, 0.35);
257 break;
258 // 胡牌 — a rising figure, the only cue that takes its time.
259 case 'hu':
260 case 'selfDraw':
261 bell(c, 523, 0, 1, 0.5);
262 bell(c, 659, 0.11, 1, 0.5);
263 bell(c, 784, 0.22, 1, 0.6);
264 bell(c, 1047, 0.33, 1.1, 1.1);
265 break;
266 // 流局 — the same figure, falling and dulled.
267 case 'drawGame':
268 bell(c, 523, 0, 0.7, 0.5);
269 bell(c, 415, 0.13, 0.7, 0.6);
270 bell(c, 330, 0.26, 0.7, 0.9);
271 break;
272 // Sixteen tiles apiece, so a scatter rather than a beat.
273 case 'deal':
274 for (let i = 0; i < 9; i++) clack(c, i * 0.045 + Math.random() * 0.02, 0.4);
275 break;
276 case 'undo':
277 tone(c, { freq: 700, to: 330, at: 0, dur: 0.22, gain: 0.18, type: 'triangle' });
278 break;
279 }
280}