//! A minimal canonical-PCM WAV reader (the analysis-side counterpart to //! `audio::recorder::WavWriter`). Handles 16-bit integer PCM — the only format //! PeerSpeak writes — in mono or interleaved multi-channel, downmixing to mono //! `f32` in `[-1, 1]` for analysis. I/O lives here, at the edge; the rest of the //! `dsp` module is pure. use std::fs; use std::path::Path; /// A decoded WAV: mono `f32` samples plus the original sample rate. pub struct WavData { pub samples: Vec, pub sample_rate: u32, } /// Reads a 16-bit PCM WAV file, downmixing any channels to mono `f32`. /// /// Returns `Err` with a human-readable reason if the file is missing, truncated, /// not RIFF/WAVE, or not 16-bit PCM. The parser walks the chunk list rather than /// assuming a fixed 44-byte header, so files with extra chunks (`LIST`, `fact`, /// …) still read. pub fn read(path: &Path) -> Result { let bytes = fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?; if bytes.len() < 12 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WAVE" { return Err("not a RIFF/WAVE file".to_string()); } let mut channels = 0u16; let mut sample_rate = 0u32; let mut bits = 0u16; let mut data: Option<&[u8]> = None; // Walk chunks starting after the 12-byte RIFF/WAVE header. let mut pos = 12usize; while pos + 8 <= bytes.len() { let id = &bytes[pos..pos + 4]; let size = u32::from_le_bytes([ bytes[pos + 4], bytes[pos + 5], bytes[pos + 6], bytes[pos + 7], ]) as usize; let body_start = pos + 8; let body_end = (body_start + size).min(bytes.len()); match id { b"fmt " if size >= 16 => { let fmt = &bytes[body_start..body_end]; let audio_format = u16::from_le_bytes([fmt[0], fmt[1]]); channels = u16::from_le_bytes([fmt[2], fmt[3]]); sample_rate = u32::from_le_bytes([fmt[4], fmt[5], fmt[6], fmt[7]]); bits = u16::from_le_bytes([fmt[14], fmt[15]]); if audio_format != 1 { return Err(format!( "unsupported WAV format tag {audio_format} (need PCM=1)" )); } } b"data" => { data = Some(&bytes[body_start..body_end]); } _ => {} } // Chunks are word-aligned: an odd size is padded with one byte. pos = body_start + size + (size & 1); } if bits != 16 { return Err(format!("only 16-bit PCM supported, got {bits}-bit")); } let channels = channels.max(1); let data = data.ok_or("no data chunk")?; // Interleaved S16LE -> per-frame channel average -> mono f32. let frame_bytes = 2 * channels as usize; let mut samples = Vec::with_capacity(data.len() / frame_bytes.max(1)); for frame in data.chunks_exact(frame_bytes) { let mut acc = 0i32; for ch in frame.chunks_exact(2) { acc += i16::from_le_bytes([ch[0], ch[1]]) as i32; } let avg = acc as f32 / channels as f32; samples.push(avg / 32768.0); } Ok(WavData { samples, sample_rate, }) } /// Writes mono `f32` samples (clamped to `[-1, 1]`) as a 16-bit PCM WAV. Used by /// the `specview gen` command to materialize conjured test signals. pub fn write(path: &Path, samples: &[f32], sample_rate: u32) -> Result<(), String> { let pcm: Vec = samples .iter() .map(|&s| (s.clamp(-1.0, 1.0) * 32767.0).round() as i16) .collect(); let data_bytes = (pcm.len() * 2) as u32; let byte_rate = sample_rate * 2; // mono, 2 bytes/sample let mut out = Vec::with_capacity(44 + pcm.len() * 2); out.extend_from_slice(b"RIFF"); out.extend_from_slice(&(36 + data_bytes).to_le_bytes()); out.extend_from_slice(b"WAVE"); out.extend_from_slice(b"fmt "); out.extend_from_slice(&16u32.to_le_bytes()); out.extend_from_slice(&1u16.to_le_bytes()); // PCM out.extend_from_slice(&1u16.to_le_bytes()); // mono out.extend_from_slice(&sample_rate.to_le_bytes()); out.extend_from_slice(&byte_rate.to_le_bytes()); out.extend_from_slice(&2u16.to_le_bytes()); // block align out.extend_from_slice(&16u16.to_le_bytes()); // bits out.extend_from_slice(b"data"); out.extend_from_slice(&data_bytes.to_le_bytes()); for s in pcm { out.extend_from_slice(&s.to_le_bytes()); } fs::write(path, out).map_err(|e| format!("cannot write {}: {e}", path.display())) } #[cfg(test)] mod tests { use super::*; #[test] fn write_then_read_round_trips() { let dir = std::env::temp_dir(); let path = dir.join(format!("dsp-wav-rt-{}.wav", std::process::id())); let sig: Vec = (0..1000).map(|i| (i as f32 * 0.01).sin() * 0.5).collect(); write(&path, &sig, 48_000).unwrap(); let back = read(&path).unwrap(); assert_eq!(back.sample_rate, 48_000); assert_eq!(back.samples.len(), sig.len()); // 16-bit quantization tolerance. for (a, b) in sig.iter().zip(&back.samples) { assert!((a - b).abs() < 1e-3, "{a} vs {b}"); } let _ = fs::remove_file(&path); } #[test] fn rejects_non_wave() { let dir = std::env::temp_dir(); let path = dir.join(format!("dsp-wav-bad-{}.bin", std::process::id())); fs::write(&path, b"not a wav at all").unwrap(); assert!(read(&path).is_err()); let _ = fs::remove_file(&path); } }