--- 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` — `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` — `crates/audio/src/wav.rs:132`. ## What's in here ### `WavWriter` (crash-safe live capture) Wraps a `hound::WavWriter>` 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 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).