Files
heros_web/scripts/verify-sound-director.mjs

948 lines
44 KiB
JavaScript

import assert from 'node:assert/strict';
import { createServer } from 'vite';
class FakeClock {
now = 0;
nextTimerId = 1;
timers = new Map();
setInterval(callback, delay = 0) {
return this.addTimer(callback, delay, true);
}
setTimeout(callback, delay = 0) {
return this.addTimer(callback, delay, false);
}
clearTimer(timerId) {
this.timers.delete(timerId);
}
advance(durationMs) {
const target = this.now + durationMs;
while (true) {
const next = [...this.timers.values()]
.filter((timer) => timer.dueAt <= target)
.sort((left, right) => left.dueAt - right.dueAt || left.id - right.id)[0];
if (!next) {
break;
}
this.now = next.dueAt;
next.callback();
if (this.timers.get(next.id) !== next) {
continue;
}
if (next.repeating) {
next.dueAt += next.delay;
} else {
this.timers.delete(next.id);
}
}
this.now = target;
}
addTimer(callback, delay, repeating) {
const id = this.nextTimerId;
this.nextTimerId += 1;
const normalizedDelay = Math.max(1, Number(delay) || 0);
this.timers.set(id, {
id,
callback,
delay: normalizedDelay,
dueAt: this.now + normalizedDelay,
repeating
});
return id;
}
}
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true },
appType: 'custom'
});
try {
const { SoundDirector, movementSurfaceForTerrain } = await server.ssrLoadModule('/src/game/audio/SoundDirector.ts');
const { ambienceTrackGainCompensation, effectTrackGainCompensation } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
const { movementAudioTerrainForPulse, movementAudioTerrainForTile } = await server.ssrLoadModule(
'/src/game/audio/movementAudio.ts'
);
const audioPreferencesModule = await server.ssrLoadModule('/src/game/settings/audioPreferences.ts');
verifyAudioPreferences(audioPreferencesModule);
const clock = new FakeClock();
const audioHarness = createAudioHarness();
const restoreGlobals = installBrowserFakes(clock, audioHarness.FakeAudio);
try {
const director = new SoundDirector();
director.registerMusicTracks({
'music-a': '/audio/music-a.ogg',
'music-b': '/audio/music-b.ogg',
'music-c': '/audio/music-c.ogg'
});
director.registerAmbienceTracks({
'ambience-a': '/audio/ambience-a.ogg',
'ambience-b': '/audio/ambience-b.ogg',
'ambience-c': '/audio/ambience-c.ogg',
'battle-fire': '/audio/battle-fire.ogg'
});
director.registerEffectTracks({
'sword-slash': '/audio/sword-slash.ogg',
'sword-swing-variant': '/audio/sword-swing-variant.ogg',
'combat-impact': '/audio/combat-impact.ogg',
'armor-impact': '/audio/armor-impact.ogg',
'shield-block': '/audio/shield-block.ogg',
'bow-release': '/audio/bow-release.ogg',
'arrow-body-impact': '/audio/arrow-body-impact.ogg',
'arrow-wood-impact': '/audio/arrow-wood-impact.ogg',
'arrow-swish': '/audio/arrow-swish.ogg',
'critical-impact': '/audio/critical-impact.ogg',
'critical-boom': '/audio/critical-boom.ogg',
'victory-fanfare': '/audio/victory-fanfare.ogg',
'defeat-sting': '/audio/defeat-sting.ogg',
'ally-turn': '/audio/ally-turn.ogg',
'enemy-turn': '/audio/enemy-turn.ogg',
'operation-alert': '/audio/operation-alert.ogg',
'objective-success': '/audio/objective-success.ogg',
'objective-failure': '/audio/objective-failure.ogg',
'story-page-turn': '/audio/story-page-turn.ogg',
'recovery-cue': '/audio/recovery-cue.ogg',
'burn-tick': '/audio/burn-tick.ogg',
'reward-reveal': '/audio/reward-reveal.ogg',
'strategy-cast': '/audio/strategy-cast.ogg',
'ui-select': '/audio/ui-select.ogg',
'footstep-earth-1': '/audio/footstep-earth-1.ogg',
'footstep-earth-2': '/audio/footstep-earth-2.ogg',
'footstep-stone-1': '/audio/footstep-stone-1.ogg',
'footstep-stone-2': '/audio/footstep-stone-2.ogg',
'footstep-wood-1': '/audio/footstep-wood-1.ogg',
'footstep-wood-2': '/audio/footstep-wood-2.ogg',
'footstep-wet-1': '/audio/footstep-wet-1.ogg',
'footstep-wet-2': '/audio/footstep-wet-2.ogg',
'hoof-earth-1': '/audio/hoof-earth-1.ogg',
'hoof-earth-2': '/audio/hoof-earth-2.ogg',
'hoof-stone-1': '/audio/hoof-stone-1.ogg',
'hoof-stone-2': '/audio/hoof-stone-2.ogg',
'hoof-wood-1': '/audio/hoof-wood-1.ogg',
'hoof-wood-2': '/audio/hoof-wood-2.ogg',
'hoof-wet-1': '/audio/hoof-wet-1.ogg',
'hoof-wet-2': '/audio/hoof-wet-2.ogg'
});
director.registerEffectPools({
impact: ['sword-slash', 'combat-impact', 'sword-slash'],
'melee-swing': ['sword-slash', 'sword-swing-variant'],
'melee-impact': ['combat-impact', 'armor-impact', 'shield-block'],
'arrow-impact': ['arrow-body-impact', 'arrow-wood-impact'],
'arrow-miss': ['arrow-swish'],
'movement-foot-earth': ['footstep-earth-1', 'footstep-earth-2'],
'movement-foot-stone': ['footstep-stone-1', 'footstep-stone-2'],
'movement-foot-wood': ['footstep-wood-1', 'footstep-wood-2'],
'movement-foot-wet': ['footstep-wet-1', 'footstep-wet-2'],
'movement-hoof-earth': ['hoof-earth-1', 'hoof-earth-2'],
'movement-hoof-stone': ['hoof-stone-1', 'hoof-stone-2'],
'movement-hoof-wood': ['hoof-wood-1', 'hoof-wood-2'],
'movement-hoof-wet': ['hoof-wet-1', 'hoof-wet-2'],
fallback: ['missing-track']
});
director.playSoundscape({ musicKey: 'music-a', ambienceKey: 'ambience-a' });
let debug = director.getDebugState();
assert.equal(debug.started, false, 'soundscape requests should remain pending before start');
assert.equal(debug.currentMusicKey, null, 'music must not create an element before start');
assert.equal(debug.pendingMusicKey, 'music-a', 'music key should be pending before start');
assert.equal(debug.currentAmbienceKey, null, 'ambience must not create an element before start');
assert.equal(debug.pendingAmbienceKey, 'ambience-a', 'ambience key should be pending before start');
assert.equal(debug.music.activeElementCount, 0, 'music should have no active elements before start');
assert.equal(debug.ambience.activeElementCount, 0, 'ambience should have no active elements before start');
assert.equal(audioHarness.instances.length, 0, 'pending requests must not construct Audio');
director.start();
debug = director.getDebugState();
assert.equal(debug.currentMusicKey, 'music-a', 'start should consume the pending music key');
assert.equal(debug.pendingMusicKey, null, 'started music should no longer be pending');
assert.equal(debug.currentAmbienceKey, 'ambience-a', 'start should consume the pending ambience key');
assert.equal(debug.pendingAmbienceKey, null, 'started ambience should no longer be pending');
assert.equal(debug.music.transition.last.durationMs, 900, 'music should use a 900ms fade');
assert.equal(debug.ambience.transition.last.durationMs, 1200, 'ambience should use a 1200ms fade');
assert.equal(audioHarness.instances.length, 2, 'start should create one Audio per requested channel');
clock.advance(1300);
debug = director.getDebugState();
assertCompletedChannel(debug.music, 1, 0.22, 'initial music fade');
assertCompletedChannel(debug.ambience, 1, 0.08, 'initial ambience fade');
assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.22, 'settled music volume');
assertClose(audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, 0.08, 'settled ambience volume');
director.setCategoryMultipliers({ music: 0.5, effects: 0.5, ambience: 0.25 });
debug = director.getDebugState();
assert.deepEqual(
debug.categoryMultipliers,
{ music: 0.5, effects: 0.5, ambience: 0.25 },
'category multipliers should be independently configurable'
);
assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.11, 'music category multiplier');
assertClose(audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, 0.02, 'ambience category multiplier');
assertClose(debug.music.effectiveTargetVolume, 0.11, 'debug music effective target volume');
assertClose(debug.ambience.effectiveTargetVolume, 0.02, 'debug ambience effective target volume');
assert.equal(
director.playEffect('impact', { volume: 0.4, rate: 0.92, stopAfterMs: 100 }),
true,
'registered effect pool should resolve to an available track'
);
const firstImpact = audioHarness.instances.at(-1);
assert.equal(
director.playEffect('impact', { volume: 0.4, rate: 1.08, stopAfterMs: 100 }),
true,
'effect pool should remain playable on repeated requests'
);
const secondImpact = audioHarness.instances.at(-1);
assert.notEqual(firstImpact.originalSrc, secondImpact.originalSrc, 'effect pools should avoid immediate track repetition');
debug = director.getDebugState();
const [firstImpactMix, secondImpactMix] = debug.recentEffects.slice(-2);
assertClose(
firstImpact.volume,
firstImpactMix.compensatedBaseVolume * 0.5,
'effect category multiplier on first gain-balanced variation'
);
assertClose(
secondImpact.volume,
secondImpactMix.compensatedBaseVolume * 0.5,
'effect category multiplier on second gain-balanced variation'
);
[firstImpactMix, secondImpactMix].forEach((mix) => {
assert.equal(
mix.trackGain,
effectTrackGainCompensation[mix.trackKey],
`${mix.trackKey} should expose its measured pool compensation`
);
});
assert.deepEqual(
debug.effectPools.impact,
['sword-slash', 'combat-impact'],
'effect pools should discard duplicate keys'
);
assert.equal(debug.lastEffect.key, 'impact', 'last effect should retain its logical request key');
assert.equal(debug.lastEffect.poolKey, 'impact', 'last effect should identify its source pool');
assert(
['sword-slash', 'combat-impact'].includes(debug.lastEffect.trackKey),
'last effect should expose the concrete registered track key'
);
assert.equal(debug.activeEffectCount, 2, 'active pooled effects should be tracked');
director.setCategoryMultiplier('effects', 0.25);
assertClose(firstImpact.volume, firstImpactMix.compensatedBaseVolume * 0.25, 'active effect should follow category multiplier changes');
assertClose(secondImpact.volume, secondImpactMix.compensatedBaseVolume * 0.25, 'all active effects should follow category multiplier changes');
director.setCategoryMultipliers({ music: 1, effects: 1, ambience: 1 });
assertClose(firstImpact.volume, firstImpactMix.compensatedBaseVolume, 'active effect should restore its compensated volume');
assertClose(secondImpact.volume, secondImpactMix.compensatedBaseVolume, 'all active effects should restore their compensated volume');
assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.22, 'restored music category multiplier');
assertClose(audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, 0.08, 'restored ambience category multiplier');
clock.advance(120);
debug = director.getDebugState();
assert.equal(debug.activeEffectCount, 0, 'timed effects should be released from active tracking');
assertRetired(firstImpact, 'first pooled impact cleanup');
assertRetired(secondImpact, 'second pooled impact cleanup');
assert.deepEqual(
['plain', 'road', 'forest', 'bridge', 'river', 'marsh', 'unknown'].map((terrain) =>
movementSurfaceForTerrain(terrain)
),
['earth', 'stone', 'wood', 'wood', 'wet', 'wet', 'earth'],
'battle terrain names should resolve to the four movement sound surfaces'
);
const terrainFixture = [
['plain', 'road', 'river'],
['forest', 'plain', 'plain']
];
assert.equal(
movementAudioTerrainForTile(terrainFixture, 1, 0, false),
'wet',
'a passable river-bank/ford tile should make the wet movement pool reachable'
);
assert.equal(
movementAudioTerrainForTile(terrainFixture, 0, 1, true),
'wet',
'rain should wet otherwise dry passable terrain'
);
assert.deepEqual(
Array.from({ length: 5 }, (_, index) => movementAudioTerrainForPulse(['plain', 'road', 'forest'], index, 5)),
['plain', 'road', 'road', 'forest', 'forest'],
'movement pulses should sample the full route instead of repeating only the destination surface'
);
assert.equal(
director.playMovementStep(false, 0, { terrain: 'road', volume: 0.2, rate: 0.94, stopAfterMs: 120 }),
true,
'foot movement should accept a terrain without breaking the legacy argument order'
);
const firstStoneStep = audioHarness.instances.at(-1);
debug = director.getDebugState();
assert.equal(debug.lastMovementStep.key, 'movement-foot-stone', 'road movement should use the stone foot pool');
assert.equal(debug.lastMovementStep.surface, 'stone', 'movement debug state should expose the resolved surface');
assert.equal(debug.lastMovementStep.terrain, 'road', 'movement debug state should retain the caller terrain');
assert(['footstep-stone-1', 'footstep-stone-2'].includes(debug.lastMovementStep.trackKey));
director.playMovementStep(false, 1, { terrain: 'road', volume: 0.2, rate: 1.04, stopAfterMs: 120 });
const secondStoneStep = audioHarness.instances.at(-1);
assert.notEqual(firstStoneStep.originalSrc, secondStoneStep.originalSrc, 'terrain movement pools should avoid immediate repetition');
assert.equal(
director.playMovementStep(false, 2, { terrain: 'road', volume: 0.2, rate: 0.94, stopAfterMs: 120 }),
false,
'a third overlapping footstep should be skipped instead of cutting an audible sample'
);
debug = director.getDebugState();
assert.equal(firstStoneStep.paused, false, 'voice limiting must let the oldest footstep reach its natural release');
assert.notEqual(firstStoneStep.src, '', 'voice limiting must not clear a playing footstep source');
assert.equal(debug.movementVoices.cap, 2, 'movement voice policy should expose the two-voice ceiling');
assert.equal(debug.movementVoices.foot.activeCount, 2, 'foot movement must never exceed two active voices');
assert.equal(
new Set(debug.movementVoices.foot.trackKeys).size,
debug.movementVoices.foot.activeCount,
'active foot movement voices must use distinct source clips'
);
const activeEffectsBeforeRush = debug.activeEffectCount;
assert.equal(
director.playMeleeRush(false, 'bridge'),
true,
'melee rush must remain audible while the foot movement voice group is saturated'
);
debug = director.getDebugState();
assert.equal(debug.lastEffect.key, 'movement-foot-wood', 'melee rush should share the terrain movement pools');
assert.equal(
debug.activeEffectCount,
activeEffectsBeforeRush + 1,
'melee rush should use a priority effect voice outside the movement-step ceiling'
);
assert.equal(debug.movementVoices.foot.activeCount, 2, 'melee rush must not consume a movement-step voice');
director.playMovementStep(true, 2, { terrain: 'river', volume: 0.24, stopAfterMs: 140 });
debug = director.getDebugState();
assert.equal(debug.lastMovementStep.key, 'movement-hoof-wet', 'mounted river movement should use the wet hoof pool');
assert.equal(debug.lastMovementStep.surface, 'wet', 'mounted movement should expose the wet surface');
assert(['hoof-wet-1', 'hoof-wet-2'].includes(debug.lastMovementStep.trackKey));
director.playMovementStep(true, 3, { terrain: 'river', volume: 0.24, stopAfterMs: 140 });
assert.equal(
director.playMovementStep(true, 4, { terrain: 'river', volume: 0.24, stopAfterMs: 140 }),
false,
'a third overlapping hoof beat should be skipped instead of cutting an audible sample'
);
debug = director.getDebugState();
assert.equal(debug.movementVoices.mounted.activeCount, 2, 'mounted movement must never exceed two active voices');
assert.equal(
new Set(debug.movementVoices.mounted.trackKeys).size,
debug.movementVoices.mounted.activeCount,
'active mounted movement voices must use distinct source clips'
);
firstStoneStep.emit('ended');
secondStoneStep.emit('ended');
clock.advance(600);
assert.equal(director.getDebugState().activeEffectCount, 0, 'terrain movement one-shots should retire without leaks');
director.duckMusic({ multiplier: 0.4, attackMs: 64, holdMs: 64, releaseMs: 96 });
clock.advance(64);
debug = director.getDebugState();
assert.equal(debug.musicDuck.active, true, 'music duck should remain active through its hold phase');
assertClose(debug.musicDuck.multiplier, 0.4, 'music duck attack target');
assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.088, 'ducked music volume');
assertClose(audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, 0.08, 'music duck must not affect ambience');
clock.advance(160);
debug = director.getDebugState();
assert.equal(debug.musicDuck.active, false, 'music duck should finish after release');
assert.equal(debug.musicDuck.completedRevision, 1, 'music duck completion revision');
assertClose(debug.musicDuck.multiplier, 1, 'music duck should restore its multiplier');
assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.22, 'music volume after duck release');
director.duckMusic({ multiplier: 0.3, attackMs: 0, holdMs: 800, releaseMs: 200 });
clock.advance(96);
director.duckMusic({ multiplier: 0.6, attackMs: 0, holdMs: 0, releaseMs: 100 });
clock.advance(160);
debug = director.getDebugState();
assert.equal(debug.musicDuck.active, true, 'a shorter overlapping duck must not end a longer active duck');
assertClose(debug.musicDuck.multiplier, 0.3, 'overlapping duck should retain the stronger attenuation');
director.setCategoryMultiplier('music', 0.5);
assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.033, 'music multiplier during duck');
director.setCategoryMultiplier('music', 1);
director.setMuted(true);
assert.equal(audioHarness.byOriginalSrc('/audio/music-a.ogg').muted, true, 'mute during duck');
director.setMuted(false);
clock.advance(800);
debug = director.getDebugState();
assert.equal(debug.musicDuck.active, false, 'overlapping duck should finish at the longest requested deadline');
assert.equal(debug.musicDuck.completedRevision, 3, 'overlapping duck completion revision');
assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.22, 'music volume after overlapping duck');
audioHarness.rejectNextPlay();
assert.equal(director.playEffect('ui-select'), true, 'effect should attempt playback before a media rejection');
const rejectedEffect = audioHarness.instances.at(-1);
await Promise.resolve();
debug = director.getDebugState();
assert.equal(debug.activeEffectCount, 0, 'rejected effects should be released from active tracking');
assertRetired(rejectedEffect, 'rejected effect cleanup');
assert.equal(director.playEffect('ui-select'), true, 'effect should be tracked until an error or end event');
const erroredEffect = audioHarness.instances.at(-1);
erroredEffect.emit('error');
debug = director.getDebugState();
assert.equal(debug.activeEffectCount, 0, 'errored effects should be released from active tracking');
assertRetired(erroredEffect, 'errored effect cleanup');
const countBeforeSameKey = audioHarness.instances.length;
const musicRevisionBeforeSameKey = debug.music.transition.revision;
const ambienceRevisionBeforeSameKey = debug.ambience.transition.revision;
director.playSoundscape({
musicKey: 'music-a',
ambienceKey: 'ambience-a',
musicVolume: 0.18,
ambienceVolume: 0.05
});
debug = director.getDebugState();
assert.equal(audioHarness.instances.length, countBeforeSameKey, 'same-key requests must not construct Audio');
assert.equal(debug.music.transition.revision, musicRevisionBeforeSameKey, 'same music key must be transition-idempotent');
assert.equal(
debug.ambience.transition.revision,
ambienceRevisionBeforeSameKey,
'same ambience key must be transition-idempotent'
);
assert.equal(debug.music.targetVolume, 0.18, 'same-key music should update its desired target volume');
assert.equal(debug.ambience.targetVolume, 0.05, 'same-key ambience should update its desired target volume');
assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.18, 'same-key music element volume');
assertClose(audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, 0.05, 'same-key ambience element volume');
director.playSoundscape({
musicKey: 'music-b',
ambienceKey: 'ambience-b',
musicVolume: 0.2,
ambienceVolume: 0.06
});
const elementsDuringCrossfade = audioHarness.activeInstances();
assert.equal(elementsDuringCrossfade.length, 4, 'two channels should each retain one outgoing element during a crossfade');
assertClose(
audioHarness.byOriginalSrc('/audio/music-a.ogg').volume,
0.18,
'outgoing music should keep its settled volume at crossfade start'
);
assertClose(
audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume,
0.05,
'outgoing ambience should keep its settled volume at crossfade start'
);
assertClose(audioHarness.byOriginalSrc('/audio/music-b.ogg').volume, 0, 'incoming music should start silent');
assertClose(audioHarness.byOriginalSrc('/audio/ambience-b.ogg').volume, 0, 'incoming ambience should start silent');
director.setMuted(true);
assert(elementsDuringCrossfade.every((element) => element.muted), 'mute should cover current and outgoing elements');
const playCallsBeforeResume = new Map(elementsDuringCrossfade.map((element) => [element, element.playCalls]));
director.resume();
elementsDuringCrossfade.forEach((element) => {
assert.equal(element.playCalls, playCallsBeforeResume.get(element) + 1, 'resume should play current and outgoing elements');
});
director.setMuted(false);
assert(elementsDuringCrossfade.every((element) => !element.muted), 'unmute should cover current and outgoing elements');
const retiredMusicA = audioHarness.byOriginalSrc('/audio/music-a.ogg');
const retiredAmbienceA = audioHarness.byOriginalSrc('/audio/ambience-a.ogg');
const retiredMusicB = audioHarness.byOriginalSrc('/audio/music-b.ogg');
const retiredAmbienceB = audioHarness.byOriginalSrc('/audio/ambience-b.ogg');
director.playSoundscape({
musicKey: 'music-c',
ambienceKey: 'ambience-c',
musicVolume: 0.16,
ambienceVolume: 0.04
});
debug = director.getDebugState();
assertRetired(retiredMusicA, 'rapid music A retirement');
assertRetired(retiredAmbienceA, 'rapid ambience A retirement');
assert.equal(debug.music.activeElementCount, 2, 'rapid music transition should keep only B outgoing and C current');
assert.equal(debug.ambience.activeElementCount, 2, 'rapid ambience transition should keep only B outgoing and C current');
assert.equal(debug.music.elementRevision, 3, 'music element revision should count A, B, and C');
assert.equal(debug.ambience.elementRevision, 3, 'ambience element revision should count A, B, and C');
assert.deepEqual(
debug.music.transition.last,
{ fromKey: 'music-b', toKey: 'music-c', durationMs: 900 },
'rapid music transition should describe the latest request'
);
assert.deepEqual(
debug.ambience.transition.last,
{ fromKey: 'ambience-b', toKey: 'ambience-c', durationMs: 1200 },
'rapid ambience transition should describe the latest request'
);
clock.advance(1300);
debug = director.getDebugState();
assertRetired(retiredMusicB, 'music B must retire after the C fade');
assertRetired(retiredAmbienceB, 'ambience B must retire after the C fade');
assertCompletedChannel(debug.music, 3, 0.16, 'rapid music fade');
assertCompletedChannel(debug.ambience, 3, 0.04, 'rapid ambience fade');
assert.equal(audioHarness.activeInstances().length, 2, 'only the current music and ambience may remain active');
director.stopAmbience();
debug = director.getDebugState();
assert.equal(debug.currentAmbienceKey, null, 'stopAmbience should clear the current ambience key immediately');
assert.equal(debug.ambience.activeElementCount, 1, 'ambience should remain outgoing until its stop fade completes');
assert.deepEqual(
debug.ambience.transition.last,
{ fromKey: 'ambience-c', toKey: null, durationMs: 1200 },
'ambience stop should be represented as a transition to silence'
);
clock.advance(1300);
debug = director.getDebugState();
assert.equal(debug.ambience.transition.active, false, 'ambience stop transition should complete');
assert.equal(debug.ambience.activeElementCount, 0, 'ambience stop must retire its final element');
assertRetired(audioHarness.byOriginalSrc('/audio/ambience-c.ogg'), 'stopped ambience');
assert.equal(debug.music.activeElementCount, 1, 'stopping ambience must not disturb music');
director.playAmbience('battle-fire', 0.13);
clock.advance(1300);
debug = director.getDebugState();
assert.equal(debug.ambience.trackGain, ambienceTrackGainCompensation['battle-fire'], 'fire ambience should expose its peak trim');
assertClose(
audioHarness.byOriginalSrc('/audio/battle-fire.ogg').volume,
0.13 * ambienceTrackGainCompensation['battle-fire'],
'fire ambience should apply its track-specific loop gain'
);
const audioCountBeforeLegacyMusic = audioHarness.instances.length;
const musicRevisionBeforeLegacyMusic = director.getDebugState().music.transition.revision;
director.playMusic('music-c');
debug = director.getDebugState();
assert.equal(
audioHarness.instances.length,
audioCountBeforeLegacyMusic,
'legacy playMusic on the same key must not create a new music element'
);
assert.equal(
debug.music.transition.revision,
musicRevisionBeforeLegacyMusic,
'legacy playMusic should remain same-key idempotent'
);
assert.equal(debug.music.targetVolume, 0.22, 'legacy playMusic should restore the default music target volume');
assert.equal(debug.currentAmbienceKey, null, 'legacy playMusic must clear camp ambience');
clock.advance(1300);
debug = director.getDebugState();
assert.equal(debug.ambience.activeElementCount, 0, 'legacy playMusic ambience cleanup should complete');
assert.equal(audioHarness.activeInstances().length, 1, 'the verifier must finish without orphaned loop elements');
const tacticalCueStart = audioHarness.instances.length;
assert.equal(director.playAllyTurnCue(), true, 'the first allied turn cue should play');
assertClose(audioHarness.byOriginalSrc('/audio/ally-turn.ogg').volume, 0.32, 'allied turn cue volume');
assert.equal(director.playAllyTurnCue(), false, 'a repeated allied turn cue should be coalesced');
assert.equal(director.playEnemyTurnCue(), false, 'turn cue cooldown should be shared across factions');
assert.equal(director.playOperationAlert(), true, 'the first operation alert should play');
assertClose(audioHarness.byOriginalSrc('/audio/operation-alert.ogg').volume, 0.34, 'operation alert volume');
assert.equal(director.playOperationAlert(), false, 'operation alerts in one event flush should be coalesced');
assert.equal(director.playObjectiveAchieved(), true, 'the first objective result should play');
assertClose(audioHarness.byOriginalSrc('/audio/objective-success.ogg').volume, 0.34, 'objective success volume');
assert.equal(director.playObjectiveFailed(), false, 'objective result cooldown should suppress re-entry');
debug = director.getDebugState();
assert.deepEqual(
debug.semanticCues.cooldownMs,
{ turn: 900, operation: 650, objective: 1200, narrative: 220, recovery: 1900, status: 280, reward: 1100 },
'semantic cue cooldowns should be visible in debug state'
);
assert.deepEqual(
debug.semanticCues.lastSuppressed,
{ group: 'objective', key: 'objective-failure', at: clock.now, remainingMs: 1200 },
'debug state should explain the most recently coalesced cue'
);
assert.equal(audioHarness.instances.length, tacticalCueStart + 3, 'coalesced cues must not construct Audio');
clock.advance(650);
assert.equal(director.playOperationAlert(), true, 'operation alert should replay after its cooldown');
clock.advance(250);
assert.equal(director.playEnemyTurnCue(), true, 'enemy turn cue should play after the shared turn cooldown');
assertClose(audioHarness.byOriginalSrc('/audio/enemy-turn.ogg').volume, 0.3, 'enemy turn cue volume');
clock.advance(300);
assert.equal(director.playObjectiveFailed(), true, 'objective failure should play after the result cooldown');
assertClose(audioHarness.byOriginalSrc('/audio/objective-failure.ogg').volume, 0.32, 'objective failure volume');
debug = director.getDebugState();
assert.deepEqual(
debug.semanticCues.lastPlayed,
{ group: 'objective', key: 'objective-failure', at: clock.now },
'debug state should expose the latest semantic cue'
);
assert.equal(audioHarness.instances.length, tacticalCueStart + 6, 'expired cooldowns should permit new Audio');
clock.advance(2500);
debug = director.getDebugState();
assert.equal(debug.activeEffectCount, 0, 'tactical cues should retire after their playback windows');
assert.equal(audioHarness.activeInstances().length, 1, 'tactical cue cleanup should leave only music active');
const narrativeCueStart = audioHarness.instances.length;
const musicDuckRevisionBeforeNarrativeCue = debug.musicDuck.revision;
assert.equal(director.playStoryAdvanceCue(), true, 'the first story advance cue should play');
assertClose(audioHarness.byOriginalSrc('/audio/story-page-turn.ogg').volume, 0.15, 'story advance cue volume');
debug = director.getDebugState();
assert.equal(
debug.musicDuck.revision,
musicDuckRevisionBeforeNarrativeCue,
'story advance cues must not duck music'
);
assert.deepEqual(
debug.semanticCues.lastPlayed,
{ group: 'narrative', key: 'story-page-turn', at: clock.now },
'debug state should expose a played story advance cue'
);
assert.equal(director.playStoryAdvanceCue(), false, 'rapid story advance cues should be coalesced');
debug = director.getDebugState();
assert.deepEqual(
debug.semanticCues.lastSuppressed,
{ group: 'narrative', key: 'story-page-turn', at: clock.now, remainingMs: 220 },
'debug state should expose a coalesced story advance cue'
);
assert.equal(audioHarness.instances.length, narrativeCueStart + 1, 'coalesced story cues must not construct Audio');
clock.advance(220);
assert.equal(director.playStoryAdvanceCue(), true, 'story advance cues should replay after the narrative cooldown');
assert.equal(audioHarness.instances.length, narrativeCueStart + 2, 'expired narrative cooldown should permit new Audio');
clock.advance(1100);
assert.equal(director.getDebugState().activeEffectCount, 0, 'story advance cues should retire after playback');
const feedbackCueStart = audioHarness.instances.length;
const musicDuckRevisionBeforeFeedback = director.getDebugState().musicDuck.revision;
assert.equal(director.playRecoveryCue(), true, 'the first recovery cue should play');
assertClose(audioHarness.byOriginalSrc('/audio/recovery-cue.ogg').volume, 0.26, 'recovery cue volume');
assert.equal(director.playRecoveryCue(), false, 'rapid recovery cues should be coalesced');
assert.equal(director.playBurnTickCue(), true, 'the first batched burn cue should play');
assertClose(audioHarness.byOriginalSrc('/audio/burn-tick.ogg').volume, 0.24, 'burn cue volume');
assert.equal(director.playBurnTickCue(), false, 'rapid burn cues should be coalesced');
assert.equal(director.playRewardRevealCue(), true, 'the first reward reveal cue should play');
assertClose(audioHarness.byOriginalSrc('/audio/reward-reveal.ogg').volume, 0.26, 'reward reveal cue volume');
assert.equal(director.playRewardRevealCue(), false, 'rapid reward reveal cues should be coalesced');
debug = director.getDebugState();
assert.equal(
debug.musicDuck.revision,
musicDuckRevisionBeforeFeedback + 1,
'only the reward reveal cue should apply a short music duck'
);
assert.deepEqual(
debug.semanticCues.lastSuppressed,
{ group: 'reward', key: 'reward-reveal', at: clock.now, remainingMs: 1100 },
'debug state should expose a coalesced reward reveal cue'
);
assert.equal(audioHarness.instances.length, feedbackCueStart + 3, 'coalesced feedback cues must not construct Audio');
clock.advance(280);
assert.equal(director.playRecoveryCue(), false, 'recovery cues should remain coalesced for the clip playback window');
assert.equal(director.playBurnTickCue(), true, 'burn cues should replay after their cooldown');
assert.equal(director.playRewardRevealCue(), false, 'reward cues should retain their longer arrival cooldown');
clock.advance(820);
assert.equal(director.playRewardRevealCue(), true, 'reward cues should replay after the arrival cooldown');
assert.equal(director.playRecoveryCue(), false, 'recovery cues should still be coalesced before the clip finishes');
clock.advance(800);
assert.equal(director.playRecoveryCue(), true, 'recovery cues should replay after the full clip playback window');
clock.advance(2000);
assert.equal(director.getDebugState().activeEffectCount, 0, 'semantic feedback cues should retire after playback');
const semanticEffectStart = audioHarness.instances.length;
director.playMeleeSwing(true, false);
assert.equal(director.getDebugState().lastEffect?.key, 'melee-swing', 'melee API should use the swing pool');
director.playArrowShot();
assert.equal(director.getDebugState().lastEffect?.key, 'bow-release', 'arrow API should play the bow release');
director.playArrowImpact(false, false);
assert.equal(director.getDebugState().lastEffect?.key, 'arrow-miss', 'missed arrows should use the miss pool');
director.playArrowImpact(true, true);
const criticalImpact = audioHarness.byOriginalSrc('/audio/critical-impact.ogg');
assert(criticalImpact, 'critical arrow impacts should layer the critical impact track');
clock.advance(34);
const criticalBoom = audioHarness.byOriginalSrc('/audio/critical-boom.ogg');
assert(criticalBoom, 'critical accents should layer the delayed boom track');
director.playCombatImpact(false, false);
assert.equal(director.getDebugState().lastEffect?.key, 'melee-impact', 'combat API should use the impact pool');
director.playStrategyImpact('fire', false);
assert.deepEqual(
pickEffect(director.getDebugState().lastEffect),
{ key: 'burn-tick', trackKey: 'burn-tick', rate: 0.94 },
'fire tactics should use the sharp burn contact layer'
);
director.playStrategyImpact('roar', false);
assert.deepEqual(
pickEffect(director.getDebugState().lastEffect),
{ key: 'combat-impact', trackKey: 'combat-impact', rate: 0.8 },
'roar tactics should use a low-pitched force impact'
);
director.playStrategyImpact('arcane', false);
assert.deepEqual(
pickEffect(director.getDebugState().lastEffect),
{ key: 'strategy-cast', trackKey: 'strategy-cast', rate: 1.2 },
'arcane tactics should use a brighter magical contact layer'
);
assert.deepEqual(
director.getDebugState().recentEffects.slice(-3).map(pickEffect),
[
{ key: 'burn-tick', trackKey: 'burn-tick', rate: 0.94 },
{ key: 'combat-impact', trackKey: 'combat-impact', rate: 0.8 },
{ key: 'strategy-cast', trackKey: 'strategy-cast', rate: 1.2 }
],
'recent effect history should retain semantic contact ordering for browser diagnostics'
);
director.playBattleOutcome('victory');
assert.equal(director.getDebugState().lastEffect?.key, 'victory-fanfare', 'victory should play its outcome track');
director.playBattleOutcome('defeat');
assert.equal(director.getDebugState().lastEffect?.key, 'defeat-sting', 'defeat should play its outcome track');
clock.advance(620);
assert.notEqual(criticalImpact.src, '', 'critical impact must not be cut at the former 520ms limit');
assert.notEqual(criticalBoom.src, '', 'critical boom must preserve its audible tail beyond 620ms');
clock.advance(180);
assertRetired(criticalImpact, 'critical impact natural-length cleanup');
assert.notEqual(criticalBoom.src, '', 'critical boom should remain active after the short impact ends');
clock.advance(1400);
debug = director.getDebugState();
assert.equal(debug.activeEffectCount, 0, 'semantic effects should all retire after their full playback windows');
assertRetired(criticalBoom, 'critical boom natural-length cleanup');
assert.equal(
audioHarness.activeInstances().length,
1,
`semantic API verification should leave only music active after ${audioHarness.instances.length - semanticEffectStart} effects`
);
console.log(
`Verified dual SoundDirector channels across ${debug.music.elementRevision} music and ` +
`${debug.ambience.elementRevision} ambience element revisions, peak-aware loop gain, gain-balanced no-repeat effect pools, ` +
'route-sampled terrain movement with bounded foot and hoof voices, ' +
'coalesced tactical, narrative, and feedback cues, semantic combat effects, category multipliers, and deterministic music ducking.'
);
} finally {
restoreGlobals();
}
} finally {
await server.close();
}
function verifyAudioPreferences({
audioLevelLabel,
audioLevelSteps,
audioPreferencesStorageKey,
defaultAudioPreferences,
loadAudioPreferences,
nextAudioLevel,
saveAudioPreferences,
updateAudioPreference
}) {
const storage = createMemoryStorage();
assert.deepEqual(loadAudioPreferences(storage), defaultAudioPreferences, 'empty audio preferences should use defaults');
storage.setItem(
audioPreferencesStorageKey,
JSON.stringify({ muted: true, music: 0.75, effects: 0.5, ambience: 0.25 })
);
const loaded = loadAudioPreferences(storage);
assert.deepEqual(
loaded,
{ muted: true, music: 0.75, effects: 0.5, ambience: 0.25 },
'stored audio preferences should load all supported levels'
);
storage.setItem(audioPreferencesStorageKey, JSON.stringify({ muted: 'yes', music: 0.6, effects: 0.75 }));
assert.deepEqual(
loadAudioPreferences(storage),
{ muted: false, music: 1, effects: 0.75, ambience: 1 },
'invalid stored values should fall back independently'
);
assert.deepEqual(
audioLevelSteps.map((level) => nextAudioLevel(level)),
[0.75, 0.5, 0.25, 1],
'audio level cycle should wrap through the supported steps'
);
const updated = updateAudioPreference(loaded, 'effects', 1);
assert.equal(updated.effects, 1, 'audio category update should replace the requested level');
assert.equal(loaded.effects, 0.5, 'audio category update should not mutate its input');
assert.equal(audioLevelLabel(0.75), '75%', 'audio level label');
saveAudioPreferences(updated, storage);
assert.deepEqual(JSON.parse(storage.getItem(audioPreferencesStorageKey)), updated, 'audio preferences should persist');
}
function createMemoryStorage() {
const values = new Map();
return {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(key, String(value));
}
};
}
function assertCompletedChannel(channel, revision, targetVolume, context) {
assert.equal(channel.transition.revision, revision, `${context}: transition revision`);
assert.equal(channel.transition.completedRevision, revision, `${context}: completed revision`);
assert.equal(channel.transition.active, false, `${context}: active flag`);
assert.equal(channel.activeElementCount, 1, `${context}: active element count`);
assert.equal(channel.targetVolume, targetVolume, `${context}: target volume`);
}
function assertRetired(element, context) {
assert(element, `${context}: missing Audio instance`);
assert.equal(element.paused, true, `${context}: element should be paused`);
assert.equal(element.src, '', `${context}: element source should be cleared`);
assert(element.pauseCalls > 0, `${context}: pause should have been called`);
}
function assertClose(actual, expected, context) {
assert(Math.abs(actual - expected) < 1e-9, `${context}: expected ${expected}, received ${actual}`);
}
function pickEffect(effect) {
return effect ? { key: effect.key, trackKey: effect.trackKey, rate: effect.rate } : null;
}
function createAudioHarness() {
const instances = [];
let shouldRejectNextPlay = false;
class FakeAudio {
constructor(src = '') {
this.originalSrc = src;
this.src = src;
this.loop = false;
this.preload = '';
this.muted = false;
this.volume = 1;
this.playbackRate = 1;
this.paused = true;
this.playCalls = 0;
this.pauseCalls = 0;
this.listeners = new Map();
instances.push(this);
}
play() {
this.paused = false;
this.playCalls += 1;
if (shouldRejectNextPlay) {
shouldRejectNextPlay = false;
return Promise.reject(new Error('Simulated media playback failure'));
}
return Promise.resolve();
}
pause() {
this.paused = true;
this.pauseCalls += 1;
}
addEventListener(type, listener, options = {}) {
const entries = this.listeners.get(type) ?? [];
entries.push({ listener, once: Boolean(options.once) });
this.listeners.set(type, entries);
}
emit(type) {
const entries = [...(this.listeners.get(type) ?? [])];
entries.forEach(({ listener }) => listener());
this.listeners.set(type, entries.filter(({ once }) => !once));
}
}
return {
FakeAudio,
instances,
byOriginalSrc(src) {
return instances.find((element) => element.originalSrc === src);
},
activeInstances() {
return instances.filter((element) => element.src !== '');
},
rejectNextPlay() {
shouldRejectNextPlay = true;
}
};
}
function installBrowserFakes(clock, FakeAudio) {
const descriptors = new Map(
['window', 'Audio', 'performance'].map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)])
);
const originalPerformance = globalThis.performance;
const fakePerformance = new Proxy(originalPerformance, {
get(target, property) {
if (property === 'now') {
return () => clock.now;
}
const value = Reflect.get(target, property, target);
return typeof value === 'function' ? value.bind(target) : value;
}
});
const FakeAudioContext = createFakeAudioContext(clock);
const fakeWindow = {
AudioContext: FakeAudioContext,
webkitAudioContext: FakeAudioContext,
setInterval: (callback, delay) => clock.setInterval(callback, delay),
clearInterval: (timerId) => clock.clearTimer(timerId),
setTimeout: (callback, delay) => clock.setTimeout(callback, delay),
clearTimeout: (timerId) => clock.clearTimer(timerId)
};
defineGlobal('window', fakeWindow);
defineGlobal('Audio', FakeAudio);
defineGlobal('performance', fakePerformance);
return () => {
for (const [key, descriptor] of descriptors) {
if (descriptor) {
Object.defineProperty(globalThis, key, descriptor);
} else {
delete globalThis[key];
}
}
};
}
function defineGlobal(key, value) {
Object.defineProperty(globalThis, key, {
configurable: true,
enumerable: true,
writable: true,
value
});
}
function createFakeAudioContext(clock) {
return class FakeAudioContext {
constructor() {
this.destination = {};
this.state = 'suspended';
}
get currentTime() {
return clock.now / 1000;
}
createGain() {
return {
gain: {
value: 1,
cancelScheduledValues() {},
linearRampToValueAtTime() {},
setValueAtTime() {},
exponentialRampToValueAtTime() {}
},
connect() {}
};
}
createOscillator() {
return {
type: 'sine',
frequency: {
value: 0,
setValueAtTime() {},
exponentialRampToValueAtTime() {}
},
connect() {},
start() {},
stop() {}
};
}
resume() {
this.state = 'running';
return Promise.resolve();
}
};
}