From da5ec2abcb23a2c790319391af063f10e646fb8c Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:13:11 -0600 Subject: [PATCH] =?UTF-8?q?feat(audio):=20implement=20controlled=20hw?= =?UTF-8?q?=E2=86=92plughw=20fallback=20for=20ALSA=20Direct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AlsaDirectError enum for structured error classification - Add BitPerfectMode enum to track runtime bit-perfect status - Implement automatic fallback: hw fails with format error → try plughw - Abort on non-recoverable errors (busy, permissions, invalid params) - Respect ALSA plugin selection from UI settings - Log runtime mode for debugging This allows SMSL-class DACs that fail on hw: to work via plughw: while maintaining bit-perfect guarantees (format conversion only, no resampling). cc --- src-tauri/src/audio/alsa_backend.rs | 92 +++++++++++++++++++++++++---- src-tauri/src/audio/backend.rs | 85 ++++++++++++++++++++++++++ src-tauri/src/player/mod.rs | 5 +- 3 files changed, 170 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/audio/alsa_backend.rs b/src-tauri/src/audio/alsa_backend.rs index 6f66226e7..9b22b7b98 100644 --- a/src-tauri/src/audio/alsa_backend.rs +++ b/src-tauri/src/audio/alsa_backend.rs @@ -411,31 +411,101 @@ impl AlsaBackend { /// Try to create direct ALSA stream for hw: devices (bypasses CPAL) /// Returns None if device is not a hw: device (should use CPAL instead) + /// + /// Implements controlled fallback: + /// 1. Try direct hw access first + /// 2. If format unsupported, try plughw (format conversion only, no resampling) + /// 3. Abort on other errors (busy, permissions, etc.) pub fn try_create_direct_stream( &self, config: &BackendConfig, - ) -> Option> { + ) -> Option> { let device_id = config.device_id.as_ref()?; - // Only use direct ALSA for hw: and plughw: devices + // Only use direct ALSA for hw:/plughw:/front: devices if !super::AlsaDirectStream::is_hw_device(device_id) { - log::info!("[ALSA Backend] Device '{}' is not hw:/plughw:, using CPAL", device_id); + log::info!("[ALSA Backend] Device '{}' is not hw:/plughw:/front:, using CPAL", device_id); return None; } + // Determine the base device path for hw/plughw attempts + // front:CARD=X,DEV=Y -> extract card name for hw attempts + let (hw_device, plughw_device) = if device_id.starts_with("front:CARD=") { + // front:CARD=AMP,DEV=0 -> need to find corresponding hw:X,0 + // For now, try the front: device directly as it's already hardware-direct + (device_id.to_string(), format!("plug:{}", device_id)) + } else if device_id.starts_with("hw:") { + (device_id.to_string(), device_id.replace("hw:", "plughw:")) + } else if device_id.starts_with("plughw:") { + // Already plughw, try it directly + (device_id.replace("plughw:", "hw:"), device_id.to_string()) + } else { + (device_id.to_string(), format!("plug:{}", device_id)) + }; + + // Respect ALSA plugin selection from settings + let try_hw_first = match config.alsa_plugin { + Some(AlsaPlugin::Hw) => true, + Some(AlsaPlugin::PlugHw) => false, // Skip hw, go directly to plughw + Some(AlsaPlugin::Pcm) => { + log::info!("[ALSA Backend] PCM mode selected, not using direct ALSA"); + return None; // Use CPAL instead + } + None => true, // Default: try hw first + }; + + if try_hw_first { + log::info!( + "[ALSA Backend] Attempting DIRECT hw stream: {} ({}Hz, {}ch)", + hw_device, + config.sample_rate, + config.channels + ); + + match super::AlsaDirectStream::new(&hw_device, config.sample_rate, config.channels) { + Ok(stream) => { + log::info!("[ALSA Backend] ✓ Direct hw stream created successfully"); + return Some(Ok((stream, super::backend::BitPerfectMode::DirectHardware))); + } + Err(e) => { + let error = super::backend::AlsaDirectError::from_alsa_error(&e); + log::warn!("[ALSA Backend] hw attempt failed: {}", error); + + if !error.allows_plughw_fallback() { + // Non-recoverable error (busy, permissions, etc.) + log::error!("[ALSA Backend] Cannot fallback - error type: {:?}", error); + return Some(Err(format!( + "ALSA Direct failed: {}. Device may be in use or inaccessible.", + error + ))); + } + + log::info!("[ALSA Backend] Format unsupported on hw, trying plughw fallback..."); + } + } + } + + // Try plughw fallback (format conversion only) log::info!( - "[ALSA Backend] Creating DIRECT ALSA stream for hw: device: {} ({}Hz, {}ch)", - device_id, + "[ALSA Backend] Attempting plughw stream: {} ({}Hz, {}ch)", + plughw_device, config.sample_rate, config.channels ); - // Create direct ALSA stream (bypasses rodio/CPAL) - Some(super::AlsaDirectStream::new( - device_id, - config.sample_rate, - config.channels, - )) + match super::AlsaDirectStream::new(&plughw_device, config.sample_rate, config.channels) { + Ok(stream) => { + log::info!("[ALSA Backend] ✓ plughw stream created (bit-perfect with format conversion)"); + Some(Ok((stream, super::backend::BitPerfectMode::PluginFallback))) + } + Err(e) => { + log::error!("[ALSA Backend] plughw fallback also failed: {}", e); + Some(Err(format!( + "Bit-perfect playback could not be established. hw failed, plughw failed: {}", + e + ))) + } + } } } diff --git a/src-tauri/src/audio/backend.rs b/src-tauri/src/audio/backend.rs index d36a06991..7b4e99c1e 100644 --- a/src-tauri/src/audio/backend.rs +++ b/src-tauri/src/audio/backend.rs @@ -117,6 +117,91 @@ pub struct BackendConfig { /// Result type for backend operations pub type BackendResult = Result; +/// ALSA Direct stream error classification +/// Used to determine if fallback to plughw is appropriate +#[derive(Debug, Clone)] +pub enum AlsaDirectError { + /// PCM format not supported by hardware (can fallback to plughw) + UnsupportedFormat(String), + /// Device is busy/in use by another application + DeviceBusy(String), + /// Permission denied to access device + PermissionDenied(String), + /// Invalid parameters (channels, sample rate) + InvalidParams(String), + /// Device not found + DeviceNotFound(String), + /// Generic/unknown error + Other(String), +} + +impl std::fmt::Display for AlsaDirectError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AlsaDirectError::UnsupportedFormat(msg) => write!(f, "Unsupported format: {}", msg), + AlsaDirectError::DeviceBusy(msg) => write!(f, "Device busy: {}", msg), + AlsaDirectError::PermissionDenied(msg) => write!(f, "Permission denied: {}", msg), + AlsaDirectError::InvalidParams(msg) => write!(f, "Invalid parameters: {}", msg), + AlsaDirectError::DeviceNotFound(msg) => write!(f, "Device not found: {}", msg), + AlsaDirectError::Other(msg) => write!(f, "{}", msg), + } + } +} + +impl AlsaDirectError { + /// Check if this error allows fallback to plughw + pub fn allows_plughw_fallback(&self) -> bool { + matches!(self, AlsaDirectError::UnsupportedFormat(_)) + } + + /// Create from raw ALSA error message + pub fn from_alsa_error(msg: &str) -> Self { + let msg_lower = msg.to_lowercase(); + + if msg_lower.contains("no supported audio format") + || msg_lower.contains("format") + || msg_lower.contains("s24_3le") + || msg_lower.contains("s24le") + || msg_lower.contains("sample format") + { + AlsaDirectError::UnsupportedFormat(msg.to_string()) + } else if msg_lower.contains("busy") + || msg_lower.contains("resource temporarily unavailable") + || msg_lower.contains("device or resource busy") + { + AlsaDirectError::DeviceBusy(msg.to_string()) + } else if msg_lower.contains("permission") + || msg_lower.contains("access denied") + || msg_lower.contains("operation not permitted") + { + AlsaDirectError::PermissionDenied(msg.to_string()) + } else if msg_lower.contains("not found") + || msg_lower.contains("no such") + || msg_lower.contains("doesn't exist") + { + AlsaDirectError::DeviceNotFound(msg.to_string()) + } else if msg_lower.contains("invalid") + || msg_lower.contains("channels") + || msg_lower.contains("rate") + { + AlsaDirectError::InvalidParams(msg.to_string()) + } else { + AlsaDirectError::Other(msg.to_string()) + } + } +} + +/// Runtime mode for bit-perfect status tracking +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BitPerfectMode { + /// Direct hardware access (hw:), guaranteed bit-perfect + DirectHardware, + /// Plugin hardware fallback (plughw:), bit-perfect with format conversion only + PluginFallback, + /// Not using bit-perfect path (pcm, pipewire, pulse) + Disabled, +} + /// Audio backend trait /// /// All audio backends must implement this trait to provide diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs index 454455571..c5ca06bfd 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -451,7 +451,10 @@ fn try_init_stream_with_backend( // Downcast backend to AlsaBackend to access try_create_direct_stream if let Some(alsa_backend) = backend.as_any().downcast_ref::() { if let Some(result) = alsa_backend.try_create_direct_stream(&config) { - return Some(result.map(|stream| StreamType::AlsaDirect(Arc::new(stream)))); + return Some(result.map(|(stream, mode)| { + log::info!("ALSA Direct stream created with mode: {:?}", mode); + StreamType::AlsaDirect(Arc::new(stream)) + })); } } }