Files
Jake 26c7307607
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
agent: lumotia-rebrand — docs, scripts, root config, residuals
Phase 9 of the rebrand cascade. Sweep covers everything the Phase 8
frontend pass deliberately skipped: docs/, root markdown, scripts,
Cargo.toml descriptions, code comments that survived earlier
word-boundary sed, plus a handful of identifiers caught on the final
verify pass.

transcription-app changes:
- README.md, HANDOVER.md, KNOWN-ISSUES.md, run.sh — magnotia/Magnotia
  -> lumotia/Lumotia.
- docs/ — sweep across all subdirs except docs/handovers/ (preserved
  as immutable audit trail). Includes architecture-map references
  to magnotia_core::*, magnotia_storage::*, etc. now pointing at
  lumotia_*; dev-setup.md tracing output examples (lumotia_startup
  target); brief/ + superpowers/ + issues/ + whisper-ecosystem/ +
  audit/.
- Cargo.toml descriptions on 9 crates (core, audio, cloud-providers,
  hotkey, llm, mcp, plus referenced others).
- crates/core/src/{error,hardware,recommendation,paths}.rs +
  crates/audio/src/wav.rs + crates/llm/src/model_manager.rs +
  crates/cloud-providers/src/keystore.rs + crates/mcp/src/lib.rs —
  doc comments and a model-manager user-agent string.
- Caught on final pass: BroadcastChannel("magnotia_task_sync") -> ...
  ("lumotia_task_sync"); magnotia_locale i18n localStorage key
  renamed + migration shim added; CSS keyframe names
  magnotiaPulse / magnotiaBar / magnotiaFade renamed in the design-
  system kit; magnotia_viewer_item / magnotia_viewer_mode handoff
  keys renamed in HistoryPage + viewer/+page.svelte; src/assets/
  wordmark.svg text.
- src-tauri/src/lib.rs comment cleanup ("magnotia era" was sed'd
  to "lumotia era" earlier — restored).

Preserved (intentional):
- crates/core/src/paths.rs — keeps "magnotia" / "Magnotia" / ".magnotia"
  legacy detection strings in legacy_and_target_paths() so the
  migration shim can still find user data from the magnotia era.
- src/lib/stores/{page,focusTimer}.svelte.ts + src/lib/i18n/index.ts
  — migration call sites reference the legacy magnotia keys
  deliberately.
- docs/handovers/ — historical audit trail.

cargo build --workspace passes. npm run check: 0 errors / 0 warnings
(3958 files). cargo test --workspace: 339 pass / 0 fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:38:03 +01:00

186 lines
14 KiB
Markdown

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