Merge refactor/qbz-audio-settings-foundation: audio settings store

This commit is contained in:
vicrodh committed 2026-05-15 23:19:10 -06:00
commit 1fe0ac3568
3 files changed
+408 -777

No files matched your search

+402 -9
View File
@@ -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);
}
}
+1 -21
View File
@@ -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.
+5 -747
View File
@@ -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(),
);
}
}