Repository navigation
Description
Linux client seems to chew up a lot of CPU doing nothing at all, here's a slop report that might help a bit.
Attached report from opus:
Idle desktop app burns ~4% CPU and pins the audio device awake: poof-sound AudioContext is never
suspended
Summary
After the first UI "poof" click, PoofBurstProvider's AudioContext stays in the
running state for the remainder of the app's lifetime. .suspend() is never
called on it — in fact .suspend() does not appear anywhere in desktop/src.
On Linux, WebKitGTK routes WebAudio through GStreamer, so a running context means
the audio graph is pumped continuously whether or not anything is playing. Measured
on an otherwise idle app that has played no sound for ~22 minutes:
- 3.91% of one CPU core, sustained, in the WebAudio/GStreamer threads
- ~0.5% more in
pipewire-pulse, charged to that process rather than to Buzz - the ALSA device is held in
RUNNINGstate permanently, which blocks the audio
codec and CPU from reaching deep idle — the battery cost on laptops is larger
than the CPU number alone suggests
The audio work is ~71% of the idle renderer's total CPU. All of it renders silence.
Evidence
Version 0.4.26 (.deb), Ubuntu 24.04, libwebkit2gtk-4.1-0 2.52.3, PipeWire.
App open ~1h, idle, nothing playing.
Per-thread CPU in the WebKitWebProcess, sampled from /proc/<tid>/stat over a
22-minute window with no interaction:
| Thread | % of one core |
|---|---|
webaudioSrc:src |
2.53% |
queue0:src |
1.37% |
webaudioSrcTask |
(included above) |
| audio subtotal | 3.91% |
WebKitWebProces (renderer main) |
2.04% |
The corresponding PipeWire node, which persists for the app's whole lifetime:
node 75
application.process.id: <WebKitWebProcess>
application.name: buzz-desktop
media.class: Stream/Output/Audio
media.name: Playback Stream
state: running
format: F32LE 2 44100
pw-top shows this node marked +, i.e. Buzz is the driver of the audio
graph. It drags the ALSA sink out of its idle C state into R in lockstep:
S ID QUANT RATE WAIT BUSY ERR FORMAT NAME
R 51 1024 48000 155.4us 22.2us 9 S32LE 2 48000 alsa_output.…analog-stereo
R 75 1764 44100 119.0us 31.5us 0 F32LE 2 44100 + buzz-desktop
Note the rate mismatch: the context runs at 44.1 kHz into a 48 kHz sink, so
PipeWire resamples continuously as well. The sink has logged 9 xruns.
Root cause
All line numbers below are against desktop/src/shared/ui/PoofBurstProvider.tsx
at 2f004159.
PoofBurstProviderwraps the app root (desktop/src/main.tsx:80), so its
module-level singleton at line 20 lives for the whole session.- Line 60 constructs
new AudioContext({ latencyHint: "interactive" }).
WebKitGTK creates contexts in thesuspendedstate, and a suspended context
opens no device — so this alone is free. - On the first poof,
playPoofSound(line 110) seesstate === "suspended"and
callsresume()at line 126. A click is a user gesture, so it succeeds and the
context goesrunning. - Nothing ever suspends it again. From that point the device stays open forever.
This matches the observed timeline exactly: the app launched at 11:27 but the
PipeWire node appeared at 11:29 — first click, not mount.
latencyHint: "interactive" requests the smallest possible buffer, which
maximises wakeup frequency — the worst setting for something left running idle.
Secondary: the render graph grows by two nodes per poof
In playPoofSound, the AudioBufferSourceNode and GainNode are connected to
destination (lines 119–124) and never disconnected. Every click permanently adds
two nodes to the graph, so per-quantum render cost climbs monotonically over a long
session on top of the fixed cost above.
Secondary: no explicit sampleRate
Leaving sampleRate unset makes WebKitGTK default to 44.1 kHz, forcing the
continuous resample described above. desktop/public/worklet.js already documents
an assumption that contexts run at 48 kHz, so the two paths disagree.
Not a memory leak
Worth recording since it's the obvious next suspicion: the renderer sits at
~730 MB RSS / 527 MB private-dirty, but that is stable, not growing. Over the
same 22-minute window RSS ranged 692–793 MB and drifted +6 MB net — GC
sawtooth, not a leak.
The high baseline looks like frontend bundle weight rather than a runtime fault.
The shipped binary embeds 416 frontend assets including 86 Shiki language grammars
and roughly 20 Shiki themes that each appear at two different content hashes
pw-top shows this node marked +, i.e. Buzz is the driver of the audio
graph. It drags the ALSA sink out of its idle C state into R in lockstep:
S ID QUANT RATE WAIT BUSY ERR FORMAT NAME
R 51 1024 48000 155.4us 22.2us 9 S32LE 2 48000 alsa_output.…analog-stereo
R 75 1764 44100 119.0us 31.5us 0 F32LE 2 44100 + buzz-desktop
Note the rate mismatch: the context runs at 44.1 kHz into a 48 kHz sink, so
PipeWire resamples continuously as well. The sink has logged 9 xruns.
Root cause
All line numbers below are against desktop/src/shared/ui/PoofBurstProvider.tsx
at 2f004159.
PoofBurstProviderwraps the app root (desktop/src/main.tsx:80), so its
module-level singleton at line 20 lives for the whole session.- Line 60 constructs
new AudioContext({ latencyHint: "interactive" }).
WebKitGTK creates contexts in thesuspendedstate, and a suspended context
opens no device — so this alone is free. - On the first poof,
playPoofSound(line 110) seesstate === "suspended"and
callsresume()at line 126. A click is a user gesture, so it succeeds and the
context goesrunning. - Nothing ever suspends it again. From that point the device stays open forever.
This matches the observed timeline exactly: the app launched at 11:27 but the
PipeWire node appeared at 11:29 — first click, not mount.
latencyHint: "interactive" requests the smallest possible buffer, which
maximises wakeup frequency — the worst setting for something left running idle.
Secondary: the render graph grows by two nodes per poof
In playPoofSound, the AudioBufferSourceNode and GainNode are connected to
destination (lines 119–124) and never disconnected. Every click permanently adds
two nodes to the graph, so per-quantum render cost climbs monotonically over a long
session on top of the fixed cost above.
Secondary: no explicit sampleRate
Leaving sampleRate unset makes WebKitGTK default to 44.1 kHz, forcing the
continuous resample described above. desktop/public/worklet.js already documents
an assumption that contexts run at 48 kHz, so the two paths disagree.
Not a memory leak
Worth recording since it's the obvious next suspicion: the renderer sits at
~730 MB RSS / 527 MB private-dirty, but that is stable, not growing. Over the
same 22-minute window RSS ranged 692–793 MB and drifted +6 MB net — GC
sawtooth, not a leak.
The high baseline looks like frontend bundle weight rather than a runtime fault.
The shipped binary embeds 416 frontend assets including 86 Shiki language grammars
and roughly 20 Shiki themes that each appear at two different content hashes
(vitesse-light, tokyo-night, rose-pine, solarized-*, …), which suggests two
import paths causing Vite to emit duplicate copies. That is inferred from the asset
manifest and has not been confirmed against the source — a separate issue if
someone wants to chase the memory baseline.
Suggested fix
Keep the singleton context (the comment in HuddleContext.tsx about the ~6
concurrent-context limit is a good reason not to create one per click), but stop
leaving it running:
- Suspend when idle. After playback ends,
suspend()the context on a short
debounce (~1.5s) so rapid clicks don't thrash it, andresume()on the next
poof.playPoofSoundalready handles the resume-from-suspended case, so this
closes the loop the existing code anticipates. - Disconnect finished nodes in an
endedhandler so the graph stays flat. - Set
sampleRate: 48000explicitly, matchingworklet.js, to drop the
resample. - Decode via
OfflineAudioContext.decodeAudioDataonly needs a
BaseAudioContext; an offline one never opens a device, so the startup preload
can't touch audio hardware at all.
An alternative worth considering: the Rust side already links rodio + cpal, so
routing UI chimes through a Tauri command would remove this WebAudio path
entirely. Larger change, cleaner end state.
Reproduce
# 1. Open Buzz. Confirm no audio stream exists yet:
pw-dump | jq -r '.[] | select(.info.props."media.class" == "Stream/Output/Audio")
| "\(.info.props."application.name") \(.info.state)"'
# 2. Click anything that poofs — e.g. hover a sidebar card and click its × dismiss
# button (the trigger lives on sidebar-action-card.tsx and SelectedRecipientChip).
# 3. Re-run the command above. A `buzz-desktop … running` stream now exists, and
# stays for as long as the app runs. Also visible in `pw-top` as the graph driver.
# 4. Watch the cost with no further interaction:
top -H -p "$(pgrep -f 'WebKitWebProcess' | head -1)"
# → webaudioSrc:src, webaudioSrcTask and queue0:src accumulate CPU indefinitelyNotes
- Evidence is Linux/WebKitGTK-specific, where WebAudio goes through GStreamer.
The underlying defect — never callingsuspend()— is platform-independent, but
the cost profile on macOS/CoreAudio will differ and is unmeasured. - The other three
AudioContextsites are correctly scoped and are not
implicated:huddle/lib/audioWorklet.tsrequests 48 kHz and closes at line 130,
and bothHuddleContext.tsxcontexts are gated onmicConnectedand closed in
effect cleanup. - Observed on
main@2f004159.
Activity
yinkev commentedon Jul 25, 2026
Taking this. I’ll keep the singleton AudioContext, disconnect each source/gain graph after playback, debounce suspension after the final sound, and cancel that suspension when another poof starts. I’ll add focused regression tests for cleanup and rapid replay. Sample-rate policy, offline decoding, and a native-audio rewrite stay out of scope.
FabianHertwig commentedon Jul 26, 2026
Same root cause reproduces on macOS, with one difference that I think matters for the fix: no poof is required — the device is pinned from app launch.
Environment
macOS 26.5.2 (25F84), Apple silicon, Buzz 0.4.26 (
xyz.block.buzz.app), built-in speakers. Line references below are againstdd222a509.Symptom
The laptop stopped idle-sleeping entirely and ran the battery flat overnight. From
pmset -g log:2026-07-25 21:14:56— wake (lid open), battery 38%21:33:18— display offSleepentry at all for the next 12.5 hours2026-07-26 09:45:02—Entering Sleep state due to 'Low Power Sleep':TCPKeepAlive=inactive Using Batt (Charge:1%)A
PreventUserIdleSystemSleepassertion spanned that entire window and was only released by the forced low-power sleep:Nothing else in that window held a prevent-sleep assertion for more than ~7 minutes. (A second long holder was Apple's Siri speaker tap in
corespeechd— unrelated to Buzz.)coreaudiodrecords the requesting process, and it is Buzz's WebKit GPU child:…where PID 9865 was
com.apple.WebKit.GPU, the renderer ofbuzz-desktop[9864].The macOS difference
The issue body's step 2 says a suspended context opens no device, so construction alone is free, and the leak begins at the first poof (step 3). On macOS the device gets pinned without any poof at all. A fresh launch, from the unified log:
RemoteAudioDestinationis WebKit's Web Audio destination, so the assertion is attributable to theAudioContextand not to a media element — the<audio>preload from lines 151–153 only shows up 35 ms after the device was already open.RemoteAudioDestination::start()firing on its own, ~1.5 s after launch with no interaction, is the context goingrunningby itself.Isolated repro
I reduced it to a minimal
WKWebViewhost that constructs the context exactly likegetPoofAudioContext()(line 60) and plays nothing:WKWebViewConfigurationstateat constructionstateafter 2 s, nothing playedmediaTypesRequiringUserActionForPlayback = .all(WebKit default)suspendedsuspendedmediaTypesRequiringUserActionForPlayback = [](autoplay allowed)suspendedrunningSo WebKit does hand back a suspended context, but when the embedder allows autoplay it auto-resumes on its own — no user gesture, nothing playing — and a running context pins
BuiltInSpeakerDevice. Buzz doesn't set that flag itself (nothing underdesktop/src-tauri/srctouches it), so it takes the webview default, and the launch trace above shows it behaving like the second row.Why this matters for the fix
PoofBurstProvider's mount effect callsloadPoofAudioBuffer()(line 149) purely to decode the sound, which constructs the hardwareAudioContext(line 60) on every launch. On macOS that alone holds the machine awake for the whole session — a session that never fires a single poof still pins the device, so a fix keyed on playback ending won't reach it. I've left the specifics on #2907.eeSeeGee commentedon Aug 12, 2026
🤖 Third platform confirmation, on macOS 26.6.1 / arm64 / Buzz 0.5.10 — and a new consequence neither prior report covers: when the default output is a Bluetooth headset, the pinned device is the headset, so A2DP never idles.
Reported by @eeSeeGee, who noticed it as "Buzz seems pretty aggressive in maintaining media streaming" and independently established that quitting Buzz is the only thing that releases it. Everything below is from his machine.
The pinned device is the headset
pmset -g assertions, ten minutes after launch with nothing playing and no interaction:The assertion name carries the BT MAC, and
Created for PID: 95501attributes it directly. PID 95501 is Buzz's own WebKit GPU child:Pinned 133 ms after launch, before anything plays
This corroborates @FabianHertwig's key correction — no poof is required. Unified log, same second as launch:
The assertion lands 7 ms after the Web Audio destination is constructed and 126 ms before it even
start()s.RemoteAudioDestinationis WebKit's Web Audio destination, so this is attributable to theAudioContext, not to the<audio>element — same conclusion Fabian reached from the 35 ms ordering gap.Worth noting the original reports were against 0.4.26. This is 0.5.10, so nothing has incidentally fixed it in the interim.
A sharper way to state the root cause
There are four
AudioContextconstruction sites indesktop/srconmain:features/huddle/HuddleContext.tsx:790ctx.close()at 852features/huddle/lib/audioWorklet.ts:56audioContext.close()at 131features/huddle/lib/useHuddlePttState.ts:41close()at 63shared/ui/PoofBurstProvider.tsx:60suspend()norclose(), everThe three huddle contexts are created on demand and torn down.
PoofBurstProvider's is the only one created unconditionally at app mount (vialoadPoofAudioBuffer()in the mountuseEffect) and the only one with no teardown at all.latencyHint: "interactive"also requests the smallest buffer, so the device is held at low latency — which is what the user perceived as "aggressive."(Enumeration is from GitHub code search over
main, so I'd treat "exactly four" as "the four I can find" rather than proven exhaustive. ThePoofBurstProviderfinding itself is direct from the file.)Fix coverage — the two open PRs are complementary, not competing
This user hit both symptoms, and they turn out to need both PRs:
AudioContext, never suspendedlocalhostin Now PlayingHTMLAudioElementauto-adopted by the Media SessionarmIdleSuspend()is called at construction with an explicitstatechangelistener commented "WebKit may start an autoplay-enabled context without any playback" — which is exactly the pinned-from-launch path above, so it does cover this issue on macOS. It keeps theHTMLAudioElementfallback, so it does not address the media-key symptom.HTMLAudioElementpath (notifications plus the poof preload), but itsPoofBurstProviderstill callsloadPoofAudioBuffer()on mount, so the context is still created at launch and never suspended. It does not fix this issue.So neither PR alone resolves the user-visible complaint. Both are currently open with zero reviews (#2907 for 18 days, #4916 for 7).
Merge hazard: #2907 and #4916 both edit
PoofBurstProvider.tsxand disagree in intent — #2907 preserves the HTML fallback that #4916 deletes. Whoever lands second needs to reconcile behavior, not just resolve a text conflict. Separately, #5222 duplicates the notification half of #4916 (independent report, keeps an HTML fallback); one of those two probably wants closing.Not verified here
I did not drive the hardware media key or read Now Playing state on this machine, and I did not build any of the three PR branches. The media-key half is from source inspection on
mainplus the existing reports; the device-pinning half is the live capture above.