Skip to content

Desktop: ask GStreamer for pipewiresink, or Linux plays nothing on Bluetooth - #3

Merged
mcinnisdev merged 1 commit into
mainfrom
desktop-linux-audio-sink
Sep 14, 2026
Merged

mcinnisdev merged 1 commit into
mainfrom
desktop-linux-audio-sink

Conversation

@mcinnisdev

Copy link
Copy Markdown
Owner

The Linux desktop app was completely silent on a machine where chopdeck.com played fine.

Cause

WebKitGTK has no audio stack of its own. Web Audio leaves through GStreamer, and WebKit asks autoaudiosink to choose the output element. autoaudiosink prefers pulsesink, and pulsesink talking to PipeWire's PulseAudio compatibility layer is silent on a Bluetooth sink.

Silent in the worst possible way: the stream opens, unmuted, at 100%, attached to the right device, the AudioContext reports running and currentTime advances. Nothing looks wrong from inside the page and nothing is logged. Measured by recording the sink's own monitor — same page, same packages, same Bluetooth device:

sink element peak
autoaudiosink → pulsesink 0.0000
pipewiresink ranked MAX 0.3349

On a wired output pulsesink happened to work (peak 0.3119), which is what made this look intermittent rather than reproducible.

Browsers never use GStreamer, which is why the same build plays in Chromium and not in the desktop window — worth remembering before blaming the machine.

Fix

src-tauri/src/lib.rs sets GST_PLUGIN_FEATURE_RANK=pipewiresink:MAX before the webview starts. The sink then follows whatever the desktop's default output is, rather than binding to one card.

  • Set before tauri::Builder, because GStreamer reads the variable while building its registry.
  • GStreamer runs in the child WebKitWebProcess, which inherits the value from us.
  • An existing GST_PLUGIN_FEATURE_RANK is appended to, not replaced, and left untouched if it already mentions pipewiresink.
  • Linux only (#[cfg(target_os = "linux")]).

Testing

Built the .deb, then ran the binary with GST_PLUGIN_FEATURE_RANK explicitly unset in the launch environment, after killing every stale WebKit process:

stale webkit processes before launch: 0
parent pid: 604276
  pid 604364 (started after parent? yes) -> GST_PLUGIN_FEATURE_RANK=pipewiresink:MAX

Note that /proc/<pid>/environ of the parent is useless here: it snapshots the environment at execve() and never reflects a runtime setenv(). The child web process is the authoritative check.

Also ruled out by measurement rather than assumption: the two engines are not the difference. Rendering a synthesised kick through Chop Deck's exact master chain (gain → DynamicsCompressor threshold −3 / knee 6 / ratio 12 → destination) in an OfflineAudioContext gives identical output in Chromium and WebKitGTK — dry peak 0.72000 rms 0.19554, limiter peak 0.43997 rms 0.15852, matching to five decimal places.

Docs

docs/DESKTOP.md gains the Bluetooth trap with the measurements and how to override it, plus one more time-waster: WebKit parks the AudioContext in a non-standard interrupted state whenever the window lacks focus, freezing currentTime. An unfocused window is silent by design.

🤖 Generated with Claude Code

https://claude.ai/code/session_019U7kCMZc9a7sezDwKKseJy

…uetooth

WebKitGTK has no audio stack of its own. Web Audio leaves through GStreamer,
and WebKit asks `autoaudiosink` to choose the output element. autoaudiosink
prefers `pulsesink`, and pulsesink talking to PipeWire's PulseAudio
compatibility layer is silent on a Bluetooth sink.

Silent in the worst way, too: the stream opens, unmuted, at full volume, on
the right device, the AudioContext reports `running` and `currentTime`
advances. Nothing about it looks wrong from inside the page, and nothing is
logged. Measured on the same page with the same packages, same device:

    autoaudiosink -> pulsesink      peak 0.0000
    pipewiresink ranked MAX         peak 0.3349

On a wired output pulsesink happened to work, which is what made this look
intermittent rather than reproducible. Ranking the native pipewiresink top
fixes Bluetooth and costs nothing elsewhere, because the sink then follows
whatever the desktop's default output is.

Set before the webview starts, since GStreamer reads the variable when it
builds its registry, and the web process inherits it from us. An existing
GST_PLUGIN_FEATURE_RANK is appended to rather than replaced, and left alone
entirely if it already mentions pipewiresink.

Browsers never use GStreamer, which is why chopdeck.com plays fine on a
machine where the desktop app is mute. DESKTOP.md now says so, along with the
other thing that wastes an afternoon: WebKit parks the AudioContext in a
non-standard `interrupted` state whenever the window lacks focus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019U7kCMZc9a7sezDwKKseJy
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying chopdeck with  Cloudflare Pages  Cloudflare Pages

Latest commit: 1985ba7
Status: ✅  Deploy successful!
Preview URL: https://4500b33b.chopdeck.pages.dev
Branch Preview URL: https://desktop-linux-audio-sink.chopdeck.pages.dev

View logs

@mcinnisdev
mcinnisdev merged commit b4d64a0 into main Sep 14, 2026
3 checks passed
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.

1 participant