anvilsign in

collin/mahjong

1import type { 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
48export function setSoundEnabled(on: boolean) {
49 enabled = on;
50 if (on) audio(); // unlock now, while we are still inside the click that toggled it
51}
52
53export function setVolume(v: number) {
54 volume = v;
55 if (master && ctx) master.gain.setTargetAtTime(v, ctx.currentTime, 0.01);
56}
57
58/** Envelope helper: a tone that starts at `gain` and decays away over `dur`. */
59function tone(
60 c: AudioContext,
61 opts: {
62 freq: number;
63 at: number;
64 dur: number;
65 gain: number;
66 type?: OscillatorType;
67 /** Slide to this frequency over the life of the note. */
68 to?: number;
69 },
70) {
71 const o = c.createOscillator();
72 const g = c.createGain();
73 o.type = opts.type ?? 'sine';
74 const t = c.currentTime + opts.at;
75 o.frequency.setValueAtTime(opts.freq, t);
76 if (opts.to !== undefined) o.frequency.exponentialRampToValueAtTime(opts.to, t + opts.dur);
77 g.gain.setValueAtTime(0.0001, t);
78 g.gain.exponentialRampToValueAtTime(opts.gain, t + 0.004);
79 g.gain.exponentialRampToValueAtTime(0.0001, t + opts.dur);
80 o.connect(g).connect(master!);
81 o.start(t);
82 o.stop(t + opts.dur + 0.02);
83}
84
85/** Bamboo-on-bamboo: a filtered noise burst with a short woody body under it. */
86function clack(c: AudioContext, at: number, gain = 1) {
87 const t = c.currentTime + at;
88 const src = c.createBufferSource();
89 src.buffer = noiseBuffer(c);
90 src.playbackRate.value = 1;
91 const bp = c.createBiquadFilter();
92 bp.type = 'bandpass';
93 bp.frequency.value = 2100;
94 bp.Q.value = 1.1;
95 const g = c.createGain();
96 g.gain.setValueAtTime(0.55 * gain, t);
97 g.gain.exponentialRampToValueAtTime(0.0001, t + 0.075);
98 src.connect(bp).connect(g).connect(master!);
99 src.start(t, Math.random() * 0.5);
100 src.stop(t + 0.1);
101 tone(c, { freq: 320, to: 160, at, dur: 0.09, gain: 0.28 * gain, type: 'triangle' });
102}
103
104/** Struck-metal chime: a fundamental with two inharmonic partials over it. */
105function bell(c: AudioContext, freq: number, at: number, gain = 1, dur = 0.55) {
106 tone(c, { freq, at, dur, gain: 0.3 * gain, type: 'sine' });
107 tone(c, { freq: freq * 2.02, at, dur: dur * 0.7, gain: 0.13 * gain, type: 'sine' });
108 tone(c, { freq: freq * 3.01, at, dur: dur * 0.4, gain: 0.06 * gain, type: 'sine' });
109}
110
111/**
112 * Each cue has its own shape so it is identifiable without looking up — the
113 * whole reason for the feature is a player noticing across the table.
114 */
115export function play(e: SoundEvent) {
116 if (!enabled) return;
117 const c = audio();
118 if (!c || !master) return;
119
120 switch (e) {
121 // A tile laid down. Dry and short so it never masks the chime after it.
122 case 'discard':
123 clack(c, 0);
124 break;
125 // Somebody's claim window is open. Rising two-note bell, easy to hear over
126 // talk, and deliberately unlike the clack that precedes it.
127 case 'claimWindow':
128 bell(c, 784, 0.06, 1, 0.4); // G5
129 bell(c, 1047, 0.19, 0.9, 0.55); // C6
130 break;
131 case 'claim':
132 clack(c, 0, 0.8);
133 clack(c, 0.055, 0.9);
134 bell(c, 660, 0.02, 0.5, 0.3);
135 break;
136 // Four tiles going down, plus a replacement off the back of the wall.
137 case 'kong':
138 clack(c, 0, 0.7);
139 clack(c, 0.05, 0.8);
140 clack(c, 0.1, 0.9);
141 clack(c, 0.15, 1);
142 bell(c, 523, 0.16, 0.55, 0.5);
143 break;
144 case 'flower':
145 bell(c, 1319, 0, 0.45, 0.35);
146 break;
147 // 胡牌 — a rising figure, the only cue that takes its time.
148 case 'win':
149 bell(c, 523, 0, 1, 0.5);
150 bell(c, 659, 0.11, 1, 0.5);
151 bell(c, 784, 0.22, 1, 0.6);
152 bell(c, 1047, 0.33, 1.1, 1.1);
153 break;
154 // 流局 — the same figure, falling and dulled.
155 case 'drawGame':
156 bell(c, 523, 0, 0.7, 0.5);
157 bell(c, 415, 0.13, 0.7, 0.6);
158 bell(c, 330, 0.26, 0.7, 0.9);
159 break;
160 // Sixteen tiles apiece, so a scatter rather than a beat.
161 case 'deal':
162 for (let i = 0; i < 9; i++) clack(c, i * 0.045 + Math.random() * 0.02, 0.4);
163 break;
164 case 'undo':
165 tone(c, { freq: 700, to: 330, at: 0, dur: 0.22, gain: 0.18, type: 'triangle' });
166 break;
167 }
168}