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