feat(audio): implement controlled hw→plughw fallback for ALSA Direct

- 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
This commit is contained in:
vicrodh committed 2026-02-01 19:13:11 -06:00
1 parent 4670b1548a
commit da5ec2abcb
3 files changed
+170 -12

No files matched your search

+81 -11
View File
@@ -411,31 +411,101 @@ impl AlsaBackend {
/// Try to create direct ALSA stream for hw: devices (bypasses CPAL) /// Try to create direct ALSA stream for hw: devices (bypasses CPAL)
/// Returns None if device is not a hw: device (should use CPAL instead) /// 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( pub fn try_create_direct_stream(
&self, &self,
config: &BackendConfig, config: &BackendConfig,
) -> Option<Result<super::AlsaDirectStream, String>> { ) -> Option<Result<(super::AlsaDirectStream, super::backend::BitPerfectMode), String>> {
let device_id = config.device_id.as_ref()?; 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) { 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; 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!( log::info!(
"[ALSA Backend] Creating DIRECT ALSA stream for hw: device: {} ({}Hz, {}ch)", "[ALSA Backend] Attempting plughw stream: {} ({}Hz, {}ch)",
device_id, plughw_device,
config.sample_rate, config.sample_rate,
config.channels config.channels
); );
// Create direct ALSA stream (bypasses rodio/CPAL) match super::AlsaDirectStream::new(&plughw_device, config.sample_rate, config.channels) {
Some(super::AlsaDirectStream::new( Ok(stream) => {
device_id, log::info!("[ALSA Backend] ✓ plughw stream created (bit-perfect with format conversion)");
config.sample_rate, Some(Ok((stream, super::backend::BitPerfectMode::PluginFallback)))
config.channels, }
)) 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
)))
}
}
} }
} }
+85
View File
@@ -117,6 +117,91 @@ pub struct BackendConfig {
/// Result type for backend operations /// Result type for backend operations
pub type BackendResult<T> = Result<T, String>; pub type BackendResult<T> = Result<T, String>;
/// 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 /// Audio backend trait
/// ///
/// All audio backends must implement this trait to provide /// All audio backends must implement this trait to provide
+4 -1
View File
@@ -451,7 +451,10 @@ fn try_init_stream_with_backend(
// Downcast backend to AlsaBackend to access try_create_direct_stream // Downcast backend to AlsaBackend to access try_create_direct_stream
if let Some(alsa_backend) = backend.as_any().downcast_ref::<crate::audio::alsa_backend::AlsaBackend>() { if let Some(alsa_backend) = backend.as_any().downcast_ref::<crate::audio::alsa_backend::AlsaBackend>() {
if let Some(result) = alsa_backend.try_create_direct_stream(&config) { 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))
}));
} }
} }
} }