---
name: Audio WAV I/O (hound)
type: architecture-map-page
slice: 03-audio-transcription
last_verified: 2026/05/09
---

# Audio WAV I/O (hound)

> **Where you are:** [Architecture map](../README.md) → [Audio + Transcription](README.md) → Audio WAV I/O

**Plain English summary.** Two helpers cover the simple round-trip case: `write_wav` writes f32 samples to a 16-bit PCM WAV; `read_wav` reads them back, converting either int or float WAV payloads to f32. A third type, `WavWriter`, is the crash-safe append writer used during long live captures: it flushes the WAV header every 8000 samples (≈500 ms at 16 kHz), so a process kill leaves a valid, playable file on disk up to the last flush.

## At a glance

- Crate: `lumotia-audio`
- Path: `crates/audio/src/wav.rs`
- LOC: 287
- External deps: `hound 3.5`.
- Internal callers (best effort, slice 2 reconciles): `WavWriter` is consumed by the live-session command in `src-tauri/src/commands/live.rs` (brief item #19). `read_wav` and `write_wav` are used by the file-import command and by the audio-decode tests.

Public surface:

- `pub struct WavWriter` — `crates/audio/src/wav.rs:21`.
- `pub fn WavWriter::create(path: &Path, sample_rate: u32, channels: u16) -> Result<Self>` — `crates/audio/src/wav.rs:36`.
- `pub fn WavWriter::append(&mut self, samples: &[f32]) -> Result<()>` — `crates/audio/src/wav.rs:58`.
- `pub fn WavWriter::flush(&mut self) -> Result<()>` — `crates/audio/src/wav.rs:78`.
- `pub fn WavWriter::finalize(self) -> Result<()>` — `crates/audio/src/wav.rs:90`.
- `pub fn write_wav(path: &Path, audio: &AudioSamples) -> Result<()>` — `crates/audio/src/wav.rs:99`.
- `pub fn read_wav(path: &Path) -> Result<AudioSamples>` — `crates/audio/src/wav.rs:132`.

## What's in here

### `WavWriter` (crash-safe live capture)

Wraps a `hound::WavWriter<BufWriter<File>>` plus a `samples_since_flush` counter and a `flush_every` threshold (`DEFAULT_FLUSH_EVERY_SAMPLES = 8_000`, 500 ms at 16 kHz).

- `create` constructs a 16-bit PCM `WavSpec` from the supplied `sample_rate` / `channels`. Sample rate and channel count come from `LocalEngine::capabilities()` rather than being hardcoded — this matters once non-Whisper engines (Parakeet, future Moonshine) declare different rates.
- `append` clamps each f32 to `[-1.0, 1.0]`, scales to `i16`, writes the sample, and flushes the header every `flush_every` samples.
- `flush` calls `hound::WavWriter::flush` (which rewrites the RIFF and data chunk sizes in the header) and resets the counter.
- `finalize` writes the terminal header and consumes the writer. A dropped-without-finalize writer leaves a valid file up to the last flush; callers that care about the unflushed tail should always call finalize.

### `write_wav` and `read_wav`

`write_wav` is the simple one-shot writer over `AudioSamples`. Same clamp + i16 scale.

`read_wav` reads either int or float WAV payloads and returns f32 in `[-1, 1]`. For int payloads it scales by `1 << (bits_per_sample - 1)`. Unlike a previous version, **per-sample errors are propagated rather than silently dropped** (see watch-out below).

## Data flow

Live capture path (production use):

```
StreamingResampler.push_samples → Vec<f32> 16 kHz mono
    └─ WavWriter::append(chunk)
        └─ for each f32:
              clamp [-1,1] → (i16) write_sample
        └─ if samples_since_flush >= 8000:
              hound flush (rewrites size headers)
…session ends…
    └─ WavWriter::finalize()
        └─ terminal header write + close
```

File round-trip (used in tests + simple imports):

```
AudioSamples → write_wav → 16-bit PCM WAV → read_wav → AudioSamples
```

## Watch-outs

- **Crash-safety guarantee is "up to the last flush", not "every sample".** Worst-case loss is `flush_every / sample_rate` seconds (default 500 ms at 16 kHz). The `wav_writer_survives_crash` test (`wav.rs:170`) uses `std::mem::forget` to skip Drop and proves the flushed prefix is recoverable.
- **`finalize` is consuming.** Once called you cannot append further. The live-session command must hold the writer until session end and only finalize at the close path.
- **i16 quantisation noise.** Both writers downcast f32 → i16. WAV round-trip introduces ~1 / 32768 absolute error per sample, validated by the `wav_roundtrip` test (`wav.rs:265`).
- **Truncated WAV bug fixed in tree (2026-04-22 review).** Previous `read_wav` used `filter_map(|s| s.ok())` and silently returned a short `AudioSamples` for corrupt input. Current code surfaces `MagnotiaError::AudioDecodeFailed`. Regression test at `wav.rs:235`.
- **Channels and sample rate are taken from `AudioSamples` directly in `write_wav`.** `WavWriter::create` takes them as explicit args because it precedes the data; the caller (live session) reads `LocalEngine::capabilities()` to know what to pass.
- **No async wrapper.** `WavWriter` operations are synchronous (Buffered file I/O). Live session must call them on a blocking task or accept the syscall cost on the audio thread.

## See also

- [Transcription engines overview](transcription-engines-overview.md) — `LocalEngine::capabilities()` is the source of `sample_rate` / `channels` for `WavWriter::create`.
- [Audio file decoding](audio-file-decoding.md) — `decode_audio_file` is the symphonia-based path used for non-WAV files.
- Tests at `crates/audio/src/wav.rs:165` (crash-safety, finalize round-trip, truncated-input regression).
