From 1985ba771ec0fe44c21a4bb1670e970523731d68 Mon Sep 17 00:00:00 2001 From: Nick McInnis Date: Mon, 14 Sep 2026 15:49:49 -0400 Subject: [PATCH] Desktop: ask GStreamer for pipewiresink, or Linux plays nothing on Bluetooth 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) Claude-Session: https://claude.ai/code/session_019U7kCMZc9a7sezDwKKseJy --- docs/DESKTOP.md | 21 +++++++++++++++++++++ src-tauri/src/lib.rs | 40 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/docs/DESKTOP.md b/docs/DESKTOP.md index 02a3b78..1b30fc6 100644 --- a/docs/DESKTOP.md +++ b/docs/DESKTOP.md @@ -61,6 +61,27 @@ sudo apt install gstreamer1.0-plugins-good gstreamer1.0-libav # Debian Check with `gst-inspect-1.0 autoaudiosink`. If that says "No such element", the app will be silent. +**And `pulsesink` is silent on Bluetooth.** Having the plugins is not the end of it. WebKit asks +`autoaudiosink` to choose the output element; autoaudiosink prefers `pulsesink`; and pulsesink +talking to PipeWire's PulseAudio compatibility layer plays nothing at all on a Bluetooth sink. The +failure is invisible from inside the page — the stream opens, unmuted, at full volume, the +AudioContext says `running`, `currentTime` advances, and the samples go nowhere. Measured on the +same page with the same packages, same Bluetooth device: + +| sink element | peak | +|---|---| +| `autoaudiosink` → `pulsesink` | 0.0000 | +| `pipewiresink` ranked MAX | 0.3349 | + +So `src-tauri/src/lib.rs` sets `GST_PLUGIN_FEATURE_RANK=pipewiresink:MAX` before the webview +starts, which makes the sink follow the desktop's default output. Set that variable yourself to +override it. Browsers never use GStreamer, which is why chopdeck.com plays on a machine where the +desktop app is mute — a difference worth remembering before blaming the machine. + +One more, not a bug: WebKit reports the AudioContext state as `interrupted` (not a standard state) +whenever the window does not have focus, and freezes `currentTime`. An unfocused window is silent +by design; it resumes on focus. + The builds are not code-signed yet. macOS will refuse to open the app until you right-click it and choose Open once; Windows SmartScreen asks for confirmation. Signing (an Apple Developer ID and a Windows certificate) is the step to take before listing on itch.io or Steam, and it is only secrets diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bff60cb..db07cc3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,8 +1,46 @@ // Chop Deck desktop. Everything the machine does lives in the web build under ../dist; this shell only // adds what a browser tab cannot: a native save dialog for SAVE mode, opening links in the system -// browser, and a second window for the owner's manual. +// browser, a second window for the owner's manual, and on Linux one nudge to GStreamer so the machine +// is audible. + +/// 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 Bluetooth outputs** -- the +/// stream opens, unmuted, at full volume, the AudioContext reports "running", and not one sample +/// arrives. Measured on one machine, same page, same packages, same Bluetooth sink: +/// +/// | sink element | peak | +/// |---------------------------|--------| +/// | autoaudiosink -> pulsesink| 0.0000 | +/// | pipewiresink ranked MAX | 0.3349 | +/// +/// Ranking the native `pipewiresink` top fixes it and costs nothing on wired outputs, where +/// pulsesink happened to work anyway. Browsers are unaffected because they never use GStreamer, +/// which is why chopdeck.com plays on a machine where this app did not. +/// +/// An existing `GST_PLUGIN_FEATURE_RANK` is respected: we append rather than replace, and do +/// nothing at all if the user has already said something about pipewiresink. This must run before +/// the webview starts, because GStreamer reads the variable when it builds its registry. +#[cfg(target_os = "linux")] +fn prefer_pipewire_audio_sink() { + const KEY: &str = "GST_PLUGIN_FEATURE_RANK"; + let existing = std::env::var(KEY).unwrap_or_default(); + if existing.contains("pipewiresink") { + return; + } + let next = if existing.is_empty() { + "pipewiresink:MAX".to_owned() + } else { + format!("{existing},pipewiresink:MAX") + }; + std::env::set_var(KEY, next); +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + #[cfg(target_os = "linux")] + prefer_pipewire_audio_sink(); + tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init())