252 lines
10 KiB
Rust
252 lines
10 KiB
Rust
//! Audio playout diagnostic probe.
|
|
//!
|
|
//! Drives a phase-continuous sine tone through the *real* playback path
|
|
//! (PipeWire on Linux, cpal/WASAPI on Windows), using the *same* fill-paced
|
|
//! production the production mixer uses (`core/mod.rs`): generate a frame only while the
|
|
//! playback ring is below `PLAYBACK_TARGET_SAMPLES`, so production tracks the
|
|
//! hardware clock. No network, no microphone — this isolates the local output
|
|
//! path so we can confirm the clock-paced playout is glitch-free.
|
|
//!
|
|
//! Use your ears on the tone (any click/pop is a glitch) together with the
|
|
//! `playout-health:` lines tailed to stdout:
|
|
//! - all-zero health lines + clean tone → local playout is healthy; any
|
|
//! crackle on real calls is upstream (per-peer jitter buffer), not this ring.
|
|
//! - steady `underrun +N samples/s` (or steady `dropped +N frames/s`) with a
|
|
//! slowly drifting `fill` → clock drift (producer vs hardware clock).
|
|
//! - random bursts correlated with system load → scheduling jitter.
|
|
//!
|
|
//! Run: cargo run --bin audio_probe -- [freq_hz] [seconds] [target_node]
|
|
//! e.g. cargo run --release --bin audio_probe -- 440 30
|
|
//!
|
|
//! This probe exercises the platform playback backend directly: PipeWire on Linux
|
|
//! and cpal/WASAPI on Windows. Other targets use a stub that explains the limitation.
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn main() {
|
|
unix_probe::run();
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn main() {
|
|
win_probe::run();
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "linux", windows)))]
|
|
fn main() {
|
|
eprintln!(
|
|
"audio_probe is only supported on Linux and Windows builds (it drives the platform playback backend directly)."
|
|
);
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
mod unix_probe {
|
|
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::AtomicUsize;
|
|
use std::sync::mpsc;
|
|
use std::time::Duration;
|
|
|
|
use peerspeak::audio::AudioBackend;
|
|
use peerspeak::audio::pipewire_impl::PipeWireBackend;
|
|
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
|
|
|
|
const SAMPLE_RATE: f32 = 48_000.0;
|
|
|
|
#[tokio::main]
|
|
pub async fn run() {
|
|
let mut args = std::env::args().skip(1);
|
|
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
|
|
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
|
|
let target_node: Option<String> = args.next();
|
|
|
|
// The playout-health logger is quiet in normal operation (it only logs
|
|
// glitches); ask it for the full once-per-second heartbeat so the probe can
|
|
// show the steady-state numbers.
|
|
// SAFETY: set before any playback thread starts, so no concurrent env read.
|
|
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
|
|
|
|
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
|
|
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
|
|
|
|
// Tail the app log (where playout-health lines land) to stdout in the
|
|
// background so it's all in one terminal.
|
|
spawn_log_tailer();
|
|
|
|
let backend = PipeWireBackend::new();
|
|
let (tx, rx) = mpsc::channel::<Vec<i16>>();
|
|
let ring_fill = Arc::new(AtomicUsize::new(0));
|
|
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
|
|
eprintln!("failed to start playback: {e}");
|
|
return;
|
|
}
|
|
|
|
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
|
|
// exactly like the production mixer: only produce while the ring is below
|
|
// target, so production tracks the PipeWire hardware clock.
|
|
use std::sync::atomic::Ordering;
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
|
|
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
|
|
while tokio::time::Instant::now() < deadline {
|
|
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
|
|
tokio::time::sleep(Duration::from_millis(2)).await;
|
|
continue;
|
|
}
|
|
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
|
|
for _ in 0..FRAME_SAMPLES {
|
|
let t = n as f32 / SAMPLE_RATE;
|
|
// 0.25 amplitude: clearly audible but not harsh.
|
|
let sample =
|
|
(0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
|
|
// Stereo playback bus: duplicate the probe tone to L/R.
|
|
frame.push(sample);
|
|
frame.push(sample);
|
|
n += 1;
|
|
}
|
|
if tx.send(frame).is_err() {
|
|
eprintln!("playback channel closed early");
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Let the ring drain, then stop.
|
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
|
let _ = backend.stop();
|
|
println!("\naudio_probe: done.");
|
|
}
|
|
|
|
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
|
|
/// reports) to stdout once they appear.
|
|
fn spawn_log_tailer() {
|
|
let path = peerspeak::log_file_path();
|
|
std::thread::spawn(move || {
|
|
// Wait for the file to exist (first log_msg creates it).
|
|
let file = loop {
|
|
if let Ok(f) = std::fs::File::open(&path) {
|
|
break f;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
};
|
|
let mut reader = BufReader::new(file);
|
|
let _ = reader.seek(SeekFrom::End(0));
|
|
loop {
|
|
let mut line = String::new();
|
|
match reader.read_line(&mut line) {
|
|
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
|
|
Ok(_) => {
|
|
if line.contains("playout-health:") {
|
|
print!("{line}");
|
|
}
|
|
}
|
|
Err(_) => std::thread::sleep(Duration::from_millis(150)),
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
mod win_probe {
|
|
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::AtomicUsize;
|
|
use std::sync::mpsc;
|
|
use std::time::Duration;
|
|
|
|
use peerspeak::audio::AudioBackend;
|
|
use peerspeak::audio::cpal_impl::CpalBackend;
|
|
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
|
|
|
|
const SAMPLE_RATE: f32 = 48_000.0;
|
|
|
|
#[tokio::main]
|
|
pub async fn run() {
|
|
let mut args = std::env::args().skip(1);
|
|
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
|
|
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
|
|
let target_node: Option<String> = args.next();
|
|
|
|
// The playout-health logger is quiet in normal operation (it only logs
|
|
// glitches); ask it for the full once-per-second heartbeat so the probe can
|
|
// show the steady-state numbers.
|
|
// SAFETY: set before any playback thread starts, so no concurrent env read.
|
|
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
|
|
|
|
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
|
|
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
|
|
|
|
// Tail the app log (where playout-health lines land) to stdout in the
|
|
// background so it's all in one terminal.
|
|
spawn_log_tailer();
|
|
|
|
let backend = CpalBackend::new();
|
|
let (tx, rx) = mpsc::channel::<Vec<i16>>();
|
|
let ring_fill = Arc::new(AtomicUsize::new(0));
|
|
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
|
|
eprintln!("failed to start playback: {e}");
|
|
return;
|
|
}
|
|
|
|
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
|
|
// exactly like the production mixer: only produce while the ring is below
|
|
// target, so production tracks the cpal/WASAPI hardware clock.
|
|
use std::sync::atomic::Ordering;
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
|
|
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
|
|
while tokio::time::Instant::now() < deadline {
|
|
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
|
|
tokio::time::sleep(Duration::from_millis(2)).await;
|
|
continue;
|
|
}
|
|
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
|
|
for _ in 0..FRAME_SAMPLES {
|
|
let t = n as f32 / SAMPLE_RATE;
|
|
// 0.25 amplitude: clearly audible but not harsh.
|
|
let sample =
|
|
(0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
|
|
// Stereo playback bus: duplicate the probe tone to L/R.
|
|
frame.push(sample);
|
|
frame.push(sample);
|
|
n += 1;
|
|
}
|
|
if tx.send(frame).is_err() {
|
|
eprintln!("playback channel closed early");
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Let the ring drain, then stop.
|
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
|
let _ = backend.stop();
|
|
println!("\naudio_probe: done.");
|
|
}
|
|
|
|
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
|
|
/// reports) to stdout once they appear.
|
|
fn spawn_log_tailer() {
|
|
let path = peerspeak::log_file_path();
|
|
std::thread::spawn(move || {
|
|
// Wait for the file to exist (first log_msg creates it).
|
|
let file = loop {
|
|
if let Ok(f) = std::fs::File::open(&path) {
|
|
break f;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
};
|
|
let mut reader = BufReader::new(file);
|
|
let _ = reader.seek(SeekFrom::End(0));
|
|
loop {
|
|
let mut line = String::new();
|
|
match reader.read_line(&mut line) {
|
|
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
|
|
Ok(_) => {
|
|
if line.contains("playout-health:") {
|
|
print!("{line}");
|
|
}
|
|
}
|
|
Err(_) => std::thread::sleep(Duration::from_millis(150)),
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|