Files
chanora/crates/chanora_audio/src/debug_wav.rs
T

340 lines
11 KiB
Rust

//! 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<DebugFrame>,
/// 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<Self> {
let (tx, rx) = mpsc::sync_channel::<DebugFrame>(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<Self> {
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<DebugFrame>, 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);
}
}