type AudioTrackMap = Record; export type AudioCategory = 'music' | 'effects' | 'ambience'; export type AudioCategoryMultipliers = Record; export type EffectPoolMap = Record; export type StrategyImpactKind = 'fire' | 'roar' | 'arcane'; export type SoundscapeOptions = { musicKey?: string; ambienceKey?: string; musicVolume?: number; ambienceVolume?: number; }; type LoopChannelName = Exclude; 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; outgoingStartVolumes: Map; elementBaseVolumes: Map; 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 = {}; private readonly lastEffectPoolSelections = new Map(); private readonly activeEffects = new Set(); private readonly effectBaseVolumes = new Map(); 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 = { turn: 900, operation: 650, objective: 1200, narrative: 220, recovery: 1900, status: 280, reward: 1100 }; private readonly semanticCuePlayedAt = new Map(); 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) { (['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(); 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();