//! 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 = 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::>(); 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 = 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::>(); 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)), } } }); } }