---
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<LiveResultMessage>, status_channel: Channel<LiveStatusMessage>) -> Result<StartLiveTranscriptionResponse, String>` — main-window only.
  - `stop_live_transcription_session(window, app, live_state, session_id: u64) -> Result<StopLiveTranscriptionResponse, String>` — main-window only.
- Events emitted: NONE in the conventional `app.emit(...)` sense. This module uses Tauri 2's typed `tauri::ipc::Channel<T>` API instead. The frontend creates the channel pair on the JS side via `new Channel<T>()`, passes it as a command argument, and Lumotia sends typed messages on it from the worker. Two channels:
  - `Channel<LiveResultMessage>` — per-chunk transcription results (segments, language, duration, raw_text, inference_ms, chunk_id, chunk_start_secs).
  - `Channel<LiveStatusMessage>` — 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<Option<RunningLiveSession>>` — 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<String>, 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<T>` 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.
