1/**
2 * AIM sound effects.
3 *
4 * If a sample exists in /static/sounds/<name>.(mp3|wav|ogg) it is played;
5 * otherwise a Web Audio sketch is synthesized. No audio files are shipped
6 * in the repository so the project stays free of copyrighted samples.
7 * Each synthesized sound evokes the original:
8 * - doorOpen : "buddy in" — creak + soft thud
9 * - doorSlam : "buddy out" — sharp slam
10 * - imReceive : incoming IM "ding"
11 * - imSend : outgoing IM "tick"
12 * - welcome : sign-on chime
13 * - modem : dial-up handshake (long, for the sign-on screen)
14 */
15import { browser } from '$app/environment';
16
17export type AimSound = 'doorOpen' | 'doorSlam' | 'imReceive' | 'imSend' | 'welcome' | 'modem';
18
19const STORAGE_KEY = 'pdraim-sounds-enabled';
20
21let ctx: AudioContext | null = null;
22// Reactive so every sound toggle (taskbar, preferences) shows the same value.
23const sound = $state({ enabled: browser ? localStorage.getItem(STORAGE_KEY) !== 'false' : true });
24
25export function soundsEnabled() {
26 return sound.enabled;
27}
28
29export function setSoundsEnabled(value: boolean) {
30 sound.enabled = value;
31 if (browser) localStorage.setItem(STORAGE_KEY, String(value));
32}
33
34function getContext(): AudioContext | null {
35 if (!browser) return null;
36 if (!ctx) {
37 try {
38 ctx = new AudioContext();
39 } catch {
40 return null;
41 }
42 }
43 if (ctx.state === 'suspended') void ctx.resume();
44 return ctx;
45}
46
47/** Call once from a user gesture so the browser allows playback. */
48export function unlockAudio() {
49 if (getContext()) preloadSounds();
50}
51
52function noiseBuffer(ac: AudioContext, seconds: number) {
53 const buffer = ac.createBuffer(1, Math.floor(ac.sampleRate * seconds), ac.sampleRate);
54 const data = buffer.getChannelData(0);
55 for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
56 return buffer;
57}
58
59function tone(
60 ac: AudioContext,
61 dest: AudioNode,
62 opts: {
63 type?: OscillatorType;
64 freq: number;
65 freqEnd?: number;
66 start: number;
67 duration: number;
68 gain?: number;
69 attack?: number;
70 }
71) {
72 const osc = ac.createOscillator();
73 const g = ac.createGain();
74 osc.type = opts.type ?? 'sine';
75 osc.frequency.setValueAtTime(opts.freq, opts.start);
76 if (opts.freqEnd) {
77 osc.frequency.exponentialRampToValueAtTime(opts.freqEnd, opts.start + opts.duration);
78 }
79 const peak = opts.gain ?? 0.3;
80 const attack = opts.attack ?? 0.005;
81 g.gain.setValueAtTime(0.0001, opts.start);
82 g.gain.exponentialRampToValueAtTime(peak, opts.start + attack);
83 g.gain.exponentialRampToValueAtTime(0.0001, opts.start + opts.duration);
84 osc.connect(g).connect(dest);
85 osc.start(opts.start);
86 osc.stop(opts.start + opts.duration + 0.05);
87}
88
89function thud(ac: AudioContext, dest: AudioNode, start: number, gain = 0.8, duration = 0.18) {
90 // Low body knock: filtered noise burst + pitched sine drop
91 const src = ac.createBufferSource();
92 src.buffer = noiseBuffer(ac, duration);
93 const filter = ac.createBiquadFilter();
94 filter.type = 'lowpass';
95 filter.frequency.setValueAtTime(900, start);
96 filter.frequency.exponentialRampToValueAtTime(120, start + duration);
97 const g = ac.createGain();
98 g.gain.setValueAtTime(gain, start);
99 g.gain.exponentialRampToValueAtTime(0.0001, start + duration);
100 src.connect(filter).connect(g).connect(dest);
101 src.start(start);
102 tone(ac, dest, {
103 freq: 160,
104 freqEnd: 45,
105 start,
106 duration: duration * 0.9,
107 gain: gain * 0.7,
108 attack: 0.002
109 });
110}
111
112function creak(ac: AudioContext, dest: AudioNode, start: number, duration = 0.35) {
113 // Hinge creak: sawtooth wobble with resonant bandpass
114 const osc = ac.createOscillator();
115 osc.type = 'sawtooth';
116 osc.frequency.setValueAtTime(420, start);
117 osc.frequency.linearRampToValueAtTime(560, start + duration * 0.6);
118 osc.frequency.linearRampToValueAtTime(380, start + duration);
119 const lfo = ac.createOscillator();
120 lfo.frequency.value = 28;
121 const lfoGain = ac.createGain();
122 lfoGain.gain.value = 60;
123 lfo.connect(lfoGain).connect(osc.frequency);
124 const filter = ac.createBiquadFilter();
125 filter.type = 'bandpass';
126 filter.frequency.value = 1400;
127 filter.Q.value = 6;
128 const g = ac.createGain();
129 g.gain.setValueAtTime(0.0001, start);
130 g.gain.exponentialRampToValueAtTime(0.12, start + 0.04);
131 g.gain.exponentialRampToValueAtTime(0.0001, start + duration);
132 osc.connect(filter).connect(g).connect(dest);
133 osc.start(start);
134 lfo.start(start);
135 osc.stop(start + duration + 0.05);
136 lfo.stop(start + duration + 0.05);
137}
138
139const players: Record<AimSound, (ac: AudioContext, dest: AudioNode, t: number) => void> = {
140 doorOpen(ac, dest, t) {
141 creak(ac, dest, t, 0.32);
142 thud(ac, dest, t + 0.3, 0.5, 0.14);
143 },
144 doorSlam(ac, dest, t) {
145 thud(ac, dest, t, 1.0, 0.22);
146 // door frame rattle
147 tone(ac, dest, {
148 type: 'square',
149 freq: 90,
150 freqEnd: 60,
151 start: t + 0.02,
152 duration: 0.12,
153 gain: 0.12
154 });
155 },
156 imReceive(ac, dest, t) {
157 // Two-note ding, bright
158 tone(ac, dest, { type: 'triangle', freq: 1046, start: t, duration: 0.18, gain: 0.25 });
159 tone(ac, dest, { type: 'sine', freq: 1568, start: t + 0.06, duration: 0.32, gain: 0.2 });
160 },
161 imSend(ac, dest, t) {
162 tone(ac, dest, { type: 'triangle', freq: 880, start: t, duration: 0.09, gain: 0.18 });
163 },
164 welcome(ac, dest, t) {
165 // Ascending three-note chime
166 tone(ac, dest, { freq: 523, start: t, duration: 0.25, gain: 0.22 });
167 tone(ac, dest, { freq: 659, start: t + 0.14, duration: 0.25, gain: 0.22 });
168 tone(ac, dest, { freq: 784, start: t + 0.28, duration: 0.5, gain: 0.25 });
169 },
170 modem(ac, dest, t) {
171 // Dial tone
172 tone(ac, dest, { freq: 350, start: t, duration: 0.5, gain: 0.08 });
173 tone(ac, dest, { freq: 440, start: t, duration: 0.5, gain: 0.08 });
174 // DTMF-ish dialing
175 const digits = [
176 [697, 1209],
177 [770, 1336],
178 [852, 1477],
179 [941, 1336],
180 [697, 1477],
181 [770, 1209],
182 [852, 1336]
183 ];
184 digits.forEach(([lo, hi], i) => {
185 const s = t + 0.6 + i * 0.13;
186 tone(ac, dest, { freq: lo, start: s, duration: 0.09, gain: 0.08 });
187 tone(ac, dest, { freq: hi, start: s, duration: 0.09, gain: 0.08 });
188 });
189 // Handshake: answer tone, then chirps
190 const h = t + 1.7;
191 tone(ac, dest, { freq: 2100, start: h, duration: 0.6, gain: 0.07 });
192 tone(ac, dest, {
193 type: 'square',
194 freq: 1200,
195 freqEnd: 2400,
196 start: h + 0.7,
197 duration: 0.35,
198 gain: 0.05
199 });
200 tone(ac, dest, {
201 type: 'square',
202 freq: 2400,
203 freqEnd: 1200,
204 start: h + 1.1,
205 duration: 0.35,
206 gain: 0.05
207 });
208 // Noise burst (training)
209 const src = ac.createBufferSource();
210 src.buffer = noiseBuffer(ac, 1.2);
211 const filter = ac.createBiquadFilter();
212 filter.type = 'bandpass';
213 filter.frequency.value = 1800;
214 filter.Q.value = 0.7;
215 const g = ac.createGain();
216 g.gain.setValueAtTime(0.0001, h + 1.5);
217 g.gain.exponentialRampToValueAtTime(0.09, h + 1.6);
218 g.gain.exponentialRampToValueAtTime(0.0001, h + 2.7);
219 src.connect(filter).connect(g).connect(dest);
220 src.start(h + 1.5);
221 }
222};
223
224export const SOUND_DURATIONS_MS: Record<AimSound, number> = {
225 doorOpen: 500,
226 doorSlam: 300,
227 imReceive: 400,
228 imSend: 120,
229 welcome: 800,
230 modem: 4500
231};
232
233// Sample cache: one in-flight/resolved promise per sound (null = no file available).
234// Not UI state, so a plain Map is intended.
235// eslint-disable-next-line svelte/prefer-svelte-reactivity
236const samples = new Map<AimSound, Promise<AudioBuffer | null>>();
237const SAMPLE_EXTENSIONS = ['flac', 'mp3', 'wav', 'ogg'];
238
239function loadSample(ac: AudioContext, name: AimSound): Promise<AudioBuffer | null> {
240 let pending = samples.get(name);
241 if (!pending) {
242 pending = (async () => {
243 for (const ext of SAMPLE_EXTENSIONS) {
244 try {
245 const res = await fetch(`/sounds/${name}.${ext}`, { cache: 'force-cache' });
246 // Skip HTML fallbacks; some servers send .flac without an audio/* type
247 if (!res.ok || (res.headers.get('content-type') ?? '').startsWith('text/')) continue;
248 return await ac.decodeAudioData(await res.arrayBuffer());
249 } catch {
250 // try next extension
251 }
252 }
253 return null;
254 })();
255 samples.set(name, pending);
256 }
257 return pending;
258}
259
260/** Warm the sample cache so the first playback is not delayed by a fetch. */
261export function preloadSounds() {
262 const ac = getContext();
263 if (!ac) return;
264 for (const name of Object.keys(players) as AimSound[]) void loadSample(ac, name);
265}
266
267export function playSound(name: AimSound) {
268 if (!sound.enabled) return;
269 const ac = getContext();
270 if (!ac) return;
271 // Always wait for the sample probe so the first play never falls back to the
272 // synthesized sketch while the real file is still downloading.
273 void loadSample(ac, name).then((buffer) => {
274 if (buffer) {
275 const src = ac.createBufferSource();
276 src.buffer = buffer;
277 src.connect(ac.destination);
278 src.start();
279 return;
280 }
281 const master = ac.createGain();
282 master.gain.value = 0.9;
283 master.connect(ac.destination);
284 try {
285 players[name](ac, master, ac.currentTime);
286 } catch (err) {
287 console.warn('Sound playback failed', err);
288 }
289 });
290}