import { combatEffectManifestEntry, type CombatActorGroup, type CombatEffectSoundAsset, type CombatPresentationEvent, type CombatPresentationSettings, } from "./combatPresentation"; import { combatMediaPackReady, fetchOptionalMediaAsset, } from "./combatMediaPack"; const MAX_COMBAT_VOICES = 32; const MAX_CACHED_AUDIO_BUFFERS = 64; const MAX_CACHED_AUDIO_BYTES = 32 * 1024 * 1024; interface ActiveVoice { readonly group: CombatActorGroup; readonly castLoop?: ActiveCastLoop; readonly stop: () => void; } interface ActiveCastLoop { readonly group: CombatActorGroup; readonly timeout: ReturnType; cancelled: boolean; 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 activeCastLoops = new Map(); const activeMasterVoices = new Set<() => void>(); const decodedBuffers = new Map>(); const decodedBufferBytes = new Map(); let cachedAudioBytes = 0; function removeCachedAudio(url: string): void { decodedBuffers.delete(url); cachedAudioBytes -= decodedBufferBytes.get(url) ?? 0; decodedBufferBytes.delete(url); } function trimCachedAudio(): void { while (decodedBuffers.size > MAX_CACHED_AUDIO_BUFFERS || cachedAudioBytes > MAX_CACHED_AUDIO_BYTES) { const oldest = decodedBuffers.keys().next().value; if (oldest === undefined) break; // Active sources retain their own buffer; eviction never stops playback. removeCachedAudio(oldest); } } 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 )); } export function authenticCombatAudioEnabled(settings: CombatPresentationSettings): boolean { return settings.originalMediaEnabled && combatMediaPackReady(); } export function combatMediaPreloadEnabled( settings: CombatPresentationSettings, masterVolume: number, ): boolean { return authenticCombatAudioEnabled(settings) && ( settings.vfxQuality !== "off" || combatAudioOutputEnabled(settings, masterVolume) ); } 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 position = event.phase === "impact" ? event.targetPosition ?? event.origin : event.origin; 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 = position[0]; panner.positionY.value = position[1]; panner.positionZ.value = position[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); }; } function castLoopKey(event: CombatPresentationEvent): string { return `${event.sessionId}:${event.sourceActorId}:${event.abilityId}`; } function finishCastLoop(key: string, loop: ActiveCastLoop): void { if (activeCastLoops.get(key) !== loop) return; loop.cancelled = true; clearTimeout(loop.timeout); activeCastLoops.delete(key); } function stopCastLoop(key: string): void { const loop = activeCastLoops.get(key); if (!loop) return; finishCastLoop(key, loop); loop.stop?.(); } function beginCastLoop(event: CombatPresentationEvent): ActiveCastLoop { const key = castLoopKey(event); stopCastLoop(key); const durationMs = Number.isFinite(event.durationMs) ? Math.max(0, event.durationMs ?? 0) : 4_500; const loop: ActiveCastLoop = { group: event.actorGroup, cancelled: false, timeout: setTimeout(() => stopCastLoop(key), durationMs), }; activeCastLoops.set(key, loop); return loop; } export function selectCombatSoundAssets( sounds: readonly CombatEffectSoundAsset[], event: Pick, ): readonly CombatEffectSoundAsset[] { const exact = sounds.filter((sound) => sound.url && (!sound.phase || sound.phase === event.phase)); if (exact.length) return exact; return event.phase === "release" && event.instantCast ? sounds.filter((sound) => sound.url && sound.phase === "cast-start") : []; } function soundsForEvent(event: CombatPresentationEvent): readonly CombatEffectSoundAsset[] { if (!configuredSettings || !authenticCombatAudioEnabled(configuredSettings)) return []; const sounds = combatEffectManifestEntry(event.sourceSpellId, event.source)?.sounds ?? []; const playable = selectCombatSoundAssets(sounds, event); if (!playable.length) return []; // RuneWaker ImageObject and ROS callbacks are independent client sound // layers. Choosing one at random drops cast fades or companion impact cues. if (event.source === "runewaker") return playable; 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.length ? [playable[playable.length - 1]] : []; } async function decodeAudio(url: string): Promise { const audioContext = ensureContext(); if (!audioContext) return null; const cached = decodedBuffers.get(url); if (cached) { decodedBuffers.delete(url); decodedBuffers.set(url, cached); return cached; } const request = fetchOptionalMediaAsset(url) .then((response) => response.ok ? response.arrayBuffer() : Promise.reject(new Error(String(response.status)))) .then((data) => audioContext.decodeAudioData(data)) .then((buffer) => { if (decodedBuffers.get(url) === request) { const bytes = buffer.length * buffer.numberOfChannels * Float32Array.BYTES_PER_ELEMENT; decodedBufferBytes.set(url, Number.isFinite(bytes) ? Math.max(0, bytes) : 0); cachedAudioBytes += decodedBufferBytes.get(url)!; trimCachedAudio(); } return buffer; }) .catch(() => { if (decodedBuffers.get(url) === request) removeCachedAudio(url); return null; }); decodedBuffers.set(url, request); trimCachedAudio(); return request; } export async function playMasterAudioCue( url: string, volume = 1, ): Promise<(() => void) | null> { if (!configuredSettings || !authenticCombatAudioEnabled(configuredSettings) || !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, castLoop?: ActiveCastLoop, ): 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 || castLoop?.cancelled ) return false; void audioContext.resume().catch(() => undefined); const source = audioContext.createBufferSource(); const envelope = audioContext.createGain(); const panner = positionPanner(audioContext, event); source.buffer = buffer; if (castLoop) source.loop = true; 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, castLoop, stop: () => { if (stopped) return; stopped = true; try { source.stop(); } catch { /* already stopped */ } }, }; const unregister = registerVoice(voice); if (castLoop) castLoop.stop = voice.stop; source.onended = () => { unregister(); if (castLoop) finishCastLoop(castLoopKey(event), castLoop); source.disconnect(); envelope.disconnect(); panner.disconnect(); }; source.start(); return true; } const GENERATED_SCHOOL_FREQUENCY: Readonly> = { physical: 150, holy: 520, fire: 230, nature: 330, frost: 410, shadow: 110, arcane: 620, }; function playGeneratedCombatCue(event: CombatPresentationEvent): boolean { if ( !configuredSettings || !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup) ) return false; const audioContext = ensureContext(); if (!audioContext || !groupGains) return false; void audioContext.resume().catch(() => undefined); const oscillator = audioContext.createOscillator(); const envelope = audioContext.createGain(); const panner = positionPanner(audioContext, event); const duration = event.phase === "cast-start" ? 0.12 : event.phase === "aura-start" ? 0.2 : 0.09; const phasePitch = event.phase === "release" ? 1.15 : event.phase === "impact" ? 0.82 : 1; oscillator.type = event.school === "shadow" || event.school === "physical" ? "triangle" : "sine"; oscillator.frequency.setValueAtTime( GENERATED_SCHOOL_FREQUENCY[event.school] * phasePitch, audioContext.currentTime, ); envelope.gain.setValueAtTime(0.0001, audioContext.currentTime); envelope.gain.exponentialRampToValueAtTime(0.075, audioContext.currentTime + 0.01); envelope.gain.exponentialRampToValueAtTime(0.0001, audioContext.currentTime + 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 = () => { stopped = true; unregister(); oscillator.disconnect(); envelope.disconnect(); panner.disconnect(); }; oscillator.start(); oscillator.stop(audioContext.currentTime + duration); return true; } export function playCombatAudio(event: CombatPresentationEvent): void { if (event.phase === "release" || event.phase === "cast-cancel") { stopCastLoop(castLoopKey(event)); } if (event.phase === "cast-cancel") { // A cancelled cast still cuts off legacy one-shot voices for this group. // Other actors' active cast loops keep playing until their own terminal event. for (const voice of [...activeVoices]) { if (voice.group === event.actorGroup && !voice.castLoop) voice.stop(); } return; } if (event.abilityId === "wow335-priest-penance" && event.sourceSpellId === 47540 && (event.phase === "release" || event.phase === "impact")) return; if ( !configuredSettings || !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup) ) return; const sounds = soundsForEvent(event); if (sounds.length) { const loopingSound = event.phase === "cast-start" ? sounds.find((sound) => sound.loopDuringCast) : undefined; const castLoop = loopingSound ? beginCastLoop(event) : undefined; void Promise.all(sounds.map((sound) => playBufferedCombatCue( event, sound, sound === loopingSound ? castLoop : undefined, ).catch(() => false))).then((played) => { if (played.some(Boolean) || castLoop?.cancelled) return; if (castLoop) stopCastLoop(castLoopKey(event)); playGeneratedCombatCue(event); }); } else { playGeneratedCombatCue(event); } } export function stopCombatAudioGroup(group: CombatActorGroup): void { for (const [key, loop] of activeCastLoops) { if (loop.group === group) stopCastLoop(key); } for (const voice of [...activeVoices]) { if (voice.group === group) voice.stop(); } } export function resetCombatAudio(): void { for (const key of activeCastLoops.keys()) stopCastLoop(key); 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; }