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:
8 files changed
+112
-4
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user