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 || {

View File

@@ -1,6 +1,7 @@
#![allow(clippy::too_many_arguments)]
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc, Mutex,
@@ -11,13 +12,13 @@ use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tauri::ipc::Channel;
use crate::commands::audio::persist_audio_samples;
use crate::commands::audio::resolve_recording_path;
use crate::commands::build_initial_prompt;
use crate::commands::models::{default_model_id_for_engine, ensure_model_loaded};
use crate::commands::power::PowerAssertion;
use crate::AppState;
use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions};
use kon_audio::{MicrophoneCapture, StreamingResampler};
use kon_audio::{MicrophoneCapture, StreamingResampler, WavWriter};
use kon_core::constants::WHISPER_SAMPLE_RATE;
use kon_core::types::{AudioSamples, Segment, TranscriptionOptions};
use kon_transcription::LocalEngine;
@@ -61,7 +62,6 @@ pub struct LiveTranscriptionState {
struct RunningLiveSession {
id: u64,
output_folder: Option<String>,
stop_flag: Arc<AtomicBool>,
handle: tokio::task::JoinHandle<Result<LiveSessionSummary, String>>,
status_channel: Channel<LiveStatusMessage>,
@@ -147,7 +147,11 @@ pub enum LiveStatusMessage {
struct LiveSessionSummary {
session_id: u64,
dropped_audio_ms: u64,
audio_samples: Option<Vec<f32>>,
/// Absolute path of the progressively-written WAV file. `Some` iff
/// `save_audio` was requested and the writer opened successfully;
/// the file on disk is a valid, playable WAV even if the session
/// was killed mid-append (brief item #19 crash-safety guarantee).
audio_path: Option<String>,
}
struct InferenceTask {
@@ -188,6 +192,7 @@ struct SpeechGateDecision {
#[tauri::command]
pub async fn start_live_transcription_session(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
live_state: tauri::State<'_, LiveTranscriptionState>,
mut config: StartLiveTranscriptionConfig,
@@ -247,18 +252,31 @@ pub async fn start_live_transcription_session(
.saturating_add(1);
let stop_flag = Arc::new(AtomicBool::new(false));
let engine = pick_engine(&state, &config.engine)?;
let output_folder = config.output_folder.clone();
// Resolve the WAV destination up front so the progressive writer
// (brief item #19) can open it before any samples arrive. Failure
// to create the recordings directory is fatal — the user asked
// for save_audio=true and silently dropping the recording would
// surprise them worse.
let audio_path = if config.save_audio {
Some(resolve_recording_path(&app, config.output_folder.as_deref())?)
} else {
None
};
let worker_stop = stop_flag.clone();
let worker_status = status_channel.clone();
let worker_results = result_channel.clone();
let dictionary_terms = profile_terms.clone();
let worker_audio_path = audio_path.clone();
let handle = tokio::task::spawn_blocking(move || {
run_live_session(
session_id,
engine,
config,
worker_audio_path,
dictionary_terms,
worker_results,
worker_status,
@@ -268,7 +286,6 @@ pub async fn start_live_transcription_session(
*live_state.running.lock().unwrap() = Some(RunningLiveSession {
id: session_id,
output_folder,
stop_flag,
handle,
status_channel,
@@ -300,11 +317,11 @@ pub async fn stop_live_transcription_session(
.await
.map_err(|e| format!("Live session task failed: {e}"))??;
let audio_path = if let Some(samples) = summary.audio_samples {
Some(persist_audio_samples(&app, samples, running.output_folder.clone()).await?)
} else {
None
};
// Progressive WAV writer (brief item #19) wrote samples to disk
// throughout the session; the path is already finalised. Nothing
// further to persist.
let _ = app;
let audio_path = summary.audio_path;
let response = StopLiveTranscriptionResponse {
session_id: summary.session_id,
@@ -333,6 +350,7 @@ fn run_live_session(
session_id: u64,
engine: Arc<LocalEngine>,
config: StartLiveTranscriptionConfig,
audio_path: Option<PathBuf>,
dictionary_terms: Vec<String>,
result_channel: Channel<LiveResultMessage>,
status_channel: Channel<LiveStatusMessage>,
@@ -358,11 +376,42 @@ fn run_live_session(
let mut resampler: Option<StreamingResampler> = None;
let mut capture_buffer: Vec<f32> = Vec::new();
let mut kept_audio = if config.save_audio {
Some(Vec::new())
// Progressive WAV writer (brief item #19). Sample rate comes from
// the loaded backend's capabilities (#13 wiring) so a future
// non-16kHz backend records at its native rate without further
// plumbing. The writer flushes its header every ~500 ms, so the
// file on disk is a playable WAV even if the process is killed.
let sample_rate = engine
.capabilities()
.map(|c| c.sample_rate)
.unwrap_or(WHISPER_SAMPLE_RATE);
let mut wav_writer: Option<WavWriter> = match audio_path.as_ref() {
Some(path) => match WavWriter::create(path, sample_rate, 1) {
Ok(w) => Some(w),
Err(e) => {
let _ = status_channel.send(LiveStatusMessage::Warning {
session_id,
message: format!(
"Failed to open audio recording file ({}); transcription will continue without saving audio.",
e
),
});
None
}
},
None => None,
};
// Resolve the reported audio_path only if the writer actually
// opened; otherwise the returned Summary must not claim a path.
let reported_audio_path: Option<String> = if wav_writer.is_some() {
audio_path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
} else {
None
};
let mut buffer_start_sample: u64 = 0;
let mut dropped_audio_ms: u64 = 0;
let mut chunk_id: u32 = 0;
@@ -412,7 +461,13 @@ fn run_live_session(
};
let resampled = resampler.push_samples(&mono).map_err(|e| e.to_string())?;
append_resampled_audio(&mut capture_buffer, &mut kept_audio, &resampled);
append_resampled_audio(
&mut capture_buffer,
&mut wav_writer,
&resampled,
session_id,
&status_channel,
);
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
@@ -442,9 +497,25 @@ fn run_live_session(
if stopping && !resampler_flushed {
if let Some(resampler) = &mut resampler {
let tail = resampler.flush().map_err(|e| e.to_string())?;
append_resampled_audio(&mut capture_buffer, &mut kept_audio, &tail);
append_resampled_audio(
&mut capture_buffer,
&mut wav_writer,
&tail,
session_id,
&status_channel,
);
}
resampler_flushed = true;
// Final flush for the WAV header so the last chunk's header
// update is on disk before we drop into the inference drain.
if let Some(writer) = wav_writer.as_mut() {
if let Err(e) = writer.flush() {
let _ = status_channel.send(LiveStatusMessage::Warning {
session_id,
message: format!("WAV flush failed near session end: {e}"),
});
}
}
}
if inflight.is_none() {
@@ -481,25 +552,53 @@ fn run_live_session(
thread::sleep(Duration::from_millis(10));
}
// Finalise the progressive WAV writer so the terminal header
// reflects every written sample. A failure here leaves the last
// flushed header state on disk — still a playable WAV — so we
// report a warning but don't fail the whole session.
if let Some(writer) = wav_writer.take() {
if let Err(e) = writer.finalize() {
let _ = status_channel.send(LiveStatusMessage::Warning {
session_id,
message: format!("WAV finalise failed: {e}; partial recording is still playable."),
});
}
}
Ok(LiveSessionSummary {
session_id,
dropped_audio_ms,
audio_samples: kept_audio,
audio_path: reported_audio_path,
})
}
fn append_resampled_audio(
capture_buffer: &mut Vec<f32>,
kept_audio: &mut Option<Vec<f32>>,
wav_writer: &mut Option<WavWriter>,
resampled: &[f32],
session_id: u64,
status_channel: &Channel<LiveStatusMessage>,
) {
if resampled.is_empty() {
return;
}
capture_buffer.extend_from_slice(resampled);
if let Some(kept_audio) = kept_audio {
kept_audio.extend_from_slice(resampled);
if let Some(writer) = wav_writer.as_mut() {
if let Err(e) = writer.append(resampled) {
// WAV write failure is non-fatal for live transcription —
// drop the writer so subsequent samples don't keep trying
// a broken file handle, and warn the user. The samples
// already written up to this point remain playable thanks
// to periodic header flushes.
let _ = status_channel.send(LiveStatusMessage::Warning {
session_id,
message: format!(
"Audio recording halted: {e}. Transcription continues; partial WAV is playable."
),
});
*wav_writer = None;
}
}
}