From d3b33f4ef669a4fa7811f362236f4613efc86576 Mon Sep 17 00:00:00 2001 From: Eason WaveKat Date: Tue, 7 Apr 2026 09:54:39 +1200 Subject: [PATCH 1/2] feat: add WAV I/O via optional wav feature Co-Authored-By: Claude Sonnet 4.6 --- crates/wavekat-core/Cargo.toml | 4 ++ crates/wavekat-core/src/audio.rs | 72 ++++++++++++++++++++++++++++++++ docs/01-wav-io.md | 55 ++++++++++++++++++++++++ docs/CLAUDE.md | 8 ++++ 4 files changed, 139 insertions(+) create mode 100644 docs/01-wav-io.md create mode 100644 docs/CLAUDE.md diff --git a/crates/wavekat-core/Cargo.toml b/crates/wavekat-core/Cargo.toml index 8b764fd..491dabd 100644 --- a/crates/wavekat-core/Cargo.toml +++ b/crates/wavekat-core/Cargo.toml @@ -12,7 +12,11 @@ keywords = ["audio", "voice", "telephony", "wavekat"] categories = ["multimedia::audio"] exclude = ["CHANGELOG.md"] +[features] +wav = ["dep:hound"] + [dependencies] +hound = { version = "3.5", optional = true } [dev-dependencies] diff --git a/crates/wavekat-core/src/audio.rs b/crates/wavekat-core/src/audio.rs index 29974a0..c3f3aca 100644 --- a/crates/wavekat-core/src/audio.rs +++ b/crates/wavekat-core/src/audio.rs @@ -99,6 +99,65 @@ impl AudioFrame<'static> { } } +#[cfg(feature = "wav")] +impl AudioFrame<'_> { + /// Write this frame to a WAV file at `path`. + /// + /// Always writes mono f32 PCM at the frame's native sample rate. + /// + /// # Example + /// + /// ```no_run + /// use wavekat_core::AudioFrame; + /// + /// let frame = AudioFrame::from_vec(vec![0.0f32; 16000], 16000); + /// frame.write_wav("output.wav").unwrap(); + /// ``` + pub fn write_wav(&self, path: impl AsRef) -> Result<(), hound::Error> { + let spec = hound::WavSpec { + channels: 1, + sample_rate: self.sample_rate, + bits_per_sample: 32, + sample_format: hound::SampleFormat::Float, + }; + let mut writer = hound::WavWriter::create(path, spec)?; + for &sample in self.samples() { + writer.write_sample(sample)?; + } + writer.finalize() + } +} + +#[cfg(feature = "wav")] +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). + /// + /// # Example + /// + /// ```no_run + /// use wavekat_core::AudioFrame; + /// + /// let frame = AudioFrame::from_wav("input.wav").unwrap(); + /// println!("{} Hz, {} samples", frame.sample_rate(), frame.len()); + /// ``` + pub fn from_wav(path: impl AsRef) -> Result { + let mut reader = hound::WavReader::open(path)?; + let spec = reader.spec(); + let sample_rate = spec.sample_rate; + let samples: Vec = match spec.sample_format { + hound::SampleFormat::Float => reader.samples::().collect::>()?, + hound::SampleFormat::Int => reader + .samples::() + .map(|s| s.map(|v| v as f32 / 32768.0)) + .collect::>()?, + }; + Ok(AudioFrame::from_vec(samples, sample_rate)) + } +} + /// Trait for types that can be converted into audio samples. /// /// Implemented for `&[f32]` (zero-copy) and `&[i16]` (normalized conversion). @@ -203,6 +262,19 @@ mod tests { assert_eq!(owned.sample_rate(), 16000); } + #[cfg(feature = "wav")] + #[test] + fn wav_round_trip() { + let original = AudioFrame::from_vec(vec![0.5f32, -0.5, 0.0, 1.0], 16000); + let path = std::env::temp_dir().join("wavekat_test.wav"); + original.write_wav(&path).unwrap(); + let loaded = AudioFrame::from_wav(&path).unwrap(); + assert_eq!(loaded.sample_rate(), 16000); + for (a, b) in original.samples().iter().zip(loaded.samples()) { + assert!((a - b).abs() < 1e-6, "sample mismatch: {a} vs {b}"); + } + } + #[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 new file mode 100644 index 0000000..b41b75b --- /dev/null +++ b/docs/01-wav-io.md @@ -0,0 +1,55 @@ +# 01 — WAV I/O (`wav` feature) + +## Overview + +The optional `wav` feature extends `AudioFrame` with `write_wav` and `from_wav`, +providing a single canonical implementation for WAV I/O across the WaveKat +ecosystem. Backed by [`hound`](https://crates.io/crates/hound). + +## Enabling + +```toml +wavekat-core = { version = "0.0.5", features = ["wav"] } +``` + +## API + +### `AudioFrame::write_wav` + +```rust +pub fn write_wav(&self, path: impl AsRef) -> Result<(), hound::Error> +``` + +Writes the frame to a WAV file. Always mono, f32 PCM, at the frame's native +sample rate. + +### `AudioFrame::from_wav` + +```rust +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). + +## Example + +```rust +use wavekat_core::AudioFrame; + +let frame = AudioFrame::from_vec(vec![0.0f32; 16000], 16000); +frame.write_wav("output.wav")?; + +let loaded = AudioFrame::from_wav("output.wav")?; +assert_eq!(loaded.sample_rate(), 16000); +assert_eq!(loaded.len(), 16000); +``` + +## Design notes + +- Feature is named `wav` (capability) rather than `hound` (implementation), so + the underlying library can change without a breaking API surface change. +- Multi-channel WAV files are not rejected at read time — `hound` interleaves + channels. Callers that need strict mono validation should check + `reader.spec().channels` themselves; wavekat-core does not add that constraint + here since the ecosystem already standardises on mono at the producer level. diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md new file mode 100644 index 0000000..b8ff3ee --- /dev/null +++ b/docs/CLAUDE.md @@ -0,0 +1,8 @@ +# docs/ + +## Naming convention + +All documents use a two-digit prefix for ordering: `NN-slug.md`. + +Assign the next available number when adding a new doc. +Duplicate numbers across branches are fine — the slug disambiguates. From 61241b8af9370a428078f4431b19251b217c1bf6 Mon Sep 17 00:00:00 2001 From: Eason WaveKat Date: Tue, 7 Apr 2026 09:56:33 +1200 Subject: [PATCH 2/2] test: add wav_read_i16 to cover i16 decode path Co-Authored-By: Claude Sonnet 4.6 --- crates/wavekat-core/src/audio.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/wavekat-core/src/audio.rs b/crates/wavekat-core/src/audio.rs index c3f3aca..2b180b7 100644 --- a/crates/wavekat-core/src/audio.rs +++ b/crates/wavekat-core/src/audio.rs @@ -262,6 +262,34 @@ mod tests { assert_eq!(owned.sample_rate(), 16000); } + #[cfg(feature = "wav")] + #[test] + fn wav_read_i16() { + // Write an i16 WAV directly via hound, then read it with from_wav. + let path = std::env::temp_dir().join("wavekat_test_i16.wav"); + let spec = hound::WavSpec { + channels: 1, + sample_rate: 16000, + bits_per_sample: 16, + sample_format: hound::SampleFormat::Int, + }; + let i16_samples: &[i16] = &[0, i16::MAX, i16::MIN, 16384]; + let mut writer = hound::WavWriter::create(&path, spec).unwrap(); + for &s in i16_samples { + writer.write_sample(s).unwrap(); + } + writer.finalize().unwrap(); + + let frame = AudioFrame::from_wav(&path).unwrap(); + assert_eq!(frame.sample_rate(), 16000); + assert_eq!(frame.len(), 4); + let s = frame.samples(); + assert!((s[0] - 0.0).abs() < 1e-6); + assert!((s[1] - (i16::MAX as f32 / 32768.0)).abs() < 1e-6); + assert!((s[2] - -1.0).abs() < 1e-6); + assert!((s[3] - 0.5).abs() < 1e-4); + } + #[cfg(feature = "wav")] #[test] fn wav_round_trip() {