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 encoded = new Map<string, ArrayBuffer>();
73const clips = new Map<string, AudioBuffer>();
74let voiceOn = false;
75let fetching: Promise<void> | null = null;
76let decoding = false;
77let voiceGain: GainNode | null = null;
78let speaking: AudioBufferSourceNode[] = [];
79
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 */
86function fetchVoicePack(): Promise<void> {
87 if (fetching) return fetching;
88 fetching = (async () => {
89 const names: string[] = await fetch(`${VOICE_DIR}manifest.json`).then((r) => r.json());
90 await Promise.all(
91 names.map(async (name) => {
92 try {
93 encoded.set(name, await fetch(`${VOICE_DIR}${name}.mp3`).then((r) => r.arrayBuffer()));
94 } catch {
95 // One missing clip just means that one call stays silent.
96 }
97 }),
98 );
99 })().catch(() => {
100 fetching = null; // let a later toggle try again
101 });
102 return fetching;
103}
104
105/** Decode what was fetched. Runs once, the first time there is a live context. */
106function 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
115}
116
117export function setVoiceEnabled(on: boolean) {
118 voiceOn = on;
119 if (on) void fetchVoicePack();
120}
121
122/**
123 * Speak one or more clips back to back. Calls land on top of each other at
124 * table pace, so anything still talking is cut off: the newest call is the
125 * only one that matters.
126 */
127function say(...names: string[]) {
128 const c = audio();
129 if (!voiceOn || !enabled || !c || !master) return;
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 }
137
138 for (const src of speaking) {
139 try {
140 src.stop();
141 } catch {
142 // already finished
143 }
144 }
145 speaking = [];
146
147 if (!voiceGain) {
148 // A little above the chimes: the chime is a nudge, the call is the message.
149 voiceGain = c.createGain();
150 voiceGain.gain.value = 1.4;
151 voiceGain.connect(master);
152 }
153
154 let at = c.currentTime + 0.02;
155 for (const name of names) {
156 const buf = clips.get(name);
157 if (!buf) continue;
158 const src = c.createBufferSource();
159 src.buffer = buf;
160 src.connect(voiceGain);
161 src.start(at);
162 speaking.push(src);
163 at += buf.duration + 0.05;
164 }
165}
166
167export function setSoundEnabled(on: boolean) {
168 enabled = on;
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 }
176}
177
178export function setVolume(v: number) {
179 volume = v;
180 if (master && ctx) master.gain.setTargetAtTime(v, ctx.currentTime, 0.01);
181}
182
183/** Envelope helper: a tone that starts at `gain` and decays away over `dur`. */
184function tone(
185 c: AudioContext,
186 opts: {
187 freq: number;
188 at: number;
189 dur: number;
190 gain: number;
191 type?: OscillatorType;
192 /** Slide to this frequency over the life of the note. */
193 to?: number;
194 },
195) {
196 const o = c.createOscillator();
197 const g = c.createGain();
198 o.type = opts.type ?? 'sine';
199 const t = c.currentTime + opts.at;
200 o.frequency.setValueAtTime(opts.freq, t);
201 if (opts.to !== undefined) o.frequency.exponentialRampToValueAtTime(opts.to, t + opts.dur);
202 g.gain.setValueAtTime(0.0001, t);
203 g.gain.exponentialRampToValueAtTime(opts.gain, t + 0.004);
204 g.gain.exponentialRampToValueAtTime(0.0001, t + opts.dur);
205 o.connect(g).connect(master!);
206 o.start(t);
207 o.stop(t + opts.dur + 0.02);
208}
209
210/** Bamboo-on-bamboo: a filtered noise burst with a short woody body under it. */
211function clack(c: AudioContext, at: number, gain = 1) {
212 const t = c.currentTime + at;
213 const src = c.createBufferSource();
214 src.buffer = noiseBuffer(c);
215 src.playbackRate.value = 1;
216 const bp = c.createBiquadFilter();
217 bp.type = 'bandpass';
218 bp.frequency.value = 2100;
219 bp.Q.value = 1.1;
220 const g = c.createGain();
221 g.gain.setValueAtTime(0.55 * gain, t);
222 g.gain.exponentialRampToValueAtTime(0.0001, t + 0.075);
223 src.connect(bp).connect(g).connect(master!);
224 src.start(t, Math.random() * 0.5);
225 src.stop(t + 0.1);
226 tone(c, { freq: 320, to: 160, at, dur: 0.09, gain: 0.28 * gain, type: 'triangle' });
227}
228
229/** Struck-metal chime: a fundamental with two inharmonic partials over it. */
230function bell(c: AudioContext, freq: number, at: number, gain = 1, dur = 0.55) {
231 tone(c, { freq, at, dur, gain: 0.3 * gain, type: 'sine' });
232 tone(c, { freq: freq * 2.02, at, dur: dur * 0.7, gain: 0.13 * gain, type: 'sine' });
233 tone(c, { freq: freq * 3.01, at, dur: dur * 0.4, gain: 0.06 * gain, type: 'sine' });
234}
235
236/**
237 * Tile on tile, out in the middle. Not a `SoundEvent`: the engine never emits
238 * one of these, because it is not something that happens in the game — it is a
239 * thrown tile landing among the others, and only the physics knows when.
240 *
241 * `strength` is 0..1, straight off the impact, so a tile dropped in is a tick
242 * and one thrown hard is a crack.
243 */
244let lastKnock = 0;
245export function knock(strength: number) {
246 if (!enabled) return;
247 const c = audio();
248 if (!c || !master) return;
249 // A tile skittering across a pool of sixty can generate a dozen contacts in a
250 // tenth of a second, and hearing all of them is a rattle, not a table.
251 if (c.currentTime - lastKnock < 0.045) return;
252 lastKnock = c.currentTime;
253 clack(c, 0, 0.15 + strength * 0.6);
254}
255
256/**
257 * A tile thrown so hard it comes off the pool and into somebody's tiles.
258 *
259 * The middle stops where the four players' hands start, so a tile that reaches
260 * the edge with any speed left has been thrown at whoever is sitting there —
261 * and at a real table that gets you told. The clack is the tile arriving; the
262 * grumble under it is the reaction, and 報牌 puts words to it.
263 *
264 * Only the physics knows this happened, so like `knock` it is not a
265 * `SoundEvent`: the engine neither knows nor cares where a discard ended up.
266 */
267let lastBarge = 0;
268export function barge(strength: number) {
269 if (!enabled) return;
270 const c = audio();
271 if (!c || !master) return;
272 // One complaint per throw, not one per bounce.
273 if (c.currentTime - lastBarge < 1.6) return;
274 lastBarge = c.currentTime;
275
276 clack(c, 0, 0.5 + strength * 0.5);
277 // Two notes falling, which is what a grumble sounds like.
278 tone(c, { freq: 300, to: 190, at: 0.05, dur: 0.14, gain: 0.16, type: 'triangle' });
279 tone(c, { freq: 240, to: 150, at: 0.2, dur: 0.22, gain: 0.13, type: 'triangle' });
280 // Never the same line twice running: the repeat is what makes a stock phrase
281 // sound like a machine, and this one fires often enough for that to show.
282 let pick = Math.floor(Math.random() * WATCH_IT.length);
283 if (WATCH_IT[pick] === lastWords) pick = (pick + 1) % WATCH_IT.length;
284 lastWords = WATCH_IT[pick];
285 say(lastWords);
286}
287
288/**
289 * What gets said. 喂,小心點 and 輕一點啦 straight, and then the way it would
290 * actually come out at a table — 是在哈囉, 你牌品很差欸, 這是麻將不是棒球.
291 * Rendered by scripts/voice.mjs; a missing clip just means silence.
292 */
293const WATCH_IT = [
294 'watchIt',
295 'watchIt2',
296 'watchIt3',
297 'watchIt4',
298 'watchIt5',
299 'watchIt6',
300 'watchIt7',
301 'watchIt8',
302];
303let lastWords = '';
304
305/**
306 * Each cue has its own shape so it is identifiable without looking up — the
307 * whole reason for the feature is a player noticing across the table.
308 */
309export function play(cue: SoundCue | SoundEvent) {
310 const { kind, tile } = typeof cue === 'string' ? { kind: cue, tile: undefined } : cue;
311 if (!enabled) return;
312
313 // A discard is announced by name; a claim by what was called; a flower by
314 // both, since "春" on its own tells you nothing about why you heard it.
315 if (kind === 'discard') {
316 if (tile !== undefined) say(`t${tile}`);
317 } else if (kind === 'flower' && tile !== undefined) {
318 say('flower', `t${tile}`);
319 } else if (CALL_CLIP[kind]) {
320 say(CALL_CLIP[kind]);
321 }
322
323 const c = audio();
324 if (!c || !master) return;
325
326 switch (kind) {
327 // A tile laid down. Dry and short so it never masks the chime after it.
328 case 'discard':
329 clack(c, 0);
330 break;
331 // Somebody's claim window is open. Rising two-note bell, easy to hear over
332 // talk, and deliberately unlike the clack that precedes it.
333 case 'claimWindow':
334 bell(c, 784, 0.06, 1, 0.4); // G5
335 bell(c, 1047, 0.19, 0.9, 0.55); // C6
336 break;
337 // 碰 / 吃 — the tile going down alongside the pair it joins.
338 case 'pung':
339 case 'chow':
340 clack(c, 0, 0.8);
341 clack(c, 0.055, 0.9);
342 bell(c, kind === 'pung' ? 660 : 587, 0.02, 0.5, 0.3);
343 break;
344 // Four tiles going down, plus a replacement off the back of the wall.
345 case 'kong':
346 clack(c, 0, 0.7);
347 clack(c, 0.05, 0.8);
348 clack(c, 0.1, 0.9);
349 clack(c, 0.15, 1);
350 bell(c, 523, 0.16, 0.55, 0.5);
351 break;
352 case 'flower':
353 bell(c, 1319, 0, 0.45, 0.35);
354 break;
355 // 胡牌 — a rising figure, the only cue that takes its time.
356 case 'hu':
357 case 'selfDraw':
358 bell(c, 523, 0, 1, 0.5);
359 bell(c, 659, 0.11, 1, 0.5);
360 bell(c, 784, 0.22, 1, 0.6);
361 bell(c, 1047, 0.33, 1.1, 1.1);
362 break;
363 // 流局 — the same figure, falling and dulled.
364 case 'drawGame':
365 bell(c, 523, 0, 0.7, 0.5);
366 bell(c, 415, 0.13, 0.7, 0.6);
367 bell(c, 330, 0.26, 0.7, 0.9);
368 break;
369 // Sixteen tiles apiece, so a scatter rather than a beat.
370 case 'deal':
371 for (let i = 0; i < 9; i++) clack(c, i * 0.045 + Math.random() * 0.02, 0.4);
372 break;
373 case 'undo':
374 tone(c, { freq: 700, to: 330, at: 0, dur: 0.22, gain: 0.18, type: 'triangle' });
375 break;
376 }
377}