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