forked from phenom/healer-man
493 lines
17 KiB
TypeScript
493 lines
17 KiB
TypeScript
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<typeof setTimeout>;
|
|
cancelled: boolean;
|
|
stop?: () => void;
|
|
}
|
|
|
|
let context: AudioContext | null = null;
|
|
let masterGain: GainNode | null = null;
|
|
let groupGains: Record<CombatActorGroup, GainNode> | null = null;
|
|
let configuredSettings: CombatPresentationSettings | null = null;
|
|
let configuredMasterVolume = 0;
|
|
const activeVoices: ActiveVoice[] = [];
|
|
const activeCastLoops = new Map<string, ActiveCastLoop>();
|
|
const activeMasterVoices = new Set<() => void>();
|
|
const decodedBuffers = new Map<string, Promise<AudioBuffer | null>>();
|
|
const decodedBufferBytes = new Map<string, number>();
|
|
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<CombatPresentationEvent, "phase" | "instantCast">,
|
|
): 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<AudioBuffer | null> {
|
|
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<boolean> {
|
|
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<Record<CombatPresentationEvent["school"], number>> = {
|
|
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;
|
|
}
|