Files
qbz/crates/qbz-audio/src/pipewire_backend.rs
T
vicrodh 83e43949b7 feat(audio): add skip_sink_switch option for JACK/DAW routing (#263)
When enabled, skips pactl set-default-sink on stream creation.
Preserves external audio routing (JACK, qjackctl, Reaper).
Mutually exclusive with DAC passthrough.

cc
2026-04-04 12:08:22 -06:00

663 lines
26 KiB
Rust

//! PipeWire audio backend
//!
//! Uses PipeWire/PulseAudio for audio output with device selection.
//! - Enumerates devices using pactl (pretty names)
//! - Sets PULSE_SINK environment variable for device routing
//! - Creates stream using CPAL "pulse" or "pipewire" device
//! - Does NOT change system default (only affects QBZ)
use super::backend::{AudioBackend, AudioBackendType, AudioDevice, BackendConfig, BackendResult};
use rodio::{
cpal::{
traits::{DeviceTrait, HostTrait},
BufferSize, SampleFormat, StreamConfig, SupportedBufferSize, SupportedStreamConfig,
},
DeviceSinkBuilder, MixerDeviceSink,
};
use std::process::Command;
pub struct PipeWireBackend {
#[allow(dead_code)]
host: rodio::cpal::Host,
}
impl PipeWireBackend {
pub fn new() -> BackendResult<Self> {
Ok(Self {
host: rodio::cpal::default_host(),
})
}
/// Reset PipeWire clock.force-rate and clock.force-quantum to 0.
/// Call this when playback stops so other apps aren't stuck at a forced rate.
/// Quantum reset is kept for safety even though we no longer force it.
pub fn reset_pipewire_clock() {
log::info!("[PipeWire Backend] Resetting clock.force-rate and clock.force-quantum to 0");
let _ = Command::new("pw-metadata")
.args(["-n", "settings", "0", "clock.force-rate", "0"])
.output();
let _ = Command::new("pw-metadata")
.args(["-n", "settings", "0", "clock.force-quantum", "0"])
.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<String> {
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.)
pub fn get_sink_supported_rates(sink_name: &str) -> Option<Vec<u32>> {
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::<u32>() {
if !all_rates.contains(&rate) {
all_rates.push(rate);
}
}
}
}
}
if all_rates.is_empty() {
None
} else {
all_rates.sort();
Some(all_rates)
}
}
/// Query the current PipeWire graph sample rate via pw-metadata.
fn get_pipewire_current_rate() -> Option<u32> {
let output = Command::new("pw-metadata")
.args(["-n", "settings", "0", "clock.rate"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
// pw-metadata output: "Found "settings" metadata 0\nupdate: id:0 key:'clock.rate' value:'96000' type:''"
for line in stdout.lines() {
if line.contains("clock.rate") && line.contains("value:") {
// Extract value between single quotes after "value:"
if let Some(start) = line.find("value:'") {
let after = &line[start + 7..];
if let Some(end) = after.find('\'') {
return after[..end].parse::<u32>().ok();
}
}
}
}
None
}
/// 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<u32> = 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<Vec<AudioDevice>> {
// Get default sink
let default_sink = 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
}
});
// Get all sinks with details
let output = Command::new("pactl")
.args(["list", "sinks"])
.output()
.map_err(|e| format!("Failed to run pactl: {}", e))?;
if !output.status.success() {
return Err("pactl command failed".to_string());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut devices = Vec::new();
// Parse pactl output
let mut current_name: Option<String> = None;
let mut current_description: Option<String> = None;
let mut current_max_rate: Option<u32> = None;
let mut current_is_hardware: bool = false;
let mut current_device_bus: Option<String> = None;
for line in stdout.lines() {
let line = line.trim();
if line.starts_with("Sink #") {
// Save previous device if complete
if let (Some(id), Some(name)) = (current_name.take(), current_description.take()) {
let is_default = default_sink.as_ref().map(|d| d == &id).unwrap_or(false);
devices.push(AudioDevice {
id: id.clone(),
name,
description: None,
is_default,
max_sample_rate: current_max_rate.take(),
supported_sample_rates: None, // PipeWire handles sample rate conversion
device_bus: current_device_bus.take(),
is_hardware: current_is_hardware,
});
}
current_max_rate = None;
current_is_hardware = false;
current_device_bus = None;
} else if line.starts_with("Name:") {
current_name = Some(line.trim_start_matches("Name:").trim().to_string());
} else if line.starts_with("Description:") {
current_description =
Some(line.trim_start_matches("Description:").trim().to_string());
} else if line.starts_with("Flags:") {
// Check for HARDWARE flag
current_is_hardware = line.contains("HARDWARE");
} else if line.contains("Sample Specification:") {
// Try to parse sample rate from lines like "Sample Specification: s32le 2ch 192000Hz"
if let Some(hz_pos) = line.find("Hz") {
let before_hz = &line[..hz_pos];
if let Some(last_space) = before_hz.rfind(' ') {
if let Ok(rate) = before_hz[last_space + 1..].parse::<u32>() {
current_max_rate = Some(rate);
}
}
}
} else if line.starts_with("device.bus = ") {
// Parse device.bus property (e.g., "usb", "pci", "bluetooth")
let bus = line
.trim_start_matches("device.bus = ")
.trim_matches('"')
.to_string();
current_device_bus = Some(bus);
}
}
// Don't forget the last device
if let (Some(id), Some(name)) = (current_name, current_description) {
let is_default = default_sink.as_ref().map(|d| d == &id).unwrap_or(false);
devices.push(AudioDevice {
id,
name,
description: None,
is_default,
max_sample_rate: current_max_rate,
supported_sample_rates: None, // PipeWire handles sample rate conversion
device_bus: current_device_bus,
is_hardware: current_is_hardware,
});
}
log::info!(
"[PipeWire Backend] Enumerated {} devices via pactl",
devices.len()
);
for (idx, dev) in devices.iter().enumerate() {
log::info!(
" [{}] {} (id: {}, bus: {:?}, hw: {})",
idx,
dev.name,
dev.id,
dev.device_bus,
dev.is_hardware
);
}
Ok(devices)
}
}
impl AudioBackend for PipeWireBackend {
fn backend_type(&self) -> AudioBackendType {
AudioBackendType::PipeWire
}
fn enumerate_devices(&self) -> BackendResult<Vec<AudioDevice>> {
self.enumerate_pipewire_sinks()
}
fn create_output_stream(&self, config: &BackendConfig) -> BackendResult<MixerDeviceSink> {
let target_sink = config.device_id.clone();
// Temporarily set default sink to target (if specified)
// We DON'T restore it - let the user's system keep the selected device as default
// This is actually the expected behavior: when you select a device, it becomes the default
// When skip_sink_switch is true, skip this entirely to preserve external routing (JACK/qjackctl)
if config.skip_sink_switch {
log::info!("[PipeWire Backend] Skipping set-default-sink (skip_sink_switch enabled)");
} else if let Some(sink_name) = &target_sink {
log::info!("[PipeWire Backend] Setting default sink to: {}", sink_name);
let set_result = Command::new("pactl")
.args(["set-default-sink", sink_name])
.output();
match set_result {
Ok(output) if output.status.success() => {
log::info!("[PipeWire Backend] Default sink set to {}", sink_name);
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("[PipeWire Backend] Failed to set default sink: {}", stderr);
}
Err(e) => {
log::warn!(
"[PipeWire Backend] Error executing pactl set-default-sink: {}",
e
);
}
}
// Wait for PipeWire to process the default sink change
std::thread::sleep(std::time::Duration::from_millis(200));
}
// 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",
&effective_rate.to_string(),
])
.output();
match metadata_result {
Ok(output) if output.status.success() => {
log::info!(
"[PipeWire Backend] Sample rate forced to {}Hz",
effective_rate
);
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("[PipeWire Backend] Failed to force sample rate: {}", stderr);
}
Err(e) => {
log::warn!("[PipeWire Backend] Error executing pw-metadata: {}", e);
}
}
// Note: clock.force-quantum is intentionally NOT set.
// rodio 0.22's MixerDeviceSink has its own internal mixer thread that
// cannot synchronize with PipeWire's forced quantum, causing massive
// buffer underruns at sample rates >= 88.2kHz. clock.force-rate alone
// is sufficient for bit-perfect sample rate switching.
// Wait for PipeWire to apply the sample rate change.
// USB hubs (e.g. Razer USB4 Dock) may need longer than direct DACs.
std::thread::sleep(std::time::Duration::from_millis(500));
// Create a NEW host (will use current default sink)
log::info!("[PipeWire Backend] Creating fresh CPAL host...");
let fresh_host = rodio::cpal::default_host();
// Find a CPAL device backed by PulseAudio/PipeWire.
// Newer CPAL description().name() returns friendly labels like
// "PipeWire Sound Server" instead of raw ids ("pipewire"/"pulse").
let mut best_device: Option<rodio::cpal::Device> = None;
let mut best_score: u8 = 0;
let mut available_output_devices: Vec<String> = Vec::new();
for device in fresh_host
.output_devices()
.map_err(|e| format!("Failed to enumerate CPAL devices: {}", e))?
{
let device_name = device
.description()
.map(|desc| desc.name().to_string())
.unwrap_or_else(|_| "unknown".to_string());
let device_name_lower = device_name.to_ascii_lowercase();
available_output_devices.push(device_name.clone());
let score = if device_name_lower == "pipewire" || device_name_lower == "pulse" {
3
} else if device_name_lower.contains("pipewire sound server")
|| device_name_lower.contains("pulseaudio sound server")
{
2
} else if device_name_lower.contains("pipewire")
|| device_name_lower.contains("pulseaudio")
{
1
} else {
0
};
if score > best_score {
best_score = score;
best_device = Some(device);
}
}
let device = best_device.ok_or_else(|| {
format!(
"Could not find 'pulse' or 'pipewire' CPAL device. Is PulseAudio/PipeWire running? Available output devices: {:?}",
available_output_devices
)
})?;
let device_name = device
.description()
.map(|desc| desc.name().to_string())
.unwrap_or_else(|_| "unknown".to_string());
log::info!("[PipeWire Backend] Using CPAL device: {}", device_name);
// Create output stream with custom sample rate configuration
log::info!(
"[PipeWire Backend] Creating stream: {}Hz (track: {}Hz), {} channels, exclusive: {}",
effective_rate,
config.sample_rate,
config.channels,
config.exclusive_mode
);
// 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: effective_rate,
buffer_size: BufferSize::Default,
};
// Check if CPAL device supports this configuration
let supported_configs = device
.supported_output_configs()
.map_err(|e| format!("Failed to get supported configs: {}", e))?;
let mut found_matching = false;
for range in supported_configs {
if range.channels() == config.channels
&& effective_rate >= range.min_sample_rate()
&& effective_rate <= range.max_sample_rate()
{
found_matching = true;
log::info!(
"[PipeWire Backend] CPAL device supports {}Hz (range: {}-{}Hz)",
effective_rate,
range.min_sample_rate(),
range.max_sample_rate()
);
break;
}
}
if !found_matching {
log::warn!(
"[PipeWire Backend] CPAL device may not support {}Hz, attempting anyway",
effective_rate
);
}
// Create SupportedStreamConfig
let supported_config = SupportedStreamConfig::new(
stream_config.channels,
stream_config.sample_rate,
SupportedBufferSize::Range { min: 64, max: 8192 },
SampleFormat::F32,
);
// Compute buffer size — must be applied AFTER with_supported_config()
// because that method resets buffer_size to Default via ..Default::default().
// MixerDeviceSink has zero internal buffering, so CPAL's buffer is the
// ONLY buffer between the mixer and audio hardware.
let cpal_buffer_size = if config.exclusive_mode {
BufferSize::Fixed(512) // Low latency for exclusive mode
} else {
// ~100ms buffer, matching old vendored cpal period size.
// Prevents underruns at high sample rates (192kHz = 19200 frames).
BufferSize::Fixed(effective_rate / 10)
};
log::info!("[PipeWire Backend] Buffer size: {:?}", cpal_buffer_size);
// Create MixerDeviceSink with custom config
let mixer_sink = DeviceSinkBuilder::from_device(device)
.map_err(|e| format!("Failed to create device sink builder: {}", e))?
.with_supported_config(&supported_config)
.with_buffer_size(cpal_buffer_size)
.open_stream()
.map_err(|e| {
format!(
"Failed to create output stream at {}Hz: {}",
effective_rate, e
)
})?;
log::info!(
"[PipeWire Backend] Output stream created successfully at {}Hz",
effective_rate
);
// Re-apply clock.force-rate AFTER stream creation.
// When resuming after PipeWire dropped the stream during pause,
// the graph may have reverted to the DAC's default rate (e.g. 44100).
// The pre-stream force-rate can be ignored if no streams were active.
// Re-applying now that the stream exists forces PipeWire to reconfigure
// the graph at the correct rate.
if effective_rate != 44100 && effective_rate != 48000 {
let _ = Command::new("pw-metadata")
.args([
"-n",
"settings",
"0",
"clock.force-rate",
&effective_rate.to_string(),
])
.output();
log::info!(
"[PipeWire Backend] Re-applied clock.force-rate={}Hz after stream creation",
effective_rate
);
// Verify PipeWire actually applied the rate.
// USB hubs/docks may need extra time for rate switching.
std::thread::sleep(std::time::Duration::from_millis(100));
if let Some(actual_rate) = Self::get_pipewire_current_rate() {
if actual_rate != effective_rate {
log::warn!(
"[PipeWire Backend] Rate mismatch: requested {}Hz but PipeWire reports {}Hz. \
Retrying with longer delay...",
effective_rate,
actual_rate
);
// Give slower USB devices more time, then force again
std::thread::sleep(std::time::Duration::from_millis(500));
let _ = Command::new("pw-metadata")
.args([
"-n",
"settings",
"0",
"clock.force-rate",
&effective_rate.to_string(),
])
.output();
std::thread::sleep(std::time::Duration::from_millis(200));
if let Some(retry_rate) = Self::get_pipewire_current_rate() {
if retry_rate == effective_rate {
log::info!(
"[PipeWire Backend] Rate verified after retry: {}Hz",
retry_rate
);
} else {
log::warn!(
"[PipeWire Backend] Rate still {}Hz after retry (expected {}Hz). \
Audio may play at wrong speed.",
retry_rate,
effective_rate
);
}
}
} else {
log::info!(
"[PipeWire Backend] Rate verified: {}Hz",
actual_rate
);
}
}
}
Ok(mixer_sink)
}
fn is_available(&self) -> bool {
// Check if pactl is available (PipeWire/PulseAudio)
Command::new("pactl")
.arg("info")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn description(&self) -> &'static str {
"PipeWire (Recommended) - Modern audio server with device sharing"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}