Skip to content

fix(sound): use Web Audio API to fix PipeWire node accumulation and volume resets#1548

Open
aspiers wants to merge 1 commit intoEpicenterHQ:mainfrom
aspiers:fix/web-audio-api-pipewire-volume
Open

fix(sound): use Web Audio API to fix PipeWire node accumulation and volume resets#1548
aspiers wants to merge 1 commit intoEpicenterHQ:mainfrom
aspiers:fix/web-audio-api-pipewire-volume

Conversation

@aspiers
Copy link
Copy Markdown

@aspiers aspiers commented Mar 21, 2026

Summary

Replaces HTMLAudioElement with the Web Audio API (AudioContext + BufferSourceNode) for sound playback. This fixes both the PipeWire node accumulation from #842 and a volume reset issue on Linux where WirePlumber restores full volume on every sound play.

Supersedes #1374.

Problem

HTMLAudioElement has two issues on Linux with PipeWire/WirePlumber:

  1. Node accumulation (Ubuntu 25.04: lots of "whispering" entries in System -> Sounds -> Volume Levels #842): Each Audio element registers a PipeWire output node that persists as long as the element is reachable. The original code cached 8 elements at module load, and HMR reloads orphaned old elements without releasing their nodes.

  2. Volume resets: fix(sound): release Audio elements after playback to prevent PipeWire node leaks #1374 fixed accumulation by creating a fresh element per playback and tearing it down after ended. However, WebKit tears down and recreates the underlying PipeWire media pipeline on every new Audio() or src change, so WirePlumber sees each playback as a brand-new stream and restores it to its database volume (1.0 / full) before the user's adjustment can be saved back.

HTMLAudioElement approaches — both broken:

Cached elements:   Module load → new Audio() × 8 → PipeWire nodes pile up on HMR
Per-play elements: playSound() → new Audio() → WirePlumber restores 1.0 → volume resets

Solution

A single AudioContext is created lazily on first use and kept alive for the app's lifetime. This registers exactly one PipeWire node that persists across all sound playback. Audio files are decoded once into AudioBuffers (cached in memory), and each playSound() call creates a lightweight BufferSourceNode — no new PipeWire nodes.

AudioContext approach:

First playSound() → new AudioContext() → 1 PipeWire node (persists forever)
                  → fetch + decodeAudioData → cached AudioBuffer

Subsequent calls  → new BufferSourceNode (no PipeWire overhead)
                  → connect to destination → start()
                  → auto-cleaned up after playback
  • No node accumulation — one stable node for the app's lifetime
  • WirePlumber can reliably track and persist volume adjustments
  • Decoded buffers are cached, so repeat plays are efficient
  • No ended event race conditions that could hang the playback promise

Fixes #842

Replace HTMLAudioElement with a shared AudioContext for sound playback.
HTMLAudioElement creates a new PipeWire node per element, and WebKit
tears down/recreates the node on any src change, causing WirePlumber to
restore full volume (1.0) before user adjustments can be saved back.

A single AudioContext maintains one stable PipeWire node for the app's
lifetime, allowing WirePlumber to reliably persist volume changes.
Decoded audio buffers are cached in memory for efficient replay.
@aspiers
Copy link
Copy Markdown
Author

aspiers commented Mar 21, 2026

CI failure is pre-existing on main — not caused by this PR. The lint errors are in unrelated files (apps/api/, apps/fuji/, apps/honeycrisp/, packages/workspace/) and include a biome schema version mismatch (2.4.6 vs 2.4.8). All tests pass locally (557 pass, 0 fail).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ubuntu 25.04: lots of "whispering" entries in System -> Sounds -> Volume Levels

1 participant