Skip to content

Audio: suspend idle AudioContext to release 1 ms platform timer and OS audio stream #99

Description

@daiverd

Problem

The AudioContext is created at MudClient construction and never suspended — not once in the entire session, even when nothing has played for days.

src/client.ts:94 calls new MediaService(), whose default argument is new Cacophony(). Cacophony's constructor immediately does new AudioContext(), creates a master gain, and connects it to context.destination. Cacophony then installs autoUnlock listeners on click/keydown/touchend, so the first keystroke resumes the context. From that point it runs at 48 kHz forever.

There is no .suspend() call anywhere in src/. The only lifecycle calls are resume() (src/audio/LiveKitSpatialAudioBridge.ts:81, plus cacophony's own auto-unlock).

Why it matters

The direct CPU cost is small — measured at roughly 2% of a core on the WebAudio render thread (identifiable externally as the renderer's TimeCritical thread). That is not the interesting part.

A running AudioContext holds:

  1. A 1 ms platform timer request, which is a system-wide effect on Windows — it raises the global interrupt rate and blocks deeper CPU idle states for the whole machine, not just this tab. powercfg /energy flags this explicitly:

    Platform Timer Resolution: Outstanding Timer Request
    Requested Period : 10000  (100-ns units = 1 ms)
    Requesting PID   : <chrome renderer>
    
  2. An open Windows audio stream, keeping audiodg.exe active continuously.

So the win here is battery, thermals, and machine-wide idle behavior, well beyond the 2%.

Evidence

Observed on a tab left open ~3 days:

Measure Value
Renderer lifetime CPU average 17.8% of a core
WebAudio render thread (lifetime) ~2% of a core, 477 s total
Fresh tab, visible, logged in, idle 2.6% of a core

The audio thread was confirmed present and time-critical while the client sat idle with nothing playing.

Proposed fix — idle suspend with programmatic wake

Keep eager construction at load time. Deliberately not doing lazy initialization: if the context were created lazily on first use, a server-sent sound arriving before the user has clicked or typed would find a suspended context and produce no audio. Creating it at load gives the auto-unlock path the longest possible window to have the context warm and running before the first Client.Media message arrives.

Instead, suspend only after a long idle period, and wake programmatically:

  • Track "audio in use" across: entries in MediaService.sounds that are playing, currentMusic, pending automate() scheduled values, LiveKit / microphone streams, and MIDI synth output.
  • Suspend via cacophony.suspend() once everything has been quiet for the idle threshold.
  • Wake with await ensureAwake() at the top of every inbound audio path: load(), play(), setChain(), voice chat start, MIDI start.

Programmatic wake works without a user gesture. Chrome's autoplay policy gates the first unblocking of a context, not subsequent resumes — once the document has sticky user activation (any click or keypress, which a MUD client guarantees at login), resume() succeeds programmatically for the life of the document.

Idle threshold: 5 minutes, not 30 seconds

The always-on cost accrues overnight and while AFK, not during active play. At a 5-minute threshold the user is essentially never idle that long mid-session, so a server-sent sound effect almost never hits a cold context — while still capturing nearly all of the savings.

Risks and constraints

  1. Wake latency; Bluetooth is the hazard. Resuming re-opens the OS audio device: ~20–150 ms for wired/internal output, but 1–3 seconds for a Bluetooth headset re-establishing its link. A short sound arriving right after wake could be late or clipped. Mitigated by the long threshold; optionally detect a Bluetooth output device and extend or disable the timer there.
  2. currentTime freezes while suspended — queued automation does not advance. Anything with pending scheduled values must count as "in use" and block sleep.
  3. Wake races. Concurrent GMCP messages during a resume must await a single memoized resume promise, so the context is never played into while still suspended and never double-resumed.
  4. Never suspend during voice chat or mic capture.
  5. Use cacophony's wrappers (cacophony.suspend() / .resume()), not raw context.suspend(), or its internal suspendState desyncs.

Acceptance criteria

  • After 5 minutes with no sound, music, automation, voice chat, or MIDI activity, the context reports state === 'suspended'.
  • A server-sent Client.Media play message while suspended produces audible sound with no user gesture.
  • While suspended, the renderer no longer holds a 1 ms platform timer request (verify with powercfg /energy) and audiodg.exe drops to idle.
  • No suspend occurs while any sound, music, pending automation, voice chat, or MIDI output is active.
  • Concurrent play requests during wake resolve through one shared resume promise.

Notes

Found while investigating sustained high CPU in a long-lived client tab. This is a real and confirmed defect, but note it is not the main CPU consumer in that investigation — the dominant cost was ~14% of a core on a main-thread (non-audio) thread, which remains unexplained and is tracked separately.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions