fix(audio): make ALSA transitions capability-aware
cx
This commit is contained in:
16 files changed
+613
-114
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 "БИТ-ПЕРФЕКТ"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user