diff --git a/src/managers/AudioManager.ts b/src/managers/AudioManager.ts index 5ac796b..b227c61 100644 --- a/src/managers/AudioManager.ts +++ b/src/managers/AudioManager.ts @@ -3,6 +3,10 @@ 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, @@ -11,15 +15,23 @@ const DEFAULT_CATEGORY_VOLUMES: Record = { interface PlaySoundOptions { category?: OneShotAudioCategory; + pan?: number; playbackRate?: number; } +interface StereoNodes { + source: MediaElementAudioSourceNode; + panner: StereoPannerNode; +} + export class AudioManager { private static _instance: AudioManager | null = null; private readonly _audioPools = new Map(); + private readonly _stereoNodes = new WeakMap(); 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; @@ -64,6 +76,11 @@ export class AudioManager { audio.volume = this._getEffectiveVolume(category, volume); 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 ( @@ -130,6 +147,8 @@ export class AudioManager { }); }); this._audioPools.clear(); + void this._audioContext?.close(); + this._audioContext = null; AudioManager._instance = null; } @@ -181,10 +200,44 @@ export class AudioManager { 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 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)); }