From 1eedd631633f6cb62e6808f0855144450d676258 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 13 Jul 2026 05:00:06 +0900 Subject: [PATCH] feat: add layered soundscape channels --- scripts/verify-sound-director.mjs | 394 ++++++++++++++++++++++++++++++ src/game/audio/SoundDirector.ts | 325 +++++++++++++++++++----- 2 files changed, 655 insertions(+), 64 deletions(-) create mode 100644 scripts/verify-sound-director.mjs diff --git a/scripts/verify-sound-director.mjs b/scripts/verify-sound-director.mjs new file mode 100644 index 0000000..033bac5 --- /dev/null +++ b/scripts/verify-sound-director.mjs @@ -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(); + } + }; +} diff --git a/src/game/audio/SoundDirector.ts b/src/game/audio/SoundDirector.ts index de9b883..d666415 100644 --- a/src/game/audio/SoundDirector.ts +++ b/src/game/audio/SoundDirector.ts @@ -1,29 +1,66 @@ type AudioTrackMap = Record; +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; + outgoingStartVolumes: Map; + fadeTimer?: number; + elementRevision: number; + transition: LoopChannelTransition; +}; + type PlayEffectOptions = { volume?: number; rate?: number; stopAfterMs?: number; }; -class SoundDirector { +export class SoundDirector { private context?: AudioContext; private master?: GainNode; private started = false; private muted = false; - private musicTracks: AudioTrackMap = {}; private effectTracks: AudioTrackMap = {}; - private music?: HTMLAudioElement; - private currentMusicKey?: string; - private pendingMusicKey?: string; - private musicFadeTimer?: number; + private readonly musicChannel = this.createLoopChannel('music', 0.22, 900); + private readonly ambienceChannel = this.createLoopChannel('ambience', 0.08, 1200); 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 readonly musicVolume = 0.22; private readonly effectVolume = 0.42; registerMusicTracks(tracks: AudioTrackMap) { - this.musicTracks = tracks; + this.registerLoopTracks(this.musicChannel, tracks); + } + + registerAmbienceTracks(tracks: AudioTrackMap) { + this.registerLoopTracks(this.ambienceChannel, tracks); } registerEffectTracks(tracks: AudioTrackMap) { @@ -33,9 +70,8 @@ class SoundDirector { start() { if (this.started) { this.resume(); - if (this.pendingMusicKey) { - this.playMusic(this.pendingMusicKey); - } + this.applyPendingLoopRequest(this.musicChannel); + this.applyPendingLoopRequest(this.ambienceChannel); return; } @@ -49,22 +85,25 @@ class SoundDirector { this.started = true; this.resume(); - if (this.pendingMusicKey) { - this.playMusic(this.pendingMusicKey); - } + this.applyPendingLoopRequest(this.musicChannel); + this.applyPendingLoopRequest(this.ambienceChannel); } resume() { void this.context?.resume(); - if (this.started && this.music) { - void this.music.play().catch(() => undefined); + if (!this.started) { + return; + } + + for (const element of this.loopElements()) { + void element.play().catch(() => undefined); } } setMuted(muted: boolean) { this.muted = muted; - if (this.music) { - this.music.muted = muted; + for (const element of this.loopElements()) { + element.muted = muted; } if (!this.context || !this.master) { @@ -200,12 +239,19 @@ class SoundDirector { } getDebugState() { + const music = this.loopChannelDebugState(this.musicChannel); + const ambience = this.loopChannelDebugState(this.ambienceChannel); + return { started: this.started, muted: this.muted, contextState: this.context?.state ?? null, - currentMusicKey: this.currentMusicKey ?? null, - pendingMusicKey: this.pendingMusicKey ?? null, + currentMusicKey: music.currentKey, + pendingMusicKey: music.pendingKey, + currentAmbienceKey: ambience.currentKey, + pendingAmbienceKey: ambience.pendingKey, + music, + ambience, lastEffect: this.lastEffect ?? null, lastMovementStep: this.lastMovementStep ?? null }; @@ -216,33 +262,23 @@ class SoundDirector { return; } - this.pendingMusicKey = key; - if (!this.started) { - return; - } + this.playSoundscape({ musicKey: key }); + } - const src = this.musicTracks[key]; - if (!src) { - return; - } + playSoundscape({ musicKey, ambienceKey, musicVolume, ambienceVolume }: SoundscapeOptions) { + this.setLoopTargetVolume(this.musicChannel, musicVolume ?? this.musicChannel.defaultVolume); + this.setLoopTargetVolume(this.ambienceChannel, ambienceVolume ?? this.ambienceChannel.defaultVolume); + this.requestLoopChannel(this.musicChannel, musicKey); + this.requestLoopChannel(this.ambienceChannel, ambienceKey); + } - if (this.currentMusicKey === key && this.music) { - this.music.muted = this.muted; - void this.music.play().catch(() => undefined); - return; - } + playAmbience(key?: string, volume = this.ambienceChannel.defaultVolume) { + this.setLoopTargetVolume(this.ambienceChannel, volume); + this.requestLoopChannel(this.ambienceChannel, key); + } - const previous = this.music; - const next = new Audio(src); - 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); + stopAmbience() { + this.requestLoopChannel(this.ambienceChannel, undefined); } private playTone( @@ -316,36 +352,197 @@ class SoundDirector { oscillator.stop(start + duration + 0.025); } - private fadeMusic(previous: HTMLAudioElement | undefined, next: HTMLAudioElement, targetVolume: number) { - if (this.musicFadeTimer) { - window.clearInterval(this.musicFadeTimer); + private createLoopChannel(name: LoopChannelName, targetVolume: number, fadeDurationMs: number): LoopChannel { + return { + 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 previousStartVolume = previous?.volume ?? 0; - - this.musicFadeTimer = window.setInterval(() => { - const progress = Math.min(1, (performance.now() - startedAt) / duration); - next.volume = targetVolume * progress; - - if (previous) { - previous.volume = previousStartVolume * (1 - progress); + channel.fadeTimer = window.setInterval(() => { + if (transitionRevision !== channel.transition.revision) { + return; } + 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 (this.musicFadeTimer) { - window.clearInterval(this.musicFadeTimer); - this.musicFadeTimer = undefined; - } - if (previous) { - previous.pause(); - previous.src = ''; - } + this.completeLoopTransition(channel, transitionRevision); } }, 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(); + 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 {