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

407 lines
15 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 } = 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();
}
};
}