diff --git a/src/bin/audio_probe.rs b/src/bin/audio_probe.rs index b08712d..c1f8063 100644 --- a/src/bin/audio_probe.rs +++ b/src/bin/audio_probe.rs @@ -1,11 +1,11 @@ //! Audio playout diagnostic probe. //! -//! Drives a phase-continuous sine tone through the *real* PipeWire playback path -//! (`PipeWireBackend::start_playback`), using the *same* fill-paced production -//! the production mixer uses (`core/mod.rs`): generate a frame only while the +//! 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 -//! PipeWire hardware clock. No network, no microphone — this isolates the local -//! output path so we can confirm the clock-paced playout is glitch-free. +//! 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: @@ -18,17 +18,22 @@ //! 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 PipeWire backend directly, so it is a Linux-only tool. -//! On non-Linux targets `main` is a stub that explains the limitation. +//! 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(not(target_os = "linux"))] +#[cfg(windows)] fn main() { - eprintln!("audio_probe is only supported on Linux builds (it drives the PipeWire backend directly)."); + 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")] @@ -135,3 +140,108 @@ mod unix_probe { }); } } + +#[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)), + } + } + }); + } +}