Merge pre-release into slint-mvp (1.2.14 backend catch-up)

One-way backend catch-up bringing the 1.2.14 maintenance work into
slint-mvp: audio (#470/#449/#466), player/offline/prefetch (#467),
qobuz retry/backoff, credential-store + image-cache refactor, dep
bumps (cpal 0.17.3 / alsa 0.11 / tokio / ashpd / tower-http), and the
qbzd daemon removal.

Conflicts resolved:
- qbz-audio settings.rs: keep slint-mvp stream_first_track/buffer
  defaults (1/2, opt-out); adopt #470's CREATE-TABLE seed removal
  (row is seeded with backend_type below).
- crates/Cargo.toml: keep qbz-slint workspace member (qbzd removed
  upstream).
- crates/Cargo.lock: regenerated (no ratatui/qbzd; cpal/alsa bumped).

cargo check --workspace green.

cc
This commit is contained in:
vicrodh committed 2026-05-25 15:12:58 -06:00
commit 022b3e46e6
94 files changed
+1752 -9920

No files matched your search

+28 -10
View File
@@ -12,6 +12,24 @@ use std::sync::atomic::AtomicBool;
#[cfg(target_os = "linux")]
use std::sync::{Arc, Mutex};
/// Log a PCM recovery and record it as a network-throttle underrun signal.
///
/// Each call to ALSA's `pcm.recover()` that returns successfully indicates
/// that the writer thread fell behind the kernel's playback buffer — i.e.
/// an audio underrun. The network throttle treats this as the strongest
/// possible "slow down" signal and immediately drops the prefetch cap to
/// zero for `PANIC_WINDOW_SECS`, so the live stream gets the full pipe to
/// recover.
#[cfg(target_os = "linux")]
fn log_pcm_recovery(suffix: &str) {
if suffix.is_empty() {
log::warn!("[ALSA Direct] Recovered from PCM error");
} else {
log::warn!("[ALSA Direct] Recovered from PCM error {}", suffix);
}
crate::network_throttle::state().record_underrun();
}
/// Direct ALSA PCM stream for hw: devices
///
/// Field order is significant: Rust drops struct fields top-to-bottom, so the
@@ -223,7 +241,7 @@ impl AlsaDirectStream {
if let Err(recover_err) = pcm.recover(e.errno() as i32, false) {
Err(format!("Failed to recover from error: {}", recover_err))
} else {
log::warn!("[ALSA Direct] Recovered from PCM error");
log_pcm_recovery("");
Ok(())
}
}
@@ -252,7 +270,7 @@ impl AlsaDirectStream {
if let Err(recover_err) = pcm.recover(e.errno() as i32, false) {
Err(format!("Failed to recover from error: {}", recover_err))
} else {
log::warn!("[ALSA Direct] Recovered from PCM error");
log_pcm_recovery("");
Ok(())
}
}
@@ -279,7 +297,7 @@ impl AlsaDirectStream {
if let Err(recover_err) = pcm.recover(e.errno() as i32, false) {
Err(format!("Failed to recover from error: {}", recover_err))
} else {
log::warn!("[ALSA Direct] Recovered from PCM error");
log_pcm_recovery("");
Ok(())
}
}
@@ -318,7 +336,7 @@ impl AlsaDirectStream {
if let Err(recover_err) = pcm.recover(e.errno() as i32, false) {
Err(format!("Failed to recover from error: {}", recover_err))
} else {
log::warn!("[ALSA Direct] Recovered from PCM error (S24_3LE)");
log_pcm_recovery("(S24_3LE)");
Ok(())
}
}
@@ -348,7 +366,7 @@ impl AlsaDirectStream {
if let Err(recover_err) = pcm.recover(e.errno() as i32, false) {
Err(format!("Failed to recover from error: {}", recover_err))
} else {
log::warn!("[ALSA Direct] Recovered from PCM error");
log_pcm_recovery("");
Ok(())
}
}
@@ -388,7 +406,7 @@ impl AlsaDirectStream {
if let Err(recover_err) = pcm.recover(e.errno() as i32, false) {
Err(format!("Failed to recover from error: {}", recover_err))
} else {
log::warn!("[ALSA Direct] Recovered from PCM error");
log_pcm_recovery("");
Ok(())
}
}
@@ -420,7 +438,7 @@ impl AlsaDirectStream {
if let Err(recover_err) = pcm.recover(e.errno() as i32, false) {
Err(format!("Failed to recover from error: {}", recover_err))
} else {
log::warn!("[ALSA Direct] Recovered from PCM error");
log_pcm_recovery("");
Ok(())
}
}
@@ -456,7 +474,7 @@ impl AlsaDirectStream {
if let Err(recover_err) = pcm.recover(e.errno() as i32, false) {
Err(format!("Failed to recover from error: {}", recover_err))
} else {
log::warn!("[ALSA Direct] Recovered from PCM error");
log_pcm_recovery("");
Ok(())
}
}
@@ -493,7 +511,7 @@ impl AlsaDirectStream {
if let Err(recover_err) = pcm.recover(e.errno() as i32, false) {
Err(format!("Failed to recover from error: {}", recover_err))
} else {
log::warn!("[ALSA Direct] Recovered from PCM error (S24_3LE)");
log_pcm_recovery("(S24_3LE)");
Ok(())
}
}
@@ -523,7 +541,7 @@ impl AlsaDirectStream {
if let Err(recover_err) = pcm.recover(e.errno() as i32, false) {
Err(format!("Failed to recover from error: {}", recover_err))
} else {
log::warn!("[ALSA Direct] Recovered from PCM error");
log_pcm_recovery("");
Ok(())
}
}
+58 -22
View File
@@ -4,7 +4,8 @@
//! allowing users to choose their preferred audio stack.
use rodio::MixerDeviceSink;
#[cfg(not(target_os = "linux"))]
// CPAL traits + DeviceSinkBuilder are used by the cross-platform CpalDefaultBackend
// ("System") on every OS, including Linux where it opens the ALSA "default" PCM.
use rodio::{
cpal::traits::{DeviceTrait, HostTrait},
DeviceSinkBuilder,
@@ -40,11 +41,11 @@ pub enum AudioBackendType {
impl Default for AudioBackendType {
fn default() -> Self {
if cfg!(target_os = "linux") {
AudioBackendType::PipeWire
} else {
AudioBackendType::SystemDefault
}
// "System" everywhere: the OOTB default plays through the OS default
// output, shared with other apps (no bit-perfect, no `pactl`). Audiophile
// users opt into PipeWire / ALSA explicitly. This was PipeWire on Linux,
// which hard-required `pactl` and froze OOTB playback without it (#470).
AudioBackendType::SystemDefault
}
}
@@ -269,6 +270,10 @@ impl BackendManager {
#[cfg(target_os = "linux")]
{
// System default (always available): shared OS default output via the
// ALSA "default" PCM. The app-like OOTB choice; listed first.
backends.push(AudioBackendType::SystemDefault);
// PipeWire (check if running)
if Self::is_pipewire_available() {
backends.push(AudioBackendType::PipeWire);
@@ -314,14 +319,12 @@ impl BackendManager {
}
}
AudioBackendType::SystemDefault => {
#[cfg(not(target_os = "linux"))]
{
Ok(Box::new(CpalDefaultBackend::new()?))
}
#[cfg(target_os = "linux")]
{
Err("SystemDefault backend is not available on Linux; use PipeWire, ALSA, or Pulse".to_string())
}
// "System": play through the OS default output via CPAL's default
// host — CoreAudio/WASAPI off-Linux, the ALSA "default" PCM on Linux
// (routes to PipeWire/Pulse/dmix for shared mixing). Opens at the
// device's negotiated rate (rodio resamples); shared, no exclusivity,
// no `pactl`. Available on every platform.
Ok(Box::new(CpalDefaultBackend::new()?))
}
AudioBackendType::Alsa => {
#[cfg(target_os = "linux")]
@@ -350,9 +353,26 @@ impl BackendManager {
#[cfg(target_os = "linux")]
fn is_pipewire_available() -> bool {
// Check if PipeWire/PulseAudio is available using pactl
// PipeWire provides PulseAudio compatibility, so pactl works for both
// This is more reliable than pw-cli, especially in sandboxed environments (Flatpak)
// Detect PipeWire via its runtime socket ($XDG_RUNTIME_DIR/pipewire-0),
// which exists whenever PipeWire is running. Unlike `pactl`, this does
// NOT require pulseaudio-utils to be installed — PipeWire-only systems
// frequently lack it, which used to hide the PipeWire backend entirely
// (issue #466). pw-cli / pactl remain as fallbacks for unusual setups
// (e.g. a non-default socket name, or Flatpak where the socket path
// differs but the pulse shim is bridged).
if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") {
if std::path::Path::new(&runtime_dir).join("pipewire-0").exists() {
return true;
}
}
if std::process::Command::new("pw-cli")
.args(["info", "0"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
return true;
}
std::process::Command::new("pactl")
.arg("info")
.output()
@@ -371,14 +391,15 @@ impl BackendManager {
}
}
/// CPAL default backend for non-Linux platforms (macOS CoreAudio, Windows WASAPI).
/// Uses the system default audio device via CPAL without any platform-specific commands.
#[cfg(not(target_os = "linux"))]
/// CPAL default backend ("System"): plays through the OS default output via
/// CPAL's default host — CoreAudio (macOS), WASAPI (Windows), and the ALSA
/// "default" PCM (Linux, which routes to PipeWire/Pulse/dmix for shared mixing).
/// No platform-specific commands (no `pactl`); opens at the device's negotiated
/// rate with rodio resampling, so it mixes with other apps like any normal player.
pub struct CpalDefaultBackend {
host: rodio::cpal::Host,
}
#[cfg(not(target_os = "linux"))]
impl CpalDefaultBackend {
pub fn new() -> BackendResult<Self> {
Ok(Self {
@@ -387,7 +408,6 @@ impl CpalDefaultBackend {
}
}
#[cfg(not(target_os = "linux"))]
impl AudioBackend for CpalDefaultBackend {
fn backend_type(&self) -> AudioBackendType {
AudioBackendType::SystemDefault
@@ -515,6 +535,22 @@ impl AudioBackend for CpalDefaultBackend {
let builder = DeviceSinkBuilder::from_device(device)
.map_err(|e| format!("Failed to create device sink builder: {}", e))?;
// MixerDeviceSink has zero internal buffering, so CPAL's buffer is the
// ONLY buffer between the mixer and the hardware. With the bare CPAL/ALSA
// default (no explicit size) the stream can underrun immediately on Linux:
// the node links to the audio server but stays suspended and never feeds
// audio (#470 — "System" shared output silent). Give it ~100ms, matching
// the PipeWire/ALSA backends. We deliberately do NOT pin the sample rate
// (no with_supported_config): the device keeps its negotiated rate and
// rodio resamples, which is the whole point of shared "System" output.
#[cfg(target_os = "linux")]
let mixer_sink = builder
.with_buffer_size(rodio::cpal::BufferSize::Fixed(
(config.sample_rate / 10).clamp(1024, 19200),
))
.open_stream()
.map_err(|e| format!("Failed to create output stream: {}", e))?;
#[cfg(not(target_os = "linux"))]
let mixer_sink = builder
.open_stream()
.map_err(|e| format!("Failed to create output stream: {}", e))?;
+1
View File
@@ -45,6 +45,7 @@ pub mod dynamic_amplify;
pub mod loudness;
pub mod loudness_analyzer;
pub mod loudness_cache;
pub mod network_throttle;
pub mod output_sinks;
pub mod settings;
pub mod visualizer;
+264
View File
@@ -0,0 +1,264 @@
//! Adaptive prefetch throttling based on observed network conditions.
//!
//! Default QBZ prefetches up to 5 tracks concurrently (≈30 simultaneous
//! HTTP/2 streams once the CMAF init + segment fan-out is counted). That
//! aggressive concurrency is exactly what makes Hi-Res playback smooth on
//! a fast connection, and we don't want to dial it down for everyone just
//! because some users have a slow link.
//!
//! Instead, this module watches two real-time signals — measured per-segment
//! bandwidth and audio underruns — and computes a *dynamic* cap that the
//! prefetch dispatcher consults. When conditions are good the cap is the
//! full memory-profile default (5 on Normal-class hosts, 1 on LowMemory).
//! When the live stream starts losing the race against the consumer, the
//! cap collapses to 0 and the prefetch fan-out gets out of the way.
//!
//! Recovery follows TCP-style slow-start logic: after `PANIC_WINDOW_SECS`
//! of no fresh underruns the cap walks back up one level at a time,
//! re-validated by each new bandwidth sample.
use std::sync::OnceLock;
use std::sync::RwLock;
use std::time::Instant;
/// EMA smoothing factor for observed bandwidth. Higher = more reactive to
/// the latest sample, lower = smoother but laggier. 0.4 is a compromise:
/// a single bad sample dents the estimate but doesn't dominate it.
const BANDWIDTH_EMA_ALPHA: f64 = 0.4;
/// How long we stay in panic mode after an audio underrun. The user just
/// experienced a glitch; we want the stream to recover with the entire
/// pipe to itself for a meaningful window before letting prefetch back in.
const PANIC_WINDOW_SECS: u64 = 30;
/// Bandwidth-to-playback ratio thresholds for the throttle levels.
///
/// - At or below `SURVIVING_RATIO`: no prefetch at all. The live stream
/// barely has bandwidth to keep itself fed.
/// - At or below `CAUTIOUS_RATIO`: only one prefetch track in flight.
/// - At or below `RELAXED_RATIO`: two prefetch tracks.
/// - Above `RELAXED_RATIO`: full memory-profile default.
const SURVIVING_RATIO: f64 = 1.5;
const CAUTIOUS_RATIO: f64 = 2.5;
const RELAXED_RATIO: f64 = 4.0;
/// Approximate sustained bandwidth required for live playback by quality
/// tier, in MB/s. These numbers are for CMAF/FLAC compressed streams;
/// they're inputs to the ratio comparison above, not hard limits.
pub fn playback_mbps_for_quality(quality_tag: PlaybackQualityTag) -> f64 {
match quality_tag {
PlaybackQualityTag::UltraHiRes => 2.5, // 24-bit / 192 kHz FLAC
PlaybackQualityTag::HiRes => 1.4, // 24-bit / 96 kHz FLAC
PlaybackQualityTag::Lossless => 0.5, // 16-bit / 44.1 kHz FLAC
PlaybackQualityTag::Lossy => 0.04, // 320 kbps MP3
}
}
/// Small enum that mirrors `qbz-models::Quality` without taking a runtime
/// dependency on it. The callsite translates its own quality enum into one
/// of these four buckets before consulting the throttle.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlaybackQualityTag {
UltraHiRes,
HiRes,
Lossless,
Lossy,
}
#[derive(Debug, Default)]
struct ThrottleInner {
/// Exponential moving average of observed segment-download bandwidth
/// in MB/s. `None` until the first sample arrives.
bandwidth_ema_mbps: Option<f64>,
/// Timestamp of the most recent audio underrun. `None` means we have
/// not observed one this session.
last_underrun: Option<Instant>,
/// Timestamp of the most recent successful segment download. `None`
/// until the first segment lands. Used as a positive liveness signal
/// for the offline detector (issue #467).
last_successful_download: Option<Instant>,
}
pub struct ThrottleState {
inner: RwLock<ThrottleInner>,
}
static GLOBAL: OnceLock<ThrottleState> = OnceLock::new();
/// Singleton accessor. Lazily initialized on first call.
pub fn state() -> &'static ThrottleState {
GLOBAL.get_or_init(|| ThrottleState {
inner: RwLock::new(ThrottleInner::default()),
})
}
impl ThrottleState {
/// Feed a fresh per-segment bandwidth measurement (MB/s). Called from
/// the CMAF streaming loop every few segments.
pub fn record_segment_bandwidth(&self, mbps: f64) {
if let Ok(mut inner) = self.inner.write() {
// Liveness signal (issue #467): reaching this path means a segment
// batch just downloaded — bytes are flowing, so we are online,
// independent of whether the throughput sample is usable for the
// EMA. Recorded before the validity guard on purpose.
inner.last_successful_download = Some(Instant::now());
if mbps.is_finite() && mbps > 0.0 {
inner.bandwidth_ema_mbps = Some(match inner.bandwidth_ema_mbps {
Some(prev) => prev * (1.0 - BANDWIDTH_EMA_ALPHA) + mbps * BANDWIDTH_EMA_ALPHA,
None => mbps,
});
}
}
}
/// Signal that an audio buffer underrun just happened. Forces the
/// throttle into panic mode for `PANIC_WINDOW_SECS`.
pub fn record_underrun(&self) {
if let Ok(mut inner) = self.inner.write() {
inner.last_underrun = Some(Instant::now());
}
}
/// Current EMA bandwidth in MB/s, or `None` if no samples yet.
pub fn current_bandwidth_mbps(&self) -> Option<f64> {
self.inner.read().ok().and_then(|i| i.bandwidth_ema_mbps)
}
/// Seconds since the last successful segment download, or `None` if no
/// bytes have been pulled this session. Positive liveness signal for the
/// offline detector (issue #467): if segments are flowing we are online
/// by definition, regardless of what a connectivity probe reports.
pub fn seconds_since_download(&self) -> Option<u64> {
self.inner
.read()
.ok()
.and_then(|i| i.last_successful_download)
.map(|t| t.elapsed().as_secs())
}
/// True when an underrun was recorded within `PANIC_WINDOW_SECS`.
pub fn in_panic_mode(&self) -> bool {
let inner = match self.inner.read() {
Ok(g) => g,
Err(_) => return false,
};
match inner.last_underrun {
Some(t) => t.elapsed().as_secs() < PANIC_WINDOW_SECS,
None => false,
}
}
/// Decide the prefetch cap given the current track's playback rate and
/// the memory-profile default. The cap is always clamped to `[0, default_cap]`
/// — we never *raise* prefetch above the memory profile, only restrict.
pub fn current_prefetch_cap(&self, playback_mbps: f64, default_cap: usize) -> usize {
if self.in_panic_mode() {
return 0;
}
let bw = match self.current_bandwidth_mbps() {
Some(v) => v,
// No samples yet — trust the memory profile default. The first
// segment of the first track will land within a few seconds and
// give us real numbers.
None => return default_cap,
};
let ratio = if playback_mbps > 0.0 {
bw / playback_mbps
} else {
f64::INFINITY
};
if ratio <= SURVIVING_RATIO {
0
} else if ratio <= CAUTIOUS_RATIO {
1.min(default_cap)
} else if ratio <= RELAXED_RATIO {
2.min(default_cap)
} else {
default_cap
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fresh_state_returns_default_cap() {
let s = ThrottleState {
inner: RwLock::new(ThrottleInner::default()),
};
assert_eq!(s.current_prefetch_cap(2.5, 5), 5);
}
#[test]
fn panic_mode_zeros_cap() {
let s = ThrottleState {
inner: RwLock::new(ThrottleInner::default()),
};
s.record_underrun();
assert_eq!(s.current_prefetch_cap(2.5, 5), 0);
}
#[test]
fn surviving_ratio_zeros_prefetch() {
let s = ThrottleState {
inner: RwLock::new(ThrottleInner::default()),
};
// bw = 3.0, playback = 2.5 → ratio = 1.2 (< 1.5)
s.record_segment_bandwidth(3.0);
assert_eq!(s.current_prefetch_cap(2.5, 5), 0);
}
#[test]
fn cautious_ratio_allows_one() {
let s = ThrottleState {
inner: RwLock::new(ThrottleInner::default()),
};
// bw = 5.0, playback = 2.5 → ratio = 2.0 (between 1.5 and 2.5)
s.record_segment_bandwidth(5.0);
assert_eq!(s.current_prefetch_cap(2.5, 5), 1);
}
#[test]
fn relaxed_ratio_allows_two() {
let s = ThrottleState {
inner: RwLock::new(ThrottleInner::default()),
};
// bw = 8.0, playback = 2.5 → ratio = 3.2 (between 2.5 and 4.0)
s.record_segment_bandwidth(8.0);
assert_eq!(s.current_prefetch_cap(2.5, 5), 2);
}
#[test]
fn abundant_bandwidth_unlocks_default() {
let s = ThrottleState {
inner: RwLock::new(ThrottleInner::default()),
};
// bw = 20.0, playback = 2.5 → ratio = 8.0 (well above 4.0)
s.record_segment_bandwidth(20.0);
assert_eq!(s.current_prefetch_cap(2.5, 5), 5);
}
#[test]
fn cap_never_exceeds_default() {
let s = ThrottleState {
inner: RwLock::new(ThrottleInner::default()),
};
s.record_segment_bandwidth(20.0);
// Memory profile says 1 — never raise above.
assert_eq!(s.current_prefetch_cap(2.5, 1), 1);
}
#[test]
fn ema_smooths_spikes() {
let s = ThrottleState {
inner: RwLock::new(ThrottleInner::default()),
};
s.record_segment_bandwidth(10.0);
s.record_segment_bandwidth(1.0);
// EMA: 10 * 0.6 + 1 * 0.4 = 6.4
let bw = s.current_bandwidth_mbps().unwrap();
assert!((bw - 6.4).abs() < 0.01);
}
}
+40 -38
View File
@@ -80,14 +80,17 @@ impl Default for AudioSettings {
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.
// OOTB default is "System" (Some(SystemDefault)): play through the OS
// default output, shared with other apps like any normal player — no
// bit-perfect, no `pactl`. See AudioBackendType::default(). "Auto"
// (None) and explicit backends (PipeWire / ALSA) are honored as-is;
// this only sets what a fresh install and the Reset action land on.
//
// History: this defaulted to Some(PipeWire) on Linux to dodge a rodio
// DeviceSink-drop-on-resume race on the CPAL path (#375), but that
// hard-required `pactl` and froze OOTB playback without it (#470). The
// #375 race is covered by the cpal 0.17.3 / alsa 0.11 stream-drop
// fixes, and "System" is the app-like default audiophiles override.
backend_type: Some(AudioBackendType::default()),
alsa_plugin: Some(AlsaPlugin::Hw), // Default to hw (bit-perfect)
alsa_hardware_volume: false, // Disabled by default (maximum compatibility)
@@ -138,9 +141,7 @@ impl AudioSettingsStore {
alsa_hardware_volume INTEGER NOT NULL DEFAULT 0,
stream_first_track INTEGER NOT NULL DEFAULT 1,
stream_buffer_seconds INTEGER NOT NULL DEFAULT 2
);
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))?;
@@ -215,29 +216,23 @@ impl AudioSettingsStore {
[],
);
// 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.
// Seed the single settings row on first run with the OOTB default backend
// ("System"). INSERT OR IGNORE is a one-time seed: it only fires when the
// row does not exist yet, so existing installs are never rewritten on
// later launches (settings are only reset by the explicit Reset action,
// never just by restarting).
//
// There is deliberately NO backfill of existing NULL backend_type rows: a
// NULL backend_type means "Auto" and is preserved as-is. (An earlier #375
// workaround backfilled NULL -> PipeWire on every open, which hard-required
// `pactl` and froze OOTB playback without it, #470.)
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",
.map_err(|e| format!("Failed to serialize default backend: {}", e))?;
conn.execute(
"INSERT OR IGNORE INTO audio_settings (id, exclusive_mode, dac_passthrough, backend_type) VALUES (1, 0, 0, ?1)",
params![default_backend_json],
);
)
.map_err(|e| format!("Failed to seed audio settings row: {}", e))?;
Ok(Self { conn })
}
@@ -781,7 +776,8 @@ mod tests {
fn audio_settings_default_values_are_stable() {
let settings = AudioSettings::default();
assert_eq!(settings.backend_type, Some(AudioBackendType::default()));
// OOTB default is "System" — the OS default output (#470).
assert_eq!(settings.backend_type, Some(AudioBackendType::SystemDefault));
assert_eq!(settings.alsa_plugin, Some(AlsaPlugin::Hw));
assert!(settings.gapless_enabled);
assert!(!settings.sync_audio_on_startup);
@@ -797,7 +793,8 @@ mod tests {
let settings = store.get_settings().expect("get settings");
assert_eq!(settings.backend_type, Some(AudioBackendType::default()));
// Fresh store is seeded with the OOTB default backend "System" (#470).
assert_eq!(settings.backend_type, Some(AudioBackendType::SystemDefault));
assert_eq!(settings.alsa_plugin, None);
assert!(!settings.gapless_enabled);
assert_eq!(settings.quality_fallback_behavior, "ask");
@@ -806,8 +803,13 @@ mod tests {
}
#[test]
fn backend_null_is_backfilled_to_default_backend() {
let dir = unique_test_dir("backend-backfill");
fn backend_null_stays_auto_on_reopen() {
// A NULL backend_type means "Auto" (system default output). It must be
// preserved across restarts — never backfilled to a concrete backend on
// store open (the old #375 backfill hard-coded PipeWire and froze OOTB
// playback on hosts without `pactl`, #470). Only the explicit Reset
// action rewrites settings.
let dir = unique_test_dir("backend-null-auto");
{
let store = AudioSettingsStore::new_at(&dir).expect("open store");
store
@@ -816,13 +818,13 @@ mod tests {
"UPDATE audio_settings SET backend_type = NULL WHERE id = 1",
[],
)
.expect("force legacy null backend");
.expect("force null (Auto) 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()));
assert_eq!(settings.backend_type, None);
let _ = std::fs::remove_dir_all(dir);
}