feat: add layered soundscape channels
This commit is contained in:
394
scripts/verify-sound-director.mjs
Normal file
394
scripts/verify-sound-director.mjs
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
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');
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,29 +1,66 @@
|
|||||||
type AudioTrackMap = Record<string, string>;
|
type AudioTrackMap = Record<string, string>;
|
||||||
|
|
||||||
|
export type SoundscapeOptions = {
|
||||||
|
musicKey?: string;
|
||||||
|
ambienceKey?: string;
|
||||||
|
musicVolume?: number;
|
||||||
|
ambienceVolume?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LoopChannelName = 'music' | 'ambience';
|
||||||
|
|
||||||
|
type LoopChannelTransition = {
|
||||||
|
revision: number;
|
||||||
|
completedRevision: number;
|
||||||
|
active: boolean;
|
||||||
|
last?: {
|
||||||
|
fromKey: string | null;
|
||||||
|
toKey: string | null;
|
||||||
|
durationMs: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type LoopChannel = {
|
||||||
|
name: LoopChannelName;
|
||||||
|
tracks: AudioTrackMap;
|
||||||
|
defaultVolume: number;
|
||||||
|
targetVolume: number;
|
||||||
|
fadeDurationMs: number;
|
||||||
|
current?: HTMLAudioElement;
|
||||||
|
currentKey?: string;
|
||||||
|
pendingKey?: string;
|
||||||
|
hasPendingRequest: boolean;
|
||||||
|
outgoing: Set<HTMLAudioElement>;
|
||||||
|
outgoingStartVolumes: Map<HTMLAudioElement, number>;
|
||||||
|
fadeTimer?: number;
|
||||||
|
elementRevision: number;
|
||||||
|
transition: LoopChannelTransition;
|
||||||
|
};
|
||||||
|
|
||||||
type PlayEffectOptions = {
|
type PlayEffectOptions = {
|
||||||
volume?: number;
|
volume?: number;
|
||||||
rate?: number;
|
rate?: number;
|
||||||
stopAfterMs?: number;
|
stopAfterMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
class SoundDirector {
|
export class SoundDirector {
|
||||||
private context?: AudioContext;
|
private context?: AudioContext;
|
||||||
private master?: GainNode;
|
private master?: GainNode;
|
||||||
private started = false;
|
private started = false;
|
||||||
private muted = false;
|
private muted = false;
|
||||||
private musicTracks: AudioTrackMap = {};
|
|
||||||
private effectTracks: AudioTrackMap = {};
|
private effectTracks: AudioTrackMap = {};
|
||||||
private music?: HTMLAudioElement;
|
private readonly musicChannel = this.createLoopChannel('music', 0.22, 900);
|
||||||
private currentMusicKey?: string;
|
private readonly ambienceChannel = this.createLoopChannel('ambience', 0.08, 1200);
|
||||||
private pendingMusicKey?: string;
|
|
||||||
private musicFadeTimer?: number;
|
|
||||||
private lastEffect?: { key: string; at: number; volume: number; rate: number };
|
private lastEffect?: { key: string; at: number; volume: number; rate: number };
|
||||||
private lastMovementStep?: { key: string; mounted: boolean; stepIndex: number; at: number; volume?: number; rate?: number };
|
private lastMovementStep?: { key: string; mounted: boolean; stepIndex: number; at: number; volume?: number; rate?: number };
|
||||||
private readonly musicVolume = 0.22;
|
|
||||||
private readonly effectVolume = 0.42;
|
private readonly effectVolume = 0.42;
|
||||||
|
|
||||||
registerMusicTracks(tracks: AudioTrackMap) {
|
registerMusicTracks(tracks: AudioTrackMap) {
|
||||||
this.musicTracks = tracks;
|
this.registerLoopTracks(this.musicChannel, tracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerAmbienceTracks(tracks: AudioTrackMap) {
|
||||||
|
this.registerLoopTracks(this.ambienceChannel, tracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
registerEffectTracks(tracks: AudioTrackMap) {
|
registerEffectTracks(tracks: AudioTrackMap) {
|
||||||
@@ -33,9 +70,8 @@ class SoundDirector {
|
|||||||
start() {
|
start() {
|
||||||
if (this.started) {
|
if (this.started) {
|
||||||
this.resume();
|
this.resume();
|
||||||
if (this.pendingMusicKey) {
|
this.applyPendingLoopRequest(this.musicChannel);
|
||||||
this.playMusic(this.pendingMusicKey);
|
this.applyPendingLoopRequest(this.ambienceChannel);
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,22 +85,25 @@ class SoundDirector {
|
|||||||
|
|
||||||
this.started = true;
|
this.started = true;
|
||||||
this.resume();
|
this.resume();
|
||||||
if (this.pendingMusicKey) {
|
this.applyPendingLoopRequest(this.musicChannel);
|
||||||
this.playMusic(this.pendingMusicKey);
|
this.applyPendingLoopRequest(this.ambienceChannel);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resume() {
|
resume() {
|
||||||
void this.context?.resume();
|
void this.context?.resume();
|
||||||
if (this.started && this.music) {
|
if (!this.started) {
|
||||||
void this.music.play().catch(() => undefined);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const element of this.loopElements()) {
|
||||||
|
void element.play().catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setMuted(muted: boolean) {
|
setMuted(muted: boolean) {
|
||||||
this.muted = muted;
|
this.muted = muted;
|
||||||
if (this.music) {
|
for (const element of this.loopElements()) {
|
||||||
this.music.muted = muted;
|
element.muted = muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.context || !this.master) {
|
if (!this.context || !this.master) {
|
||||||
@@ -200,12 +239,19 @@ class SoundDirector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getDebugState() {
|
getDebugState() {
|
||||||
|
const music = this.loopChannelDebugState(this.musicChannel);
|
||||||
|
const ambience = this.loopChannelDebugState(this.ambienceChannel);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
started: this.started,
|
started: this.started,
|
||||||
muted: this.muted,
|
muted: this.muted,
|
||||||
contextState: this.context?.state ?? null,
|
contextState: this.context?.state ?? null,
|
||||||
currentMusicKey: this.currentMusicKey ?? null,
|
currentMusicKey: music.currentKey,
|
||||||
pendingMusicKey: this.pendingMusicKey ?? null,
|
pendingMusicKey: music.pendingKey,
|
||||||
|
currentAmbienceKey: ambience.currentKey,
|
||||||
|
pendingAmbienceKey: ambience.pendingKey,
|
||||||
|
music,
|
||||||
|
ambience,
|
||||||
lastEffect: this.lastEffect ?? null,
|
lastEffect: this.lastEffect ?? null,
|
||||||
lastMovementStep: this.lastMovementStep ?? null
|
lastMovementStep: this.lastMovementStep ?? null
|
||||||
};
|
};
|
||||||
@@ -216,33 +262,23 @@ class SoundDirector {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pendingMusicKey = key;
|
this.playSoundscape({ musicKey: key });
|
||||||
if (!this.started) {
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const src = this.musicTracks[key];
|
playSoundscape({ musicKey, ambienceKey, musicVolume, ambienceVolume }: SoundscapeOptions) {
|
||||||
if (!src) {
|
this.setLoopTargetVolume(this.musicChannel, musicVolume ?? this.musicChannel.defaultVolume);
|
||||||
return;
|
this.setLoopTargetVolume(this.ambienceChannel, ambienceVolume ?? this.ambienceChannel.defaultVolume);
|
||||||
}
|
this.requestLoopChannel(this.musicChannel, musicKey);
|
||||||
|
this.requestLoopChannel(this.ambienceChannel, ambienceKey);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.currentMusicKey === key && this.music) {
|
playAmbience(key?: string, volume = this.ambienceChannel.defaultVolume) {
|
||||||
this.music.muted = this.muted;
|
this.setLoopTargetVolume(this.ambienceChannel, volume);
|
||||||
void this.music.play().catch(() => undefined);
|
this.requestLoopChannel(this.ambienceChannel, key);
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const previous = this.music;
|
stopAmbience() {
|
||||||
const next = new Audio(src);
|
this.requestLoopChannel(this.ambienceChannel, undefined);
|
||||||
next.loop = true;
|
|
||||||
next.preload = 'auto';
|
|
||||||
next.muted = this.muted;
|
|
||||||
next.volume = 0;
|
|
||||||
|
|
||||||
this.music = next;
|
|
||||||
this.currentMusicKey = key;
|
|
||||||
void next.play().catch(() => undefined);
|
|
||||||
this.fadeMusic(previous, next, this.musicVolume);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private playTone(
|
private playTone(
|
||||||
@@ -316,36 +352,197 @@ class SoundDirector {
|
|||||||
oscillator.stop(start + duration + 0.025);
|
oscillator.stop(start + duration + 0.025);
|
||||||
}
|
}
|
||||||
|
|
||||||
private fadeMusic(previous: HTMLAudioElement | undefined, next: HTMLAudioElement, targetVolume: number) {
|
private createLoopChannel(name: LoopChannelName, targetVolume: number, fadeDurationMs: number): LoopChannel {
|
||||||
if (this.musicFadeTimer) {
|
return {
|
||||||
window.clearInterval(this.musicFadeTimer);
|
name,
|
||||||
|
tracks: {},
|
||||||
|
defaultVolume: targetVolume,
|
||||||
|
targetVolume,
|
||||||
|
fadeDurationMs,
|
||||||
|
hasPendingRequest: false,
|
||||||
|
outgoing: new Set(),
|
||||||
|
outgoingStartVolumes: new Map(),
|
||||||
|
elementRevision: 0,
|
||||||
|
transition: {
|
||||||
|
revision: 0,
|
||||||
|
completedRevision: 0,
|
||||||
|
active: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerLoopTracks(channel: LoopChannel, tracks: AudioTrackMap) {
|
||||||
|
channel.tracks = tracks;
|
||||||
|
if (this.started) {
|
||||||
|
this.applyPendingLoopRequest(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLoopTargetVolume(channel: LoopChannel, volume: number) {
|
||||||
|
channel.targetVolume = Number.isFinite(volume) ? Math.min(1, Math.max(0, volume)) : channel.defaultVolume;
|
||||||
|
if (channel.current && !channel.transition.active) {
|
||||||
|
channel.current.volume = channel.targetVolume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestLoopChannel(channel: LoopChannel, key?: string) {
|
||||||
|
channel.pendingKey = key;
|
||||||
|
channel.hasPendingRequest = true;
|
||||||
|
if (this.started) {
|
||||||
|
this.applyPendingLoopRequest(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyPendingLoopRequest(channel: LoopChannel) {
|
||||||
|
if (!channel.hasPendingRequest) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const duration = 900;
|
const key = channel.pendingKey;
|
||||||
|
const requestedTarget = key ?? null;
|
||||||
|
const alreadyTargetingKey =
|
||||||
|
(key !== undefined && channel.currentKey === key && Boolean(channel.current)) ||
|
||||||
|
(key === undefined && !channel.current && !channel.outgoing.size) ||
|
||||||
|
(channel.transition.active && channel.transition.last?.toKey === requestedTarget);
|
||||||
|
|
||||||
|
if (alreadyTargetingKey) {
|
||||||
|
channel.pendingKey = undefined;
|
||||||
|
channel.hasPendingRequest = false;
|
||||||
|
if (channel.current) {
|
||||||
|
channel.current.muted = this.muted;
|
||||||
|
if (!channel.transition.active) {
|
||||||
|
channel.current.volume = channel.targetVolume;
|
||||||
|
}
|
||||||
|
void channel.current.play().catch(() => undefined);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const src = key ? channel.tracks[key] : undefined;
|
||||||
|
if (key && !src) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.pendingKey = undefined;
|
||||||
|
channel.hasPendingRequest = false;
|
||||||
|
this.beginLoopTransition(channel, key, src);
|
||||||
|
}
|
||||||
|
|
||||||
|
private beginLoopTransition(channel: LoopChannel, key?: string, src?: string) {
|
||||||
|
this.cancelLoopTransitionTimer(channel);
|
||||||
|
this.retireOutgoingElements(channel);
|
||||||
|
|
||||||
|
const previous = channel.current;
|
||||||
|
const fromKey = channel.currentKey ?? null;
|
||||||
|
if (previous) {
|
||||||
|
channel.outgoing.add(previous);
|
||||||
|
channel.outgoingStartVolumes.set(previous, previous.volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.current = undefined;
|
||||||
|
channel.currentKey = undefined;
|
||||||
|
|
||||||
|
if (key && src) {
|
||||||
|
const next = new Audio(src);
|
||||||
|
next.loop = true;
|
||||||
|
next.preload = 'auto';
|
||||||
|
next.muted = this.muted;
|
||||||
|
next.volume = 0;
|
||||||
|
channel.current = next;
|
||||||
|
channel.currentKey = key;
|
||||||
|
channel.elementRevision += 1;
|
||||||
|
void next.play().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.transition.revision += 1;
|
||||||
|
channel.transition.active = true;
|
||||||
|
channel.transition.last = {
|
||||||
|
fromKey,
|
||||||
|
toKey: key ?? null,
|
||||||
|
durationMs: channel.fadeDurationMs
|
||||||
|
};
|
||||||
|
|
||||||
|
const transitionRevision = channel.transition.revision;
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
const previousStartVolume = previous?.volume ?? 0;
|
channel.fadeTimer = window.setInterval(() => {
|
||||||
|
if (transitionRevision !== channel.transition.revision) {
|
||||||
this.musicFadeTimer = window.setInterval(() => {
|
return;
|
||||||
const progress = Math.min(1, (performance.now() - startedAt) / duration);
|
|
||||||
next.volume = targetVolume * progress;
|
|
||||||
|
|
||||||
if (previous) {
|
|
||||||
previous.volume = previousStartVolume * (1 - progress);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const progress = Math.min(1, Math.max(0, (performance.now() - startedAt) / channel.fadeDurationMs));
|
||||||
|
if (channel.current) {
|
||||||
|
channel.current.volume = channel.targetVolume * progress;
|
||||||
|
}
|
||||||
|
channel.outgoing.forEach((element) => {
|
||||||
|
const startVolume = channel.outgoingStartVolumes.get(element) ?? element.volume;
|
||||||
|
element.volume = startVolume * (1 - progress);
|
||||||
|
});
|
||||||
|
|
||||||
if (progress >= 1) {
|
if (progress >= 1) {
|
||||||
if (this.musicFadeTimer) {
|
this.completeLoopTransition(channel, transitionRevision);
|
||||||
window.clearInterval(this.musicFadeTimer);
|
|
||||||
this.musicFadeTimer = undefined;
|
|
||||||
}
|
|
||||||
if (previous) {
|
|
||||||
previous.pause();
|
|
||||||
previous.src = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, 32);
|
}, 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private completeLoopTransition(channel: LoopChannel, transitionRevision: number) {
|
||||||
|
if (transitionRevision !== channel.transition.revision) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cancelLoopTransitionTimer(channel);
|
||||||
|
this.retireOutgoingElements(channel);
|
||||||
|
if (channel.current) {
|
||||||
|
channel.current.volume = channel.targetVolume;
|
||||||
|
}
|
||||||
|
channel.transition.active = false;
|
||||||
|
channel.transition.completedRevision = transitionRevision;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelLoopTransitionTimer(channel: LoopChannel) {
|
||||||
|
if (channel.fadeTimer !== undefined) {
|
||||||
|
window.clearInterval(channel.fadeTimer);
|
||||||
|
channel.fadeTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private retireOutgoingElements(channel: LoopChannel) {
|
||||||
|
channel.outgoing.forEach((element) => this.retireLoopElement(element));
|
||||||
|
channel.outgoing.clear();
|
||||||
|
channel.outgoingStartVolumes.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private retireLoopElement(element: HTMLAudioElement) {
|
||||||
|
element.pause();
|
||||||
|
element.src = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private loopElements() {
|
||||||
|
const elements = new Set<HTMLAudioElement>();
|
||||||
|
for (const channel of [this.musicChannel, this.ambienceChannel]) {
|
||||||
|
if (channel.current) {
|
||||||
|
elements.add(channel.current);
|
||||||
|
}
|
||||||
|
channel.outgoing.forEach((element) => elements.add(element));
|
||||||
|
}
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
private loopChannelDebugState(channel: LoopChannel) {
|
||||||
|
return {
|
||||||
|
currentKey: channel.currentKey ?? null,
|
||||||
|
pendingKey: channel.hasPendingRequest ? channel.pendingKey ?? null : null,
|
||||||
|
hasPendingRequest: channel.hasPendingRequest,
|
||||||
|
elementRevision: channel.elementRevision,
|
||||||
|
activeElementCount: (channel.current ? 1 : 0) + channel.outgoing.size,
|
||||||
|
targetVolume: channel.targetVolume,
|
||||||
|
transition: {
|
||||||
|
revision: channel.transition.revision,
|
||||||
|
completedRevision: channel.transition.completedRevision,
|
||||||
|
active: channel.transition.active,
|
||||||
|
last: channel.transition.last ? { ...channel.transition.last } : null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
Reference in New Issue
Block a user