Files
llink/js/desktop/src/lib/sound-effects/engine.ts
T

153 lines
5.0 KiB
TypeScript

/**
* Web Audio engine for HUD sound effects.
*
* Uses AudioBufferSourceNode for low-latency, overlapping playback (HTMLAudio
* can't overlap the same source and stalls when triggered rapidly). Adds a
* small pitch jitter on each play so repeated sounds don't feel robotic — the
* trick games use to keep keypress chirps from grating.
*/
import { logError } from "@/lib/errors";
type EngineOptions = {
/** Master volume 0..1 applied on top of per-call volume. */
masterVolume: number;
/** When false, all play() calls are no-ops. */
enabled: boolean;
};
type PlayOptions = {
/** 0..1, multiplied with master volume. */
volume?: number;
/** ±fraction of playbackRate jitter; 0.05 = ±5%. */
pitchVariance?: number;
/** Skip if the same sound was played within this many ms. Prevents audible doubling on rapid triggers. */
throttleMs?: number;
};
class SoundEffectsEngine {
private ctx: AudioContext | null = null;
private masterGain: GainNode | null = null;
private limiter: DynamicsCompressorNode | null = null;
private buffers = new Map<string, AudioBuffer>();
private loading = new Map<string, Promise<AudioBuffer | null>>();
private lastPlayedAt = new Map<string, number>();
private options: EngineOptions = { masterVolume: 0.02, enabled: true };
setOptions(next: Partial<EngineOptions>) {
this.options = { ...this.options, ...next };
if (this.masterGain) {
this.masterGain.gain.value = this.options.masterVolume;
}
}
/** Lazy AudioContext init. Browsers (and Electron in some configs) start it suspended until a user gesture. */
private getContext(): AudioContext | null {
if (this.ctx) return this.ctx;
try {
const Ctor =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext;
if (!Ctor) return null;
this.ctx = new Ctor();
this.masterGain = this.ctx.createGain();
this.masterGain.gain.value = this.options.masterVolume;
// Brickwall limiter: caps peaks regardless of system volume so the
// click stays controlled when the OS volume is cranked. Threshold sets
// the ceiling; high ratio + fast attack make it a hard limiter.
this.limiter = this.ctx.createDynamicsCompressor();
this.limiter.threshold.value = -24; // dB ceiling for peaks
this.limiter.knee.value = 0;
this.limiter.ratio.value = 20;
this.limiter.attack.value = 0.001;
this.limiter.release.value = 0.08;
this.masterGain.connect(this.limiter);
this.limiter.connect(this.ctx.destination);
return this.ctx;
} catch (err) {
logError(err, { scope: "soundEffects.createContext" });
return null;
}
}
/**
* Preload a sound by name from a URL. Safe to call multiple times — caches.
* Missing/failed loads resolve to null and the sound silently no-ops on play.
*/
preload(name: string, url: string): Promise<AudioBuffer | null> {
if (this.buffers.has(name)) {
return Promise.resolve(this.buffers.get(name)!);
}
const existing = this.loading.get(name);
if (existing) return existing;
const ctx = this.getContext();
if (!ctx) return Promise.resolve(null);
const promise = fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status} loading ${url}`);
return res.arrayBuffer();
})
.then((data) => ctx.decodeAudioData(data))
.then((buffer) => {
this.buffers.set(name, buffer);
return buffer;
})
.catch((err) => {
logError(err, { scope: "soundEffects.preload", name });
return null;
});
this.loading.set(name, promise);
return promise;
}
play(name: string, opts: PlayOptions = {}) {
if (!this.options.enabled) return;
const buffer = this.buffers.get(name);
if (!buffer) return; // silently skip if not loaded
const ctx = this.ctx;
const master = this.masterGain;
if (!ctx || !master) return;
// Resume suspended context (autoplay policies). Resume is async but
// start(0) is queued correctly once the context resumes.
if (ctx.state === "suspended") {
void ctx.resume().catch((err) =>
logError(err, { scope: "soundEffects.resume" }),
);
}
const throttleMs = opts.throttleMs ?? 15;
const now = ctx.currentTime * 1000;
const last = this.lastPlayedAt.get(name) ?? -Infinity;
if (now - last < throttleMs) return;
this.lastPlayedAt.set(name, now);
const source = ctx.createBufferSource();
source.buffer = buffer;
const variance = opts.pitchVariance ?? 0.04;
if (variance > 0) {
// Centered around 1.0; e.g. variance 0.04 → 0.96..1.04
const jitter = 1 + (Math.random() * 2 - 1) * variance;
source.playbackRate.value = jitter;
}
const gain = ctx.createGain();
gain.gain.value = opts.volume ?? 1;
source.connect(gain).connect(master);
source.start(0);
}
}
export const soundEffects = new SoundEffectsEngine();
export type { PlayOptions };