chore(qconnect): checkpoint current poc state

cx
This commit is contained in:
vicrodh committed 2026-03-13 17:28:05 -06:00
1 parent 6d20360dc3
commit de29f1e2e0
92 files changed
+3886 -2433

No files matched your search

+124 -46
View File
@@ -8,7 +8,9 @@
//! Uses CPAL's ALSA host with specific device selection.
//! Device enumeration reads directly from /proc/asound (no alsa-utils dependency).
use super::backend::{AlsaPlugin, AudioBackend, AudioBackendType, AudioDevice, BackendConfig, BackendResult};
use super::backend::{
AlsaPlugin, AudioBackend, AudioBackendType, AudioDevice, BackendConfig, BackendResult,
};
use rodio::{
cpal::{
traits::{DeviceTrait, HostTrait},
@@ -225,7 +227,11 @@ fn read_card_pcm_devices(card_num: &str) -> Vec<ProcPcmInfo> {
if !device_num.is_empty() {
devices.push(ProcPcmInfo {
device_num,
name: if pcm_name.is_empty() { "Unknown".to_string() } else { pcm_name },
name: if pcm_name.is_empty() {
"Unknown".to_string()
} else {
pcm_name
},
});
}
}
@@ -235,7 +241,9 @@ fn read_card_pcm_devices(card_num: &str) -> Vec<ProcPcmInfo> {
// Sort by device number
devices.sort_by(|a, b| {
a.device_num.parse::<u32>().unwrap_or(0)
a.device_num
.parse::<u32>()
.unwrap_or(0)
.cmp(&b.device_num.parse::<u32>().unwrap_or(0))
});
@@ -257,7 +265,8 @@ fn build_card_info_map() -> HashMap<String, (String, String)> {
/// Find card number by short name (e.g., "C20" -> "0")
fn find_card_number_by_name(short_name: &str) -> Option<String> {
let cards = read_proc_asound_cards();
cards.iter()
cards
.iter()
.find(|c| c.short_name == short_name)
.map(|c| c.number.clone())
}
@@ -271,10 +280,15 @@ fn extract_card_name_from_device(device_id: &str) -> Option<String> {
Some(after_card.split(',').next()?.to_string())
} else if device_id.starts_with("hw:") || device_id.starts_with("plughw:") {
// hw:0,0 -> card number 0 -> look up short name
let prefix = if device_id.starts_with("hw:") { "hw:" } else { "plughw:" };
let prefix = if device_id.starts_with("hw:") {
"hw:"
} else {
"plughw:"
};
let card_num = device_id.strip_prefix(prefix)?.split(',').next()?;
let cards = read_proc_asound_cards();
cards.iter()
cards
.iter()
.find(|c| c.number == card_num)
.map(|c| c.short_name.clone())
} else {
@@ -337,7 +351,10 @@ impl AlsaBackend {
// Check if ALSA is available
// cpal 0.17 changed HostId::name() from "ALSA" to "Alsa" (uses stringify!)
if !available_hosts.iter().any(|h| h.name().eq_ignore_ascii_case("alsa")) {
if !available_hosts
.iter()
.any(|h| h.name().eq_ignore_ascii_case("alsa"))
{
return Err("ALSA host not available on this system".to_string());
}
@@ -346,8 +363,9 @@ impl AlsaBackend {
available_hosts
.into_iter()
.find(|h| h.name().eq_ignore_ascii_case("alsa"))
.ok_or("ALSA host not found".to_string())?
).map_err(|e| format!("Failed to create ALSA host: {}", e))?;
.ok_or("ALSA host not found".to_string())?,
)
.map_err(|e| format!("Failed to create ALSA host: {}", e))?;
log::info!("[ALSA Backend] Initialized successfully");
@@ -373,24 +391,32 @@ impl AlsaBackend {
for card in &cards {
log::debug!(
"[ALSA Backend] Card {}: {} = {} ({} PCM devices)",
card.number, card.short_name, card.long_name, card.pcm_playback_devices.len()
card.number,
card.short_name,
card.long_name,
card.pcm_playback_devices.len()
);
}
// Build CPAL device map for sample rate enrichment (OPTIONAL - may be incomplete)
let cpal_devices = self.build_cpal_device_map();
log::debug!("[ALSA Backend] CPAL found {} devices for enrichment", cpal_devices.len());
log::debug!(
"[ALSA Backend] CPAL found {} devices for enrichment",
cpal_devices.len()
);
// Add system default device
let default_sample_rates = cpal_devices.get("default")
let default_sample_rates = cpal_devices
.get("default")
.and_then(|d| get_supported_sample_rates(d));
let default_max_rate = default_sample_rates.as_ref()
let default_max_rate = default_sample_rates
.as_ref()
.and_then(|rates| rates.iter().max().copied());
devices.push(AudioDevice {
id: "default".to_string(),
name: "default".to_string(),
description: None, // Frontend shows "System Default"
description: None, // Frontend shows "System Default"
is_default: true,
max_sample_rate: default_max_rate.or(Some(384000)),
supported_sample_rates: default_sample_rates,
@@ -402,7 +428,8 @@ impl AlsaBackend {
for card in &cards {
// Add sysdefault:CARD=name (card default with software mixing)
let sysdefault_id = format!("sysdefault:CARD={}", card.short_name);
let sysdefault_rates = cpal_devices.get(&sysdefault_id)
let sysdefault_rates = cpal_devices
.get(&sysdefault_id)
.and_then(|d| get_supported_sample_rates(d));
devices.push(AudioDevice {
@@ -410,12 +437,13 @@ impl AlsaBackend {
name: sysdefault_id.clone(),
description: Some(format!("{}, {}", card.long_name, sysdefault_id)),
is_default: false,
max_sample_rate: sysdefault_rates.as_ref()
max_sample_rate: sysdefault_rates
.as_ref()
.and_then(|r| r.iter().max().copied())
.or(Some(192000)),
supported_sample_rates: sysdefault_rates,
device_bus: None,
is_hardware: false, // sysdefault uses dmix
is_hardware: false, // sysdefault uses dmix
});
// Add PCM-specific devices (front:, iec958:, hdmi:)
@@ -424,14 +452,18 @@ impl AlsaBackend {
let device_prefix = if pcm.name.to_lowercase().contains("hdmi") {
"hdmi"
} else if pcm.name.to_lowercase().contains("iec958")
|| pcm.name.to_lowercase().contains("spdif")
|| pcm.name.to_lowercase().contains("s/pdif") {
|| pcm.name.to_lowercase().contains("spdif")
|| pcm.name.to_lowercase().contains("s/pdif")
{
"iec958"
} else {
"front" // Default to front: for analog/USB audio
"front" // Default to front: for analog/USB audio
};
let device_id = format!("{}:CARD={},DEV={}", device_prefix, card.short_name, pcm.device_num);
let device_id = format!(
"{}:CARD={},DEV={}",
device_prefix, card.short_name, pcm.device_num
);
// Skip if already added (shouldn't happen, but be safe)
if devices.iter().any(|d| d.id == device_id) {
@@ -439,11 +471,13 @@ impl AlsaBackend {
}
// Try to get sample rates from CPAL (may fail if device is busy)
let sample_rates = cpal_devices.get(&device_id)
let sample_rates = cpal_devices
.get(&device_id)
.and_then(|d| get_supported_sample_rates(d));
let max_rate = sample_rates.as_ref()
let max_rate = sample_rates
.as_ref()
.and_then(|r| r.iter().max().copied())
.or(Some(384000)); // Assume high capability if CPAL unavailable
.or(Some(384000)); // Assume high capability if CPAL unavailable
devices.push(AudioDevice {
id: device_id.clone(),
@@ -504,7 +538,10 @@ impl AlsaBackend {
// 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:/front:, using CPAL", device_id);
log::info!(
"[ALSA Backend] Device '{}' is not hw:/plughw:/front:, using CPAL",
device_id
);
return None;
}
@@ -538,7 +575,9 @@ impl AlsaBackend {
}
log::info!(
"[ALSA Backend] Hardware confirms support for {}Hz (card '{}', rates: {:?})",
config.sample_rate, card_name, hw_rates
config.sample_rate,
card_name,
hw_rates
);
}
}
@@ -583,23 +622,38 @@ impl AlsaBackend {
log::info!("[ALSA Backend] PipeWire sink suspended, waiting for device release...");
std::thread::sleep(std::time::Duration::from_millis(200));
match super::AlsaDirectStream::new(&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 after PipeWire suspend");
return Some(Ok((stream, super::backend::BitPerfectMode::DirectHardware)));
return Some(Ok((
stream,
super::backend::BitPerfectMode::DirectHardware,
)));
}
Err(e2) => {
log::warn!("[ALSA Backend] Retry after suspend also failed: {}", e2);
log::warn!(
"[ALSA Backend] Retry after suspend also failed: {}",
e2
);
}
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("[ALSA Backend] Failed to suspend PipeWire sink: {}", stderr);
log::warn!(
"[ALSA Backend] Failed to suspend PipeWire sink: {}",
stderr
);
}
}
// If retry failed or suspend failed, fall through to error
log::error!("[ALSA Backend] Cannot acquire device even after PipeWire suspend");
log::error!(
"[ALSA Backend] Cannot acquire device even after PipeWire suspend"
);
return Some(Err(format!(
"ALSA Direct failed: {}. Device may be in use or inaccessible.",
error
@@ -629,7 +683,9 @@ impl AlsaBackend {
)));
}
log::info!("[ALSA Backend] Format unsupported on hw, trying plughw fallback...");
log::info!(
"[ALSA Backend] Format unsupported on hw, trying plughw fallback..."
);
}
}
}
@@ -644,7 +700,9 @@ impl AlsaBackend {
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)");
log::info!(
"[ALSA Backend] ✓ plughw stream created (bit-perfect with format conversion)"
);
Some(Ok((stream, super::backend::BitPerfectMode::PluginFallback)))
}
Err(e) => {
@@ -657,16 +715,28 @@ impl AlsaBackend {
.output()
{
if output.status.success() {
log::info!("[ALSA Backend] PipeWire sink suspended, retrying plughw...");
log::info!(
"[ALSA Backend] PipeWire sink suspended, retrying plughw..."
);
std::thread::sleep(std::time::Duration::from_millis(200));
match super::AlsaDirectStream::new(&plughw_device, 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 after PipeWire suspend");
return Some(Ok((stream, super::backend::BitPerfectMode::PluginFallback)));
return Some(Ok((
stream,
super::backend::BitPerfectMode::PluginFallback,
)));
}
Err(e2) => {
log::error!("[ALSA Backend] plughw retry after suspend also failed: {}", e2);
log::error!(
"[ALSA Backend] plughw retry after suspend also failed: {}",
e2
);
}
}
}
@@ -758,7 +828,10 @@ pub fn resolve_stable_to_current_hw(device_id: &str) -> Option<String> {
let stripped = device_id.strip_prefix("front:CARD=")?;
let parts: Vec<&str> = stripped.split(',').collect();
let card_name = parts.first()?;
let dev_part = parts.get(1).and_then(|s| s.strip_prefix("DEV=")).unwrap_or("0");
let dev_part = parts
.get(1)
.and_then(|s| s.strip_prefix("DEV="))
.unwrap_or("0");
// Find current card number for this name using /proc/asound
if let Some(card_num) = find_card_number_by_name(card_name) {
@@ -767,7 +840,10 @@ pub fn resolve_stable_to_current_hw(device_id: &str) -> Option<String> {
return Some(hw_id);
}
log::warn!("[ALSA] Card '{}' not found in current enumeration", card_name);
log::warn!(
"[ALSA] Card '{}' not found in current enumeration",
card_name
);
None
}
@@ -796,10 +872,7 @@ impl AudioBackend for AlsaBackend {
self.enumerate_with_proc_descriptions()
}
fn create_output_stream(
&self,
config: &BackendConfig,
) -> BackendResult<MixerDeviceSink> {
fn create_output_stream(&self, config: &BackendConfig) -> BackendResult<MixerDeviceSink> {
log::info!(
"[ALSA Backend] Creating stream: {}Hz, {} channels, exclusive: {}, plugin: {:?}",
config.sample_rate,
@@ -898,7 +971,9 @@ impl AudioBackend for AlsaBackend {
// In exclusive mode, PipeWire may have re-acquired the device after the
// previous ALSA Direct stream released it. Suspend PipeWire before opening.
if config.exclusive_mode {
log::info!("[ALSA Backend] Exclusive mode: suspending PipeWire sinks before CPAL stream");
log::info!(
"[ALSA Backend] Exclusive mode: suspending PipeWire sinks before CPAL stream"
);
if let Ok(output) = std::process::Command::new("pactl")
.args(["suspend-sink", "@DEFAULT_SINK@", "1"])
.output()
@@ -941,12 +1016,15 @@ impl AudioBackend for AlsaBackend {
if effective_rate != config.sample_rate {
log::info!(
"[ALSA Backend] Output stream created at {}Hz (resampled from {}Hz, exclusive: {})",
effective_rate, config.sample_rate, config.exclusive_mode
effective_rate,
config.sample_rate,
config.exclusive_mode
);
} else {
log::info!(
"[ALSA Backend] Output stream created successfully at {}Hz (exclusive: {})",
config.sample_rate, config.exclusive_mode
config.sample_rate,
config.exclusive_mode
);
}
+107 -58
View File
@@ -38,8 +38,8 @@ impl AlsaDirectStream {
// Set hardware parameters and auto-detect best format
let selected_format = {
let hwp = HwParams::any(&pcm)
.map_err(|e| format!("Failed to get hardware params: {}", e))?;
let hwp =
HwParams::any(&pcm).map_err(|e| format!("Failed to get hardware params: {}", e))?;
// Set access type (interleaved)
hwp.set_access(Access::RWInterleaved)
@@ -49,10 +49,10 @@ impl AlsaDirectStream {
// S24_3LE first: required by SMSL-class USB DACs (TAS1020B chip)
// Then descending bit-depth for quality
let format_priority = [
(Format::S243LE, "S24_3LE"), // 24-bit packed (SMSL, Topping, Fosi DACs)
(Format::S32LE, "S32LE"), // 32-bit
(Format::S24LE, "S24LE"), // 24-bit in 32-bit container
(Format::S16LE, "S16LE"), // 16-bit
(Format::S243LE, "S24_3LE"), // 24-bit packed (SMSL, Topping, Fosi DACs)
(Format::S32LE, "S32LE"), // 32-bit
(Format::S24LE, "S24LE"), // 24-bit in 32-bit container
(Format::S16LE, "S16LE"), // 16-bit
(Format::FloatLE, "Float32LE"), // Float (compatibility)
];
@@ -65,8 +65,10 @@ impl AlsaDirectStream {
}
}
let format = selected_format
.ok_or_else(|| "No supported audio format found (tried S24_3LE, S32LE, S24LE, S16LE, FloatLE)".to_string())?;
let format = selected_format.ok_or_else(|| {
"No supported audio format found (tried S24_3LE, S32LE, S24LE, S16LE, FloatLE)"
.to_string()
})?;
// Set channels
hwp.set_channels(channels as u32)
@@ -99,8 +101,13 @@ impl AlsaDirectStream {
pcm.hw_params(&hwp)
.map_err(|e| format!("Failed to apply hardware params: {}", e))?;
log::info!("[ALSA Direct] Hardware configured: {}Hz, {}ch, buffer: {} frames, format: {:?}",
sample_rate, channels, buffer_size, format);
log::info!(
"[ALSA Direct] Hardware configured: {}Hz, {}ch, buffer: {} frames, format: {:?}",
sample_rate,
channels,
buffer_size,
format
);
format
};
@@ -128,18 +135,21 @@ impl AlsaDirectStream {
match self.format {
Format::FloatLE => {
// Convert i16 to f32
let samples_f32: Vec<f32> = samples_i16
.iter()
.map(|&s| s as f32 / 32768.0)
.collect();
let samples_f32: Vec<f32> =
samples_i16.iter().map(|&s| s as f32 / 32768.0).collect();
let io = pcm.io_f32()
let io = pcm
.io_f32()
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
match io.writei(&samples_f32) {
Ok(written) => {
if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
}
Ok(())
}
@@ -155,18 +165,20 @@ impl AlsaDirectStream {
}
Format::S32LE => {
// Convert i16 to i32 (bit-perfect: shift left 16 bits)
let samples_i32: Vec<i32> = samples_i16
.iter()
.map(|&s| (s as i32) << 16)
.collect();
let samples_i32: Vec<i32> = samples_i16.iter().map(|&s| (s as i32) << 16).collect();
let io = pcm.io_i32()
let io = pcm
.io_i32()
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
match io.writei(&samples_i32) {
Ok(written) => {
if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
}
Ok(())
}
@@ -182,13 +194,18 @@ impl AlsaDirectStream {
}
Format::S16LE => {
// Direct write (no conversion needed)
let io = pcm.io_i16()
let io = pcm
.io_i16()
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
match io.writei(samples_i16) {
Ok(written) => {
if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
}
Ok(())
}
@@ -212,8 +229,8 @@ impl AlsaDirectStream {
// Convert i16 to i24 (lossless: zeros in lower 8 bits)
let s24 = (sample as i32) << 8;
// Pack as 3 bytes in little-endian order
bytes.push((s24 & 0xFF) as u8); // LSB
bytes.push(((s24 >> 8) & 0xFF) as u8); // Middle
bytes.push((s24 & 0xFF) as u8); // LSB
bytes.push(((s24 >> 8) & 0xFF) as u8); // Middle
bytes.push(((s24 >> 16) & 0xFF) as u8); // MSB (sign-extended)
}
@@ -223,7 +240,11 @@ impl AlsaDirectStream {
match io.writei(&bytes) {
Ok(written) => {
if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames (S24_3LE)", written, frames);
log::warn!(
"[ALSA Direct] Partial write: {} / {} frames (S24_3LE)",
written,
frames
);
}
Ok(())
}
@@ -240,18 +261,20 @@ impl AlsaDirectStream {
Format::S24LE => {
// S24LE: 24-bit in 32-bit container (padded)
// Convert i16 → i32, shift left 16 bits (same as S32LE for i16 source)
let samples_i32: Vec<i32> = samples_i16
.iter()
.map(|&s| (s as i32) << 16)
.collect();
let samples_i32: Vec<i32> = samples_i16.iter().map(|&s| (s as i32) << 16).collect();
let io = pcm.io_i32()
let io = pcm
.io_i32()
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
match io.writei(&samples_i32) {
Ok(written) => {
if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
}
Ok(())
}
@@ -265,9 +288,7 @@ impl AlsaDirectStream {
}
}
}
_ => {
Err(format!("Unsupported format: {:?}", self.format))
}
_ => Err(format!("Unsupported format: {:?}", self.format)),
}
}
@@ -283,13 +304,18 @@ impl AlsaDirectStream {
match self.format {
Format::FloatLE => {
// Direct write - no conversion needed
let io = pcm.io_f32()
let io = pcm
.io_f32()
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
match io.writei(samples_f32) {
Ok(written) => {
if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
}
Ok(())
}
@@ -310,13 +336,18 @@ impl AlsaDirectStream {
.map(|&s| (s * 2_147_483_647.0) as i32)
.collect();
let io = pcm.io_i32()
let io = pcm
.io_i32()
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
match io.writei(&samples_i32) {
Ok(written) => {
if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
}
Ok(())
}
@@ -341,13 +372,18 @@ impl AlsaDirectStream {
})
.collect();
let io = pcm.io_i32()
let io = pcm
.io_i32()
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
match io.writei(&samples_i32) {
Ok(written) => {
if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
}
Ok(())
}
@@ -370,8 +406,8 @@ impl AlsaDirectStream {
let scaled = sample * 8_388_607.0;
let s24 = scaled.clamp(-8_388_608.0, 8_388_607.0) as i32;
// Pack as 3 bytes in little-endian order
bytes.push((s24 & 0xFF) as u8); // LSB
bytes.push(((s24 >> 8) & 0xFF) as u8); // Middle
bytes.push((s24 & 0xFF) as u8); // LSB
bytes.push(((s24 >> 8) & 0xFF) as u8); // Middle
bytes.push(((s24 >> 16) & 0xFF) as u8); // MSB (sign-extended)
}
@@ -380,7 +416,11 @@ impl AlsaDirectStream {
match io.writei(&bytes) {
Ok(written) => {
if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames (S24_3LE)", written, frames);
log::warn!(
"[ALSA Direct] Partial write: {} / {} frames (S24_3LE)",
written,
frames
);
}
Ok(())
}
@@ -396,18 +436,21 @@ impl AlsaDirectStream {
}
Format::S16LE => {
// f32 -> i16
let samples_i16: Vec<i16> = samples_f32
.iter()
.map(|&s| (s * 32_767.0) as i16)
.collect();
let samples_i16: Vec<i16> =
samples_f32.iter().map(|&s| (s * 32_767.0) as i16).collect();
let io = pcm.io_i16()
let io = pcm
.io_i16()
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
match io.writei(&samples_i16) {
Ok(written) => {
if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
}
Ok(())
}
@@ -421,9 +464,7 @@ impl AlsaDirectStream {
}
}
}
_ => {
Err(format!("Unsupported format: {:?}", self.format))
}
_ => Err(format!("Unsupported format: {:?}", self.format)),
}
}
@@ -471,8 +512,8 @@ impl AlsaDirectStream {
/// NOTE: Failure doesn't break playback, just means volume can't be controlled.
#[cfg(target_os = "linux")]
pub fn set_hardware_volume(&self, volume: f32) -> Result<(), String> {
use alsa::mixer::{Mixer, SelemId};
use alsa::mixer::SelemChannelId::*;
use alsa::mixer::{Mixer, SelemId};
// Open mixer for device
let mixer = Mixer::new(&self.device_id, false)
@@ -491,8 +532,13 @@ impl AlsaDirectStream {
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);
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] {
@@ -504,7 +550,10 @@ impl AlsaDirectStream {
}
}
Err(format!("No volume control found for {}. DAC may not support hardware mixer.", self.device_id))
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
+1 -4
View File
@@ -217,10 +217,7 @@ pub trait AudioBackend: Send + Sync {
fn enumerate_devices(&self) -> BackendResult<Vec<AudioDevice>>;
/// Create an output stream for the given configuration
fn create_output_stream(
&self,
config: &BackendConfig,
) -> BackendResult<MixerDeviceSink>;
fn create_output_stream(&self, config: &BackendConfig) -> BackendResult<MixerDeviceSink>;
/// Check if this backend is available on the current system
fn is_available(&self) -> bool;
+5 -1
View File
@@ -78,7 +78,11 @@ impl AudioDiagnostic {
let sample_rate = self.sample_rate.load(Ordering::SeqCst);
let channels = self.channels.load(Ordering::SeqCst);
let trailing_zeros = if or_mask == 0 { 32 } else { or_mask.trailing_zeros() };
let trailing_zeros = if or_mask == 0 {
32
} else {
or_mask.trailing_zeros()
};
let effective_bits = 32 - trailing_zeros;
let frames = if channels > 0 {
+23 -27
View File
@@ -26,41 +26,37 @@
//! └───────────────┘
//! ```
pub mod backend;
pub mod pipewire_backend;
pub mod alsa_backend;
pub mod pulse_backend;
pub mod alsa_direct;
pub mod diagnostic;
pub mod loudness;
pub mod dynamic_amplify;
pub mod analysis;
pub mod analyzer_tap;
pub mod loudness_cache;
pub mod backend;
pub mod diagnostic;
pub mod dynamic_amplify;
pub mod loudness;
pub mod loudness_analyzer;
pub mod loudness_cache;
pub mod pipewire_backend;
pub mod pulse_backend;
pub mod settings;
pub mod visualizer;
pub mod analysis;
// Re-export commonly used types
pub use backend::{
AlsaPlugin,
AudioBackend,
AudioBackendType,
AudioDevice,
BackendConfig,
BackendManager,
BackendResult,
AlsaDirectError,
BitPerfectMode,
pub use alsa_backend::{
device_supports_sample_rate, get_device_supported_rates, normalize_device_id_to_stable,
resolve_stable_to_current_hw,
};
pub use alsa_direct::AlsaDirectStream;
pub use alsa_backend::{normalize_device_id_to_stable, resolve_stable_to_current_hw, device_supports_sample_rate, get_device_supported_rates};
pub use diagnostic::{AudioDiagnostic, DiagnosticSource, BitDepthResult};
pub use loudness::{ReplayGainData, extract_replaygain, calculate_gain_factor, db_to_linear};
pub use dynamic_amplify::DynamicAmplify;
pub use analyzer_tap::{AnalyzerTap, AnalyzerMessage};
pub use loudness_cache::LoudnessCache;
pub use loudness_analyzer::LoudnessAnalyzer;
pub use settings::AudioSettings;
pub use visualizer::{VisualizerTap, TappedSource, RingBuffer};
pub use analysis::SpectralAnalyzer;
pub use analyzer_tap::{AnalyzerMessage, AnalyzerTap};
pub use backend::{
AlsaDirectError, AlsaPlugin, AudioBackend, AudioBackendType, AudioDevice, BackendConfig,
BackendManager, BackendResult, BitPerfectMode,
};
pub use diagnostic::{AudioDiagnostic, BitDepthResult, DiagnosticSource};
pub use dynamic_amplify::DynamicAmplify;
pub use loudness::{calculate_gain_factor, db_to_linear, extract_replaygain, ReplayGainData};
pub use loudness_analyzer::LoudnessAnalyzer;
pub use loudness_cache::LoudnessCache;
pub use settings::AudioSettings;
pub use visualizer::{RingBuffer, TappedSource, VisualizerTap};
+58 -17
View File
@@ -4,13 +4,13 @@
//! for volume normalization. When normalization is disabled, this module is
//! not invoked and the audio pipeline remains bit-perfect.
use symphonia::core::formats::FormatReader;
use symphonia::core::meta::{MetadataOptions, StandardTagKey, Tag, Value};
use std::io::{Cursor, Read, Seek, SeekFrom};
use symphonia::core::formats::FormatOptions;
use symphonia::core::formats::FormatReader;
use symphonia::core::io::{MediaSource, MediaSourceStream};
use symphonia::core::meta::{MetadataOptions, StandardTagKey, Tag, Value};
use symphonia::core::probe::Hint;
use symphonia::default::get_probe;
use std::io::{Cursor, Read, Seek, SeekFrom};
/// Extracted loudness data for a track
#[derive(Debug, Clone)]
@@ -28,7 +28,9 @@ struct CursorMediaSource {
impl CursorMediaSource {
fn new(data: Vec<u8>) -> Self {
Self { inner: Cursor::new(data) }
Self {
inner: Cursor::new(data),
}
}
}
@@ -45,7 +47,9 @@ impl Seek for CursorMediaSource {
}
impl MediaSource for CursorMediaSource {
fn is_seekable(&self) -> bool { true }
fn is_seekable(&self) -> bool {
true
}
fn byte_len(&self) -> Option<u64> {
Some(self.inner.get_ref().len() as u64)
}
@@ -122,7 +126,11 @@ pub fn extract_replaygain_from_reader(format: &mut dyn FormatReader) -> Option<R
}
gain_db.map(|db| {
log::info!("Loudness: found ReplayGain (streaming): {:.2} dB, peak: {:?}", db, peak);
log::info!(
"Loudness: found ReplayGain (streaming): {:.2} dB, peak: {:?}",
db,
peak
);
ReplayGainData { gain_db: db, peak }
})
}
@@ -174,7 +182,11 @@ fn extract_from_tags(tags: &[Tag], gain_db: &mut Option<f32>, peak: &mut Option<
fn parse_gain_value(value: &Value) -> Option<f32> {
let s = value_to_string(value)?;
// Strip " dB" suffix if present, then parse
let trimmed = s.trim().trim_end_matches(" dB").trim_end_matches(" db").trim_end_matches("dB");
let trimmed = s
.trim()
.trim_end_matches(" dB")
.trim_end_matches(" db")
.trim_end_matches("dB");
trimmed.parse::<f32>().ok()
}
@@ -240,7 +252,9 @@ pub fn calculate_gain_factor(rg: &ReplayGainData, target_lufs: f32) -> f32 {
if gain > max_safe_gain {
log::debug!(
"Loudness: capping gain from {:.3} to {:.3} (peak: {:.4})",
gain, max_safe_gain, peak
gain,
max_safe_gain,
peak
);
gain = max_safe_gain;
}
@@ -258,7 +272,10 @@ pub fn calculate_gain_factor(rg: &ReplayGainData, target_lufs: f32) -> f32 {
log::debug!(
"Loudness: gain_db={:.2}, target={:.1} LUFS, adjusted={:.2} dB, factor={:.4}",
rg.gain_db, target_lufs, adjusted_gain_db, gain
rg.gain_db,
target_lufs,
adjusted_gain_db,
gain
);
gain
@@ -283,7 +300,10 @@ mod tests {
#[test]
fn test_calculate_gain_factor_at_reference() {
// At -18 LUFS target (ReplayGain reference), gain_db should pass through directly
let rg = ReplayGainData { gain_db: -3.0, peak: Some(0.9) };
let rg = ReplayGainData {
gain_db: -3.0,
peak: Some(0.9),
};
let factor = calculate_gain_factor(&rg, -18.0);
// -3 dB → ~0.708
assert!((factor - 0.708).abs() < 0.01);
@@ -292,7 +312,10 @@ mod tests {
#[test]
fn test_calculate_gain_factor_with_target_adjustment() {
// At -14 LUFS target, we add +4 dB to the RG gain
let rg = ReplayGainData { gain_db: -3.0, peak: Some(0.5) };
let rg = ReplayGainData {
gain_db: -3.0,
peak: Some(0.5),
};
let factor = calculate_gain_factor(&rg, -14.0);
// -3 + 4 = +1 dB → ~1.122
assert!((factor - 1.122).abs() < 0.01);
@@ -301,7 +324,10 @@ mod tests {
#[test]
fn test_clipping_prevention_with_peak() {
// High positive gain but peak close to 1.0 — should be capped
let rg = ReplayGainData { gain_db: 10.0, peak: Some(0.95) };
let rg = ReplayGainData {
gain_db: 10.0,
peak: Some(0.95),
};
let factor = calculate_gain_factor(&rg, -18.0);
// max_safe_gain = 1/0.95 ≈ 1.053, which is less than db_to_linear(10) ≈ 3.162
assert!((factor - (1.0 / 0.95)).abs() < 0.01);
@@ -310,7 +336,10 @@ mod tests {
#[test]
fn test_clipping_prevention_without_peak() {
// High gain without peak data — capped at +6 dB
let rg = ReplayGainData { gain_db: 12.0, peak: None };
let rg = ReplayGainData {
gain_db: 12.0,
peak: None,
};
let factor = calculate_gain_factor(&rg, -18.0);
assert!((factor - db_to_linear(6.0)).abs() < 0.01);
}
@@ -318,18 +347,30 @@ mod tests {
#[test]
fn test_parse_gain_value_formats() {
// Standard format: "-6.54 dB"
assert!((parse_gain_value(&Value::String("-6.54 dB".to_string())).unwrap() - (-6.54)).abs() < 0.001);
assert!(
(parse_gain_value(&Value::String("-6.54 dB".to_string())).unwrap() - (-6.54)).abs()
< 0.001
);
// Without dB suffix
assert!((parse_gain_value(&Value::String("-6.54".to_string())).unwrap() - (-6.54)).abs() < 0.001);
assert!(
(parse_gain_value(&Value::String("-6.54".to_string())).unwrap() - (-6.54)).abs()
< 0.001
);
// Positive
assert!((parse_gain_value(&Value::String("+3.21 dB".to_string())).unwrap() - 3.21).abs() < 0.001);
assert!(
(parse_gain_value(&Value::String("+3.21 dB".to_string())).unwrap() - 3.21).abs()
< 0.001
);
// Float value
assert!((parse_gain_value(&Value::Float(-6.54)).unwrap() - (-6.54)).abs() < 0.001);
}
#[test]
fn test_parse_peak_value() {
assert!((parse_peak_value(&Value::String("0.988553".to_string())).unwrap() - 0.988553).abs() < 0.0001);
assert!(
(parse_peak_value(&Value::String("0.988553".to_string())).unwrap() - 0.988553).abs()
< 0.0001
);
assert!((parse_peak_value(&Value::Float(0.95)).unwrap() - 0.95).abs() < 0.001);
}
}
+24 -7
View File
@@ -16,13 +16,12 @@ use std::thread;
use ebur128::{EbuR128, Mode};
use super::analyzer_tap::AnalyzerMessage;
use super::loudness_cache::LoudnessCache;
use super::loudness::db_to_linear;
use super::loudness_cache::LoudnessCache;
/// Maximum gain boost in dB (conservative clipping prevention)
const MAX_GAIN_DB: f32 = 6.0;
pub struct LoudnessAnalyzer;
impl LoudnessAnalyzer {
@@ -56,10 +55,19 @@ impl LoudnessAnalyzer {
};
match msg {
AnalyzerMessage::NewTrack { track_id, sample_rate, channels, target_lufs, gain_atomic } => {
AnalyzerMessage::NewTrack {
track_id,
sample_rate,
channels,
target_lufs,
gain_atomic,
} => {
log::info!(
"[LoudnessAnalyzer] New track {} ({}Hz, {}ch, target {:.1} LUFS)",
track_id, sample_rate, channels, target_lufs
track_id,
sample_rate,
channels,
target_lufs
);
// Check cache first
@@ -74,7 +82,8 @@ impl LoudnessAnalyzer {
gain_atomic.store(gain.to_bits(), Ordering::Relaxed);
// Create state marked as cached — still accept samples for refinement
let mut s = AnalyzerState::new(track_id, sample_rate, channels, target_lufs);
let mut s =
AnalyzerState::new(track_id, sample_rate, channels, target_lufs);
s.gain_atomic = Some(gain_atomic);
s.initial_done = true;
state = Some(s);
@@ -197,7 +206,11 @@ impl AnalyzerState {
// -inf means silence — don't adjust
if loudness.is_infinite() || loudness.is_nan() {
log::debug!("[LoudnessAnalyzer] Track {}: loudness is {:?}, skipping", self.track_id, loudness);
log::debug!(
"[LoudnessAnalyzer] Track {}: loudness is {:?}, skipping",
self.track_id,
loudness
);
return;
}
@@ -205,7 +218,11 @@ impl AnalyzerState {
let adjustment_db = self.target_lufs - measured_lufs;
let gain = compute_gain_capped(adjustment_db);
let phase = if self.initial_done { "refine" } else { "initial" };
let phase = if self.initial_done {
"refine"
} else {
"initial"
};
log::info!(
"[LoudnessAnalyzer] Track {} ({}): measured {:.1} LUFS, target {:.1}, adjustment {:.2} dB, gain {:.4}",
self.track_id, phase, measured_lufs, self.target_lufs, adjustment_db, gain
+68 -21
View File
@@ -157,7 +157,9 @@ impl PipeWireBackend {
.ok()
.and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout).ok().map(|s| s.trim().to_string())
String::from_utf8(o.stdout)
.ok()
.map(|s| s.trim().to_string())
} else {
None
}
@@ -207,7 +209,8 @@ impl PipeWireBackend {
} 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());
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");
@@ -223,7 +226,10 @@ impl PipeWireBackend {
}
} 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();
let bus = line
.trim_start_matches("device.bus = ")
.trim_matches('"')
.to_string();
current_device_bus = Some(bus);
}
}
@@ -243,10 +249,19 @@ impl PipeWireBackend {
});
}
log::info!("[PipeWire Backend] Enumerated {} devices via pactl", devices.len());
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);
log::info!(
" [{}] {} (id: {}, bus: {:?}, hw: {})",
idx,
dev.name,
dev.id,
dev.device_bus,
dev.is_hardware
);
}
Ok(devices)
@@ -262,10 +277,7 @@ impl AudioBackend for PipeWireBackend {
self.enumerate_pipewire_sinks()
}
fn create_output_stream(
&self,
config: &BackendConfig,
) -> BackendResult<MixerDeviceSink> {
fn create_output_stream(&self, config: &BackendConfig) -> BackendResult<MixerDeviceSink> {
let target_sink = config.device_id.clone();
// Temporarily set default sink to target (if specified)
@@ -287,7 +299,10 @@ impl AudioBackend for PipeWireBackend {
log::warn!("[PipeWire Backend] Failed to set default sink: {}", stderr);
}
Err(e) => {
log::warn!("[PipeWire Backend] Error executing pactl set-default-sink: {}", e);
log::warn!(
"[PipeWire Backend] Error executing pactl set-default-sink: {}",
e
);
}
}
@@ -307,7 +322,9 @@ impl AudioBackend for PipeWireBackend {
.ok()
.and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout).ok().map(|s| s.trim().to_string())
String::from_utf8(o.stdout)
.ok()
.map(|s| s.trim().to_string())
} else {
None
}
@@ -319,7 +336,8 @@ impl AudioBackend for PipeWireBackend {
Some(rates) if rates.contains(&config.sample_rate) => {
log::info!(
"[PipeWire Backend] DAC supports {}Hz (available: {:?})",
config.sample_rate, rates
config.sample_rate,
rates
);
config.sample_rate
}
@@ -344,14 +362,26 @@ impl AudioBackend for PipeWireBackend {
};
// 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);
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()])
.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);
log::info!(
"[PipeWire Backend] Sample rate forced to {}Hz",
effective_rate
);
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
@@ -486,7 +516,7 @@ impl AudioBackend for PipeWireBackend {
// 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
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).
@@ -500,9 +530,17 @@ 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: {}", effective_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", effective_rate);
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,
@@ -512,9 +550,18 @@ impl AudioBackend for PipeWireBackend {
// 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()])
.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);
log::info!(
"[PipeWire Backend] Re-applied clock.force-rate={}Hz after stream creation",
effective_rate
);
}
Ok(mixer_sink)
+1 -4
View File
@@ -30,10 +30,7 @@ impl AudioBackend for PulseBackend {
self.inner.enumerate_devices()
}
fn create_output_stream(
&self,
config: &BackendConfig,
) -> BackendResult<MixerDeviceSink> {
fn create_output_stream(&self, config: &BackendConfig) -> BackendResult<MixerDeviceSink> {
// Delegate to PipeWire backend (same mechanism)
self.inner.create_output_stream(config)
}
+2 -5
View File
@@ -402,11 +402,8 @@ impl AudioSettingsStore {
.map_err(|e| format!("Failed to get device sample rate limits: {}", e))?;
match json {
Some(s) if !s.is_empty() => {
serde_json::from_str(&s).map_err(|e| {
format!("Failed to parse device sample rate limits: {}", e)
})
}
Some(s) if !s.is_empty() => serde_json::from_str(&s)
.map_err(|e| format!("Failed to parse device sample rate limits: {}", e)),
_ => Ok(HashMap::new()),
}
}
+1 -1
View File
@@ -16,8 +16,8 @@ mod tapped_source;
pub use ring_buffer::RingBuffer;
pub use tapped_source::TappedSource;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
/// Number of frequency bins to send to frontend
pub const NUM_BARS: usize = 16;
@@ -9,8 +9,8 @@
//! - No allocations
//! - Minimal atomic operations
use std::sync::atomic::{AtomicUsize, Ordering};
use std::cell::UnsafeCell;
use std::sync::atomic::{AtomicUsize, Ordering};
/// A lock-free single-producer single-consumer ring buffer
///
@@ -4,9 +4,9 @@
//! without affecting audio playback. The tap is completely transparent to the
//! audio pipeline.
use rodio::Source;
use std::sync::Arc;
use std::time::Duration;
use rodio::Source;
use super::RingBuffer;