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 } = await server.ssrLoadModule('/src/game/audio/SoundDirector.ts'); 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' }); 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'); 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('ambience-a', 0.03); clock.advance(1300); 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'); console.log( `Verified dual SoundDirector channels across ${debug.music.elementRevision} music and ` + `${debug.ambience.elementRevision} ambience element revisions with deterministic 900/1200ms transitions.` ); } finally { restoreGlobals(); } } finally { await server.close(); } 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 createAudioHarness() { const instances = []; 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; instances.push(this); } play() { this.paused = false; this.playCalls += 1; return Promise.resolve(); } pause() { this.paused = true; this.pauseCalls += 1; } addEventListener() {} } return { FakeAudio, instances, byOriginalSrc(src) { return instances.find((element) => element.originalSrc === src); }, activeInstances() { return instances.filter((element) => element.src !== ''); } }; } 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(); } }; }