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. //! allowing users to choose their preferred audio stack.
use rodio::MixerDeviceSink; 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::{ use rodio::{
cpal::traits::{DeviceTrait, HostTrait}, cpal::traits::{DeviceTrait, HostTrait},
DeviceSinkBuilder, DeviceSinkBuilder,
@@ -40,11 +41,11 @@ pub enum AudioBackendType {
impl Default for AudioBackendType { impl Default for AudioBackendType {
fn default() -> Self { fn default() -> Self {
if cfg!(target_os = "linux") { // "System" everywhere: the OOTB default plays through the OS default
AudioBackendType::PipeWire // output, shared with other apps (no bit-perfect, no `pactl`). Audiophile
} else { // users opt into PipeWire / ALSA explicitly. This was PipeWire on Linux,
AudioBackendType::SystemDefault // which hard-required `pactl` and froze OOTB playback without it (#470).
} AudioBackendType::SystemDefault
} }
} }
@@ -269,6 +270,10 @@ impl BackendManager {
#[cfg(target_os = "linux")] #[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) // PipeWire (check if running)
if Self::is_pipewire_available() { if Self::is_pipewire_available() {
backends.push(AudioBackendType::PipeWire); backends.push(AudioBackendType::PipeWire);
@@ -314,14 +319,12 @@ impl BackendManager {
} }
} }
AudioBackendType::SystemDefault => { AudioBackendType::SystemDefault => {
#[cfg(not(target_os = "linux"))] // "System": play through the OS default output via CPAL's default
{ // host — CoreAudio/WASAPI off-Linux, the ALSA "default" PCM on Linux
Ok(Box::new(CpalDefaultBackend::new()?)) // (routes to PipeWire/Pulse/dmix for shared mixing). Opens at the
} // device's negotiated rate (rodio resamples); shared, no exclusivity,
#[cfg(target_os = "linux")] // no `pactl`. Available on every platform.
{ Ok(Box::new(CpalDefaultBackend::new()?))
Err("SystemDefault backend is not available on Linux; use PipeWire, ALSA, or Pulse".to_string())
}
} }
AudioBackendType::Alsa => { AudioBackendType::Alsa => {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -388,14 +391,15 @@ impl BackendManager {
} }
} }
/// CPAL default backend for non-Linux platforms (macOS CoreAudio, Windows WASAPI). /// CPAL default backend ("System"): plays through the OS default output via
/// Uses the system default audio device via CPAL without any platform-specific commands. /// CPAL's default host — CoreAudio (macOS), WASAPI (Windows), and the ALSA
#[cfg(not(target_os = "linux"))] /// "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 { pub struct CpalDefaultBackend {
host: rodio::cpal::Host, host: rodio::cpal::Host,
} }
#[cfg(not(target_os = "linux"))]
impl CpalDefaultBackend { impl CpalDefaultBackend {
pub fn new() -> BackendResult<Self> { pub fn new() -> BackendResult<Self> {
Ok(Self { Ok(Self {
@@ -404,7 +408,6 @@ impl CpalDefaultBackend {
} }
} }
#[cfg(not(target_os = "linux"))]
impl AudioBackend for CpalDefaultBackend { impl AudioBackend for CpalDefaultBackend {
fn backend_type(&self) -> AudioBackendType { fn backend_type(&self) -> AudioBackendType {
AudioBackendType::SystemDefault AudioBackendType::SystemDefault
@@ -532,6 +535,22 @@ impl AudioBackend for CpalDefaultBackend {
let builder = DeviceSinkBuilder::from_device(device) let builder = DeviceSinkBuilder::from_device(device)
.map_err(|e| format!("Failed to create device sink builder: {}", e))?; .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 let mixer_sink = builder
.open_stream() .open_stream()
.map_err(|e| format!("Failed to create output stream: {}", e))?; .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, exclusive_mode: false,
dac_passthrough: false, dac_passthrough: false,
preferred_sample_rate: None, preferred_sample_rate: None,
// Default to a per-OS-correct backend (PipeWire on Linux, SystemDefault // OOTB default is "System" (Some(SystemDefault)): play through the OS
// elsewhere) instead of None. None used to mean "auto-detect" but it // default output, shared with other apps like any normal player — no
// actually fell through to the legacy CPAL path in init_device, which // bit-perfect, no `pactl`. See AudioBackendType::default(). "Auto"
// has a known race against rodio's DeviceSink drop on resume — visible // (None) and explicit backends (PipeWire / ALSA) are honored as-is;
// as "audio doesn't come back after pause" (#375). Picking a concrete // this only sets what a fresh install and the Reset action land on.
// backend here puts new installs on the proper backend init path; the //
// runtime still degrades gracefully if the chosen backend isn't // History: this defaulted to Some(PipeWire) on Linux to dodge a rodio
// available on the host. // 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()), backend_type: Some(AudioBackendType::default()),
alsa_plugin: Some(AlsaPlugin::Hw), // Default to hw (bit-perfect) alsa_plugin: Some(AlsaPlugin::Hw), // Default to hw (bit-perfect)
alsa_hardware_volume: false, // Disabled by default (maximum compatibility) alsa_hardware_volume: false, // Disabled by default (maximum compatibility)
@@ -138,9 +141,7 @@ impl AudioSettingsStore {
alsa_hardware_volume INTEGER NOT NULL DEFAULT 0, alsa_hardware_volume INTEGER NOT NULL DEFAULT 0,
stream_first_track INTEGER NOT NULL DEFAULT 0, stream_first_track INTEGER NOT NULL DEFAULT 0,
stream_buffer_seconds INTEGER NOT NULL DEFAULT 3 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))?; .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 // Seed the single settings row on first run with the OOTB default backend
// the user never explicitly picked a backend. NULL fell through to the // ("System"). INSERT OR IGNORE is a one-time seed: it only fires when the
// legacy CPAL path in the player, which has a race against rodio's // row does not exist yet, so existing installs are never rewritten on
// DeviceSink drop on resume that produces silent streams after pause. // later launches (settings are only reset by the explicit Reset action,
// Backfill NULL rows with the per-OS default so those installs move onto // never just by restarting).
// 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 // There is deliberately NO backfill of existing NULL backend_type rows: a
// here for the row read path to round-trip cleanly. // 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()) let default_backend_json = serde_json::to_string(&AudioBackendType::default())
.unwrap_or_else(|_| { .map_err(|e| format!("Failed to serialize default backend: {}", e))?;
// Fallback only matters if serde_json::to_string fails for a conn.execute(
// unit-variant enum, which it can't — but keep a sane string in "INSERT OR IGNORE INTO audio_settings (id, exclusive_mode, dac_passthrough, backend_type) VALUES (1, 0, 0, ?1)",
// 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], params![default_backend_json],
); )
.map_err(|e| format!("Failed to seed audio settings row: {}", e))?;
Ok(Self { conn }) Ok(Self { conn })
} }
@@ -781,7 +776,8 @@ mod tests {
fn audio_settings_default_values_are_stable() { fn audio_settings_default_values_are_stable() {
let settings = AudioSettings::default(); 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_eq!(settings.alsa_plugin, Some(AlsaPlugin::Hw));
assert!(settings.gapless_enabled); assert!(settings.gapless_enabled);
assert!(!settings.sync_audio_on_startup); assert!(!settings.sync_audio_on_startup);
@@ -797,7 +793,8 @@ mod tests {
let settings = store.get_settings().expect("get settings"); 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_eq!(settings.alsa_plugin, None);
assert!(!settings.gapless_enabled); assert!(!settings.gapless_enabled);
assert_eq!(settings.quality_fallback_behavior, "ask"); assert_eq!(settings.quality_fallback_behavior, "ask");
@@ -806,8 +803,13 @@ mod tests {
} }
#[test] #[test]
fn backend_null_is_backfilled_to_default_backend() { fn backend_null_stays_auto_on_reopen() {
let dir = unique_test_dir("backend-backfill"); // 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"); let store = AudioSettingsStore::new_at(&dir).expect("open store");
store store
@@ -816,13 +818,13 @@ mod tests {
"UPDATE audio_settings SET backend_type = NULL WHERE id = 1", "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 reopened = AudioSettingsStore::new_at(&dir).expect("reopen store");
let settings = reopened.get_settings().expect("get settings"); 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); let _ = std::fs::remove_dir_all(dir);
} }
+10 -10
View File
@@ -759,16 +759,16 @@ fn try_init_stream_with_backend(
channels: u16, channels: u16,
state: &SharedState, state: &SharedState,
) -> Option<Result<StreamType, String>> { ) -> Option<Result<StreamType, String>> {
// Check if backend system is configured. // A None backend_type means "Auto" / unset. Resolve it to SystemDefault on
// On macOS, default to SystemDefault when not explicitly set, // every platform instead of returning None — returning None made the caller
// so CoreAudio device probing and sample rate switching are active. // fall through to the legacy CPAL path, which forced the track rate onto the
let backend_type = audio_settings.backend_type.or_else(|| { // shared default device: that froze the seekbar with no audio AND left a
if cfg!(target_os = "macos") { // process-wide stuck audio handle that survived Reset (#470). "Auto" is
Some(qbz_audio::AudioBackendType::SystemDefault) // resolved to a concrete backend in the UI; this is the backend-side safety
} else { // net for any remaining None (legacy installs, headless callers).
None let backend_type = audio_settings
} .backend_type
})?; .unwrap_or(qbz_audio::AudioBackendType::SystemDefault);
log::info!( log::info!(
"Using backend system: {:?} (device: {:?}, plugin: {:?})", "Using backend system: {:?} (device: {:?}, plugin: {:?})",
+1 -1
View File
@@ -246,7 +246,7 @@ pub fn v2_get_available_backends() -> Result<Vec<BackendInfo>, String> {
AudioBackendType::PipeWire => "PipeWire", AudioBackendType::PipeWire => "PipeWire",
AudioBackendType::Alsa => "ALSA Direct", AudioBackendType::Alsa => "ALSA Direct",
AudioBackendType::Pulse => "PulseAudio", AudioBackendType::Pulse => "PulseAudio",
AudioBackendType::SystemDefault => "System Audio", AudioBackendType::SystemDefault => "System",
}; };
BackendInfo { BackendInfo {
+17 -2
View File
@@ -3358,11 +3358,26 @@
} }
async function handleBackendChange(backendName: string) { async function handleBackendChange(backendName: string) {
// "Auto" is a resolve-and-set action, not a persisted mode. Pick the best
// available backend — PipeWire if available (best quality + device routing),
// otherwise System (always works) — and continue exactly as if the user had
// selected that concrete backend. This keeps backend_type always concrete: it
// never stays null, which on Linux fell through to the legacy CPAL path that
// froze the seekbar with no audio and left a process-wide stuck audio handle
// surviving Reset (#470). The dropdown then shows what was chosen, and the
// device picker lets the user correct the DAC if the auto pick isn't theirs.
if (backendName === 'Auto') {
const pipewire = availableBackends.find(
b => b.backend_type === 'PipeWire' && b.is_available
);
const system = availableBackends.find(b => b.backend_type === 'SystemDefault');
backendName = (pipewire ?? system)?.name ?? 'System';
}
selectedBackend = backendName; selectedBackend = backendName;
// Map UI name to backend type // Map UI name to backend type (always concrete after the Auto resolution above)
const backend = availableBackends.find(b => b.name === backendName); const backend = availableBackends.find(b => b.name === backendName);
const backendType = backendName === 'Auto' ? null : backend?.backend_type ?? null; const backendType = backend?.backend_type ?? null;
// Auto-disable incompatible features // Auto-disable incompatible features
// DAC Passthrough and PW force bit-perfect only work with PipeWire // DAC Passthrough and PW force bit-perfect only work with PipeWire
+1 -1
View File
@@ -2145,7 +2145,7 @@
"syncAudioOnStartup": "Audio-Einstellungen beim Start synchronisieren", "syncAudioOnStartup": "Audio-Einstellungen beim Start synchronisieren",
"syncAudioOnStartupDesc": "Audiokonfiguration beim App-Start aus der Datenbank neu laden. Aktiviere dies, wenn dein Audiogerät oder deine Einstellungen nach dem Starten falsch erscheinen.", "syncAudioOnStartupDesc": "Audiokonfiguration beim App-Start aus der Datenbank neu laden. Aktiviere dies, wenn dein Audiogerät oder deine Einstellungen nach dem Starten falsch erscheinen.",
"audioBackend": "Audio-Backend", "audioBackend": "Audio-Backend",
"audioBackendDesc": "Wähle Audiosystem: Auto (empfohlen), PipeWire (modern), ALSA Direct (bitperfekt, exklusiv) oder PulseAudio (veraltet).", "audioBackendDesc": "Wähle Audiosystem: System (Standard — geteilte Ausgabe wie jede App, kein Bitperfect), Auto (empfohlen — wählt das beste verfügbare Backend; ggf. musst du noch deinen DAC auswählen), PipeWire (modern), ALSA Direct (bitperfekt, exklusiv) oder PulseAudio (veraltet).",
"currentSampleRate": "Aktuelle Abtastrate", "currentSampleRate": "Aktuelle Abtastrate",
"noActivePlayback": "Keine aktive Wiedergabe", "noActivePlayback": "Keine aktive Wiedergabe",
"loadingDevices": "Geräte werden geladen...", "loadingDevices": "Geräte werden geladen...",
+1 -1
View File
@@ -2145,7 +2145,7 @@
"syncAudioOnStartup": "Sync audio settings on startup", "syncAudioOnStartup": "Sync audio settings on startup",
"syncAudioOnStartupDesc": "Reload audio configuration from database when the app starts. Enable if your audio device or settings appear incorrect after launching.", "syncAudioOnStartupDesc": "Reload audio configuration from database when the app starts. Enable if your audio device or settings appear incorrect after launching.",
"audioBackend": "Audio Backend", "audioBackend": "Audio Backend",
"audioBackendDesc": "Choose audio system: Auto (recommended), PipeWire (modern), ALSA Direct (bit-perfect, exclusive), or PulseAudio (legacy).", "audioBackendDesc": "Choose audio system: System (default — shared output like any app, no bit-perfect), Auto (recommended — picks the best available backend; you may still need to select your DAC), PipeWire (modern), ALSA Direct (bit-perfect, exclusive), or PulseAudio (legacy).",
"currentSampleRate": "Current Sample Rate", "currentSampleRate": "Current Sample Rate",
"noActivePlayback": "No active playback", "noActivePlayback": "No active playback",
"loadingDevices": "Loading devices...", "loadingDevices": "Loading devices...",
+1 -1
View File
@@ -2145,7 +2145,7 @@
"syncAudioOnStartup": "Sincronizar configuración de audio al iniciar", "syncAudioOnStartup": "Sincronizar configuración de audio al iniciar",
"syncAudioOnStartupDesc": "Recarga la configuración de audio desde la base de datos al iniciar la app. Actívalo si tu dispositivo o configuración de audio aparecen incorrectos al abrir.", "syncAudioOnStartupDesc": "Recarga la configuración de audio desde la base de datos al iniciar la app. Actívalo si tu dispositivo o configuración de audio aparecen incorrectos al abrir.",
"audioBackend": "Backend de Audio", "audioBackend": "Backend de Audio",
"audioBackendDesc": "Elige el sistema de audio: Auto (recomendado), PipeWire (moderno), ALSA Direct (bit-perfect, exclusivo), o PulseAudio (legacy).", "audioBackendDesc": "Elige el sistema de audio: System (predeterminado — salida compartida como cualquier app, sin bit-perfect), Auto (recomendado — elige el mejor backend disponible; quizá aún debas seleccionar tu DAC), PipeWire (moderno), ALSA Direct (bit-perfect, exclusivo), o PulseAudio (legacy).",
"currentSampleRate": "Tasa de Muestreo Actual", "currentSampleRate": "Tasa de Muestreo Actual",
"noActivePlayback": "Sin reproducción activa", "noActivePlayback": "Sin reproducción activa",
"loadingDevices": "Cargando dispositivos...", "loadingDevices": "Cargando dispositivos...",
+1 -1
View File
@@ -2145,7 +2145,7 @@
"syncAudioOnStartup": "Synchroniser la configuration audio au démarrage", "syncAudioOnStartup": "Synchroniser la configuration audio au démarrage",
"syncAudioOnStartupDesc": "Recharge la configuration audio depuis la base de données au démarrage de l'application. Activez si votre appareil ou vos paramètres audio semblent incorrects après le lancement.", "syncAudioOnStartupDesc": "Recharge la configuration audio depuis la base de données au démarrage de l'application. Activez si votre appareil ou vos paramètres audio semblent incorrects après le lancement.",
"audioBackend": "Backend audio", "audioBackend": "Backend audio",
"audioBackendDesc": "Choisissez le système audio : Auto (recommandé), PipeWire (moderne), ALSA Direct (bit-perfect, exclusif) ou PulseAudio (ancien).", "audioBackendDesc": "Choisissez le système audio : System (par défaut — sortie partagée comme toute app, pas de bit-perfect), Auto (recommandé — choisit le meilleur backend disponible ; vous devrez peut-être encore sélectionner votre DAC), PipeWire (moderne), ALSA Direct (bit-perfect, exclusif) ou PulseAudio (ancien).",
"currentSampleRate": "Taux d'échantillonnage actuel", "currentSampleRate": "Taux d'échantillonnage actuel",
"noActivePlayback": "Pas de lecture en cours", "noActivePlayback": "Pas de lecture en cours",
"loadingDevices": "Chargement des appareils...", "loadingDevices": "Chargement des appareils...",
+1 -1
View File
@@ -2145,7 +2145,7 @@
"syncAudioOnStartup": "Sincronizar configurações de áudio na inicialização", "syncAudioOnStartup": "Sincronizar configurações de áudio na inicialização",
"syncAudioOnStartupDesc": "Recarregar configuração de áudio do banco de dados quando o app iniciar. Ative se o dispositivo ou configurações de áudio parecerem incorretos após o lançamento.", "syncAudioOnStartupDesc": "Recarregar configuração de áudio do banco de dados quando o app iniciar. Ative se o dispositivo ou configurações de áudio parecerem incorretos após o lançamento.",
"audioBackend": "Backend de Áudio", "audioBackend": "Backend de Áudio",
"audioBackendDesc": "Escolha o sistema de áudio: Auto (recomendado), PipeWire (moderno), ALSA Direct (bit-perfect, exclusivo) ou PulseAudio (legado).", "audioBackendDesc": "Escolha o sistema de áudio: System (padrão — saída compartilhada como qualquer app, sem bit-perfect), Auto (recomendado — escolhe o melhor backend disponível; talvez ainda precise selecionar seu DAC), PipeWire (moderno), ALSA Direct (bit-perfect, exclusivo) ou PulseAudio (legado).",
"currentSampleRate": "Taxa de Amostragem Atual", "currentSampleRate": "Taxa de Amostragem Atual",
"noActivePlayback": "Nenhuma reprodução ativa", "noActivePlayback": "Nenhuma reprodução ativa",
"loadingDevices": "Carregando dispositivos...", "loadingDevices": "Carregando dispositivos...",