chore(qconnect): checkpoint current poc state
cx
This commit is contained in:
92 files changed
+3886
-2433
No files matched your search
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user