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 // Whether this device is actually going to make a noise is something the
33 // room wants to know (see `voiceLive`), and waking is when it changes.
34 ctx.onstatechange = () => told();
35 }
36 // Chrome parks the context until a gesture, and again when the tab sleeps.
37 if (ctx.state === 'suspended') void ctx.resume();
38 return ctx;
39}
40
41/** One second of white noise, reused by every clack. */
42function noiseBuffer(c: AudioContext): AudioBuffer {
43 if (!noise) {
44 noise = c.createBuffer(1, c.sampleRate, c.sampleRate);
45 const d = noise.getChannelData(0);
46 for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
47 }
48 return noise;
49}
50
51const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
52
53// ---- where it came from --------------------------------------------------
54/**
55 * 方位音. At a real table you know who called without looking up, because the
56 * shout came from your left. A screen can do the same: everything a seat does
57 * is panned to that seat's edge and dulled by how far across the felt it is,
58 * so 碰 from 上家 and 碰 from 下家 are not the same sound.
59 *
60 * `pos` is a position *round this screen*, not a seat number — 0 the near edge
61 * (yours), 1 the right, 2 across, 3 the left. Turning seats into positions is
62 * the caller's job, because only it knows which way the table is facing: an
63 * online table is rotated so your own seat is at the bottom, and the sound has
64 * to turn with it.
65 */
66export type Pos = 0 | 1 | 2 | 3;
67
68export interface Place {
69 /** Which edge it came from, as this screen has them laid out. */
70 pos?: Pos | null;
71 /** Straight left/right, −1..1, for a tile at a known spot on the felt. */
72 pan?: number;
73 /** 0 right under your nose … 1 the far edge. Quieter and duller with it. */
74 far?: number;
75}
76
77/** Pan and distance for each edge, heard from the near one. */
78const EDGE: Record<Pos, { pan: number; far: number }> = {
79 0: { pan: 0, far: 0 },
80 1: { pan: 0.72, far: 0.5 },
81 2: { pan: 0, far: 1 },
82 3: { pan: -0.72, far: 0.5 },
83};
84
85let spatial = true;
86
87export function setSpatial(on: boolean) {
88 spatial = on;
89}
90
91/** A little above the chimes: the chime is a nudge, the call is the message. */
92const VOICE_GAIN = 1.4;
93
94/** One spot on the table: everything from there goes through the same nodes. */
95interface Bus {
96 /** Where chimes and clacks from this spot go in. */
97 in: AudioNode;
98 /** Where the voice does — the same place, a touch louder. */
99 voice: GainNode;
100}
101const buses = new Map<string, Bus>();
102
103function busAt(c: AudioContext, pan: number, far: number): Bus {
104 const key = `${pan.toFixed(2)}:${far.toFixed(2)}`;
105 const had = buses.get(key);
106 if (had) return had;
107 // Distance is two things at once: quieter, and with the top taken off it.
108 // Between you and the far edge there is a metre of air, a wall of tiles, and
109 // three people's arms, and none of that carries 12 kHz.
110 const g = c.createGain();
111 g.gain.value = 1 - 0.32 * far;
112 let head: AudioNode = g;
113 if (far > 0.01) {
114 const lp = c.createBiquadFilter();
115 lp.type = 'lowpass';
116 lp.frequency.value = 18000 - 13000 * far;
117 lp.Q.value = 0.5;
118 g.connect(lp);
119 head = lp;
120 }
121 // Old Safari has no stereo panner; there it simply plays where it always did.
122 const p = c.createStereoPanner?.();
123 if (p) {
124 p.pan.value = pan;
125 head.connect(p).connect(master!);
126 } else {
127 head.connect(master!);
128 }
129 const voice = c.createGain();
130 voice.gain.value = VOICE_GAIN;
131 voice.connect(g);
132 const bus = { in: g, voice };
133 buses.set(key, bus);
134 return bus;
135}
136
137function busFor(c: AudioContext, place?: Place | null): Bus {
138 if (!spatial || !place) return busAt(c, 0, 0);
139 const e = place.pos === null || place.pos === undefined ? null : EDGE[place.pos];
140 const pan = clamp(place.pan ?? e?.pan ?? 0, -1, 1);
141 const far = clamp(place.far ?? e?.far ?? 0, 0, 1);
142 // Quantised, so a pool full of skidding tiles reuses a handful of buses
143 // rather than building a new one for every contact.
144 return busAt(c, Math.round(pan * 10) / 10, Math.round(far * 4) / 4);
145}
146
147// ---- voice ---------------------------------------------------------------
148/**
149 * 報牌 — the calls said out loud, the way they are shouted at a table: 碰, 吃,
150 * 槓, 胡了, and the name of every tile as it is discarded (三條, 五萬, 東風…).
151 *
152 * A table only ever says about fifty things, so the whole vocabulary is
153 * rendered ahead of time by `scripts/voice.mjs` into `public/voice/` and
154 * shipped as audio, rather than left to whatever speech synthesis the browser
155 * has lying around — which on Linux is usually espeak-ng, and sounds like it.
156 * Playback then goes through the same Web Audio graph as the chimes: instant,
157 * identical everywhere, and it can be cut off mid-word when the next call
158 * lands.
159 */
160const CALL_CLIP: Partial<Record<SoundEvent, string>> = {
161 pung: 'pung',
162 chow: 'chow',
163 kong: 'kong',
164 hu: 'hu',
165 selfDraw: 'selfDraw',
166 drawGame: 'drawGame',
167 flower: 'flower',
168};
169
170const VOICE_DIR = `${import.meta.env.BASE_URL}voice/`;
171const encoded = new Map<string, ArrayBuffer>();
172const clips = new Map<string, AudioBuffer>();
173let voiceOn = false;
174let fetching: Promise<void> | null = null;
175let decoding = false;
176let speaking: AudioBufferSourceNode[] = [];
177
178/**
179 * Fetching needs no AudioContext, which matters: sound is on from the start,
180 * but a browser will not let the audio hardware wake until the page has been
181 * clicked. So the bytes come down straight away and are decoded later, on the
182 * first gesture — by which time the whole pack (~330 kB) is already in hand.
183 */
184/** base64 → the bytes decodeAudioData wants. */
185function fromB64(b64: string): ArrayBuffer {
186 const bin = atob(b64);
187 const out = new Uint8Array(bin.length);
188 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
189 return out.buffer;
190}
191
192function fetchVoicePack(): Promise<void> {
193 if (fetching) return fetching;
194 fetching = (async () => {
195 // A host that packed the whole voice into one file (voice/pack.json)
196 // gets it in one request; the loose mp3s below are the normal path.
197 const packed: Record<string, string> | null = await fetch(`${VOICE_DIR}pack.json`)
198 .then((r) => (r.ok ? r.json() : null))
199 .catch(() => null);
200 if (packed) {
201 for (const [name, b64] of Object.entries(packed)) encoded.set(name, fromB64(b64));
202 return;
203 }
204 const names: string[] = await fetch(`${VOICE_DIR}manifest.json`).then((r) => r.json());
205 await Promise.all(
206 names.map(async (name) => {
207 try {
208 encoded.set(name, await fetch(`${VOICE_DIR}${name}.mp3`).then((r) => r.arrayBuffer()));
209 } catch {
210 // One missing clip just means that one call stays silent.
211 }
212 }),
213 );
214 })().catch(() => {
215 fetching = null; // let a later toggle try again
216 });
217 return fetching;
218}
219
220/** Decode what was fetched. Runs once, the first time there is a live context. */
221function decodeVoicePack(c: AudioContext) {
222 if (decoding || clips.size || !encoded.size) return;
223 decoding = true;
224 for (const [name, bytes] of encoded) {
225 c.decodeAudioData(bytes)
226 .then((buf) => {
227 clips.set(name, buf);
228 told();
229 })
230 .catch(() => {});
231 }
232 encoded.clear(); // decodeAudioData takes ownership of the buffers
233}
234
235export function setVoiceEnabled(on: boolean) {
236 voiceOn = on;
237 if (on) void fetchVoicePack();
238 told();
239}
240
241/**
242 * Speak one or more clips back to back. Calls land on top of each other at
243 * table pace, so anything still talking is cut off: the newest call is the
244 * only one that matters.
245 *
246 * Returns how long it will be talking for, in seconds — zero if it is not
247 * going to say anything at all. That is what lets the table hold still while
248 * somebody is speaking, and only for as long as they are.
249 */
250function say(place: Place | null, ...names: string[]): number {
251 const c = audio();
252 if (!voiceOn || !enabled || !c || !master) return 0;
253 if (!clips.size) {
254 // Nothing to say yet: either still coming down the wire, or waiting for
255 // this very gesture to be allowed to decode.
256 decodeVoicePack(c);
257 void fetchVoicePack();
258 return 0;
259 }
260
261 for (const src of speaking) {
262 try {
263 src.stop();
264 } catch {
265 // already finished
266 }
267 }
268 speaking = [];
269
270 const out = busFor(c, place).voice;
271 const from = c.currentTime + 0.02;
272 let at = from;
273 for (const name of names) {
274 const buf = clips.get(name);
275 if (!buf) continue;
276 const src = c.createBufferSource();
277 src.buffer = buf;
278 src.connect(out);
279 src.start(at);
280 speaking.push(src);
281 at += buf.duration + 0.05;
282 }
283 return at - from;
284}
285
286export function setSoundEnabled(on: boolean) {
287 enabled = on;
288 // Reach for the hardware only once the page has been interacted with —
289 // sound is on from the start, and a context built before that is refused
290 // anyway. When this runs from the click that toggled it, it unlocks there.
291 if (on && navigator.userActivation?.hasBeenActive !== false) unlock();
292 told();
293}
294
295export function setVolume(v: number) {
296 volume = v;
297 if (master && ctx) master.gain.setTargetAtTime(v, ctx.currentTime, 0.01);
298}
299
300/**
301 * Wake the hardware and decode the voice, from inside a gesture. Worth calling
302 * on any tap on a device whose whole job is to talk — a party phone — because
303 * until this has happened it cannot, and it is the room's business whether it
304 * can. Cheap and idempotent after the first time.
305 */
306export function unlock() {
307 if (!enabled) return;
308 const c = audio();
309 if (c) decodeVoicePack(c);
310}
311
312/**
313 * Whether this device would actually say something if it were asked to right
314 * now: sound on, 報牌 on, the hardware awake, and the words decoded and ready.
315 *
316 * A party phone reports this to the table, which is what lets the table stop
317 * saying that seat's calls and leave them to the person sitting there — and
318 * start again the moment they mute their phone or it goes to sleep.
319 */
320export function voiceLive(): boolean {
321 return enabled && voiceOn && !!ctx && ctx.state === 'running' && clips.size > 0;
322}
323
324const watchers = new Set<() => void>();
325/** Told when the answer to `voiceLive` might have changed. */
326export function onAudioChange(fn: () => void): () => void {
327 watchers.add(fn);
328 return () => {
329 watchers.delete(fn);
330 };
331}
332function told() {
333 for (const w of watchers) w();
334}
335
336/** Envelope helper: a tone that starts at `gain` and decays away over `dur`. */
337function tone(
338 c: AudioContext,
339 opts: {
340 freq: number;
341 at: number;
342 dur: number;
343 gain: number;
344 out: AudioNode;
345 type?: OscillatorType;
346 /** Slide to this frequency over the life of the note. */
347 to?: number;
348 },
349) {
350 const o = c.createOscillator();
351 const g = c.createGain();
352 o.type = opts.type ?? 'sine';
353 const t = c.currentTime + opts.at;
354 o.frequency.setValueAtTime(opts.freq, t);
355 if (opts.to !== undefined) o.frequency.exponentialRampToValueAtTime(opts.to, t + opts.dur);
356 g.gain.setValueAtTime(0.0001, t);
357 g.gain.exponentialRampToValueAtTime(opts.gain, t + 0.004);
358 g.gain.exponentialRampToValueAtTime(0.0001, t + opts.dur);
359 o.connect(g).connect(opts.out);
360 o.start(t);
361 o.stop(t + opts.dur + 0.02);
362}
363
364/** Bamboo-on-bamboo: a filtered noise burst with a short woody body under it. */
365function clack(c: AudioContext, out: AudioNode, at: number, gain = 1) {
366 const t = c.currentTime + at;
367 const src = c.createBufferSource();
368 src.buffer = noiseBuffer(c);
369 src.playbackRate.value = 1;
370 const bp = c.createBiquadFilter();
371 bp.type = 'bandpass';
372 bp.frequency.value = 2100;
373 bp.Q.value = 1.1;
374 const g = c.createGain();
375 g.gain.setValueAtTime(0.55 * gain, t);
376 g.gain.exponentialRampToValueAtTime(0.0001, t + 0.075);
377 src.connect(bp).connect(g).connect(out);
378 src.start(t, Math.random() * 0.5);
379 src.stop(t + 0.1);
380 tone(c, { freq: 320, to: 160, at, dur: 0.09, gain: 0.28 * gain, type: 'triangle', out });
381}
382
383/** Struck-metal chime: a fundamental with two inharmonic partials over it. */
384function bell(c: AudioContext, out: AudioNode, freq: number, at: number, gain = 1, dur = 0.55) {
385 tone(c, { freq, at, dur, gain: 0.3 * gain, type: 'sine', out });
386 tone(c, { freq: freq * 2.02, at, dur: dur * 0.7, gain: 0.13 * gain, type: 'sine', out });
387 tone(c, { freq: freq * 3.01, at, dur: dur * 0.4, gain: 0.06 * gain, type: 'sine', out });
388}
389
390/**
391 * Tile on tile, out in the middle. Not a `SoundEvent`: the engine never emits
392 * one of these, because it is not something that happens in the game — it is a
393 * thrown tile landing among the others, and only the physics knows when.
394 *
395 * `strength` is 0..1, straight off the impact, so a tile dropped in is a tick
396 * and one thrown hard is a crack. `where` is where on the felt it happened,
397 * which the physics also knows to the pixel — the middle is the one place a
398 * sound has a real position rather than an edge it belongs to.
399 */
400let lastKnock = 0;
401export function knock(strength: number, where?: Place) {
402 if (!enabled) return;
403 const c = audio();
404 if (!c || !master) return;
405 // A tile skittering across a pool of sixty can generate a dozen contacts in a
406 // tenth of a second, and hearing all of them is a rattle, not a table.
407 if (c.currentTime - lastKnock < 0.045) return;
408 lastKnock = c.currentTime;
409 clack(c, busFor(c, where).in, 0, 0.15 + strength * 0.6);
410}
411
412/**
413 * A tile thrown so hard it comes off the pool and into somebody's tiles.
414 *
415 * The middle stops where the four players' hands start, so a tile that reaches
416 * the edge with any speed left has been thrown at whoever is sitting there —
417 * and at a real table that gets you told. The clack is the tile arriving; the
418 * grumble under it is the reaction, and 報牌 puts words to it. Both come from
419 * that seat's edge: it is their tiles being knocked about, and their voice.
420 *
421 * Only the physics knows this happened, so like `knock` it is not a
422 * `SoundEvent`: the engine neither knows nor cares where a discard ended up.
423 *
424 * Returns how long it will be talking for, so the table can wait it out — a
425 * computer player reaching for the tile over the top of somebody complaining
426 * about it is the whole thing landing on nobody.
427 */
428let lastBarge = 0;
429export function barge(strength: number, where?: Place): number {
430 if (!enabled) return 0;
431 const c = audio();
432 if (!c || !master) return 0;
433 // One complaint per throw, not one per bounce.
434 if (c.currentTime - lastBarge < 1.6) return 0;
435 lastBarge = c.currentTime;
436
437 const out = busFor(c, where).in;
438 clack(c, out, 0, 0.5 + strength * 0.5);
439 // Two notes falling, which is what a grumble sounds like.
440 tone(c, { freq: 300, to: 190, at: 0.05, dur: 0.14, gain: 0.16, type: 'triangle', out });
441 tone(c, { freq: 240, to: 150, at: 0.2, dur: 0.22, gain: 0.13, type: 'triangle', out });
442 // Never the same line twice running: the repeat is what makes a stock phrase
443 // sound like a machine, and this one fires often enough for that to show.
444 let pick = Math.floor(Math.random() * WATCH_IT.length);
445 if (WATCH_IT[pick] === lastWords) pick = (pick + 1) % WATCH_IT.length;
446 lastWords = WATCH_IT[pick];
447 return say(where ?? null, lastWords);
448}
449
450/**
451 * What gets said. 喂,小心點 and 輕一點啦 straight, and then the way it would
452 * actually come out at a table — 是在哈囉, 你牌品很差欸, 這是麻將不是棒球.
453 * Rendered by scripts/voice.mjs; a missing clip just means silence.
454 */
455const WATCH_IT = [
456 'watchIt',
457 'watchIt2',
458 'watchIt3',
459 'watchIt4',
460 'watchIt5',
461 'watchIt6',
462 'watchIt7',
463 'watchIt8',
464];
465let lastWords = '';
466
467/**
468 * Where a cue is to be played, and which halves of it this device is to play.
469 *
470 * The two halves come apart in party mode, where the table and four phones are
471 * all in the same room: the table makes the noise, because the tiles are on
472 * the table, and the phone in a player's hand says the words, because the
473 * words are theirs. See `App.tsx` for who decides which.
474 */
475export interface Cast extends Place {
476 /** Say the words. Off where somebody else's phone is saying them for us. */
477 voice?: boolean;
478 /** Make the table noise. Off on a phone, which is not the table. */
479 noise?: boolean;
480}
481
482/**
483 * Each cue has its own shape so it is identifiable without looking up — the
484 * whole reason for the feature is a player noticing across the table.
485 */
486export function play(cue: SoundCue | SoundEvent, cast: Cast = {}) {
487 const { kind, tile } = typeof cue === 'string' ? { kind: cue, tile: undefined } : cue;
488 if (!enabled) return;
489
490 // A discard is announced by name; a claim by what was called; a flower by
491 // both, since "春" on its own tells you nothing about why you heard it.
492 if (cast.voice !== false) {
493 if (kind === 'discard') {
494 if (tile !== undefined) say(cast, `t${tile}`);
495 } else if (kind === 'flower' && tile !== undefined) {
496 say(cast, 'flower', `t${tile}`);
497 } else if (CALL_CLIP[kind]) {
498 say(cast, CALL_CLIP[kind]);
499 }
500 }
501 if (cast.noise === false) return;
502
503 const c = audio();
504 if (!c || !master) return;
505 const out = busFor(c, cast).in;
506
507 switch (kind) {
508 // A tile laid down. Dry and short so it never masks the chime after it.
509 case 'discard':
510 clack(c, out, 0);
511 break;
512 // Somebody's claim window is open. Rising two-note bell, easy to hear over
513 // talk, and deliberately unlike the clack that precedes it.
514 case 'claimWindow':
515 bell(c, out, 784, 0.06, 1, 0.4); // G5
516 bell(c, out, 1047, 0.19, 0.9, 0.55); // C6
517 break;
518 // 碰 / 吃 — the tile going down alongside the pair it joins.
519 case 'pung':
520 case 'chow':
521 clack(c, out, 0, 0.8);
522 clack(c, out, 0.055, 0.9);
523 bell(c, out, kind === 'pung' ? 660 : 587, 0.02, 0.5, 0.3);
524 break;
525 // Four tiles going down, plus a replacement off the back of the wall.
526 case 'kong':
527 clack(c, out, 0, 0.7);
528 clack(c, out, 0.05, 0.8);
529 clack(c, out, 0.1, 0.9);
530 clack(c, out, 0.15, 1);
531 bell(c, out, 523, 0.16, 0.55, 0.5);
532 break;
533 case 'flower':
534 bell(c, out, 1319, 0, 0.45, 0.35);
535 break;
536 // 胡牌 — a rising figure, the only cue that takes its time.
537 case 'hu':
538 case 'selfDraw':
539 bell(c, out, 523, 0, 1, 0.5);
540 bell(c, out, 659, 0.11, 1, 0.5);
541 bell(c, out, 784, 0.22, 1, 0.6);
542 bell(c, out, 1047, 0.33, 1.1, 1.1);
543 break;
544 // 流局 — the same figure, falling and dulled.
545 case 'drawGame':
546 bell(c, out, 523, 0, 0.7, 0.5);
547 bell(c, out, 415, 0.13, 0.7, 0.6);
548 bell(c, out, 330, 0.26, 0.7, 0.9);
549 break;
550 // Sixteen tiles apiece, so a scatter rather than a beat.
551 case 'deal':
552 for (let i = 0; i < 9; i++) clack(c, out, i * 0.045 + Math.random() * 0.02, 0.4);
553 break;
554 case 'undo':
555 tone(c, { freq: 700, to: 330, at: 0, dur: 0.22, gain: 0.18, type: 'triangle', out });
556 break;
557 }
558}