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));
}
}