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
RUNNING state 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.
PoofBurstProvider wraps 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 the suspended state, and a suspended context
opens no device — so this alone is free.
- On the first poof,
playPoofSound (line 110) sees state === "suspended" and
calls resume() at line 126. A click is a user gesture, so it succeeds and the
context goes running.
- 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.
PoofBurstProvider wraps 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 the suspended state, and a suspended context
opens no device — so this alone is free.
- On the first poof,
playPoofSound (line 110) sees state === "suspended" and
calls resume() at line 126. A click is a user gesture, so it succeeds and the
context goes running.
- 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, and resume() on the next
poof. playPoofSound already handles the resume-from-suspended case, so this
closes the loop the existing code anticipates.
- Disconnect finished nodes in an
ended handler so the graph stays flat.
- Set
sampleRate: 48000 explicitly, matching worklet.js, to drop the
resample.
- Decode via
OfflineAudioContext. decodeAudioData only 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 indefinitely
Notes
- Evidence is Linux/WebKitGTK-specific, where WebAudio goes through GStreamer.
The underlying defect — never calling suspend() — is platform-independent, but
the cost profile on macOS/CoreAudio will differ and is unmeasured.
- The other three
AudioContext sites are correctly scoped and are not
implicated: huddle/lib/audioWorklet.ts requests 48 kHz and closes at line 130,
and both HuddleContext.tsx contexts are gated on micConnected and closed in
effect cleanup.
- Observed on
main @ 2f004159.
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
AudioContextis neversuspended
Summary
After the first UI "poof" click,
PoofBurstProvider'sAudioContextstays in therunningstate for the remainder of the app's lifetime..suspend()is nevercalled on it — in fact
.suspend()does not appear anywhere indesktop/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:
pipewire-pulse, charged to that process rather than to BuzzRUNNINGstate permanently, which blocks the audiocodec 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-02.52.3, PipeWire.App open ~1h, idle, nothing playing.
Per-thread CPU in the
WebKitWebProcess, sampled from/proc/<tid>/statover a22-minute window with no interaction:
webaudioSrc:srcqueue0:srcwebaudioSrcTaskWebKitWebProces(renderer main)The corresponding PipeWire node, which persists for the app's whole lifetime:
pw-topshows this node marked+, i.e. Buzz is the driver of the audiograph. It drags the ALSA sink out of its idle
Cstate intoRin lockstep: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.tsxat
2f004159.PoofBurstProviderwraps the app root (desktop/src/main.tsx:80), so itsmodule-level singleton at line 20 lives for the whole session.
new AudioContext({ latencyHint: "interactive" }).WebKitGTK creates contexts in the
suspendedstate, and a suspended contextopens no device — so this alone is free.
playPoofSound(line 110) seesstate === "suspended"andcalls
resume()at line 126. A click is a user gesture, so it succeeds and thecontext goes
running.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, whichmaximises wakeup frequency — the worst setting for something left running idle.
Secondary: the render graph grows by two nodes per poof
In
playPoofSound, theAudioBufferSourceNodeandGainNodeare connected todestination(lines 119–124) and never disconnected. Every click permanently addstwo 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
sampleRateLeaving
sampleRateunset makes WebKitGTK default to 44.1 kHz, forcing thecontinuous resample described above.
desktop/public/worklet.jsalready documentsan 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-topshows this node marked+, i.e. Buzz is the driver of the audiograph. It drags the ALSA sink out of its idle
Cstate intoRin lockstep: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.tsxat
2f004159.PoofBurstProviderwraps the app root (desktop/src/main.tsx:80), so itsmodule-level singleton at line 20 lives for the whole session.
new AudioContext({ latencyHint: "interactive" }).WebKitGTK creates contexts in the
suspendedstate, and a suspended contextopens no device — so this alone is free.
playPoofSound(line 110) seesstate === "suspended"andcalls
resume()at line 126. A click is a user gesture, so it succeeds and thecontext goes
running.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, whichmaximises wakeup frequency — the worst setting for something left running idle.
Secondary: the render graph grows by two nodes per poof
In
playPoofSound, theAudioBufferSourceNodeandGainNodeare connected todestination(lines 119–124) and never disconnected. Every click permanently addstwo 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
sampleRateLeaving
sampleRateunset makes WebKitGTK default to 44.1 kHz, forcing thecontinuous resample described above.
desktop/public/worklet.jsalready documentsan 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 twoimport 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.tsxabout the ~6concurrent-context limit is a good reason not to create one per click), but stop
leaving it running:
suspend()the context on a shortdebounce (~1.5s) so rapid clicks don't thrash it, and
resume()on the nextpoof.
playPoofSoundalready handles the resume-from-suspended case, so thiscloses the loop the existing code anticipates.
endedhandler so the graph stays flat.sampleRate: 48000explicitly, matchingworklet.js, to drop theresample.
OfflineAudioContext.decodeAudioDataonly needs aBaseAudioContext; an offline one never opens a device, so the startup preloadcan't touch audio hardware at all.
An alternative worth considering: the Rust side already links
rodio+cpal, sorouting UI chimes through a Tauri command would remove this WebAudio path
entirely. Larger change, cleaner end state.
Reproduce
Notes
The underlying defect — never calling
suspend()— is platform-independent, butthe cost profile on macOS/CoreAudio will differ and is unmeasured.
AudioContextsites are correctly scoped and are notimplicated:
huddle/lib/audioWorklet.tsrequests 48 kHz and closes at line 130,and both
HuddleContext.tsxcontexts are gated onmicConnectedand closed ineffect cleanup.
main@2f004159.