fix(audio): make ALSA transitions capability-aware

cx
This commit is contained in:
vicrodh committed 2026-08-28 02:58:04 -06:00
1 parent 0f69895f15
commit 2df2dafe67
16 files changed
+613 -114

No files matched your search

+67 -7
View File
@@ -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
+221 -50
View File
@@ -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=<name>` 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<alsa::mixer::Mixer, String> {
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::<Vec<_>>();
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::<Vec<_>>()
.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<HardwareVolumeInfo, String> {
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<HardwareVolumeInfo, String> {
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<Self, String> {
@@ -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));
}
}
@@ -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"
@@ -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."
@@ -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"
@@ -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"
@@ -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 "ビットパーフェクト"
@@ -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"
@@ -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"
@@ -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 "БИТ-ПЕРФЕКТ"
+62 -8
View File
@@ -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<Result<(), String>>,
},
/// Append next track to current engine for gapless playback (Rodio only)
PlayNext {
data: Vec<u8>,
@@ -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)
+2 -2
View File
@@ -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) }
+11 -4
View File
@@ -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
+11 -7
View File
@@ -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]
+173 -21
View File
@@ -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<qbz_audio::alsa_direct::HardwareVolumeInfo, String> {
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<AppRuntime<LoggingAdapter>>,
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<AppRuntime<LoggingAdapter>>,
enabled: bool,
) -> Result<Apply, String> {
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<AppRuntime<LoggingAdapter>>, apply: Apply) {
let reinit = match apply {
Apply::None => return,
@@ -2348,18 +2409,33 @@ fn apply_audio(runtime: &Arc<AppRuntime<LoggingAdapter>>, 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<AppRuntime<LoggingAdapter>>) -> 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<AppRuntime<LoggingAdapter>>,
) {
pub(crate) async fn maybe_force_bitperfect_volume(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
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<AppRuntime<LoggingAdapter>>,
) -> Option<f32> {
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<AppRuntime<LoggingAdapter>>, 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<AppRuntime<LoggingAdapter>>, 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<AppRuntime<LoggingAdapter>>, 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<AppRuntime<LoggingAdapter>>, 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<AppRuntime<LoggingAdapter>>, 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<AppRuntime<LoggingAdapter>>) {
pub async fn refresh_devices(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
// 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<AppRuntime<LoggingAdapter>>) {
// 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));
}
}
+1 -1
View File
@@ -316,7 +316,7 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>) -> 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();
}