The repo never enforced rustfmt, so formatting had drifted broadly. This is a single mechanical `cargo fmt` pass over the whole crate (no behavioral change; lib suite green, 493 passed). Going forward fmt should be enforced (planned CI fmt --check step). Part of the 0.6.1 hygiene pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
150 lines
5.5 KiB
Rust
150 lines
5.5 KiB
Rust
//! 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<f32>,
|
|
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<WavData, String> {
|
|
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<i16> = 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<f32> = (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);
|
|
}
|
|
}
|