diablo2-web/src/client/audio/manager.ts

337 lines
11 KiB
TypeScript

export interface ToneSpec {
readonly type: OscillatorType;
readonly from: number;
readonly to: number;
readonly gain: number;
readonly durationS: number;
readonly delayS?: number;
}
type AudioContextCtor = new () => AudioContext;
let sharedCtx: AudioContext | null = null;
let sharedCtxCtor: AudioContextCtor | null = null;
let sharedMasterGain: GainNode | null = null;
let sharedSfxGain: GainNode | null = null;
let sharedMusicGain: GainNode | null = null;
let warnedAudioFailure = false;
function getAudioContextCtor(): AudioContextCtor | undefined {
const g = globalThis as unknown as {
AudioContext?: AudioContextCtor;
webkitAudioContext?: AudioContextCtor;
window?: {
AudioContext?: AudioContextCtor;
webkitAudioContext?: AudioContextCtor;
};
};
return (
g.AudioContext ??
g.webkitAudioContext ??
g.window?.AudioContext ??
g.window?.webkitAudioContext
);
}
function warnOnce(err: unknown): void {
if (!warnedAudioFailure) {
warnedAudioFailure = true;
console.warn('[AudioManager] Audio playback failed:', err);
}
}
export class AudioManager {
private ctx: AudioContext | null = null;
private masterGain: GainNode | null = null;
private sfxGain: GainNode | null = null;
private musicGain: GainNode | null = null;
private buffers: Map<string, AudioBuffer> = new Map();
private currentMusicSource: AudioBufferSourceNode | null = null;
constructor() {
// We defer AudioContext creation until needed due to autoplay policies
}
private ensureContext(): AudioContext | null {
const Ctor = getAudioContextCtor();
if (!Ctor) {
return null;
}
try {
if (!sharedCtx || sharedCtx.state === 'closed' || sharedCtxCtor !== Ctor) {
sharedCtx = new Ctor();
sharedCtxCtor = Ctor;
sharedMasterGain = typeof sharedCtx.createGain === 'function' ? sharedCtx.createGain() : null;
sharedSfxGain = typeof sharedCtx.createGain === 'function' ? sharedCtx.createGain() : null;
sharedMusicGain = typeof sharedCtx.createGain === 'function' ? sharedCtx.createGain() : null;
if (sharedMasterGain && sharedCtx.destination) {
sharedMasterGain.connect?.(sharedCtx.destination);
}
if (sharedSfxGain && sharedMasterGain) {
sharedSfxGain.connect?.(sharedMasterGain);
}
if (sharedMusicGain && sharedMasterGain) {
sharedMusicGain.connect?.(sharedMasterGain);
}
}
if (sharedCtx.state === 'suspended' && typeof sharedCtx.resume === 'function') {
void Promise.resolve(sharedCtx.resume()).catch(() => {});
}
this.ctx = sharedCtx;
this.masterGain = sharedMasterGain;
this.sfxGain = sharedSfxGain;
this.musicGain = sharedMusicGain;
return this.ctx;
} catch (err) {
warnOnce(err);
return null;
}
}
/** Return the active shared AudioContext if created, or null. */
getContext(): AudioContext | null {
return this.ctx ?? sharedCtx;
}
/** Set the global master volume [0.0..1.0] */
setMasterVolume(value: number): void {
const clamped = Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0));
this.ensureContext();
if (this.masterGain?.gain) this.masterGain.gain.value = clamped;
}
/** Set the SFX volume [0.0..1.0] */
setSfxVolume(value: number): void {
const clamped = Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0));
this.ensureContext();
if (this.sfxGain?.gain) this.sfxGain.gain.value = clamped;
}
/** Set the music volume [0.0..1.0] */
setMusicVolume(value: number): void {
const clamped = Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0));
this.ensureContext();
if (this.musicGain?.gain) this.musicGain.gain.value = clamped;
}
/** Decode a full WAFF/RIFF MPQ audio member and cache it by name */
async loadBuffer(name: string, data: Uint8Array): Promise<void> {
const ctx = this.ensureContext();
if (!ctx) return;
// Ensure WAFF/RIFF is correctly passed. WebAudio takes an ArrayBuffer
const buffer = await ctx.decodeAudioData(
data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer,
);
this.buffers.set(name, buffer);
}
/** Play a synthesized tone using the shared AudioContext and currentTime scheduling */
playTone(spec: ToneSpec): void {
const ctx = this.ensureContext();
if (!ctx) return;
try {
const now = typeof ctx.currentTime === 'number' ? ctx.currentTime : 0;
const t0 = now + (spec.delayS ?? 0);
const t1 = t0 + spec.durationS;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = spec.type;
osc.frequency?.setValueAtTime?.(spec.from, t0);
osc.frequency?.exponentialRampToValueAtTime?.(Math.max(0.01, spec.to), t1);
gain.gain?.setValueAtTime?.(spec.gain, t0);
gain.gain?.exponentialRampToValueAtTime?.(0.01, t1);
osc.connect?.(gain);
const targetNode = this.sfxGain ?? ctx.destination;
if (targetNode) {
gain.connect?.(targetNode);
}
const cleanup = () => {
try {
osc.disconnect?.();
} catch {
// ignore disconnect errors on already-disconnected nodes
}
try {
gain.disconnect?.();
} catch {
// ignore disconnect errors on already-disconnected nodes
}
};
if (typeof osc.addEventListener === 'function') {
osc.addEventListener('ended', cleanup, { once: true });
} else {
osc.onended = cleanup;
}
osc.start?.(t0);
osc.stop?.(t1);
} catch (err) {
warnOnce(err);
}
}
/** Play a sound effect by name or synthesized ToneSpec using the shared AudioContext */
playSound(soundOrSpec?: string | ToneSpec): void {
if (typeof soundOrSpec === 'object' && soundOrSpec !== null) {
this.playTone(soundOrSpec);
return;
}
if (typeof soundOrSpec === 'string' && this.buffers.has(soundOrSpec)) {
this.playSfx(soundOrSpec);
return;
}
this.playTone({
type: 'triangle',
from: 160,
to: 80,
gain: 0.25,
durationS: 0.12,
delayS: 0,
});
}
/** Play an item drop sound effect by name using the shared AudioContext */
playItemDropSound(soundName?: string): void {
this.playSound(soundName);
}
/** Play a sound effect one-shot */
playSfx(name: string): void {
if (name === 'inventory_full' && !this.buffers.has(name)) {
this.playTone({
type: 'sawtooth',
from: 175,
to: 90,
gain: 0.3,
durationS: 0.3,
delayS: 0,
});
this.playTone({
type: 'triangle',
from: 110,
to: 45,
gain: 0.2,
durationS: 0.1,
delayS: 0.35,
});
return;
}
const ctx = this.ensureContext();
if (!ctx || !this.sfxGain) return;
const buffer = this.buffers.get(name);
if (!buffer) return;
try {
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(this.sfxGain);
const cleanup = () => {
try {
source.disconnect?.();
} catch {
// ignore
}
};
if (typeof source.addEventListener === 'function') {
source.addEventListener('ended', cleanup, { once: true });
} else {
source.onended = cleanup;
}
source.start();
} catch (err) {
warnOnce(err);
}
}
/** Play music (loops, switches without crossfade) */
playMusic(name: string): void {
const ctx = this.ensureContext();
if (!ctx || !this.musicGain) return;
const buffer = this.buffers.get(name);
if (!buffer) return;
try {
// Stop current music
if (this.currentMusicSource) {
this.currentMusicSource.stop();
this.currentMusicSource.disconnect();
this.currentMusicSource = null;
}
const source = ctx.createBufferSource();
source.buffer = buffer;
source.loop = true;
source.connect(this.musicGain);
source.start();
this.currentMusicSource = source;
} catch (err) {
warnOnce(err);
}
}
/** Close the shared AudioContext and release audio resources */
dispose(): void {
if (this.currentMusicSource) {
try {
this.currentMusicSource.stop();
this.currentMusicSource.disconnect();
} catch {
// ignore
}
this.currentMusicSource = null;
}
const ctxToClose = this.ctx ?? sharedCtx;
this.ctx = null;
this.masterGain = null;
this.sfxGain = null;
this.musicGain = null;
sharedCtx = null;
sharedCtxCtor = null;
sharedMasterGain = null;
sharedSfxGain = null;
sharedMusicGain = null;
warnedAudioFailure = false;
if (ctxToClose && ctxToClose.state !== 'closed' && typeof ctxToClose.close === 'function') {
void Promise.resolve(ctxToClose.close()).catch(() => {});
}
}
static getShared(): AudioManager {
return sharedAudioManager;
}
static playTone(spec: ToneSpec): void {
sharedAudioManager.playTone(spec);
}
static playSound(soundOrSpec?: string | ToneSpec): void {
sharedAudioManager.playSound(soundOrSpec);
}
static playSfx(name: string): void {
sharedAudioManager.playSfx(name);
}
static dispose(): void {
sharedAudioManager.dispose();
}
static resetSharedContextForTesting(): void {
sharedAudioManager.dispose();
}
}
export class SoundManager extends AudioManager {}
export const sharedAudioManager = new AudioManager();