diff --git a/crates/wavekat-core/src/audio.rs b/crates/wavekat-core/src/audio.rs index 8804525..92f45e6 100644 --- a/crates/wavekat-core/src/audio.rs +++ b/crates/wavekat-core/src/audio.rs @@ -390,8 +390,9 @@ impl AudioFrame<'_> { impl AudioFrame<'static> { /// Read a mono WAV file and return an owned `AudioFrame`. /// - /// Accepts both f32 and i16 WAV files. i16 samples are normalised to - /// `[-1.0, 1.0]` (divided by 32768). + /// Accepts f32, i16, and G.711 (A-law / μ-law) WAV files. i16 samples + /// are normalised to `[-1.0, 1.0]` (divided by 32768); G.711 bytes are + /// expanded to 16-bit PCM first, then normalised the same way. /// /// # Example /// @@ -402,7 +403,14 @@ impl AudioFrame<'static> { /// println!("{} Hz, {} samples", frame.sample_rate(), frame.len()); /// ``` pub fn from_wav(path: impl AsRef) -> Result { - let mut reader = hound::WavReader::open(path)?; + let bytes = std::fs::read(path)?; + // hound only parses integer-PCM and float WAVs, but telephony + // tooling commonly emits G.711 WAVs (format tag 6 = A-law, + // 7 = μ-law) — sniff the fmt chunk and decode those ourselves. + if let Some((samples, sample_rate)) = g711_wav_samples(&bytes)? { + return Ok(AudioFrame::from_vec(samples, sample_rate)); + } + let mut reader = hound::WavReader::new(std::io::Cursor::new(bytes))?; let spec = reader.spec(); let sample_rate = spec.sample_rate; let samples: Vec = match spec.sample_format { @@ -416,6 +424,58 @@ impl AudioFrame<'static> { } } +/// WAVE format tags for the two G.711 companding laws (RFC 2361). +#[cfg(feature = "wav")] +const WAVE_FORMAT_ALAW: u16 = 0x0006; +#[cfg(feature = "wav")] +const WAVE_FORMAT_MULAW: u16 = 0x0007; + +/// Return the payload of the first RIFF chunk named `id`, or `None` if the +/// bytes are not a RIFF/WAVE file or the chunk is absent. A declared chunk +/// size that runs past the end of the file is clamped to the bytes present. +#[cfg(feature = "wav")] +fn riff_chunk<'a>(bytes: &'a [u8], id: &[u8; 4]) -> Option<&'a [u8]> { + if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WAVE" { + return None; + } + let mut pos = 12; + while pos + 8 <= bytes.len() { + let size = u32::from_le_bytes(bytes[pos + 4..pos + 8].try_into().ok()?) as usize; + let start = pos + 8; + if &bytes[pos..pos + 4] == id { + return Some(&bytes[start..start.checked_add(size)?.min(bytes.len())]); + } + // Chunks are word-aligned: an odd size is followed by a pad byte. + pos = start.checked_add(size)?.checked_add(size & 1)?; + } + None +} + +/// If `bytes` are a G.711 WAV (format tag 6 or 7), decode the data chunk to +/// normalised f32 samples and return them with the sample rate. Returns +/// `Ok(None)` for anything that isn't G.711 WAV — including non-RIFF bytes — +/// so the caller can fall through to hound (whose parse errors are more +/// descriptive than anything this sniffer could produce). +#[cfg(feature = "wav")] +fn g711_wav_samples(bytes: &[u8]) -> Result, u32)>, crate::CoreError> { + let Some(fmt) = riff_chunk(bytes, b"fmt ") else { + return Ok(None); + }; + if fmt.len() < 16 { + return Ok(None); + } + let decode: fn(u8) -> i16 = match u16::from_le_bytes([fmt[0], fmt[1]]) { + WAVE_FORMAT_ALAW => crate::codec::g711::alaw_to_linear, + WAVE_FORMAT_MULAW => crate::codec::g711::ulaw_to_linear, + _ => return Ok(None), + }; + let sample_rate = u32::from_le_bytes([fmt[4], fmt[5], fmt[6], fmt[7]]); + let data = riff_chunk(bytes, b"data") + .ok_or_else(|| crate::CoreError::Audio("G.711 WAV has no data chunk".to_string()))?; + let samples = data.iter().map(|&b| decode(b) as f32 / 32768.0).collect(); + Ok(Some((samples, sample_rate))) +} + /// Trait for types that can be converted into audio samples. /// /// Implemented for `&[f32]` (zero-copy) and `&[i16]` (normalized conversion). @@ -561,6 +621,137 @@ mod tests { } } + /// Build a G.711 WAV the way telephony encoders do: 18-byte fmt chunk + /// (cbSize = 0) followed by a `fact` chunk, so reading also exercises + /// unknown-chunk skipping in the RIFF walker. + #[cfg(feature = "wav")] + fn write_g711_wav(path: &std::path::Path, format_tag: u16, sample_rate: u32, data: &[u8]) { + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"RIFF"); + bytes.extend_from_slice(&0u32.to_le_bytes()); // patched below + bytes.extend_from_slice(b"WAVE"); + bytes.extend_from_slice(b"fmt "); + bytes.extend_from_slice(&18u32.to_le_bytes()); + bytes.extend_from_slice(&format_tag.to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); // mono + bytes.extend_from_slice(&sample_rate.to_le_bytes()); + bytes.extend_from_slice(&sample_rate.to_le_bytes()); // byte rate: 1 byte/sample + bytes.extend_from_slice(&1u16.to_le_bytes()); // block align + bytes.extend_from_slice(&8u16.to_le_bytes()); // bits per sample + bytes.extend_from_slice(&0u16.to_le_bytes()); // cbSize + bytes.extend_from_slice(b"fact"); + bytes.extend_from_slice(&4u32.to_le_bytes()); + bytes.extend_from_slice(&(data.len() as u32).to_le_bytes()); + bytes.extend_from_slice(b"data"); + bytes.extend_from_slice(&(data.len() as u32).to_le_bytes()); + bytes.extend_from_slice(data); + if data.len() % 2 == 1 { + bytes.push(0); // RIFF chunks are word-aligned + } + let riff_size = (bytes.len() - 8) as u32; + bytes[4..8].copy_from_slice(&riff_size.to_le_bytes()); + std::fs::write(path, bytes).unwrap(); + } + + #[cfg(feature = "wav")] + #[test] + fn wav_read_g711_ulaw() { + use crate::codec::g711; + let pcm: &[i16] = &[0, 1000, -1000, i16::MAX, i16::MIN + 1]; + let encoded: Vec = pcm.iter().map(|&s| g711::linear_to_ulaw(s)).collect(); + let path = std::env::temp_dir().join("wavekat_test_ulaw.wav"); + write_g711_wav(&path, 0x0007, 8000, &encoded); + + let frame = AudioFrame::from_wav(&path).unwrap(); + assert_eq!(frame.sample_rate(), 8000); + assert_eq!(frame.len(), pcm.len()); + for (got, &byte) in frame.samples().iter().zip(&encoded) { + let want = g711::ulaw_to_linear(byte) as f32 / 32768.0; + assert!( + (got - want).abs() < 1e-6, + "sample mismatch: {got} vs {want}" + ); + } + } + + #[cfg(feature = "wav")] + #[test] + fn wav_read_g711_alaw() { + use crate::codec::g711; + let pcm: &[i16] = &[0, 1000, -1000, i16::MAX, i16::MIN + 1]; + let encoded: Vec = pcm.iter().map(|&s| g711::linear_to_alaw(s)).collect(); + let path = std::env::temp_dir().join("wavekat_test_alaw.wav"); + write_g711_wav(&path, 0x0006, 8000, &encoded); + + let frame = AudioFrame::from_wav(&path).unwrap(); + assert_eq!(frame.sample_rate(), 8000); + assert_eq!(frame.len(), pcm.len()); + for (got, &byte) in frame.samples().iter().zip(&encoded) { + let want = g711::alaw_to_linear(byte) as f32 / 32768.0; + assert!( + (got - want).abs() < 1e-6, + "sample mismatch: {got} vs {want}" + ); + } + } + + #[cfg(feature = "wav")] + #[test] + fn wav_read_g711_missing_data_chunk_errors() { + // fmt says μ-law but there is no data chunk at all. + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"RIFF"); + bytes.extend_from_slice(&30u32.to_le_bytes()); + bytes.extend_from_slice(b"WAVE"); + bytes.extend_from_slice(b"fmt "); + bytes.extend_from_slice(&18u32.to_le_bytes()); + bytes.extend_from_slice(&0x0007u16.to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); + bytes.extend_from_slice(&8000u32.to_le_bytes()); + bytes.extend_from_slice(&8000u32.to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); + bytes.extend_from_slice(&8u16.to_le_bytes()); + bytes.extend_from_slice(&0u16.to_le_bytes()); + let path = std::env::temp_dir().join("wavekat_test_ulaw_nodata.wav"); + std::fs::write(&path, bytes).unwrap(); + + let err = AudioFrame::from_wav(&path).unwrap_err(); + assert!( + err.to_string().contains("data chunk"), + "unexpected error: {err}" + ); + } + + #[cfg(feature = "wav")] + #[test] + fn wav_read_truncated_fmt_chunk_falls_through_to_hound() { + // A fmt chunk shorter than the 16-byte minimum can't carry a format + // tag — the sniffer must not decode it as G.711, and hound rejects it. + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"RIFF"); + bytes.extend_from_slice(&20u32.to_le_bytes()); + bytes.extend_from_slice(b"WAVE"); + bytes.extend_from_slice(b"fmt "); + bytes.extend_from_slice(&8u32.to_le_bytes()); + bytes.extend_from_slice(&0x0007u16.to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); + bytes.extend_from_slice(&8000u32.to_le_bytes()); + let path = std::env::temp_dir().join("wavekat_test_short_fmt.wav"); + std::fs::write(&path, bytes).unwrap(); + + assert!(AudioFrame::from_wav(&path).is_err()); + } + + #[cfg(feature = "wav")] + #[test] + fn wav_read_non_riff_bytes_still_error() { + // Not a WAV at all: the G.711 sniffer must pass it through to + // hound, which rejects it — not panic or return silence. + let path = std::env::temp_dir().join("wavekat_test_not_a_wav.wav"); + std::fs::write(&path, b"these bytes are not a RIFF WAV").unwrap(); + assert!(AudioFrame::from_wav(&path).is_err()); + } + #[test] fn from_vec_is_zero_copy() { let samples = vec![0.5f32, -0.5]; diff --git a/docs/01-wav-io.md b/docs/01-wav-io.md index b41b75b..62332e2 100644 --- a/docs/01-wav-io.md +++ b/docs/01-wav-io.md @@ -29,8 +29,12 @@ sample rate. pub fn from_wav(path: impl AsRef) -> Result, hound::Error> ``` -Reads a mono WAV file and returns an owned `AudioFrame`. Accepts both f32 and -i16 files; i16 samples are normalised to `[-1.0, 1.0]` (divided by 32768). +Reads a mono WAV file and returns an owned `AudioFrame`. Accepts f32, i16, +and G.711 files (format tag 6 = A-law, 7 = μ-law — what telephony tooling +emits); i16 samples are normalised to `[-1.0, 1.0]` (divided by 32768), and +G.711 bytes are expanded to 16-bit PCM via `codec::g711` first, then +normalised the same way. hound handles the PCM/float parsing; the G.711 path +is a small built-in RIFF walk, since hound only parses integer and float PCM. ## Example