1047 lines
32 KiB
TypeScript
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();
|