Phase 9 of the rebrand cascade. Sweep covers everything the Phase 8
frontend pass deliberately skipped: docs/, root markdown, scripts,
Cargo.toml descriptions, code comments that survived earlier
word-boundary sed, plus a handful of identifiers caught on the final
verify pass.
transcription-app changes:
- README.md, HANDOVER.md, KNOWN-ISSUES.md, run.sh — magnotia/Magnotia
-> lumotia/Lumotia.
- docs/ — sweep across all subdirs except docs/handovers/ (preserved
as immutable audit trail). Includes architecture-map references
to magnotia_core::*, magnotia_storage::*, etc. now pointing at
lumotia_*; dev-setup.md tracing output examples (lumotia_startup
target); brief/ + superpowers/ + issues/ + whisper-ecosystem/ +
audit/.
- Cargo.toml descriptions on 9 crates (core, audio, cloud-providers,
hotkey, llm, mcp, plus referenced others).
- crates/core/src/{error,hardware,recommendation,paths}.rs +
crates/audio/src/wav.rs + crates/llm/src/model_manager.rs +
crates/cloud-providers/src/keystore.rs + crates/mcp/src/lib.rs —
doc comments and a model-manager user-agent string.
- Caught on final pass: BroadcastChannel("magnotia_task_sync") -> ...
("lumotia_task_sync"); magnotia_locale i18n localStorage key
renamed + migration shim added; CSS keyframe names
magnotiaPulse / magnotiaBar / magnotiaFade renamed in the design-
system kit; magnotia_viewer_item / magnotia_viewer_mode handoff
keys renamed in HistoryPage + viewer/+page.svelte; src/assets/
wordmark.svg text.
- src-tauri/src/lib.rs comment cleanup ("magnotia era" was sed'd
to "lumotia era" earlier — restored).
Preserved (intentional):
- crates/core/src/paths.rs — keeps "magnotia" / "Magnotia" / ".magnotia"
legacy detection strings in legacy_and_target_paths() so the
migration shim can still find user data from the magnotia era.
- src/lib/stores/{page,focusTimer}.svelte.ts + src/lib/i18n/index.ts
— migration call sites reference the legacy magnotia keys
deliberately.
- docs/handovers/ — historical audit trail.
cargo build --workspace passes. npm run check: 0 errors / 0 warnings
(3958 files). cargo test --workspace: 339 pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
85 lines
5.3 KiB
Markdown
85 lines
5.3 KiB
Markdown
---
|
|
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).
|