337 lines
11 KiB
TypeScript
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();
|
|
|