--- name: Live transcription session type: architecture-map-page slice: 02-tauri-runtime last_verified: 2026/05/09 --- # `commands::live` > **Where you are:** [Architecture map](../../README.md) → [Tauri runtime](../README.md) → [Commands](README.md) → Live transcription **Plain English summary.** The 1,737-line beast that runs a live dictation session end-to-end. Captures audio via a dedicated `MicrophoneCapture`, streams chunks through a `StreamingResampler`, runs a speech gate to skip near-silent chunks, dispatches 2-second windows with 0.25-second overlap to whisper or parakeet, polls inference results on a background thread, dedupes overlapping segments against a recent-history buffer, post-processes with the formatting pipeline, and emits typed messages back to the frontend on two `tauri::ipc::Channel`s (one for results, one for status). Holds a macOS App Nap power assertion for the duration of the session. Writes audio progressively to a WAV file so a crash mid-session leaves a playable recording. ## At a glance - Path: `src-tauri/src/commands/live.rs`. - LOC: 1,737. Largest file in the slice. - Tauri commands exposed: - `start_live_transcription_session(window, app, state, live_state, config: StartLiveTranscriptionConfig, result_channel: Channel, status_channel: Channel) -> Result` — main-window only. - `stop_live_transcription_session(window, app, live_state, session_id: u64) -> Result` — main-window only. - Events emitted: NONE in the conventional `app.emit(...)` sense. This module uses Tauri 2's typed `tauri::ipc::Channel` API instead. The frontend creates the channel pair on the JS side via `new Channel()`, passes it as a command argument, and Lumotia sends typed messages on it from the worker. Two channels: - `Channel` — per-chunk transcription results (segments, language, duration, raw_text, inference_ms, chunk_id, chunk_start_secs). - `Channel` — tagged enum: `Warning { message }`, `Overload { dropped_audio_ms, message }`, `Error { message }`, `Finished { audio_path, dropped_audio_ms }`. - Depends on: `lumotia_audio::{AudioChunk, CaptureRuntimeError, MicrophoneCapture, StreamingResampler, WavWriter}`, `lumotia_core::constants::WHISPER_SAMPLE_RATE`, `lumotia_core::types::{AudioSamples, Segment, TranscriptionOptions}`, `lumotia_transcription::LocalEngine`, `lumotia_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions}`, `lumotia_storage::{database::get_profile, database::list_profile_terms, DEFAULT_PROFILE_ID}`. Plus `commands::audio::resolve_recording_path`, `commands::build_initial_prompt`, `commands::models::{default_model_id_for_engine, ensure_model_loaded}`, `commands::power::PowerAssertion`, `commands::security::ensure_main_window`. - Called from frontend at: dictation page (when the user starts and stops a live session — most common entry). ## What's in here ### Constants (`src-tauri/src/commands/live.rs:30`) Speech-gate, dedup, and chunking parameters. The headline numbers: `CHUNK_SAMPLES = 32_000` (2 s at 16 kHz), `OVERLAP_SAMPLES = 4_000` (0.25 s), `FINAL_CHUNK_MIN_SAMPLES = 4_000`, `MAX_PENDING_SAMPLES = CHUNK_SAMPLES`. Speech-gate thresholds (RMS / peak / consecutive-window counts) follow. ### State - `LiveTranscriptionState` (`src-tauri/src/commands/live.rs:62`) — the Tauri-managed struct stashed by `lib.rs::run`. Fields: - `next_session_id: AtomicU64` — monotonic session-id generator. - `lifecycle: AsyncMutex<()>` — start/stop barrier. - `running: Mutex>` — the currently-running session, if any. - `RunningLiveSession` (`src-tauri/src/commands/live.rs:68`) — id, stop_flag, JoinHandle for the blocking worker, the status channel. ### Public payload types - `StartLiveTranscriptionConfig` (`src-tauri/src/commands/live.rs:77`) — engine, model_id, language, initial_prompt, save_audio, output_folder, post-processing flags, format_mode, microphone_device, profile_id. - `StartLiveTranscriptionResponse` — `{ session_id }`. - `StopLiveTranscriptionResponse` — `{ session_id, audio_path: Option, dropped_audio_ms: u64 }`. - `LiveResultMessage` — per-chunk result. - `LiveStatusMessage` — tagged enum (4 variants). ### `ActiveCapture` (`src-tauri/src/commands/live.rs:166`) Wraps `MicrophoneCapture` plus its `cpal` chunk receiver and the optional runtime-error receiver. `drain_runtime_errors` posts `LiveStatusMessage::Warning` for each cpal-side error. ### `LiveLoopState` (`src-tauri/src/commands/live.rs:208`) Per-session mutable state: resampler, capture buffer, WAV writer, buffer start sample index, dropped-audio counter, chunk id, in-flight inference task, resampler-flushed flag, result-listener-lost flag, recent-segments dedup history. ### `LiveSessionRuntime` (`src-tauri/src/commands/live.rs:231`) Owns everything for one session. Constructor opens the WAV writer. `run()` is the main loop: ``` loop { poll_inference()?; capture.drain_runtime_errors(); if let Some(chunk) = recv_audio()? { process_audio_chunk(chunk)?; } drop_pending_overflow(); // bounded buffer; emits Overload status flush_tail_if_stopping()?; if dispatch_inference_if_ready() { continue; } if should_exit_loop() { break; } } drain_inference()?; finish() ``` Methods: - `process_audio_chunk` — downmix, lazy-init `StreamingResampler`, push samples, append to capture buffer + WAV (`src-tauri/src/commands/live.rs:323`). - `drop_pending_overflow` — when the inflight inference is busy and the buffer exceeds `MAX_PENDING_SAMPLES`, drop the oldest samples and emit `LiveStatusMessage::Overload` with the cumulative dropped-audio counter (`:344`). - `flush_tail_if_stopping` — flush the resampler and the WAV header on shutdown (`:365`). - `dispatch_inference_if_ready` — wraps `maybe_dispatch_chunk` (the chunking + speech-gate + thread-spawn function) (`:396`). - `drain_inference` — busy-loops with 10 ms sleeps until the in-flight inference completes after stop (`:425`). - `finish` — finalise WAV, return `LiveSessionSummary` (`:433`). ### `start_live_transcription_session` (`src-tauri/src/commands/live.rs:484`) 1. `ensure_main_window`. 2. `lifecycle.lock().await` — barrier against concurrent start/stop. 3. Reject if a session is already running. 4. Resolve profile_id, fetch profile + profile_terms from `lumotia_storage`. 5. Collapse the effective `initial_prompt` via `build_initial_prompt` (so the worker doesn't have to know about profile fallback). 6. Resolve model_id via `default_model_id_for_engine` if absent. 7. `ensure_model_loaded(state, engine, model_id, None)` — `None` means don't enforce sequential-GPU mode (Settings owns that toggle). 8. Resolve audio_path via `commands::audio::resolve_recording_path` if `save_audio` is true. 9. `tokio::task::spawn_blocking(move || run_live_session(...))` — the real worker runs on a dedicated blocking thread, not the Tokio runtime, because Whisper inference itself spawns its own threads and the work is CPU-bound. 10. Stash the new `RunningLiveSession`. Return the session_id. ### `stop_live_transcription_session` (`src-tauri/src/commands/live.rs:591`) 1. `ensure_main_window`, lifecycle lock. 2. Take the running session out of state. 3. Validate `session_id` matches; on mismatch, restore the session and return an error. 4. Set the stop flag and await the worker `JoinHandle`. 5. Read the summary, send `LiveStatusMessage::Finished` on the status channel, return the response. ### `run_live_session` (`src-tauri/src/commands/live.rs:646`) The blocking entry. Holds a `PowerAssertion::begin("lumotia live dictation session")` for the entire scope. Constructs and runs `LiveSessionRuntime`. The drop on the power assertion ends the macOS App Nap pin. ### `maybe_dispatch_chunk` (`src-tauri/src/commands/live.rs:753`) The brain of the chunking pipeline. Decides whether to dispatch a chunk now, based on capture buffer size and the stopping flag: - Full chunk path: `target_len = CHUNK_SAMPLES`, with `OVERLAP_SAMPLES` of trim against the previous chunk to dedupe. - Stopping path: dispatch any partial chunk ≥ `FINAL_CHUNK_MIN_SAMPLES`. - Speech gate: `evaluate_speech_gate(speech_window)` (`:1305`) returns a decision based on per-frame RMS / peak amplitude / consecutive-speech-window counts. If skipped, drop those samples and emit a Warning. - On dispatch: spawn a `std::thread` that calls `engine.transcribe_sync` and posts the result back via a `std::sync::mpsc` channel. The 2025 version of this code used a Tokio task; switching to a plain thread keeps inference off the blocking pool entirely. ### `poll_inference` (`src-tauri/src/commands/live.rs:864`) Polls the in-flight `InferenceTask`'s mpsc receiver. On result: - Trim overlap segments against the previous chunk via `trim_overlap_segments`. - Run dedup vs the `recent_segments` history via `filter_duplicate_boundary_segments`. - Post-process with `post_process_segments` (using the dictionary terms and `PostProcessOptions`). - Build a `LiveResultMessage` and `emit_live_result(...)`. ### `emit_live_result` (`src-tauri/src/commands/live.rs:971`) Sends on the result channel. If the listener is dead, sets `result_listener_lost = true` and tries to send a Warning on the status channel. If *that* also fails, self-asserts the stop flag so the worker drains and exits — otherwise the worker would burn CPU + GPU memory polling forever after the user closes the app window without a clean stop call. ### Dedup helpers (`src-tauri/src/commands/live.rs:1027` onwards) - `filter_duplicate_boundary_segments` — drops segments at chunk boundaries that meaningfully overlap the recent-segments history. - `remember_recent_segments` — maintains the rolling window (~`DUPLICATE_HISTORY_RETENTION_SECS = 8.0`). - `build_nearby_transcript_candidates` — collects candidates in the leading-edge window (`DUPLICATE_CHECK_LEADING_SECS = 1.5`). - `transcripts_overlap` and `transcripts_loosely_overlap` — token-coverage / longest-common-subsequence checks against `LOW_SIGNAL_TOKENS` (a stop-word-equivalent list of ~60 high-frequency tokens). ### Speech gate (`src-tauri/src/commands/live.rs:1251` onwards) - `record_speech_window`, `speech_gate_decision`, `evaluate_speech_gate`. Two thresholds: a strong-speech path (high RMS or high peak, or two consecutive speech windows) and a soft-speech path. `FLATLINE_PEAK_THRESHOLD` catches the silent-buffer case (e.g. mic disconnected). The gate keeps Whisper from hallucinating on near-silent audio, which Whisper is famous for doing ("you are watching the show"). ### Other helpers - `downmix_chunk` (`:1336`) — same pattern as `commands::audio`. - `pick_engine` (`:638`) — `state.whisper_engine` or `state.parakeet_engine`. - `open_wav_writer`, `finalize_wav_writer`, `append_resampled_audio` — progressive WAV plumbing (brief item #19). ## Data flow ``` frontend invoke('start_live_transcription_session', { config, result_channel, status_channel }) -> Rust: validate, fetch profile, build prompt, ensure model loaded, spawn worker worker (blocking thread): loop: cpal -> ActiveCapture -> StreamingResampler -> capture_buffer + WAV when buffer >= 32k samples (or stopping with >= 4k): speech-gate -> if pass: thread::spawn(engine.transcribe_sync) poll inflight: filter overlap, dedup vs history, post_process_segments send LiveResultMessage on result_channel on overflow: drop oldest, send LiveStatusMessage::Overload on stop flag: flush resampler tail, drain inflight, finalise WAV return LiveSessionSummary frontend invoke('stop_live_transcription_session', { session_id }) -> Rust: set stop flag, await worker, send LiveStatusMessage::Finished, return response ``` ## Watch-outs - **Size.** 1,737 LOC in one file. The runtime + loop + speech gate + dedup + chunker really should be split. The pieces are already modular; pulling each out into its own file under `commands/live/` would make the surface much easier to read and audit. - **`thread::spawn` for inference.** Each chunk spawns a fresh OS thread (`live.rs:841`). Inside Whisper this is fine because whisper.cpp uses its own thread pool, and only one chunk is in flight at a time per session. Two simultaneous live sessions would multiply this; the lifecycle lock forbids that today. - **`poll_inference` busy-loops with 10 ms sleeps in `drain_inference`.** Acceptable because we only enter the drain on stop. Don't reuse this pattern for the main loop. - **Result-listener-lost path is critical.** Without it, closing the main window without a clean stop would leave the worker spinning forever, holding the GPU memory and the WAV file handle until process exit. The self-asserted stop flag is the safety net. - **Power assertion only does work on macOS.** On Linux the function is a no-op (see [Power assertions and security](power-and-security.md)). A long live-dictation session on Linux can still be idled by the compositor. - **The recent-segments history is bounded by time, not count.** A high chunk rate could grow it more than expected; the retention is `DUPLICATE_HISTORY_RETENTION_SECS = 8.0`. - **Channel back-pressure.** The result channel is the JS-side `Channel` queue. If the frontend stops reading, the queue grows. Lumotia's overload-signalling currently uses the in-buffer `MAX_PENDING_SAMPLES` cap; it does NOT detect a JS-side stalled listener except via the `emit_live_result`-failure path. - **`ensure_model_loaded(state, engine, model_id, None)`** intentionally passes `None` for `concurrent`, so live sessions never trigger the sequential-GPU guard in `commands::models`. If you ever ship a tight-VRAM machine and the user has switched to sequential mode, this could OOM. Today's hardware survey indicates this is uncommon; flag this when revisiting Phase A.4. ## See also - [Audio capture](audio.md) — `resolve_recording_path` and `recording_filename` are shared. - [Models](models.md) — `default_model_id_for_engine` and `ensure_model_loaded` are called from start. - [Transcription](transcription.md) — the non-live transcription path that shares the post-processing pipeline. - [Profiles](profiles.md) — the profile + profile-terms fetch happens before the worker spawns. - [Power assertions and security](power-and-security.md) — the App Nap pin and `ensure_main_window` guard. - [`commands::mod`](mod.md) — `build_initial_prompt` is the prompt assembler used here.