153 lines
5.0 KiB
TypeScript
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 };
|