From 7f6ac9bae625182dcb540d3c2aef14c36bd79e2b Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 26 Feb 2026 22:32:08 -0600 Subject: [PATCH] fix: add sample rate fallback when DAC doesn't support requested rate Query the DAC's supported sample rates from /proc/asound/cardN/stream0 (maps PipeWire sink to ALSA card via pactl). If the track's native rate isn't supported (e.g., 176.4kHz), fall back to the nearest rate in the same family (e.g., 88.2kHz). Rodio resamples from track rate to stream rate automatically, so audio plays instead of producing silence. --- crates/qbz-audio/src/pipewire_backend.rs | 190 +++++++++++++++++++++-- 1 file changed, 173 insertions(+), 17 deletions(-) diff --git a/crates/qbz-audio/src/pipewire_backend.rs b/crates/qbz-audio/src/pipewire_backend.rs index f67f8a8b7..5622a2df4 100644 --- a/crates/qbz-audio/src/pipewire_backend.rs +++ b/crates/qbz-audio/src/pipewire_backend.rs @@ -41,6 +41,113 @@ impl PipeWireBackend { .output(); } + /// Get the ALSA card number for a PipeWire/PulseAudio sink name. + /// Parses `pactl list sinks` to find the `alsa.card` property. + fn get_alsa_card_for_sink(sink_name: &str) -> Option { + let output = Command::new("pactl") + .args(["list", "sinks"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut in_target_sink = false; + + for line in stdout.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("Sink #") { + if in_target_sink { + return None; // Passed target sink without finding alsa.card + } + } else if trimmed.starts_with("Name:") { + let name = trimmed.trim_start_matches("Name:").trim(); + in_target_sink = name == sink_name; + } else if in_target_sink && trimmed.starts_with("alsa.card = ") { + let card = trimmed + .trim_start_matches("alsa.card = ") + .trim_matches('"') + .to_string(); + return Some(card); + } + } + + None + } + + /// Query the DAC's supported sample rates from /proc/asound/cardN/stream0. + /// Returns None if rates can't be determined (non-USB device, continuous range, etc.) + fn get_sink_supported_rates(sink_name: &str) -> Option> { + let alsa_card = Self::get_alsa_card_for_sink(sink_name)?; + + let stream_path = format!("/proc/asound/card{}/stream0", alsa_card); + let content = std::fs::read_to_string(&stream_path).ok()?; + + // Collect all rates from Playback Rates: lines (handles multiple alt settings) + let mut in_playback = false; + let mut all_rates = Vec::new(); + + for line in content.lines() { + let trimmed = line.trim(); + if trimmed == "Playback:" { + in_playback = true; + } else if trimmed == "Capture:" { + in_playback = false; + } + if in_playback && trimmed.starts_with("Rates:") { + let rates_str = trimmed.trim_start_matches("Rates:").trim(); + if rates_str.contains("continuous") { + return None; // Any rate in range is supported + } + for rate_str in rates_str.split(',') { + if let Ok(rate) = rate_str.trim().parse::() { + if !all_rates.contains(&rate) { + all_rates.push(rate); + } + } + } + } + } + + if all_rates.is_empty() { + None + } else { + all_rates.sort(); + Some(all_rates) + } + } + + /// Find the best fallback sample rate in the same family. + /// 44.1kHz family: 44100, 88200, 176400, 352800 + /// 48kHz family: 48000, 96000, 192000, 384000 + fn find_best_fallback_rate(requested: u32, supported: &[u32]) -> u32 { + let is_441_family = requested % 44100 == 0; + + // Find highest supported rate in the same family that's <= requested + let mut candidates: Vec = supported + .iter() + .filter(|&&r| { + if is_441_family { + r % 44100 == 0 + } else { + r % 48000 == 0 + } + }) + .filter(|&&r| r <= requested) + .copied() + .collect(); + candidates.sort(); + + if let Some(&best) = candidates.last() { + return best; + } + + // No rate in the same family — use highest supported rate overall + supported.iter().copied().max().unwrap_or(48000) + } + /// Parse pactl output to get device list with pretty names fn enumerate_pipewire_sinks(&self) -> BackendResult> { // Get default sink @@ -188,15 +295,63 @@ impl AudioBackend for PipeWireBackend { std::thread::sleep(std::time::Duration::from_millis(200)); } - // Force PipeWire to use the requested sample rate (for bit-perfect playback) - log::info!("[PipeWire Backend] Forcing sample rate to {}Hz via pw-metadata", config.sample_rate); + // Check if the DAC supports the requested sample rate. + // Query via /proc/asound/ (USB DACs list discrete supported rates). + // If unsupported, fall back to the nearest rate in the same family + // (e.g., 176.4kHz → 88.2kHz). Rodio resamples from track rate to + // stream rate automatically. + let effective_sink = target_sink.clone().or_else(|| { + Command::new("pactl") + .args(["get-default-sink"]) + .output() + .ok() + .and_then(|o| { + if o.status.success() { + String::from_utf8(o.stdout).ok().map(|s| s.trim().to_string()) + } else { + None + } + }) + }); + + let effective_rate = if let Some(ref sink_name) = effective_sink { + match Self::get_sink_supported_rates(sink_name) { + Some(rates) if rates.contains(&config.sample_rate) => { + log::info!( + "[PipeWire Backend] DAC supports {}Hz (available: {:?})", + config.sample_rate, rates + ); + config.sample_rate + } + Some(rates) => { + let fallback = Self::find_best_fallback_rate(config.sample_rate, &rates); + log::warn!( + "[PipeWire Backend] DAC doesn't support {}Hz. Supported: {:?}. Falling back to {}Hz (resampled by rodio)", + config.sample_rate, rates, fallback + ); + fallback + } + None => { + log::info!( + "[PipeWire Backend] Could not determine DAC supported rates, using {}Hz", + config.sample_rate + ); + config.sample_rate + } + } + } else { + config.sample_rate + }; + + // Force PipeWire to use the effective sample rate (for bit-perfect playback) + log::info!("[PipeWire Backend] Forcing sample rate to {}Hz via pw-metadata", effective_rate); let metadata_result = Command::new("pw-metadata") - .args(["-n", "settings", "0", "clock.force-rate", &config.sample_rate.to_string()]) + .args(["-n", "settings", "0", "clock.force-rate", &effective_rate.to_string()]) .output(); match metadata_result { Ok(output) if output.status.success() => { - log::info!("[PipeWire Backend] Sample rate forced to {}Hz", config.sample_rate); + log::info!("[PipeWire Backend] Sample rate forced to {}Hz", effective_rate); } Ok(output) => { let stderr = String::from_utf8_lossy(&output.stderr); @@ -239,22 +394,23 @@ impl AudioBackend for PipeWireBackend { // Create output stream with custom sample rate configuration log::info!( - "[PipeWire Backend] Creating stream: {}Hz, {} channels, exclusive: {}", + "[PipeWire Backend] Creating stream: {}Hz (track: {}Hz), {} channels, exclusive: {}", + effective_rate, config.sample_rate, config.channels, config.exclusive_mode ); - // Create StreamConfig with desired sample rate + // Create StreamConfig with effective sample rate // Note: buffer_size here is unused — with_supported_config() resets it. // The actual buffer size is set via with_buffer_size() below. let stream_config = StreamConfig { channels: config.channels, - sample_rate: config.sample_rate, + sample_rate: effective_rate, buffer_size: BufferSize::Default, }; - // Check if device supports this configuration + // Check if CPAL device supports this configuration let supported_configs = device .supported_output_configs() .map_err(|e| format!("Failed to get supported configs: {}", e))?; @@ -262,13 +418,13 @@ impl AudioBackend for PipeWireBackend { let mut found_matching = false; for range in supported_configs { if range.channels() == config.channels - && config.sample_rate >= range.min_sample_rate() - && config.sample_rate <= range.max_sample_rate() + && effective_rate >= range.min_sample_rate() + && effective_rate <= range.max_sample_rate() { found_matching = true; log::info!( - "[PipeWire Backend] Device supports {}Hz (range: {}-{}Hz)", - config.sample_rate, + "[PipeWire Backend] CPAL device supports {}Hz (range: {}-{}Hz)", + effective_rate, range.min_sample_rate(), range.max_sample_rate() ); @@ -278,8 +434,8 @@ impl AudioBackend for PipeWireBackend { if !found_matching { log::warn!( - "[PipeWire Backend] Device may not support {}Hz, attempting anyway", - config.sample_rate + "[PipeWire Backend] CPAL device may not support {}Hz, attempting anyway", + effective_rate ); } @@ -300,7 +456,7 @@ impl AudioBackend for PipeWireBackend { } else { // ~100ms buffer, matching old vendored cpal period size. // Prevents underruns at high sample rates (192kHz = 19200 frames). - BufferSize::Fixed(config.sample_rate / 10) + BufferSize::Fixed(effective_rate / 10) }; log::info!("[PipeWire Backend] Buffer size: {:?}", cpal_buffer_size); @@ -310,9 +466,9 @@ impl AudioBackend for PipeWireBackend { .with_supported_config(&supported_config) .with_buffer_size(cpal_buffer_size) .open_stream() - .map_err(|e| format!("Failed to create output stream at {}Hz: {}", config.sample_rate, e))?; + .map_err(|e| format!("Failed to create output stream at {}Hz: {}", effective_rate, e))?; - log::info!("[PipeWire Backend] Output stream created successfully at {}Hz", config.sample_rate); + log::info!("[PipeWire Backend] Output stream created successfully at {}Hz", effective_rate); Ok(mixer_sink) }