fix(audio): default to shared System output and resolve Auto to a concrete backend (#470)

OOTB playback froze (seekbar advancing, no audio) on hosts where the
configured backend was not usable — e.g. PipeWire as the Linux default but
`pactl` absent (minimal Debian/Ubuntu). "Auto" (a null backend_type) fell
through to a legacy CPAL path that forced the track rate onto the shared
default device, producing a suspended/silent stream and leaving a
process-wide stuck audio handle that survived Reset.

- Add a cross-platform "System" backend (CpalDefaultBackend now on Linux
  too): opens the OS default output at its negotiated rate with rodio
  resampling and an explicit ~100ms CPAL buffer (MixerDeviceSink has zero
  internal buffering, so the bare default underran and stayed suspended).
  Shared, no exclusivity, no `pactl` — mixes with other apps like any
  normal player.
- Make System the OOTB default and what Reset lands on; seed it on first
  run. Drop the per-startup #375 NULL->PipeWire backfill migration.
- Resolve a null backend_type to System in the player so the legacy CPAL
  path is never reached (covers legacy installs and headless callers).
- "Auto" in Settings is now a resolve-and-set action: it picks the best
  available backend (PipeWire if available, else System), stores that
  concrete value and updates the dropdown + device picker, so backend_type
  is never null.
- Explicit PipeWire (DAC passthrough) and ALSA Direct (exclusive) configs
  are untouched and remain bit-perfect.

cc
This commit is contained in:
vicrodh committed 2026-05-24 21:13:11 -06:00
1 parent bf56eefd7a
commit a0051ab513
10 files changed
+111 -75

No files matched your search

+38 -19
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")]
@@ -388,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 {
@@ -404,7 +408,6 @@ impl CpalDefaultBackend {
}
}
#[cfg(not(target_os = "linux"))]
impl AudioBackend for CpalDefaultBackend {
fn backend_type(&self) -> AudioBackendType {
AudioBackendType::SystemDefault
@@ -532,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))?;
+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 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))?;
@@ -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);
}