main f6d8405

sounds.svelte.ts RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close