feat(voice): add iOS VAD runtime support
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
//! 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 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user