Files
heros_web/src/game/audio/SoundDirector.ts

1047 lines
32 KiB
TypeScript

type AudioTrackMap = Record<string, string>;
export type AudioCategory = 'music' | 'effects' | 'ambience';
export type AudioCategoryMultipliers = Record<AudioCategory, number>;
export type EffectPoolMap = Record<string, readonly string[]>;
export type StrategyImpactKind = 'fire' | 'roar' | 'arcane';
export type SoundscapeOptions = {
musicKey?: string;
ambienceKey?: string;
musicVolume?: number;
ambienceVolume?: number;
};
type LoopChannelName = Exclude<AudioCategory, 'effects'>;
type LoopChannelTransition = {
revision: number;
completedRevision: number;
active: boolean;
last?: {
fromKey: string | null;
toKey: string | null;
durationMs: number;
};
};
type LoopChannel = {
name: LoopChannelName;
tracks: AudioTrackMap;
defaultVolume: number;
targetVolume: number;
fadeDurationMs: number;
current?: HTMLAudioElement;
currentKey?: string;
pendingKey?: string;
hasPendingRequest: boolean;
outgoing: Set<HTMLAudioElement>;
outgoingStartVolumes: Map<HTMLAudioElement, number>;
elementBaseVolumes: Map<HTMLAudioElement, number>;
fadeTimer?: number;
elementRevision: number;
transition: LoopChannelTransition;
};
export type PlayEffectOptions = {
volume?: number;
rate?: number;
stopAfterMs?: number;
};
export type MusicDuckOptions = {
multiplier?: number;
attackMs?: number;
holdMs?: number;
releaseMs?: number;
};
type SemanticCueGroup = 'turn' | 'operation' | 'objective' | 'narrative' | 'recovery' | 'status' | 'reward';
type MusicDuckState = {
multiplier: number;
timer?: number;
revision: number;
completedRevision: number;
active: boolean;
endsAt: number;
last?: {
multiplier: number;
attackMs: number;
holdMs: number;
releaseMs: number;
};
};
type PlayedEffectDebug = {
key: string;
trackKey: string;
poolKey: string | null;
at: number;
baseVolume: number;
volume: number;
rate: number;
};
export class SoundDirector {
private context?: AudioContext;
private master?: GainNode;
private effectsBus?: GainNode;
private started = false;
private muted = false;
private effectTracks: AudioTrackMap = {};
private effectPools: Record<string, string[]> = {};
private readonly lastEffectPoolSelections = new Map<string, string>();
private readonly activeEffects = new Set<HTMLAudioElement>();
private readonly effectBaseVolumes = new Map<HTMLAudioElement, number>();
private readonly musicChannel = this.createLoopChannel('music', 0.22, 900);
private readonly ambienceChannel = this.createLoopChannel('ambience', 0.08, 1200);
private readonly categoryMultipliers: AudioCategoryMultipliers = {
music: 1,
effects: 1,
ambience: 1
};
private readonly musicDuck: MusicDuckState = {
multiplier: 1,
revision: 0,
completedRevision: 0,
active: false,
endsAt: 0
};
private readonly semanticCueCooldownMs: Record<SemanticCueGroup, number> = {
turn: 900,
operation: 650,
objective: 1200,
narrative: 220,
recovery: 1900,
status: 280,
reward: 1100
};
private readonly semanticCuePlayedAt = new Map<SemanticCueGroup, number>();
private lastPlayedSemanticCue?: { group: SemanticCueGroup; key: string; at: number };
private lastSuppressedSemanticCue?: {
group: SemanticCueGroup;
key: string;
at: number;
remainingMs: number;
};
private lastEffect?: PlayedEffectDebug;
private readonly recentEffects: PlayedEffectDebug[] = [];
private lastMovementStep?: { key: string; mounted: boolean; stepIndex: number; at: number; volume?: number; rate?: number };
private readonly effectVolume = 0.42;
registerMusicTracks(tracks: AudioTrackMap) {
this.registerLoopTracks(this.musicChannel, tracks);
}
registerAmbienceTracks(tracks: AudioTrackMap) {
this.registerLoopTracks(this.ambienceChannel, tracks);
}
registerEffectTracks(tracks: AudioTrackMap) {
this.effectTracks = tracks;
}
registerEffectPools(pools: EffectPoolMap) {
this.effectPools = Object.fromEntries(
Object.entries(pools).map(([key, trackKeys]) => [
key,
[...new Set(trackKeys.filter((trackKey) => typeof trackKey === 'string' && trackKey.length > 0))]
])
);
this.lastEffectPoolSelections.clear();
}
start() {
if (this.started) {
this.resume();
this.applyPendingLoopRequest(this.musicChannel);
this.applyPendingLoopRequest(this.ambienceChannel);
return;
}
const AudioContextCtor = window.AudioContext ?? window.webkitAudioContext;
if (AudioContextCtor) {
this.context = new AudioContextCtor();
this.master = this.context.createGain();
this.master.gain.value = this.muted ? 0 : 0.3;
this.master.connect(this.context.destination);
this.effectsBus = this.context.createGain();
this.effectsBus.gain.value = this.categoryMultipliers.effects;
this.effectsBus.connect(this.master);
}
this.started = true;
this.resume();
this.applyPendingLoopRequest(this.musicChannel);
this.applyPendingLoopRequest(this.ambienceChannel);
}
resume() {
void this.context?.resume();
if (!this.started) {
return;
}
for (const element of this.loopElements()) {
void element.play().catch(() => undefined);
}
}
setMuted(muted: boolean) {
this.muted = muted;
for (const element of this.loopElements()) {
element.muted = muted;
}
for (const effect of this.activeEffects) {
effect.muted = muted;
}
if (!this.context || !this.master) {
return;
}
this.master.gain.cancelScheduledValues(this.context.currentTime);
this.master.gain.linearRampToValueAtTime(muted ? 0 : 0.3, this.context.currentTime + 0.18);
}
isMuted() {
return this.muted;
}
setCategoryMultiplier(category: AudioCategory, multiplier: number) {
const normalizedMultiplier = this.normalizeUnitInterval(multiplier, this.categoryMultipliers[category]);
this.categoryMultipliers[category] = normalizedMultiplier;
if (category === 'effects') {
if (this.effectsBus) {
this.effectsBus.gain.value = normalizedMultiplier;
}
this.refreshActiveEffectVolumes();
return;
}
this.refreshLoopChannelVolumes(category === 'music' ? this.musicChannel : this.ambienceChannel);
}
setCategoryMultipliers(multipliers: Partial<AudioCategoryMultipliers>) {
(['music', 'effects', 'ambience'] as const).forEach((category) => {
const multiplier = multipliers[category];
if (multiplier !== undefined) {
this.setCategoryMultiplier(category, multiplier);
}
});
}
duckMusic({ multiplier = 0.42, attackMs = 48, holdMs = 180, releaseMs = 280 }: MusicDuckOptions = {}) {
this.cancelMusicDuckTimer();
const normalizedMultiplier = this.normalizeUnitInterval(multiplier, 0.42);
const normalizedAttackMs = this.normalizeDuration(attackMs, 48);
const normalizedHoldMs = this.normalizeDuration(holdMs, 180);
const normalizedReleaseMs = this.normalizeDuration(releaseMs, 280);
const fromMultiplier = this.musicDuck.multiplier;
const targetMultiplier = Math.min(fromMultiplier, normalizedMultiplier);
const startedAt = performance.now();
const previousEndsAt = this.musicDuck.active ? this.musicDuck.endsAt : startedAt;
const previousReleaseMs = this.musicDuck.active ? this.musicDuck.last?.releaseMs ?? 0 : 0;
const effectiveReleaseMs = Math.max(normalizedReleaseMs, previousReleaseMs);
const requestedEndsAt = startedAt + normalizedAttackMs + normalizedHoldMs + normalizedReleaseMs;
const endsAt = Math.max(previousEndsAt, requestedEndsAt);
const releaseStartsAt = Math.max(startedAt + normalizedAttackMs + normalizedHoldMs, endsAt - effectiveReleaseMs);
this.musicDuck.revision += 1;
this.musicDuck.active = true;
this.musicDuck.endsAt = endsAt;
this.musicDuck.last = {
multiplier: normalizedMultiplier,
attackMs: normalizedAttackMs,
holdMs: normalizedHoldMs,
releaseMs: normalizedReleaseMs
};
const revision = this.musicDuck.revision;
const update = () => {
if (revision !== this.musicDuck.revision) {
return;
}
const now = performance.now();
const elapsedMs = Math.max(0, now - startedAt);
let nextMultiplier = targetMultiplier;
if (normalizedAttackMs > 0 && elapsedMs < normalizedAttackMs) {
const progress = elapsedMs / normalizedAttackMs;
nextMultiplier = fromMultiplier + (targetMultiplier - fromMultiplier) * progress;
} else if (now >= releaseStartsAt) {
if (effectiveReleaseMs <= 0) {
nextMultiplier = 1;
} else {
const releaseProgress = Math.min(1, (now - releaseStartsAt) / effectiveReleaseMs);
nextMultiplier = targetMultiplier + (1 - targetMultiplier) * releaseProgress;
}
}
this.setMusicDuckMultiplier(nextMultiplier);
if (now >= endsAt) {
this.completeMusicDuck(revision);
}
};
update();
if (endsAt > startedAt && this.musicDuck.active) {
this.musicDuck.timer = window.setInterval(update, 16);
}
}
playHover() {
this.playTone(540, 0.04, 0.018, 'sine');
}
playSelect() {
if (this.playEffect('ui-select', { volume: 0.24, stopAfterMs: 420 })) {
return;
}
this.playTone(300, 0.07, 0.04, 'sine');
window.setTimeout(() => this.playTone(450, 0.08, 0.028, 'sine'), 70);
}
playGrowthTick() {
if (this.playEffect('exp-gain', { volume: 0.18, rate: 1.12, stopAfterMs: 360 })) {
return;
}
this.playTone(620, 0.05, 0.018, 'triangle');
}
playLevelUp() {
if (this.playEffect('level-up', { volume: 0.36, stopAfterMs: 1200 })) {
return;
}
this.playEffect('exp-gain', { volume: 0.42, rate: 1.08, stopAfterMs: 820 });
this.playTone(520, 0.08, 0.035, 'triangle');
window.setTimeout(() => this.playTone(720, 0.09, 0.04, 'triangle'), 82);
window.setTimeout(() => this.playTone(980, 0.12, 0.034, 'sine'), 168);
}
playMeleeRush(mounted: boolean) {
if (mounted) {
this.playEffect('horse-gallop', { volume: 0.2, rate: 1.18, stopAfterMs: 360 });
return;
}
this.playEffect('footstep-walk', { volume: 0.2, rate: 1.35, stopAfterMs: 320 });
}
playMovementStep(mounted: boolean, stepIndex: number, options: PlayEffectOptions = {}) {
const key = mounted ? 'horse-gallop' : 'footstep-walk';
this.lastMovementStep = { key, mounted, stepIndex, at: performance.now(), volume: options.volume, rate: options.rate };
const played = this.playEffect(key, options);
this.playStepPulse(mounted, stepIndex, options.volume);
return played;
}
playMeleeSwing(hit: boolean, critical: boolean) {
this.playEffect('melee-swing', {
volume: critical ? 0.62 : hit ? 0.52 : 0.38,
rate: critical ? 0.94 : hit ? 1 : 1.1,
stopAfterMs: 1700
});
this.playTone(critical ? 180 : 220, 0.04, critical ? 0.03 : 0.02, 'sawtooth');
}
playArrowShot() {
this.playEffect('bow-release', { volume: 0.34, rate: 1, stopAfterMs: 420 });
this.playTone(760, 0.05, 0.012, 'triangle');
window.setTimeout(() => this.playTone(1180, 0.045, 0.01, 'sine'), 46);
}
playArrowImpact(hit: boolean, critical: boolean) {
if (hit) {
this.playEffect('arrow-impact', {
volume: critical ? 0.5 : 0.38,
rate: critical ? 0.94 : 1,
stopAfterMs: 760
});
this.playTone(critical ? 260 : 340, 0.05, critical ? 0.026 : 0.018, 'square');
if (critical) {
this.playCriticalAccent();
}
return;
}
this.playEffect('arrow-miss', { volume: 0.28, rate: 1.04, stopAfterMs: 500 });
this.playTone(940, 0.06, 0.011, 'sine');
}
playCombatImpact(critical: boolean, restrained = false) {
this.playEffect('melee-impact', {
volume: restrained ? (critical ? 0.4 : 0.26) : critical ? 0.58 : 0.46,
rate: critical ? 0.94 : 1,
stopAfterMs: 1700
});
if (critical) {
this.playCriticalAccent();
}
}
playStrategyImpact(kind: StrategyImpactKind, critical: boolean) {
if (kind === 'fire') {
this.playEffect('burn-tick', {
volume: critical ? 0.34 : 0.28,
rate: critical ? 0.86 : 0.94,
stopAfterMs: 680
});
this.playTone(critical ? 150 : 185, 0.11, critical ? 0.026 : 0.018, 'sawtooth');
} else if (kind === 'roar') {
this.playEffect('combat-impact', {
volume: critical ? 0.4 : 0.3,
rate: critical ? 0.72 : 0.8,
stopAfterMs: 980
});
this.playTone(critical ? 92 : 118, 0.16, critical ? 0.03 : 0.022, 'triangle');
} else {
this.playEffect('strategy-cast', {
volume: critical ? 0.34 : 0.26,
rate: critical ? 1.12 : 1.2,
stopAfterMs: 720
});
this.playTone(critical ? 820 : 690, 0.12, critical ? 0.022 : 0.016, 'sine');
}
if (critical) {
this.playCriticalAccent();
}
}
playBattleOutcome(outcome: 'victory' | 'defeat') {
this.duckMusic({
multiplier: outcome === 'victory' ? 0.34 : 0.24,
attackMs: 90,
holdMs: 820,
releaseMs: 520
});
this.playEffect(outcome === 'victory' ? 'victory-fanfare' : 'defeat-sting', {
volume: outcome === 'victory' ? 0.34 : 0.26,
stopAfterMs: 1200
});
}
playAllyTurnCue() {
return this.playSemanticCue('turn', 'ally-turn', {
volume: 0.32,
stopAfterMs: 2100,
duck: { multiplier: 0.62, attackMs: 36, holdMs: 1050, releaseMs: 420 }
});
}
playEnemyTurnCue() {
return this.playSemanticCue('turn', 'enemy-turn', {
volume: 0.3,
stopAfterMs: 2400,
duck: { multiplier: 0.56, attackMs: 36, holdMs: 1350, releaseMs: 480 }
});
}
playOperationAlert() {
return this.playSemanticCue('operation', 'operation-alert', {
volume: 0.34,
stopAfterMs: 2100,
duck: { multiplier: 0.48, attackMs: 28, holdMs: 820, releaseMs: 430 }
});
}
playObjectiveAchieved() {
return this.playSemanticCue('objective', 'objective-success', {
volume: 0.34,
stopAfterMs: 1700,
duck: { multiplier: 0.42, attackMs: 48, holdMs: 900, releaseMs: 480 }
});
}
playObjectiveFailed() {
return this.playSemanticCue('objective', 'objective-failure', {
volume: 0.32,
stopAfterMs: 1600,
duck: { multiplier: 0.36, attackMs: 48, holdMs: 900, releaseMs: 520 }
});
}
playStoryAdvanceCue() {
return this.playSemanticCue('narrative', 'story-page-turn', {
volume: 0.15,
stopAfterMs: 1100
});
}
playRecoveryCue() {
return this.playSemanticCue('recovery', 'recovery-cue', {
volume: 0.26,
stopAfterMs: 1900
});
}
playBurnTickCue() {
return this.playSemanticCue('status', 'burn-tick', {
volume: 0.24,
stopAfterMs: 680
});
}
playRewardRevealCue() {
return this.playSemanticCue('reward', 'reward-reveal', {
volume: 0.26,
stopAfterMs: 1400,
duck: { multiplier: 0.76, attackMs: 36, holdMs: 420, releaseMs: 260 }
});
}
playStrategyPulse() {
this.playEffect('strategy-cast', { volume: 0.44, rate: 0.96, stopAfterMs: 960 });
this.playTone(460, 0.08, 0.012, 'sine');
window.setTimeout(() => this.playTone(690, 0.08, 0.012, 'triangle'), 78);
}
playItemLaunch() {
this.playEffect('cart-roll', { volume: 0.34, rate: 1.06, stopAfterMs: 740 });
this.playTone(180, 0.055, 0.02, 'sawtooth');
}
playEffect(key: string, options: PlayEffectOptions = {}) {
if (!this.started || this.muted) {
return false;
}
const resolvedTrack = this.resolveEffectTrack(key);
if (!resolvedTrack) {
return false;
}
const effect = new Audio(resolvedTrack.src);
const baseVolume = this.normalizeUnitInterval(options.volume ?? this.effectVolume, this.effectVolume);
effect.preload = 'auto';
effect.volume = this.effectOutputVolume(baseVolume);
effect.playbackRate = options.rate ?? 1;
effect.muted = this.muted;
this.activeEffects.add(effect);
this.effectBaseVolumes.set(effect, baseVolume);
const playedEffect = {
key,
trackKey: resolvedTrack.trackKey,
poolKey: resolvedTrack.poolKey,
at: performance.now(),
baseVolume,
volume: effect.volume,
rate: effect.playbackRate
};
this.lastEffect = playedEffect;
this.recentEffects.push(playedEffect);
if (this.recentEffects.length > 12) {
this.recentEffects.shift();
}
effect.addEventListener(
'ended',
() => {
this.retireEffectElement(effect);
},
{ once: true }
);
effect.addEventListener(
'error',
() => {
this.retireEffectElement(effect);
},
{ once: true }
);
if (options.stopAfterMs) {
window.setTimeout(() => {
this.retireEffectElement(effect);
}, options.stopAfterMs);
}
void effect.play().catch(() => this.retireEffectElement(effect));
return true;
}
private playSemanticCue(
group: SemanticCueGroup,
key: string,
options: PlayEffectOptions & { duck?: MusicDuckOptions }
) {
if (!this.started || this.muted || !this.effectTracks[key]) {
return false;
}
const now = performance.now();
const lastPlayedAt = this.semanticCuePlayedAt.get(group);
const cooldownMs = this.semanticCueCooldownMs[group];
if (lastPlayedAt !== undefined && now - lastPlayedAt < cooldownMs) {
this.lastSuppressedSemanticCue = {
group,
key,
at: now,
remainingMs: Math.ceil(cooldownMs - (now - lastPlayedAt))
};
return false;
}
if (options.duck) {
this.duckMusic(options.duck);
}
const played = this.playEffect(key, options);
if (played) {
this.semanticCuePlayedAt.set(group, now);
this.lastPlayedSemanticCue = { group, key, at: now };
}
return played;
}
getDebugState() {
const music = this.loopChannelDebugState(this.musicChannel);
const ambience = this.loopChannelDebugState(this.ambienceChannel);
return {
started: this.started,
muted: this.muted,
contextState: this.context?.state ?? null,
currentMusicKey: music.currentKey,
pendingMusicKey: music.pendingKey,
currentAmbienceKey: ambience.currentKey,
pendingAmbienceKey: ambience.pendingKey,
music,
ambience,
categoryMultipliers: { ...this.categoryMultipliers },
musicDuck: {
multiplier: this.musicDuck.multiplier,
revision: this.musicDuck.revision,
completedRevision: this.musicDuck.completedRevision,
active: this.musicDuck.active,
endsAt: this.musicDuck.endsAt,
last: this.musicDuck.last ? { ...this.musicDuck.last } : null
},
semanticCues: {
cooldownMs: { ...this.semanticCueCooldownMs },
playedAt: Object.fromEntries(this.semanticCuePlayedAt),
lastPlayed: this.lastPlayedSemanticCue ? { ...this.lastPlayedSemanticCue } : null,
lastSuppressed: this.lastSuppressedSemanticCue ? { ...this.lastSuppressedSemanticCue } : null
},
effectPools: Object.fromEntries(
Object.entries(this.effectPools).map(([key, trackKeys]) => [key, [...trackKeys]])
),
activeEffectCount: this.activeEffects.size,
lastEffect: this.lastEffect ?? null,
recentEffects: this.recentEffects.map((effect) => ({ ...effect })),
lastMovementStep: this.lastMovementStep ?? null
};
}
playMusic(key?: string) {
if (!key) {
return;
}
this.playSoundscape({ musicKey: key });
}
playSoundscape({ musicKey, ambienceKey, musicVolume, ambienceVolume }: SoundscapeOptions) {
this.setLoopTargetVolume(this.musicChannel, musicVolume ?? this.musicChannel.defaultVolume);
this.setLoopTargetVolume(this.ambienceChannel, ambienceVolume ?? this.ambienceChannel.defaultVolume);
this.requestLoopChannel(this.musicChannel, musicKey);
this.requestLoopChannel(this.ambienceChannel, ambienceKey);
}
playAmbience(key?: string, volume = this.ambienceChannel.defaultVolume) {
this.setLoopTargetVolume(this.ambienceChannel, volume);
this.requestLoopChannel(this.ambienceChannel, key);
}
stopAmbience() {
this.requestLoopChannel(this.ambienceChannel, undefined);
}
private playTone(
frequency: number,
duration: number,
gainValue: number,
type: OscillatorType
) {
if (!this.context || this.muted) {
return;
}
const oscillator = this.context.createOscillator();
const gain = this.context.createGain();
const now = this.context.currentTime;
oscillator.type = type;
oscillator.frequency.value = frequency;
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(gainValue, now + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
oscillator.connect(gain);
gain.connect(this.effectsBus ?? this.master ?? this.context.destination);
oscillator.start(now);
oscillator.stop(now + duration + 0.04);
}
private playCriticalAccent() {
this.duckMusic({ multiplier: 0.52, attackMs: 32, holdMs: 140, releaseMs: 280 });
this.playEffect('critical-impact', { volume: 0.3, rate: 1, stopAfterMs: 800 });
window.setTimeout(() => {
this.playEffect('critical-boom', { volume: 0.16, rate: 1.04, stopAfterMs: 2000 });
}, 34);
}
private playStepPulse(mounted: boolean, stepIndex: number, volume = 0.2) {
if (!this.context || this.muted) {
return;
}
const scale = Math.max(0.35, Math.min(1.2, volume / 0.24));
const start = this.context.currentTime;
const primaryFrequency = mounted ? 112 + (stepIndex % 2) * 26 : 148 + (stepIndex % 2) * 34;
const duration = mounted ? 0.09 : 0.075;
const gainValue = (mounted ? 0.022 : 0.016) * scale;
this.playPercussivePulse(primaryFrequency, start, duration, gainValue, mounted ? 'square' : 'triangle');
if (mounted) {
this.playPercussivePulse(82 + (stepIndex % 2) * 18, start + 0.055, 0.075, gainValue * 0.78, 'square');
}
}
private playPercussivePulse(
frequency: number,
start: number,
duration: number,
gainValue: number,
type: OscillatorType
) {
if (!this.context || this.muted) {
return;
}
const oscillator = this.context.createOscillator();
const gain = this.context.createGain();
oscillator.type = type;
oscillator.frequency.setValueAtTime(frequency, start);
oscillator.frequency.exponentialRampToValueAtTime(Math.max(40, frequency * 0.62), start + duration);
gain.gain.setValueAtTime(0.0001, start);
gain.gain.linearRampToValueAtTime(gainValue, start + 0.006);
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
oscillator.connect(gain);
gain.connect(this.effectsBus ?? this.master ?? this.context.destination);
oscillator.start(start);
oscillator.stop(start + duration + 0.025);
}
private createLoopChannel(name: LoopChannelName, targetVolume: number, fadeDurationMs: number): LoopChannel {
return {
name,
tracks: {},
defaultVolume: targetVolume,
targetVolume,
fadeDurationMs,
hasPendingRequest: false,
outgoing: new Set(),
outgoingStartVolumes: new Map(),
elementBaseVolumes: new Map(),
elementRevision: 0,
transition: {
revision: 0,
completedRevision: 0,
active: false
}
};
}
private registerLoopTracks(channel: LoopChannel, tracks: AudioTrackMap) {
channel.tracks = tracks;
if (this.started) {
this.applyPendingLoopRequest(channel);
}
}
private setLoopTargetVolume(channel: LoopChannel, volume: number) {
channel.targetVolume = Number.isFinite(volume) ? Math.min(1, Math.max(0, volume)) : channel.defaultVolume;
}
private requestLoopChannel(channel: LoopChannel, key?: string) {
channel.pendingKey = key;
channel.hasPendingRequest = true;
if (this.started) {
this.applyPendingLoopRequest(channel);
}
}
private applyPendingLoopRequest(channel: LoopChannel) {
if (!channel.hasPendingRequest) {
return;
}
const key = channel.pendingKey;
const requestedTarget = key ?? null;
const alreadyTargetingKey =
(key !== undefined && channel.currentKey === key && Boolean(channel.current)) ||
(key === undefined && !channel.current && !channel.outgoing.size) ||
(channel.transition.active && channel.transition.last?.toKey === requestedTarget);
if (alreadyTargetingKey) {
channel.pendingKey = undefined;
channel.hasPendingRequest = false;
if (channel.current) {
channel.current.muted = this.muted;
if (!channel.transition.active) {
this.setLoopElementBaseVolume(channel, channel.current, channel.targetVolume);
}
void channel.current.play().catch(() => undefined);
}
return;
}
const src = key ? channel.tracks[key] : undefined;
if (key && !src) {
return;
}
channel.pendingKey = undefined;
channel.hasPendingRequest = false;
this.beginLoopTransition(channel, key, src);
}
private beginLoopTransition(channel: LoopChannel, key?: string, src?: string) {
this.cancelLoopTransitionTimer(channel);
this.retireOutgoingElements(channel);
const previous = channel.current;
const fromKey = channel.currentKey ?? null;
if (previous) {
channel.outgoing.add(previous);
channel.outgoingStartVolumes.set(previous, channel.elementBaseVolumes.get(previous) ?? channel.targetVolume);
}
channel.current = undefined;
channel.currentKey = undefined;
if (key && src) {
const next = new Audio(src);
next.loop = true;
next.preload = 'auto';
next.muted = this.muted;
this.setLoopElementBaseVolume(channel, next, 0);
channel.current = next;
channel.currentKey = key;
channel.elementRevision += 1;
void next.play().catch(() => undefined);
}
channel.transition.revision += 1;
channel.transition.active = true;
channel.transition.last = {
fromKey,
toKey: key ?? null,
durationMs: channel.fadeDurationMs
};
const transitionRevision = channel.transition.revision;
const startedAt = performance.now();
channel.fadeTimer = window.setInterval(() => {
if (transitionRevision !== channel.transition.revision) {
return;
}
const progress = Math.min(1, Math.max(0, (performance.now() - startedAt) / channel.fadeDurationMs));
if (channel.current) {
this.setLoopElementBaseVolume(channel, channel.current, channel.targetVolume * progress);
}
channel.outgoing.forEach((element) => {
const startVolume = channel.outgoingStartVolumes.get(element) ?? channel.elementBaseVolumes.get(element) ?? 0;
this.setLoopElementBaseVolume(channel, element, startVolume * (1 - progress));
});
if (progress >= 1) {
this.completeLoopTransition(channel, transitionRevision);
}
}, 32);
}
private completeLoopTransition(channel: LoopChannel, transitionRevision: number) {
if (transitionRevision !== channel.transition.revision) {
return;
}
this.cancelLoopTransitionTimer(channel);
this.retireOutgoingElements(channel);
if (channel.current) {
this.setLoopElementBaseVolume(channel, channel.current, channel.targetVolume);
}
channel.transition.active = false;
channel.transition.completedRevision = transitionRevision;
}
private cancelLoopTransitionTimer(channel: LoopChannel) {
if (channel.fadeTimer !== undefined) {
window.clearInterval(channel.fadeTimer);
channel.fadeTimer = undefined;
}
}
private retireOutgoingElements(channel: LoopChannel) {
channel.outgoing.forEach((element) => this.retireLoopElement(channel, element));
channel.outgoing.clear();
channel.outgoingStartVolumes.clear();
}
private retireLoopElement(channel: LoopChannel, element: HTMLAudioElement) {
element.pause();
element.src = '';
channel.elementBaseVolumes.delete(element);
}
private loopElements() {
const elements = new Set<HTMLAudioElement>();
for (const channel of [this.musicChannel, this.ambienceChannel]) {
if (channel.current) {
elements.add(channel.current);
}
channel.outgoing.forEach((element) => elements.add(element));
}
return elements;
}
private loopChannelDebugState(channel: LoopChannel) {
return {
currentKey: channel.currentKey ?? null,
pendingKey: channel.hasPendingRequest ? channel.pendingKey ?? null : null,
hasPendingRequest: channel.hasPendingRequest,
elementRevision: channel.elementRevision,
activeElementCount: (channel.current ? 1 : 0) + channel.outgoing.size,
targetVolume: channel.targetVolume,
categoryMultiplier: this.categoryMultipliers[channel.name],
duckMultiplier: channel.name === 'music' ? this.musicDuck.multiplier : 1,
effectiveTargetVolume: this.loopOutputVolume(channel, channel.targetVolume),
transition: {
revision: channel.transition.revision,
completedRevision: channel.transition.completedRevision,
active: channel.transition.active,
last: channel.transition.last ? { ...channel.transition.last } : null
}
};
}
private resolveEffectTrack(key: string) {
const pool = this.effectPools[key];
if (pool?.length) {
const availableTrackKeys = pool.filter((trackKey) => Boolean(this.effectTracks[trackKey]));
if (availableTrackKeys.length) {
const lastSelection = this.lastEffectPoolSelections.get(key);
const candidates =
availableTrackKeys.length > 1
? availableTrackKeys.filter((trackKey) => trackKey !== lastSelection)
: availableTrackKeys;
const randomIndex = Math.floor(Math.random() * candidates.length);
const trackKey = candidates[randomIndex] ?? candidates[0];
this.lastEffectPoolSelections.set(key, trackKey);
return { trackKey, src: this.effectTracks[trackKey], poolKey: key };
}
}
const src = this.effectTracks[key];
return src ? { trackKey: key, src, poolKey: null } : undefined;
}
private refreshActiveEffectVolumes() {
this.activeEffects.forEach((effect) => {
const baseVolume = this.effectBaseVolumes.get(effect);
if (baseVolume !== undefined) {
effect.volume = this.effectOutputVolume(baseVolume);
}
});
}
private effectOutputVolume(baseVolume: number) {
return this.normalizeUnitInterval(baseVolume * this.categoryMultipliers.effects, 0);
}
private retireEffectElement(effect: HTMLAudioElement) {
effect.pause();
effect.src = '';
this.activeEffects.delete(effect);
this.effectBaseVolumes.delete(effect);
}
private setLoopElementBaseVolume(channel: LoopChannel, element: HTMLAudioElement, baseVolume: number) {
const normalizedBaseVolume = this.normalizeUnitInterval(baseVolume, 0);
channel.elementBaseVolumes.set(element, normalizedBaseVolume);
element.volume = this.loopOutputVolume(channel, normalizedBaseVolume);
}
private refreshLoopChannelVolumes(channel: LoopChannel) {
if (channel.current) {
this.refreshLoopElementVolume(channel, channel.current);
}
channel.outgoing.forEach((element) => this.refreshLoopElementVolume(channel, element));
}
private refreshLoopElementVolume(channel: LoopChannel, element: HTMLAudioElement) {
const baseVolume = channel.elementBaseVolumes.get(element);
if (baseVolume !== undefined) {
element.volume = this.loopOutputVolume(channel, baseVolume);
}
}
private loopOutputVolume(channel: LoopChannel, baseVolume: number) {
const duckMultiplier = channel.name === 'music' ? this.musicDuck.multiplier : 1;
return this.normalizeUnitInterval(baseVolume * this.categoryMultipliers[channel.name] * duckMultiplier, 0);
}
private setMusicDuckMultiplier(multiplier: number) {
this.musicDuck.multiplier = this.normalizeUnitInterval(multiplier, 1);
this.refreshLoopChannelVolumes(this.musicChannel);
}
private completeMusicDuck(revision: number) {
if (revision !== this.musicDuck.revision) {
return;
}
this.cancelMusicDuckTimer();
this.setMusicDuckMultiplier(1);
this.musicDuck.active = false;
this.musicDuck.endsAt = 0;
this.musicDuck.completedRevision = revision;
}
private cancelMusicDuckTimer() {
if (this.musicDuck.timer !== undefined) {
window.clearInterval(this.musicDuck.timer);
this.musicDuck.timer = undefined;
}
}
private normalizeUnitInterval(value: number, fallback: number) {
return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : fallback;
}
private normalizeDuration(value: number, fallback: number) {
return Number.isFinite(value) ? Math.min(10_000, Math.max(0, value)) : fallback;
}
}
declare global {
interface Window {
webkitAudioContext?: typeof AudioContext;
}
}
export const soundDirector = new SoundDirector();