From 2df2dafe67e797ce4334609f317171566b620132 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:58:04 -0600 Subject: [PATCH] fix(audio): make ALSA transitions capability-aware cx --- crates/qbz-audio/src/alsa_backend.rs | 74 ++++- crates/qbz-audio/src/alsa_direct.rs | 271 ++++++++++++++---- .../translations/de/LC_MESSAGES/qbz-ui.po | 10 +- .../translations/en/LC_MESSAGES/qbz-ui.po | 9 + .../translations/es/LC_MESSAGES/qbz-ui.po | 10 +- .../translations/fr/LC_MESSAGES/qbz-ui.po | 10 +- .../translations/ja/LC_MESSAGES/qbz-ui.po | 10 +- .../translations/nl/LC_MESSAGES/qbz-ui.po | 10 +- .../translations/pt/LC_MESSAGES/qbz-ui.po | 10 +- .../translations/ru/LC_MESSAGES/qbz-ui.po | 10 +- crates/qbz-player/src/player/mod.rs | 70 ++++- crates/qbz-qt/qml/settings/AudioSettings.qml | 4 +- crates/qbz-qt/src/main.rs | 15 +- crates/qbz-qt/src/output_labels.rs | 18 +- crates/qbz-qt/src/settings_qt.rs | 194 +++++++++++-- crates/qbzd/src/daemon.rs | 2 +- 16 files changed, 613 insertions(+), 114 deletions(-) diff --git a/crates/qbz-audio/src/alsa_backend.rs b/crates/qbz-audio/src/alsa_backend.rs index 3e5be1172..1dd5930f2 100644 --- a/crates/qbz-audio/src/alsa_backend.rs +++ b/crates/qbz-audio/src/alsa_backend.rs @@ -69,17 +69,77 @@ fn suspend_default_sink_for_exclusive() { /// fix). No-op if QBZ did not suspend one — so it is safe to call on every /// stop/teardown. Call it once the exclusive device has actually been released /// so the rest of the system can use the sink again. -pub fn resume_suspended_sink() { +pub fn resume_suspended_sink() -> Result<(), String> { let target = match SUSPENDED_SINK.lock() { - Ok(mut guard) => guard.take(), + Ok(guard) => guard.clone(), Err(_) => None, }; - if let Some(sink) = target { - let _ = std::process::Command::new("pactl") - .args(["suspend-sink", &sink, "0"]) - .output(); - log::info!("[ALSA Backend] Resumed PipeWire sink '{}'", sink); + let Some(sink) = target else { + return Ok(()); + }; + + // The PCM and reservation have already been dropped by every caller, but + // PipeWire may need a short moment to recreate the parked sink. Retry the + // exact recorded name, then the default alias as a compatibility fallback. + // Keep SUSPENDED_SINK populated until one command actually succeeds: the + // old take-before-command implementation permanently lost the recovery + // target on one transient `pactl` failure while logging success anyway. + let mut last_error = String::new(); + for delay_ms in [0u64, 25, 75, 150] { + if delay_ms > 0 { + std::thread::sleep(std::time::Duration::from_millis(delay_ms)); + } + let mut targets = vec![sink.as_str()]; + if sink != "@DEFAULT_SINK@" { + targets.push("@DEFAULT_SINK@"); + } + for target in targets { + match std::process::Command::new("pactl") + .args(["suspend-sink", target, "0"]) + .output() + { + Ok(output) if output.status.success() => { + if let Ok(mut guard) = SUSPENDED_SINK.lock() { + if guard.as_deref() == Some(sink.as_str()) { + *guard = None; + } + } + log::info!( + "[ALSA Backend] Resumed PipeWire sink '{}' via '{}'", + sink, + target + ); + return Ok(()); + } + Ok(output) => { + last_error = String::from_utf8_lossy(&output.stderr).trim().to_string(); + } + Err(error) => { + last_error = error.to_string(); + if error.kind() == std::io::ErrorKind::NotFound { + let message = format!( + "cannot resume PipeWire sink '{}': pactl is unavailable ({error})", + sink + ); + log::warn!("[ALSA Backend] {message}"); + return Err(message); + } + } + } + } } + + let message = format!( + "cannot resume PipeWire sink '{}' after releasing ALSA: {}", + sink, + if last_error.is_empty() { + "pactl failed without a diagnostic" + } else { + last_error.as_str() + } + ); + log::warn!("[ALSA Backend] {message}"); + Err(message) } /// Common audio sample rates to check for device support diff --git a/crates/qbz-audio/src/alsa_direct.rs b/crates/qbz-audio/src/alsa_direct.rs index b3b59b129..db70e179e 100644 --- a/crates/qbz-audio/src/alsa_direct.rs +++ b/crates/qbz-audio/src/alsa_direct.rs @@ -113,6 +113,18 @@ pub struct AlsaDirectStream { device_id: String, } +/// A writable ALSA mixer element and its current normalized level. +/// +/// The probe is deliberately read-only. Callers use the sampled level to seed +/// QBZ's volume state *before* enabling hardware volume, so changing the +/// setting cannot copy the direct path's fixed 100% UI value into the +/// physical mixer. +#[derive(Clone, Debug, PartialEq)] +pub struct HardwareVolumeInfo { + pub control_name: String, + pub volume: f32, +} + /// Defensive settle delay between reservation acquisition and PCM open. /// /// Only applied when the reservation actually transitioned ownership (i.e. @@ -886,7 +898,7 @@ impl AlsaDirectStream { &self.device_id } - /// Try to set hardware volume via ALSA mixer + /// Try to set hardware volume via ALSA mixer. /// /// Returns error if: /// - DAC doesn't have mixer controls (common for USB DACs) @@ -894,57 +906,29 @@ impl AlsaDirectStream { /// /// NOTE: Failure doesn't break playback, just means volume can't be controlled. pub fn set_hardware_volume(&self, volume: f32) -> Result<(), String> { - use alsa::mixer::SelemChannelId::*; - use alsa::mixer::{Mixer, SelemId}; + let mixer = open_hardware_mixer(&self.device_id)?; + let (selem, name) = find_hardware_volume_control(&mixer, &self.device_id)?; + let (min, max) = usable_volume_range(&selem, &self.device_id, &name)?; + let volume = volume.clamp(0.0, 1.0); + let target = min + (((max - min) as f32 * volume).round() as i64); - // Mixer controls live on the CARD ctl device, not on the PCM alias the - // stream opened: `iec958:CARD=x,DEV=0` (HiFiBerry Digi, #331/#659), - // `hdmi:`, `front:`… are PCM plugin ids the mixer can't attach to, and - // a `DEV`-qualified `hw:` id isn't a valid ctl name either. Open the - // mixer on the derived `hw:CARD=` instead. - let ctl_name = mixer_ctl_name(&self.device_id); - let mixer = Mixer::new(&ctl_name, false).map_err(|e| { + log::info!( + "[ALSA Direct] Setting hardware volume via '{}': {:.0}% (raw: {}/{})", + name, + volume * 100.0, + target, + max + ); + + // The ALSA helper addresses every playback channel the element owns + // and, unlike the old hand-written five-channel loop, propagates a + // write failure instead of reporting success after ignoring it. + selem.set_playback_volume_all(target).map_err(|error| { format!( - "Failed to open mixer for {} (ctl {}): {}", - self.device_id, ctl_name, e + "Failed to set ALSA hardware volume via '{}' for {}: {}", + name, self.device_id, error ) - })?; - - // Try to find a volume control element - // Common names: "Master", "PCM", "Speaker", "Headphone" - let control_names = ["Master", "PCM", "Speaker", "Headphone", "Digital"]; - - for name in &control_names { - let selem_id = SelemId::new(name, 0); - - if let Some(selem) = mixer.find_selem(&selem_id) { - // Check if this element has playback volume control - if selem.has_playback_volume() { - let (min, max) = selem.get_playback_volume_range(); - let target = min + ((max - min) as f32 * volume) as i64; - - log::info!( - "[ALSA Direct] Setting hardware volume via '{}': {:.0}% (raw: {}/{})", - name, - volume * 100.0, - target, - max - ); - - // Set volume on all channels - for channel in &[FrontLeft, FrontRight, FrontCenter, RearLeft, RearRight] { - let _ = selem.set_playback_volume(*channel, target); - } - - return Ok(()); - } - } - } - - Err(format!( - "No volume control found for {}. DAC may not support hardware mixer.", - self.device_id - )) + }) } /// Check if device is a bit-perfect hardware device @@ -956,6 +940,158 @@ impl AlsaDirectStream { } } +#[cfg(target_os = "linux")] +fn open_hardware_mixer(device_id: &str) -> Result { + use alsa::mixer::Mixer; + + // Mixer controls live on the CARD ctl device, not on the PCM alias the + // stream opened: `iec958:CARD=x,DEV=0` (HiFiBerry Digi, #331/#659), + // `hdmi:`, `front:`… are PCM plugin ids the mixer can't attach to, and a + // `DEV`-qualified `hw:` id isn't a valid ctl name either. + let ctl_name = mixer_ctl_name(device_id); + Mixer::new(&ctl_name, false).map_err(|error| { + format!( + "Failed to open mixer for {} (ctl {}): {}", + device_id, ctl_name, error + ) + }) +} + +/// Rank playback elements conservatively. ALSA devices are inconsistent: +/// many expose `DAC` or `Line Out` instead of the five names the old code +/// hard-coded. Capture/sidetone elements are never acceptable fallbacks. +fn hardware_volume_rank(name: &str) -> u8 { + let lower = name.to_ascii_lowercase(); + if ["capture", "mic", "boost", "sidetone", "loopback"] + .iter() + .any(|token| lower.contains(token)) + { + return 0; + } + + [ + ("master", 100), + ("pcm", 90), + ("speaker", 80), + ("headphone", 70), + ("digital", 60), + ("dac", 50), + ("line out", 40), + ("playback", 30), + ] + .into_iter() + .find_map(|(token, score)| lower.contains(token).then_some(score)) + .unwrap_or(1) +} + +#[cfg(target_os = "linux")] +fn find_hardware_volume_control<'a>( + mixer: &'a alsa::mixer::Mixer, + device_id: &str, +) -> Result<(alsa::mixer::Selem<'a>, String), String> { + use alsa::mixer::Selem; + + let mut candidates = mixer + .iter() + .filter_map(Selem::new) + .filter(|selem| selem.has_playback_volume()) + .filter_map(|selem| { + let name = selem.get_id().get_name().ok()?.to_string(); + let rank = hardware_volume_rank(&name); + (rank > 0).then_some((rank, name, selem)) + }) + .collect::>(); + + candidates.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1))); + + let Some((top_rank, _, _)) = candidates.first() else { + return Err(format!( + "No writable playback-volume control found for {}", + device_id + )); + }; + if candidates.get(1).is_some_and(|next| next.0 == *top_rank) { + let names = candidates + .iter() + .filter(|candidate| candidate.0 == *top_rank) + .map(|candidate| candidate.1.as_str()) + .collect::>() + .join(", "); + return Err(format!( + "Ambiguous playback-volume controls for {}: {}", + device_id, names + )); + } + + let (_, name, selem) = candidates.remove(0); + Ok((selem, name)) +} + +#[cfg(target_os = "linux")] +fn usable_volume_range( + selem: &alsa::mixer::Selem<'_>, + device_id: &str, + control_name: &str, +) -> Result<(i64, i64), String> { + let (min, max) = selem.get_playback_volume_range(); + (max > min).then_some((min, max)).ok_or_else(|| { + format!( + "ALSA playback-volume control '{}' for {} has no usable range ({min}..{max})", + control_name, device_id + ) + }) +} + +/// Read-only capability probe used before the UI enables hardware volume. +#[cfg(target_os = "linux")] +pub fn probe_hardware_volume(device_id: &str) -> Result { + use alsa::mixer::SelemChannelId; + + let mixer = open_hardware_mixer(device_id)?; + let (selem, name) = find_hardware_volume_control(&mixer, device_id)?; + let (min, max) = usable_volume_range(&selem, device_id, &name)?; + let channel = SelemChannelId::all() + .iter() + .copied() + .find(|channel| selem.has_playback_channel(*channel)) + .ok_or_else(|| { + format!( + "ALSA playback-volume control '{}' for {} has no playback channel", + name, device_id + ) + })?; + let raw = selem.get_playback_volume(channel).map_err(|error| { + format!( + "Failed to read ALSA hardware volume via '{}' for {}: {}", + name, device_id, error + ) + })?; + let volume = ((raw - min) as f32 / (max - min) as f32).clamp(0.0, 1.0); + + Ok(HardwareVolumeInfo { + control_name: name, + volume, + }) +} + +#[cfg(not(target_os = "linux"))] +pub fn probe_hardware_volume(_device_id: &str) -> Result { + Err("ALSA hardware volume is only available on Linux".to_string()) +} + +/// Mirrors the exact route predicate used by the player: ALSA plus a direct +/// device id and either the `hw` or `plughw` engine. ALSA's default/sysdefault +/// and `Pcm` routes use CPAL/Rodio and retain software volume. +pub fn uses_alsa_direct_route(audio: &crate::settings::AudioSettings) -> bool { + audio.backend_type == Some(crate::backend::AudioBackendType::Alsa) + && audio.alsa_plugin.unwrap_or(crate::backend::AlsaPlugin::Hw) + != crate::backend::AlsaPlugin::Pcm + && audio + .output_device + .as_deref() + .is_some_and(AlsaDirectStream::is_hw_device) +} + #[cfg(not(target_os = "linux"))] impl AlsaDirectStream { pub fn new(_device_id: &str, _sample_rate: u32, _channels: u16) -> Result { @@ -1027,7 +1163,9 @@ pub(crate) fn mixer_ctl_name(device_id: &str) -> String { #[cfg(test)] mod tests { - use super::mixer_ctl_name; + use super::{hardware_volume_rank, mixer_ctl_name, uses_alsa_direct_route}; + use crate::backend::{AlsaPlugin, AudioBackendType}; + use crate::settings::AudioSettings; #[test] fn mixer_ctl_name_maps_card_forms() { @@ -1055,4 +1193,37 @@ mod tests { assert_eq!(mixer_ctl_name("default"), "default"); assert_eq!(mixer_ctl_name("pulse"), "pulse"); } + + #[test] + fn hardware_volume_ranking_prefers_output_controls_and_rejects_capture_paths() { + assert!(hardware_volume_rank("Master") > hardware_volume_rank("PCM")); + assert!(hardware_volume_rank("PCM") > hardware_volume_rank("USB DAC")); + assert!(hardware_volume_rank("Speaker Playback") > 1); + assert_eq!(hardware_volume_rank("Mic Playback Volume"), 0); + assert_eq!(hardware_volume_rank("Capture"), 0); + } + + #[test] + fn direct_route_requires_alsa_direct_device_and_non_pcm_plugin() { + let mut audio = AudioSettings::default(); + assert!(!uses_alsa_direct_route(&audio)); + + audio.backend_type = Some(AudioBackendType::Alsa); + audio.output_device = Some("front:CARD=USB,DEV=0".to_string()); + audio.alsa_plugin = Some(AlsaPlugin::Hw); + assert!(uses_alsa_direct_route(&audio)); + + audio.alsa_plugin = Some(AlsaPlugin::PlugHw); + assert!(uses_alsa_direct_route(&audio)); + + audio.alsa_plugin = Some(AlsaPlugin::Pcm); + assert!(!uses_alsa_direct_route(&audio)); + + audio.alsa_plugin = Some(AlsaPlugin::Hw); + audio.output_device = Some("sysdefault:CARD=USB".to_string()); + assert!(!uses_alsa_direct_route(&audio)); + + audio.output_device = None; + assert!(!uses_alsa_direct_route(&audio)); + } } diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po index 4284f66fd..cda4ce401 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -2077,8 +2077,14 @@ msgstr "Wie ALSA das Gerät öffnet – hw ist bit-perfect, plughw konvertiert." msgid "Hardware volume control" msgstr "Hardware-Lautstärkeregelung" -msgid "Use the ALSA mixer for volume instead of software gain." -msgstr "Verwendet den ALSA-Mixer für die Lautstärke anstelle von Software-Gain." +msgid "Control volume through a mixer exposed by the selected ALSA device. If unavailable, direct playback stays fixed at 100%." +msgstr "Steuert die Lautstärke über einen Mixer, den das ausgewählte ALSA-Gerät bereitstellt. Ist keiner verfügbar, bleibt die direkte Wiedergabe fest auf 100 %." + +msgid "This ALSA device has no compatible hardware volume control. Direct playback remains fixed at 100%." +msgstr "Dieses ALSA-Gerät hat keine kompatible Hardware-Lautstärkeregelung. Die direkte Wiedergabe bleibt fest auf 100 %." + +msgid "QBZ could not fully release the previous audio device. See the audio log for details." +msgstr "QBZ konnte das vorherige Audiogerät nicht vollständig freigeben. Details stehen im Audioprotokoll." msgid "BIT-PERFECT" msgstr "BIT-PERFECT" diff --git a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po index b8d2de5e3..26ae17cd6 100644 --- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -1641,3 +1641,12 @@ msgstr "This album has no tracks." msgid "Reactive Rings" msgstr "Reactive Rings" + +msgid "Control volume through a mixer exposed by the selected ALSA device. If unavailable, direct playback stays fixed at 100%." +msgstr "Control volume through a mixer exposed by the selected ALSA device. If unavailable, direct playback stays fixed at 100%." + +msgid "This ALSA device has no compatible hardware volume control. Direct playback remains fixed at 100%." +msgstr "This ALSA device has no compatible hardware volume control. Direct playback remains fixed at 100%." + +msgid "QBZ could not fully release the previous audio device. See the audio log for details." +msgstr "QBZ could not fully release the previous audio device. See the audio log for details." diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po index f827a0df0..c335f06ab 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -2077,8 +2077,14 @@ msgstr "Cómo ALSA abre el dispositivo: hw es bit-perfect, plughw convierte." msgid "Hardware volume control" msgstr "Control de volumen por hardware" -msgid "Use the ALSA mixer for volume instead of software gain." -msgstr "Usa el mezclador de ALSA para el volumen en lugar de la ganancia por software." +msgid "Control volume through a mixer exposed by the selected ALSA device. If unavailable, direct playback stays fixed at 100%." +msgstr "Controla el volumen mediante un mezclador expuesto por el dispositivo ALSA seleccionado. Si no está disponible, la reproducción directa se mantiene fija al 100 %." + +msgid "This ALSA device has no compatible hardware volume control. Direct playback remains fixed at 100%." +msgstr "Este dispositivo ALSA no tiene un control de volumen por hardware compatible. La reproducción directa se mantiene fija al 100 %." + +msgid "QBZ could not fully release the previous audio device. See the audio log for details." +msgstr "QBZ no pudo liberar por completo el dispositivo de audio anterior. Consulta el registro de audio para ver los detalles." msgid "BIT-PERFECT" msgstr "BIT-PERFECT" diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po index d44a126da..33bfeef0a 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -2077,8 +2077,14 @@ msgstr "Comment ALSA ouvre le périphérique — hw est bit-perfect, plughw conv msgid "Hardware volume control" msgstr "Contrôle du volume matériel" -msgid "Use the ALSA mixer for volume instead of software gain." -msgstr "Utilise le mixeur ALSA pour le volume au lieu du gain logiciel." +msgid "Control volume through a mixer exposed by the selected ALSA device. If unavailable, direct playback stays fixed at 100%." +msgstr "Contrôle le volume via un mixeur exposé par le périphérique ALSA sélectionné. S’il n’est pas disponible, la lecture directe reste fixée à 100 %." + +msgid "This ALSA device has no compatible hardware volume control. Direct playback remains fixed at 100%." +msgstr "Ce périphérique ALSA ne possède aucun contrôle matériel du volume compatible. La lecture directe reste fixée à 100 %." + +msgid "QBZ could not fully release the previous audio device. See the audio log for details." +msgstr "QBZ n’a pas pu libérer complètement le périphérique audio précédent. Consultez le journal audio pour plus de détails." msgid "BIT-PERFECT" msgstr "BIT-PERFECT" diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po index 47f692b9e..b1770f78a 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -2010,8 +2010,14 @@ msgstr "ALSAがデバイスを開く方法 — hwはビットパーフェクト msgid "Hardware volume control" msgstr "ハードウェア音量コントロール" -msgid "Use the ALSA mixer for volume instead of software gain." -msgstr "ソフトウェアゲインの代わりにALSAミキサーで音量を調整します。" +msgid "Control volume through a mixer exposed by the selected ALSA device. If unavailable, direct playback stays fixed at 100%." +msgstr "選択したALSAデバイスが提供するミキサーで音量を調整します。利用できない場合、ダイレクト再生の音量は100%に固定されます。" + +msgid "This ALSA device has no compatible hardware volume control. Direct playback remains fixed at 100%." +msgstr "このALSAデバイスには互換性のあるハードウェア音量コントロールがありません。ダイレクト再生の音量は100%に固定されます。" + +msgid "QBZ could not fully release the previous audio device. See the audio log for details." +msgstr "QBZは以前のオーディオデバイスを完全に解放できませんでした。詳細はオーディオログを確認してください。" msgid "BIT-PERFECT" msgstr "ビットパーフェクト" diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po index 596dff021..61759595b 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -2073,8 +2073,14 @@ msgstr "Hoe ALSA het apparaat opent — hw is bit-perfect, plughw converteert." msgid "Hardware volume control" msgstr "Hardware-volumeregeling" -msgid "Use the ALSA mixer for volume instead of software gain." -msgstr "Gebruik de ALSA-mixer voor volume in plaats van software-versterking." +msgid "Control volume through a mixer exposed by the selected ALSA device. If unavailable, direct playback stays fixed at 100%." +msgstr "Regel het volume via een mixer die door het geselecteerde ALSA-apparaat wordt aangeboden. Als die niet beschikbaar is, blijft directe weergave vast op 100%." + +msgid "This ALSA device has no compatible hardware volume control. Direct playback remains fixed at 100%." +msgstr "Dit ALSA-apparaat heeft geen compatibele hardwarevolumeregeling. Directe weergave blijft vast op 100%." + +msgid "QBZ could not fully release the previous audio device. See the audio log for details." +msgstr "QBZ kon het vorige audioapparaat niet volledig vrijgeven. Bekijk het audiologboek voor details." msgid "BIT-PERFECT" msgstr "BIT-PERFECT" diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po index 5fd3671bf..2f44b9b31 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -2077,8 +2077,14 @@ msgstr "Como o ALSA abre o dispositivo — hw é bit-perfect, plughw converte." msgid "Hardware volume control" msgstr "Controlo de volume por hardware" -msgid "Use the ALSA mixer for volume instead of software gain." -msgstr "Usa o mixer do ALSA para o volume em vez do ganho por software." +msgid "Control volume through a mixer exposed by the selected ALSA device. If unavailable, direct playback stays fixed at 100%." +msgstr "Controla o volume por meio de um mixer disponibilizado pelo dispositivo ALSA selecionado. Se não estiver disponível, a reprodução direta permanece fixa em 100%." + +msgid "This ALSA device has no compatible hardware volume control. Direct playback remains fixed at 100%." +msgstr "Este dispositivo ALSA não possui um controle de volume por hardware compatível. A reprodução direta permanece fixa em 100%." + +msgid "QBZ could not fully release the previous audio device. See the audio log for details." +msgstr "O QBZ não conseguiu liberar completamente o dispositivo de áudio anterior. Consulte o log de áudio para obter detalhes." msgid "BIT-PERFECT" msgstr "BIT-PERFECT" diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po index 69d221ce5..27e199dc1 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -2014,8 +2014,14 @@ msgstr "Как ALSA открывает устройство — hw обеспе msgid "Hardware volume control" msgstr "Аппаратное управление громкостью" -msgid "Use the ALSA mixer for volume instead of software gain." -msgstr "Использовать микшер ALSA для громкости вместо программного усиления." +msgid "Control volume through a mixer exposed by the selected ALSA device. If unavailable, direct playback stays fixed at 100%." +msgstr "Управляет громкостью через микшер выбранного устройства ALSA. Если он недоступен, громкость прямого воспроизведения остаётся на уровне 100 %." + +msgid "This ALSA device has no compatible hardware volume control. Direct playback remains fixed at 100%." +msgstr "У этого устройства ALSA нет совместимого аппаратного регулятора громкости. Громкость прямого воспроизведения остаётся на уровне 100 %." + +msgid "QBZ could not fully release the previous audio device. See the audio log for details." +msgstr "QBZ не удалось полностью освободить предыдущее аудиоустройство. Подробности смотрите в журнале аудио." msgid "BIT-PERFECT" msgstr "БИТ-ПЕРФЕКТ" diff --git a/crates/qbz-player/src/player/mod.rs b/crates/qbz-player/src/player/mod.rs index 66aa46e09..3fbd75c81 100644 --- a/crates/qbz-player/src/player/mod.rs +++ b/crates/qbz-player/src/player/mod.rs @@ -102,7 +102,9 @@ enum AudioCommand { /// stream (freeing an exclusive ALSA `hw:` grab + its D-Bus reservation) /// and un-suspends / un-forces anything QBZ parked, so PipeWire can /// reclaim a device QBZ was holding. User-triggered from settings. - ReleaseDevice, + ReleaseDevice { + completed: SyncSender>, + }, /// Append next track to current engine for gapless playback (Rodio only) PlayNext { data: Vec, @@ -3806,6 +3808,35 @@ impl Player { drop(stream_opt.take()); log::info!("Audio thread: previous stream dropped, device released"); + // ReloadSettings updates `thread_settings` before + // this command is queued. When the NEW route is no + // longer ALSA Direct, finish the teardown the same + // way ReleaseDevice does: dropping the PCM alone + // releases the fd/reservation but leaves the + // PipeWire sink QBZ suspended. Keep it suspended + // for direct->direct reinitialization so PipeWire + // cannot race the immediate exclusive reopen. + #[cfg(target_os = "linux")] + { + let next_is_alsa_direct = thread_settings + .lock() + .ok() + .map(|settings| { + qbz_audio::alsa_direct::uses_alsa_direct_route(&settings) + }) + .unwrap_or(false); + if !next_is_alsa_direct { + if let Err(error) = + qbz_audio::alsa_backend::resume_suspended_sink() + { + log::warn!( + "Audio thread: release during reinit was incomplete: {error}" + ); + } + } + qbz_audio::pipewire_backend::PipeWireBackend::reset_pipewire_clock(); + } + std::thread::sleep(Duration::from_millis(100)); *current_device_name = new_device; @@ -3830,7 +3861,7 @@ impl Player { // Keep current_audio_data and current_streaming_source // intact so Resume can recreate the engine and seek. } - AudioCommand::ReleaseDevice => { + AudioCommand::ReleaseDevice { completed } => { log::info!("Audio thread: releasing output device (user-requested)"); // Cancel any deferred drop and tear the stream down NOW so // the device is freed immediately (no warm-stream lingering). @@ -3844,15 +3875,19 @@ impl Player { // apps after bit-perfect ALSA Direct held it exclusively). // Both calls are self-gating no-ops if QBZ didn't set them. #[cfg(target_os = "linux")] - { - qbz_audio::alsa_backend::resume_suspended_sink(); + let release_result = { + let result = qbz_audio::alsa_backend::resume_suspended_sink(); qbz_audio::pipewire_backend::PipeWireBackend::reset_pipewire_clock(); - } + result + }; + #[cfg(not(target_os = "linux"))] + let release_result = Ok(()); thread_state.pause_playback_timer(); thread_state.is_playing.store(false, Ordering::SeqCst); // Keep current_audio_data / current_streaming_source intact // so a later Play / Resume reopens and continues. log::info!("Audio thread: output device released"); + let _ = completed.send(release_result); } AudioCommand::PlayNext { data, @@ -4501,7 +4536,7 @@ impl Player { // above), so resume any PipeWire sink we suspended for // exclusive access — self-gating no-op otherwise (#263). #[cfg(target_os = "linux")] - qbz_audio::alsa_backend::resume_suspended_sink(); + let _ = qbz_audio::alsa_backend::resume_suspended_sink(); log::info!("Audio thread: suspended stream after pause"); continue; } @@ -6100,6 +6135,19 @@ impl Player { .map_err(|e| format!("Failed to send volume command: {}", e)) } + /// Seed the volume state for an imminent engine rebuild without touching + /// the currently open output. Settings uses this when enabling ALSA + /// hardware volume: it samples the physical mixer first, stores that level + /// here, then reinitializes the direct engine. Sending `SetVolume` instead + /// would write the new device's sampled level through the *old* device's + /// still-live hardware mixer before the reinit command reached the audio + /// thread. + pub fn seed_volume_state(&self, volume: f32) { + self.state + .volume + .store(volume.clamp(0.0, 1.0).to_bits(), Ordering::SeqCst); + } + /// Seek to position in seconds pub fn seek(&self, position: u64) -> Result<(), String> { // Clamp to duration if known @@ -6131,9 +6179,15 @@ impl Player { /// with a device re-enumeration in the UI to surface a freed or /// hot-plugged DAC without restarting the app. pub fn release_device(&self) -> Result<(), String> { + let (completed_tx, completed_rx) = mpsc::sync_channel(1); self.tx - .send(AudioCommand::ReleaseDevice) - .map_err(|e| format!("Failed to send release command: {}", e)) + .send(AudioCommand::ReleaseDevice { + completed: completed_tx, + }) + .map_err(|e| format!("Failed to send release command: {}", e))?; + completed_rx + .recv_timeout(Duration::from_secs(5)) + .map_err(|error| format!("Timed out waiting for audio-device release: {error}"))? } /// Reload audio settings from fresh config (e.g., after database update) diff --git a/crates/qbz-qt/qml/settings/AudioSettings.qml b/crates/qbz-qt/qml/settings/AudioSettings.qml index 3dda07b23..0e396c48e 100644 --- a/crates/qbz-qt/qml/settings/AudioSettings.qml +++ b/crates/qbz-qt/qml/settings/AudioSettings.qml @@ -141,9 +141,9 @@ Column { } } SettingRow { - visible: root.doc.backendIsAlsa === true && root.doc.alsaPluginIsHw === true + visible: root.doc.backendIsAlsa === true && root.doc.alsaDirectSelected === true label: QbzSession.tr("Hardware volume control", QbzSession.trRev) - description: QbzSession.tr("Use the ALSA mixer for volume instead of software gain.", QbzSession.trRev) + description: QbzSession.tr("Control volume through a mixer exposed by the selected ALSA device. If unavailable, direct playback stays fixed at 100%.", QbzSession.trRev) QbzToggle { checked: root.doc.alsaHardwareVolume === true onToggled: function (v) { QbzBridge.settingsBool("alsa-hardware-volume", v) } diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index c10c07677..36f55c7d1 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -802,12 +802,19 @@ fn on_session_entered() { qconnect_service.spawn_offline_force_disconnect(&tokio_handle); } } - // Seed the saved level, then enforce unity gain for ALSA-direct `hw`. - // Sequencing both writes in one task prevents the asynchronous restore - // from landing after the bit-perfect correction. + // Revalidate hardware volume first. When a compatible ALSA mixer exists, + // the probe samples its physical level and seeds SharedState without a + // write; restoring the persisted slider afterward would otherwise move + // that mixer at launch. Without hardware volume, restore the saved level + // and then enforce unity for the protected ALSA-direct path. Sequencing + // these decisions in one task prevents an asynchronous restore from + // landing after the bit-perfect correction. let rt = app(); spawn(async move { - playback_qt::set_volume(&rt, restored).await; + let hardware_level = settings_qt::reconcile_alsa_hardware_volume(&rt).await; + if hardware_level.is_none() { + playback_qt::set_volume(&rt, restored).await; + } settings_qt::maybe_force_bitperfect_volume(&rt).await; }); // 3. Startup auto-connect — gated on NOT offline: the offline shell entry diff --git a/crates/qbz-qt/src/output_labels.rs b/crates/qbz-qt/src/output_labels.rs index cf4032e2c..27eabf512 100644 --- a/crates/qbz-qt/src/output_labels.rs +++ b/crates/qbz-qt/src/output_labels.rs @@ -105,17 +105,14 @@ pub fn output_labels(audio: &AudioSettings) -> OutputLabels { /// (`qbz-player/src/player/playback_engine.rs`) is a documented NO-OP on the /// `AlsaDirect` arm unless `alsa_hardware_volume` is on, in which case it /// drives the DAC's own mixer instead (still bit-perfect, so the slider stays -/// live). The ALSA backend builds an `AlsaDirectStream` for BOTH the `hw` -/// (bit-perfect direct) and the `plughw` fallback plugin; only `Pcm` opts out -/// of the direct engine entirely and lands on CPAL/rodio, where software -/// volume works. Hence: ALSA backend, plugin != Pcm, hardware volume off. +/// live). The route predicate also includes the selected device id: ALSA's +/// system-default/sysdefault rows land on CPAL/Rodio and keep software volume, +/// even if the plugin preference says `hw`. /// /// READ-ONLY derivation from the persisted `AudioSettings` — no audio /// behaviour is changed anywhere by this, the UI just stops lying about it. pub fn volume_locked(audio: &AudioSettings) -> bool { - matches!(audio.backend_type, Some(AudioBackendType::Alsa)) - && !matches!(audio.alsa_plugin, Some(AlsaPlugin::Pcm)) - && !audio.alsa_hardware_volume + qbz_audio::alsa_direct::uses_alsa_direct_route(audio) && !audio.alsa_hardware_volume } /// Derive + push the four LED values (and the volume-lock flag) onto the @@ -219,6 +216,7 @@ mod tests { assert!(!volume_locked(&settings(b)), "{b:?} must keep the slider"); } let mut s = settings(Some(AudioBackendType::Alsa)); + s.output_device = Some("front:CARD=USB,DEV=0".to_string()); // hw = the bit-perfect direct path: engine set_volume is a no-op. s.alsa_plugin = Some(AlsaPlugin::Hw); s.alsa_hardware_volume = false; @@ -233,6 +231,12 @@ mod tests { // Pcm opts out of the direct engine (CPAL/rodio) -> software volume. s.alsa_plugin = Some(AlsaPlugin::Pcm); assert!(!volume_locked(&s)); + // ALSA default/sysdefault use CPAL/Rodio rather than AlsaDirect. + s.alsa_plugin = Some(AlsaPlugin::Hw); + s.output_device = None; + assert!(!volume_locked(&s)); + s.output_device = Some("sysdefault:CARD=USB".to_string()); + assert!(!volume_locked(&s)); } #[test] diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index b6b941baa..f7836b1b1 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -1618,6 +1618,8 @@ pub struct SettingsDoc { pub alsa_plugin_index: i32, #[serde(rename = "alsaPluginIsHw")] pub alsa_plugin_is_hw: bool, + #[serde(rename = "alsaDirectSelected")] + pub alsa_direct_selected: bool, #[serde(rename = "alsaHardwareVolume")] pub alsa_hardware_volume: bool, #[serde(rename = "dsdModes")] @@ -2105,6 +2107,7 @@ pub async fn publish_snapshot() { alsa_plugins: ALSA_PLUGIN_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(), alsa_plugin_index: alsa_plugin_index as i32, alsa_plugin_is_hw: alsa_plugin == AlsaPlugin::Hw, + alsa_direct_selected: qbz_audio::alsa_direct::uses_alsa_direct_route(&audio_settings), alsa_hardware_volume: audio_settings.alsa_hardware_volume, dsd_modes: DSD_MODE_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(), dsd_mode_index: DSD_MODE_VALUES @@ -2317,6 +2320,64 @@ enum Apply { Reinit, } +async fn probe_selected_hardware_volume( + audio: &qbz_audio::settings::AudioSettings, +) -> Result { + if !qbz_audio::alsa_direct::uses_alsa_direct_route(audio) { + return Err( + "hardware volume requires an explicitly selected ALSA Direct device".to_string(), + ); + } + let device_id = audio + .output_device + .clone() + .ok_or_else(|| "ALSA Direct device is missing".to_string())?; + tokio::task::spawn_blocking(move || qbz_audio::alsa_direct::probe_hardware_volume(&device_id)) + .await + .map_err(|error| format!("ALSA hardware-volume probe task failed: {error}"))? +} + +fn seed_hardware_volume( + runtime: &Arc>, + info: &qbz_audio::alsa_direct::HardwareVolumeInfo, +) { + // Update SharedState without touching the old output. The new direct + // engine reads this after Reinit and writes back the same level the probe + // sampled, instead of copying the locked path's synthetic 100% into the + // physical mixer (or writing the new device's level through the old one). + runtime.core().player().seed_volume_state(info.volume); + save_pref("volume", serde_json::json!(info.volume)); + crate::now_playing::set_volume(info.volume); + log::info!( + "[qbz-qt] ALSA hardware volume: '{}' available at {:.0}%", + info.control_name, + info.volume * 100.0 + ); +} + +async fn set_alsa_hardware_volume( + runtime: &Arc>, + enabled: bool, +) -> Result { + if !enabled { + return with_audio(|store| store.set_alsa_hardware_volume(false)).map(|_| Apply::Reinit); + } + + let audio = with_audio(|store| store.get_settings())?; + let info = match probe_selected_hardware_volume(&audio).await { + Ok(info) => info, + Err(error) => { + // An old build or an external settings edit may already have left + // this true. Fail closed so the UI cannot advertise an enabled, + // inert slider after the probe rejects the device. + let _ = with_audio(|store| store.set_alsa_hardware_volume(false)); + return Err(error); + } + }; + seed_hardware_volume(runtime, &info); + with_audio(|store| store.set_alsa_hardware_volume(true)).map(|_| Apply::Reinit) +} + fn apply_audio(runtime: &Arc>, apply: Apply) { let reinit = match apply { Apply::None => return, @@ -2348,18 +2409,33 @@ fn apply_audio(runtime: &Arc>, apply: Apply) { crate::publish_settings(); } -fn uses_alsa_direct_hw(audio: &qbz_audio::settings::AudioSettings) -> bool { - audio.backend_type.unwrap_or_default() == AudioBackendType::Alsa - && audio.alsa_plugin.unwrap_or(AlsaPlugin::Hw) == AlsaPlugin::Hw +/// Release the live output and wait for the audio thread to confirm that the +/// PCM/reservation is gone and any PipeWire sink QBZ suspended is awake. +/// `Player::release_device` is intentionally blocking because the ack is the +/// ordering guarantee; keep that wait off Tokio's worker threads. +async fn release_output_device(runtime: &Arc>) -> Result<(), String> { + let player = runtime.core().player(); + tokio::task::spawn_blocking(move || player.release_device()) + .await + .map_err(|error| format!("audio-device release task failed: {error}"))? } -/// Keep software gain at unity for ALSA-direct `hw` output. The public core +fn report_release_failure(error: &str) { + log::error!("[qbz-qt] output-device release failed: {error}"); + crate::toast_qt::error(qbz_i18n::t( + "QBZ could not fully release the previous audio device. See the audio log for details.", + )); +} + +fn requires_alsa_direct_unity(audio: &qbz_audio::settings::AudioSettings) -> bool { + qbz_audio::alsa_direct::uses_alsa_direct_route(audio) && !audio.alsa_hardware_volume +} + +/// Keep software gain at unity for ALSA-direct output. The public core /// volume seam does not reconfigure the protected device/backend; the DAC owns /// level in this mode. A QConnect peer is deliberately exempt because its /// remote volume is the active control. -pub(crate) async fn maybe_force_bitperfect_volume( - runtime: &Arc>, -) { +pub(crate) async fn maybe_force_bitperfect_volume(runtime: &Arc>) { let audio = match with_audio(|store| store.get_settings()) { Ok(audio) => audio, Err(error) => { @@ -2367,7 +2443,7 @@ pub(crate) async fn maybe_force_bitperfect_volume( return; } }; - if !uses_alsa_direct_hw(&audio) { + if !requires_alsa_direct_unity(&audio) { return; } let controlling_peer = match crate::qconnect_qt::service() { @@ -2385,6 +2461,46 @@ pub(crate) async fn maybe_force_bitperfect_volume( crate::now_playing::set_volume(1.0); } +/// Revalidate a persisted hardware-volume preference after startup or a route +/// change. A successful probe synchronizes QBZ to the physical mixer before +/// any stream reinitialization. A failed probe disables the preference and +/// reloads the player, leaving the protected direct path locked at unity. +pub(crate) async fn reconcile_alsa_hardware_volume( + runtime: &Arc>, +) -> Option { + let audio = match with_audio(|store| store.get_settings()) { + Ok(audio) => audio, + Err(error) => { + log::error!("[qbz-qt] re-read audio for hardware-volume probe failed: {error}"); + return None; + } + }; + if !audio.alsa_hardware_volume || !qbz_audio::alsa_direct::uses_alsa_direct_route(&audio) { + return None; + } + + match probe_selected_hardware_volume(&audio).await { + Ok(info) => { + seed_hardware_volume(runtime, &info); + Some(info.volume) + } + Err(error) => { + log::warn!("[qbz-qt] ALSA hardware volume unavailable; disabling the setting: {error}"); + if let Err(persist_error) = with_audio(|store| store.set_alsa_hardware_volume(false)) { + log::error!( + "[qbz-qt] failed to disable unavailable hardware volume: {persist_error}" + ); + return None; + } + apply_audio(runtime, Apply::Reinit); + crate::toast_qt::error(qbz_i18n::t( + "This ALSA device has no compatible hardware volume control. Direct playback remains fixed at 100%.", + )); + None + } + } +} + // --------------------------------------------------------------------------- // Handlers (settings.rs handle_bool / handle_select / handle_slider / // handle_string, including the cascades) @@ -2416,9 +2532,7 @@ pub async fn settings_bool(runtime: &Arc>, key: &str, "limit-quality-to-device" => { with_audio(|s| s.set_limit_quality_to_device(value)).map(|_| Apply::Reload) } - "alsa-hardware-volume" => { - with_audio(|s| s.set_alsa_hardware_volume(value)).map(|_| Apply::Reinit) - } + "alsa-hardware-volume" => set_alsa_hardware_volume(runtime, value).await, "exclusive-mode" => with_audio(|s| s.set_exclusive_mode(value)).map(|_| Apply::Reinit), "reserve-dac" => { with_audio(|s| s.set_reserve_dac_while_running(value)).map(|_| Apply::Reload) @@ -2715,9 +2829,24 @@ pub async fn settings_bool(runtime: &Arc>, key: &str, refresh_device_cap(runtime).await; } apply_audio(runtime, apply); + if key == "alsa-hardware-volume" { + maybe_force_bitperfect_volume(runtime).await; + } publish_snapshot().await; } - Err(e) => log::error!("[qbz-qt] settings persist failed ({key}): {e}"), + Err(e) => { + log::error!("[qbz-qt] settings persist failed ({key}): {e}"); + if key == "alsa-hardware-volume" { + // The failed enable path persisted `false`; push that state to + // the player/document and explain why the toggle bounced back. + apply_audio(runtime, Apply::Reinit); + maybe_force_bitperfect_volume(runtime).await; + publish_snapshot().await; + crate::toast_qt::error(qbz_i18n::t( + "This ALSA device has no compatible hardware volume control. Direct playback remains fixed at 100%.", + )); + } + } } } @@ -2752,6 +2881,16 @@ pub async fn settings_select(runtime: &Arc>, key: &st None => return, } }; + let previous_backend = audio_settings().backend_type.unwrap_or_default(); + if previous_backend == AudioBackendType::Alsa && backend != AudioBackendType::Alsa { + // This must complete BEFORE the new backend is persisted and + // initialized. Reinit used to drop the ALSA PCM but omitted + // the suspended-sink cleanup, while Refresh merely queued a + // release and raced its own re-enumeration. + if let Err(error) = release_output_device(runtime).await { + report_release_failure(&error); + } + } if let Err(e) = with_audio(|s| s.set_backend_type(Some(backend))) { log::error!("[qbz-qt] persist backend failed: {e}"); return; @@ -2821,6 +2960,7 @@ pub async fn settings_select(runtime: &Arc>, key: &st } // #638 fix 3, trigger 4 — a different DAC has a different ceiling. refresh_device_cap(runtime).await; + reconcile_alsa_hardware_volume(runtime).await; apply_audio(runtime, Apply::Reinit); } "dsd-mode" => { @@ -2841,6 +2981,7 @@ pub async fn settings_select(runtime: &Arc>, key: &st log::error!("[qbz-qt] persist alsa plugin failed: {e}"); return; } + reconcile_alsa_hardware_volume(runtime).await; apply_audio(runtime, Apply::Reinit); maybe_force_bitperfect_volume(runtime).await; } @@ -3228,9 +3369,8 @@ pub async fn settings_reset(runtime: &Arc>) { pub async fn refresh_devices(runtime: &Arc>) { // Release whatever the player holds, then re-enumerate from scratch (the // whole point of the button is a device that was not in the last list). - let player = runtime.core().player(); - if let Err(e) = player.release_device() { - log::warn!("[qbz-qt] release device failed: {e}"); + if let Err(error) = release_output_device(runtime).await { + report_release_failure(&error); } invalidate_device_cache(); // #638 fix 3, trigger 6 — Qt-only, and the easiest of the six to miss. @@ -3240,6 +3380,7 @@ pub async fn refresh_devices(runtime: &Arc>) { // default". Placed after the re-enumeration so the probe reads the new // device list, and before the publish so the row lands settled. refresh_device_cap(runtime).await; + reconcile_alsa_hardware_volume(runtime).await; publish_snapshot().await; } @@ -3299,18 +3440,29 @@ mod local_tab_order_tests { } #[test] - fn only_alsa_hw_requires_unity_software_volume() { + fn only_alsa_direct_without_a_hardware_mixer_requires_unity_volume() { let mut audio = qbz_audio::settings::AudioSettings::default(); - assert!(!uses_alsa_direct_hw(&audio)); + assert!(!requires_alsa_direct_unity(&audio)); audio.backend_type = Some(AudioBackendType::Alsa); + audio.output_device = Some("front:CARD=USB,DEV=0".to_string()); audio.alsa_plugin = Some(AlsaPlugin::Hw); - assert!(uses_alsa_direct_hw(&audio)); + assert!(requires_alsa_direct_unity(&audio)); + + audio.alsa_hardware_volume = true; + assert!(!requires_alsa_direct_unity(&audio)); + audio.alsa_hardware_volume = false; audio.alsa_plugin = Some(AlsaPlugin::PlugHw); - assert!(!uses_alsa_direct_hw(&audio)); - audio.backend_type = Some(AudioBackendType::PipeWire); + assert!(requires_alsa_direct_unity(&audio)); + audio.alsa_plugin = Some(AlsaPlugin::Pcm); + assert!(!requires_alsa_direct_unity(&audio)); + audio.alsa_plugin = Some(AlsaPlugin::Hw); - assert!(!uses_alsa_direct_hw(&audio)); + audio.output_device = None; + assert!(!requires_alsa_direct_unity(&audio)); + audio.backend_type = Some(AudioBackendType::PipeWire); + audio.output_device = Some("front:CARD=USB,DEV=0".to_string()); + assert!(!requires_alsa_direct_unity(&audio)); } } diff --git a/crates/qbzd/src/daemon.rs b/crates/qbzd/src/daemon.rs index 4e5741fba..01e465d45 100644 --- a/crates/qbzd/src/daemon.rs +++ b/crates/qbzd/src/daemon.rs @@ -316,7 +316,7 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec) -> Re // Both calls self-gate to no-ops when QBZ forced nothing. #[cfg(target_os = "linux")] { - qbz_audio::alsa_backend::resume_suspended_sink(); + let _ = qbz_audio::alsa_backend::resume_suspended_sink(); qbz_audio::pipewire_backend::PipeWireBackend::reset_pipewire_clock(); }