feat(A.2 #19): progressive WAV write during live capture

The Vec<f32> in-memory accumulator on run_live_session had three
failure modes: (a) a crash during transcription took the recording
with it, (b) RAM grew linearly with session length, (c) OOM killed
the capture thread silently.

New kon_audio::WavWriter wraps hound::WavWriter<BufWriter<File>> with
an append-friendly API and a 500 ms-granularity header flush. On any
abort after a flush the on-disk file is a valid, playable WAV. Unit
test (brief item #19 acceptance) simulates the abort with
std::mem::forget and asserts the pre-flush samples are recoverable.

Live capture now:
- resolves the destination path at start_live_transcription_session
  time via a new resolve_recording_path helper extracted from
  persist_audio_samples,
- opens a WavWriter before any samples arrive, sample rate taken from
  LocalEngine::capabilities() (#13 wiring) with 16 kHz fallback,
- feeds the resampler output through WavWriter::append inside
  append_resampled_audio — drops the writer with a user-visible
  warning if a write fails mid-session,
- calls flush() at stop (after resampler tail), finalise() on clean
  exit, and drops-to-last-flushed state on abort.

LiveSessionSummary.audio_samples → audio_path: the path is already
written by the time stop_live_transcription_session runs; no
post-session write step remains for live capture.
persist_audio_samples is kept for the offline save_audio command.
This commit is contained in:
2026-04-22 04:39:44 +01:00
parent 8b49d0fe9c
commit f9b396a966
4 changed files with 300 additions and 38 deletions

View File

@@ -228,25 +228,22 @@ pub async fn stop_native_capture(
Ok(samples)
}
pub async fn persist_audio_samples(
/// Resolve the destination path for a new live-capture recording,
/// ensuring the parent directory exists. Extracted from
/// `persist_audio_samples` so `start_live_transcription_session` can
/// hand the path to the progressive WAV writer before any samples
/// arrive (brief item #19).
pub fn resolve_recording_path(
app: &tauri::AppHandle,
samples: Vec<f32>,
output_folder: Option<String>,
) -> Result<String, String> {
let recordings_dir = if let Some(ref folder) = output_folder {
if !folder.is_empty() {
PathBuf::from(folder)
} else {
app.path()
.app_local_data_dir()
.map_err(|e: tauri::Error| e.to_string())?
.join("recordings")
}
} else {
app.path()
output_folder: Option<&str>,
) -> Result<PathBuf, String> {
let recordings_dir = match output_folder.map(str::trim).filter(|s| !s.is_empty()) {
Some(folder) => PathBuf::from(folder),
None => app
.path()
.app_local_data_dir()
.map_err(|e: tauri::Error| e.to_string())?
.join("recordings")
.join("recordings"),
};
std::fs::create_dir_all(&recordings_dir)
@@ -256,8 +253,15 @@ pub async fn persist_audio_samples(
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let filename = format!("kon-{timestamp}.wav");
let path = recordings_dir.join(&filename);
Ok(recordings_dir.join(format!("kon-{timestamp}.wav")))
}
pub async fn persist_audio_samples(
app: &tauri::AppHandle,
samples: Vec<f32>,
output_folder: Option<String>,
) -> Result<String, String> {
let path = resolve_recording_path(app, output_folder.as_deref())?;
let path_clone = path.clone();
tokio::task::spawn_blocking(move || {