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