import { logger } from "@/utils/core/Logger"; interface PlaySoundOptions { playbackRate?: number; } export class AudioManager { private static _instance: AudioManager | null = null; private readonly _audioPools = new Map(); private _music: HTMLAudioElement | null = null; private _musicPath: string | null = null; private _musicUnlockHandler: (() => void) | null = null; private static readonly MAX_POOL_SIZE_PER_SOUND = 6; 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() {} playSound(path: string, volume = 1, options: PlaySoundOptions = {}): void { const audio = this._acquireAudio(path); audio.volume = Math.max(0, Math.min(1, volume)); audio.playbackRate = options.playbackRate ?? 1; audio.currentTime = 0; 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, error: AudioManager._toLogValue(error), }); }); } playMusic(path: string, volume = 1): void { if (this._musicPath === path && this._music) { this._music.volume = Math.max(0, Math.min(1, volume)); if (!this._music.paused) return; } else { this.stopMusic(); this._music = new Audio(path); this._music.loop = true; this._musicPath = path; } this._music.volume = Math.max(0, Math.min(1, volume)); 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(); 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 static _toLogValue(error: unknown): Error | DOMException | string { if (error instanceof Error || error instanceof DOMException) { return error; } return String(error); } }