340 lines
11 KiB
Rust
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);
|
|
}
|
|
}
|