# wav-codec
WAV (RIFF/WAVE) reader and writer over `std::io`. PCM and IEEE-float WAV
only. Compressed WAV (ADPCM, mu-law, A-law) not decoded. The reader
decodes PCM at 8, 16, 24, and 32 bits, packed or in wider
`WAVE_FORMAT_EXTENSIBLE` containers, plus IEEE float at 32 and 64 bits. The
writer emits the same encodings with the canonical header for each.
- Zero runtime dependencies, `#![forbid(unsafe_code)]`, MSRV 1.63.0.
- Block-buffered decoding: the reader refills a 64 KiB buffer with bulk
reads clamped to the `data` chunk and decodes whole blocks. On the
5.3 MB perf fixture `read_samples` runs at 0.8x and `samples()` at 1.9x
the time of a raw `fs::read` plus `chunks_exact` decode (see
`BENCHES.md`). The test suite holds both paths within 3x.
- Truncation is an error. A stream that ends inside a sample, or short of
the declared `data` size, yields exactly one `Err` and then the iterator
is fused. A 48-byte file declaring 4 GiB of data costs two samples and
one error, and `size_hint` reports `(0, Some(declared))`.
- Word alignment per RIFF: odd-sized chunks carry a pad byte on both the
read and write sides, `fact` chunks of any length are skipped by their
declared size, and the writer finalizes an even `riff_size`.
- Byte-exact round-trips: six canonical conformance fixtures across u8, i16,
i24, i32, f32, and f64 re-encode to their original sha256. Two fixtures
from ffmpeg 8.1.2 (f64 and EXTENSIBLE s24) match after nearest-integer
scaling.
- Malformed input maps to a typed `Error` with no panic path: 27 conformance
vectors, every truncation length and single-byte mutation of each, and a
cargo-fuzz target over open plus drain.
## Reading
```rust
use wav_codec::WavReader;
let mut reader = WavReader::open("input.wav")?;
let spec = reader.spec();
println!("{} Hz, {} channels, {} bits", spec.sample_rate, spec.channels, spec.bits_per_sample);
let samples: Vec<i16> = reader.samples::<i16>().collect::<Result<_, _>>()?;
```
`samples` borrows the reader and `into_samples` consumes it. Both yield
`Result<S>`. `read_samples` fills a slice in bulk and returns the count, with
a short count only at the end of the `data` chunk. `seek` positions the
reader at a frame and advances by the container width, so a 24-in-32 file
seeks in 4-byte steps.
| 8-bit PCM (offset binary) | `i8`, `i16`, `i32` | `i8`, `i16`, `i32` (in range) |
| 16-bit PCM | `i16`, `i32` | `i8`, `i16`, `i32` (in range) |
| 24-bit PCM, 3 or 4 bytes | `i32` | `i8`, `i16`, `i32` (in range) |
| 32-bit PCM | `i32` | `i8`, `i16`, `i32` |
| 32-bit float | `f32`, `f64` | `f32` |
| 64-bit float | `f64` | `f32`, `f64` |
8-bit files are unsigned on disk and exposed as `i8` with the value
`stored_byte - 128`. A type too narrow for the file, or of the wrong class,
yields one `Error::TooWide`.
## Writing
```rust
use wav_codec::{SampleFormat, WavSpec, WavWriter};
let spec = WavSpec {
channels: 2,
sample_rate: 48_000,
bits_per_sample: 24,
sample_format: SampleFormat::Int,
};
let mut writer = WavWriter::create("out.wav", spec)?;
writer.write_samples(&[0i32, 1, -1, 8_388_607])?;
writer.finalize()?;
```
`WavWriter::new` picks the narrowest container for the bit depth.
`new_with_spec_ex` takes an explicit container width, for example 24 valid
bits in 4-byte containers. Header layout by spec:
- integer PCM, up to 2 channels, valid bits filling the container: `fmt `
size 16.
- IEEE float, up to 2 channels: `fmt ` size 18 plus a `fact` chunk with the
frame count.
- a container wider than the valid bits, or more than 2 channels: `fmt `
size 40 (`WAVE_FORMAT_EXTENSIBLE`), plus `fact` for float.
`finalize` writes the pad byte for an odd `data` size, patches the RIFF,
`data`, and `fact` sizes, and returns `Error::UnfinishedFrame` when the
sample count is off a frame boundary. The header is patched to whole
frames so the file stays readable. Dropping a writer runs the same steps
and discards the result, so Drop cannot report `UnfinishedFrame`. Call
`finalize` to observe that error. A write that would push the `data` size
past the 32-bit header fields returns `Error::TooLarge` before any
arithmetic can wrap.
## Scope
Rejected with a clean error: compressed format tags, `RF64` and `BW64`
(over 4 GiB), `RIFX`, `W64`, and any `WAVE_FORMAT_EXTENSIBLE` GUID other
than PCM and IEEE float. The crate is an adapter over `std::io::Read` and
`std::io::Write`, so `no_std` is unsupported.
## Testing
`cargo test` replays the 27-vector conformance manifest in `tests/`, every
fixture pinned by sha256, and the perf gate regenerates its 5.3 MB fixture
from the manifest's LCG description. `cargo bench --bench read_throughput`
reports MiB/s for the three decode paths and fails in release on a ratio
above 3x. `cargo fuzz run read_drain` (nightly) seeds from the fixtures.
CI builds the library and doctests at 1.63.0 and builds and runs an
external consumer crate on that toolchain.
## License
MIT. Copyright (c) 2026 Justin Jones.