From 74da01c68ce69369f3940262085dbc07c7ffe432 Mon Sep 17 00:00:00 2001 From: David Sexton Date: Tue, 11 Aug 2026 19:25:41 -0700 Subject: [PATCH] Suspend idle AudioContext to release the 1 ms platform timer The AudioContext was created at MudClient construction and never suspended for the life of the tab, holding a 1 ms Windows platform timer request and an open OS audio stream even after days of silence. MediaService now suspends the context after 5 minutes with no audio activity and wakes it programmatically via a memoized ensureAwake() at the top of every inbound audio path (load, play, update, setChain, automate, music resume). Pending ramps and fades defer suspension via a busyUntil deadline so scheduled automation is never frozen, and a wake-hold API keeps the context alive for activity the sound registry cannot see; voice chat holds it for the room's lifetime. Construction stays eager so cacophony's auto-unlock still has the longest possible window before the first server-sent sound. Fixes #99 Co-Authored-By: Claude Fable 5 --- src/audio/MediaService.idleSuspend.test.ts | 182 +++++++++++++++++++++ src/audio/MediaService.ts | 140 +++++++++++++++- src/components/audioChat.tsx | 9 +- 3 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 src/audio/MediaService.idleSuspend.test.ts diff --git a/src/audio/MediaService.idleSuspend.test.ts b/src/audio/MediaService.idleSuspend.test.ts new file mode 100644 index 00000000..d67696e3 --- /dev/null +++ b/src/audio/MediaService.idleSuspend.test.ts @@ -0,0 +1,182 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MediaService } from './MediaService'; + +type MockCacophony = ConstructorParameters[0]; + +const IDLE_SUSPEND_MS = 5 * 60 * 1000; + +function makeMasterBus() { + return { + name: 'master', + input: {}, + output: { gain: { value: 1, setValueAtTime: vi.fn(), linearRampToValueAtTime: vi.fn() } }, + addFilter: vi.fn(), + removeFilter: vi.fn(), + destroy: vi.fn(), + destroyed: false, + gain: 1, + }; +} + +function createMockSound(url: string, order: string[]) { + const playback = { connect: vi.fn(), disconnect: vi.fn(), duration: 5, stereoPan: 0 }; + const sound = { + cleanup: vi.fn(), + isPlaying: false, + key: undefined as string | undefined, + loop: vi.fn(), + on: vi.fn(() => () => undefined), + play: vi.fn(() => { + order.push('play'); + sound.isPlaying = true; + return [playback]; + }), + playbacks: [playback], + position: [0, 0, 0], + routeTo: vi.fn(), + seek: vi.fn(), + stereoPan: 0, + url, + volume: 1, + }; + return sound; +} + +function createHarness() { + const order: string[] = []; + const master = makeMasterBus(); + const sounds: ReturnType[] = []; + const cacophony = { + context: { currentTime: 100, sampleRate: 48000 }, + createSound: vi.fn(async (url: string) => { + order.push('createSound'); + const sound = createMockSound(url, order); + sounds.push(sound); + return sound; + }), + getBus: vi.fn(() => master), + listenerForwardOrientation: [0, 0, -1], + listenerUpOrientation: [0, 1, 0], + listenerPosition: [0, 0, 0], + locked: false, + muted: false, + setGlobalVolume: vi.fn(), + pause: vi.fn(async () => { + order.push('pause'); + }), + resume: vi.fn(async () => { + order.push('resume'); + }), + }; + const media = new MediaService(cacophony as unknown as MockCacophony, { manageFocus: false }); + return { cacophony, media, order, sounds }; +} + +const playPayload = { name: 'ding.ogg', url: 'https://example.test/' }; + +describe('MediaService idle suspend', () => { + let harness: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + harness = createHarness(); + }); + + afterEach(() => { + harness.media.shutdown(); + vi.useRealTimers(); + }); + + it('suspends the context after the idle period with nothing playing', async () => { + await vi.advanceTimersByTimeAsync(IDLE_SUSPEND_MS); + + expect(harness.cacophony.pause).toHaveBeenCalledOnce(); + }); + + it('does not suspend while a sound is playing', async () => { + await harness.media.play(playPayload); + expect(harness.sounds[0].isPlaying).toBe(true); + + await vi.advanceTimersByTimeAsync(IDLE_SUSPEND_MS * 2); + + expect(harness.cacophony.pause).not.toHaveBeenCalled(); + }); + + it('suspends once a finished sound is stopped', async () => { + await harness.media.play(playPayload); + harness.media.stop({ name: 'ding.ogg' }); + + await vi.advanceTimersByTimeAsync(IDLE_SUSPEND_MS); + + expect(harness.cacophony.pause).toHaveBeenCalledOnce(); + }); + + it('does not suspend while a wake hold is held, and suspends after it is released', async () => { + harness.media.acquireWakeHold('livekit-voice'); + + await vi.advanceTimersByTimeAsync(IDLE_SUSPEND_MS * 2); + expect(harness.cacophony.pause).not.toHaveBeenCalled(); + + harness.media.releaseWakeHold('livekit-voice'); + await vi.advanceTimersByTimeAsync(IDLE_SUSPEND_MS); + + expect(harness.cacophony.pause).toHaveBeenCalledOnce(); + }); + + it('waits out a pending automation ramp before suspending', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + // Ramp outlives the idle period, so the suspend has to be deferred until it has run: + // AudioContext.currentTime freezes while suspended and the ramp would never complete. + harness.media.automate({ target: 'gain', params: { gain: 0.5 }, ramp: IDLE_SUSPEND_MS * 2 }); + + await vi.advanceTimersByTimeAsync(IDLE_SUSPEND_MS); + expect(harness.cacophony.pause).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(IDLE_SUSPEND_MS); + expect(harness.cacophony.pause).toHaveBeenCalledOnce(); + warn.mockRestore(); + }); + + it('resumes exactly once for concurrent ensureAwake callers', async () => { + let releaseResume: (() => void) | undefined; + harness.cacophony.resume.mockImplementation( + () => + new Promise((resolve) => { + releaseResume = () => resolve(); + }), + ); + await vi.advanceTimersByTimeAsync(IDLE_SUSPEND_MS); + expect(harness.cacophony.pause).toHaveBeenCalledOnce(); + + const awakened = Promise.all([harness.media.ensureAwake(), harness.media.ensureAwake()]); + releaseResume?.(); + await awakened; + + expect(harness.cacophony.resume).toHaveBeenCalledOnce(); + }); + + it('resumes a suspended context before playing', async () => { + await vi.advanceTimersByTimeAsync(IDLE_SUSPEND_MS); + expect(harness.order).toEqual(['pause']); + + await harness.media.play(playPayload); + + expect(harness.order).toEqual(['pause', 'resume', 'createSound', 'play']); + }); + + it('does not resume a context that is already running', async () => { + await harness.media.play(playPayload); + + expect(harness.cacophony.resume).not.toHaveBeenCalled(); + }); + + it('stops the idle timer on shutdown', async () => { + harness.media.shutdown(); + + await vi.advanceTimersByTimeAsync(IDLE_SUSPEND_MS * 2); + + expect(harness.cacophony.pause).not.toHaveBeenCalled(); + }); +}); diff --git a/src/audio/MediaService.ts b/src/audio/MediaService.ts index dbef624e..a4d3691c 100644 --- a/src/audio/MediaService.ts +++ b/src/audio/MediaService.ts @@ -20,6 +20,13 @@ type CacophonySoundKind = NonNullable[1]>; const CACOPHONY_BUFFER = 'buffer' satisfies CacophonySoundKind; const CACOPHONY_HTML = 'html' satisfies CacophonySoundKind; const MAX_PRELOADED_SOUNDS = 32; +/** Idle period after which the AudioContext is suspended. A running context holds an open OS + * audio stream and a 1 ms platform timer request (a machine-wide effect on Windows), so a tab + * left open overnight burns battery for nothing. Five minutes is long enough that a server-sent + * sound effect essentially never lands on a cold context mid-session, while still capturing the + * overnight/AFK savings. Resuming is allowed without a fresh gesture once the document has + * sticky user activation, which a MUD client has by login. */ +const IDLE_SUSPEND_MS = 5 * 60 * 1000; /** Constant makeup gain restoring the clean positional FOA decode to a useful level. Tune by ear. * The SN3D encode + SH-HRIR binaural decode lands well below unity, so a positioned source is @@ -163,6 +170,14 @@ export class MediaService { private readonly manageFocus: boolean; private unsubscribePreferences: (() => void) | null = null; private shutdownComplete = false; + private idleTimer: ReturnType | null = null; + /** Single in-flight resume shared by concurrent {@link ensureAwake} callers. */ + private resumePromise: Promise | null = null; + /** True while we have suspended the context ourselves (mirrors cacophony's suspendState). */ + private contextSuspended = false; + /** Wall-clock deadline for scheduled-but-not-yet-run automation (ramps, fades). */ + private busyUntil = 0; + private readonly wakeHolds = new Set(); constructor(cacophony: Cacophony = new Cacophony(), options: MediaServiceOptions = {}) { this.cacophony = cacophony; @@ -181,6 +196,10 @@ export class MediaService { this.updateBackgroundMuteState(); }, ); + + // The context is constructed eagerly (deliberately — see IDLE_SUSPEND_MS), so a session + // that never plays anything still has to fall asleep on its own. + this.scheduleIdleCheck(); } get muted(): boolean { @@ -202,6 +221,48 @@ export class MediaService { this.cacophony.muted = this.globalMuted || shouldMuteInBackground; } + /** + * Wake the audio context if the idle timer put it to sleep, and restart the idle countdown. + * Awaited at the top of every inbound audio path so nothing is ever played into a suspended + * context. Concurrent callers share one memoized resume promise, so a burst of GMCP messages + * during a wake resolves through a single `resume()` rather than racing several. + * + * Never rejects: a rejected resume (autoplay policy before the first gesture) must not abort + * the play, since cacophony's auto-unlock will start the queued sound at the first gesture. + */ + async ensureAwake(): Promise { + this.scheduleIdleCheck(); + if (!this.contextSuspended && this.cacophony.locked !== true) { + return; + } + this.resumePromise ??= this.cacophony + .resume() + .then(() => { + this.contextSuspended = false; + }) + .catch((error) => { + console.warn('Client.Media: failed to resume the audio context', error); + }) + .finally(() => { + this.resumePromise = null; + }); + await this.resumePromise; + } + + /** + * Pin the context awake for an activity the sound registry cannot see (voice chat, mic + * capture). Held by id so overlapping owners each release their own hold. + */ + acquireWakeHold(id: string): void { + this.wakeHolds.add(id); + void this.ensureAwake(); + } + + releaseWakeHold(id: string): void { + this.wakeHolds.delete(id); + this.scheduleIdleCheck(); + } + setListenerPosition(position: Position | null | undefined): void { if (position?.length) { this.cacophony.listenerPosition = position; @@ -241,7 +302,9 @@ export class MediaService { } } - setChain(data: ClientMediaChainPayload): Promise { + async setChain(data: ClientMediaChainPayload): Promise { + await this.ensureAwake(); + this.markBusyFor(data.fadein); return this.effects.setChain(data); } @@ -250,6 +313,10 @@ export class MediaService { } automate(data: ClientMediaAutomatePayload): void { + // Sync by contract (GMCP handler), so the wake is fire-and-forget: the ramp is scheduled on + // the timeline and busyUntil keeps the suspend away until it has actually run. + void this.ensureAwake(); + this.markBusyFor(data.ramp); const chain = this.resolveAutomateTarget(data); if (!chain) { console.warn('Client.Media.Automate: target chain/sound not found; ignored'); @@ -269,6 +336,7 @@ export class MediaService { } async load(data: ClientMediaLoadPayload): Promise { + await this.ensureAwake(); const url = this.mediaUrl(data); const key = url; if (!this.sounds[key]) { @@ -302,6 +370,9 @@ export class MediaService { } async play(data: ClientMediaPlayPayload): Promise { + await this.ensureAwake(); + this.markBusyFor(data.fadein); + this.markBusyFor(data.fadeout); const mediaUrl = this.mediaUrl(data); data.key = data.key || mediaUrl; const soundKey = data.key; @@ -386,6 +457,10 @@ export class MediaService { } update(data: ClientMediaUpdatePayload): void { + // Sync by contract (GMCP handler); the wake runs alongside, as in automate(). + void this.ensureAwake(); + this.markBusyFor(data.fadein); + this.markBusyFor(data.fadeout); const targetSounds = data.key ? this.soundsByKey(data.key) : data.name @@ -425,6 +500,7 @@ export class MediaService { } stop(data: ClientMediaStopPayload): void { + this.scheduleIdleCheck(); if (data.name) { this.soundsByName(data.name).forEach((sound) => { this.stopSound(sound); @@ -490,6 +566,11 @@ export class MediaService { return; } this.shutdownComplete = true; + if (this.idleTimer !== null) { + clearTimeout(this.idleTimer); + this.idleTimer = null; + } + this.wakeHolds.clear(); if (this.manageFocus && typeof window !== 'undefined') { window.removeEventListener('focus', this.handleWindowFocus); window.removeEventListener('blur', this.handleWindowBlur); @@ -501,6 +582,60 @@ export class MediaService { this.effects.shutdown(); } + /** (Re)arm the idle countdown. Any audio activity pushes the suspend further out. */ + private scheduleIdleCheck(delay: number = IDLE_SUSPEND_MS): void { + if (this.shutdownComplete) { + return; + } + if (this.idleTimer !== null) { + clearTimeout(this.idleTimer); + } + this.idleTimer = setTimeout(() => { + this.idleTimer = null; + void this.suspendIfIdle(); + }, delay); + } + + private async suspendIfIdle(): Promise { + // AudioContext.currentTime freezes while suspended, so anything already scheduled on the + // timeline would never run. Wait out the longest pending ramp/fade before re-checking. + const busyRemaining = this.busyUntil - Date.now(); + if (busyRemaining > 0) { + this.scheduleIdleCheck(busyRemaining); + return; + } + if (this.isAudioInUse()) { + this.scheduleIdleCheck(); + return; + } + if (this.contextSuspended) { + return; + } + this.contextSuspended = true; + try { + await this.cacophony.pause(); + } catch (error) { + console.warn('Client.Media: failed to suspend the idle audio context', error); + this.contextSuspended = false; + this.scheduleIdleCheck(); + } + } + + private isAudioInUse(): boolean { + if (this.wakeHolds.size > 0 || this.currentMusic) { + return true; + } + return this.allSounds.some((sound) => sound.isPlaying); + } + + /** Extend the "scheduled automation pending" deadline. Ramp/fade durations are milliseconds. */ + private markBusyFor(durationMs?: number): void { + if (typeof durationMs !== 'number' || !Number.isFinite(durationMs) || durationMs <= 0) { + return; + } + this.busyUntil = Math.max(this.busyUntil, Date.now() + durationMs); + } + private readonly handleWindowFocus = (): void => { this.isWindowFocused = true; this.updateBackgroundMuteState(); @@ -953,6 +1088,9 @@ export class MediaService { if (!sound) { return; } + // The OS transport control expects an immediate response, so the context wake runs alongside + // the resume rather than gating it; the element keeps playing once the context is back. + void this.ensureAwake(); sound.resume(); this.mediaSession.setPlaybackState('playing'); this.updateMusicPosition(); diff --git a/src/components/audioChat.tsx b/src/components/audioChat.tsx index 05717980..c96cef31 100644 --- a/src/components/audioChat.tsx +++ b/src/components/audioChat.tsx @@ -18,6 +18,8 @@ import { useLiveKitStore } from '../stores/liveKitStore'; import { useSpatialStore } from '../stores/spatialStore'; const serverUrl = 'wss://mongoose-67t79p35.livekit.cloud'; +/** Wake-hold id keeping MediaService's idle suspend off while voice chat is live. */ +const VOICE_WAKE_HOLD = 'livekit-voice'; interface AudioChatProps { client: MudClient; @@ -69,6 +71,10 @@ const SpatialLiveKitAudio: React.FC = ({ client }) => { const bridge = bridgeRef.current; if (!bridge) return; + // Voice runs through the shared cacophony context, and MediaService cannot see LiveKit's + // streams in its sound registry, so hold the context awake for the room's lifetime. + client.media.acquireWakeHold(VOICE_WAKE_HOLD); + const syncAll = () => bridge.syncAll(); const unsubscribeSpatialStore = useSpatialStore.subscribe(syncAll); syncAll(); @@ -76,8 +82,9 @@ const SpatialLiveKitAudio: React.FC = ({ client }) => { return () => { unsubscribeSpatialStore(); bridge.cleanup(); + client.media.releaseWakeHold(VOICE_WAKE_HOLD); }; - }, []); + }, [client]); return null; };