Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/wavekat-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
100 changes: 100 additions & 0 deletions crates/wavekat-core/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::path::Path>) -> 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<std::path::Path>) -> Result<Self, hound::Error> {
let mut reader = hound::WavReader::open(path)?;
let spec = reader.spec();
let sample_rate = spec.sample_rate;
let samples: Vec<f32> = match spec.sample_format {
hound::SampleFormat::Float => reader.samples::<f32>().collect::<Result<_, _>>()?,
hound::SampleFormat::Int => reader
.samples::<i16>()
.map(|s| s.map(|v| v as f32 / 32768.0))
.collect::<Result<_, _>>()?,
};
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).
Expand Down Expand Up @@ -203,6 +262,47 @@ 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() {
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];
Expand Down
55 changes: 55 additions & 0 deletions docs/01-wav-io.md
Original file line number Diff line number Diff line change
@@ -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<Path>) -> 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<Path>) -> Result<AudioFrame<'static>, 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.
8 changes: 8 additions & 0 deletions docs/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.