//! Async WAV debug dump writer for P1 diagnostics. //! //! Captures three streams for offline analysis: //! * `raw_mic` — before AudioProcessor (INV_007: never from callback) //! * `render_reference` — remote mixer output before playout //! * `processed_mic` — after AudioProcessor //! //! ## Design //! //! The realtime callback MUST NOT write to disk (INV_007). Instead it //! pushes 10 ms f32 frames onto a bounded `std::sync::mpsc` channel. //! A background `tokio::task` drains the channel and writes WAV data. //! //! The channel is bounded (capacity = 500 frames ≈ 5 s of audio per //! stream). If the writer falls behind, frames are dropped rather than //! blocking the callback thread. //! //! WAV files are written to the OS temp directory with a filename that //! encodes the stream name, route, backend, and a timestamp so //! multiple sessions don't overwrite each other. //! //! ## Usage //! //! ```ignore //! let writer = WavDebugRecorder::start(route, backend); //! // In realtime callback (non-blocking): //! writer.push_raw_mic(&frame); //! writer.push_render_reference(&frame); //! writer.push_processed_mic(&frame); //! // On session end: //! writer.stop(); // flushes and closes files //! ``` use std::io::Write; use std::path::PathBuf; use std::sync::mpsc; use std::time::{SystemTime, UNIX_EPOCH}; use tracing::{info, warn}; use crate::audio_processing::{AudioBackend, AudioRoute}; use crate::frame::FRAME_10MS_SAMPLES; /// Maximum number of 10 ms frames buffered per stream before drops. const CHANNEL_CAPACITY: usize = 500; /// Sample rate for WAV output (matches the capture pipeline). const WAV_SAMPLE_RATE: u32 = 48_000; /// Identifies which debug stream a frame belongs to. #[derive(Debug, Clone, Copy)] enum StreamId { RawMic, RenderReference, ProcessedMic, } /// A single 10 ms frame tagged with its stream. struct DebugFrame { stream: StreamId, samples: Box<[f32; FRAME_10MS_SAMPLES]>, } /// Handle for pushing frames from the realtime callback. /// /// All push methods are non-blocking: if the channel is full the /// frame is silently dropped and a counter is incremented. pub struct WavDebugRecorder { tx: mpsc::SyncSender, /// Frames dropped due to full channel (diagnostic only). drops: std::sync::atomic::AtomicU64, /// Whether the recorder is active (set to false on stop). active: std::sync::atomic::AtomicBool, } impl WavDebugRecorder { /// Start the async WAV writer task. Returns a handle for pushing /// frames from the realtime callback. /// /// `route` and `backend` are embedded in the output filenames. pub fn start(route: AudioRoute, backend: AudioBackend) -> std::sync::Arc { let (tx, rx) = mpsc::sync_channel::(CHANNEL_CAPACITY); let recorder = std::sync::Arc::new(Self { tx, drops: std::sync::atomic::AtomicU64::new(0), active: std::sync::atomic::AtomicBool::new(true), }); let ts = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); let route_str = route.as_str().to_string(); let backend_str = backend.as_str().to_string(); // Spawn a blocking task so the WAV writer doesn't compete // with the tokio async executor for CPU time. std::thread::Builder::new() .name("chanora-wav-writer".to_string()) .spawn(move || { wav_writer_task(rx, &route_str, &backend_str, ts); }) .ok(); recorder } /// Push a raw mic frame (before AudioProcessor). Non-blocking. pub fn push_raw_mic(&self, samples: &[f32; FRAME_10MS_SAMPLES]) { self.push(StreamId::RawMic, samples); } /// Push a render-reference frame (remote mixer output before playout). /// Non-blocking. pub fn push_render_reference(&self, samples: &[f32; FRAME_10MS_SAMPLES]) { self.push(StreamId::RenderReference, samples); } /// Push a processed mic frame (after AudioProcessor). Non-blocking. pub fn push_processed_mic(&self, samples: &[f32; FRAME_10MS_SAMPLES]) { self.push(StreamId::ProcessedMic, samples); } /// Stop the recorder. Drops the sender so the writer task drains /// and closes the WAV files. pub fn stop(&self) { self.active .store(false, std::sync::atomic::Ordering::Relaxed); // The sender is not dropped here because `self` is behind Arc. // The writer task will exit when all senders are dropped (i.e. // when the Arc is dropped). This is intentional: the task // drains any remaining frames before closing files. } /// Number of frames dropped due to a full channel. pub fn drop_count(&self) -> u64 { self.drops.load(std::sync::atomic::Ordering::Relaxed) } fn push(&self, stream: StreamId, samples: &[f32; FRAME_10MS_SAMPLES]) { if !self.active.load(std::sync::atomic::Ordering::Relaxed) { return; } let mut boxed = Box::new([0.0_f32; FRAME_10MS_SAMPLES]); boxed.copy_from_slice(samples); let frame = DebugFrame { stream, samples: boxed, }; if self.tx.try_send(frame).is_err() { self.drops .fetch_add(1, std::sync::atomic::Ordering::Relaxed); } } } // ---------- WAV writer task ---------- struct WavFile { path: PathBuf, file: std::fs::File, samples_written: u32, } impl WavFile { fn create(dir: &std::path::Path, name: &str) -> Option { let path = dir.join(name); match std::fs::File::create(&path) { Ok(mut file) => { // Write a placeholder WAV header; we'll patch it on close. if write_wav_header(&mut file, 0).is_ok() { Some(Self { path, file, samples_written: 0, }) } else { None } } Err(e) => { warn!(target: "chanora_audio", error = %e, path = %path.display(), "wav debug: failed to create file"); None } } } fn write_samples(&mut self, samples: &[f32]) { for &s in samples { let i16_val = (s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16; let _ = self.file.write_all(&i16_val.to_le_bytes()); } self.samples_written += samples.len() as u32; } fn finalize(mut self) { // Seek back to the start and rewrite the header with the // correct data size. use std::io::Seek; if self.file.seek(std::io::SeekFrom::Start(0)).is_ok() { let _ = write_wav_header(&mut self.file, self.samples_written); } info!( target: "chanora_audio", path = %self.path.display(), samples = self.samples_written, "wav debug: file closed" ); } } fn write_wav_header(file: &mut std::fs::File, num_samples: u32) -> std::io::Result<()> { // PCM WAV header: 44 bytes. // Channels: 1 (mono), sample rate: 48000, bit depth: 16. let channels: u16 = 1; let sample_rate: u32 = WAV_SAMPLE_RATE; let bits_per_sample: u16 = 16; let byte_rate = sample_rate * channels as u32 * bits_per_sample as u32 / 8; let block_align = channels * bits_per_sample / 8; let data_size = num_samples * channels as u32 * bits_per_sample as u32 / 8; let chunk_size = 36 + data_size; file.write_all(b"RIFF")?; file.write_all(&chunk_size.to_le_bytes())?; file.write_all(b"WAVE")?; file.write_all(b"fmt ")?; file.write_all(&16u32.to_le_bytes())?; // subchunk1 size file.write_all(&1u16.to_le_bytes())?; // PCM format file.write_all(&channels.to_le_bytes())?; file.write_all(&sample_rate.to_le_bytes())?; file.write_all(&byte_rate.to_le_bytes())?; file.write_all(&block_align.to_le_bytes())?; file.write_all(&bits_per_sample.to_le_bytes())?; file.write_all(b"data")?; file.write_all(&data_size.to_le_bytes())?; Ok(()) } fn wav_writer_task(rx: mpsc::Receiver, route: &str, backend: &str, ts: u64) { let dir = std::env::temp_dir(); let prefix = format!("chanora_debug_{route}_{backend}_{ts}"); let mut raw_mic = WavFile::create(&dir, &format!("{prefix}_raw_mic.wav")); let mut render_ref = WavFile::create(&dir, &format!("{prefix}_render_reference.wav")); let mut processed = WavFile::create(&dir, &format!("{prefix}_processed_mic.wav")); info!( target: "chanora_audio", dir = %dir.display(), prefix = %prefix, "wav debug: writer started" ); for frame in rx { match frame.stream { StreamId::RawMic => { if let Some(f) = raw_mic.as_mut() { f.write_samples(&*frame.samples); } } StreamId::RenderReference => { if let Some(f) = render_ref.as_mut() { f.write_samples(&*frame.samples); } } StreamId::ProcessedMic => { if let Some(f) = processed.as_mut() { f.write_samples(&*frame.samples); } } } } // Channel closed — finalize all files. if let Some(f) = raw_mic { f.finalize(); } if let Some(f) = render_ref { f.finalize(); } if let Some(f) = processed { f.finalize(); } info!(target: "chanora_audio", "wav debug: writer task exited"); } #[cfg(test)] mod tests { use super::*; #[test] fn recorder_starts_and_stops_without_panic() { let rec = WavDebugRecorder::start(AudioRoute::Speaker, AudioBackend::PlatformVoiceProcessing); let frame = [0.1_f32; FRAME_10MS_SAMPLES]; rec.push_raw_mic(&frame); rec.push_render_reference(&frame); rec.push_processed_mic(&frame); rec.stop(); // Drop the Arc to let the writer task drain. drop(rec); // Give the writer thread a moment to finish. std::thread::sleep(std::time::Duration::from_millis(100)); } #[test] fn drop_count_increments_when_channel_full() { // Use a tiny channel by creating a recorder and flooding it. // We can't easily test the bounded channel directly, but we // can verify the drop counter starts at zero. let rec = WavDebugRecorder::start(AudioRoute::Speaker, AudioBackend::Noop); assert_eq!(rec.drop_count(), 0); rec.stop(); } #[test] fn ios_raw_debug_wav_does_not_push_from_realtime_callback() { let src = include_str!("ios_raw_unit.rs"); assert!( !src.contains("push_raw_mic") && !src.contains("push_processed_mic"), "ios raw callbacks must not call WavDebugRecorder push_*_mic until it has a preallocated handoff" ); } #[test] fn wav_header_is_44_bytes() { // Write to a temp file to test the header. let tmp = std::env::temp_dir().join("chanora_test_wav_header.wav"); let mut f = std::fs::File::create(&tmp).unwrap(); write_wav_header(&mut f, 960).unwrap(); drop(f); let data = std::fs::read(&tmp).unwrap(); assert_eq!(data.len(), 44, "WAV header must be 44 bytes"); assert_eq!(&data[0..4], b"RIFF"); assert_eq!(&data[8..12], b"WAVE"); let _ = std::fs::remove_file(&tmp); } }