import { logger } from "@/utils/core/Logger"; export type AudioCategory = "music" | "sfx" | "dialogue"; export type OneShotAudioCategory = Exclude; interface AudioContextWindow extends Window { webkitAudioContext?: typeof AudioContext; } const DEFAULT_CATEGORY_VOLUMES: Record = { music: 1, sfx: 1, dialogue: 1, }; interface PlaySoundOptions { category?: OneShotAudioCategory; pan?: number; playbackRate?: number; } interface StereoNodes { source: MediaElementAudioSourceNode; panner: StereoPannerNode; } interface OneShotAudioState { category: OneShotAudioCategory; volume: number; } export class AudioManager { private static _instance: AudioManager | null = null; private readonly _audioPools = new Map(); private readonly _stereoNodes = new WeakMap(); private readonly _oneShotStates = new WeakMap< HTMLAudioElement, OneShotAudioState >(); private readonly _categoryVolumes: Record = { ...DEFAULT_CATEGORY_VOLUMES, }; private _audioContext: AudioContext | null = null; private _music: HTMLAudioElement | null = null; private _musicPath: string | null = null; private _musicVolume = 1; private _musicUnlockHandler: (() => void) | null = null; private static readonly MAX_POOL_SIZE_PER_SOUND = 6; private static readonly DEFAULT_SOUND_CATEGORY: OneShotAudioCategory = "sfx"; private static readonly IGNORED_PLAYBACK_ERRORS = new Set([ "AbortError", "NotAllowedError", ]); static getInstance(): AudioManager { if (!AudioManager._instance) { AudioManager._instance = new AudioManager(); } return AudioManager._instance; } private constructor() {} setCategoryVolume(category: AudioCategory, volume: number): void { this._categoryVolumes[category] = AudioManager._clampVolume(volume); if (category === "music" && this._music) { this._music.volume = this._getEffectiveVolume("music", this._musicVolume); return; } this._updateOneShotVolumes(category); } getCategoryVolume(category: AudioCategory): number { return this._categoryVolumes[category]; } playSound( path: string, volume = 1, options: PlaySoundOptions = {}, ): HTMLAudioElement { const audio = this._acquireAudio(path); const category = options.category ?? AudioManager.DEFAULT_SOUND_CATEGORY; const baseVolume = AudioManager._clampVolume(volume); this._oneShotStates.set(audio, { category, volume: baseVolume }); audio.volume = this._getEffectiveVolume(category, baseVolume); audio.playbackRate = options.playbackRate ?? 1; audio.currentTime = 0; this._setStereoPan(audio, options.pan ?? 0); if (this._audioContext?.state === "suspended") { void this._audioContext.resume(); } void audio.play().catch((error: unknown) => { if ( error instanceof DOMException && AudioManager.IGNORED_PLAYBACK_ERRORS.has(error.name) ) { return; } logger.error("AudioManager", "Failed to play sound", { path, category, error: AudioManager._toLogValue(error), }); }); return audio; } playSoundWithCallback( path: string, volume: number, onEnded: () => void, options: PlaySoundOptions = {}, ): HTMLAudioElement { const audio = this.playSound(path, volume, options); audio.addEventListener("ended", onEnded, { once: true }); return audio; } playMusic(path: string, volume = 1): void { this._musicVolume = AudioManager._clampVolume(volume); if (this._musicPath === path && this._music) { this._music.volume = this._getEffectiveVolume("music", this._musicVolume); if (!this._music.paused) return; } else { this.stopMusic(); this._music = new Audio(path); this._music.loop = true; this._musicPath = path; } this._music.volume = this._getEffectiveVolume("music", this._musicVolume); void this._music.play().catch((error: unknown) => { if ( error instanceof DOMException && AudioManager.IGNORED_PLAYBACK_ERRORS.has(error.name) ) { this._waitForUserGestureToPlayMusic(); return; } logger.error("AudioManager", "Failed to play music", { path, error: AudioManager._toLogValue(error), }); }); } stopMusic(): void { this._removeMusicUnlockHandler(); this._music?.pause(); this._music = null; this._musicPath = null; } destroy(): void { this.stopMusic(); this._audioPools.forEach((pool) => { pool.forEach((audio) => { audio.pause(); audio.src = ""; }); }); this._audioPools.clear(); void this._audioContext?.close(); this._audioContext = null; AudioManager._instance = null; } private _acquireAudio(path: string): HTMLAudioElement { const existingPool = this._audioPools.get(path); if (existingPool) { const availableAudio = existingPool.find( (audio) => audio.paused || audio.ended, ); if (availableAudio) return availableAudio; if (existingPool.length < AudioManager.MAX_POOL_SIZE_PER_SOUND) { const pooledAudio = new Audio(path); existingPool.push(pooledAudio); return pooledAudio; } const recycledAudio = existingPool[0]; if (recycledAudio) return recycledAudio; } const initialAudio = new Audio(path); this._audioPools.set(path, [initialAudio]); return initialAudio; } private _waitForUserGestureToPlayMusic(): void { if (this._musicUnlockHandler) return; this._musicUnlockHandler = () => { this._removeMusicUnlockHandler(); const music = this._music; if (!music) return; void music.play().catch((error: unknown) => { if ( error instanceof DOMException && AudioManager.IGNORED_PLAYBACK_ERRORS.has(error.name) ) { return; } logger.error("AudioManager", "Failed to unlock music playback", { path: this._musicPath, error: AudioManager._toLogValue(error), }); }); }; window.addEventListener("pointerdown", this._musicUnlockHandler, { once: true, }); window.addEventListener("keydown", this._musicUnlockHandler, { once: true, }); } private _removeMusicUnlockHandler(): void { if (!this._musicUnlockHandler) return; window.removeEventListener("pointerdown", this._musicUnlockHandler); window.removeEventListener("keydown", this._musicUnlockHandler); this._musicUnlockHandler = null; } private _setStereoPan(audio: HTMLAudioElement, pan: number): void { const audioContext = this._getAudioContext(); if (!audioContext || !("createStereoPanner" in audioContext)) return; let nodes = this._stereoNodes.get(audio); if (!nodes) { nodes = { source: audioContext.createMediaElementSource(audio), panner: audioContext.createStereoPanner(), }; nodes.source.connect(nodes.panner).connect(audioContext.destination); this._stereoNodes.set(audio, nodes); } nodes.panner.pan.value = AudioManager._clampPan(pan); } private _getAudioContext(): AudioContext | null { if (this._audioContext) return this._audioContext; const AudioContextConstructor = window.AudioContext ?? (window as AudioContextWindow).webkitAudioContext ?? null; if (!AudioContextConstructor) return null; this._audioContext = new AudioContextConstructor(); return this._audioContext; } private _getEffectiveVolume(category: AudioCategory, volume: number): number { return AudioManager._clampVolume(volume) * this._categoryVolumes[category]; } private _updateOneShotVolumes(category: AudioCategory): void { if (category === "music") return; this._audioPools.forEach((pool) => { pool.forEach((audio) => { const state = this._oneShotStates.get(audio); if (!state || state.category !== category) return; audio.volume = this._getEffectiveVolume(category, state.volume); }); }); } private static _clampPan(pan: number): number { return Math.max(-1, Math.min(1, pan)); } private static _clampVolume(volume: number): number { return Math.max(0, Math.min(1, volume)); } private static _toLogValue(error: unknown): Error | DOMException | string { if (error instanceof Error || error instanceof DOMException) { return error; } return String(error); } }