feat(audio): route locked output to the selected sink via PIPEWIRE_NODE (#263)

Completes the "Lock output device" (skip_sink_switch) fix. The lock already
skips set-default-sink + (Tier 1) the global clock force, but targeting was
implemented AS set-default-sink — so with the lock on QBZ's stream followed the
current default, and if the selected DAC was not the default, locked audio went
to the wrong device.

In locked mode, set PIPEWIRE_NODE=<selected sink> before the cpal PCM open so the
pipewire-ALSA plugin routes this stream to the chosen node WITHOUT changing the
system default. A Drop guard restores the prior env value after the open.

Delivers the reporter's full comment-3 ask: output to the selected sink AND never
change the default/priority AND never force the graph clock. Verified on hardware:
lock on + DAC selected + system default = onboard -> QBZ plays on the Cambridge DAC
(alsa_playback.qbz-slint linked to the DAC sink) while the default stays onboard and
another app keeps using it. Bit-perfect is traded for routing freedom in locked mode
(the graph clock is not forced), as designed.

cc
This commit is contained in:
vicrodh committed 2026-05-31 11:31:33 -06:00
1 parent 64fb77ade1
commit d2c4aaf99c
1 file changed
+36
+36
View File
@@ -23,6 +23,22 @@ use std::sync::atomic::{AtomicBool, Ordering};
/// The force and the reset both run on the audio thread, so `Relaxed` is enough.
static CLOCK_FORCE_APPLIED: AtomicBool = AtomicBool::new(false);
/// Restores the `PIPEWIRE_NODE` env var to its previous value when dropped, so
/// locked-mode sink targeting (Tier 2a, #263) does not leak into later stream
/// opens. Edition 2021: `set_var`/`remove_var` are safe.
#[cfg(target_os = "linux")]
struct PwNodeEnvGuard(Option<String>);
#[cfg(target_os = "linux")]
impl Drop for PwNodeEnvGuard {
fn drop(&mut self) {
match self.0.take() {
Some(v) => std::env::set_var("PIPEWIRE_NODE", v),
None => std::env::remove_var("PIPEWIRE_NODE"),
}
}
}
pub struct PipeWireBackend {
#[allow(dead_code)]
host: rodio::cpal::Host,
@@ -589,6 +605,26 @@ impl AudioBackend for PipeWireBackend {
};
log::info!("[PipeWire Backend] Buffer size: {:?}", cpal_buffer_size);
// Tier 2a (#263): in locked mode (skip_sink_switch) QBZ does NOT steal the
// system default sink, so route THIS stream to the selected sink via the
// pipewire-ALSA plugin's PIPEWIRE_NODE env — it targets that node WITHOUT
// changing the system default. The guard restores the prior env value
// after the PCM open (kept alive until the end of this function).
#[cfg(target_os = "linux")]
let _pw_node_guard = if config.skip_sink_switch {
target_sink.as_ref().map(|sink| {
let prev = std::env::var("PIPEWIRE_NODE").ok();
std::env::set_var("PIPEWIRE_NODE", sink);
log::info!(
"[PipeWire Backend] Targeting sink '{}' via PIPEWIRE_NODE (locked mode, default unchanged)",
sink
);
PwNodeEnvGuard(prev)
})
} else {
None
};
// Create MixerDeviceSink with custom config
let mixer_sink = DeviceSinkBuilder::from_device(device)
.map_err(|e| format!("Failed to create device sink builder: {}", e))?