style: apply cargo fmt across the crate (A20)

The repo never enforced rustfmt, so formatting had drifted broadly. This is a
single mechanical `cargo fmt` pass over the whole crate (no behavioral change;
lib suite green, 493 passed). Going forward fmt should be enforced (planned CI
fmt --check step). Part of the 0.6.1 hygiene pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 02:11:44 -04:00
co-authored by Claude Opus 4.8
parent e0325d4590
commit d0a16cb8b9
54 changed files with 3761 additions and 1789 deletions
+53 -12
View File
@@ -578,7 +578,9 @@ fn choose_config(device: &Device, output: bool) -> Result<cpal::SupportedStreamC
let pick = |channels: Option<u16>| {
ranges
.iter()
.find(|r| usable_range(r) && supports_48k(r) && channels.is_none_or(|c| r.channels() == c))
.find(|r| {
usable_range(r) && supports_48k(r) && channels.is_none_or(|c| r.channels() == c)
})
.cloned()
};
@@ -666,15 +668,30 @@ fn run_capture(
let device_rate = config.sample_rate.0;
let stream = match sample_format {
SampleFormat::F32 => build_input::<f32, _>(
&device, &config, producer, channels, overrun.clone(), callbacks.clone(),
&device,
&config,
producer,
channels,
overrun.clone(),
callbacks.clone(),
err_code.clone(),
),
SampleFormat::I16 => build_input::<i16, _>(
&device, &config, producer, channels, overrun.clone(), callbacks.clone(),
&device,
&config,
producer,
channels,
overrun.clone(),
callbacks.clone(),
err_code.clone(),
),
SampleFormat::U16 => build_input::<u16, _>(
&device, &config, producer, channels, overrun.clone(), callbacks.clone(),
&device,
&config,
producer,
channels,
overrun.clone(),
callbacks.clone(),
err_code.clone(),
),
other => Err(AudioError::Stream(format!(
@@ -764,7 +781,10 @@ fn run_capture(
// Surface a stream error the RT callback flagged (it can't log itself).
let ec = err_code.load(Ordering::Relaxed);
if ec != STREAM_ERR_NONE && ec != last_err {
crate::log_msg(&format!("cpal capture stream error: {}", stream_err_text(ec)));
crate::log_msg(&format!(
"cpal capture stream error: {}",
stream_err_text(ec)
));
last_err = ec;
}
if !drained {
@@ -914,16 +934,34 @@ fn run_playback(
let device_rate = config.sample_rate.0;
let stream = match sample_format {
SampleFormat::F32 => build_output::<f32, _>(
&device, &config, consumer, ring_fill.clone(), underrun.clone(),
max_cb.clone(), callbacks.clone(), err_code.clone(),
&device,
&config,
consumer,
ring_fill.clone(),
underrun.clone(),
max_cb.clone(),
callbacks.clone(),
err_code.clone(),
),
SampleFormat::I16 => build_output::<i16, _>(
&device, &config, consumer, ring_fill.clone(), underrun.clone(),
max_cb.clone(), callbacks.clone(), err_code.clone(),
&device,
&config,
consumer,
ring_fill.clone(),
underrun.clone(),
max_cb.clone(),
callbacks.clone(),
err_code.clone(),
),
SampleFormat::U16 => build_output::<u16, _>(
&device, &config, consumer, ring_fill.clone(), underrun.clone(),
max_cb.clone(), callbacks.clone(), err_code.clone(),
&device,
&config,
consumer,
ring_fill.clone(),
underrun.clone(),
max_cb.clone(),
callbacks.clone(),
err_code.clone(),
),
other => Err(AudioError::Stream(format!(
"unsupported playback sample format: {other:?}"
@@ -1199,7 +1237,10 @@ fn spawn_health_logger(
// Surface a stream error the RT callback flagged (it can't log itself).
let ec = err_code.load(Ordering::Relaxed);
if ec != STREAM_ERR_NONE && ec != last_err {
crate::log_msg(&format!("cpal playback stream error: {}", stream_err_text(ec)));
crate::log_msg(&format!(
"cpal playback stream error: {}",
stream_err_text(ec)
));
last_err = ec;
}
// Report the device's per-cycle demand (in internal 48 kHz-stereo
+49 -11
View File
@@ -54,7 +54,10 @@ impl Drop for EchoCancelGuard {
.arg("unload-module")
.arg(&self.module_index)
.output();
crate::log_msg(&format!("Echo cancel: unloaded module {}", self.module_index));
crate::log_msg(&format!(
"Echo cancel: unloaded module {}",
self.module_index
));
}
}
@@ -65,7 +68,10 @@ impl Drop for EchoCancelGuard {
/// `None` (or an empty string) to bind to the system defaults. Returns `Err` with
/// a human-readable reason if `pactl` is missing, the load fails, or the nodes
/// don't appear — the caller should fall back to the direct devices.
pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<EchoCancelGuard, String> {
pub fn enable(
real_source: Option<&str>,
real_sink: Option<&str>,
) -> Result<EchoCancelGuard, String> {
// Best-effort: clear any stale instance left by a crashed prior run so we
// don't stack duplicate modules / fight over the virtual node names.
unload_stale();
@@ -101,7 +107,11 @@ pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<Echo
if module_index.parse::<u64>().is_err() {
return Err(format!("unexpected pactl output: {module_index:?}"));
}
let guard = EchoCancelGuard { module_index, source_name, sink_name };
let guard = EchoCancelGuard {
module_index,
source_name,
sink_name,
};
// The virtual nodes appear shortly after the module loads; wait for both so
// the subsequent capture/playback streams can actually target them. If they
@@ -134,7 +144,12 @@ fn wait_for_nodes(source_name: &str, sink_name: &str) -> bool {
/// Whether `pactl list <kind> short` lists a node named `name`.
/// `kind` is "sources" or "sinks".
fn node_present(kind: &str, name: &str) -> bool {
let Ok(out) = Command::new("pactl").arg("list").arg(kind).arg("short").output() else {
let Ok(out) = Command::new("pactl")
.arg("list")
.arg(kind)
.arg("short")
.output()
else {
return false;
};
String::from_utf8_lossy(&out.stdout)
@@ -167,7 +182,12 @@ fn process_is_alive(_pid: u32) -> bool {
/// Unloads leftover PeerSpeak `module-echo-cancel` instances only when their
/// owning process is gone. Best-effort and conservative on non-Linux platforms.
fn unload_stale() {
let Ok(out) = Command::new("pactl").arg("list").arg("modules").arg("short").output() else {
let Ok(out) = Command::new("pactl")
.arg("list")
.arg("modules")
.arg("short")
.output()
else {
return;
};
for line in String::from_utf8_lossy(&out.stdout).lines() {
@@ -179,7 +199,10 @@ fn unload_stale() {
&& ec_module_is_stale(args, process_is_alive)
&& index.parse::<u64>().is_ok()
{
let _ = Command::new("pactl").arg("unload-module").arg(index).output();
let _ = Command::new("pactl")
.arg("unload-module")
.arg(index)
.output();
crate::log_msg(&format!("Echo cancel: cleaned up stale module {index}"));
}
}
@@ -198,13 +221,25 @@ mod tests {
let guard = enable(None, None).expect("module-echo-cancel should load");
let source_name = guard.source_name().to_string();
let sink_name = guard.sink_name().to_string();
assert!(node_present("sources", &source_name), "cleaned source must exist");
assert!(node_present("sinks", &sink_name), "reference sink must exist");
assert!(
node_present("sources", &source_name),
"cleaned source must exist"
);
assert!(
node_present("sinks", &sink_name),
"reference sink must exist"
);
drop(guard);
// Give pactl a moment to tear the nodes down.
std::thread::sleep(Duration::from_millis(300));
assert!(!node_present("sources", &source_name), "source must be gone after unload");
assert!(!node_present("sinks", &sink_name), "sink must be gone after unload");
assert!(
!node_present("sources", &source_name),
"source must be gone after unload"
);
assert!(
!node_present("sinks", &sink_name),
"sink must be gone after unload"
);
}
#[test]
@@ -220,7 +255,10 @@ mod tests {
pid_from_ec_args("source_name=peerspeak_echocancel_source.not-a-pid"),
None
);
assert_eq!(pid_from_ec_args("source_name=someone_elses_source.4242"), None);
assert_eq!(
pid_from_ec_args("source_name=someone_elses_source.4242"),
None
);
}
#[test]
+17 -9
View File
@@ -251,7 +251,10 @@ mod tests {
let before = rms(&low);
eq.process_frame(&mut low);
let after = rms(&low);
assert!(after > before * 1.6, "low shelf should boost low RMS: {before} -> {after}");
assert!(
after > before * 1.6,
"low shelf should boost low RMS: {before} -> {after}"
);
}
#[test]
@@ -264,7 +267,10 @@ mod tests {
let before = rms(&high);
eq.process_frame(&mut high);
let after = rms(&high);
assert!(after > before * 1.6, "high shelf should boost high RMS: {before} -> {after}");
assert!(
after > before * 1.6,
"high shelf should boost high RMS: {before} -> {after}"
);
}
#[test]
@@ -275,7 +281,10 @@ mod tests {
Biquad::peaking(DEFAULT_SAMPLE_RATE, MID_PEAK_HZ, gain, MID_Q),
Biquad::high_shelf(DEFAULT_SAMPLE_RATE, HIGH_SHELF_HZ, gain, SHELF_Q),
] {
assert!(b.coeffs.all_finite(), "coefficients must be finite at {gain} dB");
assert!(
b.coeffs.all_finite(),
"coefficients must be finite at {gain} dB"
);
}
}
}
@@ -289,12 +298,11 @@ mod tests {
});
let mut frame = sine(1_000.0, 48_000, 30_000.0);
eq.process_frame(&mut frame);
let peak = frame
.iter()
.map(|&s| i32::from(s).abs())
.max()
.unwrap_or(0);
assert!(peak > 1_000, "processed signal should retain audible energy");
let peak = frame.iter().map(|&s| i32::from(s).abs()).max().unwrap_or(0);
assert!(
peak > 1_000,
"processed signal should retain audible energy"
);
assert!(
frame.iter().any(|&s| s > 0) && frame.iter().any(|&s| s < 0),
"a boosted sine should retain both polarities"
+51 -12
View File
@@ -169,7 +169,10 @@ mod tests {
assert!(g.process(&mut f, 0.05), "loud frame must transmit");
last = peak(&f);
}
assert!(last >= 9900, "gain should reach ~1.0 on sustained loud input, got peak {last}");
assert!(
last >= 9900,
"gain should reach ~1.0 on sustained loud input, got peak {last}"
);
}
#[test]
@@ -179,8 +182,15 @@ mod tests {
g.process(&mut f, 0.05);
// 5ms attack @48k = 240 samples; across a 960-sample frame the gain ramps
// 0->1, so the early samples are well below full scale (no instant click).
assert!(f[0].abs() < 5000, "attack should start near zero, got {}", f[0]);
assert!(f[FRAME - 1].abs() > 9000, "attack should complete within the frame");
assert!(
f[0].abs() < 5000,
"attack should start near zero, got {}",
f[0]
);
assert!(
f[FRAME - 1].abs() > 9000,
"attack should complete within the frame"
);
}
#[test]
@@ -193,8 +203,14 @@ mod tests {
}
// First quiet frame right after speech: hold keeps it open (not chopped).
let mut q = frame(50); // rms ~0.0015, below close (0.03)
assert!(g.process(&mut q, 0.05), "first quiet frame must stay open (hangover)");
assert!(peak(&q) > 0, "held-open frame must not be silenced immediately");
assert!(
g.process(&mut q, 0.05),
"first quiet frame must stay open (hangover)"
);
assert!(
peak(&q) > 0,
"held-open frame must not be silenced immediately"
);
// Hold is 200ms = 10 frames; keep feeding quiet until it fully closes.
let mut closed = false;
@@ -205,7 +221,10 @@ mod tests {
break;
}
}
assert!(closed, "gate must eventually close and stop transmitting after sustained silence");
assert!(
closed,
"gate must eventually close and stop transmitting after sustained silence"
);
}
#[test]
@@ -216,8 +235,14 @@ mod tests {
g.process(&mut f, 0.05); // open=0.05, close=0.03
// A frame between close and open thresholds: rms ~0.04 (amp ~1310).
let mut mid = frame(1310);
assert!(g.process(&mut mid, 0.05), "between-threshold frame must keep an open gate open");
assert!(g.open, "hysteresis: gate stays open above the close threshold");
assert!(
g.process(&mut mid, 0.05),
"between-threshold frame must keep an open gate open"
);
assert!(
g.open,
"hysteresis: gate stays open above the close threshold"
);
}
#[test]
@@ -225,7 +250,10 @@ mod tests {
let mut g = NoiseGate::new(SR);
// Never opened; feed silence — should report don't-transmit promptly.
let mut f = frame(0);
assert!(!g.process(&mut f, 0.05), "an unopened gate on silence must not transmit");
assert!(
!g.process(&mut f, 0.05),
"an unopened gate on silence must not transmit"
);
}
#[test]
@@ -266,7 +294,11 @@ mod tests {
let mut f2 = frame(10000);
assert!(g.process(&mut f2, 0.05)); // enabled
assert!(f2[0].abs() > 9000, "expected first sample of enabled frame to have no fade-in, got {}", f2[0]);
assert!(
f2[0].abs() > 9000,
"expected first sample of enabled frame to have no fade-in, got {}",
f2[0]
);
}
#[test]
@@ -302,7 +334,10 @@ mod tests {
let mut f = frame(1310);
assert!(g.process(&mut f, 0.05));
}
assert!(g.open, "gate must stay open (hold refreshed by mid-level input)");
assert!(
g.open,
"gate must stay open (hold refreshed by mid-level input)"
);
}
#[test]
@@ -333,6 +368,10 @@ mod tests {
last_peak = peak(&f);
}
assert!(g.open);
assert!(last_peak >= 9900, "peak of the 3rd reopened frame must be >= 9900, got {}", last_peak);
assert!(
last_peak >= 9900,
"peak of the 3rd reopened frame must be >= 9900, got {}",
last_peak
);
}
}
+69 -15
View File
@@ -123,7 +123,10 @@ mod tests {
let out = lim.process(&loud, 1.0);
let ceiling = lim.ceiling().ceil() as i16;
for &s in &out {
assert!(s > 0, "positive loud input stays positive (no wrap), got {s}");
assert!(
s > 0,
"positive loud input stays positive (no wrap), got {s}"
);
assert!(s <= ceiling, "sample {s} exceeded ceiling {ceiling}");
}
}
@@ -175,7 +178,10 @@ mod tests {
let out_pos = lim.process(&pos_loud, 1.0);
for &s in &out_pos {
assert!(s > 0, "positive input stays positive, got {s}");
assert!(s <= ceiling_ceil, "positive sample {s} exceeded ceiling {ceiling_ceil}");
assert!(
s <= ceiling_ceil,
"positive sample {s} exceeded ceiling {ceiling_ceil}"
);
}
// Sustained negative loud sum
@@ -185,7 +191,10 @@ mod tests {
let neg_ceiling = -ceiling_ceil;
for &s in &out_neg {
assert!(s < 0, "negative input stays negative, got {s}");
assert!(s >= neg_ceiling, "negative sample {s} exceeded negative ceiling {neg_ceiling}");
assert!(
s >= neg_ceiling,
"negative sample {s} exceeded negative ceiling {neg_ceiling}"
);
}
}
@@ -200,8 +209,14 @@ mod tests {
let out = lim.process(&input, 8.0);
for &s in &out {
assert!(s > 0, "positive stays positive");
assert!(s <= ceiling_ceil, "sample {s} must be limited to ceiling {ceiling_ceil}");
assert!((s - ceiling_ceil).abs() <= 2, "sample {s} should ride the ceiling {ceiling_ceil}");
assert!(
s <= ceiling_ceil,
"sample {s} must be limited to ceiling {ceiling_ceil}"
);
assert!(
(s - ceiling_ceil).abs() <= 2,
"sample {s} should ride the ceiling {ceiling_ceil}"
);
}
}
@@ -213,7 +228,10 @@ mod tests {
let out = lim.process(&input, 0.5);
for (i, &s) in out.iter().enumerate() {
let expected = (input[i] as f32 * 0.5).round() as i16;
assert!((s - expected).abs() <= 1, "sample {s} should be close to expected {expected}");
assert!(
(s - expected).abs() <= 1,
"sample {s} should be close to expected {expected}"
);
}
// Subsequently feed a new sample at unity gain. It must be transparent,
@@ -230,7 +248,12 @@ mod tests {
let loud = vec![200_000i32; 10];
let out = lim.process(&loud, 1.0);
assert!(out[0] <= ceiling_ceil, "first sample {} must not overshoot ceiling {}", out[0], ceiling_ceil);
assert!(
out[0] <= ceiling_ceil,
"first sample {} must not overshoot ceiling {}",
out[0],
ceiling_ceil
);
}
/// 5. Release direction & monotonicity.
@@ -247,13 +270,23 @@ mod tests {
// Output should be monotonic (non-decreasing)
for i in 1..out.len() {
assert!(out[i] >= out[i - 1], "output must be monotonic; index {} was {}, index {} was {}", i - 1, out[i - 1], i, out[i]);
assert!(
out[i] >= out[i - 1],
"output must be monotonic; index {} was {}, index {} was {}",
i - 1,
out[i - 1],
i,
out[i]
);
}
// The end sample should be closer to the original input than the start sample
let start_diff = (mid_val as i16 - out[0]).abs();
let end_diff = (mid_val as i16 - *out.last().unwrap()).abs();
assert!(end_diff < start_diff, "end diff {end_diff} should be smaller than start diff {start_diff}");
assert!(
end_diff < start_diff,
"end diff {end_diff} should be smaller than start diff {start_diff}"
);
}
/// 6. Release is gradual, not instantaneous.
@@ -265,7 +298,11 @@ mod tests {
// Immediately follow with a sub-ceiling sample
let out = lim.process(&[10_000i32], 1.0);
assert!(out[0] < 10_000, "first quiet sample should still be attenuated (got {})", out[0]);
assert!(
out[0] < 10_000,
"first quiet sample should still be attenuated (got {})",
out[0]
);
}
/// 7. State carries across process calls.
@@ -287,7 +324,10 @@ mod tests {
let mut out_split = out_split1;
out_split.extend(&out_split2);
assert_eq!(out_single, out_split, "splitting process calls must produce identical output to a single call");
assert_eq!(
out_single, out_split,
"splitting process calls must produce identical output to a single call"
);
// Test 2: Pre-loaded limiter vs fresh limiter on the same input
let mut lim_preloaded = SoftLimiter::new(SR);
@@ -299,8 +339,16 @@ mod tests {
let out_preloaded = lim_preloaded.process(&test_input, 1.0);
let out_fresh = lim_fresh.process(&test_input, 1.0);
assert_ne!(out_preloaded, out_fresh, "pre-loaded and fresh limiter outputs should differ");
assert!(out_preloaded[0] < out_fresh[0], "pre-loaded limiter first sample {} should be smaller than fresh limiter first sample {}", out_preloaded[0], out_fresh[0]);
assert_ne!(
out_preloaded, out_fresh,
"pre-loaded and fresh limiter outputs should differ"
);
assert!(
out_preloaded[0] < out_fresh[0],
"pre-loaded limiter first sample {} should be smaller than fresh limiter first sample {}",
out_preloaded[0],
out_fresh[0]
);
}
/// 8. Empty input.
@@ -320,7 +368,10 @@ mod tests {
// Gain 0.0
let out_zero = lim.process(&input, 0.0);
assert_eq!(out_zero.len(), input.len());
assert!(out_zero.iter().all(|&s| s == 0), "0.0 gain should result in all zeros");
assert!(
out_zero.iter().all(|&s| s == 0),
"0.0 gain should result in all zeros"
);
// Gain 1.0
let out_unity = lim.process(&input, 1.0);
@@ -354,6 +405,9 @@ mod tests {
let out = lim.process(&input, 1.0);
let expected: Vec<i16> = input.iter().map(|&s| s as i16).collect();
assert_eq!(out, expected, "below ceiling input must be bit-exact at unity gain");
assert_eq!(
out, expected,
"below ceiling input must be bit-exact at unity gain"
);
}
}
+11 -7
View File
@@ -1,6 +1,6 @@
use std::sync::mpsc::{Receiver, Sender};
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::mpsc::{Receiver, Sender};
use thiserror::Error;
/// Playback output channel count. Capture/encode/network remain mono; only the
@@ -35,7 +35,11 @@ pub enum AudioError {
pub trait AudioBackend: Send + Sync {
/// Starts capturing raw PCM audio from the input device (microphone),
/// sending chunks of samples (e.g. `Vec<i16>`) to the provided Sender.
fn start_capture(&self, tx: Sender<Vec<i16>>, target_node: Option<String>) -> Result<(), AudioError>;
fn start_capture(
&self,
tx: Sender<Vec<i16>>,
target_node: Option<String>,
) -> Result<(), AudioError>;
/// Starts playing back raw PCM audio to the output device (speaker),
/// reading mixed/incoming chunks of samples from the provided Receiver.
@@ -65,16 +69,16 @@ pub mod pan;
// Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and
// pure, so it builds (and its tests run) everywhere even though only the cpal
// backend wires it in.
pub mod resample;
#[cfg(windows)]
pub mod cpal_impl;
#[cfg(target_os = "linux")]
pub mod echo_cancel;
#[cfg(target_os = "linux")]
pub mod pipewire_impl;
#[cfg(windows)]
pub mod cpal_impl;
#[cfg(target_os = "linux")]
pub mod pw_cli;
pub mod recorder;
pub mod resample;
/// A selectable audio device for the input/output pickers. `name` is the stable
/// identifier the backend uses to request the device (`target_node`);
@@ -96,10 +100,10 @@ impl std::fmt::Display for AudioDevice {
// Enumerate audio input/output devices for the pickers (sorted by description),
// returning the same `AudioDevice` shape regardless of platform: PipeWire
// (`pw-cli`) on Linux, cpal/WASAPI on Windows.
#[cfg(target_os = "linux")]
pub use pw_cli::enumerate_audio_devices;
#[cfg(windows)]
pub use cpal_impl::enumerate_audio_devices;
#[cfg(target_os = "linux")]
pub use pw_cli::enumerate_audio_devices;
/// The audio backend implementation for the current platform.
///
+33 -7
View File
@@ -108,7 +108,13 @@ pub fn track_filename(name: &str, id: &EndpointId) -> String {
let clean = crate::sanitize::sanitize_name(name);
let mut slug: String = clean
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
// Collapse runs of '-' and trim them off the ends.
while slug.contains("--") {
@@ -284,7 +290,10 @@ mod tests {
let short: String = id.to_string().chars().take(8).collect();
assert_eq!(track_filename("Alice", &id), format!("alice-{short}.wav"));
// Spaces / punctuation collapse to single dashes, trimmed.
assert_eq!(track_filename(" Bob the Builder! ", &id), format!("bob-the-builder-{short}.wav"));
assert_eq!(
track_filename(" Bob the Builder! ", &id),
format!("bob-the-builder-{short}.wav")
);
// A name that sanitizes/slugs to nothing falls back to "peer".
assert_eq!(track_filename("!!!", &id), format!("peer-{short}.wav"));
}
@@ -327,10 +336,18 @@ mod tests {
rec.finalize().unwrap();
let expected = 3 * frame;
assert_eq!(wav_samples(&dir.join("me.wav")), expected, "mic padded to full length");
assert_eq!(
wav_samples(&dir.join("me.wav")),
expected,
"mic padded to full length"
);
assert_eq!(wav_samples(&dir.join("mix.wav")), expected);
assert_eq!(wav_samples(&dir.join(track_filename("p1", &p1))), expected);
assert_eq!(wav_samples(&dir.join(track_filename("p2", &p2))), expected, "silent peer still full length");
assert_eq!(
wav_samples(&dir.join(track_filename("p2", &p2))),
expected,
"silent peer still full length"
);
}
#[test]
@@ -357,8 +374,14 @@ mod tests {
rec.finalize().unwrap();
// Both tracks are the full 5 cycles long (late one was back-padded).
assert_eq!(wav_samples(&dir.join(track_filename("early", &early))), 5 * frame);
assert_eq!(wav_samples(&dir.join(track_filename("late", &late))), 5 * frame);
assert_eq!(
wav_samples(&dir.join(track_filename("early", &early))),
5 * frame
);
assert_eq!(
wav_samples(&dir.join(track_filename("late", &late))),
5 * frame
);
// The late track's first 2 cycles are silence, then the real audio.
let bytes = std::fs::read(dir.join(track_filename("late", &late))).unwrap();
@@ -378,6 +401,9 @@ mod tests {
rec.end_cycle().unwrap();
rec.finalize().unwrap();
assert!(dir.join("me.wav").exists());
assert!(!dir.join("mix.wav").exists(), "no mix track in stems-only mode");
assert!(
!dir.join("mix.wav").exists(),
"no mix track in stems-only mode"
);
}
}
+20 -5
View File
@@ -23,7 +23,10 @@ pub fn pan_gains(pan: f32) -> (f32, f32) {
/// still following the same equal-power curve as a peer is moved away from center.
pub fn playback_pan_gains(pan: f32) -> (f32, f32) {
let (left, right) = pan_gains(pan);
(left * std::f32::consts::SQRT_2, right * std::f32::consts::SQRT_2)
(
left * std::f32::consts::SQRT_2,
right * std::f32::consts::SQRT_2,
)
}
#[cfg(test)]
@@ -36,8 +39,14 @@ mod tests {
fn hard_left_and_right_are_endpoints() {
assert_eq!(pan_gains(-1.0), (1.0, 0.0));
let (l, r) = pan_gains(1.0);
assert!(l.abs() < EPS, "left at hard-right should be zero-ish, got {l}");
assert!((r - 1.0).abs() < EPS, "right at hard-right should be one, got {r}");
assert!(
l.abs() < EPS,
"left at hard-right should be zero-ish, got {l}"
);
assert!(
(r - 1.0).abs() < EPS,
"right at hard-right should be one, got {r}"
);
}
#[test]
@@ -55,8 +64,14 @@ mod tests {
let mut prev_r = f32::NEG_INFINITY;
for pan in pans {
let (l, r) = pan_gains(pan);
assert!(l <= prev_l + EPS, "left gain must not rise as pan moves right");
assert!(r >= prev_r - EPS, "right gain must not fall as pan moves right");
assert!(
l <= prev_l + EPS,
"left gain must not rise as pan moves right"
);
assert!(
r >= prev_r - EPS,
"right gain must not fall as pan moves right"
);
prev_l = l;
prev_r = r;
}
+66 -44
View File
@@ -1,13 +1,16 @@
use crate::audio::{AudioBackend, AudioError};
use std::sync::mpsc::{Sender, Receiver, RecvTimeoutError};
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use pipewire as pw;
use pw::{properties::properties, spa};
use ringbuf::{
HeapRb,
traits::{Consumer, Producer, Split},
};
use spa::pod::Pod;
use ringbuf::{HeapRb, traits::{Consumer, Producer, Split}};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;
pub struct PipeWireBackend {
capture_state: Mutex<Option<CaptureState>>,
@@ -41,7 +44,11 @@ impl PipeWireBackend {
}
impl AudioBackend for PipeWireBackend {
fn start_capture(&self, tx: Sender<Vec<i16>>, target_node: Option<String>) -> Result<(), AudioError> {
fn start_capture(
&self,
tx: Sender<Vec<i16>>,
target_node: Option<String>,
) -> Result<(), AudioError> {
let mut capture_guard = self.capture_state.lock().unwrap();
if capture_guard.is_some() {
return Err(AudioError::Stream("Capture already started".to_string()));
@@ -108,12 +115,17 @@ impl AudioBackend for PipeWireBackend {
}
}
fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_node: Option<String>) -> Result<(), AudioError> {
let mainloop = pw::main_loop::MainLoopRc::new(None)
.map_err(|e| AudioError::Init(e.to_string()))?;
fn run_capture(
cmd_rx: pw::channel::Receiver<()>,
tx: Sender<Vec<i16>>,
target_node: Option<String>,
) -> Result<(), AudioError> {
let mainloop =
pw::main_loop::MainLoopRc::new(None).map_err(|e| AudioError::Init(e.to_string()))?;
let context = pw::context::ContextRc::new(&mainloop, None)
.map_err(|e| AudioError::Init(e.to_string()))?;
let core = context.connect_rc(None)
let core = context
.connect_rc(None)
.map_err(|e| AudioError::Init(e.to_string()))?;
// Ring buffer setup: 9600 samples (200ms capacity for mono 48kHz)
@@ -181,15 +193,16 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_n
let mut params = [Pod::from_bytes(&values).unwrap()];
stream.connect(
spa::utils::Direction::Input,
None,
pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::MAP_BUFFERS
| pw::stream::StreamFlags::RT_PROCESS,
&mut params,
)
.map_err(|e| AudioError::Stream(e.to_string()))?;
stream
.connect(
spa::utils::Direction::Input,
None,
pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::MAP_BUFFERS
| pw::stream::StreamFlags::RT_PROCESS,
&mut params,
)
.map_err(|e| AudioError::Stream(e.to_string()))?;
// Spawn the worker thread to pop from consumer and send Vec<i16> frames
let running = Arc::new(AtomicBool::new(true));
@@ -257,11 +270,7 @@ const WORKER_POLL: Duration = Duration::from_millis(100);
/// every `WORKER_POLL` even when no frames arrive — this is what lets `stop()`
/// join the worker promptly instead of hanging on a parked blocking `recv()`
/// (bug A7). Pure w.r.t. its inputs (no PipeWire), so it's unit-testable.
fn drain_loop(
rx: &Receiver<Vec<i16>>,
running: &AtomicBool,
mut on_frame: impl FnMut(Vec<i16>),
) {
fn drain_loop(rx: &Receiver<Vec<i16>>, running: &AtomicBool, mut on_frame: impl FnMut(Vec<i16>)) {
while running.load(Ordering::Relaxed) {
match rx.recv_timeout(WORKER_POLL) {
Ok(frame) => on_frame(frame),
@@ -293,7 +302,11 @@ fn publish_frame<P: Producer<Item = i16>>(
fn frames_to_produce(requested: usize, mapped_frames: usize) -> usize {
/// Safe per-cycle fallback when the graph doesn't report a quantum.
const FALLBACK_FRAMES: usize = 1024;
let want = if requested > 0 { requested } else { FALLBACK_FRAMES };
let want = if requested > 0 {
requested
} else {
FALLBACK_FRAMES
};
want.min(mapped_frames)
}
@@ -303,11 +316,12 @@ fn run_playback(
target_node: Option<String>,
fill_gauge: Arc<AtomicUsize>,
) -> Result<(), AudioError> {
let mainloop = pw::main_loop::MainLoopRc::new(None)
.map_err(|e| AudioError::Init(e.to_string()))?;
let mainloop =
pw::main_loop::MainLoopRc::new(None).map_err(|e| AudioError::Init(e.to_string()))?;
let context = pw::context::ContextRc::new(&mainloop, None)
.map_err(|e| AudioError::Init(e.to_string()))?;
let core = context.connect_rc(None)
let core = context
.connect_rc(None)
.map_err(|e| AudioError::Init(e.to_string()))?;
// Ring buffer setup: 19200 interleaved samples (200ms capacity for stereo
@@ -428,7 +442,9 @@ fn run_playback(
}
if starved > 0 {
// One wait-free atomic add per quantum — RT-safe.
user_data.underrun_samples.fetch_add(starved, Ordering::Relaxed);
user_data
.underrun_samples
.fetch_add(starved, Ordering::Relaxed);
}
// Decrement the exact occupancy counter by the samples we
// actually pulled (excluding underruns, which removed
@@ -493,7 +509,11 @@ fn run_playback(
pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Int(
pw::spa::utils::Choice(
pw::spa::utils::ChoiceFlags::empty(),
pw::spa::utils::ChoiceEnum::Range { default: 8, min: 2, max: 64 },
pw::spa::utils::ChoiceEnum::Range {
default: 8,
min: 2,
max: 64,
},
),
)),
),
@@ -524,15 +544,16 @@ fn run_playback(
Pod::from_bytes(&buffers_values).unwrap(),
];
stream.connect(
spa::utils::Direction::Output,
None,
pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::MAP_BUFFERS
| pw::stream::StreamFlags::RT_PROCESS,
&mut params,
)
.map_err(|e| AudioError::Stream(e.to_string()))?;
stream
.connect(
spa::utils::Direction::Output,
None,
pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::MAP_BUFFERS
| pw::stream::StreamFlags::RT_PROCESS,
&mut params,
)
.map_err(|e| AudioError::Stream(e.to_string()))?;
// Spawn a worker thread to read from rx and push to producer
let running = Arc::new(AtomicBool::new(true));
@@ -607,7 +628,10 @@ fn run_playback(
#[cfg(test)]
mod tests {
use super::{drain_loop, for_each_capture_sample, frames_to_produce, publish_frame};
use ringbuf::{HeapRb, traits::{Consumer, Producer, Split}};
use ringbuf::{
HeapRb,
traits::{Consumer, Producer, Split},
};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -647,9 +671,7 @@ mod tests {
#[test]
fn capture_size_larger_than_mapping_is_clamped() {
let mut samples = Vec::new();
for_each_capture_sample(&[1, 0, 2, 0, 3], usize::MAX, |sample| {
samples.push(sample)
});
for_each_capture_sample(&[1, 0, 2, 0, 3], usize::MAX, |sample| samples.push(sample));
assert_eq!(samples, vec![1, 2]);
}
+31 -6
View File
@@ -16,11 +16,20 @@ pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
/// Emits the in-progress node as an `AudioDevice` if it's a complete Audio/*
/// node, then resets the accumulators for the next block. Non-audio or
/// incomplete blocks are dropped (but still reset).
fn push_device(name: &mut String, desc: &mut String, class: &mut String, out: &mut Vec<AudioDevice>) {
fn push_device(
name: &mut String,
desc: &mut String,
class: &mut String,
out: &mut Vec<AudioDevice>,
) {
if !name.is_empty() && class.starts_with("Audio/") {
out.push(AudioDevice {
name: name.clone(),
description: if desc.is_empty() { name.clone() } else { desc.clone() },
description: if desc.is_empty() {
name.clone()
} else {
desc.clone()
},
is_input: class == "Audio/Source",
});
}
@@ -44,7 +53,12 @@ fn parse_pw_nodes(text: &str) -> Vec<AudioDevice> {
for line in text.lines() {
let line = line.trim();
if line.starts_with("id ") {
push_device(&mut current_name, &mut current_desc, &mut current_class, &mut devices);
push_device(
&mut current_name,
&mut current_desc,
&mut current_class,
&mut devices,
);
} else if let Some(val) = line.strip_prefix("node.name = \"") {
current_name = val.trim_end_matches('"').to_string();
} else if let Some(val) = line.strip_prefix("node.description = \"") {
@@ -53,7 +67,12 @@ fn parse_pw_nodes(text: &str) -> Vec<AudioDevice> {
current_class = val.trim_end_matches('"').to_string();
}
}
push_device(&mut current_name, &mut current_desc, &mut current_class, &mut devices);
push_device(
&mut current_name,
&mut current_desc,
&mut current_class,
&mut devices,
);
devices.sort_by(|a, b| a.description.cmp(&b.description));
devices
@@ -108,8 +127,14 @@ mod tests {
fn source_is_input_sink_is_output() {
let devices = parse_pw_nodes(SAMPLE_NODES);
// Find devices by name or description to verify is_input
let mic = devices.iter().find(|d| d.name == "alsa_input.builtin").unwrap();
let speakers = devices.iter().find(|d| d.name == "alsa_output.builtin").unwrap();
let mic = devices
.iter()
.find(|d| d.name == "alsa_input.builtin")
.unwrap();
let speakers = devices
.iter()
.find(|d| d.name == "alsa_output.builtin")
.unwrap();
let bare = devices.iter().find(|d| d.name == "bare.sink").unwrap();
assert!(mic.is_input);
+8 -2
View File
@@ -145,7 +145,10 @@ impl StereoPullResampler {
self.frac -= 1.0;
}
let f = self.frac as f32;
let out = (lerp(self.prev.0, self.cur.0, f), lerp(self.prev.1, self.cur.1, f));
let out = (
lerp(self.prev.0, self.cur.0, f),
lerp(self.prev.1, self.cur.1, f),
);
self.frac += self.step;
Some(out)
}
@@ -273,7 +276,10 @@ mod tests {
}
}
// At step 2.0 we consume ~2 input frames per output frame.
assert!(idx > emitted, "consumed {idx} input, emitted {emitted} output");
assert!(
idx > emitted,
"consumed {idx} input, emitted {emitted} output"
);
}
/// A zero rate must not produce a zero `step` (which would spin `push`'s inner