use std::io::BufWriter; use std::path::Path; use lumotia_core::error::{Error, Result}; use lumotia_core::types::AudioSamples; /// Append-friendly WAV writer for long-running captures. /// /// The in-memory `Vec` used by `run_live_session` to persist audio /// on session end (brief item #19) has three failure modes: (a) a crash /// during transcription takes the recording with it; (b) RAM bloat at /// long session lengths; (c) an OOM kills the capture loop. `WavWriter` /// replaces that pattern with an on-disk writer that periodically /// flushes the WAV header so the file on disk is a valid, playable WAV /// at any point the process is interrupted. /// /// The writer samples at the rate / channel count supplied at /// construction; callers read those from /// `LocalEngine::capabilities()` (brief item #13 wiring) rather than /// hardcoding 16 kHz / mono. pub struct WavWriter { inner: hound::WavWriter>, samples_since_flush: usize, flush_every: usize, } impl WavWriter { /// Sample count between automatic header flushes. Flushing costs /// two seeks per call; 8000 samples at 16 kHz = 500 ms, so the /// worst-case "last half second is lost on crash" bound holds. const DEFAULT_FLUSH_EVERY_SAMPLES: usize = 8_000; /// Create a new WAV file at `path`, truncating any previous content. /// Header reflects zero samples until the first `flush` or /// `finalize`. pub fn create(path: &Path, sample_rate: u32, channels: u16) -> Result { let spec = hound::WavSpec { channels, sample_rate, bits_per_sample: 16, sample_format: hound::SampleFormat::Int, }; let file = std::fs::File::create(path).map_err(Error::from)?; let buffered = BufWriter::new(file); let inner = hound::WavWriter::new(buffered, spec).map_err(|e| { Error::from(std::io::Error::other(format!("WAV create failed: {e}"))) })?; Ok(Self { inner, samples_since_flush: 0, flush_every: Self::DEFAULT_FLUSH_EVERY_SAMPLES, }) } /// Append f32 samples in `[-1.0, 1.0]`. Samples outside that range /// are clamped (matching `write_wav`). Automatically flushes the /// header every `flush_every` samples so the on-disk file stays a /// valid WAV even if the process is killed between appends. pub fn append(&mut self, samples: &[f32]) -> Result<()> { for &sample in samples { let clamped = sample.clamp(-1.0, 1.0); let int_sample = (clamped * i16::MAX as f32) as i16; self.inner.write_sample(int_sample).map_err(|e| { Error::from(std::io::Error::other(format!("WAV write failed: {e}"))) })?; } self.samples_since_flush += samples.len(); if self.samples_since_flush >= self.flush_every { self.flush()?; } Ok(()) } /// Force an immediate header flush. Leaves the file in a valid-WAV /// state up to the current sample count. Callers do not need to /// call this explicitly — `append` flushes every /// `Self::DEFAULT_FLUSH_EVERY_SAMPLES` — but may do so at natural /// boundaries (end-of-utterance, UI events) for tighter recovery. pub fn flush(&mut self) -> Result<()> { self.inner.flush().map_err(|e| { Error::from(std::io::Error::other(format!("WAV flush failed: {e}"))) })?; self.samples_since_flush = 0; Ok(()) } /// Finalise the WAV: writes the terminal header state and closes /// the file. Call on clean session end. A dropped-without-finalize /// writer leaves a playable file up to the last flush; callers /// that care about the unflushed tail should always finalise. pub fn finalize(self) -> Result<()> { self.inner.finalize().map_err(|e| { Error::from(std::io::Error::other(format!("WAV finalize failed: {e}"))) })?; Ok(()) } } /// Write f32 PCM samples to a 16-bit WAV file. pub fn write_wav(path: &Path, audio: &AudioSamples) -> Result<()> { let spec = hound::WavSpec { channels: audio.channels(), sample_rate: audio.sample_rate(), bits_per_sample: 16, sample_format: hound::SampleFormat::Int, }; let mut writer = hound::WavWriter::create(path, spec).map_err(|e| { Error::from(std::io::Error::other(format!("WAV create failed: {e}"))) })?; for &sample in audio.samples() { let clamped = sample.clamp(-1.0, 1.0); let int_sample = (clamped * i16::MAX as f32) as i16; writer.write_sample(int_sample).map_err(|e| { Error::from(std::io::Error::other(format!("WAV write failed: {e}"))) })?; } writer.finalize().map_err(|e| { Error::from(std::io::Error::other(format!("WAV finalize failed: {e}"))) })?; Ok(()) } /// Read a WAV file to f32 PCM `AudioSamples`. /// /// Any per-sample decode error is surfaced as `Error::AudioDecodeFailed` /// rather than silently dropped. A previous implementation used /// `filter_map(|s| s.ok())`, so a truncated or corrupt payload returned /// a short, silently-partial `AudioSamples` — callers got `Ok` while /// losing audio (flagged by the 2026-04-22 review). pub fn read_wav(path: &Path) -> Result { let reader = hound::WavReader::open(path) .map_err(|e| Error::AudioDecodeFailed(format!("WAV open failed: {e}")))?; let spec = reader.spec(); let sample_rate = spec.sample_rate; let channels = spec.channels; let bits_per_sample = spec.bits_per_sample; let samples: Vec = match spec.sample_format { hound::SampleFormat::Int => reader .into_samples::() .map(|sample| { sample .map(|s| s as f32 / (1 << (bits_per_sample - 1)) as f32) .map_err(|e| { Error::AudioDecodeFailed(format!("WAV sample decode failed: {e}")) }) }) .collect::>>()?, hound::SampleFormat::Float => reader .into_samples::() .map(|sample| { sample.map_err(|e| { Error::AudioDecodeFailed(format!("WAV sample decode failed: {e}")) }) }) .collect::>>()?, }; Ok(AudioSamples::new(samples, sample_rate, channels)) } #[cfg(test)] mod tests { use super::*; #[test] fn wav_writer_survives_crash() { // Property under test: a `WavWriter` that has been flushed but // never finalised leaves a valid, readable WAV on disk. This // is the crash-safety guarantee — if the lumotia process aborts // mid-session, the on-disk file up to the last flush is // recoverable. // // `std::mem::forget` is the canonical way to simulate an // abort inside a unit test: it skips the Drop impl (which // would otherwise finalise the hound writer for us) and // mirrors what happens when the OS reaps the process without // giving Rust a chance to run destructors. let temp_dir = std::env::temp_dir(); let path = temp_dir.join("lumotia_test_wav_writer_survives_crash.wav"); let _ = std::fs::remove_file(&path); let mut writer = WavWriter::create(&path, 16_000, 1).unwrap(); let flushed_samples = vec![0.1_f32; 16_000]; // 1s writer.append(&flushed_samples).unwrap(); writer.flush().unwrap(); // Post-flush, append another second that will NOT be reflected // in the header if the writer dies before the next flush. let unflushed_tail = vec![0.2_f32; 16_000]; writer.append(&unflushed_tail).unwrap(); // Abort — Drop does not run, the hound finaliser is skipped. std::mem::forget(writer); let loaded = read_wav(&path).unwrap(); assert_eq!(loaded.sample_rate(), 16_000); assert!( loaded.samples().len() >= 16_000, "expected at least the flushed 16000 samples, got {}", loaded.samples().len() ); // The flushed portion is readable and approximately correct. for s in &loaded.samples()[..16_000] { assert!( (s - 0.1).abs() < 0.01, "flushed sample {s} deviates from 0.1 beyond 16-bit quantisation slack", ); } let _ = std::fs::remove_file(&path); } #[test] fn wav_writer_append_then_finalize_roundtrips() { let temp_dir = std::env::temp_dir(); let path = temp_dir.join("lumotia_test_wav_writer_finalize.wav"); let _ = std::fs::remove_file(&path); let mut writer = WavWriter::create(&path, 16_000, 1).unwrap(); writer.append(&vec![0.0_f32; 8_000]).unwrap(); writer.append(&vec![0.5_f32; 8_000]).unwrap(); writer.finalize().unwrap(); let loaded = read_wav(&path).unwrap(); assert_eq!(loaded.sample_rate(), 16_000); assert_eq!(loaded.samples().len(), 16_000); let _ = std::fs::remove_file(&path); } #[test] fn read_wav_surfaces_truncated_sample_stream_errors() { // Regression for the 2026-04-22 review: filter_map(|s| s.ok()) // previously swallowed decode errors on corrupt input, so a // truncated WAV returned Ok with a short samples vec. The // new code must propagate the error. let temp_dir = std::env::temp_dir(); let path = temp_dir.join("lumotia_test_truncated_wav.wav"); let _ = std::fs::remove_file(&path); // Write 100 samples (200 bytes at 16-bit). let original = AudioSamples::mono_16khz((0..100).map(|i| (i as f32) / 100.0).collect()); write_wav(&path, &original).unwrap(); // Drop the last 10 bytes — 5 samples' worth. hound's iterator // should surface an UnexpectedEof on the final read once its // internal data-chunk accounting runs out of bytes. let content = std::fs::read(&path).unwrap(); let truncated = &content[..content.len() - 10]; std::fs::write(&path, truncated).unwrap(); let result = read_wav(&path); assert!( result.is_err(), "truncated WAV must surface an AudioDecodeFailed error, got: {result:?}" ); let _ = std::fs::remove_file(&path); } #[test] fn wav_roundtrip() { let temp_dir = std::env::temp_dir(); let path = temp_dir.join("lumotia_test_roundtrip.wav"); let original = AudioSamples::mono_16khz(vec![0.0, 0.5, -0.5, 0.25, -0.25]); write_wav(&path, &original).unwrap(); let loaded = read_wav(&path).unwrap(); assert_eq!(loaded.sample_rate(), 16000); assert_eq!(loaded.samples().len(), 5); // 16-bit quantisation introduces small error for (a, b) in original.samples().iter().zip(loaded.samples().iter()) { assert!( (a - b).abs() < 0.001, "Sample mismatch: original={a}, loaded={b}" ); } std::fs::remove_file(&path).ok(); } }