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(); } }; }