feat(audio): add skip_sink_switch option for JACK/DAW routing (#263)

When enabled, skips pactl set-default-sink on stream creation.
Preserves external audio routing (JACK, qjackctl, Reaper).
Mutually exclusive with DAC passthrough.

cc
This commit is contained in:
vicrodh committed 2026-04-04 12:08:22 -06:00
1 parent e4e40b21e0
commit 83e43949b7
8 files changed
+112 -4

No files matched your search

+4
View File
@@ -128,6 +128,10 @@ pub struct BackendConfig {
/// When true, force PipeWire clock.force-quantum for bit-perfect playback /// When true, force PipeWire clock.force-quantum for bit-perfect playback
pub pw_force_bitperfect: bool, pub pw_force_bitperfect: bool,
/// When true, skip `pactl set-default-sink` on stream creation.
/// Preserves external routing (JACK, qjackctl, Reaper).
pub skip_sink_switch: bool,
} }
/// Result type for backend operations /// Result type for backend operations
+4 -1
View File
@@ -308,7 +308,10 @@ impl AudioBackend for PipeWireBackend {
// Temporarily set default sink to target (if specified) // Temporarily set default sink to target (if specified)
// We DON'T restore it - let the user's system keep the selected device as default // We DON'T restore it - let the user's system keep the selected device as default
// This is actually the expected behavior: when you select a device, it becomes the default // This is actually the expected behavior: when you select a device, it becomes the default
if let Some(sink_name) = &target_sink { // When skip_sink_switch is true, skip this entirely to preserve external routing (JACK/qjackctl)
if config.skip_sink_switch {
log::info!("[PipeWire Backend] Skipping set-default-sink (skip_sink_switch enabled)");
} else if let Some(sink_name) = &target_sink {
log::info!("[PipeWire Backend] Setting default sink to: {}", sink_name); log::info!("[PipeWire Backend] Setting default sink to: {}", sink_name);
let set_result = Command::new("pactl") let set_result = Command::new("pactl")
+10 -1
View File
@@ -50,6 +50,9 @@ pub struct AudioSettings {
/// When true, force PipeWire clock.force-quantum alongside clock.force-rate for bit-perfect. /// When true, force PipeWire clock.force-quantum alongside clock.force-rate for bit-perfect.
/// Reset both to 0 on stop. PipeWire-only, requires dac_passthrough. /// Reset both to 0 on stop. PipeWire-only, requires dac_passthrough.
pub pw_force_bitperfect: bool, pub pw_force_bitperfect: bool,
/// When true, skip `pactl set-default-sink` on stream creation.
/// Preserves external routing (JACK, qjackctl, Reaper).
pub skip_sink_switch: bool,
} }
impl Default for AudioSettings { impl Default for AudioSettings {
@@ -72,6 +75,7 @@ impl Default for AudioSettings {
normalization_target_lufs: -14.0, // Spotify/YouTube standard normalization_target_lufs: -14.0, // Spotify/YouTube standard
gapless_enabled: false, // Off by default — user opts in gapless_enabled: false, // Off by default — user opts in
pw_force_bitperfect: false, // Off by default — experimental PipeWire feature pw_force_bitperfect: false, // Off by default — experimental PipeWire feature
skip_sink_switch: false, // Off by default — only for JACK/DAW routing setups
} }
} }
} }
@@ -160,6 +164,10 @@ impl AudioSettingsStore {
"ALTER TABLE audio_settings ADD COLUMN pw_force_bitperfect INTEGER DEFAULT 0", "ALTER TABLE audio_settings ADD COLUMN pw_force_bitperfect INTEGER DEFAULT 0",
[], [],
); );
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN skip_sink_switch INTEGER DEFAULT 0",
[],
);
Ok(Self { conn }) Ok(Self { conn })
} }
@@ -178,7 +186,7 @@ impl AudioSettingsStore {
pub fn get_settings(&self) -> Result<AudioSettings, String> { pub fn get_settings(&self) -> Result<AudioSettings, String> {
self.conn self.conn
.query_row( .query_row(
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect FROM audio_settings WHERE id = 1", "SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, skip_sink_switch FROM audio_settings WHERE id = 1",
[], [],
|row| { |row| {
// Parse backend_type from JSON string // Parse backend_type from JSON string
@@ -215,6 +223,7 @@ impl AudioSettingsStore {
normalization_target_lufs: row.get::<_, Option<f64>>(13)?.unwrap_or(-14.0) as f32, normalization_target_lufs: row.get::<_, Option<f64>>(13)?.unwrap_or(-14.0) as f32,
gapless_enabled: row.get::<_, Option<i64>>(14)?.unwrap_or(0) != 0, gapless_enabled: row.get::<_, Option<i64>>(14)?.unwrap_or(0) != 0,
pw_force_bitperfect: row.get::<_, Option<i64>>(16)?.unwrap_or(0) != 0, pw_force_bitperfect: row.get::<_, Option<i64>>(16)?.unwrap_or(0) != 0,
skip_sink_switch: row.get::<_, Option<i64>>(17)?.unwrap_or(0) != 0,
}) })
}, },
) )
+1
View File
@@ -508,6 +508,7 @@ fn try_init_stream_with_backend(
exclusive_mode: audio_settings.exclusive_mode, exclusive_mode: audio_settings.exclusive_mode,
alsa_plugin: audio_settings.alsa_plugin, alsa_plugin: audio_settings.alsa_plugin,
pw_force_bitperfect: audio_settings.pw_force_bitperfect, pw_force_bitperfect: audio_settings.pw_force_bitperfect,
skip_sink_switch: audio_settings.skip_sink_switch,
}; };
// For ALSA backend with hw: devices, try direct ALSA first (Linux only) // For ALSA backend with hw: devices, try direct ALSA first (Linux only)
+44
View File
@@ -167,6 +167,7 @@ pub(crate) fn convert_to_qbz_audio_settings(settings: &AudioSettings) -> qbz_aud
normalization_target_lufs: settings.normalization_target_lufs, normalization_target_lufs: settings.normalization_target_lufs,
gapless_enabled: settings.gapless_enabled, gapless_enabled: settings.gapless_enabled,
pw_force_bitperfect: settings.pw_force_bitperfect, pw_force_bitperfect: settings.pw_force_bitperfect,
skip_sink_switch: settings.skip_sink_switch,
} }
} }
@@ -8845,6 +8846,49 @@ pub async fn v2_set_audio_gapless_enabled(
Ok(()) Ok(())
} }
/// Set skip sink switch (V2) — preserves JACK/qjackctl routing
#[tauri::command]
pub async fn v2_set_audio_skip_sink_switch(
enabled: bool,
state: State<'_, AudioSettingsState>,
bridge: State<'_, CoreBridgeState>,
) -> Result<(), RuntimeError> {
log::info!("[V2] set_audio_skip_sink_switch: {}", enabled);
let fresh_settings = {
let guard = state
.store
.lock()
.map_err(|e| RuntimeError::Internal(format!("Lock error: {}", e)))?;
let store = guard
.as_ref()
.ok_or(RuntimeError::UserSessionNotActivated)?;
// Constraint: cannot enable when dac_passthrough is on
if enabled {
let current = store.get_settings().map_err(RuntimeError::Internal)?;
if current.dac_passthrough {
return Err(RuntimeError::Internal(
"Cannot enable skip sink switch while DAC passthrough is active".to_string(),
));
}
}
store
.set_skip_sink_switch(enabled)
.map_err(RuntimeError::Internal)?;
store.get_settings().ok()
};
if let Some(fresh) = fresh_settings {
if let Some(b) = bridge.try_get().await {
let _ = b
.player()
.reload_settings(convert_to_qbz_audio_settings(&fresh));
}
}
Ok(())
}
/// Set normalization enabled (V2) /// Set normalization enabled (V2)
#[tauri::command] #[tauri::command]
pub async fn v2_set_audio_normalization_enabled( pub async fn v2_set_audio_normalization_enabled(
+47 -2
View File
@@ -55,6 +55,10 @@ pub struct AudioSettings {
/// Values: "ask" (default), "always_fallback", "always_skip" /// Values: "ask" (default), "always_fallback", "always_skip"
/// Protected by ADR-003: must survive reset_all() and migrations. /// Protected by ADR-003: must survive reset_all() and migrations.
pub quality_fallback_behavior: String, pub quality_fallback_behavior: String,
/// When true, skip `pactl set-default-sink` on stream creation.
/// Preserves external routing (JACK, qjackctl, Reaper).
/// Mutually exclusive with dac_passthrough.
pub skip_sink_switch: bool,
} }
impl Default for AudioSettings { impl Default for AudioSettings {
@@ -79,6 +83,7 @@ impl Default for AudioSettings {
pw_force_bitperfect: false, // Off by default — experimental PipeWire feature pw_force_bitperfect: false, // Off by default — experimental PipeWire feature
sync_audio_on_startup: false, // Off by default — opt-in for stale-settings edge case sync_audio_on_startup: false, // Off by default — opt-in for stale-settings edge case
quality_fallback_behavior: "ask".to_string(), quality_fallback_behavior: "ask".to_string(),
skip_sink_switch: false, // Off by default — only for JACK/DAW routing setups
} }
} }
} }
@@ -175,6 +180,10 @@ impl AudioSettingsStore {
"ALTER TABLE audio_settings ADD COLUMN quality_fallback_behavior TEXT DEFAULT 'ask'", "ALTER TABLE audio_settings ADD COLUMN quality_fallback_behavior TEXT DEFAULT 'ask'",
[], [],
); );
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN skip_sink_switch INTEGER DEFAULT 0",
[],
);
Ok(Self { conn }) Ok(Self { conn })
} }
@@ -193,7 +202,7 @@ impl AudioSettingsStore {
pub fn get_settings(&self) -> Result<AudioSettings, String> { pub fn get_settings(&self) -> Result<AudioSettings, String> {
self.conn self.conn
.query_row( .query_row(
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, sync_audio_on_startup, quality_fallback_behavior FROM audio_settings WHERE id = 1", "SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, sync_audio_on_startup, quality_fallback_behavior, skip_sink_switch FROM audio_settings WHERE id = 1",
[], [],
|row| { |row| {
// Parse backend_type from JSON string // Parse backend_type from JSON string
@@ -234,6 +243,7 @@ impl AudioSettingsStore {
quality_fallback_behavior: row quality_fallback_behavior: row
.get::<_, Option<String>>(18)? .get::<_, Option<String>>(18)?
.unwrap_or_else(|| "ask".to_string()), .unwrap_or_else(|| "ask".to_string()),
skip_sink_switch: row.get::<_, Option<i64>>(19)?.unwrap_or(0) != 0,
}) })
}, },
) )
@@ -447,6 +457,16 @@ impl AudioSettingsStore {
Ok(()) Ok(())
} }
pub fn set_skip_sink_switch(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE audio_settings SET skip_sink_switch = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set skip_sink_switch: {}", e))?;
Ok(())
}
pub fn set_pw_force_bitperfect(&self, enabled: bool) -> Result<(), String> { pub fn set_pw_force_bitperfect(&self, enabled: bool) -> Result<(), String> {
self.conn self.conn
.execute( .execute(
@@ -543,7 +563,8 @@ impl AudioSettingsStore {
gapless_enabled = ?15, gapless_enabled = ?15,
device_sample_rate_limits = ?16, device_sample_rate_limits = ?16,
pw_force_bitperfect = ?17, pw_force_bitperfect = ?17,
sync_audio_on_startup = ?18 sync_audio_on_startup = ?18,
skip_sink_switch = ?19
WHERE id = 1", WHERE id = 1",
params![ params![
defaults.output_device, defaults.output_device,
@@ -564,6 +585,7 @@ impl AudioSettingsStore {
limits_json, limits_json,
defaults.pw_force_bitperfect as i64, defaults.pw_force_bitperfect as i64,
defaults.sync_audio_on_startup as i64, defaults.sync_audio_on_startup as i64,
defaults.skip_sink_switch as i64,
], ],
) )
.map_err(|e| format!("Failed to reset audio settings: {}", e))?; .map_err(|e| format!("Failed to reset audio settings: {}", e))?;
@@ -873,6 +895,29 @@ pub fn set_audio_normalization_target(
store.set_normalization_target_lufs(target_lufs) store.set_normalization_target_lufs(target_lufs)
} }
#[tauri::command]
pub fn set_audio_skip_sink_switch(
state: tauri::State<'_, AudioSettingsState>,
enabled: bool,
) -> Result<(), String> {
log::info!("Command: set_audio_skip_sink_switch {}", enabled);
let guard = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
// Constraint: cannot enable skip_sink_switch when dac_passthrough is on
if enabled {
let settings = store.get_settings()?;
if settings.dac_passthrough {
return Err("Cannot enable skip sink switch while DAC passthrough is active".to_string());
}
}
store.set_skip_sink_switch(enabled)
}
#[tauri::command] #[tauri::command]
pub fn set_audio_gapless_enabled( pub fn set_audio_gapless_enabled(
state: tauri::State<'_, AudioSettingsState>, state: tauri::State<'_, AudioSettingsState>,
+1
View File
@@ -1564,6 +1564,7 @@ pub fn run() {
commands_v2::v2_set_audio_backend_type, commands_v2::v2_set_audio_backend_type,
commands_v2::v2_set_audio_alsa_plugin, commands_v2::v2_set_audio_alsa_plugin,
commands_v2::v2_set_audio_gapless_enabled, commands_v2::v2_set_audio_gapless_enabled,
commands_v2::v2_set_audio_skip_sink_switch,
commands_v2::v2_set_audio_normalization_enabled, commands_v2::v2_set_audio_normalization_enabled,
commands_v2::v2_set_audio_normalization_target, commands_v2::v2_set_audio_normalization_target,
commands_v2::v2_set_audio_device_max_sample_rate, commands_v2::v2_set_audio_device_max_sample_rate,
+1
View File
@@ -513,6 +513,7 @@ fn try_init_stream_with_backend(
exclusive_mode: audio_settings.exclusive_mode, exclusive_mode: audio_settings.exclusive_mode,
alsa_plugin: audio_settings.alsa_plugin, alsa_plugin: audio_settings.alsa_plugin,
pw_force_bitperfect: audio_settings.pw_force_bitperfect, pw_force_bitperfect: audio_settings.pw_force_bitperfect,
skip_sink_switch: audio_settings.skip_sink_switch,
}; };
// For ALSA backend with hw: devices, try direct ALSA first (Linux only) // For ALSA backend with hw: devices, try direct ALSA first (Linux only)