import { combatEffectManifestEntry, type CombatActorGroup, type CombatEffectSoundAsset, type CombatPresentationEvent, type CombatPresentationSettings, type CombatPresentationSchool, } from "./combatPresentation"; const MAX_COMBAT_VOICES = 32; interface ActiveVoice { readonly group: CombatActorGroup; readonly stop: () => void; } let context: AudioContext | null = null; let masterGain: GainNode | null = null; let groupGains: Record | null = null; let configuredSettings: CombatPresentationSettings | null = null; let configuredMasterVolume = 0; const activeVoices: ActiveVoice[] = []; const activeMasterVoices = new Set<() => void>(); const decodedBuffers = new Map>(); export function masterAudioOutputEnabled(masterVolume: number): boolean { return Number.isFinite(masterVolume) && masterVolume > 0; } export function combatAudioOutputEnabled( settings: CombatPresentationSettings, masterVolume: number, ): boolean { return masterAudioOutputEnabled(masterVolume) && (["player", "party", "enemy"] as const).some((group) => ( settings.audioEnabled[group] && settings.audioVolume[group] > 0 )); } function combatAudioGroupOutputEnabled( settings: CombatPresentationSettings, masterVolume: number, group: CombatActorGroup, ): boolean { return masterAudioOutputEnabled(masterVolume) && settings.audioEnabled[group] && settings.audioVolume[group] > 0; } function audioContextConstructor(): typeof AudioContext | null { if (typeof window === "undefined") return null; return window.AudioContext ?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext ?? null; } function ensureContext(): AudioContext | null { if (context) return context; const Constructor = audioContextConstructor(); if (!Constructor) return null; context = new Constructor(); masterGain = context.createGain(); masterGain.connect(context.destination); groupGains = { player: context.createGain(), party: context.createGain(), enemy: context.createGain(), }; for (const gain of Object.values(groupGains)) gain.connect(masterGain); applyCombatAudioSettings(); return context; } function applyCombatAudioSettings(): void { if (!context || !masterGain || !groupGains || !configuredSettings) return; masterGain.gain.setTargetAtTime(configuredMasterVolume, context.currentTime, 0.02); for (const group of ["player", "party", "enemy"] as const) { const volume = configuredSettings.audioEnabled[group] ? configuredSettings.audioVolume[group] : 0; groupGains[group].gain.setTargetAtTime(volume, context.currentTime, 0.02); if (volume <= 0) stopCombatAudioGroup(group); } } export function configureCombatAudio( settings: CombatPresentationSettings, masterVolume: number, ): void { configuredSettings = settings; configuredMasterVolume = Number.isFinite(masterVolume) ? Math.max(0, Math.min(1, masterVolume)) : 0; if (!masterAudioOutputEnabled(configuredMasterVolume)) { resetCombatAudio(); if (context?.state === "running") void context.suspend().catch(() => undefined); } applyCombatAudioSettings(); } function positionPanner( audioContext: AudioContext, event: CombatPresentationEvent, ): PannerNode | StereoPannerNode { if (typeof audioContext.createPanner === "function") { const panner = audioContext.createPanner(); panner.panningModel = "HRTF"; panner.distanceModel = "inverse"; panner.refDistance = event.actorGroup === "player" ? 8 : 5; panner.maxDistance = 55; panner.rolloffFactor = event.actorGroup === "player" ? 0.15 : 0.8; panner.positionX.value = event.origin[0]; panner.positionY.value = event.origin[1]; panner.positionZ.value = event.origin[2]; return panner; } return audioContext.createStereoPanner(); } function trimVoices(): void { while (activeVoices.length >= MAX_COMBAT_VOICES) activeVoices.shift()?.stop(); } function registerVoice(voice: ActiveVoice): () => void { trimVoices(); activeVoices.push(voice); return () => { const index = activeVoices.indexOf(voice); if (index >= 0) activeVoices.splice(index, 1); }; } const SCHOOL_FREQUENCY: Readonly> = { physical: 115, arcane: 520, fire: 190, frost: 720, nature: 360, shadow: 145, holy: 660, }; function playSynthesizedCombatCue(event: CombatPresentationEvent): void { if ( !configuredSettings || !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup) ) return; const audioContext = ensureContext(); if (!audioContext || !groupGains || !configuredSettings?.audioEnabled[event.actorGroup]) return; void audioContext.resume().catch(() => undefined); const oscillator = audioContext.createOscillator(); const envelope = audioContext.createGain(); const panner = positionPanner(audioContext, event); const start = audioContext.currentTime; const isImpact = event.phase === "impact" || event.delivery === "melee"; const duration = event.phase === "cast-start" ? 0.22 : isImpact ? 0.12 : event.phase === "tick" ? 0.1 : 0.18; const base = SCHOOL_FREQUENCY[event.school]; oscillator.type = event.school === "physical" ? "sawtooth" : event.school === "holy" ? "sine" : "triangle"; oscillator.frequency.setValueAtTime(base * (event.phase === "cast-start" ? 0.7 : 1), start); oscillator.frequency.exponentialRampToValueAtTime(Math.max(35, base * (isImpact ? 0.42 : 1.55)), start + duration); envelope.gain.setValueAtTime(0.0001, start); envelope.gain.exponentialRampToValueAtTime(event.actorGroup === "player" ? 0.12 : 0.085, start + 0.015); envelope.gain.exponentialRampToValueAtTime(0.0001, start + duration); oscillator.connect(envelope); envelope.connect(panner); panner.connect(groupGains[event.actorGroup]); let stopped = false; const voice: ActiveVoice = { group: event.actorGroup, stop: () => { if (stopped) return; stopped = true; try { oscillator.stop(); } catch { /* already stopped */ } }, }; const unregister = registerVoice(voice); oscillator.onended = () => { unregister(); oscillator.disconnect(); envelope.disconnect(); panner.disconnect(); }; oscillator.start(start); oscillator.stop(start + duration + 0.02); } function weightedSound(event: CombatPresentationEvent): CombatEffectSoundAsset | null { const sounds = combatEffectManifestEntry(event.sourceSpellId, event.source)?.sounds ?? []; const playable = sounds.filter((sound) => sound.url); if (!playable.length) return null; const total = playable.reduce((sum, sound) => sum + Math.max(1, sound.weight ?? 1), 0); let cursor = (event.id * 0.61803398875 % 1) * total; for (const sound of playable) { cursor -= Math.max(1, sound.weight ?? 1); if (cursor <= 0) return sound; } return playable[playable.length - 1] ?? null; } async function decodeAudio(url: string): Promise { const audioContext = ensureContext(); if (!audioContext) return null; if (!decodedBuffers.has(url)) { decodedBuffers.set(url, fetch(url) .then((response) => response.ok ? response.arrayBuffer() : Promise.reject(new Error(String(response.status)))) .then((data) => audioContext.decodeAudioData(data)) .catch(() => null)); } return decodedBuffers.get(url)!; } export async function playMasterAudioCue( url: string, volume = 1, ): Promise<(() => void) | null> { if (!masterAudioOutputEnabled(configuredMasterVolume)) return null; const audioContext = ensureContext(); const buffer = await decodeAudio(url); if ( !masterAudioOutputEnabled(configuredMasterVolume) || !audioContext || !buffer || !masterGain ) return null; void audioContext.resume().catch(() => undefined); const source = audioContext.createBufferSource(); const envelope = audioContext.createGain(); source.buffer = buffer; envelope.gain.value = Math.max(0, Math.min(1, volume)); source.connect(envelope); envelope.connect(masterGain); let stopped = false; const stop = () => { if (stopped) return; stopped = true; activeMasterVoices.delete(stop); try { source.stop(); } catch { /* already stopped */ } }; activeMasterVoices.add(stop); source.onended = () => { stopped = true; activeMasterVoices.delete(stop); source.disconnect(); envelope.disconnect(); }; source.start(); return stop; } async function playBufferedCombatCue(event: CombatPresentationEvent, sound: CombatEffectSoundAsset): Promise { if (!sound.url) return false; if ( !configuredSettings || !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup) ) return false; const audioContext = ensureContext(); const buffer = await decodeAudio(sound.url); if ( !configuredSettings || !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup) || !audioContext || !buffer || !groupGains ) return false; void audioContext.resume().catch(() => undefined); const source = audioContext.createBufferSource(); const envelope = audioContext.createGain(); const panner = positionPanner(audioContext, event); source.buffer = buffer; envelope.gain.value = Math.max(0, Math.min(1, sound.volume ?? 1)); source.connect(envelope); envelope.connect(panner); panner.connect(groupGains[event.actorGroup]); let stopped = false; const voice: ActiveVoice = { group: event.actorGroup, stop: () => { if (stopped) return; stopped = true; try { source.stop(); } catch { /* already stopped */ } }, }; const unregister = registerVoice(voice); source.onended = () => { unregister(); source.disconnect(); envelope.disconnect(); panner.disconnect(); }; source.start(); return true; } export function playCombatAudio(event: CombatPresentationEvent): void { if (event.phase === "cast-cancel") { stopCombatAudioGroup(event.actorGroup); return; } if ( !configuredSettings || !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup) ) return; const sound = weightedSound(event); if (!sound) { playSynthesizedCombatCue(event); return; } void playBufferedCombatCue(event, sound).then((played) => { if (!played) playSynthesizedCombatCue(event); }); } export function stopCombatAudioGroup(group: CombatActorGroup): void { for (const voice of [...activeVoices]) { if (voice.group === group) voice.stop(); } } export function resetCombatAudio(): void { for (const voice of [...activeVoices]) voice.stop(); for (const stop of [...activeMasterVoices]) stop(); activeVoices.length = 0; } export function setCombatAudioListener( position: readonly [number, number, number], forward: readonly [number, number, number], up: readonly [number, number, number] = [0, 1, 0], ): void { if (!context) return; const listener = context.listener; const now = context.currentTime; listener.positionX?.setTargetAtTime(position[0], now, 0.01); listener.positionY?.setTargetAtTime(position[1], now, 0.01); listener.positionZ?.setTargetAtTime(position[2], now, 0.01); listener.forwardX?.setTargetAtTime(forward[0], now, 0.01); listener.forwardY?.setTargetAtTime(forward[1], now, 0.01); listener.forwardZ?.setTargetAtTime(forward[2], now, 0.01); listener.upX?.setTargetAtTime(up[0], now, 0.01); listener.upY?.setTargetAtTime(up[1], now, 0.01); listener.upZ?.setTargetAtTime(up[2], now, 0.01); } export function activeCombatAudioVoiceCount(): number { return activeVoices.length; }