Delete ios_raw_unit.rs (538 lines) and all SonoraExperimental references from the core audio crate. The experimental RemoteIO path that bypassed Apple VPIO in favor of software WebRTC APM was never shipped and is no longer needed. VPIO is the sole production iOS audio backend. - Delete ios_raw_unit.rs entirely - Remove Raw variant from IosVoiceBackend enum in engine.rs - Remove SonoraExperimental from IosVoiceProcessingMode enum - Simplify validate_for_ios() (single-variant enum, no mode check) - Remove 2 SonoraExperimental validation tests - Remove ios_raw_unit module declaration from lib.rs - Remove include_str!-based debug_wav test for the deleted file
331 lines
11 KiB
Rust
331 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 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);
|
|
}
|
|
}
|