Merge refactor/qbz-audio-settings-foundation: audio settings store
This commit is contained in:
3 files changed
+408
-777
No files matched your search
@@ -44,17 +44,33 @@ pub struct AudioSettings {
|
||||
/// Target loudness in LUFS for normalization.
|
||||
/// Common values: -14.0 (Spotify/YouTube), -18.0 (audiophile), -23.0 (EBU broadcast)
|
||||
pub normalization_target_lufs: f32,
|
||||
/// When true, tracks with the same format are cross-faded seamlessly via Rodio Sink queueing.
|
||||
/// Only works with cached tracks on Rodio backend (not ALSA Direct or streaming).
|
||||
/// When true, consecutive same-format tracks play without gap.
|
||||
/// Works on Rodio (PipeWire/Pulse) and ALSA Direct backends. Requires cached tracks.
|
||||
pub gapless_enabled: bool,
|
||||
/// 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.
|
||||
pub pw_force_bitperfect: bool,
|
||||
/// When true, reload audio settings from DB into the player on app startup.
|
||||
/// Useful when Player::new() may hold stale settings (e.g., after Flatpak updates).
|
||||
/// Default: false (most users don't need this).
|
||||
pub sync_audio_on_startup: bool,
|
||||
/// User preference for what happens when all quality retries fail.
|
||||
/// Values: "ask" (default), "always_fallback", "always_skip"
|
||||
/// Protected by ADR-003: must survive reset_all() and migrations.
|
||||
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,
|
||||
/// When true, automatically try lower quality tiers if the requested one fails.
|
||||
/// When false (default), playback or download fails if the exact quality is unavailable.
|
||||
pub allow_quality_fallback: bool,
|
||||
/// When true, hold a per-process ALSA device reservation (Lifetime B) for the
|
||||
/// configured output device while QBZ is running, so other PulseAudio/PipeWire
|
||||
/// clients won't grab the DAC and break exclusive playback. Off by default.
|
||||
/// See `qbz-nix-docs/specs/2026-05-07-alsa-exclusive-hardening-design.md`.
|
||||
#[serde(default)]
|
||||
pub reserve_dac_while_running: bool,
|
||||
}
|
||||
|
||||
impl Default for AudioSettings {
|
||||
@@ -64,7 +80,15 @@ impl Default for AudioSettings {
|
||||
exclusive_mode: false,
|
||||
dac_passthrough: false,
|
||||
preferred_sample_rate: None,
|
||||
backend_type: None, // Auto-detect (PipeWire if available, else ALSA)
|
||||
// Default to a per-OS-correct backend (PipeWire on Linux, SystemDefault
|
||||
// elsewhere) instead of None. None used to mean "auto-detect" but it
|
||||
// actually fell through to the legacy CPAL path in init_device, which
|
||||
// has a known race against rodio's DeviceSink drop on resume — visible
|
||||
// as "audio doesn't come back after pause" (#375). Picking a concrete
|
||||
// backend here puts new installs on the proper backend init path; the
|
||||
// runtime still degrades gracefully if the chosen backend isn't
|
||||
// available on the host.
|
||||
backend_type: Some(AudioBackendType::default()),
|
||||
alsa_plugin: Some(AlsaPlugin::Hw), // Default to hw (bit-perfect)
|
||||
alsa_hardware_volume: false, // Disabled by default (maximum compatibility)
|
||||
stream_first_track: false, // Disabled by default — user opts in
|
||||
@@ -75,10 +99,13 @@ impl Default for AudioSettings {
|
||||
device_sample_rate_limits: HashMap::new(), // Per-device limits (empty = no limit)
|
||||
normalization_enabled: false, // Off by default — preserves bit-perfect pipeline
|
||||
normalization_target_lufs: -14.0, // Spotify/YouTube standard
|
||||
gapless_enabled: false, // Off by default — user opts in
|
||||
gapless_enabled: true, // On by default — works for same-format tracks on all backends
|
||||
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
|
||||
quality_fallback_behavior: "ask".to_string(),
|
||||
skip_sink_switch: false, // Off by default — only for JACK/DAW routing setups
|
||||
allow_quality_fallback: false, // Off by default — fail rather than silently downgrade
|
||||
reserve_dac_while_running: false, // Off by default — opt-in DAC reservation (Lifetime B)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,6 +194,14 @@ impl AudioSettingsStore {
|
||||
"ALTER TABLE audio_settings ADD COLUMN pw_force_bitperfect INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN sync_audio_on_startup INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"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",
|
||||
[],
|
||||
@@ -175,6 +210,34 @@ impl AudioSettingsStore {
|
||||
"ALTER TABLE audio_settings ADD COLUMN allow_quality_fallback INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN reserve_dac_while_running INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
|
||||
// Migration (#375): existing installs persisted backend_type = NULL when
|
||||
// the user never explicitly picked a backend. NULL fell through to the
|
||||
// legacy CPAL path in the player, which has a race against rodio's
|
||||
// DeviceSink drop on resume that produces silent streams after pause.
|
||||
// Backfill NULL rows with the per-OS default so those installs move onto
|
||||
// the proper backend init path. set_backend_type is the canonical writer
|
||||
// (it serialises via serde_json), so we go through the same JSON shape
|
||||
// here for the row read path to round-trip cleanly.
|
||||
let default_backend_json = serde_json::to_string(&AudioBackendType::default())
|
||||
.unwrap_or_else(|_| {
|
||||
// Fallback only matters if serde_json::to_string fails for a
|
||||
// unit-variant enum, which it can't — but keep a sane string in
|
||||
// case the enum gains a non-trivial variant in the future.
|
||||
if cfg!(target_os = "linux") {
|
||||
"\"PipeWire\"".to_string()
|
||||
} else {
|
||||
"\"SystemDefault\"".to_string()
|
||||
}
|
||||
});
|
||||
let _ = conn.execute(
|
||||
"UPDATE audio_settings SET backend_type = ?1 WHERE id = 1 AND backend_type IS NULL",
|
||||
params![default_backend_json],
|
||||
);
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
@@ -193,7 +256,7 @@ impl AudioSettingsStore {
|
||||
pub fn get_settings(&self) -> Result<AudioSettings, String> {
|
||||
self.conn
|
||||
.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, skip_sink_switch, allow_quality_fallback 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, allow_quality_fallback, reserve_dac_while_running FROM audio_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
// Parse backend_type from JSON string
|
||||
@@ -230,8 +293,16 @@ impl AudioSettingsStore {
|
||||
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,
|
||||
pw_force_bitperfect: row.get::<_, Option<i64>>(16)?.unwrap_or(0) != 0,
|
||||
skip_sink_switch: row.get::<_, Option<i64>>(17)?.unwrap_or(0) != 0,
|
||||
allow_quality_fallback: row.get::<_, Option<i64>>(18)?.unwrap_or(0) != 0,
|
||||
sync_audio_on_startup: row.get::<_, Option<i64>>(17)?.unwrap_or(0) != 0,
|
||||
quality_fallback_behavior: row
|
||||
.get::<_, Option<String>>(18)?
|
||||
.unwrap_or_else(|| "ask".to_string()),
|
||||
skip_sink_switch: row.get::<_, Option<i64>>(19)?.unwrap_or(0) != 0,
|
||||
allow_quality_fallback: row.get::<_, Option<i64>>(20)?.unwrap_or(0) != 0,
|
||||
reserve_dac_while_running: row
|
||||
.get::<_, Option<i64>>(21)?
|
||||
.unwrap_or(0)
|
||||
!= 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -290,6 +361,25 @@ impl AudioSettingsStore {
|
||||
params![backend_json],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set backend type: {}", e))?;
|
||||
|
||||
// When switching to ALSA, ensure alsa_plugin has a value (default to Hw)
|
||||
if backend == Some(AudioBackendType::Alsa) {
|
||||
let current_plugin: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT alsa_plugin FROM audio_settings WHERE id = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("Failed to check alsa_plugin: {}", e))?;
|
||||
|
||||
if current_plugin.is_none() {
|
||||
log::info!(
|
||||
"[AudioSettings] ALSA backend selected with no plugin set, defaulting to Hw"
|
||||
);
|
||||
self.set_alsa_plugin(Some(AlsaPlugin::Hw))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -445,6 +535,40 @@ impl AudioSettingsStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_allow_quality_fallback(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET allow_quality_fallback = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set allow_quality_fallback: {}", e))?;
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Persist the `reserve_dac_while_running` flag (Lifetime B from the
|
||||
/// ALSA exclusive-hardening design spec). Toggling this only updates
|
||||
/// the DB row; applying the change to the live `DeviceReservation`
|
||||
/// guard is the caller's responsibility.
|
||||
pub fn set_reserve_dac_while_running(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET reserve_dac_while_running = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set reserve_dac_while_running: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_pw_force_bitperfect(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
@@ -455,6 +579,39 @@ impl AudioSettingsStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_sync_audio_on_startup(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET sync_audio_on_startup = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set sync_audio_on_startup: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_quality_fallback_behavior(&self) -> Result<String, String> {
|
||||
let settings = self.get_settings()?;
|
||||
let value = &settings.quality_fallback_behavior;
|
||||
match value.as_str() {
|
||||
"ask" | "always_fallback" | "always_skip" => Ok(value.clone()),
|
||||
_ => Ok("ask".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_quality_fallback_behavior(&self, behavior: &str) -> Result<(), String> {
|
||||
match behavior {
|
||||
"ask" | "always_fallback" | "always_skip" => {}
|
||||
_ => return Err(format!("Invalid quality_fallback_behavior: {}", behavior)),
|
||||
}
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET quality_fallback_behavior = ?1 WHERE id = 1",
|
||||
params![behavior],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set quality_fallback_behavior: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_normalization_target_lufs(&self, target: f32) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
@@ -467,6 +624,11 @@ impl AudioSettingsStore {
|
||||
|
||||
/// Reset all audio settings to their default values
|
||||
pub fn reset_all(&self) -> Result<AudioSettings, String> {
|
||||
// ADR-003: quality_fallback_behavior must survive reset_all()
|
||||
let saved_fallback = self
|
||||
.get_quality_fallback_behavior()
|
||||
.unwrap_or_else(|_| "ask".to_string());
|
||||
|
||||
let defaults = AudioSettings::default();
|
||||
let backend_json: Option<String> = defaults
|
||||
.backend_type
|
||||
@@ -502,7 +664,11 @@ impl AudioSettingsStore {
|
||||
normalization_target_lufs = ?14,
|
||||
gapless_enabled = ?15,
|
||||
device_sample_rate_limits = ?16,
|
||||
pw_force_bitperfect = ?17
|
||||
pw_force_bitperfect = ?17,
|
||||
sync_audio_on_startup = ?18,
|
||||
skip_sink_switch = ?19,
|
||||
allow_quality_fallback = ?20,
|
||||
reserve_dac_while_running = ?21
|
||||
WHERE id = 1",
|
||||
params![
|
||||
defaults.output_device,
|
||||
@@ -522,11 +688,30 @@ impl AudioSettingsStore {
|
||||
defaults.gapless_enabled as i64,
|
||||
limits_json,
|
||||
defaults.pw_force_bitperfect as i64,
|
||||
defaults.sync_audio_on_startup as i64,
|
||||
defaults.skip_sink_switch as i64,
|
||||
defaults.allow_quality_fallback as i64,
|
||||
defaults.reserve_dac_while_running as i64,
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to reset audio settings: {}", e))?;
|
||||
|
||||
Ok(defaults)
|
||||
// ADR-003: restore quality_fallback_behavior after reset (it is not an audio config)
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET quality_fallback_behavior = ?1 WHERE id = 1",
|
||||
params![saved_fallback],
|
||||
)
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to restore quality_fallback_behavior after reset: {}",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut result = defaults;
|
||||
result.quality_fallback_behavior = saved_fallback;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,3 +758,211 @@ impl Default for AudioSettingsState {
|
||||
Self::new_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unique_test_dir(name: &str) -> std::path::PathBuf {
|
||||
let nonce = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("qbz-audio-{name}-{}-{nonce}", std::process::id()))
|
||||
}
|
||||
|
||||
fn fresh_store(name: &str) -> (std::path::PathBuf, AudioSettingsStore) {
|
||||
let dir = unique_test_dir(name);
|
||||
let store = AudioSettingsStore::new_at(&dir).expect("open store in temp dir");
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_settings_default_values_are_stable() {
|
||||
let settings = AudioSettings::default();
|
||||
|
||||
assert_eq!(settings.backend_type, Some(AudioBackendType::default()));
|
||||
assert_eq!(settings.alsa_plugin, Some(AlsaPlugin::Hw));
|
||||
assert!(settings.gapless_enabled);
|
||||
assert!(!settings.sync_audio_on_startup);
|
||||
assert_eq!(settings.quality_fallback_behavior, "ask");
|
||||
assert!(!settings.skip_sink_switch);
|
||||
assert!(!settings.allow_quality_fallback);
|
||||
assert!(!settings.reserve_dac_while_running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_settings_store_returns_current_defaults() {
|
||||
let (dir, store) = fresh_store("defaults");
|
||||
|
||||
let settings = store.get_settings().expect("get settings");
|
||||
|
||||
assert_eq!(settings.backend_type, Some(AudioBackendType::default()));
|
||||
assert_eq!(settings.alsa_plugin, None);
|
||||
assert!(!settings.gapless_enabled);
|
||||
assert_eq!(settings.quality_fallback_behavior, "ask");
|
||||
assert!(!settings.reserve_dac_while_running);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_null_is_backfilled_to_default_backend() {
|
||||
let dir = unique_test_dir("backend-backfill");
|
||||
{
|
||||
let store = AudioSettingsStore::new_at(&dir).expect("open store");
|
||||
store
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET backend_type = NULL WHERE id = 1",
|
||||
[],
|
||||
)
|
||||
.expect("force legacy null backend");
|
||||
}
|
||||
|
||||
let reopened = AudioSettingsStore::new_at(&dir).expect("reopen store");
|
||||
let settings = reopened.get_settings().expect("get settings");
|
||||
|
||||
assert_eq!(settings.backend_type, Some(AudioBackendType::default()));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_to_alsa_sets_default_plugin_when_missing() {
|
||||
let (dir, store) = fresh_store("alsa-plugin-default");
|
||||
store.set_alsa_plugin(None).expect("clear alsa plugin");
|
||||
|
||||
store
|
||||
.set_backend_type(Some(AudioBackendType::Alsa))
|
||||
.expect("set backend");
|
||||
let settings = store.get_settings().expect("get settings");
|
||||
|
||||
assert_eq!(settings.backend_type, Some(AudioBackendType::Alsa));
|
||||
assert_eq!(settings.alsa_plugin, Some(AlsaPlugin::Hw));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_buffer_seconds_clamps_to_valid_range() {
|
||||
let (dir, store) = fresh_store("stream-buffer-clamp");
|
||||
|
||||
store
|
||||
.set_stream_buffer_seconds(0)
|
||||
.expect("set low buffer");
|
||||
assert_eq!(
|
||||
store.get_settings().expect("get settings").stream_buffer_seconds,
|
||||
1
|
||||
);
|
||||
|
||||
store
|
||||
.set_stream_buffer_seconds(99)
|
||||
.expect("set high buffer");
|
||||
assert_eq!(
|
||||
store.get_settings().expect("get settings").stream_buffer_seconds,
|
||||
10
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_fallback_invalid_value_reads_as_ask() {
|
||||
let (dir, store) = fresh_store("quality-invalid");
|
||||
store
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET quality_fallback_behavior = 'bad-value' WHERE id = 1",
|
||||
[],
|
||||
)
|
||||
.expect("write invalid fallback behavior");
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.get_quality_fallback_behavior()
|
||||
.expect("get quality fallback"),
|
||||
"ask"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_all_preserves_quality_fallback_behavior() {
|
||||
let (dir, store) = fresh_store("reset-preserves-quality");
|
||||
store
|
||||
.set_quality_fallback_behavior("always_skip")
|
||||
.expect("set quality fallback");
|
||||
store.set_output_device(Some("hw:4,0")).expect("set device");
|
||||
store.set_dac_passthrough(true).expect("set dac");
|
||||
|
||||
let reset = store.reset_all().expect("reset settings");
|
||||
let settings = store.get_settings().expect("get settings");
|
||||
|
||||
assert_eq!(reset.quality_fallback_behavior, "always_skip");
|
||||
assert_eq!(settings.quality_fallback_behavior, "always_skip");
|
||||
assert_eq!(settings.output_device, None);
|
||||
assert!(!settings.dac_passthrough);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_persist_and_reopen_all_new_fields() {
|
||||
let dir = unique_test_dir("persist-new-fields");
|
||||
{
|
||||
let store = AudioSettingsStore::new_at(&dir).expect("open store");
|
||||
store
|
||||
.set_sync_audio_on_startup(true)
|
||||
.expect("set sync flag");
|
||||
store
|
||||
.set_quality_fallback_behavior("always_fallback")
|
||||
.expect("set quality fallback");
|
||||
store
|
||||
.set_reserve_dac_while_running(true)
|
||||
.expect("set reserve flag");
|
||||
store
|
||||
.set_allow_quality_fallback(true)
|
||||
.expect("set allow fallback");
|
||||
store
|
||||
.set_skip_sink_switch(true)
|
||||
.expect("set skip sink switch");
|
||||
}
|
||||
|
||||
let reopened = AudioSettingsStore::new_at(&dir).expect("reopen store");
|
||||
let settings = reopened.get_settings().expect("get settings");
|
||||
|
||||
assert!(settings.sync_audio_on_startup);
|
||||
assert_eq!(settings.quality_fallback_behavior, "always_fallback");
|
||||
assert!(settings.reserve_dac_while_running);
|
||||
assert!(settings.allow_quality_fallback);
|
||||
assert!(settings.skip_sink_switch);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_legacy_json_without_reserve_dac_field() {
|
||||
let legacy = r#"{
|
||||
"output_device": null,
|
||||
"exclusive_mode": false,
|
||||
"dac_passthrough": false,
|
||||
"preferred_sample_rate": null,
|
||||
"backend_type": null,
|
||||
"alsa_plugin": null,
|
||||
"alsa_hardware_volume": false,
|
||||
"stream_first_track": false,
|
||||
"stream_buffer_seconds": 3,
|
||||
"streaming_only": false,
|
||||
"limit_quality_to_device": false,
|
||||
"device_max_sample_rate": null,
|
||||
"normalization_enabled": false,
|
||||
"normalization_target_lufs": -14.0,
|
||||
"gapless_enabled": true,
|
||||
"pw_force_bitperfect": false,
|
||||
"sync_audio_on_startup": false,
|
||||
"quality_fallback_behavior": "ask",
|
||||
"skip_sink_switch": false,
|
||||
"allow_quality_fallback": false
|
||||
}"#;
|
||||
|
||||
let settings: AudioSettings =
|
||||
serde_json::from_str(legacy).expect("legacy JSON should deserialize");
|
||||
|
||||
assert!(!settings.reserve_dac_while_running);
|
||||
}
|
||||
}
|
||||
@@ -98,27 +98,7 @@ pub struct V2SearchAllResults {
|
||||
/// Used by runtime_bootstrap (once at startup) and v2_reinit_audio_device
|
||||
/// to ensure the Player has fresh settings from the database.
|
||||
pub(crate) fn convert_to_qbz_audio_settings(settings: &AudioSettings) -> qbz_audio::AudioSettings {
|
||||
qbz_audio::AudioSettings {
|
||||
output_device: settings.output_device.clone(),
|
||||
exclusive_mode: settings.exclusive_mode,
|
||||
dac_passthrough: settings.dac_passthrough,
|
||||
preferred_sample_rate: settings.preferred_sample_rate,
|
||||
limit_quality_to_device: settings.limit_quality_to_device,
|
||||
device_max_sample_rate: settings.device_max_sample_rate,
|
||||
device_sample_rate_limits: settings.device_sample_rate_limits.clone(),
|
||||
backend_type: settings.backend_type.clone(),
|
||||
alsa_plugin: settings.alsa_plugin.clone(),
|
||||
alsa_hardware_volume: settings.alsa_hardware_volume,
|
||||
stream_first_track: settings.stream_first_track,
|
||||
stream_buffer_seconds: settings.stream_buffer_seconds,
|
||||
streaming_only: settings.streaming_only,
|
||||
normalization_enabled: settings.normalization_enabled,
|
||||
normalization_target_lufs: settings.normalization_target_lufs,
|
||||
gapless_enabled: settings.gapless_enabled,
|
||||
pw_force_bitperfect: settings.pw_force_bitperfect,
|
||||
skip_sink_switch: settings.skip_sink_switch,
|
||||
allow_quality_fallback: settings.allow_quality_fallback,
|
||||
}
|
||||
settings.clone()
|
||||
}
|
||||
|
||||
/// Reload audio settings from the per-user store into the CoreBridge player.
|
||||
|
||||
@@ -1,754 +1,10 @@
|
||||
//! Audio settings persistence
|
||||
//!
|
||||
//! Stores user preferences for audio output device, exclusive mode, and DAC passthrough.
|
||||
//! Tauri command adapters for the canonical qbz-audio settings store.
|
||||
|
||||
use crate::audio::{AlsaPlugin, AudioBackendType};
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AudioSettings {
|
||||
pub output_device: Option<String>, // None = system default
|
||||
pub exclusive_mode: bool,
|
||||
pub dac_passthrough: bool,
|
||||
pub preferred_sample_rate: Option<u32>, // None = auto
|
||||
pub backend_type: Option<AudioBackendType>, // None = auto-detect
|
||||
pub alsa_plugin: Option<AlsaPlugin>, // Only used when backend is ALSA
|
||||
pub alsa_hardware_volume: bool, // Use ALSA mixer for volume (only with hw: devices)
|
||||
/// When true, uncached tracks start playing via streaming instead of waiting for full download
|
||||
pub stream_first_track: bool,
|
||||
/// Initial buffer size in seconds before starting streaming playback (1-10, default 3)
|
||||
pub stream_buffer_seconds: u8,
|
||||
/// When true, skip L1+L2 cache writes (streaming-only mode). Offline cache still works.
|
||||
pub streaming_only: bool,
|
||||
/// When true, limit streaming quality to device's max supported sample rate.
|
||||
/// This ensures bit-perfect playback by avoiding tracks that exceed device capabilities.
|
||||
/// Default: true (recommended for bit-perfect setups)
|
||||
pub limit_quality_to_device: bool,
|
||||
/// Cached max sample rate of the selected device (set when device is selected)
|
||||
/// Used when limit_quality_to_device is true
|
||||
pub device_max_sample_rate: Option<u32>,
|
||||
/// Per-device sample rate limits: device_id -> max_sample_rate
|
||||
/// Allows different DACs to have independent max sample rate configurations
|
||||
#[serde(default)]
|
||||
pub device_sample_rate_limits: HashMap<String, u32>,
|
||||
/// When true, apply volume normalization using ReplayGain metadata.
|
||||
/// When false (default), the audio pipeline is 100% bit-perfect — no sample modification.
|
||||
pub normalization_enabled: bool,
|
||||
/// Target loudness in LUFS for normalization.
|
||||
/// Common values: -14.0 (Spotify/YouTube), -18.0 (audiophile), -23.0 (EBU broadcast)
|
||||
pub normalization_target_lufs: f32,
|
||||
/// When true, consecutive same-format tracks play without gap.
|
||||
/// Works on Rodio (PipeWire/Pulse) and ALSA Direct backends. Requires cached tracks.
|
||||
pub gapless_enabled: bool,
|
||||
/// 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.
|
||||
pub pw_force_bitperfect: bool,
|
||||
/// When true, reload audio settings from DB into the player on app startup.
|
||||
/// Useful when Player::new() may hold stale settings (e.g., after Flatpak updates).
|
||||
/// Default: false (most users don't need this).
|
||||
pub sync_audio_on_startup: bool,
|
||||
/// User preference for what happens when all quality retries fail.
|
||||
/// Values: "ask" (default), "always_fallback", "always_skip"
|
||||
/// Protected by ADR-003: must survive reset_all() and migrations.
|
||||
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,
|
||||
/// When true, automatically try lower quality tiers if the requested one fails.
|
||||
/// When false (default), playback or download fails if the exact quality is unavailable.
|
||||
pub allow_quality_fallback: bool,
|
||||
/// When true, hold a per-process ALSA device reservation (Lifetime B) for the
|
||||
/// configured output device while QBZ is running, so other PulseAudio/PipeWire
|
||||
/// clients won't grab the DAC and break exclusive playback. Off by default.
|
||||
/// See `qbz-nix-docs/specs/2026-05-07-alsa-exclusive-hardening-design.md`.
|
||||
#[serde(default)]
|
||||
pub reserve_dac_while_running: bool,
|
||||
}
|
||||
|
||||
impl Default for AudioSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
output_device: None,
|
||||
exclusive_mode: false,
|
||||
dac_passthrough: false,
|
||||
preferred_sample_rate: None,
|
||||
// Default to a per-OS-correct backend (PipeWire on Linux, SystemDefault
|
||||
// elsewhere) instead of None. None used to mean "auto-detect" but it
|
||||
// actually fell through to the legacy CPAL path in init_device, which
|
||||
// has a known race against rodio's DeviceSink drop on resume — visible
|
||||
// as "audio doesn't come back after pause" (#375). Picking a concrete
|
||||
// backend here puts new installs on the proper backend init path; the
|
||||
// runtime still degrades gracefully if the chosen backend isn't
|
||||
// available on the host.
|
||||
backend_type: Some(AudioBackendType::default()),
|
||||
alsa_plugin: Some(AlsaPlugin::Hw), // Default to hw (bit-perfect)
|
||||
alsa_hardware_volume: false, // Disabled by default (maximum compatibility)
|
||||
stream_first_track: false, // Disabled by default — user opts in
|
||||
stream_buffer_seconds: 3, // 3 seconds initial buffer
|
||||
streaming_only: false, // Disabled by default (cache tracks for instant replay)
|
||||
limit_quality_to_device: false, // Disabled in 1.1.9 — detection logic unreliable (#45)
|
||||
device_max_sample_rate: None, // Set when device is selected
|
||||
device_sample_rate_limits: HashMap::new(), // Per-device limits (empty = no limit)
|
||||
normalization_enabled: false, // Off by default — preserves bit-perfect pipeline
|
||||
normalization_target_lufs: -14.0, // Spotify/YouTube standard
|
||||
gapless_enabled: true, // On by default — works for same-format tracks on all backends
|
||||
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
|
||||
quality_fallback_behavior: "ask".to_string(),
|
||||
skip_sink_switch: false, // Off by default — only for JACK/DAW routing setups
|
||||
allow_quality_fallback: false, // Off by default — fail rather than silently downgrade
|
||||
reserve_dac_while_running: false, // Off by default — opt-in DAC reservation (Lifetime B)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AudioSettingsStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl AudioSettingsStore {
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open audio settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to enable WAL for audio settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS audio_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
output_device TEXT,
|
||||
exclusive_mode INTEGER NOT NULL DEFAULT 0,
|
||||
dac_passthrough INTEGER NOT NULL DEFAULT 0,
|
||||
preferred_sample_rate INTEGER,
|
||||
backend_type TEXT,
|
||||
alsa_plugin TEXT,
|
||||
alsa_hardware_volume INTEGER NOT NULL DEFAULT 0,
|
||||
stream_first_track INTEGER NOT NULL DEFAULT 0,
|
||||
stream_buffer_seconds INTEGER NOT NULL DEFAULT 3
|
||||
);
|
||||
INSERT OR IGNORE INTO audio_settings (id, exclusive_mode, dac_passthrough)
|
||||
VALUES (1, 0, 0);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create audio settings table: {}", e))?;
|
||||
|
||||
// Migration: Add new columns if they don't exist (for existing databases)
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN backend_type TEXT",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute("ALTER TABLE audio_settings ADD COLUMN alsa_plugin TEXT", []);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN alsa_hardware_volume INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN stream_first_track INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN stream_buffer_seconds INTEGER DEFAULT 3",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN streaming_only INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN limit_quality_to_device INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN device_max_sample_rate INTEGER",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN normalization_enabled INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN normalization_target_lufs REAL DEFAULT -14.0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN gapless_enabled INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN device_sample_rate_limits TEXT",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN pw_force_bitperfect INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN sync_audio_on_startup INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"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",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN allow_quality_fallback INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN reserve_dac_while_running INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
|
||||
// Migration (#375): existing installs persisted backend_type = NULL when
|
||||
// the user never explicitly picked a backend. NULL fell through to the
|
||||
// legacy CPAL path in the player, which has a race against rodio's
|
||||
// DeviceSink drop on resume that produces silent streams after pause.
|
||||
// Backfill NULL rows with the per-OS default so those installs move onto
|
||||
// the proper backend init path. set_backend_type is the canonical writer
|
||||
// (it serialises via serde_json), so we go through the same JSON shape
|
||||
// here for the row read path to round-trip cleanly.
|
||||
let default_backend_json = serde_json::to_string(&AudioBackendType::default())
|
||||
.unwrap_or_else(|_| {
|
||||
// Fallback only matters if serde_json::to_string fails for a
|
||||
// unit-variant enum, which it can't — but keep a sane string in
|
||||
// case the enum gains a non-trivial variant in the future.
|
||||
if cfg!(target_os = "linux") {
|
||||
"\"PipeWire\"".to_string()
|
||||
} else {
|
||||
"\"SystemDefault\"".to_string()
|
||||
}
|
||||
});
|
||||
let _ = conn.execute(
|
||||
"UPDATE audio_settings SET backend_type = ?1 WHERE id = 1 AND backend_type IS NULL",
|
||||
params![default_backend_json],
|
||||
);
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "audio_settings.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "audio_settings.db")
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Result<AudioSettings, String> {
|
||||
self.conn
|
||||
.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, skip_sink_switch, allow_quality_fallback, reserve_dac_while_running FROM audio_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
// Parse backend_type from JSON string
|
||||
let backend_type: Option<AudioBackendType> = row
|
||||
.get::<_, Option<String>>(4)?
|
||||
.and_then(|s| serde_json::from_str(&s).ok());
|
||||
|
||||
// Parse alsa_plugin from JSON string
|
||||
let alsa_plugin: Option<AlsaPlugin> = row
|
||||
.get::<_, Option<String>>(5)?
|
||||
.and_then(|s| serde_json::from_str(&s).ok());
|
||||
|
||||
// Parse device_sample_rate_limits from JSON string
|
||||
let device_sample_rate_limits: HashMap<String, u32> = row
|
||||
.get::<_, Option<String>>(15)?
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(AudioSettings {
|
||||
output_device: row.get(0)?,
|
||||
exclusive_mode: row.get::<_, i64>(1)? != 0,
|
||||
dac_passthrough: row.get::<_, i64>(2)? != 0,
|
||||
preferred_sample_rate: row.get(3)?,
|
||||
backend_type,
|
||||
alsa_plugin,
|
||||
alsa_hardware_volume: row.get::<_, Option<i64>>(6)?.unwrap_or(0) != 0,
|
||||
stream_first_track: row.get::<_, Option<i64>>(7)?.unwrap_or(0) != 0,
|
||||
stream_buffer_seconds: row.get::<_, Option<i64>>(8)?.unwrap_or(3) as u8,
|
||||
streaming_only: row.get::<_, Option<i64>>(9)?.unwrap_or(0) != 0,
|
||||
limit_quality_to_device: row.get::<_, Option<i64>>(10)?.unwrap_or(1) != 0,
|
||||
device_max_sample_rate: row.get::<_, Option<i64>>(11)?.map(|r| r as u32),
|
||||
device_sample_rate_limits,
|
||||
normalization_enabled: row.get::<_, Option<i64>>(12)?.unwrap_or(0) != 0,
|
||||
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,
|
||||
pw_force_bitperfect: row.get::<_, Option<i64>>(16)?.unwrap_or(0) != 0,
|
||||
sync_audio_on_startup: row.get::<_, Option<i64>>(17)?.unwrap_or(0) != 0,
|
||||
quality_fallback_behavior: row
|
||||
.get::<_, Option<String>>(18)?
|
||||
.unwrap_or_else(|| "ask".to_string()),
|
||||
skip_sink_switch: row.get::<_, Option<i64>>(19)?.unwrap_or(0) != 0,
|
||||
allow_quality_fallback: row.get::<_, Option<i64>>(20)?.unwrap_or(0) != 0,
|
||||
reserve_dac_while_running: row
|
||||
.get::<_, Option<i64>>(21)?
|
||||
.unwrap_or(0)
|
||||
!= 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to get audio settings: {}", e))
|
||||
}
|
||||
|
||||
pub fn set_output_device(&self, device: Option<&str>) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET output_device = ?1 WHERE id = 1",
|
||||
params![device],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set output device: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_exclusive_mode(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET exclusive_mode = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set exclusive mode: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_dac_passthrough(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET dac_passthrough = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set DAC passthrough: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_sample_rate(&self, rate: Option<u32>) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET preferred_sample_rate = ?1 WHERE id = 1",
|
||||
params![rate.map(|r| r as i64)],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set sample rate: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_backend_type(&self, backend: Option<AudioBackendType>) -> Result<(), String> {
|
||||
let backend_json = backend
|
||||
.map(|b| serde_json::to_string(&b))
|
||||
.transpose()
|
||||
.map_err(|e| format!("Failed to serialize backend type: {}", e))?;
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET backend_type = ?1 WHERE id = 1",
|
||||
params![backend_json],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set backend type: {}", e))?;
|
||||
|
||||
// When switching to ALSA, ensure alsa_plugin has a value (default to Hw)
|
||||
if backend == Some(AudioBackendType::Alsa) {
|
||||
let current_plugin: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT alsa_plugin FROM audio_settings WHERE id = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("Failed to check alsa_plugin: {}", e))?;
|
||||
|
||||
if current_plugin.is_none() {
|
||||
log::info!("[AudioSettings] ALSA backend selected with no plugin set, defaulting to Hw");
|
||||
self.set_alsa_plugin(Some(AlsaPlugin::Hw))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_alsa_plugin(&self, plugin: Option<AlsaPlugin>) -> Result<(), String> {
|
||||
let plugin_json = plugin
|
||||
.map(|p| serde_json::to_string(&p))
|
||||
.transpose()
|
||||
.map_err(|e| format!("Failed to serialize ALSA plugin: {}", e))?;
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET alsa_plugin = ?1 WHERE id = 1",
|
||||
params![plugin_json],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set ALSA plugin: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_alsa_hardware_volume(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET alsa_hardware_volume = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set ALSA hardware volume: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_stream_first_track(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET stream_first_track = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set stream first track: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_stream_buffer_seconds(&self, seconds: u8) -> Result<(), String> {
|
||||
// Clamp to valid range 1-10
|
||||
let clamped = seconds.clamp(1, 10);
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET stream_buffer_seconds = ?1 WHERE id = 1",
|
||||
params![clamped as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set stream buffer seconds: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_streaming_only(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET streaming_only = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set streaming only: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_limit_quality_to_device(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET limit_quality_to_device = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set limit quality to device: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_device_max_sample_rate(&self, rate: Option<u32>) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET device_max_sample_rate = ?1 WHERE id = 1",
|
||||
params![rate.map(|r| r as i64)],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set device max sample rate: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the sample rate limit for a specific device
|
||||
/// If rate is None, removes the limit for that device
|
||||
pub fn set_device_sample_rate_limit(
|
||||
&self,
|
||||
device_id: &str,
|
||||
rate: Option<u32>,
|
||||
) -> Result<(), String> {
|
||||
// Get current limits
|
||||
let mut limits = self.get_device_sample_rate_limits()?;
|
||||
|
||||
// Update or remove the limit for this device
|
||||
if let Some(r) = rate {
|
||||
limits.insert(device_id.to_string(), r);
|
||||
} else {
|
||||
limits.remove(device_id);
|
||||
}
|
||||
|
||||
// Serialize and save
|
||||
let json = serde_json::to_string(&limits)
|
||||
.map_err(|e| format!("Failed to serialize device sample rate limits: {}", e))?;
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET device_sample_rate_limits = ?1 WHERE id = 1",
|
||||
params![json],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set device sample rate limits: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the sample rate limit for a specific device
|
||||
/// Returns None if no limit is set for this device
|
||||
pub fn get_device_sample_rate_limit(&self, device_id: &str) -> Result<Option<u32>, String> {
|
||||
let limits = self.get_device_sample_rate_limits()?;
|
||||
Ok(limits.get(device_id).copied())
|
||||
}
|
||||
|
||||
/// Get all device sample rate limits
|
||||
fn get_device_sample_rate_limits(&self) -> Result<HashMap<String, u32>, String> {
|
||||
let json: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT device_sample_rate_limits FROM audio_settings WHERE id = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("Failed to get device sample rate limits: {}", e))?;
|
||||
|
||||
match json {
|
||||
Some(s) if !s.is_empty() => serde_json::from_str(&s)
|
||||
.map_err(|e| format!("Failed to parse device sample rate limits: {}", e)),
|
||||
_ => Ok(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_normalization_enabled(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET normalization_enabled = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set normalization enabled: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_gapless_enabled(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET gapless_enabled = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set gapless enabled: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_allow_quality_fallback(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET allow_quality_fallback = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set allow_quality_fallback: {}", e))?;
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Persist the `reserve_dac_while_running` flag (Lifetime B from the
|
||||
/// ALSA exclusive-hardening design spec). Toggling this only updates
|
||||
/// the DB row; applying the change to the live `DeviceReservation`
|
||||
/// guard is the caller's responsibility — see
|
||||
/// `AppState::apply_dac_reservation`.
|
||||
pub fn set_reserve_dac_while_running(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET reserve_dac_while_running = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set reserve_dac_while_running: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_pw_force_bitperfect(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET pw_force_bitperfect = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set pw_force_bitperfect: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_sync_audio_on_startup(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET sync_audio_on_startup = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set sync_audio_on_startup: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_quality_fallback_behavior(&self) -> Result<String, String> {
|
||||
let settings = self.get_settings()?;
|
||||
let value = &settings.quality_fallback_behavior;
|
||||
match value.as_str() {
|
||||
"ask" | "always_fallback" | "always_skip" => Ok(value.clone()),
|
||||
_ => Ok("ask".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_quality_fallback_behavior(&self, behavior: &str) -> Result<(), String> {
|
||||
match behavior {
|
||||
"ask" | "always_fallback" | "always_skip" => {}
|
||||
_ => return Err(format!("Invalid quality_fallback_behavior: {}", behavior)),
|
||||
}
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET quality_fallback_behavior = ?1 WHERE id = 1",
|
||||
params![behavior],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set quality_fallback_behavior: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_normalization_target_lufs(&self, target: f32) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET normalization_target_lufs = ?1 WHERE id = 1",
|
||||
params![target as f64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set normalization target LUFS: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset all audio settings to their default values
|
||||
pub fn reset_all(&self) -> Result<AudioSettings, String> {
|
||||
// ADR-003: quality_fallback_behavior must survive reset_all()
|
||||
let saved_fallback = self
|
||||
.get_quality_fallback_behavior()
|
||||
.unwrap_or_else(|_| "ask".to_string());
|
||||
|
||||
let defaults = AudioSettings::default();
|
||||
let backend_json: Option<String> = defaults
|
||||
.backend_type
|
||||
.map(|b| serde_json::to_string(&b))
|
||||
.transpose()
|
||||
.map_err(|e| format!("Failed to serialize backend type: {}", e))?;
|
||||
let plugin_json: Option<String> = defaults
|
||||
.alsa_plugin
|
||||
.map(|p| serde_json::to_string(&p))
|
||||
.transpose()
|
||||
.map_err(|e| format!("Failed to serialize ALSA plugin: {}", e))?;
|
||||
|
||||
// Serialize per-device limits (empty on reset)
|
||||
let limits_json = serde_json::to_string(&defaults.device_sample_rate_limits)
|
||||
.map_err(|e| format!("Failed to serialize device sample rate limits: {}", e))?;
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET
|
||||
output_device = ?1,
|
||||
exclusive_mode = ?2,
|
||||
dac_passthrough = ?3,
|
||||
preferred_sample_rate = ?4,
|
||||
backend_type = ?5,
|
||||
alsa_plugin = ?6,
|
||||
alsa_hardware_volume = ?7,
|
||||
stream_first_track = ?8,
|
||||
stream_buffer_seconds = ?9,
|
||||
streaming_only = ?10,
|
||||
limit_quality_to_device = ?11,
|
||||
device_max_sample_rate = ?12,
|
||||
normalization_enabled = ?13,
|
||||
normalization_target_lufs = ?14,
|
||||
gapless_enabled = ?15,
|
||||
device_sample_rate_limits = ?16,
|
||||
pw_force_bitperfect = ?17,
|
||||
sync_audio_on_startup = ?18,
|
||||
skip_sink_switch = ?19,
|
||||
allow_quality_fallback = ?20,
|
||||
reserve_dac_while_running = ?21
|
||||
WHERE id = 1",
|
||||
params![
|
||||
defaults.output_device,
|
||||
defaults.exclusive_mode as i64,
|
||||
defaults.dac_passthrough as i64,
|
||||
defaults.preferred_sample_rate.map(|r| r as i64),
|
||||
backend_json,
|
||||
plugin_json,
|
||||
defaults.alsa_hardware_volume as i64,
|
||||
defaults.stream_first_track as i64,
|
||||
defaults.stream_buffer_seconds as i64,
|
||||
defaults.streaming_only as i64,
|
||||
defaults.limit_quality_to_device as i64,
|
||||
defaults.device_max_sample_rate.map(|r| r as i64),
|
||||
defaults.normalization_enabled as i64,
|
||||
defaults.normalization_target_lufs as f64,
|
||||
defaults.gapless_enabled as i64,
|
||||
limits_json,
|
||||
defaults.pw_force_bitperfect as i64,
|
||||
defaults.sync_audio_on_startup as i64,
|
||||
defaults.skip_sink_switch as i64,
|
||||
defaults.allow_quality_fallback as i64,
|
||||
defaults.reserve_dac_while_running as i64,
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to reset audio settings: {}", e))?;
|
||||
|
||||
// ADR-003: restore quality_fallback_behavior after reset (it is not an audio config)
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET quality_fallback_behavior = ?1 WHERE id = 1",
|
||||
params![saved_fallback],
|
||||
)
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to restore quality_fallback_behavior after reset: {}",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut result = defaults;
|
||||
result.quality_fallback_behavior = saved_fallback;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe wrapper
|
||||
pub struct AudioSettingsState {
|
||||
pub store: Arc<Mutex<Option<AudioSettingsStore>>>,
|
||||
}
|
||||
|
||||
impl AudioSettingsState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
store: Arc::new(Mutex::new(Some(AudioSettingsStore::new()?))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_empty() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
|
||||
let new_store = AudioSettingsStore::new_at(base_dir)?;
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock audio settings store".to_string())?;
|
||||
*guard = Some(new_store);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn teardown(&self) -> Result<(), String> {
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock audio settings store".to_string())?;
|
||||
*guard = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub use qbz_audio::settings::{AudioSettings, AudioSettingsState, AudioSettingsStore};
|
||||
|
||||
// Tauri commands
|
||||
#[tauri::command]
|
||||
@@ -1014,7 +270,9 @@ pub fn set_audio_skip_sink_switch(
|
||||
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());
|
||||
return Err(
|
||||
"Cannot enable skip sink switch while DAC passthrough is active".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user