fix(audio): gate global clock.force-rate by skip_sink_switch (#263 Tier 1)

When "Lock output device" (skip_sink_switch) is on, also skip the global
pw-metadata clock.force-rate writes (the pre-stream force and the hi-res
re-apply), not just set-default-sink. This stops QBZ from yanking the whole
PipeWire graph clock on every track-rate change, which broke external routing
(qjackctl/qpwgraph/Reaper) for users who enabled the lock — issue #263 comment-3.

The bit-perfect path is unchanged: with the lock off (including DAC passthrough)
the clock is still forced to the track's native rate. skip_sink_switch is
transitively mutually exclusive with dac_passthrough/pw_force_bitperfect, so this
never collides with a forced-rate bit-perfect session.

Verified on hardware (Cambridge USB DAC): lock on + 44.1k->192k leaves
clock.force-rate untouched; lock off + passthrough forces 44100/192000 with the
DAC running at the native rate.

cc
This commit is contained in:
vicrodh committed 2026-05-30 23:38:48 -06:00
1 parent 171d05f432
commit d19312fe17
1 file changed
+48 -28
+48 -28
View File
@@ -389,34 +389,50 @@ impl AudioBackend for PipeWireBackend {
config.sample_rate
};
// Force PipeWire to use the effective sample rate (for bit-perfect playback)
log::info!(
"[PipeWire Backend] Forcing sample rate to {}Hz via pw-metadata",
effective_rate
);
let metadata_result = Command::new("pw-metadata")
.args([
"-n",
"settings",
"0",
"clock.force-rate",
&effective_rate.to_string(),
])
.output();
// Force PipeWire to use the effective sample rate (for bit-perfect playback).
//
// Tier 1 (issue #263): when skip_sink_switch ("Lock output device") is ON, the
// user has asked QBZ not to mutate GLOBAL graph state to preserve external
// routing (JACK/qjackctl/qpwgraph). So we skip the global `clock.force-rate`
// write too — not just `set-default-sink`. This trades device-native-rate
// playback for routing freedom (PipeWire resamples when track rate != graph
// rate). Safe: skip_sink_switch is transitively mutually exclusive with
// dac_passthrough / pw_force_bitperfect (the bit-perfect clock path), so this
// never collides with a forced-rate bit-perfect session.
if config.skip_sink_switch {
log::info!(
"[PipeWire Backend] Skipping clock.force-rate (skip_sink_switch enabled) — \
external routing preserved; PipeWire may resample if graph rate differs"
);
} else {
log::info!(
"[PipeWire Backend] Forcing sample rate to {}Hz via pw-metadata",
effective_rate
);
let metadata_result = Command::new("pw-metadata")
.args([
"-n",
"settings",
"0",
"clock.force-rate",
&effective_rate.to_string(),
])
.output();
match metadata_result {
Ok(output) if output.status.success() => {
log::info!(
"[PipeWire Backend] Sample rate forced to {}Hz",
effective_rate
);
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("[PipeWire Backend] Failed to force sample rate: {}", stderr);
}
Err(e) => {
log::warn!("[PipeWire Backend] Error executing pw-metadata: {}", e);
match metadata_result {
Ok(output) if output.status.success() => {
log::info!(
"[PipeWire Backend] Sample rate forced to {}Hz",
effective_rate
);
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("[PipeWire Backend] Failed to force sample rate: {}", stderr);
}
Err(e) => {
log::warn!("[PipeWire Backend] Error executing pw-metadata: {}", e);
}
}
}
@@ -577,7 +593,11 @@ impl AudioBackend for PipeWireBackend {
// The pre-stream force-rate can be ignored if no streams were active.
// Re-applying now that the stream exists forces PipeWire to reconfigure
// the graph at the correct rate.
if effective_rate != 44100 && effective_rate != 48000 {
//
// Tier 1 (issue #263): also gated by skip_sink_switch — in "Lock output
// device" mode QBZ never writes the global clock force (pre-stream OR
// re-apply), so the user's external routing/graph clock is left untouched.
if !config.skip_sink_switch && effective_rate != 44100 && effective_rate != 48000 {
let _ = Command::new("pw-metadata")
.args([
"-n",