38 Commits

Author SHA1 Message Date
d7363cc913 fix(rb-06): native capture worker is joined on stop
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
The accumulator task was fire-and-forget — `tokio::spawn` without
retaining the JoinHandle. `stop_native_capture` sent a stop signal,
slept 50ms, and returned; the worker could still be running its
final flush and appending to `all_samples` when the next
`start_native_capture` cleared it. Rapid start→stop→start could
leak tail samples from one session into another.

Replace `NativeCaptureState.stop_tx` with `worker:
AsyncMutex<Option<CaptureWorker>>`, where CaptureWorker owns both
the stop sender and the spawned task's JoinHandle. New helper
`stop_worker(worker)` sends stop, drops the sender, and `.await`s
the join. Both the prior-worker tear-down in `start_native_capture`
and `stop_native_capture` itself go through the helper, so the
worker is always fully terminated before any downstream read or
next-session cleanup.

AsyncMutex (not std::sync::Mutex) because the stop path awaits
while holding the lock. Also drops the 50ms sleep from
stop_native_capture — the join is an exact barrier.

Two regression tests:
  - stop_worker_awaits_full_termination_no_writes_after_join:
    synthetic worker with an atomic counter and a flush marker.
    After stop_worker the flush must have run and no further
    writes may appear.
  - stop_worker_is_idempotent_on_a_worker_that_has_already_exited:
    tasks that stop themselves must still join cleanly.

A full cpal-backed start→stop→start integration test is not
feasible in Linux CI without an audio device. The component tests
cover the invariant the real flow depends on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 10:36:34 +01:00
54d9adf1f0 fix(rb-07): runtime capabilities derive accelerators honestly
get_runtime_capabilities was returning `accelerators = ["cpu",
"vulkan"]` and `whisper.supports_gpu = true` regardless of build
config or runtime state. On a macOS build it falsely advertised
"vulkan" (the backend actually resolves via MoltenVK as Metal); on a
whisper-disabled build it claimed GPU support for an engine that
hadn't been linked.

Added `compose_accelerators(whisper_enabled, loader_available, target)`
— a pure helper that always emits "cpu" first and appends the
platform-appropriate GPU name only when whisper is compiled in AND the
Vulkan loader resolves. `supported_accelerators()` wraps it with the
live `cfg!(feature = "whisper")`, loader probe, and target OS.

`get_runtime_capabilities` now calls `supported_accelerators()` and
sets `whisper.supports_gpu = cfg!(feature = "whisper")`. Parakeet
stays CPU-only.

Five tests in `commands::models::tests` cover the permutation matrix:
whisper on/off, loader present/missing, macOS vs other. Both feature
configurations (`--features whisper` and `--no-default-features`)
build and pass tests.

Macos Metal-loader resolution on real hardware stays on the
ship-gate checklist — the detection logic is verifiable from Linux
but runtime behaviour is not.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 10:32:04 +01:00
05d823bf05 fix(rb-02): migrations run inside a single transaction
Every multi-statement migration and its matching schema_version insert
now execute on the same sqlx Transaction. A failure anywhere — a bad
statement, the version insert, or the commit itself — rolls the
database back to its previous state, so the next startup replays the
migration against a clean schema rather than a half-mutated one.

Extracted run_migrations_slice(pool, migrations) as the single apply
path. run_migrations delegates to it with MIGRATIONS; the test helper
run_migrations_up_to now filters MIGRATIONS by target and delegates to
the same code, eliminating the duplicated loop that previously lived
in the test module.

Regression test multi_statement_migration_rolls_back_on_failure
injects a poisoned v9 migration (valid CREATE followed by a bogus
function call) and asserts neither the partial schema change nor the
schema_version row persists after the failure.

SQLite DDL participates in transactions, so this is sufficient. Any
future migration that needs an implicitly-committing statement
(VACUUM / REINDEX / ATTACH — none today) must be its own
non-transactional migration; that's a reviewer responsibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 10:27:29 +01:00
b48d39bfb1 fix(rb-09): decoder propagates read and decode errors
decode_audio_file's packet loop was `Err(_) => break`, so any non-EOF
read error during playback dropped out silently with whatever samples
had accumulated. Per-packet decode errors were tallied and skipped,
contributing to the same outcome. A corrupt or truncated input
therefore came back as `Ok(partial_samples)` — no way for callers to
distinguish a clean decode from a compromised one.

Every SymphoniaError other than the explicit EOF
(`IoError(UnexpectedEof)`) now maps to `AudioDecodeFailed`. Decoder
errors bubble via `?` rather than being counted. `ResetRequired`
promotes to an error rather than a silent break.

Extracted an internal `decode_media_stream(mss, hint)` so tests can
inject a custom `MediaSource`. Added `FlakyCursor` — a seekable cursor
that returns a synthetic I/O error after N bytes — and a regression
test that confirms mid-stream read failure surfaces as `Err` instead of
returning partial audio. Happy-path and missing-file tests added for
coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 10:24:18 +01:00
528facfab0 fix(rb-12): hotkey device filter consults configured HotkeyCombo
try_attach_device was rejecting any device that did not report KEY_A or
KEY_R — a leftover heuristic from the whisper-overlay seed. A user whose
binding was anything else (Ctrl+Shift+D is a common default) would see
no hotkey events from that device even though it supports the key.

Replace the hard-coded check with device_supports_combo(supported,
combo), a pure helper that reads the configured trigger key code from
the HotkeyCombo snapshot. Snapshot is taken from hotkey_rx.borrow()
before opening the device; an unconfigured or shutting-down listener
short-circuits to a non-attach.

Four regression tests in linux::tests cover: supported+D → attach,
unsupported → reject, no reported keys → reject, and the explicit
non-A/non-R case that demonstrates the bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 10:18:54 +01:00
1250a70ba2 docs(cr-2026-04-22): commit source code-review document
Every issue under docs/issues/ links to this file as its Source. It was
created for 592b894 but not staged, leaving dangling links in the
release-blocker tracker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 10:18:45 +01:00
592b894790 docs(cr-2026-04-22): add release-blocker issue tracker at docs/issues/
Captures the 12 items from docs/code-review-2026-04-22.md that
must land before v0.1 ships. One markdown file per issue with:
severity, path:line, problem description, acceptance criteria,
fix scope, and dependency graph.

Split by severity:
- 3 CRITICAL: live-session race, migration atomicity, transcript-
  profile FK
- 9 MAJOR: monolith refactor, channel-fatality, capture worker
  join, runtime capabilities, macOS App Nap, decoder error prop,
  LLM prompt preflight, keystore thread-safety, hotkey device
  filter

README.md indexes them with a fix-order dependency graph and a
fish-shell script for bulk-converting to GitHub issues once `gh`
CLI is installed and authed. Deferred step by user decision —
markdown tracker is authoritative until then.
2026-04-22 09:46:08 +01:00
fd24b81a5f fix(cr-2026-04-22): recording_filename uses atomic counter for absolute uniqueness
MINOR from the batch review of 6e9ed99: SystemTime::now() alone
cannot guarantee uniqueness under tight loops — two calls in the
same clock tick can return identical secs + nanos on some OS
timing resolutions. The filename reduction from "every second"
to "every nanosecond" addresses the flagged bug but leaves a
theoretical gap.

Adds a process-lifetime AtomicU64 counter, zero-padded to 4 digits,
as the third filename component. New shape:
  kon-<secs>-<nanos_in_sec>-<counter>.wav
  e.g. kon-1776828000-123456789-0000.wav

Across process restarts the counter resets to 0, but the wall-clock
secs/nanos have advanced — no cross-launch collisions possible.
Within a single process, the counter guarantees uniqueness regardless
of clock behaviour.

Test strengthened from ">=32 of 64 unique" (probabilistic) to
"1024 of 1024 unique" (absolute).
2026-04-22 09:24:13 +01:00
a5bc45e847 fix(cr-2026-04-22): list_transcripts accepts omitted arguments
Regression surfaced by the batch review: commit 8400128 switched
list_transcripts from unwrap_or_default to map_err(-32602). This
correctly errors on malformed payloads but also rejected the common
case where a client omits the 'arguments' field entirely — which
arrives as Value::Null, and serde_json::from_value does not
deserialise Null into a struct.

Short-circuits the Null case to Args::default() before attempting
deserialisation. Genuine shape mismatches ("limit": "twenty")
still return -32602 as the previous test asserts.

New regression test: tools/call with list_transcripts and no
arguments key must return a successful response.
2026-04-22 09:23:17 +01:00
b376b98f33 refactor(cr-2026-04-22): remove dead code and stale allow(dead_code) suppressions
2026-04-22 review MINORs and NITs:

- crates/core/src/providers.rs: delete entire module. SpeechToText /
  TextProcessor / ProviderRegistry were forward-looking traits that
  never got wired — the Transcriber trait in kon-transcription
  (A.2 #13) has since superseded SpeechToText, and the Registry
  pattern was redundant against LocalEngine. Keeping them as dead
  public surface signalled future direction that is no longer
  accurate.
- crates/core/src/types.rs: delete TranscriptMetadata. Forward-
  looking struct with an unfulfilled TODO; storage has evolved
  independently through v7/v8 migrations without adopting it.
- crates/ai-formatting/src/llm_client.rs: remove #[allow(dead_code)]
  from CLEANUP_PROMPT and format_dictionary_suffix. Both are
  actively called; the suppressions would hide future genuine
  dead-code warnings in this regression-sensitive prompt file.
- src-tauri/src/commands/live.rs: remove #[allow(dead_code)] from
  LiveStatusMessage. Every variant (Warning, Overload, Error,
  Finished) is constructed in the module today.
- README.md: update kon-transcription row from "SpeechToText
  trait" to "Transcriber trait" and mention the new streaming/
  module.

Workspace test gate green (225 lib tests across all crates).
2026-04-22 09:17:05 +01:00
840012822f fix(cr-2026-04-22): list_transcripts tool returns -32602 on malformed params
MAJOR from the 2026-04-22 review (crates/mcp/src/lib.rs:188-195):
the handler called serde_json::from_value(args).unwrap_or_default(),
so a request like { "limit": "twenty" } silently became the default
limit of 20. Every other tool handler in this file map_errs to
-32602 Invalid arguments; this one was the outlier.

Switches to the same map_err pattern. Empty params still
deserialise cleanly to Args::default (via #[serde(default)] on the
Option<i64> field), so callers that send no args are unaffected —
only genuinely malformed shapes now error.

Regression test: tools/call with list_transcripts and a
string-typed limit must return code -32602 with an "Invalid
arguments" message.
2026-04-22 09:14:42 +01:00
d25b095788 fix(cr-2026-04-22): MCP stdio replies with parse-error on malformed JSON
MAJOR from the 2026-04-22 review (crates/mcp/src/main.rs:26-30): the
stdio transport logged malformed JSON lines to stderr and continued
without sending any JSON-RPC response. Clients saw silence instead of
the -32700 Parse Error they could key off. handle_message has a
parse-error branch for shape mismatch, but it never ran for bytes
that failed to parse as JSON at all.

Exposes a new public helper kon_mcp::parse_error_response(detail)
that mirrors the existing internal error_response pattern, filling
id with null per JSON-RPC 2.0 §5.1 (parse error, no id recoverable).
main.rs now writes that response out before continuing the read
loop.

Regression test on the helper asserts: jsonrpc "2.0", id null,
code -32700, message starts with "Parse error" and includes the
underlying serde detail.
2026-04-22 09:13:33 +01:00
7ece0df0ac fix(cr-2026-04-22): CSP guard matches connect-src as exact directive name
MINOR from the 2026-04-22 review (build.rs:47-64): the guard used
strip_prefix("connect-src") to find the directive, which would also
match hypothetical future directives like connect-src-elem and
validate the wrong allow-list.

Switches to split_once(char::is_whitespace) on each directive and
requires the first token to equal "connect-src" exactly. Robust
against prefix collisions with any future CSP3 directive
additions.
2026-04-22 09:11:53 +01:00
53d303f4b7 fix(cr-2026-04-22): uncomplete_task reopens auto-completed parents
MAJOR from the 2026-04-22 review (database.rs:389-449):
complete_subtask_and_check_parent auto-completes a parent task when
the last child completes, but uncomplete_task only flipped the
requested row — reopening a child left the parent wrongly marked
done, breaking the "parent done iff every child done" invariant.

Wraps uncomplete_task in a transaction and, after flipping the
subtask, looks up its parent_task_id. If present, resets the
parent to done=0 as well. Scoped to "done=1" on the parent update
so an already-open parent is untouched.

Two regression tests:
- uncomplete_subtask_reopens_auto_completed_parent: the direct
  mirror of the existing subtask_crud_roundtrip completion flow.
- uncomplete_top_level_task_does_not_touch_siblings: ensures the
  parent-reopen branch is a no-op for tasks with no parent, and
  siblings without a parent relationship are unaffected.
2026-04-22 09:11:14 +01:00
93a7165dac fix(cr-2026-04-22): reject HTTP 4xx/5xx on non-resume download path
MAJOR from the 2026-04-22 review (model_manager.rs:161-262): reqwest
does not return Err on 4xx/5xx by default. The resume branch
validated 206/200 and errored on anything else, but the non-resume
branch skipped the status check entirely — a 404 or 500 body was
streamed into .part and atomically renamed over the destination as
if it were the model file. For models without a sha256 declared,
this failure mode was silent and catastrophic (the engine would
crash loading an HTML error page as GGML on next launch).

Adds an is_success() check in the non-resume branch: any non-2xx
returns KonError::DownloadFailed with the HTTP status in the
message, and (importantly) we return before File::create so no
.part file is left behind.

Test: spawn_500_server that responds 500 to any request; a fresh
(no .part, no sha256) download must Err with "HTTP 500" and leave
neither .part nor dest on disk.
2026-04-22 09:09:09 +01:00
b665754560 fix(cr-2026-04-22): read_wav surfaces sample decode errors
MAJOR from the 2026-04-22 review (wav.rs:135-145): read_wav used
filter_map(|s| s.ok()) on both integer and float sample iterators, so
any per-sample decode error (truncated payload, corrupted format
descriptor after a partial write) was silently discarded. Callers
received Ok with a short samples vec, losing audio without any signal
to investigate.

Switches to collect::<Result<Vec<f32>>>() with a map that converts
hound's per-sample errors into KonError::AudioDecodeFailed. First
error aborts the read rather than returning a partial vector.

Test fabricates the regression by writing a valid WAV and chopping
the last 10 bytes; the previous filter_map would have returned Ok
with a shortened vec, the new code returns Err.
2026-04-22 09:07:55 +01:00
6e9ed99b3a fix(cr-2026-04-22): recording_filename avoids same-second collisions
MAJOR from the 2026-04-22 review (audio.rs:236-257): filenames were
derived from SystemTime::now().as_secs(), so two recordings started
within the same second resolved to the same path — possible overwrite
or merge.

Extracts the filename generation into a private helper and appends
the sub-second nanosecond component. Format is now
`kon-<secs>-<nanos_in_sec>.wav`, e.g. `kon-1776828000-123456789.wav`,
which stays human-readable, sortable by timestamp, and effectively
uncollidable under any realistic live-capture pattern.

Two tests cover the regression:
- recording_filenames_are_unique_across_rapid_calls: 64 tight-loop
  calls must produce at least 32 unique names.
- recording_filename_has_expected_shape: prefix/suffix plus the
  zero-padded 9-digit nanos component.
2026-04-22 09:06:47 +01:00
a37caa2219 fix(cr-2026-04-22): paste_text_replacing snapshots and restores prior clipboard
MAJOR from the 2026-04-22 review (paste.rs:181-217): unlike
paste_text, the replace flow did not snapshot the user's clipboard
before writing the raw transcript. A successful revert left the raw
transcript on the clipboard and destroyed whatever the user had
copied before invoking it — inconsistent with paste_text's Handy-#921
contract (brief item #3).

Extracts the snapshot+restore pattern into two helpers shared
between paste_text and paste_text_replacing:

- snapshot_clipboard_text() -> Option<String>
- schedule_clipboard_restore(prior, transcript)

Both paste_text and paste_text_replacing now run the same
capture-before-stomp, restore-after-fire sequence. The restore-
guard (only restore if clipboard still holds the transcript we set)
is shared too, so a user who copies something new within the 300 ms
window is still respected.

DRY: removes ~15 lines of inline clipboard bookkeeping from
paste_text while making the replace flow identical.
2026-04-22 09:05:40 +01:00
0ea230fef4 fix(cr-2026-04-22 C2): VadChunker::flush returns Vec<VadChunk> and preserves mid-flush emissions
CRITICAL from the 2026-04-22 code review: RmsVadChunker::flush() was
calling consume_frame() on a zero-padded final frame via `let _ = ...`,
discarding any VadChunk the call emitted. If the padded frame triggered
end-of-utterance (silent tail + padding zeros push past
silence_close_samples) or max_chunk_samples (buffered speech + padding
push past the cap), the emitted chunk was lost; the outer state check
then either returned None or an empty closing chunk.

Changes the VadChunker trait flush signature from Option<VadChunk> to
Vec<VadChunk> so both the mid-flush emission (from consume_frame) and
the closing emission (from emit_active_chunk_and_close) can be
surfaced. Updates RmsVadChunker::flush to collect from both sites
and skip a zero-length closing emit when the hit_max continue variant
already cleared active_chunk.

Two regression tests land alongside:
- flush_preserves_hit_max_chunk_from_padded_final_frame: tight
  max_chunk, sub-frame speech tail; pre-fix dropped the chunk, post-fix
  emitted samples cover the full active-speech region.
- flush_preserves_end_of_utterance_chunk_from_padded_final_frame:
  silent tail near silence_close; padded zero frame closes the
  utterance inside consume_frame; pre-fix returned None.

No production callers yet — the VadChunker wiring in live.rs is a
deferred item from A.3. API change is clean within the repo.
2026-04-22 09:03:50 +01:00
e54f0404ce fix(A.4 #29): strip zero-width format chars in to_plain_text
Review feedback (MINOR): char::is_whitespace returns false for
zero-width format codepoints (U+200B ZWSP, U+200C ZWNJ, U+200D ZWJ,
U+2060 WORD JOINER, U+FEFF ZWNBSP / BOM). The original normalise
pass let them through to the LLM where they waste tokens without
contributing any natural-language content.

Makes the decision explicit: these chars STRIP entirely rather than
collapse to a space. Collapsing would silently insert a word break
where the source had none ("hello<FEFF>world" → "hello world"
would merge two words into a space-separated pair that the original
author did not intend). Stripping preserves the original token
boundaries and drops the invisible noise.

Three new tests:
- zero_width_format_chars_strip_entirely — exhaustive coverage of
  all five handled codepoints.
- zero_width_chars_do_not_break_adjacent_whitespace_collapsing —
  "hello <FEFF> world" still collapses to "hello world" (the
  strip does not leave behind an artefact that breaks the whitespace
  collapse pass).
- leading_bom_is_stripped — a BOM at segment start, the common
  artefact pattern when Whisper consumes an encoded file.
2026-04-22 08:37:43 +01:00
53fe848979 feat(A.4 #29): plain-text pre-formatter before LLM cleanup
New crates/ai-formatting/src/to_plain_text.rs module with one public
function: to_plain_text(&[Segment]) -> String.

Rules the function enforces:
- each segment's text is whitespace-normalised (any run of unicode
  whitespace collapses to a single ASCII space, so tabs, newlines,
  and NBSPs never reach the LLM),
- empty and whitespace-only segments are dropped,
- remaining segments are joined with a single ASCII space,
- the joined string is normalised again (so a segment ending in a
  space followed by one starting in a space does not produce a double
  space) and trimmed end-to-end.

pipeline.rs's inline join is replaced with this call. Whisper's
timestamp fields (Segment.start / .end) are carried separately and
never reach the LLM by construction — the "timestamps stripped"
half of brief item #29's acceptance falls out of using Segment.text
alone. The work the module actually adds is whitespace discipline
and the tested boundary (empty input, empty-only input, NBSPs,
pathological whitespace runs, idempotence, double-space at join
boundaries).

Source: Scriberr PR #288 — feeding raw Whisper JSON (with timestamps
and per-segment structure) degraded cleanup quality; plain-text
input raised it back.
2026-04-22 08:34:04 +01:00
4c1d368d05 fix(A.3 #25): guard sample_index_for_seconds against NaN and infinity
Review feedback (MINOR): the original <= 0.0 clamp caught negatives
and zero but not non-finite inputs. Rust's saturating float-to-int
cast turns f64::INFINITY into u64::MAX, which would park the capture
buffer origin beyond any reachable sample index and trim the whole
buffer forever if a future end_secs source ever produces infinity
(clock glitch, overflow upstream, corrupted timestamp in a pass).

Adds is_finite() check. NaN, +infinity, -infinity, and zero all
return 0, which downstream trim_buffer_to_commit_point treats as
no-op. Test covers all three non-finite cases.
2026-04-22 08:02:23 +01:00
ed90de3c93 fix(A.3 #21): preserve audio contiguity across max_chunk splits in RmsVadChunker
Review feedback (CRITICAL): when a chunk hit max_chunk_samples during
continuous speech, emit_active_chunk reset state to Idle. The next
1-2 loud frames of post-split continued speech went into onset_buffer
and were silently cleared if silence arrived before the 3-frame onset
threshold — 50-100ms of user audio lost at every max-chunk boundary
in long-continuous-speech scenarios.

Splits emit_active_chunk into two variants:

- emit_active_chunk_and_close: the existing behaviour. Used for
  end-of-utterance closes and end-of-session flush. Truncates trailing
  silence, resets state to Idle.
- emit_active_chunk_continue: mid-utterance split on max_chunk. Stays
  in State::InSpeech, clears active_chunk for continued accumulation,
  advances active_chunk_start by the emitted length so the next
  chunk's start_sample is contiguous with this one's end. No
  silence-trim (by definition still in speech — end-of-utterance
  takes priority).

Adds max_chunk_split_preserves_audio_contiguity test: feeds 17 frames
of continuous speech into a chunker with a 4-frame cap, asserts
(a) chunk[i+1].start_sample == chunk[i].start_sample + chunk[i].samples.len()
across every pair, and (b) the final emitted region reaches the end
of the fed speech with no sample loss.
2026-04-22 08:01:14 +01:00
4455e4d1b1 fix(A.3 #24): clamp LocalAgreement slices against latest.len() to prevent panic on shrinking passes
Review feedback (CRITICAL): LocalAgreement::push could panic with an
index OOB when a later pass arrived shorter than committed_count.
Concrete case: commit [a, b], next pass arrives [a] — lcp_len=1,
new_committed=max(1, 2)=2, then latest[2..] panicked because
latest.len()==1.

A Whisper re-transcription of an overlapping window can legitimately
collapse repeated segments, or the user can stop mid-utterance after
some tokens were already committed, both of which produce this
shape. The committed_count invariant still holds (non-shrinkage) —
it is the slicing that was unsafe.

Clamps every latest[..] slice against latest.len() before indexing.
committed_count stays at new_committed even when the pass is shorter:
non-shrinkage is relative to what we have already emitted, not to
the current pass length. newly_committed and tentative both return
empty when the shorter pass has nothing past the committed prefix.

Adds two regression tests:
- shorter_pass_after_commit_does_not_panic (commit 2, push 1)
- empty_pass_after_commit_does_not_panic (commit 1, push empty)
2026-04-22 07:59:27 +01:00
cea15c12c7 feat(A.3 #25): aggressive buffer trim tied to commit points
New streaming::buffer_trim module with two pure helpers:

- sample_index_for_seconds(end_secs, sample_rate) -> u64: converts
  LocalAgreement::last_committed_end_secs() into an absolute sample
  index. Defensive against negative end_secs (treats as 0) so a future
  clock-skewed timestamp cannot wrap to a huge u64.
- trim_buffer_to_commit_point(buffer, buffer_start_sample,
  commit_sample_index) -> new_buffer_start_sample: drains the prefix
  of the capture buffer that falls below the commit point and returns
  the new absolute-index origin.

Edge cases covered by tests:
- commit before buffer start → no drain
- commit equal to buffer start → no drain
- commit inside buffer → drain prefix, advance origin
- commit at buffer end → drain all, origin moves forward
- commit past buffer end → drain all, origin parks at commit (rare
  edge after a committer reset)
- sample_index_for_seconds rounds nearest, negatives clamp to 0
- integration with LocalAgreement::last_committed_end_secs

trim_bounds_buffer_over_long_session is the acceptance fixture for
the ufal #120/#102 failure mode: 100 cycles of 16_000 captured samples
with a 200-sample tentative tail per cycle, and the buffer stays below
2× the tentative envelope instead of growing to 1.6M samples.

Integration into src-tauri/src/commands/live.rs deferred to the
dogfood session that wires VadChunker and LocalAgreement end-to-end —
the trim is a one-line replacement at the maybe_dispatch_chunk drain
site once the committer is feeding it commit points.
2026-04-22 07:52:48 +01:00
da2340325f feat(A.3 #24): LocalAgreement-n commit policy
New streaming::commit_policy module implementing ufal's
LocalAgreement-n pattern: tokens emitted by the streaming ASR
pipeline stay tentative until N consecutive passes produce the same
prefix, at which point the agreed prefix commits.

Types:
- Token: text + absolute start/end seconds. PartialEq is text-only so
  identical words from overlapping Whisper windows compare equal even
  when their timestamps drift.
- CommitDecision { newly_committed, tentative }: the partition fed
  back to the live-session worker after each pass.
- CommitPolicy enum with LocalAgreement { n }. Default is n=2 (ufal).
- LocalAgreement: stateful committer with push/flush/reset and a
  last_committed_end_secs accessor. Brief item #25 uses the latter
  to compute the sample-index drain target for aggressive buffer trim.

Invariants exercised by tests:
- first pass is all tentative (need 2 passes to commit under n=2)
- two matching passes commit their common prefix
- divergent second pass commits nothing
- extending agreement commits only the newly-agreed tokens
- tentative tail tracks latest pass only (no stale guesses)
- committed prefix never shrinks, even if later passes contradict
- flush emits any tentative-but-not-committed tail at session end
- flush on empty history is a no-op
- reset clears all commit state
- n=3 requires three matching passes before anything commits
- CommitPolicy::default() is LocalAgreement { n: 2 }

Integration into src-tauri/src/commands/live.rs deferred — the
tentative/committed split needs the B-side 'tentative: bool' field on
LiveResultMessage.segments (workstream-B #24 UI contract) and
validation against real streaming captures before going live.
2026-04-22 07:50:18 +01:00
05eea41649 feat(A.3 #21): VadChunker trait + RMS backend (Silero deferred)
New crates/transcription/src/streaming/ module with:

- VadChunker trait: Send-bound, object-safe, push/flush/reset/
  next_sample_index. Same surface a future Silero backend will
  present, so live.rs wiring does not change when Silero drops in.
- VadChunk type: (start_sample: u64, samples: Vec<f32>) for
  commit-policy sample-offset bookkeeping in #24.
- RmsVadChunker: fallback backend the plan permits while the
  ort 2.0.0-rc.10 vs rc.12 ecosystem conflict blocks silero-vad-rust
  / voice_activity_detector. Tuned to match the existing
  evaluate_speech_gate behaviour (enter 0.003, exit 0.0014, 3-frame
  onset, 500 ms silence close, 2 s max chunk).

Key behavioural properties, each backed by a test:
- pure silence emits nothing
- samples between exit and enter thresholds never trigger onset
- a single loud frame does not start a chunk (sustained speech only)
- sustained speech followed by silence emits exactly one chunk
- hysteresis: a dip between enter and exit does not split a chunk
- max_chunk_samples caps continuous speech (Whisper never fed > 2 s)
- flush surfaces in-flight speech
- flush on an idle chunker emits nothing
- reset restores a clean state
- emitted chunk start_sample includes the onset buffer (Whisper sees
  the speech attack, not post-onset audio)

Open items tracked as follow-ups:
1. Silero backend via direct ort rc.12 bridge (Handy-style). Blocked
   on either ecosystem ort alignment or dedicated bridge session.
2. Integration into src-tauri/src/commands/live.rs. Deferred so
   threshold tuning can be validated against real microphone
   captures rather than synthetic constant-signal fixtures.
2026-04-22 07:47:30 +01:00
6f4adae56c fix(A.2 #19): only report audio_path when the WAV writer finalises cleanly
Review feedback: reported_audio_path was cached at writer-open time
and survived a mid-session writer drop. If an append error cleared
wav_writer (or hound's Drop ran instead of finalize), the Summary
still reported the path — risking StopLiveTranscriptionResponse
pointing at a file whose header did not reflect its data chunk.

Moves the decision to end-of-session. wav_writer.take() is inspected:
- writer present + finalize Ok → report the path,
- writer present + finalize Err → None, emit a Warning,
- writer absent (mid-session drop, or never opened) → None.

StopLiveTranscriptionResponse.audio_path now means "the recording is
known-good" rather than "a recording was attempted". Users can still
recover partial files via filesystem if needed; the warning toasts
already emitted by append_resampled_audio on write failure surface
that path implicitly.
2026-04-22 04:54:15 +01:00
e4adcc1832 fix(A.2 #13): propagate whisper feature from kon crate through to kon-transcription
Review feedback: src-tauri/src/commands/models.rs was still naming
load_whisper unconditionally, so a --no-default-features workspace
build (kon-transcription without the whisper feature) would have
compiled the transcription crate cleanly but failed in the kon crate
as soon as it tried to resolve the load_whisper symbol.

Adds a matching [features] section to src-tauri/Cargo.toml:
- default = ["whisper"]
- whisper = ["kon-transcription/whisper"]

and declares the kon-transcription dep with default-features = false so
the feature actually propagates rather than being forced-on by the
child crate's default. Cfg-gates the load_whisper import in models.rs
and adds a companion match arm that returns a runtime error when the
feature is off, keeping the API shape intact.

Verified with both cargo build -p kon and cargo build -p kon
--no-default-features.
2026-04-22 04:54:07 +01:00
f9b396a966 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.
2026-04-22 04:39:44 +01:00
8b49d0fe9c feat(A.2 #13): replace SpeechBackend enum with Transcriber trait
New crates/transcription/src/transcriber.rs defines a Transcriber
trait (Send supertrait for spawn_blocking travel) with
TranscriberCapabilities (sample_rate, channels, supports_initial_prompt).
TranscriberCapabilities.sample_rate is load-bearing for the upcoming
progressive WAV writer (#19).

Concrete impls:
- SpeechModelAdapter wraps Box<dyn transcribe_rs::SpeechModel + Send>
  for Parakeet (and any future transcribe-rs-backed engine).
- WhisperRsBackend moves its transcribe_sync body into the impl,
  widening the signature from &self to &mut self so per-call
  WhisperState can be created cleanly through the trait object.

LocalEngine now holds Box<dyn Transcriber + Send>; dispatch in
transcribe_sync collapses from a match to a direct call. Adds
LocalEngine::capabilities() for the WAV-writer.

Cargo feature flag "whisper" (default on) makes whisper-rs, num_cpus,
and the whole whisper_rs_backend module optional. cargo check
--no-default-features -p kon-transcription now builds without pulling
whisper-rs-sys — the escape hatch brief item #6 / #13 called for on
Windows / non-AVX2 / cloud-only builds. load_whisper is cfg-gated
behind the same feature.

src-tauri/src/commands/models.rs load_model_from_disk returns
Box<dyn Transcriber + Send> instead of SpeechBackend; caller chain
(ensure_model_loaded, prewarm_default_model) is unchanged.

transcriber_trait_is_object_safe test lands alongside the trait as a
compile-time witness against future Self-returning / generic-method
additions.
2026-04-22 04:33:23 +01:00
dd98cb7994 fix(A.1 #8): make ResumeUnsupported test actually prove Range header was sent
Review feedback: the previous test would pass even if the Range-header
logic in download_file were deleted entirely, because File::create
truncates the stale .part regardless of which branch set
actually_resuming to false.

Tightens spawn_no_range_server to return HTTP 400 when the request
carries no Range header, and only return 200 + full body when Range is
present. A regression that stops sending Range now surfaces as a
download failure (empty body from 400, bytes != body assertion)
instead of silently passing through the truncation path.
2026-04-22 00:36:37 +01:00
6fd38932ce fix(A.1 #2): parse tauri.conf.json properly in CSP regression guard
Review feedback: the original guard substring-searched the whole file
after the first "csp" token, which (a) false-passes if any unrelated
JSON value elsewhere in the config happens to contain a localhost URL
and (b) false-fails if the CSP is ever re-serialised with escaped
forward slashes.

Switches to serde_json + a /app/security/csp pointer lookup, then
splits the CSP on ';', finds the connect-src directive, tokenises its
allow-list on whitespace, and requires an exact match for both
http://127.0.0.1:* and ws://127.0.0.1:*. The error now also includes
the current connect-src value so a developer who breaks it can see
exactly what needs restoring.
2026-04-22 00:35:33 +01:00
2371e73f18 test(A.1 #8): cover ResumeUnsupported restart path in transcription downloader
The downloader already handles servers that return 200 to a Range
request by falling through to a truncating File::create on the .part
path, discarding stale partial bytes. That branch had no dedicated
fixture test — the SHA mismatch and Range-honouring resume cases were
covered but the mirror / proxy that strips Range support was not.

Adds spawn_no_range_server (always 200, full body regardless of Range
header) and download_file_restarts_when_server_ignores_range. Writes 12
bytes of stale content to .part, kicks off a download, asserts the
final file matches the fresh body exactly (not stale-bytes-prefixed)
and the .part file is cleaned up.

Brings the transcription downloader to test-coverage parity with
crates/llm/src/model_manager.rs per brief item #8 ("test coverage
parity" acceptance).
2026-04-22 00:29:13 +01:00
f486ff4cbc feat(A.1 #2): build-time CSP regression guard for localhost LLM
Pins the connect-src CSP entries for http://127.0.0.1:* and
ws://127.0.0.1:* at build time. If a future edit to tauri.conf.json
strips the local-LLM permit, the kon crate fails to build rather than
shipping a binary whose webview fetch() silently 404s with an opaque
scope error (Vibe #438 / #487).

Closes item #2 of docs/whisper-ecosystem/brief.md — CSP widening itself
landed in an earlier commit; this is the regression-proofing the plan
calls for.
2026-04-22 00:26:15 +01:00
5c17544a63 fix(sounds): fall back to default volume on out-of-range input
resolveVolume previously clamped any value >=1 to full blast. If a
future settings change ever leaks a 0–100 scale through (instead of
0–1), the user gets jump-scared at max volume. Treat any v>1 as a
scale-drift bug and play at DEFAULT_VOLUME instead.

Also reject NaN / Infinity explicitly rather than relying on the
<=0 branch catching them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:32:51 +01:00
59209bd181 ci(audit): add weekly cargo-audit + npm-audit workflow
Runs Mondays 06:00 UTC (plus workflow_dispatch) so any freshly
published advisory surfaces as its own failing run rather than
slipping into an unrelated PR's check.yml noise. npm audit is
gated to --audit-level=high to skip the low/moderate chatter that
doesn't warrant a bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:32:34 +01:00
697642fa4d ci(check): add svelte-check and workspace lib tests
svelte-check catches type/template errors Vite's build skips; cargo
test --workspace --lib runs our pure-unit suites (prompt contract,
hallucination filter, preset parsing) without GPU or runtime deps.
Test step is Linux-only so the Windows/macOS legs stay focused on
platform compile coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:32:18 +01:00
49 changed files with 4093 additions and 317 deletions

51
.github/workflows/audit.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
# Weekly dependency vulnerability scan.
#
# This runs separately from check.yml so a newly published advisory
# surfaces as its own failing run (easy to spot, easy to track)
# without blocking unrelated PR work. Manually triggerable via
# workflow_dispatch for ad-hoc checks after dependency bumps.
name: audit
on:
schedule:
# Mondays 06:00 UTC — early in the week so any advisory has the
# whole week to be triaged rather than landing on a Friday.
- cron: "0 6 * * 1"
workflow_dispatch:
jobs:
cargo-audit:
name: cargo audit
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
# rustsec/audit-check runs cargo-audit against the RustSec
# advisory DB. Fails the job on any unignored advisory.
- name: Run cargo audit
uses: rustsec/audit-check@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
npm-audit:
name: npm audit
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install JS deps
run: npm ci
# --audit-level=high ignores low/moderate noise — we care about
# high and critical advisories, which are the ones that warrant
# an actual bump.
- name: Run npm audit
run: npm audit --audit-level=high

View File

@@ -128,6 +128,12 @@ jobs:
- name: cargo check (workspace)
run: cargo check --workspace --all-targets
# Library tests only — no runtime/GPU deps. Linux-gated to keep
# the macOS + Windows legs focused on compile coverage.
- name: cargo test (workspace, libs)
if: matrix.os == 'ubuntu-22.04'
run: cargo test --workspace --lib
frontend:
name: svelte build + lint
runs-on: ubuntu-22.04
@@ -149,3 +155,9 @@ jobs:
# Svelte/Vite frontend compiles cleanly.
- name: Build frontend (Vite only)
run: npm run build
# svelte-check catches type and template errors that Vite's build
# step happily lets through (Vite only type-checks .ts; .svelte
# type drift slips past until svelte-check runs).
- name: svelte-check
run: npm run check

View File

@@ -164,7 +164,7 @@ kon/
|---|---|
| **`kon-core`** | Shared types (`Segment`, `Transcript`, `Megabytes`, `ModelId`), constants, the `Engine` / `SpeedTier` / `AccuracyTier` enums, hardware probe (`sysinfo`-based), model registry (Whisper + Parakeet + Moonshine entries), hardware-aware recommendation scoring, `process_watch` for meeting detection. |
| **`kon-audio`** | `cpal`-based microphone capture with device hotplug + error forwarding, VAD, `rubato` streaming resampler to 16 kHz mono, `symphonia` file decoding, `hound` WAV I/O. |
| **`kon-transcription`** | `whisper-rs` backend (`WhisperRsBackend`) that owns a `WhisperContext` and supports `set_initial_prompt`. `LocalEngine` wraps both Whisper and Parakeet (via `transcribe-rs` ONNX) behind a common `SpeechToText` trait. Model manager handles downloads, paths, and disk checks. |
| **`kon-transcription`** | `whisper-rs` backend (`WhisperRsBackend`) that owns a `WhisperContext` and supports `set_initial_prompt`. `LocalEngine` wraps both Whisper and Parakeet (via `transcribe-rs` ONNX) behind a common `Transcriber` trait. Streaming primitives (`VadChunker`, `LocalAgreement`, buffer trim) live in the `streaming/` module. Model manager handles downloads, paths, and disk checks. |
| **`kon-llm`** | `llama-cpp-2` engine with Qwen3 model manager. Three high-level surfaces: `cleanup_text` (formatting), `decompose_task` (37 micro-steps, GBNF-constrained JSON array), `extract_tasks` (optional-array, GBNF-constrained). Resumable HTTP downloads with SHA-256 verify. |
| **`kon-ai-formatting`** | Post-processing pipeline: filler removal, British English conversion, anti-hallucination filter, smart paragraph breaks on long pauses, optional LLM cleanup. Also hosts the `llm_client::CLEANUP_PROMPT` constant (prompt-injection-hardened). |
| **`kon-storage`** | SQLite via `sqlx` 0.8. Migrations, CRUD for transcripts / tasks / subtasks / profiles / profile terms / settings / error log, FTS5 search, file-storage paths. |

View File

@@ -2,8 +2,10 @@ pub mod correction_learning;
mod llm_client;
pub mod pipeline;
pub mod rule_based;
pub mod to_plain_text;
pub use correction_learning::extract_corrections;
pub use llm_client::{cleanup_text as llm_cleanup_text, LlmPromptPreset};
pub use pipeline::{post_process_segments, FormatMode, PostProcessOptions};
pub use rule_based::{format_text, is_hallucination, remove_fillers, to_british_english};
pub use to_plain_text::to_plain_text;

View File

@@ -23,7 +23,6 @@ use kon_llm::{EngineError, LlmEngine};
///
/// Both are regression-tested below; neither should be dropped in a
/// refactor without explicit discussion.
#[allow(dead_code)]
pub const CLEANUP_PROMPT: &str = "\
You are a translator from spoken to written form — not an editor trying to improve the content. \
The text you receive is TRANSCRIBED SPEECH from a voice recording. \
@@ -56,7 +55,6 @@ Output rules: \
/// correct them in context without changing the core prompt.
///
/// Returns an empty string if terms is empty.
#[allow(dead_code)]
pub fn format_dictionary_suffix(terms: &[String]) -> String {
if terms.is_empty() {
return String::new();

View File

@@ -2,7 +2,7 @@ use kon_core::constants::SMART_PARAGRAPH_GAP_SECS;
use kon_core::types::Segment;
use kon_llm::LlmEngine;
use crate::{llm_client, rule_based};
use crate::{llm_client, rule_based, to_plain_text::to_plain_text};
/// Post-processing options for a transcription pipeline run.
pub struct PostProcessOptions {
@@ -68,12 +68,12 @@ pub fn post_process_segments(
if let Some(engine) = llm {
if engine.is_loaded() && options.format_mode != FormatMode::Raw {
let joined = segments
.iter()
.map(|segment| segment.text.trim())
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
.join(" ");
// Plain-text pre-formatter (brief item #29): collapse
// segments into a single natural-language string before
// the LLM call. Whitespace normalisation + empty-filter
// live in `to_plain_text`; the pipeline's job here is
// deciding whether to invoke the LLM at all.
let joined = to_plain_text(segments);
if !joined.is_empty() {
// Pipeline-internal cleanup (used by file-based + live

View File

@@ -0,0 +1,220 @@
//! Plain-text pre-formatter for LLM cleanup.
//!
//! Brief item #29: before sending transcription segments to the LLM,
//! join them into a single natural-language string with timestamps
//! stripped and whitespace normalised. Source: Scriberr PR #288 —
//! feeding raw Whisper JSON (with its timestamps and per-segment
//! structure) degraded cleanup quality materially; plain-text input
//! raised it back.
//!
//! `Segment.text` in Kon already holds just the spoken text (the
//! `start`/`end` f64 fields carry the timing), so "timestamp
//! stripping" falls out of using the text field alone. The work here
//! is the whitespace pass and empty-segment filter, plus a single
//! public function the pipeline can depend on.
use kon_core::types::Segment;
/// Join transcription segments into a single plain-text string
/// suitable for feeding to an LLM cleanup prompt.
///
/// Rules:
/// - each segment's text is whitespace-normalised (any run of
/// whitespace — spaces, tabs, newlines, non-breaking spaces —
/// collapses to a single ASCII space),
/// - segments that are empty or whitespace-only are dropped,
/// - the remaining segments are joined with a single ASCII space,
/// - the final string is whitespace-normalised again (so a segment
/// ending in a space and the next beginning with one do not produce
/// a double space) and trimmed of leading/trailing whitespace.
///
/// Pure function. No panics. Returns an empty string if every segment
/// filters out.
pub fn to_plain_text(segments: &[Segment]) -> String {
let joined = segments
.iter()
.map(|s| normalise_whitespace(&s.text))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" ");
normalise_whitespace(&joined).trim().to_string()
}
/// Collapse any run of unicode whitespace into a single ASCII space,
/// and strip zero-width format characters entirely.
///
/// Zero-width chars (U+200B/C/D, U+2060, U+FEFF) are handled as a
/// separate class from whitespace: `char::is_whitespace()` returns
/// false for them, so the standard whitespace pass would let them
/// through to the LLM where they waste tokens without contributing
/// any natural-language content. Treating them as "strip entirely"
/// rather than "collapse to a space" avoids silently inserting word
/// breaks where the source had none.
///
/// Kept private; the module's contract is `to_plain_text`.
fn normalise_whitespace(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut prev_was_space = false;
for ch in s.chars() {
if is_zero_width_format(ch) {
// Strip without emitting anything. prev_was_space unchanged
// so a space on either side of a zero-width char still
// collapses correctly.
continue;
}
if ch.is_whitespace() {
if !prev_was_space {
out.push(' ');
prev_was_space = true;
}
} else {
out.push(ch);
prev_was_space = false;
}
}
out
}
/// Zero-width format characters the transcription pipeline should
/// never feed to an LLM. Sourced from common "invisible" codepoints:
/// - U+200B ZERO WIDTH SPACE
/// - U+200C ZERO WIDTH NON-JOINER
/// - U+200D ZERO WIDTH JOINER
/// - U+2060 WORD JOINER
/// - U+FEFF ZERO WIDTH NO-BREAK SPACE (also BOM)
fn is_zero_width_format(ch: char) -> bool {
matches!(
ch,
'\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{2060}' | '\u{FEFF}'
)
}
#[cfg(test)]
mod tests {
use super::*;
fn seg(text: &str) -> Segment {
Segment {
start: 0.0,
end: 1.0,
text: text.into(),
}
}
#[test]
fn empty_input_is_empty_output() {
assert_eq!(to_plain_text(&[]), "");
}
#[test]
fn single_segment_returns_its_text_trimmed() {
let out = to_plain_text(&[seg(" hello world ")]);
assert_eq!(out, "hello world");
}
#[test]
fn multiple_segments_are_joined_with_single_space() {
let out = to_plain_text(&[seg("the cat"), seg("sat on the mat")]);
assert_eq!(out, "the cat sat on the mat");
}
#[test]
fn empty_and_whitespace_segments_are_filtered() {
let out = to_plain_text(&[
seg("hello"),
seg(""),
seg(" "),
seg("\n\t "),
seg("world"),
]);
assert_eq!(out, "hello world");
}
#[test]
fn internal_whitespace_runs_collapse_to_single_space() {
let out = to_plain_text(&[seg("hello\t\t \nworld")]);
assert_eq!(out, "hello world");
}
#[test]
fn join_boundary_does_not_produce_double_spaces() {
// First segment ends with whitespace, next starts with it —
// naive join would produce "foo bar".
let out = to_plain_text(&[seg("foo "), seg(" bar")]);
assert_eq!(out, "foo bar");
}
#[test]
fn non_breaking_space_is_treated_as_whitespace() {
// \u{00A0} is NBSP — char::is_whitespace returns true for it.
// LLM cleanup should not see NBSP leaked in.
let out = to_plain_text(&[seg("hello\u{00A0}world")]);
assert_eq!(out, "hello world");
}
#[test]
fn zero_width_format_chars_strip_entirely() {
// char::is_whitespace returns false for all of these, so the
// default whitespace pass would let them through. They carry
// no natural-language content — stripping them saves LLM
// tokens without changing meaning.
let cases = [
("hello\u{200B}world", "helloworld"), // ZERO WIDTH SPACE
("hello\u{200C}world", "helloworld"), // ZWNJ
("hello\u{200D}world", "helloworld"), // ZWJ
("hello\u{2060}world", "helloworld"), // WORD JOINER
("hello\u{FEFF}world", "helloworld"), // ZWNBSP / BOM
];
for (input, expected) in cases {
let out = to_plain_text(&[seg(input)]);
assert_eq!(out, expected, "input {input:?} should strip to {expected:?}");
}
}
#[test]
fn zero_width_chars_do_not_break_adjacent_whitespace_collapsing() {
// "hello \u{FEFF} world" — the zero-width char between two
// spaces should strip, leaving a single collapsed space.
let out = to_plain_text(&[seg("hello \u{FEFF} world")]);
assert_eq!(out, "hello world");
}
#[test]
fn leading_bom_is_stripped() {
// BOM at start of segment — common artifact when Whisper
// consumes a file whose encoding pass inserted one.
let out = to_plain_text(&[seg("\u{FEFF}hello world")]);
assert_eq!(out, "hello world");
}
#[test]
fn newlines_inside_segments_collapse() {
let out = to_plain_text(&[seg("line one\nline two\n\nline three")]);
assert_eq!(out, "line one line two line three");
}
#[test]
fn idempotent_on_already_normalised_text() {
// If the pipeline ever calls us twice, the second call must
// not mangle the result.
let once = to_plain_text(&[seg("hello world"), seg("foo bar")]);
let twice = to_plain_text(&[seg(&once)]);
assert_eq!(once, twice);
}
#[test]
fn only_empty_segments_yields_empty_string() {
let out = to_plain_text(&[seg(""), seg(" "), seg("\t")]);
assert_eq!(out, "");
}
#[test]
fn no_panic_on_pathological_whitespace_runs() {
// A segment that is 10k spaces long normalises in linear time
// without panicking on capacity guesses.
let big_spaces = " ".repeat(10_000);
let out = to_plain_text(&[seg(&format!("a{big_spaces}b"))]);
assert_eq!(out, "a b");
}
}

View File

@@ -3,6 +3,7 @@ use std::path::Path;
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::DecoderOptions;
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
@@ -13,6 +14,12 @@ use kon_core::types::AudioSamples;
/// Decode an audio file to mono f32 PCM samples.
/// Supports all formats symphonia handles: mp3, aac, flac, wav, ogg, etc.
///
/// Any read- or decode-side error is propagated as `KonError::AudioDecodeFailed`.
/// A previous implementation `break`ed out of the packet loop on any read
/// error and skipped per-packet decode errors, so a truncated or corrupt
/// input silently returned `Ok` with whatever had decoded before the
/// failure — flagged by the 2026-04-22 review (RB-09).
pub fn decode_audio_file(path: &Path) -> Result<AudioSamples> {
let file = File::open(path)
.map_err(|e| KonError::AudioDecodeFailed(format!("Cannot open file: {e}")))?;
@@ -23,9 +30,16 @@ pub fn decode_audio_file(path: &Path) -> Result<AudioSamples> {
hint.with_extension(ext);
}
decode_media_stream(mss, &hint)
}
/// Decode from an already-constructed `MediaSourceStream`. Split out so
/// tests can inject a custom `MediaSource` (for example, one that
/// returns a mid-stream I/O error) to verify error propagation.
fn decode_media_stream(mss: MediaSourceStream, hint: &Hint) -> Result<AudioSamples> {
let probed = symphonia::default::get_probe()
.format(
&hint,
hint,
mss,
&FormatOptions::default(),
&MetadataOptions::default(),
@@ -53,31 +67,35 @@ pub fn decode_audio_file(path: &Path) -> Result<AudioSamples> {
.map_err(|e| KonError::AudioDecodeFailed(format!("Codec error: {e}")))?;
let mut samples: Vec<f32> = Vec::new();
let mut decode_errors = 0u32;
loop {
let packet = match format.next_packet() {
Ok(p) => p,
Err(symphonia::core::errors::Error::IoError(ref e))
Err(SymphoniaError::IoError(ref e))
if e.kind() == std::io::ErrorKind::UnexpectedEof =>
{
// Normal end of stream — symphonia signals EOF via UnexpectedEof.
break;
}
Err(symphonia::core::errors::Error::ResetRequired) => break,
Err(_) => break,
Err(SymphoniaError::ResetRequired) => {
return Err(KonError::AudioDecodeFailed(
"decoder reset required mid-stream — input contains a discontinuity".into(),
));
}
Err(e) => {
return Err(KonError::AudioDecodeFailed(format!(
"packet read failed: {e}"
)));
}
};
if packet.track_id() != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(d) => d,
Err(_) => {
decode_errors += 1;
continue;
}
};
let decoded = decoder
.decode(&packet)
.map_err(|e| KonError::AudioDecodeFailed(format!("packet decode failed: {e}")))?;
let spec = *decoded.spec();
let channels = spec.channels.count();
@@ -96,13 +114,118 @@ pub fn decode_audio_file(path: &Path) -> Result<AudioSamples> {
}
if samples.is_empty() {
if decode_errors > 0 {
return Err(KonError::AudioDecodeFailed(format!(
"No audio decoded ({decode_errors} packets failed — file may be corrupt)"
)));
}
return Err(KonError::AudioDecodeFailed("No audio data decoded".into()));
}
Ok(AudioSamples::new(samples, sample_rate, 1))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wav::write_wav;
use std::io::{Cursor, Read, Seek, SeekFrom};
use symphonia::core::io::MediaSource;
fn temp_path(name: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
p.push(name);
let _ = std::fs::remove_file(&p);
p
}
fn valid_wav_bytes(sample_count: usize) -> Vec<u8> {
let path = temp_path("kon_decode_tmp_for_bytes.wav");
let samples: Vec<f32> = (0..sample_count).map(|i| (i as f32) / 1000.0).collect();
let audio = AudioSamples::mono_16khz(samples);
write_wav(&path, &audio).unwrap();
let bytes = std::fs::read(&path).unwrap();
std::fs::remove_file(&path).ok();
bytes
}
/// A `MediaSource` that wraps a byte buffer and returns an injected
/// I/O error once more than `fail_after_bytes` total bytes have been
/// returned successfully. Simulates real-world disk or network read
/// failure mid-stream.
struct FlakyCursor {
inner: Cursor<Vec<u8>>,
fail_after_bytes: u64,
bytes_read: u64,
}
impl Read for FlakyCursor {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.bytes_read >= self.fail_after_bytes {
return Err(std::io::Error::other("injected mid-stream read error"));
}
let n = self.inner.read(buf)?;
self.bytes_read = self.bytes_read.saturating_add(n as u64);
Ok(n)
}
}
impl Seek for FlakyCursor {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
self.inner.seek(pos)
}
}
impl MediaSource for FlakyCursor {
fn is_seekable(&self) -> bool {
true
}
fn byte_len(&self) -> Option<u64> {
Some(self.inner.get_ref().len() as u64)
}
}
#[test]
fn decodes_valid_wav_successfully() {
let path = temp_path("kon_decode_valid.wav");
let samples: Vec<f32> = (0..4_000).map(|i| (i as f32) / 1000.0).collect();
write_wav(&path, &AudioSamples::mono_16khz(samples)).unwrap();
let loaded = decode_audio_file(&path).expect("valid WAV must decode");
assert_eq!(loaded.sample_rate(), 16_000);
assert!(!loaded.samples().is_empty());
std::fs::remove_file(&path).ok();
}
#[test]
fn missing_file_surfaces_error() {
let path = temp_path("kon_decode_missing.wav");
let result = decode_audio_file(&path);
assert!(result.is_err(), "missing file must error, got: {result:?}");
}
// RB-09 regression: once probe has succeeded, any mid-stream I/O
// error must surface as `Err(AudioDecodeFailed)` rather than being
// silently swallowed and returning whatever was decoded so far.
//
// Pre-fix behaviour: the packet loop had `Err(_) => break`, so an
// I/O error during `format.next_packet()` dropped out of the loop
// and the function returned `Ok` with partial samples.
#[test]
fn mid_stream_io_error_propagates_instead_of_returning_partial_audio() {
let bytes = valid_wav_bytes(16_000);
// Fail after ~1 KiB — probe has seen the RIFF/WAVE header by then,
// so probing succeeds. The packet loop hits our injected error
// before the stream reaches its natural EOF.
let flaky = FlakyCursor {
inner: Cursor::new(bytes),
fail_after_bytes: 1024,
bytes_read: 0,
};
let mss = MediaSourceStream::new(Box::new(flaky), Default::default());
let mut hint = Hint::new();
hint.with_extension("wav");
let result = decode_media_stream(mss, &hint);
assert!(
result.is_err(),
"mid-stream I/O error must surface, got: {result:?}"
);
}
}

View File

@@ -12,4 +12,4 @@ pub use decode::decode_audio_file;
pub use resample::resample_to_16khz;
pub use streaming_resample::StreamingResampler;
pub use vad::SpeechDetector;
pub use wav::{read_wav, write_wav};
pub use wav::{read_wav, write_wav, WavWriter};

View File

@@ -1,8 +1,101 @@
use std::io::BufWriter;
use std::path::Path;
use kon_core::error::{KonError, Result};
use kon_core::types::AudioSamples;
/// Append-friendly WAV writer for long-running captures.
///
/// The in-memory `Vec<f32>` used by `run_live_session` to persist audio
/// on session end (brief item #19) has three failure modes: (a) a crash
/// during transcription takes the recording with it; (b) RAM bloat at
/// long session lengths; (c) an OOM kills the capture loop. `WavWriter`
/// replaces that pattern with an on-disk writer that periodically
/// flushes the WAV header so the file on disk is a valid, playable WAV
/// at any point the process is interrupted.
///
/// The writer samples at the rate / channel count supplied at
/// construction; callers read those from
/// `LocalEngine::capabilities()` (brief item #13 wiring) rather than
/// hardcoding 16 kHz / mono.
pub struct WavWriter {
inner: hound::WavWriter<BufWriter<std::fs::File>>,
samples_since_flush: usize,
flush_every: usize,
}
impl WavWriter {
/// Sample count between automatic header flushes. Flushing costs
/// two seeks per call; 8000 samples at 16 kHz = 500 ms, so the
/// worst-case "last half second is lost on crash" bound holds.
const DEFAULT_FLUSH_EVERY_SAMPLES: usize = 8_000;
/// Create a new WAV file at `path`, truncating any previous content.
/// Header reflects zero samples until the first `flush` or
/// `finalize`.
pub fn create(path: &Path, sample_rate: u32, channels: u16) -> Result<Self> {
let spec = hound::WavSpec {
channels,
sample_rate,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
};
let file = std::fs::File::create(path).map_err(KonError::Io)?;
let buffered = BufWriter::new(file);
let inner = hound::WavWriter::new(buffered, spec).map_err(|e| {
KonError::Io(std::io::Error::other(format!("WAV create failed: {e}")))
})?;
Ok(Self {
inner,
samples_since_flush: 0,
flush_every: Self::DEFAULT_FLUSH_EVERY_SAMPLES,
})
}
/// Append f32 samples in `[-1.0, 1.0]`. Samples outside that range
/// are clamped (matching `write_wav`). Automatically flushes the
/// header every `flush_every` samples so the on-disk file stays a
/// valid WAV even if the process is killed between appends.
pub fn append(&mut self, samples: &[f32]) -> Result<()> {
for &sample in samples {
let clamped = sample.clamp(-1.0, 1.0);
let int_sample = (clamped * i16::MAX as f32) as i16;
self.inner.write_sample(int_sample).map_err(|e| {
KonError::Io(std::io::Error::other(format!("WAV write failed: {e}")))
})?;
}
self.samples_since_flush += samples.len();
if self.samples_since_flush >= self.flush_every {
self.flush()?;
}
Ok(())
}
/// Force an immediate header flush. Leaves the file in a valid-WAV
/// state up to the current sample count. Callers do not need to
/// call this explicitly — `append` flushes every
/// `Self::DEFAULT_FLUSH_EVERY_SAMPLES` — but may do so at natural
/// boundaries (end-of-utterance, UI events) for tighter recovery.
pub fn flush(&mut self) -> Result<()> {
self.inner.flush().map_err(|e| {
KonError::Io(std::io::Error::other(format!("WAV flush failed: {e}")))
})?;
self.samples_since_flush = 0;
Ok(())
}
/// Finalise the WAV: writes the terminal header state and closes
/// the file. Call on clean session end. A dropped-without-finalize
/// writer leaves a playable file up to the last flush; callers
/// that care about the unflushed tail should always finalise.
pub fn finalize(self) -> Result<()> {
self.inner.finalize().map_err(|e| {
KonError::Io(std::io::Error::other(format!("WAV finalize failed: {e}")))
})?;
Ok(())
}
}
/// Write f32 PCM samples to a 16-bit WAV file.
pub fn write_wav(path: &Path, audio: &AudioSamples) -> Result<()> {
let spec = hound::WavSpec {
@@ -30,7 +123,13 @@ pub fn write_wav(path: &Path, audio: &AudioSamples) -> Result<()> {
Ok(())
}
/// Read a WAV file to f32 PCM AudioSamples.
/// Read a WAV file to f32 PCM `AudioSamples`.
///
/// Any per-sample decode error is surfaced as `KonError::AudioDecodeFailed`
/// rather than silently dropped. A previous implementation used
/// `filter_map(|s| s.ok())`, so a truncated or corrupt payload returned
/// a short, silently-partial `AudioSamples` — callers got `Ok` while
/// losing audio (flagged by the 2026-04-22 review).
pub fn read_wav(path: &Path) -> Result<AudioSamples> {
let reader = hound::WavReader::open(path)
.map_err(|e| KonError::AudioDecodeFailed(format!("WAV open failed: {e}")))?;
@@ -38,17 +137,27 @@ pub fn read_wav(path: &Path) -> Result<AudioSamples> {
let spec = reader.spec();
let sample_rate = spec.sample_rate;
let channels = spec.channels;
let bits_per_sample = spec.bits_per_sample;
let samples: Vec<f32> = match spec.sample_format {
hound::SampleFormat::Int => reader
.into_samples::<i32>()
.filter_map(|s| s.ok())
.map(|s| s as f32 / (1 << (spec.bits_per_sample - 1)) as f32)
.collect(),
.map(|sample| {
sample
.map(|s| s as f32 / (1 << (bits_per_sample - 1)) as f32)
.map_err(|e| {
KonError::AudioDecodeFailed(format!("WAV sample decode failed: {e}"))
})
})
.collect::<Result<Vec<f32>>>()?,
hound::SampleFormat::Float => reader
.into_samples::<f32>()
.filter_map(|s| s.ok())
.collect(),
.map(|sample| {
sample.map_err(|e| {
KonError::AudioDecodeFailed(format!("WAV sample decode failed: {e}"))
})
})
.collect::<Result<Vec<f32>>>()?,
};
Ok(AudioSamples::new(samples, sample_rate, channels))
@@ -58,6 +167,103 @@ pub fn read_wav(path: &Path) -> Result<AudioSamples> {
mod tests {
use super::*;
#[test]
fn wav_writer_survives_crash() {
// Property under test: a `WavWriter` that has been flushed but
// never finalised leaves a valid, readable WAV on disk. This
// is the crash-safety guarantee — if the kon process aborts
// mid-session, the on-disk file up to the last flush is
// recoverable.
//
// `std::mem::forget` is the canonical way to simulate an
// abort inside a unit test: it skips the Drop impl (which
// would otherwise finalise the hound writer for us) and
// mirrors what happens when the OS reaps the process without
// giving Rust a chance to run destructors.
let temp_dir = std::env::temp_dir();
let path = temp_dir.join("kon_test_wav_writer_survives_crash.wav");
let _ = std::fs::remove_file(&path);
let mut writer = WavWriter::create(&path, 16_000, 1).unwrap();
let flushed_samples = vec![0.1_f32; 16_000]; // 1s
writer.append(&flushed_samples).unwrap();
writer.flush().unwrap();
// Post-flush, append another second that will NOT be reflected
// in the header if the writer dies before the next flush.
let unflushed_tail = vec![0.2_f32; 16_000];
writer.append(&unflushed_tail).unwrap();
// Abort — Drop does not run, the hound finaliser is skipped.
std::mem::forget(writer);
let loaded = read_wav(&path).unwrap();
assert_eq!(loaded.sample_rate(), 16_000);
assert!(
loaded.samples().len() >= 16_000,
"expected at least the flushed 16000 samples, got {}",
loaded.samples().len()
);
// The flushed portion is readable and approximately correct.
for s in &loaded.samples()[..16_000] {
assert!(
(s - 0.1).abs() < 0.01,
"flushed sample {s} deviates from 0.1 beyond 16-bit quantisation slack",
);
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn wav_writer_append_then_finalize_roundtrips() {
let temp_dir = std::env::temp_dir();
let path = temp_dir.join("kon_test_wav_writer_finalize.wav");
let _ = std::fs::remove_file(&path);
let mut writer = WavWriter::create(&path, 16_000, 1).unwrap();
writer.append(&vec![0.0_f32; 8_000]).unwrap();
writer.append(&vec![0.5_f32; 8_000]).unwrap();
writer.finalize().unwrap();
let loaded = read_wav(&path).unwrap();
assert_eq!(loaded.sample_rate(), 16_000);
assert_eq!(loaded.samples().len(), 16_000);
let _ = std::fs::remove_file(&path);
}
#[test]
fn read_wav_surfaces_truncated_sample_stream_errors() {
// Regression for the 2026-04-22 review: filter_map(|s| s.ok())
// previously swallowed decode errors on corrupt input, so a
// truncated WAV returned Ok with a short samples vec. The
// new code must propagate the error.
let temp_dir = std::env::temp_dir();
let path = temp_dir.join("kon_test_truncated_wav.wav");
let _ = std::fs::remove_file(&path);
// Write 100 samples (200 bytes at 16-bit).
let original =
AudioSamples::mono_16khz((0..100).map(|i| (i as f32) / 100.0).collect());
write_wav(&path, &original).unwrap();
// Drop the last 10 bytes — 5 samples' worth. hound's iterator
// should surface an UnexpectedEof on the final read once its
// internal data-chunk accounting runs out of bytes.
let content = std::fs::read(&path).unwrap();
let truncated = &content[..content.len() - 10];
std::fs::write(&path, truncated).unwrap();
let result = read_wav(&path);
assert!(
result.is_err(),
"truncated WAV must surface an AudioDecodeFailed error, got: {result:?}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn wav_roundtrip() {
let temp_dir = std::env::temp_dir();

View File

@@ -3,12 +3,11 @@ pub mod error;
pub mod hardware;
pub mod model_registry;
pub mod process_watch;
pub mod providers;
pub mod recommendation;
pub mod types;
pub use error::{KonError, Result};
pub use types::{
AudioSamples, DownloadProgress, EngineName, Megabytes, ModelId, Segment, Transcript,
TranscriptMetadata, TranscriptionOptions,
TranscriptionOptions,
};

View File

@@ -1,40 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::error::Result;
use crate::types::{AudioSamples, EngineName, Transcript, TranscriptionOptions};
/// Any speech-to-text engine implements this trait.
/// Base types know nothing about their derivatives.
#[async_trait]
pub trait SpeechToText: Send + Sync {
async fn transcribe(
&self,
audio: AudioSamples,
options: &TranscriptionOptions,
) -> Result<Transcript>;
fn name(&self) -> &EngineName;
fn is_available(&self) -> bool;
}
/// Any text post-processor implements this trait.
#[async_trait]
pub trait TextProcessor: Send + Sync {
async fn process(&self, text: &str, instruction: &str) -> Result<String>;
fn name(&self) -> &EngineName;
fn is_available(&self) -> bool;
}
/// Holds the active provider instances. Constructed at startup,
/// rebuilt when user changes provider in settings.
// TODO: Wire into Tauri app state once multi-engine switching is implemented.
#[allow(dead_code)]
pub struct ProviderRegistry {
pub stt: Arc<dyn SpeechToText>,
pub text: Option<Arc<dyn TextProcessor>>,
}

View File

@@ -166,23 +166,6 @@ pub struct TranscriptionOptions {
pub initial_prompt: Option<String>,
}
/// Full provenance metadata for a transcript.
/// Captures everything needed to reproduce the transcription.
// TODO: Attach to Transcript once the store layer persists transcription provenance.
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TranscriptMetadata {
pub engine: String,
pub model_id: ModelId,
pub inference_ms: u64,
pub sample_rate: u32,
pub audio_channels: u16,
pub format_mode: String,
pub remove_fillers: bool,
pub british_english: bool,
pub anti_hallucination: bool,
}
/// Progress update during model download.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadProgress {

View File

@@ -13,7 +13,7 @@ use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use evdev::{Device, InputEventKind, Key};
use evdev::{AttributeSetRef, Device, InputEventKind, Key};
use notify::{recommended_watcher, EventKind, RecursiveMode, Watcher};
use tokio::sync::{mpsc, watch, Mutex};
@@ -225,6 +225,11 @@ async fn try_attach_device(
return true;
}
let Some(combo) = hotkey_rx.borrow().clone() else {
// Listener is unconfigured or shutting down.
return false;
};
let device = match Device::open(path) {
Ok(d) => d,
Err(e) => {
@@ -233,14 +238,7 @@ async fn try_attach_device(
}
};
// Check if this device has the keys we need
let supported = device.supported_keys();
let has_keys = supported.map_or(false, |keys| {
// Must support at least some keyboard keys
keys.contains(Key::KEY_A) || keys.contains(Key::KEY_R)
});
if !has_keys {
if !device_supports_combo(device.supported_keys(), &combo) {
return false;
}
@@ -347,3 +345,69 @@ fn is_event_device(path: &Path) -> bool {
.and_then(|n| n.to_str())
.map_or(false, |n| n.starts_with("event"))
}
/// Return true when the device's reported key set includes the combo's
/// configured trigger key. A device that reports no keys at all (for
/// example a mouse whose `EV_KEY` capability is buttons only) is rejected.
fn device_supports_combo(
supported: Option<&AttributeSetRef<Key>>,
combo: &HotkeyCombo,
) -> bool {
supported.map_or(false, |keys| keys.contains(Key::new(combo.key_code)))
}
#[cfg(test)]
mod tests {
use super::*;
use evdev::AttributeSet;
fn combo_for(key_code: u16) -> HotkeyCombo {
HotkeyCombo {
ctrl: false,
shift: false,
alt: false,
super_key: false,
key_code,
label: "test".to_string(),
}
}
const KEY_D: u16 = 32;
#[test]
fn attaches_when_device_supports_configured_trigger() {
let mut keys = AttributeSet::<Key>::new();
keys.insert(Key::KEY_D);
assert!(device_supports_combo(Some(&keys), &combo_for(KEY_D)));
}
#[test]
fn rejects_when_device_lacks_configured_trigger() {
let mut keys = AttributeSet::<Key>::new();
keys.insert(Key::KEY_A);
assert!(!device_supports_combo(Some(&keys), &combo_for(KEY_D)));
}
#[test]
fn rejects_when_device_reports_no_keys() {
assert!(!device_supports_combo(None, &combo_for(KEY_D)));
}
// Regression for RB-12: the original filter hard-coded KEY_A || KEY_R
// and would drop a keyboard bound to any other trigger — for example
// a user's Ctrl+Shift+D binding on a keyboard that (hypothetically)
// reports only KEY_D — even though the device clearly supports it.
#[test]
fn attaches_for_non_a_non_r_trigger() {
let mut keys = AttributeSet::<Key>::new();
keys.insert(Key::KEY_D);
assert!(device_supports_combo(Some(&keys), &combo_for(KEY_D)));
// And conversely, a device that only supports KEY_R is correctly
// rejected when the binding is KEY_D — the old implementation
// would have incorrectly attached.
let mut keys = AttributeSet::<Key>::new();
keys.insert(Key::KEY_R);
assert!(!device_supports_combo(Some(&keys), &combo_for(KEY_D)));
}
}

View File

@@ -191,7 +191,19 @@ async fn list_transcripts_tool(pool: &SqlitePool, args: Value) -> Result<Value,
#[serde(default)]
limit: Option<i64>,
}
let args: Args = serde_json::from_value(args).unwrap_or_default();
// The `arguments` field in CallParams defaults to `Value::Null`
// when a client omits it entirely. `serde_json::from_value` does
// not accept Null as an empty object, so we short-circuit that
// case before deserialising — a missing `arguments` still falls
// back to defaults (the common case for list_transcripts), while
// a genuinely malformed payload returns -32602 per the Invalid
// arguments contract the other handlers use.
let args: Args = if args.is_null() {
Args::default()
} else {
serde_json::from_value(args)
.map_err(|e| error(-32602, format!("Invalid arguments: {e}")))?
};
let limit = args.limit.unwrap_or(20).clamp(1, 200);
let rows = kon_storage::list_transcripts(pool, limit)
@@ -335,6 +347,16 @@ fn error_response(id: Value, code: i32, message: String) -> JsonRpcResponse {
}
}
/// Build a JSON-RPC 2.0 Parse Error response (code -32700, id null),
/// for use by the stdio transport when a raw line fails to parse as
/// JSON at all. `handle_message` covers the shape-mismatch case; this
/// helper covers the `serde_json::from_str` failure in `main.rs` so
/// clients receive a well-formed JSON-RPC reply instead of silence
/// (2026-04-22 review MAJOR).
pub fn parse_error_response(detail: &str) -> JsonRpcResponse {
error_response(Value::Null, -32700, format!("Parse error: {detail}"))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -398,6 +420,72 @@ mod tests {
);
}
#[test]
fn parse_error_response_has_jsonrpc_2_0_shape() {
let resp = parse_error_response("expected value at line 1 column 1");
assert_eq!(resp.jsonrpc, "2.0");
assert_eq!(resp.id, Value::Null);
assert!(resp.result.is_none());
let err = resp.error.expect("parse_error_response must carry an error");
assert_eq!(err.code, -32700);
assert!(err.message.contains("Parse error"));
assert!(err.message.contains("expected value"));
}
#[tokio::test]
async fn list_transcripts_accepts_omitted_arguments() {
// Regression for the review-of-review: tools/call requests
// that omit `arguments` arrive with `Value::Null`. The
// malformed-params fix must not reject those — it is the
// common shape for an empty call, equivalent to defaults.
let request = json!({
"jsonrpc": "2.0",
"id": 98,
"method": "tools/call",
"params": {
"name": "list_transcripts",
// `arguments` omitted
},
});
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
kon_storage::migrations::run_migrations(&pool).await.unwrap();
let response = handle_message(&pool, request).await.expect("has response");
assert!(
response.error.is_none(),
"omitted arguments must not error, got: {:?}",
response.error
);
assert!(response.result.is_some());
}
#[tokio::test]
async fn list_transcripts_rejects_malformed_params_with_invalid_arguments() {
// Regression for the 2026-04-22 review MAJOR: previously the
// handler did `from_value(args).unwrap_or_default()`, so
// `{"limit": "not-a-number"}` silently became `limit = 20`.
// Every other handler returns -32602 on shape mismatch; this
// one must now do the same.
let request = json!({
"jsonrpc": "2.0",
"id": 99,
"method": "tools/call",
"params": {
"name": "list_transcripts",
"arguments": { "limit": "twenty" },
},
});
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
let response = handle_message(&pool, request).await.expect("has response");
assert!(response.result.is_none());
let err = response.error.expect("expected error");
assert_eq!(err.code, -32602, "invalid arguments must surface as -32602");
assert!(err.message.contains("Invalid arguments"));
}
#[tokio::test]
async fn unknown_method_returns_method_not_found_error() {
let request = json!({

View File

@@ -23,18 +23,22 @@ async fn main() -> anyhow::Result<()> {
continue;
}
let raw: serde_json::Value = match serde_json::from_str(trimmed) {
Ok(value) => value,
let response = match serde_json::from_str::<serde_json::Value>(trimmed) {
Ok(raw) => match kon_mcp::handle_message(&pool, raw).await {
Some(response) => response,
None => continue, // notification — no reply
},
Err(err) => {
eprintln!("[kon-mcp] ignoring malformed line: {err}");
continue;
// Per JSON-RPC 2.0 §5.1: a Parse Error responds with
// code -32700 and id null. Previously this branch
// logged and continued, dropping the response —
// clients saw silence instead of a structured error
// (2026-04-22 review MAJOR).
eprintln!("[kon-mcp] parse error: {err}");
kon_mcp::parse_error_response(&err.to_string())
}
};
let Some(response) = kon_mcp::handle_message(&pool, raw).await else {
continue; // notification — no reply
};
let payload = serde_json::to_string(&response)?;
stdout.write_all(payload.as_bytes()).await?;
stdout.write_all(b"\n").await?;

View File

@@ -441,11 +441,42 @@ pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> {
}
pub async fn uncomplete_task(pool: &SqlitePool, id: &str) -> Result<()> {
let mut tx = pool
.begin()
.await
.map_err(|e| KonError::StorageError(format!("Begin transaction failed: {e}")))?;
sqlx::query("UPDATE tasks SET done = 0, done_at = NULL WHERE id = ?")
.bind(id)
.execute(pool)
.execute(&mut *tx)
.await
.map_err(|e| KonError::StorageError(format!("Uncomplete task failed: {e}")))?;
// Mirror the auto-complete invariant from
// `complete_subtask_and_check_parent`: a parent task is done iff
// every child is done. If the child we just reopened had a done
// parent, reopen the parent too so state stays consistent
// (2026-04-22 review MAJOR). No-op for top-level tasks.
let parent_id: Option<String> =
sqlx::query_scalar("SELECT parent_task_id FROM tasks WHERE id = ?")
.bind(id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| KonError::StorageError(format!("Get parent_task_id failed: {e}")))?
.flatten();
if let Some(pid) = parent_id {
sqlx::query("UPDATE tasks SET done = 0, done_at = NULL WHERE id = ? AND done = 1")
.bind(&pid)
.execute(&mut *tx)
.await
.map_err(|e| KonError::StorageError(format!("Reopen parent failed: {e}")))?;
}
tx.commit()
.await
.map_err(|e| KonError::StorageError(format!("Commit transaction failed: {e}")))?;
Ok(())
}
@@ -1005,6 +1036,57 @@ mod tests {
);
}
#[tokio::test]
async fn uncomplete_subtask_reopens_auto_completed_parent() {
// Regression for the 2026-04-22 review MAJOR on
// asymmetric complete / uncomplete semantics: once all
// subtasks completed auto-completes the parent, reopening a
// child must also reopen the parent so "parent is done iff
// every child is done" holds in both directions.
let pool = test_pool().await;
insert_task(&pool, "p1", "Ship release", "inbox", None, None, None)
.await
.unwrap();
insert_subtask(&pool, "s1", "Final test", "p1").await.unwrap();
insert_subtask(&pool, "s2", "Tag release", "p1").await.unwrap();
complete_subtask_and_check_parent(&pool, "s1").await.unwrap();
complete_subtask_and_check_parent(&pool, "s2").await.unwrap();
let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
assert!(parent.done);
uncomplete_task(&pool, "s2").await.unwrap();
let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
assert!(
!parent.done,
"reopening any child must reopen the auto-completed parent"
);
let s2 = get_task_by_id(&pool, "s2").await.unwrap().unwrap();
assert!(!s2.done, "subtask itself must also be reopened");
}
#[tokio::test]
async fn uncomplete_top_level_task_does_not_touch_siblings() {
// Sanity: tasks without a parent must not trigger the parent-
// reopen branch (it should no-op cleanly).
let pool = test_pool().await;
insert_task(&pool, "a", "A", "inbox", None, None, None)
.await
.unwrap();
insert_task(&pool, "b", "B", "inbox", None, None, None)
.await
.unwrap();
complete_task(&pool, "a").await.unwrap();
complete_task(&pool, "b").await.unwrap();
uncomplete_task(&pool, "a").await.unwrap();
let a = get_task_by_id(&pool, "a").await.unwrap().unwrap();
let b = get_task_by_id(&pool, "b").await.unwrap().unwrap();
assert!(!a.done);
assert!(b.done, "sibling must be untouched");
}
#[tokio::test]
async fn update_transcript_meta_happy_path() {
// Task 2.5 — insert a transcript, update starred=true, read it back.

View File

@@ -261,6 +261,30 @@ fn split_statements(sql: &str) -> Vec<String> {
/// Ensure the schema_version table exists and run any pending migrations.
pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
run_migrations_slice(pool, MIGRATIONS).await
}
/// Apply the pending prefix of `migrations`, each inside its own
/// transaction along with the matching `schema_version` row insert.
///
/// Atomicity was added in response to the 2026-04-22 review (RB-02):
/// the previous implementation executed statements individually against
/// the pool and only recorded the new version after all statements had
/// succeeded. A multi-statement migration that failed midway therefore
/// left the schema partially changed but still appearing unapplied —
/// the next startup would replay the migration against a mutated DB
/// and fail in surprising ways.
///
/// Wrapping both the statements and the version record in a single
/// `Transaction` is sufficient for SQLite (DDL participates in
/// transactions there). If a future migration needs an operation that
/// implicitly commits (`VACUUM`, `REINDEX`, `ATTACH`), it must be split
/// out into its own non-transactional migration — reviewer's job to
/// flag.
async fn run_migrations_slice(
pool: &SqlitePool,
migrations: &[(i64, &str, &str)],
) -> Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
@@ -277,27 +301,36 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
.await
.map_err(|e| KonError::StorageError(format!("Schema version query failed: {e}")))?;
for (version, description, sql) in MIGRATIONS {
for (version, description, sql) in migrations {
if *version > current {
log::info!("Running migration {}: {}", version, description);
let statements = split_statements(sql);
let mut tx = pool.begin().await.map_err(|e| {
KonError::StorageError(format!("Migration {} tx begin failed: {e}", version))
})?;
for statement in &statements {
sqlx::query(statement).execute(pool).await.map_err(|e| {
KonError::StorageError(format!("Migration {} failed: {e}", version))
})?;
for statement in split_statements(sql) {
sqlx::query(&statement)
.execute(&mut *tx)
.await
.map_err(|e| {
KonError::StorageError(format!("Migration {} failed: {e}", version))
})?;
}
sqlx::query("INSERT INTO schema_version (version, description) VALUES (?, ?)")
.bind(version)
.bind(description)
.execute(pool)
.execute(&mut *tx)
.await
.map_err(|e| {
KonError::StorageError(format!("Migration version record failed: {e}"))
})?;
tx.commit().await.map_err(|e| {
KonError::StorageError(format!("Migration {} commit failed: {e}", version))
})?;
log::info!("Migration {} complete", version);
}
}
@@ -463,47 +496,15 @@ mod tests {
}
/// Test-only helper: run migrations only up to (and including) `target_version`.
/// Mirrors `run_migrations` but stops early — used by the v6 upgrade-path test
/// to seed a v5 schema with dictionary rows before applying v6.
/// Used by the v6 upgrade-path test to seed a v5 schema with
/// dictionary rows before applying v6.
async fn run_migrations_up_to(pool: &SqlitePool, target_version: i64) -> Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
description TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
)
.execute(pool)
.await
.map_err(|e| {
KonError::StorageError(format!("Schema version table creation failed: {e}"))
})?;
let current: i64 =
sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
.fetch_one(pool)
.await
.map_err(|e| KonError::StorageError(format!("Schema version query failed: {e}")))?;
for (version, description, sql) in MIGRATIONS {
if *version > current && *version <= target_version {
let statements = split_statements(sql);
for statement in &statements {
sqlx::query(statement).execute(pool).await.map_err(|e| {
KonError::StorageError(format!("Migration {} failed: {e}", version))
})?;
}
sqlx::query("INSERT INTO schema_version (version, description) VALUES (?, ?)")
.bind(version)
.bind(description)
.execute(pool)
.await
.map_err(|e| {
KonError::StorageError(format!("Migration version record failed: {e}"))
})?;
}
}
Ok(())
let filtered: Vec<(i64, &str, &str)> = MIGRATIONS
.iter()
.filter(|(v, _, _)| *v <= target_version)
.copied()
.collect();
run_migrations_slice(pool, &filtered).await
}
#[tokio::test]
@@ -632,4 +633,63 @@ mod tests {
let err = result.unwrap_err().to_string().to_lowercase();
assert!(err.contains("no such table"), "got: {err}");
}
// RB-02 regression: a multi-statement migration that fails part-way
// through must leave no trace on disk — the transaction rolls back
// both the partial schema change and (implicitly) the `schema_version`
// row that the pre-fix implementation would have recorded after
// statement-level success.
//
// The poisoned migration below first creates `poison_marker`
// (syntactically valid, would succeed against any SQLite) and then
// runs a guaranteed-invalid function call. Under the new atomic
// implementation, neither `poison_marker` nor the v9 row should
// survive the failed call.
#[tokio::test]
async fn multi_statement_migration_rolls_back_on_failure() {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("pool");
run_migrations(&pool).await.expect("baseline migrate");
const POISON: &[(i64, &str, &str)] = &[(
9,
"rb-02 atomicity poison",
r#"
CREATE TABLE poison_marker (id INTEGER PRIMARY KEY);
SELECT this_function_does_not_exist();
"#,
)];
let result = run_migrations_slice(&pool, POISON).await;
assert!(
result.is_err(),
"poisoned migration must return Err, got: {result:?}"
);
// `poison_marker` must not be on disk — transaction rolled back.
let marker: std::result::Result<i64, sqlx::Error> =
sqlx::query_scalar("SELECT COUNT(*) FROM poison_marker")
.fetch_one(&pool)
.await;
assert!(
marker.is_err(),
"poison_marker must not exist; got: {marker:?}"
);
// `schema_version` must not include v9 — version insert is part
// of the same transaction that rolled back.
let max: i64 =
sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
.fetch_one(&pool)
.await
.expect("read schema_version");
assert_eq!(
max, 8,
"schema_version must not advance past the failed migration"
);
}
}

View File

@@ -5,6 +5,15 @@ edition = "2021"
description = "Speech-to-text engine wrappers, model management, and inference concurrency for Kon"
build = "build.rs"
[features]
# Whisper backend (direct whisper-rs, vulkan-accelerated). Default on —
# gating it exists so a future Windows non-AVX2 build, or a cloud-only
# ASR configuration, can drop whisper-rs-sys entirely per brief item
# #13. Disabling this feature also drops the WhisperRsBackend module
# and the load_whisper entry point.
default = ["whisper"]
whisper = ["dep:whisper-rs", "dep:num_cpus"]
[dependencies]
kon-core = { path = "../core" }
@@ -21,10 +30,16 @@ futures-util = "0.3"
# Download integrity verification
sha2 = "0.10"
whisper-rs = { version = "0.16", default-features = false, features = ["vulkan"] }
# Gated behind the `whisper` feature (see [features] above).
whisper-rs = { version = "0.16", default-features = false, features = ["vulkan"], optional = true }
# Direct whisper-rs backend (WhisperRsBackend): thread pool sizing + typed errors.
num_cpus = "1"
# Direct whisper-rs backend (WhisperRsBackend): thread pool sizing.
# Gated alongside whisper-rs since no other code in this crate needs it.
num_cpus = { version = "1", optional = true }
# Typed error enum used by WhisperRsBackend + elsewhere. Kept
# unconditional because it is a derive-macro crate with negligible
# build cost.
thiserror = "2"
# Structured logging at backend boundaries (observability for initial_prompt flow).

View File

@@ -1,9 +1,19 @@
pub mod concurrency;
pub mod local_engine;
pub mod model_manager;
pub mod streaming;
pub mod transcriber;
#[cfg(feature = "whisper")]
pub mod whisper_rs_backend;
pub use concurrency::run_inference;
pub use local_engine::{load_parakeet, load_whisper, LocalEngine, SpeechBackend, TimedTranscript};
pub use local_engine::{load_parakeet, LocalEngine, SpeechModelAdapter, TimedTranscript};
#[cfg(feature = "whisper")]
pub use local_engine::load_whisper;
pub use model_manager::{download, is_downloaded, list_downloaded, model_dir, models_dir};
pub use streaming::{
sample_index_for_seconds, trim_buffer_to_commit_point, CommitDecision, CommitPolicy,
LocalAgreement, RmsVadChunker, Token, VadChunk, VadChunker,
};
pub use transcriber::{Transcriber, TranscriberCapabilities};
pub use transcribe_rs::SpeechModel;

View File

@@ -9,6 +9,8 @@ use kon_core::types::{
AudioSamples, EngineName, ModelId, Segment, Transcript, TranscriptionOptions,
};
use crate::transcriber::{Transcriber, TranscriberCapabilities};
#[cfg(feature = "whisper")]
use crate::whisper_rs_backend::WhisperRsBackend;
/// Result of a timed transcription: transcript + inference duration.
@@ -17,22 +19,54 @@ pub struct TimedTranscript {
pub inference_ms: u64,
}
/// Public discriminator selected by the loaders (`load_parakeet`, `load_whisper`)
/// and passed to `LocalEngine::load`. `src-tauri::commands::models` names this
/// type as the return of `load_model_from_disk`, so it must be `pub`.
pub enum SpeechBackend {
/// transcribe-rs-owned model. Used for Parakeet ONNX (wrapped in
/// ParakeetWordGranularity for word-level timestamps).
Adapter(Box<dyn SpeechModel + Send>),
/// Direct whisper-rs. The only path that actually forwards `initial_prompt`.
WhisperRs(WhisperRsBackend),
/// Adapts any `transcribe-rs` `SpeechModel` into the `Transcriber`
/// trait. Today this is only used for Parakeet (ONNX), but the adapter
/// is the path any future transcribe-rs-backed engine plugs through —
/// Moonshine, fine-tuned Parakeet variants, etc.
pub struct SpeechModelAdapter(pub Box<dyn SpeechModel + Send>);
impl Transcriber for SpeechModelAdapter {
fn capabilities(&self) -> TranscriberCapabilities {
TranscriberCapabilities {
sample_rate: kon_core::constants::WHISPER_SAMPLE_RATE,
channels: 1,
supports_initial_prompt: false,
}
}
fn transcribe_sync(
&mut self,
samples: &[f32],
options: &TranscriptionOptions,
) -> Result<Vec<Segment>> {
let opts = TranscribeOptions {
language: options.language.clone(),
translate: false,
leading_silence_ms: None,
trailing_silence_ms: None,
};
let result: TranscriptionResult = self
.0
.transcribe(samples, &opts)
.map_err(|e| KonError::TranscriptionFailed(e.to_string()))?;
Ok(result
.segments
.unwrap_or_default()
.into_iter()
.map(|s| Segment {
start: s.start as f64,
end: s.end as f64,
text: s.text,
})
.collect())
}
}
/// Wraps any transcribe-rs engine in Kon's SpeechToText trait.
/// Encapsulates threading: inference always runs on a blocking thread.
/// The rest of the app never imports transcribe-rs directly.
/// Owns the currently-loaded speech backend and serialises inference
/// against model-swap operations via a `Mutex`. All transcription goes
/// through this struct; no caller ever holds a raw `Box<dyn Transcriber>`.
pub struct LocalEngine {
engine: Mutex<Option<SpeechBackend>>,
engine: Mutex<Option<Box<dyn Transcriber + Send>>>,
engine_name: EngineName,
loaded_model_id: Mutex<Option<ModelId>>,
}
@@ -46,7 +80,7 @@ impl LocalEngine {
}
}
pub fn load(&self, backend: SpeechBackend, model_id: ModelId) {
pub fn load(&self, backend: Box<dyn Transcriber + Send>, model_id: ModelId) {
let mut guard = self.engine.lock().unwrap_or_else(|e| e.into_inner());
*guard = Some(backend);
let mut id_guard = self
@@ -90,6 +124,14 @@ impl LocalEngine {
guard.is_some()
}
/// Capabilities of the currently-loaded backend. Returns `None`
/// when nothing is loaded. Callers (live capture WAV writer, #19)
/// read sample_rate from here.
pub fn capabilities(&self) -> Option<TranscriberCapabilities> {
let guard = self.engine.lock().unwrap_or_else(|e| e.into_inner());
guard.as_ref().map(|b| b.capabilities())
}
/// Run transcription synchronously with timing.
/// Called from within spawn_blocking.
pub fn transcribe_sync(
@@ -101,32 +143,7 @@ impl LocalEngine {
let backend = guard.as_mut().ok_or(KonError::EngineNotLoaded)?;
let start = Instant::now();
let segments: Vec<Segment> = match backend {
SpeechBackend::Adapter(model) => {
let opts = TranscribeOptions {
language: options.language.clone(),
translate: false,
leading_silence_ms: None,
trailing_silence_ms: None,
};
let result: TranscriptionResult = model
.transcribe(audio.samples(), &opts)
.map_err(|e| KonError::TranscriptionFailed(e.to_string()))?;
result
.segments
.unwrap_or_default()
.into_iter()
.map(|s| Segment {
start: s.start as f64,
end: s.end as f64,
text: s.text,
})
.collect()
}
SpeechBackend::WhisperRs(w) => w
.transcribe_sync(audio.samples(), options)
.map_err(|e| KonError::TranscriptionFailed(e.to_string()))?,
};
let segments = backend.transcribe_sync(audio.samples(), options)?;
let inference_ms = start.elapsed().as_millis() as u64;
Ok(TimedTranscript {
@@ -177,20 +194,21 @@ impl transcribe_rs::SpeechModel for ParakeetWordGranularity {
}
/// Load a Parakeet model from a directory path.
pub fn load_parakeet(model_dir: &Path) -> Result<SpeechBackend> {
pub fn load_parakeet(model_dir: &Path) -> Result<Box<dyn Transcriber + Send>> {
use transcribe_rs::onnx::Quantization;
let model = transcribe_rs::onnx::parakeet::ParakeetModel::load(model_dir, &Quantization::Int8)
.map_err(|e| KonError::TranscriptionFailed(format!("Failed to load Parakeet: {e}")))?;
Ok(SpeechBackend::Adapter(Box::new(ParakeetWordGranularity(
model,
Ok(Box::new(SpeechModelAdapter(Box::new(
ParakeetWordGranularity(model),
))))
}
/// Load a Whisper model from a GGML file path via whisper-rs.
pub fn load_whisper(model_path: &Path) -> Result<SpeechBackend> {
#[cfg(feature = "whisper")]
pub fn load_whisper(model_path: &Path) -> Result<Box<dyn Transcriber + Send>> {
let backend = WhisperRsBackend::load(model_path)
.map_err(|e| KonError::TranscriptionFailed(format!("Failed to load Whisper: {e}")))?;
Ok(SpeechBackend::WhisperRs(backend))
Ok(Box::new(backend))
}
#[cfg(test)]
@@ -202,5 +220,6 @@ mod tests {
let engine = LocalEngine::new(EngineName::new("test"));
assert!(!engine.is_loaded());
assert!(engine.loaded_model_id().is_none());
assert!(engine.capabilities().is_none());
}
}

View File

@@ -168,6 +168,12 @@ async fn download_file(
// full file on top of our partial bytes (which would produce a
// corrupt result), restart cleanly. This mirrors the kon-llm
// ResumeUnsupported branch — item #8 of the brief.
//
// For the non-resume path, we still have to validate the status:
// reqwest does not error on 4xx/5xx by default, so without this
// check a 404 or 500 would be streamed into `.part` and renamed
// over the destination as if the download succeeded
// (2026-04-22 review MAJOR).
let actually_resuming = if resuming {
match response.status().as_u16() {
206 => true,
@@ -183,6 +189,13 @@ async fn download_file(
}
}
} else {
if !response.status().is_success() {
return Err(KonError::DownloadFailed(format!(
"download returned HTTP {} for {}",
response.status(),
file.filename
)));
}
false
};
@@ -350,6 +363,49 @@ mod tests {
addr
}
/// A minimal HTTP server that responds with 200 + full body **iff**
/// the request actually carries a `Range` header, and 400 otherwise.
/// This models a mirror / proxy that accepts Range requests but
/// refuses to honour them (returning a fresh full body), which is
/// exactly the ResumeUnsupported branch `download_file` needs to
/// handle. The 400-on-missing-Range behaviour is load-bearing for
/// the test: it turns "client never sent Range" into a download
/// failure, so deleting the resume-detection logic causes the test
/// to fail rather than pass coincidentally through File::create's
/// truncation semantics.
async fn spawn_no_range_server(content: Vec<u8>) -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 2048];
let size = socket.read(&mut buf).await.unwrap();
let request = String::from_utf8_lossy(&buf[..size]).to_lowercase();
let saw_range_header = request
.lines()
.any(|line| line.trim_start().starts_with("range:"));
if !saw_range_header {
let response = "HTTP/1.1 400 Bad Request\r\n\
Content-Length: 0\r\n\r\n";
socket.write_all(response.as_bytes()).await.unwrap();
return;
}
let response = format!(
"HTTP/1.1 200 OK\r\n\
Content-Length: {}\r\n\r\n",
content.len(),
);
socket.write_all(response.as_bytes()).await.unwrap();
socket.write_all(&content).await.unwrap();
});
addr
}
/// ModelFile stores `&'static str` fields, so we leak the strings
/// once per test — tests are one-shot, so the cost is noise.
fn leak(s: String) -> &'static str {
@@ -387,6 +443,99 @@ mod tests {
assert_eq!(sha256_of_file(&dest).unwrap(), expected_sha);
}
#[tokio::test]
async fn download_file_restarts_when_server_ignores_range() {
// Covers the ResumeUnsupported branch documented in `download_file`:
// when a partial `.part` file exists and the server returns 200
// (full body) to our Range request, we must discard the stale
// partial bytes and write the fresh body from offset zero rather
// than appending on top.
let body = b"fresh transcription payload that replaces any stale partial".to_vec();
let addr = spawn_no_range_server(body.clone()).await;
let dir = tempdir().unwrap();
let dest = dir.path().join("fixture.bin");
let part = dest.with_extension("bin.part");
// Pretend a previous attempt downloaded 12 bytes of something
// entirely unrelated. If the client naively appended the 200
// body, the final file would start with these bytes.
std::fs::write(&part, b"STALE_BYTES1").unwrap();
let file = ModelFile {
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
url: leak(format!("http://{addr}/fixture.bin")),
size: kon_core::types::Megabytes(0),
sha256: None,
};
let id = ModelId::new("test-fixture");
download_file(&file, &dest, &id, &|_| ()).await.unwrap();
let bytes = std::fs::read(&dest).unwrap();
assert_eq!(
bytes, body,
"server returned 200 to Range — downloader must discard stale .part and rewrite from scratch"
);
assert!(!part.exists(), ".part → dest rename must run after restart");
}
/// Always returns HTTP 500 with a short error body. Used to verify
/// the non-resume download path validates status codes rather than
/// writing error bodies into `.part` and renaming them over the
/// destination.
async fn spawn_500_server() -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 2048];
let _ = socket.read(&mut buf).await.unwrap();
let body = b"internal error";
let response = format!(
"HTTP/1.1 500 Internal Server Error\r\n\
Content-Length: {}\r\n\r\n",
body.len()
);
socket.write_all(response.as_bytes()).await.unwrap();
socket.write_all(body).await.unwrap();
});
addr
}
#[tokio::test]
async fn download_file_rejects_5xx_on_non_resume_path() {
// Regression for the 2026-04-22 review: reqwest does not
// auto-error on 4xx/5xx, and the non-resume branch previously
// streamed any status' body into `.part` and renamed it over
// the destination.
let addr = spawn_500_server().await;
let dir = tempdir().unwrap();
let dest = dir.path().join("fixture.bin");
let part = dest.with_extension("bin.part");
let file = ModelFile {
filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()),
url: leak(format!("http://{addr}/fixture.bin")),
size: kon_core::types::Megabytes(0),
sha256: None,
};
let id = ModelId::new("test-fixture");
let err = download_file(&file, &dest, &id, &|_| ())
.await
.expect_err("5xx must fail");
let msg = err.to_string();
assert!(
msg.contains("HTTP 500"),
"error should name the HTTP status, got: {msg}"
);
assert!(!dest.exists(), "5xx must not leave a destination file");
assert!(!part.exists(), "5xx must not leave a .part file");
}
#[tokio::test]
async fn download_file_fails_on_sha_mismatch_and_cleans_part_file() {
let body = b"speech-to-text fixture body".to_vec();

View File

@@ -0,0 +1,207 @@
//! Buffer-trim helpers for streaming transcription.
//!
//! Brief item #25: replace the current `OVERLAP_SAMPLES`-based drain
//! in `src-tauri/src/commands/live.rs` with a trim tied to the last
//! commit point emitted by the `CommitPolicy`. This keeps the capture
//! buffer bounded regardless of wall-clock session length (ufal #120 /
//! #102) by guaranteeing that any sample already committed to the
//! transcript is never kept in the working buffer.
//!
//! The helpers here are pure — they don't know about the live session
//! loop. Integration into `live.rs` ships as a follow-up after the
//! LocalAgreement wiring (#24) is dogfooded.
/// Absolute sample index at the end of the given session-relative
/// seconds mark, rounded to the nearest sample. `end_secs` typically
/// comes from `LocalAgreement::last_committed_end_secs()`.
///
/// Guards against non-finite inputs: NaN and ±infinity both return 0
/// ("nothing committed yet"). Without this, Rust's saturating
/// float-to-int cast turns `f64::INFINITY` into `u64::MAX`, which
/// would park the capture buffer origin at an index beyond any
/// reachable sample and trim the entire buffer forever.
pub fn sample_index_for_seconds(end_secs: f64, sample_rate: u32) -> u64 {
if !end_secs.is_finite() || end_secs <= 0.0 {
return 0;
}
(end_secs * sample_rate as f64).round() as u64
}
/// Drain the prefix of `buffer` whose absolute sample indices fall
/// below `commit_sample_index`. `buffer_start_sample` is the absolute
/// index of `buffer[0]` before the trim.
///
/// Returns the new `buffer_start_sample`. If the commit point is
/// before or equal to `buffer_start_sample`, nothing is drained.
/// If the commit point is beyond the current end of the buffer, the
/// whole buffer is drained and the new start is set to the commit
/// index — the buffer is still empty, but its absolute-index origin
/// moves forward so subsequent samples are positioned correctly.
pub fn trim_buffer_to_commit_point(
buffer: &mut Vec<f32>,
buffer_start_sample: u64,
commit_sample_index: u64,
) -> u64 {
if commit_sample_index <= buffer_start_sample {
return buffer_start_sample;
}
let drain_count = (commit_sample_index - buffer_start_sample) as usize;
if drain_count >= buffer.len() {
buffer.clear();
return commit_sample_index;
}
buffer.drain(..drain_count);
buffer_start_sample + drain_count as u64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sample_index_for_seconds_zero_is_zero() {
assert_eq!(sample_index_for_seconds(0.0, 16_000), 0);
}
#[test]
fn sample_index_for_seconds_negative_is_zero() {
// Defensive: end_secs should never be negative, but if it is
// (clock skew in a future f64 source) treat as "nothing
// committed yet" rather than wrapping to a huge u64.
assert_eq!(sample_index_for_seconds(-1.0, 16_000), 0);
}
#[test]
fn sample_index_for_seconds_rejects_nan_and_infinity() {
// Defensive against non-finite inputs: without the is_finite()
// check, Rust's saturating float-to-int cast makes +infinity
// become u64::MAX, which would park the buffer origin beyond
// reach and trim the whole buffer forever.
assert_eq!(sample_index_for_seconds(f64::NAN, 16_000), 0);
assert_eq!(sample_index_for_seconds(f64::INFINITY, 16_000), 0);
assert_eq!(sample_index_for_seconds(f64::NEG_INFINITY, 16_000), 0);
}
#[test]
fn sample_index_for_seconds_rounds_nearest() {
// 0.5 s at 16 kHz = 8000 samples exactly.
assert_eq!(sample_index_for_seconds(0.5, 16_000), 8_000);
// Round-nearest: 0.50003 s × 16 kHz = 8000.48 → 8000.
assert_eq!(sample_index_for_seconds(0.50003, 16_000), 8_000);
// 0.5001 s × 16 kHz = 8001.6 → 8002.
assert_eq!(sample_index_for_seconds(0.5001, 16_000), 8_002);
}
#[test]
fn trim_does_nothing_when_commit_is_before_buffer_start() {
let mut buf = vec![1.0, 2.0, 3.0];
let new_start = trim_buffer_to_commit_point(&mut buf, 1000, 500);
assert_eq!(new_start, 1000);
assert_eq!(buf, vec![1.0, 2.0, 3.0]);
}
#[test]
fn trim_does_nothing_when_commit_equals_buffer_start() {
let mut buf = vec![1.0, 2.0, 3.0];
let new_start = trim_buffer_to_commit_point(&mut buf, 1000, 1000);
assert_eq!(new_start, 1000);
assert_eq!(buf, vec![1.0, 2.0, 3.0]);
}
#[test]
fn trim_drains_prefix_when_commit_is_inside_buffer() {
let mut buf = vec![1.0, 2.0, 3.0, 4.0, 5.0];
// buffer starts at absolute index 100, commit is at 102.
// Drain 2 samples; remaining buffer starts at 102.
let new_start = trim_buffer_to_commit_point(&mut buf, 100, 102);
assert_eq!(new_start, 102);
assert_eq!(buf, vec![3.0, 4.0, 5.0]);
}
#[test]
fn trim_clears_buffer_when_commit_is_at_buffer_end() {
let mut buf = vec![1.0, 2.0, 3.0];
// buffer is [100, 103). commit at 103 means every sample is
// committed — drain all, start moves forward.
let new_start = trim_buffer_to_commit_point(&mut buf, 100, 103);
assert_eq!(new_start, 103);
assert!(buf.is_empty());
}
#[test]
fn trim_clears_buffer_when_commit_is_past_buffer_end() {
let mut buf = vec![1.0, 2.0, 3.0];
// Commit well beyond the buffer — this happens in rare edge
// cases where the committer's notion of time outstrips the
// current buffer (e.g. after a reset). Defensive: drain and
// park the origin at the commit point.
let new_start = trim_buffer_to_commit_point(&mut buf, 100, 200);
assert_eq!(new_start, 200);
assert!(buf.is_empty());
}
#[test]
fn trim_bounds_buffer_over_long_session() {
// Simulate a committer that keeps up with capture: each cycle
// feeds 16_000 samples and commits all but a 200-sample
// tentative tail. Over 100 cycles the buffer must stay near
// that tentative envelope — not accumulate 100 × 16_000 samples
// as it would without the commit-point trim.
//
// The tentative tail stacks by 200 per cycle because each new
// push extends the buffer BEFORE the trim runs against the
// previous cycle's commit point, so the expected bound is
// (tentative_per_cycle + new_push_minus_commit), not just
// tentative_per_cycle.
let mut buf: Vec<f32> = Vec::new();
let mut start: u64 = 0;
let mut total_pushed: u64 = 0;
let tentative_per_cycle: u64 = 200;
for _ in 0..100 {
buf.extend(std::iter::repeat(0.25_f32).take(16_000));
total_pushed += 16_000;
let commit_point = total_pushed - tentative_per_cycle;
start = trim_buffer_to_commit_point(&mut buf, start, commit_point);
}
assert!(
buf.len() as u64 <= 2 * tentative_per_cycle,
"buffer outgrew the commit-bounded envelope: len = {} (bound {})",
buf.len(),
2 * tentative_per_cycle
);
}
#[test]
fn integrates_with_local_agreement_last_committed_end_secs() {
use super::super::commit_policy::{LocalAgreement, Token};
let mut la = LocalAgreement::new(2);
let _ = la.push(vec![Token {
text: "hello".into(),
start_secs: 0.0,
end_secs: 0.5,
}]);
let _ = la.push(vec![
Token {
text: "hello".into(),
start_secs: 0.0,
end_secs: 0.5,
},
Token {
text: "world".into(),
start_secs: 0.5,
end_secs: 1.0,
},
]);
// "hello" is committed, ending at 0.5 s.
let commit_idx = sample_index_for_seconds(la.last_committed_end_secs(), 16_000);
assert_eq!(commit_idx, 8_000);
// Simulate a capture buffer that has received 1.2 s of audio
// starting at t=0.
let mut buf: Vec<f32> = std::iter::repeat(0.1_f32).take(19_200).collect();
let new_start = trim_buffer_to_commit_point(&mut buf, 0, commit_idx);
assert_eq!(new_start, 8_000);
assert_eq!(buf.len(), 19_200 - 8_000);
}
}

View File

@@ -0,0 +1,407 @@
//! LocalAgreement-n commit policy for streaming transcription.
//!
//! Source: ufal/whisper_streaming. Tokens emitted by a streaming ASR
//! pipeline are held as tentative until `n` consecutive passes produce
//! the same prefix. Only the agreed prefix is "committed" — the rest
//! is a tentative tail the UI renders differently (dashed underline
//! per brief item #24, workstream-B contract).
//!
//! This module ships the committer plus a Token type carrying
//! timestamps so brief item #25 (aggressive buffer trim tied to commit
//! points) can compute the absolute sample index of the last
//! committed token and drain the capture buffer up to that point.
//!
//! Integration into `src-tauri/src/commands/live.rs` lands in a
//! separate commit so the tentative/committed partition can be
//! validated against real streaming captures.
use std::collections::VecDeque;
/// A single token (word or sub-segment) emitted by the ASR pipeline.
///
/// Equality on `Token` is text-only — the committer matches tokens
/// across passes by their spelling, since timestamps drift slightly
/// between overlapping Whisper windows. Start and end seconds are
/// absolute (session-relative) so #25 can translate them to sample
/// indices.
#[derive(Debug, Clone)]
pub struct Token {
pub text: String,
pub start_secs: f64,
pub end_secs: f64,
}
impl PartialEq for Token {
fn eq(&self, other: &Self) -> bool {
self.text == other.text
}
}
impl Eq for Token {}
/// Outcome of pushing a new pass through the committer.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CommitDecision {
/// Tokens newly committed by this pass. Empty if no new agreement
/// was reached. Append to the frontend's committed list.
pub newly_committed: Vec<Token>,
/// Tentative tail — tokens past the agreement prefix in the most
/// recent pass. Replaces (not appends to) any previous tentative.
pub tentative: Vec<Token>,
}
/// Commit policy selector. Keeping this as an enum leaves room for
/// future policies (AlignAtt, length-capped, etc.) without a breaking
/// API change.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommitPolicy {
/// LocalAgreement-n: `n` consecutive passes must produce the same
/// prefix before emission. `n = 2` is the ufal default.
LocalAgreement { n: usize },
}
impl Default for CommitPolicy {
fn default() -> Self {
CommitPolicy::LocalAgreement { n: 2 }
}
}
/// Stateful LocalAgreement-n committer.
///
/// Invariants:
/// - `history` holds at most `n` most-recent passes.
/// - `committed_count` counts tokens committed so far; these are
/// always a prefix of every pass in `history`.
/// - `last_committed_end_secs` is 0 when nothing is committed,
/// otherwise the `end_secs` of the most recent committed token.
pub struct LocalAgreement {
n: usize,
history: VecDeque<Vec<Token>>,
committed_count: usize,
last_committed_end_secs: f64,
}
impl LocalAgreement {
pub fn new(n: usize) -> Self {
assert!(n >= 1, "LocalAgreement-n requires n >= 1");
Self {
n,
history: VecDeque::with_capacity(n),
committed_count: 0,
last_committed_end_secs: 0.0,
}
}
pub fn from_policy(policy: CommitPolicy) -> Self {
match policy {
CommitPolicy::LocalAgreement { n } => Self::new(n),
}
}
/// Feed the next pass of transcribed tokens. Returns newly
/// committed tokens and the current tentative tail.
pub fn push(&mut self, pass: Vec<Token>) -> CommitDecision {
self.history.push_back(pass);
while self.history.len() > self.n {
self.history.pop_front();
}
// Can't commit anything until we have n passes in hand.
if self.history.len() < self.n {
let tentative = self
.history
.back()
.cloned()
.unwrap_or_default();
return CommitDecision {
newly_committed: Vec::new(),
tentative,
};
}
let lcp_len = longest_common_prefix_len(&self.history);
// The agreed prefix can only grow — never shrink below what we
// already committed. ufal's invariant: once committed, stay
// committed.
let new_committed = lcp_len.max(self.committed_count);
let latest = self.history.back().expect("history is non-empty here");
// Clamp every slice against `latest.len()` — a later pass can
// legitimately arrive shorter than `committed_count` (Whisper
// re-transcribing an overlapping window with fewer segments,
// or user stopping mid-word while the committer holds a longer
// history). Without the clamp, `latest[committed_count..]`
// panics with an index OOB.
let old_committed = self.committed_count;
let latest_len = latest.len();
let emit_start = old_committed.min(latest_len);
let emit_end = new_committed.min(latest_len);
let newly_committed = if emit_end > emit_start {
latest[emit_start..emit_end].to_vec()
} else {
Vec::new()
};
if let Some(last) = newly_committed.last() {
self.last_committed_end_secs = last.end_secs;
}
// `committed_count` stays at `new_committed` even when the
// latest pass is shorter — the non-shrinkage invariant holds
// relative to what we've already emitted, not to the current
// pass length.
self.committed_count = new_committed;
let tentative_start = new_committed.min(latest_len);
let tentative = latest[tentative_start..].to_vec();
CommitDecision {
newly_committed,
tentative,
}
}
/// End-of-stream: commit anything still tentative in the latest
/// pass and return it. Callers do this when the session closes so
/// the final utterance reaches the transcript.
pub fn flush(&mut self) -> Vec<Token> {
let Some(latest) = self.history.back().cloned() else {
return Vec::new();
};
if latest.len() <= self.committed_count {
return Vec::new();
}
let flushed = latest[self.committed_count..].to_vec();
if let Some(last) = flushed.last() {
self.last_committed_end_secs = last.end_secs;
}
self.committed_count = latest.len();
flushed
}
/// Absolute (session-relative) seconds at the end of the most
/// recently committed token. `0.0` when nothing has committed yet.
/// Brief item #25 will multiply this by the capture sample rate to
/// get the buffer-drain target.
pub fn last_committed_end_secs(&self) -> f64 {
self.last_committed_end_secs
}
/// Drop all state — used after a repetition-detector context
/// reset (#26) so the committer doesn't carry stale history
/// across the reset boundary.
pub fn reset(&mut self) {
self.history.clear();
self.committed_count = 0;
self.last_committed_end_secs = 0.0;
}
}
fn longest_common_prefix_len(passes: &VecDeque<Vec<Token>>) -> usize {
let Some(first) = passes.front() else {
return 0;
};
let shortest = passes.iter().map(|p| p.len()).min().unwrap_or(0);
for i in 0..shortest {
let candidate = &first[i];
for pass in passes.iter().skip(1) {
if pass[i] != *candidate {
return i;
}
}
}
shortest
}
#[cfg(test)]
mod tests {
use super::*;
fn tok(text: &str, start: f64, end: f64) -> Token {
Token {
text: text.into(),
start_secs: start,
end_secs: end,
}
}
#[test]
fn first_pass_is_all_tentative() {
let mut la = LocalAgreement::new(2);
let decision = la.push(vec![tok("hello", 0.0, 0.5), tok("world", 0.5, 1.0)]);
assert!(decision.newly_committed.is_empty());
assert_eq!(decision.tentative.len(), 2);
assert_eq!(la.last_committed_end_secs(), 0.0);
}
#[test]
fn two_matching_passes_commit_common_prefix() {
let mut la = LocalAgreement::new(2);
let _ = la.push(vec![tok("the", 0.0, 0.3), tok("cat", 0.3, 0.6)]);
let decision = la.push(vec![
tok("the", 0.0, 0.3),
tok("cat", 0.3, 0.6),
tok("sat", 0.6, 0.9),
]);
assert_eq!(decision.newly_committed.len(), 2);
assert_eq!(decision.newly_committed[0].text, "the");
assert_eq!(decision.newly_committed[1].text, "cat");
assert_eq!(decision.tentative.len(), 1);
assert_eq!(decision.tentative[0].text, "sat");
assert!((la.last_committed_end_secs() - 0.6).abs() < f64::EPSILON);
}
#[test]
fn divergent_second_pass_commits_nothing() {
let mut la = LocalAgreement::new(2);
let _ = la.push(vec![tok("hello", 0.0, 0.5)]);
let decision = la.push(vec![tok("yellow", 0.0, 0.5)]);
assert!(
decision.newly_committed.is_empty(),
"no common prefix — must not commit"
);
assert_eq!(decision.tentative.len(), 1);
assert_eq!(decision.tentative[0].text, "yellow");
}
#[test]
fn extending_agreement_commits_newly_agreed_tokens() {
let mut la = LocalAgreement::new(2);
let _ = la.push(vec![tok("a", 0.0, 0.1), tok("b", 0.1, 0.2)]);
let _ = la.push(vec![
tok("a", 0.0, 0.1),
tok("b", 0.1, 0.2),
tok("c", 0.2, 0.3),
]);
// Now history has [[a,b], [a,b,c]], committed = 2 (a, b).
let decision = la.push(vec![
tok("a", 0.0, 0.1),
tok("b", 0.1, 0.2),
tok("c", 0.2, 0.3),
tok("d", 0.3, 0.4),
]);
assert_eq!(decision.newly_committed.len(), 1, "c becomes committed");
assert_eq!(decision.newly_committed[0].text, "c");
assert_eq!(decision.tentative.len(), 1);
assert_eq!(decision.tentative[0].text, "d");
}
#[test]
fn tentative_tail_tracks_latest_pass_only() {
let mut la = LocalAgreement::new(2);
let _ = la.push(vec![tok("x", 0.0, 0.1)]);
let _ = la.push(vec![tok("x", 0.0, 0.1), tok("y_guess", 0.1, 0.2)]);
// x is committed, tail is y_guess.
let decision = la.push(vec![tok("x", 0.0, 0.1), tok("y_real", 0.1, 0.2)]);
assert!(decision.newly_committed.is_empty());
assert_eq!(decision.tentative.len(), 1);
assert_eq!(
decision.tentative[0].text, "y_real",
"tentative must reflect the latest pass, not carry stale y_guess"
);
}
#[test]
fn committed_prefix_never_shrinks() {
// Even if a later pass contradicts an earlier commit, the
// committed prefix stays frozen. This is ufal's invariant.
let mut la = LocalAgreement::new(2);
let _ = la.push(vec![tok("foo", 0.0, 0.3)]);
let _ = la.push(vec![tok("foo", 0.0, 0.3), tok("bar", 0.3, 0.6)]);
// "foo" is committed.
assert_eq!(la.committed_count, 1);
let decision = la.push(vec![tok("fop", 0.0, 0.3), tok("baz", 0.3, 0.6)]);
// LCP with previous pass [foo, bar] is 0 — but we already
// committed "foo", so committed_count stays at 1.
assert_eq!(la.committed_count, 1);
assert!(decision.newly_committed.is_empty());
}
#[test]
fn shorter_pass_after_commit_does_not_panic() {
// Regression: committed_count = 2, then a pass arrives with
// only 1 token (Whisper re-transcribing an overlapping window
// that collapses repeated segments, or user stopping mid-
// utterance). `latest[committed_count..]` would index OOB.
let mut la = LocalAgreement::new(2);
let _ = la.push(vec![tok("a", 0.0, 0.1), tok("b", 0.1, 0.2)]);
let _ = la.push(vec![tok("a", 0.0, 0.1), tok("b", 0.1, 0.2)]);
assert_eq!(la.committed_count, 2);
let decision = la.push(vec![tok("a", 0.0, 0.1)]);
// committed_count stays at 2 (non-shrinkage invariant).
assert_eq!(la.committed_count, 2);
// No new commit, no tentative (nothing past position 2 in the
// shorter pass).
assert!(decision.newly_committed.is_empty());
assert!(decision.tentative.is_empty());
}
#[test]
fn empty_pass_after_commit_does_not_panic() {
let mut la = LocalAgreement::new(2);
let _ = la.push(vec![tok("a", 0.0, 0.1)]);
let _ = la.push(vec![tok("a", 0.0, 0.1)]);
let decision = la.push(vec![]);
assert_eq!(la.committed_count, 1);
assert!(decision.newly_committed.is_empty());
assert!(decision.tentative.is_empty());
}
#[test]
fn flush_emits_remaining_tentative() {
let mut la = LocalAgreement::new(2);
let _ = la.push(vec![tok("a", 0.0, 0.1), tok("b", 0.1, 0.2)]);
let _ = la.push(vec![
tok("a", 0.0, 0.1),
tok("b", 0.1, 0.2),
tok("c", 0.2, 0.3),
]);
// Committed: a, b. Tentative: c.
let flushed = la.flush();
assert_eq!(flushed.len(), 1);
assert_eq!(flushed[0].text, "c");
assert!((la.last_committed_end_secs() - 0.3).abs() < f64::EPSILON);
}
#[test]
fn flush_with_no_history_is_empty() {
let mut la = LocalAgreement::new(2);
assert!(la.flush().is_empty());
}
#[test]
fn reset_clears_commit_state() {
let mut la = LocalAgreement::new(2);
let _ = la.push(vec![tok("a", 0.0, 0.1)]);
let _ = la.push(vec![tok("a", 0.0, 0.1), tok("b", 0.1, 0.2)]);
la.reset();
assert_eq!(la.committed_count, 0);
assert_eq!(la.last_committed_end_secs(), 0.0);
let decision = la.push(vec![tok("z", 0.0, 0.1)]);
assert!(decision.newly_committed.is_empty());
assert_eq!(decision.tentative[0].text, "z");
}
#[test]
fn n_three_requires_three_matching_passes_to_commit() {
let mut la = LocalAgreement::new(3);
let _ = la.push(vec![tok("x", 0.0, 0.1)]);
let _ = la.push(vec![tok("x", 0.0, 0.1)]);
// Only 2 passes so far; with n=3 no commit yet.
let decision = la.push(vec![tok("x", 0.0, 0.1), tok("y", 0.1, 0.2)]);
assert_eq!(
decision.newly_committed.len(),
1,
"on the 3rd matching pass, x becomes committed"
);
assert_eq!(decision.newly_committed[0].text, "x");
}
#[test]
fn from_policy_default_is_local_agreement_n2() {
let la = LocalAgreement::from_policy(CommitPolicy::default());
assert_eq!(la.n, 2);
}
}

View File

@@ -0,0 +1,83 @@
//! Streaming primitives for live capture: VAD-gated chunking,
//! agreement-based commit policy, and bounded buffer management.
//!
//! These types are tested at the unit level. Integration into
//! `src-tauri/src/commands/live.rs` lands in follow-up commits so
//! threshold tuning can be validated against real microphone captures
//! rather than synthetic fixtures (brief items #21, #24, #25).
pub mod buffer_trim;
pub mod commit_policy;
pub mod rms_vad;
pub use buffer_trim::{sample_index_for_seconds, trim_buffer_to_commit_point};
pub use commit_policy::{CommitDecision, CommitPolicy, LocalAgreement, Token};
pub use rms_vad::RmsVadChunker;
/// A span of audio the VAD considers worth transcribing. `start_sample`
/// is an absolute index into the stream the `VadChunker` has been fed
/// since its last `reset`; `samples` is f32 PCM at the chunker's
/// configured sample rate.
#[derive(Debug, Clone)]
pub struct VadChunk {
pub start_sample: u64,
pub samples: Vec<f32>,
}
/// A streaming VAD-gated chunker.
///
/// Implementations accumulate incoming samples, decide whether the
/// current segment is speech using a score + hysteresis (brief item
/// #21), and emit `VadChunk`s when a speech region ends — or when an
/// in-progress speech region exceeds the configured max length so
/// Whisper is not fed a 30-second monolith.
///
/// `push` returns any chunks ready to dispatch; typical usage is
/// `for chunk in chunker.push(&samples) { dispatch(chunk); }` inside
/// the capture loop.
///
/// `flush` is called at end-of-session to emit any in-flight speech as
/// a final chunk (even if silence hasn't closed it).
///
/// `Send` because a chunker is owned by the live-session worker thread
/// and moved into `spawn_blocking`.
pub trait VadChunker: Send {
/// Feed new samples. Returns any chunks the chunker has decided to
/// emit as a result. An empty Vec means "still gathering".
fn push(&mut self, samples: &[f32]) -> Vec<VadChunk>;
/// End-of-session: emit any in-progress speech as chunks even
/// though silence has not closed them. Returns an empty Vec if
/// there is nothing buffered (or only sub-threshold samples).
///
/// Returns `Vec<VadChunk>` rather than `Option<VadChunk>` because
/// the zero-padded final frame can legitimately trigger both a
/// mid-flush emission (end-of-utterance or `max_chunk_samples`)
/// AND a closing emission if the backend stays in-speech after
/// the mid-flush cut. The previous `Option` signature silently
/// dropped the mid-flush chunk.
fn flush(&mut self) -> Vec<VadChunk>;
/// Drop accumulated state. Used between sessions on the same
/// chunker instance (or after a context-window reset from the
/// repetition detector — brief item #26).
fn reset(&mut self);
/// Absolute sample index of the next sample that will be fed via
/// `push`. Exposed so the commit policy (#24) can compute sample
/// offsets for its agreement window.
fn next_sample_index(&self) -> u64;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vad_chunker_trait_is_object_safe() {
// Compile-time witness: keep the trait dyn-compatible so the
// live-session worker can hold `Box<dyn VadChunker>` and swap
// between RMS and Silero backends at runtime.
let _: Option<Box<dyn VadChunker>> = None;
}
}

View File

@@ -0,0 +1,689 @@
//! RMS-energy-backed VAD chunker.
//!
//! This is the fallback backend the plan (`docs/whisper-ecosystem/
//! workstream-A.md`, Phase A.3 "Known unknowns") permits while the ort
//! 2.0.0-rc.10 vs rc.12 ecosystem conflict prevents a drop-in Silero
//! dep. The `VadChunker` trait surface is identical to what a Silero
//! backend will present, so the live-session path does not change when
//! Silero lands.
//!
//! The chunker emits a `VadChunk` when a sustained-speech region ends
//! (RMS drops below `exit_threshold` for `silence_close_samples`) or
//! when an in-progress region exceeds `max_chunk_samples` (so Whisper
//! is not fed a 30-second monolith). It applies hysteresis — an
//! `enter_threshold` higher than `exit_threshold` — so a VAD score
//! bouncing around the threshold does not toggle state every frame.
use super::{VadChunk, VadChunker};
/// Sample window used to compute a single RMS reading. 50 ms at 16
/// kHz. Shorter windows twitch on transients; longer windows blur the
/// speech-onset boundary.
const FRAME_SAMPLES: usize = 800;
/// Default thresholds tuned to match the existing `evaluate_speech_gate`
/// behaviour in `src-tauri/src/commands/live.rs`. The underlying
/// constants live in that file; this chunker exposes them as fields so
/// they can be tuned per-session without a recompile.
const DEFAULT_ENTER_RMS_THRESHOLD: f32 = 0.003;
const DEFAULT_EXIT_RMS_THRESHOLD: f32 = 0.0014;
/// Frames of sustained speech required before the chunker enters the
/// "in-speech" state. Filters out single-frame transients (keyboard
/// clicks, door closes).
const DEFAULT_SPEECH_ONSET_FRAMES: usize = 3;
/// Silence duration that closes an in-progress chunk, in samples.
/// 500 ms = 10 frames at 16 kHz / 50 ms-frames.
const DEFAULT_SILENCE_CLOSE_SAMPLES: usize = 8_000;
/// Hard cap on a single chunk. Matches the existing `CHUNK_SAMPLES`
/// (2 s) so the live-streaming experience is not delayed arbitrarily
/// by a user speaking continuously.
const DEFAULT_MAX_CHUNK_SAMPLES: usize = 32_000;
/// Sample rate the thresholds above assume. Exposed so future backends
/// (Parakeet, Moonshine) at different rates can construct a chunker
/// matching their native sample rate.
const DEFAULT_SAMPLE_RATE_HZ: u32 = 16_000;
#[derive(Debug, Clone, Copy, PartialEq)]
enum State {
/// Nothing buffered. Waiting for the first RMS excursion over
/// `enter_threshold`.
Idle,
/// In-progress speech. Samples accumulate; closes on
/// `silence_close_samples` of sub-threshold audio or on
/// `max_chunk_samples`.
InSpeech,
}
pub struct RmsVadChunker {
// Tunables
enter_threshold: f32,
exit_threshold: f32,
speech_onset_frames: usize,
silence_close_samples: usize,
max_chunk_samples: usize,
// Running state
state: State,
/// Frame-boundary reassembly: samples that did not complete a
/// frame on the previous `push`. Always shorter than `FRAME_SAMPLES`.
pending: Vec<f32>,
/// Samples belonging to the current in-progress chunk (State::InSpeech).
active_chunk: Vec<f32>,
/// Trailing silence sample count inside the current chunk. Resets
/// to zero whenever a speech frame is seen.
silent_tail_samples: usize,
/// Consecutive speech frames observed while `State::Idle`. When
/// this hits `speech_onset_frames`, state transitions to InSpeech.
pending_onset_frames: usize,
/// Samples buffered from the onset window that should be attached
/// to the front of the emitted chunk so Whisper sees the speech
/// onset itself, not just the post-onset audio.
onset_buffer: Vec<f32>,
/// Absolute sample index of the next sample `push` will consume.
next_sample_index: u64,
/// Absolute sample index where the current in-progress chunk
/// started. Valid only while `state == InSpeech`.
active_chunk_start: u64,
}
impl RmsVadChunker {
pub fn new() -> Self {
Self::with_thresholds(
DEFAULT_ENTER_RMS_THRESHOLD,
DEFAULT_EXIT_RMS_THRESHOLD,
DEFAULT_SPEECH_ONSET_FRAMES,
DEFAULT_SILENCE_CLOSE_SAMPLES,
DEFAULT_MAX_CHUNK_SAMPLES,
)
}
pub fn with_thresholds(
enter_threshold: f32,
exit_threshold: f32,
speech_onset_frames: usize,
silence_close_samples: usize,
max_chunk_samples: usize,
) -> Self {
debug_assert!(
exit_threshold <= enter_threshold,
"exit_threshold must not exceed enter_threshold (hysteresis requires enter >= exit)"
);
Self {
enter_threshold,
exit_threshold,
speech_onset_frames,
silence_close_samples,
max_chunk_samples,
state: State::Idle,
pending: Vec::new(),
active_chunk: Vec::new(),
silent_tail_samples: 0,
pending_onset_frames: 0,
onset_buffer: Vec::new(),
next_sample_index: 0,
active_chunk_start: 0,
}
}
pub fn sample_rate_hz(&self) -> u32 {
DEFAULT_SAMPLE_RATE_HZ
}
fn frame_rms(frame: &[f32]) -> f32 {
if frame.is_empty() {
return 0.0;
}
let sum_sq: f32 = frame.iter().map(|x| x * x).sum();
(sum_sq / frame.len() as f32).sqrt()
}
/// Consume one complete frame's worth of samples and update state.
/// `frame_start` is the absolute sample index of `frame[0]` in the
/// stream fed since `reset`. Returns a `VadChunk` if this frame
/// closed the in-progress chunk.
fn consume_frame(&mut self, frame: Vec<f32>, frame_start: u64) -> Option<VadChunk> {
let rms = Self::frame_rms(&frame);
match self.state {
State::Idle => self.consume_frame_idle(frame, frame_start, rms),
State::InSpeech => self.consume_frame_in_speech(frame, rms),
}
}
fn consume_frame_idle(
&mut self,
frame: Vec<f32>,
frame_start: u64,
rms: f32,
) -> Option<VadChunk> {
if rms >= self.enter_threshold {
self.pending_onset_frames += 1;
// Keep a rolling buffer of onset audio so once we confirm
// speech, the emitted chunk contains the speech attack
// rather than starting mid-syllable.
self.onset_buffer.extend_from_slice(&frame);
let onset_cap = self.speech_onset_frames * FRAME_SAMPLES;
if self.onset_buffer.len() > onset_cap {
let overflow = self.onset_buffer.len() - onset_cap;
self.onset_buffer.drain(..overflow);
}
if self.pending_onset_frames >= self.speech_onset_frames {
// Transition: flush the onset buffer into active_chunk
// and begin accumulating. The onset buffer includes
// the current frame, so its start index is
// `frame_start + FRAME_SAMPLES - onset_buffer.len()`.
self.state = State::InSpeech;
self.active_chunk_start = frame_start
.saturating_add(FRAME_SAMPLES as u64)
.saturating_sub(self.onset_buffer.len() as u64);
self.active_chunk.clear();
self.active_chunk.append(&mut self.onset_buffer);
self.silent_tail_samples = 0;
self.pending_onset_frames = 0;
}
} else {
// Sub-threshold frame while idle — reset the onset counter
// and drop any onset buffer. The gate demands *sustained*
// speech, not a single frame over threshold.
self.pending_onset_frames = 0;
self.onset_buffer.clear();
}
None
}
fn consume_frame_in_speech(&mut self, frame: Vec<f32>, rms: f32) -> Option<VadChunk> {
self.active_chunk.extend_from_slice(&frame);
if rms >= self.exit_threshold {
self.silent_tail_samples = 0;
} else {
self.silent_tail_samples += frame.len();
}
let end_of_utterance = self.silent_tail_samples >= self.silence_close_samples;
if end_of_utterance {
return Some(self.emit_active_chunk_and_close());
}
let hit_max = self.active_chunk.len() >= self.max_chunk_samples;
if hit_max {
return Some(self.emit_active_chunk_continue());
}
None
}
/// Emit the active chunk as an end-of-utterance close: trailing
/// silence is trimmed off (Whisper does not need dead air) and
/// state returns to Idle. Next speech onset must re-cross the
/// sustained-speech threshold before a new chunk begins.
fn emit_active_chunk_and_close(&mut self) -> VadChunk {
let mut samples = std::mem::take(&mut self.active_chunk);
if self.silent_tail_samples > 0 && samples.len() > self.silent_tail_samples {
let keep = samples.len() - self.silent_tail_samples;
samples.truncate(keep);
}
let start_sample = self.active_chunk_start;
self.state = State::Idle;
self.silent_tail_samples = 0;
self.pending_onset_frames = 0;
self.onset_buffer.clear();
VadChunk {
start_sample,
samples,
}
}
/// Emit the active chunk as a mid-utterance split because we hit
/// `max_chunk_samples`. State stays `InSpeech` and `active_chunk`
/// resets to empty — the very next frame in this still-ongoing
/// speech region accumulates into the new chunk, so no audio is
/// dropped across the split. `active_chunk_start` advances by the
/// emitted length so the next chunk's `start_sample` is contiguous
/// with this one's end.
///
/// No trailing-silence truncation: we are by definition still in
/// speech when this fires (end-of-utterance takes priority in the
/// caller), so any brief silent stretch is legitimately part of
/// the continuing utterance and belongs to one of the chunks.
fn emit_active_chunk_continue(&mut self) -> VadChunk {
let samples = std::mem::take(&mut self.active_chunk);
let chunk_len = samples.len() as u64;
let start_sample = self.active_chunk_start;
self.active_chunk_start = start_sample.saturating_add(chunk_len);
// Reset silent_tail so any silence accumulated just before
// the split does not carry over into the next chunk's
// end-of-utterance detector. onset_buffer stays empty
// (we never leave InSpeech).
self.silent_tail_samples = 0;
VadChunk {
start_sample,
samples,
}
}
}
impl Default for RmsVadChunker {
fn default() -> Self {
Self::new()
}
}
impl VadChunker for RmsVadChunker {
fn push(&mut self, samples: &[f32]) -> Vec<VadChunk> {
if samples.is_empty() {
return Vec::new();
}
self.pending.extend_from_slice(samples);
self.next_sample_index = self.next_sample_index.saturating_add(samples.len() as u64);
let mut emitted = Vec::new();
while self.pending.len() >= FRAME_SAMPLES {
// Absolute index of the first sample in the frame we are
// about to consume: total fed minus what is still pending.
let frame_start = self
.next_sample_index
.saturating_sub(self.pending.len() as u64);
let frame: Vec<f32> = self.pending.drain(..FRAME_SAMPLES).collect();
if let Some(chunk) = self.consume_frame(frame, frame_start) {
emitted.push(chunk);
}
}
emitted
}
fn flush(&mut self) -> Vec<VadChunk> {
let mut emitted = Vec::new();
// Consume any tail of fewer-than-frame samples so the last
// utterance is not lost when a user stops recording mid-word.
// The padded frame can legitimately trigger a chunk emission
// (end-of-utterance if the zeros close a near-expired silent
// tail, or `max_chunk_samples` if the speech pushes past the
// cap). Both must be surfaced — dropping them loses audio.
if !self.pending.is_empty() {
let frame_start = self
.next_sample_index
.saturating_sub(self.pending.len() as u64);
let pad_len = FRAME_SAMPLES - self.pending.len();
let mut padded = std::mem::take(&mut self.pending);
padded.extend(std::iter::repeat(0.0_f32).take(pad_len));
if let Some(chunk) = self.consume_frame(padded, frame_start) {
emitted.push(chunk);
}
}
// If the backend is still mid-speech after the padded frame
// (no end-of-utterance, or it was a hit_max continue that
// left state in InSpeech with an empty active_chunk), emit
// whatever is still open as the closing chunk.
if self.state == State::InSpeech && !self.active_chunk.is_empty() {
emitted.push(self.emit_active_chunk_and_close());
} else if self.state == State::InSpeech {
// hit_max emitted mid-flush and left state in InSpeech
// with active_chunk empty. Reset cleanly without emitting
// a zero-length closing chunk — the hit_max chunk already
// carried all the audio.
self.state = State::Idle;
self.silent_tail_samples = 0;
self.pending_onset_frames = 0;
self.onset_buffer.clear();
}
emitted
}
fn reset(&mut self) {
self.state = State::Idle;
self.pending.clear();
self.active_chunk.clear();
self.silent_tail_samples = 0;
self.pending_onset_frames = 0;
self.onset_buffer.clear();
self.next_sample_index = 0;
self.active_chunk_start = 0;
}
fn next_sample_index(&self) -> u64 {
self.next_sample_index
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Generate a vector of `len` samples at amplitude `amp`. The
/// signal is a constant DC offset, which gives a deterministic
/// RMS of exactly `amp.abs()` — simpler than a sinusoid for
/// threshold-crossing tests.
fn constant_signal(len: usize, amp: f32) -> Vec<f32> {
vec![amp; len]
}
#[test]
fn pure_silence_emits_nothing() {
let mut c = RmsVadChunker::new();
let silence = constant_signal(16_000, 0.0); // 1 s of zero
let chunks = c.push(&silence);
assert!(chunks.is_empty());
assert!(c.flush().is_empty());
}
#[test]
fn below_enter_threshold_does_not_trigger() {
let mut c = RmsVadChunker::new();
// 0.002 is between the default exit (0.0014) and enter (0.003)
// thresholds — must NOT transition Idle → InSpeech.
let hum = constant_signal(16_000, 0.002);
let chunks = c.push(&hum);
assert!(
chunks.is_empty(),
"samples below enter_threshold must not trigger onset"
);
}
#[test]
fn single_loud_frame_does_not_trigger_onset() {
let mut c = RmsVadChunker::new();
// One frame above enter, surrounded by silence. With
// speech_onset_frames=3 this should NOT transition.
let mut signal = Vec::new();
signal.extend(constant_signal(FRAME_SAMPLES, 0.0));
signal.extend(constant_signal(FRAME_SAMPLES, 0.01)); // loud, one frame
signal.extend(constant_signal(FRAME_SAMPLES * 4, 0.0));
let chunks = c.push(&signal);
assert!(
chunks.is_empty(),
"single-frame transient must not cross sustained-speech onset"
);
}
#[test]
fn sustained_speech_followed_by_silence_emits_one_chunk() {
let mut c = RmsVadChunker::new();
// 8 frames of speech (well over onset) followed by 12 frames of
// silence (well over silence_close). Must emit exactly one
// chunk.
let mut signal = Vec::new();
signal.extend(constant_signal(FRAME_SAMPLES * 8, 0.01));
signal.extend(constant_signal(FRAME_SAMPLES * 12, 0.0));
let chunks = c.push(&signal);
assert_eq!(chunks.len(), 1, "one speech region → one chunk");
let chunk = &chunks[0];
assert!(
!chunk.samples.is_empty(),
"emitted chunk must contain samples"
);
}
#[test]
fn hysteresis_prevents_mid_utterance_close_on_brief_dip() {
let mut c = RmsVadChunker::new();
// Onset → loud → brief dip between enter and exit → loud again
// → silence. The dip is above exit_threshold so the chunk must
// NOT close across it.
let loud = constant_signal(FRAME_SAMPLES * 4, 0.01);
let dip = constant_signal(FRAME_SAMPLES, 0.002);
let more_loud = constant_signal(FRAME_SAMPLES * 4, 0.01);
let silence = constant_signal(FRAME_SAMPLES * 12, 0.0);
let mut signal = Vec::new();
signal.extend(loud);
signal.extend(dip);
signal.extend(more_loud);
signal.extend(silence);
let chunks = c.push(&signal);
assert_eq!(
chunks.len(),
1,
"hysteresis dip between enter and exit thresholds must not split a chunk"
);
}
#[test]
fn max_chunk_samples_caps_continuous_speech() {
let mut c = RmsVadChunker::with_thresholds(
DEFAULT_ENTER_RMS_THRESHOLD,
DEFAULT_EXIT_RMS_THRESHOLD,
DEFAULT_SPEECH_ONSET_FRAMES,
DEFAULT_SILENCE_CLOSE_SAMPLES,
FRAME_SAMPLES * 4, // tighter cap for the test
);
// Feed 12 frames of sustained speech with no silence break.
// The 4-frame cap must cause at least one emission mid-stream.
let signal = constant_signal(FRAME_SAMPLES * 12, 0.01);
let chunks = c.push(&signal);
assert!(
!chunks.is_empty(),
"continuous speech over the cap must emit at least one chunk"
);
for chunk in &chunks {
assert!(
chunk.samples.len() <= FRAME_SAMPLES * 4,
"emitted chunk exceeded max_chunk_samples"
);
}
}
#[test]
fn max_chunk_split_preserves_audio_contiguity() {
// Regression: a max_chunk emission in the middle of continuous
// speech used to reset state to Idle, which dropped 1-2 frames
// of post-split speech into the onset buffer where they were
// cleared if silence arrived before the onset threshold.
//
// Property under test: across a multi-chunk continuous-speech
// session, (a) chunk starts are contiguous with previous chunk
// ends, and (b) the total emitted+flushed sample count equals
// the input speech sample count (sans the pre-onset frames
// that are correctly dropped as silence).
let max_chunk = FRAME_SAMPLES * 4;
let mut c = RmsVadChunker::with_thresholds(
DEFAULT_ENTER_RMS_THRESHOLD,
DEFAULT_EXIT_RMS_THRESHOLD,
DEFAULT_SPEECH_ONSET_FRAMES,
DEFAULT_SILENCE_CLOSE_SAMPLES,
max_chunk,
);
// 17 frames of continuous speech. 3 onset + 14 post-onset.
// With a 4-frame max cap, we expect multiple chunks.
let total_frames = 17;
let signal = constant_signal(FRAME_SAMPLES * total_frames, 0.01);
let mut chunks = c.push(&signal);
chunks.extend(c.flush());
assert!(
chunks.len() >= 2,
"continuous speech past the cap must produce at least 2 chunks"
);
// Contiguity: chunk[i+1].start == chunk[i].start + chunk[i].samples.len()
for pair in chunks.windows(2) {
let prev = &pair[0];
let next = &pair[1];
assert_eq!(
next.start_sample,
prev.start_sample + prev.samples.len() as u64,
"chunk starts must be contiguous across the max-chunk split \
(prev start={}, prev len={}, next start={})",
prev.start_sample,
prev.samples.len(),
next.start_sample,
);
}
// Every chunk honours the cap.
for chunk in &chunks {
assert!(
chunk.samples.len() <= max_chunk,
"chunk exceeded max_chunk_samples cap"
);
}
// No audio loss: total emitted samples covers the full speech
// region (from the onset start — samples before onset are
// legitimately dropped).
let first_start = chunks.first().unwrap().start_sample;
let total_emitted: u64 = chunks.iter().map(|c| c.samples.len() as u64).sum();
let end = first_start + total_emitted;
assert_eq!(
end,
(FRAME_SAMPLES * total_frames) as u64,
"emitted sample region must reach the end of the fed speech"
);
}
#[test]
fn flush_emits_in_flight_speech() {
let mut c = RmsVadChunker::new();
// Sustained speech with NO closing silence. Without flush this
// stays buffered; flush must surface it as a final chunk.
let signal = constant_signal(FRAME_SAMPLES * 5, 0.01);
let chunks = c.push(&signal);
assert!(
chunks.is_empty(),
"in-progress speech with no silence close stays buffered until flush"
);
let flushed = c.flush();
assert_eq!(
flushed.len(),
1,
"flush must emit exactly one in-flight chunk"
);
}
#[test]
fn flush_returns_empty_when_idle() {
let mut c = RmsVadChunker::new();
assert!(c.flush().is_empty());
let _ = c.push(&constant_signal(16_000, 0.0));
assert!(
c.flush().is_empty(),
"flushing pure silence emits nothing"
);
}
#[test]
fn flush_preserves_hit_max_chunk_from_padded_final_frame() {
// Regression for CRITICAL C2 (2026-04-22 audit): if the zero-
// padded final frame in flush() triggers `max_chunk_samples`,
// the continue-variant emission was previously discarded by
// `let _ = consume_frame(...)`. Must now surface in the
// returned Vec.
//
// Setup: tight max_chunk so 4 frames of accumulated speech
// (3 onset + 1) plus the padded tail exceeds the cap during
// consume_frame, triggering a hit_max continue emission.
let max_chunk = FRAME_SAMPLES * 4;
let mut c = RmsVadChunker::with_thresholds(
DEFAULT_ENTER_RMS_THRESHOLD,
DEFAULT_EXIT_RMS_THRESHOLD,
DEFAULT_SPEECH_ONSET_FRAMES,
DEFAULT_SILENCE_CLOSE_SAMPLES,
max_chunk,
);
// 3 onset frames — transitions to InSpeech, active_chunk = 3 frames.
let onset = constant_signal(FRAME_SAMPLES * 3, 0.01);
let mid = c.push(&onset);
assert!(mid.is_empty());
// Sub-frame tail of speech that padding will push to 4 full
// frames in active_chunk = max_chunk, triggering hit_max.
let half_frame = constant_signal(FRAME_SAMPLES / 2, 0.01);
let mid2 = c.push(&half_frame);
assert!(mid2.is_empty());
let flushed = c.flush();
assert!(
!flushed.is_empty(),
"flush must surface the hit_max chunk triggered by the padded frame"
);
// Coverage of the onset + half-frame speech is the property
// under test. Emitted samples across all chunks must add up
// to at least the active-speech duration (some trailing
// zero-pad may be included in the final chunk — that is
// acceptable, dropping live speech is not).
let total: usize = flushed.iter().map(|c| c.samples.len()).sum();
let speech_samples = FRAME_SAMPLES * 3 + FRAME_SAMPLES / 2;
assert!(
total >= speech_samples,
"flush lost audio: emitted {total} samples, expected at least {speech_samples}"
);
}
#[test]
fn flush_preserves_end_of_utterance_chunk_from_padded_final_frame() {
// Second regression for CRITICAL C2: if the padded final
// frame's zeros close a near-expired silent tail (triggering
// end_of_utterance → emit_active_chunk_and_close inside
// consume_frame), state flips to Idle and the outer check
// previously returned None. Must now surface.
//
// Setup: speak long enough to enter InSpeech, then trail with
// near-silence so the silent_tail is just below the close
// threshold. A padded zero frame during flush pushes it over.
let silence_close = FRAME_SAMPLES * 2;
let mut c = RmsVadChunker::with_thresholds(
DEFAULT_ENTER_RMS_THRESHOLD,
DEFAULT_EXIT_RMS_THRESHOLD,
DEFAULT_SPEECH_ONSET_FRAMES,
silence_close,
DEFAULT_MAX_CHUNK_SAMPLES,
);
// 3 onset frames → InSpeech.
let _ = c.push(&constant_signal(FRAME_SAMPLES * 3, 0.01));
// 1 frame of near-silence: pushes silent_tail to 1 frame.
// Needs to stay below silence_close so no emit happens during push.
let _ = c.push(&constant_signal(FRAME_SAMPLES, 0.0));
// Push a sub-frame tail of silence — after padding this
// produces a full zero frame, pushing silent_tail from 1 to 2
// frames = silence_close, triggering end_of_utterance inside
// consume_frame.
let _ = c.push(&constant_signal(FRAME_SAMPLES / 4, 0.0));
let flushed = c.flush();
assert_eq!(
flushed.len(),
1,
"flush must surface the end-of-utterance chunk triggered by the padded frame"
);
}
#[test]
fn reset_clears_state() {
let mut c = RmsVadChunker::new();
let signal = constant_signal(FRAME_SAMPLES * 5, 0.01);
let _ = c.push(&signal);
c.reset();
assert_eq!(c.next_sample_index(), 0);
// After reset, silence must not emit a chunk derived from pre-reset state.
let silence = constant_signal(FRAME_SAMPLES * 12, 0.0);
let chunks = c.push(&silence);
assert!(chunks.is_empty());
assert!(c.flush().is_empty());
}
#[test]
fn start_sample_includes_onset_audio() {
let mut c = RmsVadChunker::new();
// First 2 frames silent (so next_sample_index is advanced but
// no onset). Then speech.
let silence = constant_signal(FRAME_SAMPLES * 2, 0.0);
let _ = c.push(&silence);
assert_eq!(c.next_sample_index(), (FRAME_SAMPLES * 2) as u64);
let speech = constant_signal(FRAME_SAMPLES * 5, 0.01);
let closing_silence = constant_signal(FRAME_SAMPLES * 12, 0.0);
let mut signal = Vec::new();
signal.extend(speech);
signal.extend(closing_silence);
let chunks = c.push(&signal);
assert_eq!(chunks.len(), 1);
let chunk = &chunks[0];
// The chunk's start_sample should reflect the absolute index
// of the first onset-buffered sample, NOT the post-onset index.
assert!(
chunk.start_sample >= (FRAME_SAMPLES * 2) as u64,
"start_sample must be at or after the pre-speech silence"
);
assert!(
chunk.start_sample
<= (FRAME_SAMPLES * 2 + FRAME_SAMPLES * DEFAULT_SPEECH_ONSET_FRAMES) as u64,
"start_sample must not skip past the onset frames"
);
}
}

View File

@@ -0,0 +1,61 @@
//! Engine-abstraction trait for speech-to-text backends.
//!
//! Replaces the previous `SpeechBackend` enum so new backends
//! (Moonshine, whisper-rs forks, cloud ASR shims, Windows non-AVX2
//! fallbacks) can drop in without adding a match arm in `LocalEngine`.
//!
//! Concrete implementers today: `SpeechModelAdapter` (wraps any
//! `transcribe-rs` model, currently used for Parakeet) and — behind the
//! `whisper` feature — `WhisperRsBackend` (direct whisper-rs, the only
//! path that pipes `initial_prompt`).
use kon_core::error::Result;
use kon_core::types::{Segment, TranscriptionOptions};
/// Static capabilities a `Transcriber` advertises to callers.
///
/// `sample_rate` is load-bearing for the progressive WAV writer (#19)
/// which writes live capture samples to disk at the transcriber's
/// native rate. `supports_initial_prompt` lets the Settings surface
/// hide the initial-prompt field for backends that ignore it (Parakeet
/// today).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TranscriberCapabilities {
pub sample_rate: u32,
pub channels: u16,
pub supports_initial_prompt: bool,
}
/// Unified interface for speech-to-text backends.
///
/// `Send` is a supertrait so `Box<dyn Transcriber + Send>` travels
/// across `spawn_blocking` boundaries without a per-site bound. All
/// inference is synchronous — async callers wrap a `tokio::spawn_blocking`
/// around `transcribe_sync`.
pub trait Transcriber: Send {
fn capabilities(&self) -> TranscriberCapabilities;
/// Synchronously transcribe 16 kHz mono f32 PCM (or whatever the
/// backend's `capabilities().sample_rate` declares). `&mut self` so
/// backends that keep per-call scratch state (whisper-rs's
/// `WhisperState`, Parakeet's decoder buffers) can mutate them
/// without interior-mutability gymnastics.
fn transcribe_sync(
&mut self,
samples: &[f32],
options: &TranscriptionOptions,
) -> Result<Vec<Segment>>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transcriber_trait_is_object_safe() {
// Compile-time witness: if the trait stops being object-safe
// (e.g. someone adds a generic method or a Self-returning
// method) this declaration fails to build. No runtime work.
let _: Option<Box<dyn Transcriber + Send>> = None;
}
}

View File

@@ -10,8 +10,11 @@ use std::path::Path;
use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters};
use kon_core::error::{KonError, Result};
use kon_core::types::{Segment, TranscriptionOptions};
use crate::transcriber::{Transcriber, TranscriberCapabilities};
#[derive(Debug, thiserror::Error)]
pub enum WhisperBackendError {
#[error("whisper-rs load failed: {0}")]
@@ -27,20 +30,32 @@ pub struct WhisperRsBackend {
}
impl WhisperRsBackend {
pub fn load(model_path: &Path) -> Result<Self, WhisperBackendError> {
pub fn load(model_path: &Path) -> std::result::Result<Self, WhisperBackendError> {
let ctx = WhisperContext::new_with_params(model_path, WhisperContextParameters::default())
.map_err(|e| WhisperBackendError::Load(e.to_string()))?;
Ok(Self { ctx })
}
}
impl Transcriber for WhisperRsBackend {
fn capabilities(&self) -> TranscriberCapabilities {
TranscriberCapabilities {
sample_rate: kon_core::constants::WHISPER_SAMPLE_RATE,
channels: 1,
supports_initial_prompt: true,
}
}
/// Synchronously transcribe 16 kHz mono f32 PCM.
///
/// `options.initial_prompt` is piped directly to whisper-rs.
pub fn transcribe_sync(
&self,
/// `options.initial_prompt` is piped directly to whisper-rs — this
/// is the only backend path that honours it; `SpeechModelAdapter`
/// discards it (Parakeet has no equivalent).
fn transcribe_sync(
&mut self,
samples: &[f32],
options: &TranscriptionOptions,
) -> Result<Vec<Segment>, WhisperBackendError> {
) -> Result<Vec<Segment>> {
tracing::info!(
language = ?options.language,
has_initial_prompt = options.initial_prompt.as_deref().map(|p| !p.is_empty()).unwrap_or(false),
@@ -50,7 +65,7 @@ impl WhisperRsBackend {
let mut state = self
.ctx
.create_state()
.map_err(|e| WhisperBackendError::State(e.to_string()))?;
.map_err(|e| KonError::TranscriptionFailed(WhisperBackendError::State(e.to_string()).to_string()))?;
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
if let Some(lang) = options.language.as_deref() {
@@ -70,7 +85,7 @@ impl WhisperRsBackend {
state
.full(params, samples)
.map_err(|e| WhisperBackendError::Transcribe(e.to_string()))?;
.map_err(|e| KonError::TranscriptionFailed(WhisperBackendError::Transcribe(e.to_string()).to_string()))?;
let n = state.full_n_segments();
@@ -81,7 +96,7 @@ impl WhisperRsBackend {
};
let text = seg
.to_str()
.map_err(|e| WhisperBackendError::Transcribe(e.to_string()))?
.map_err(|e| KonError::TranscriptionFailed(WhisperBackendError::Transcribe(e.to_string()).to_string()))?
.to_string();
// whisper-rs timestamps are centiseconds (10ms units). Convert to seconds (f64).
let start = seg.start_timestamp() as f64 * 0.01;

View File

@@ -0,0 +1,247 @@
---
name: Code Review — 2026/04/22
description: Full-sweep audit findings across all Kon crates + src-tauri, with triage buckets for quick wins vs release-blockers
type: reference
tags: [code-review, audit, bugs, kon, release-blockers]
date: 2026/04/22
---
# Kon Code Review — 2026/04/22
Full-sweep read-only audit of every `.rs` file across the Kon workspace. Four parallel Codex agents scanned:
- **Agent A** — `crates/transcription/`, `crates/audio/`
- **Agent B** — `crates/ai-formatting/`, `crates/llm/`, `crates/storage/`
- **Agent C** — `src-tauri/src/` (commands layer + lib.rs + main.rs + types.rs)
- **Agent D** — `crates/core/`, `crates/cloud-providers/`, `crates/hotkey/`, `crates/mcp/`, `src-tauri/build.rs`
## Summary
| Severity | Count |
|---|---|
| **CRITICAL** | 4 |
| **MAJOR** | 16 |
| **MINOR** | 15 |
| **NIT** | 3 |
**CRITICAL items are all real bugs** — not speculative. Three were introduced or touched during the whisper-ecosystem sprint; one is a latent data-integrity issue in the storage layer.
**Recommended path:**
1. Fix the four CRITICALs this session.
2. Log all MAJORs as release-blockers (must land before v0.1).
3. MINORs become a boy-scout backlog — picked up opportunistically when adjacent code is touched.
4. NITs resolve inline when the surrounding file is next edited.
---
## CRITICAL
### C1 — Racy single-session guard in live.rs
- **Path:** `src-tauri/src/commands/live.rs:193-338`
- **Issue:** `start_live_transcription_session` checks `running` is None before multiple `await`s and only stores the handle at the end; `stop_live_transcription_session` removes `running` before awaiting the worker join. Two overlapping IPC calls can admit a second live session OR expose an empty slot while the first session is still shutting down.
- **Fix scope:** large — requires holding the mutex across the async boundary or restructuring the state machine.
- **Bucket:** RELEASE-BLOCKER (this is the file's core invariant).
### C2 — `RmsVadChunker::flush()` drops chunks
- **Path:** `crates/transcription/src/streaming/rms_vad.rs:294-311`
- **Issue:** `flush()` zero-pads the final partial frame and calls `consume_frame()` via `let _ = ...`, discarding the returned `VadChunk`. If the padded frame triggers end-of-utterance or `max_chunk_samples`, the emitted chunk is lost and the outer state check either returns `None` or an empty chunk.
- **Fix scope:** small — change `flush` trait signature to return `Vec<VadChunk>`, collect chunks from both the `consume_frame` call and the final `emit_active_chunk_and_close`.
- **Bucket:** QUICK WIN. Regression test in the same commit.
- **Attribution:** Introduced in `05eea41` yesterday.
### C3 — Multi-statement migrations can half-apply
- **Path:** `crates/storage/src/migrations.rs:263-299`
- **Issue:** `run_migrations` executes statements individually and only records schema version after the full migration succeeds. A crash mid-migration leaves the schema half-mutated while still appearing unapplied; the next startup replays it against the partially-mutated DB.
- **Fix scope:** medium — wrap each migration in `BEGIN`/`COMMIT` transaction, update version row within the same transaction.
- **Bucket:** RELEASE-BLOCKER. A user with a mid-migration crash today gets a bricked DB.
### C4 — Transcript provenance can reference deleted profiles
- **Path:** `crates/storage/src/migrations.rs:208-216`, `crates/storage/src/database.rs:61-89`, `:697-708`
- **Issue:** v8 migration adds `transcripts.profile_id` without a foreign-key constraint. `insert_transcript` accepts any `profile_id`; `delete_profile` doesn't guard against existing transcript references. Transcripts can keep orphaned profile IDs, breaking provenance integrity.
- **Fix scope:** large — v9 migration to add FK constraint + reconcile existing orphans; update delete_profile to either cascade or block.
- **Bucket:** RELEASE-BLOCKER. Silent data-integrity hole.
---
## MAJOR (16)
### src-tauri — Commands layer
**[MAJOR] `poll_inference` treats IPC listener loss as session-fatal**
- `src-tauri/src/commands/live.rs:721-813`
- Closing the frontend or reloading it kills the whole live session via `?` on `result_channel.send(...)`. Non-fatal Tauri channel lifecycle should not terminate capture.
- Fix scope: medium. Bucket: RELEASE-BLOCKER.
**[MAJOR] `run_live_session` is a 200+ line multi-responsibility monolith**
- `src-tauri/src/commands/live.rs:349-579`
- Owns mic startup, runtime error draining, resampling, progressive WAV persistence, overload dropping, inference scheduling, and shutdown finalisation in one function. Known lifecycle bugs trace to this.
- Fix scope: large. Bucket: RELEASE-BLOCKER (refactor enables C1 fix).
**[MAJOR] Native capture worker is detached and can outlive stop/start**
- `src-tauri/src/commands/audio.rs:46-228`
- `start_native_capture` spawns a worker but never retains a join handle. A previous capture can flush into `all_samples` after `stop_native_capture` clears it — truncation and cross-session contamination possible.
- Fix scope: medium. Bucket: RELEASE-BLOCKER.
**[MAJOR] `resolve_recording_path` collides within the same second**
- `src-tauri/src/commands/audio.rs:236-257`
- Filename derived from `SystemTime::now().as_secs()`. Two recordings started in the same second get the same path → overwrite or merge.
- Fix scope: small. Bucket: QUICK WIN (append milliseconds + session_id).
**[MAJOR] `get_runtime_capabilities` advertises wrong accelerators**
- `src-tauri/src/commands/models.rs:435-489`
- Hard-codes `accelerators = ["cpu", "vulkan"]` even when `detect_active_compute_device` would report `metal` on macOS or the binary was compiled without the `whisper` feature.
- Fix scope: medium. Bucket: RELEASE-BLOCKER (frontend shows wrong settings otherwise).
**[MAJOR] `paste_text_replacing` doesn't snapshot the clipboard**
- `src-tauri/src/commands/paste.rs:181-217`
- Inconsistent with `paste_text`. Replacing leaves the raw transcript on the clipboard and destroys whatever the user had copied before.
- Fix scope: small. Bucket: QUICK WIN.
**[MAJOR] `PowerAssertion::begin` is a non-functional macOS stub**
- `src-tauri/src/commands/power.rs:41-121`
- `begin_activity` always returns `Err` → guard never acquires an App Nap assertion. The plan for A.1 #9 explicitly deferred this; still flagging so it's not forgotten.
- Fix scope: medium. Bucket: RELEASE-BLOCKER (before macOS ship).
### Transcription + audio
**[MAJOR] Decoder returns partial audio on errors**
- `crates/audio/src/decode.rs:58-79`
- Packet-read errors break the loop; decoder errors are skipped; function still returns `Ok` if any samples were produced. Truncated files silently accepted.
- Fix scope: medium. Bucket: RELEASE-BLOCKER.
**[MAJOR] `read_wav()` silently drops sample decode errors**
- `crates/audio/src/wav.rs:135-145`
- `filter_map(|s| s.ok())` for both integer and float iterators. Corrupt samples silently discarded.
- Fix scope: small. Bucket: QUICK WIN.
**[MAJOR] Model downloads don't validate non-resume HTTP status**
- `crates/transcription/src/model_manager.rs:161-262`
- Resume branch checks 206/200. Normal downloads never call `error_for_status()` → a 4xx/5xx response body gets written to `.part` and renamed.
- Fix scope: small. Bucket: QUICK WIN.
### LLM + storage
**[MAJOR] LLM prompts not preflighted against context window**
- `crates/llm/src/lib.rs:143-166`, `:317-321`
- `generate` tokenises the full prompt; `context_window_size` hard-caps at 8192. Long transcripts reach inference with prompts bigger than context → late runtime failure.
- Fix scope: medium. Bucket: RELEASE-BLOCKER.
**[MAJOR] `uncomplete_task` doesn't reopen auto-completed parents**
- `crates/storage/src/database.rs:389-449`
- `complete_subtask_and_check_parent` auto-completes a parent when the last child completes. `uncomplete_task` only flips the requested row → reopening a child leaves the parent wrongly marked done.
- Fix scope: small. Bucket: QUICK WIN.
### Core + small crates
**[MAJOR] `keystore::store_api_key` is a thread-unsafe safe API**
- `crates/cloud-providers/src/keystore.rs:6-18`
- `std::env::set_var` is UB outside single-threaded init per documented precondition. The safe `pub fn` doesn't enforce this.
- Fix scope: medium. Bucket: RELEASE-BLOCKER.
**[MAJOR] Hotkey device filtering hard-codes `KEY_A` / `KEY_R`**
- `crates/hotkey/src/linux.rs:236-241`
- `try_attach_device` claims to check for the configured hotkey's key but tests for hard-coded `KEY_A` or `KEY_R`. Hotkeys on other keys get silently dropped.
- Fix scope: small. Bucket: RELEASE-BLOCKER (correctness bug in a feature users rely on).
**[MAJOR] Malformed JSON-RPC silently dropped**
- `crates/mcp/src/main.rs:26-30`
- stdio entry point logs malformed lines and moves on without sending a JSON-RPC parse-error response. `handle_message` has parse-error handling that never runs.
- Fix scope: small. Bucket: QUICK WIN.
**[MAJOR] `list_transcripts` accepts invalid params as defaults**
- `crates/mcp/src/lib.rs:188-195`
- `serde_json::from_value(args).unwrap_or_default()` converts malformed args into defaults. Every other handler in the file returns `-32602` instead. Inconsistent behaviour.
- Fix scope: small. Bucket: QUICK WIN.
**[MAJOR] CSP guard matches `connect-src` by prefix**
- `src-tauri/build.rs:47-64`
- `strip_prefix("connect-src")` would also match `connect-src-elem` (if ever added to CSP3). Defensive: exact directive name match.
- Fix scope: small. Bucket: QUICK WIN.
- **Attribution:** Introduced in `6fd3893` yesterday.
---
## MINOR (15)
Grouped here for brevity — full details in agent outputs. Bucket: BOY SCOUT (fix when adjacent code touched).
- `commands/live.rs:341-347``pick_engine` duplicates dispatch logic from `commands/models.rs` and `commands/transcription.rs`
- `commands/live.rs:123-145` — stale `#[allow(dead_code)]` on `LiveStatusMessage` (all variants are constructed)
- `crates/audio/src/capture.rs:355-499``open_and_validate()` is 145 lines; only one unit test in the file
- `crates/audio/src/lib.rs:14` + `vad.rs:14-34``SpeechDetector` re-exported but no in-repo uses (stub awaiting Silero)
- `crates/audio/src/resample.rs:25-39` + `streaming_resample.rs:63-80` — rubato tuning duplicated between offline and streaming
- `crates/transcription/src/local_engine.rs:83-157``load`/`unload`/`capabilities`/`transcribe_sync` have no direct tests
- `crates/transcription/src/whisper_rs_backend.rs:54-107` — multi-responsibility function, behaviour-testing limited to `Display`
- `crates/ai-formatting/src/pipeline.rs:38-100``post_process_segments` does filtering + formatting + LLM invocation + failure handling in one function
- `crates/storage/src/database.rs` (×4 sites) — repeated `SELECT` column lists invite schema drift
- `crates/storage/src/database.rs` (×3 sites) — `list_transcripts_paged`, `count_transcripts`, `update_transcript`, `uncomplete_task`, `log_error`, `list_recent_errors` all untested
- `crates/storage/src/database.rs:774-775` — TODO flagging that Tauri command failures aren't wired into `error_log`
- `crates/core/src/providers.rs:35-40` — dead `ProviderRegistry` suppressed with `#[allow(dead_code)]`
- `crates/core/src/types.rs:169-184` — dead `TranscriptMetadata` suppressed with `#[allow(dead_code)]`
- `crates/hotkey/src/lib.rs:44-77` — parser silently discards extra triggers (`Ctrl+A+B` parses as `B`); no malformed-combo tests
- `crates/hotkey/src/linux.rs:46-142``EvdevHotkeyListener::start` is ~100 lines mixing channel setup + device scanning + watcher + retry + task orchestration
- `crates/mcp/src/lib.rs:168-303``list_transcripts`, `get_transcript`, `search_transcripts`, `list_tasks` handlers untested
---
## NIT (3)
- `crates/ai-formatting/src/llm_client.rs:26-27`, `:59-60``#[allow(dead_code)]` on actively-used `CLEANUP_PROMPT` and `format_dictionary_suffix`
- `crates/storage/src/file_storage.rs:12-14` — open TODO for consolidating OS-path helpers
- `src-tauri/src/commands/live.rs:123-145` — covered above (re-flagged by Agent C as NIT)
---
## Triage buckets
### Quick wins (this session or next)
One concern per commit. TDD where testable — failing regression test, then fix.
1. **C2** flush() drops chunks → change return type to `Vec<VadChunk>`
2. **paste_text_replacing** clipboard snapshot
3. **resolve_recording_path** collision → append millis + session_id
4. **read_wav** propagate sample errors
5. **model_manager** check HTTP status on non-resume path
6. **uncomplete_task** reopen auto-completed parents
7. **CSP guard** exact-name directive match (Rule: my own commit, Boy Scout)
8. **MCP parse-error** reply on malformed JSON-RPC
9. **list_transcripts** return -32602 on invalid params
10. Dead-code cleanups: `ProviderRegistry`, `TranscriptMetadata`, `CLEANUP_PROMPT`/`format_dictionary_suffix` allows, `LiveStatusMessage` allow
That's 10 items, ~1 commit each. Maybe 23 hours.
### Release-blockers (before v0.1 ship)
Tracked items that must land before first public release:
- **C1** racy single-session guard — needs `run_live_session` refactor first
- **C3** migrations atomicity — BEGIN/COMMIT wrap + version in same tx
- **C4** transcript-profile FK + delete_profile guard (v9 migration)
- `run_live_session` monolith refactor (unblocks C1)
- `poll_inference` IPC channel loss resilience
- Native capture worker join handle
- `get_runtime_capabilities` accelerator correctness
- `PowerAssertion` macOS objc2 bridge (known deferred)
- Decoder error propagation (`audio/src/decode.rs`)
- LLM prompt preflight against context window
- Keystore thread-safety
- Hotkey linux device filtering KEY_A/KEY_R bug
### Boy Scout backlog
All MINORs + NITs. Pick up opportunistically when adjacent code is touched.
### Deferred (quality improvements, not release-blocking)
- SQL SELECT list refactoring (needs macro or typed query builder)
- Test coverage improvements across `local_engine`, `whisper_rs_backend`, `pipeline`, storage APIs, MCP handlers
- Resampler tuning consolidation
- File-storage path helpers consolidation
---
## Notes
- No `TODO` / `FIXME` / `HACK` / `XXX` markers in the transcription + audio crates (Agent A confirmed).
- Clean files: `transcription/src/lib.rs`, `transcriber.rs`, `concurrency.rs`, `streaming/buffer_trim.rs`, `streaming/commit_policy.rs`, `streaming/mod.rs`, `audio/src/concurrency.rs`, `ai-formatting/src/{correction_learning,lib,rule_based,to_plain_text}.rs`, `llm/src/{grammars,prompts}.rs`, `storage/src/lib.rs`.
- Most-touched files in the sprint (`streaming/*`, `wav.rs`, `commit_policy`, `buffer_trim`) came back clean from A and B — the sprint code itself is in reasonable shape; the bugs cluster in `live.rs` and older storage surfaces.

69
docs/issues/README.md Normal file
View File

@@ -0,0 +1,69 @@
---
name: Release-blockers index
description: Open issues that must land before v0.1 ships, derived from the 2026-04-22 code review
type: index
tags: [issues, release-blockers]
---
# Release-blockers
Issues here must land before Kon v0.1 ships. Each is sourced from
`docs/code-review-2026-04-22.md`. When `gh` CLI is available, these
should be mirrored as real GitHub issues on `jakejars/kon`.
## CRITICAL (2 open, 1 resolved)
| # | File | Area | Fix scope |
|---|---|---|---|
| RB-01 | [c1-live-session-race.md](c1-live-session-race.md) | `src-tauri/commands/live.rs` | large |
| RB-03 | [c4-transcript-profile-fk.md](c4-transcript-profile-fk.md) | `crates/storage/migrations.rs` + `database.rs` | large |
## MAJOR (5 open, 4 resolved)
| # | File | Area | Fix scope |
|---|---|---|---|
| RB-04 | [run-live-session-monolith.md](run-live-session-monolith.md) | `src-tauri/commands/live.rs` | large |
| RB-05 | [poll-inference-channel-fatality.md](poll-inference-channel-fatality.md) | `src-tauri/commands/live.rs` | medium |
| RB-08 | [power-assertion-macos-objc2.md](power-assertion-macos-objc2.md) | `src-tauri/commands/power.rs` | medium |
| RB-10 | [llm-prompt-preflight.md](llm-prompt-preflight.md) | `crates/llm/lib.rs` | medium |
| RB-11 | [keystore-thread-safety.md](keystore-thread-safety.md) | `crates/cloud-providers/keystore.rs` | medium |
## Resolved
| # | File | Area | Resolution |
|---|---|---|---|
| RB-02 | [c3-migrations-atomicity.md](c3-migrations-atomicity.md) | `crates/storage/migrations.rs` | Each migration now runs inside a `pool.begin()` / `tx.commit()` transaction alongside its `schema_version` insert. Regression test injects a poisoned v9 migration and asserts neither the partial schema change nor the version row persists. DRY'd `run_migrations_up_to` test helper onto the same code path. |
| RB-06 | [native-capture-worker-join.md](native-capture-worker-join.md) | `src-tauri/commands/audio.rs` | `NativeCaptureState.stop_tx` replaced by `worker: AsyncMutex<Option<CaptureWorker>>`. `CaptureWorker` bundles the stop sender and the spawned task's `JoinHandle`; `stop_worker(worker)` sends stop then `await`s termination. Both `start_native_capture` (prior-worker stop) and `stop_native_capture` use the helper. Removed the 50ms sleep — the join barrier is exact. Two regression tests cover the lifecycle guarantee and the already-exited case. |
| RB-07 | [runtime-capabilities-accelerators.md](runtime-capabilities-accelerators.md) | `src-tauri/commands/models.rs` | Introduced `compose_accelerators(whisper_enabled, loader_available, target)` as a pure helper; `supported_accelerators()` reads `cfg(feature = "whisper")`, `vulkan_loader_available()`, and target OS then delegates. `get_runtime_capabilities` uses it in place of the hard-coded `["cpu", "vulkan"]`. Whisper's `supports_gpu` now follows the feature flag. Five regression tests cover all permutations. |
| RB-09 | [decoder-partial-audio-on-error.md](decoder-partial-audio-on-error.md) | `crates/audio/decode.rs` | Packet-loop now propagates all non-EOF `SymphoniaError`s as `AudioDecodeFailed`; per-packet decode errors bubble via `?`. Mock-`MediaSource` regression test confirms mid-stream I/O errors surface instead of returning partial audio. |
| RB-12 | [hotkey-linux-device-filter.md](hotkey-linux-device-filter.md) | `crates/hotkey/linux.rs` | Extracted `device_supports_combo` helper; `try_attach_device` now reads the configured `HotkeyCombo` from the watch channel and checks support for that trigger key. Four regression tests land in `linux::tests`. |
## Dependencies
```
RB-01 (live session race)
└── blocked by RB-04 (run_live_session monolith refactor)
RB-03 (transcript-profile FK)
└── coupled with RB-02 (migrations atomicity)
— a v9 migration adding the FK constraint must be transactional
```
## How to convert to GitHub issues
Once `gh` CLI is installed and authed (`sudo dnf install -y gh && gh auth login`):
```fish
for file in docs/issues/rb-*.md c1-*.md c3-*.md c4-*.md run-*.md poll-*.md \
native-*.md runtime-*.md power-*.md decoder-*.md llm-*.md \
keystore-*.md hotkey-*.md
set -l title (head -1 "$file" | sed 's/^# //')
gh issue create --repo jakejars/kon --title "$title" --body-file "$file" \
--label release-blocker
end
```
Issue labels to create first (`gh label create`):
- `release-blocker` — colour `#d73a4a`
- `critical` — colour `#b60205`
- `major` — colour `#d93f0b`

View File

@@ -0,0 +1,29 @@
# RB-01 CRITICAL: racy single-session guard in live.rs
**Severity:** CRITICAL
**Path:** `src-tauri/src/commands/live.rs:193-338`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md#c1--racy-single-session-guard-in-livers)
**Labels:** release-blocker, critical, concurrency
## Problem
`start_live_transcription_session` checks `running` is `None` before multiple `await`s and only stores the handle at the end. `stop_live_transcription_session` removes `running` before awaiting the worker join. Two overlapping IPC calls can:
- Admit a second live session (start sees `running == None`, awaits, another start fires in the gap, both proceed)
- Expose an empty slot while the first session is still shutting down (stop removes the handle, awaits, a fresh start runs against the incoherent state)
This breaks the file's core invariant that only one microphone/live session exists at a time.
## Acceptance
- Hold the session-slot lock (or a semaphore) across the async boundary so no two `start`/`stop` IPC calls can interleave.
- Regression test: fire two `start_live_transcription_session` IPC calls concurrently; exactly one must succeed and the other must error cleanly.
- Regression test: during an in-flight `stop`, a concurrent `start` must block until the previous session's worker has fully joined.
## Fix scope
Large. Will likely require the `run_live_session` monolith refactor (RB-04) to land first so the state machine is small enough to reason about under the lock discipline.
## Dependencies
- **Blocked by:** RB-04 (`run_live_session` monolith refactor)

View File

@@ -0,0 +1,50 @@
# RB-02 CRITICAL: multi-statement migrations can half-apply
**Severity:** CRITICAL
**Path:** `crates/storage/src/migrations.rs:263-299`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md#c3--multi-statement-migrations-can-half-apply)
**Labels:** release-blocker, critical, data-integrity, storage
**Status:** RESOLVED (2026-04-22)
## Resolution
Extracted `run_migrations_slice(pool, migrations)` as the single code
path that applies pending migrations. For each pending version it
opens a `Transaction` via `pool.begin()`, applies every split statement
on that transaction, records the `schema_version` row inside the same
transaction, and finally `tx.commit()`s. A failure anywhere in the
sequence — statement, version insert, commit — rolls the whole
migration back.
`run_migrations` delegates to `run_migrations_slice(pool, MIGRATIONS)`
and the test helper `run_migrations_up_to` to a filtered subset, so
only one version of the apply logic exists.
Regression test `multi_statement_migration_rolls_back_on_failure`
feeds a poisoned v9 migration (`CREATE TABLE poison_marker; SELECT
this_function_does_not_exist()`) through `run_migrations_slice`. The
call returns `Err`, and post-call `SELECT COUNT(*) FROM poison_marker`
fails with "no such table" while `MAX(schema_version)` remains at 8.
SQLite DDL participates in transactions, so this is sufficient for the
Kon schema. If any future migration needs a statement that implicitly
commits (`VACUUM`, `REINDEX`, `ATTACH`) — none do today — it must be
split into its own non-transactional migration. Reviewer's job to flag.
## Problem
`run_migrations` executes each statement individually and only records the schema version after the full migration succeeds. If a multi-statement migration (v5, v6, v8 — any containing more than one `CREATE` / `ALTER` / `UPDATE`) fails mid-run, or the process is killed between statements, the schema can end up partially changed while still appearing unapplied. The next startup replays the same migration against the mutated database, which can fail in confusing ways or corrupt data further.
## Acceptance
- Every migration runs inside a single `BEGIN` / `COMMIT` transaction.
- The version row update happens inside the same transaction — atomic success or no change.
- Regression test: a migration that panics partway through leaves the database at the previous schema version with no partial changes visible on restart.
## Fix scope
Medium. Wrap each migration in `pool.begin()` / `tx.commit()`. The version update and the migration statements all execute on the same `Transaction` handle. Needs careful review of any migration that uses implicit commits (SQLite `VACUUM`, `REINDEX`, `ATTACH` — none of which Kon currently uses, but the review pattern should guard against future additions).
## Dependencies
- Coupled with RB-03 (any v9 migration adding the transcript-profile FK must itself be transactional — this fix is a prerequisite).

View File

@@ -0,0 +1,26 @@
# RB-03 CRITICAL: transcript provenance can reference deleted profiles
**Severity:** CRITICAL
**Path:** `crates/storage/src/migrations.rs:208-216`, `crates/storage/src/database.rs:61-89`, `:697-708`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md#c4--transcript-provenance-can-reference-deleted-profiles)
**Labels:** release-blocker, critical, data-integrity, storage
## Problem
v8 migration adds `transcripts.profile_id` but without a `FOREIGN KEY` constraint. `insert_transcript` accepts any `profile_id` string without validation. `delete_profile` doesn't guard against existing transcript references. The combined result: persisted transcripts can keep orphaned profile IDs indefinitely, breaking provenance integrity.
## Acceptance
- A v9 migration adds `FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE RESTRICT` (or `ON DELETE SET NULL` if soft-orphaning is preferred — decide during the fix).
- The migration reconciles existing orphans: either backfill with `DEFAULT_PROFILE_ID`, or null them, per the chosen FK semantic.
- `insert_transcript` passes the FK check — no behaviour change on the happy path.
- `delete_profile` returns a meaningful error when transcripts reference the profile being deleted (or cascades to null, matching the FK semantic).
- Regression tests: (a) delete_profile with transcript references behaves per the chosen semantic; (b) insert_transcript with a non-existent profile_id errors; (c) existing orphans are reconciled on first migration to v9.
## Fix scope
Large. FK constraint design decision + migration + reconciliation + `database.rs` updates + tests.
## Dependencies
- **Blocked by:** RB-02 (migrations atomicity — the v9 migration must be transactional).

View File

@@ -0,0 +1,52 @@
# RB-09 MAJOR: decoder returns partial audio on read/decode errors
**Severity:** MAJOR
**Path:** `crates/audio/src/decode.rs:58-79`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
**Labels:** release-blocker, major, audio, data-integrity
**Status:** RESOLVED (2026-04-22)
## Resolution
`decode_audio_file` now propagates every `SymphoniaError` other than the
explicit end-of-stream `UnexpectedEof`:
- `SymphoniaError::ResetRequired` → error (mid-stream discontinuity).
- Any other packet-read error → `KonError::AudioDecodeFailed`.
- `decoder.decode(&packet)` errors → bubble via `?` instead of
counter-then-skip.
The decode logic was refactored into an internal
`decode_media_stream(mss, hint)` so tests can inject a custom
`MediaSource`. The regression test `FlakyCursor` returns a valid WAV
header followed by an injected `io::Error` after 1024 bytes; the
`mid_stream_io_error_propagates_instead_of_returning_partial_audio` test
asserts the caller receives `Err`, not an `Ok` with a truncated samples
vector. Companion tests cover the happy path and the
file-does-not-exist path.
The optional `decode_audio_file_best_effort` variant suggested in the
original issue was not added — no caller needs it today.
## Problem
`decode_audio_file`:
- Breaks the read loop on packet-read errors (truncated / corrupt inputs)
- Counts and skips per-packet decoder errors
- Still returns `Ok` if any samples were produced before the break
A corrupt or truncated input file is silently accepted as partial audio. Callers have no way to distinguish "file decoded cleanly" from "file was bad and we handed you half of it".
## Acceptance
- Propagate read and decode errors to the caller (return `Err`) — match the pattern used in `read_wav` (fixed in the 2026-04-22 quick-wins batch, commit `b665754`).
- Optional: expose a `decode_audio_file_best_effort` variant if anyone genuinely wants the partial-audio-on-error behaviour. Today no caller needs it.
- Regression tests: (a) truncated MP3; (b) corrupted FLAC; (c) valid file continues to decode successfully.
## Fix scope
Medium. Error-propagation pattern is the same as the `read_wav` fix, but the symphonia packet-loop has several skip branches to audit.
## Dependencies
- None — standalone fix.

View File

@@ -0,0 +1,44 @@
# RB-12 MAJOR: hotkey device filtering hard-codes KEY_A / KEY_R
**Severity:** MAJOR
**Path:** `crates/hotkey/src/linux.rs:236-241`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
**Labels:** release-blocker, major, hotkey, correctness
**Status:** RESOLVED (2026-04-22)
## Resolution
Extracted `device_supports_combo(supported, combo) -> bool` as a pure helper.
`try_attach_device` now snapshots the current `HotkeyCombo` from `hotkey_rx`
(returning early with `false` if the listener is unconfigured) and uses the
helper to filter devices by the configured trigger key.
Tests in `crates/hotkey/src/linux.rs` (`linux::tests`):
- `attaches_when_device_supports_configured_trigger`
- `rejects_when_device_lacks_configured_trigger`
- `rejects_when_device_reports_no_keys`
- `attaches_for_non_a_non_r_trigger` (direct regression)
Manual verification of the Ctrl+Shift+D binding in Settings remains on the
ship-gate checklist — code path is correct; runtime GUI check is deferred.
## Problem
`try_attach_device` claims to check whether an input device supports the configured hotkey's key, but the implementation tests for hard-coded `KEY_A` or `KEY_R` instead of consulting the actual `HotkeyCombo` that was configured. Hotkeys bound to any other key (which is most of them) can be silently skipped even when the device supports them.
This is a correctness bug in a user-facing feature. A user who binds Kon to `Ctrl+Shift+D` and sees "no hotkey fires" has no obvious path to diagnose it.
## Acceptance
- Device attachment consults the actual configured `HotkeyCombo.trigger` key code.
- Regression test: `try_attach_device` called with a mock device that supports `KEY_D` attaches when the configured hotkey's trigger is `D`, does not attach when the trigger is a key the device doesn't support.
- Manual verification: bind `Ctrl+Shift+D` in Settings, confirm it fires in a running Kon.
## Fix scope
Small. Replace the hard-coded constants with a lookup from the passed-in `HotkeyCombo`.
## Dependencies
- None — standalone fix.

View File

@@ -0,0 +1,29 @@
# RB-11 MAJOR: keystore::store_api_key is a thread-unsafe safe API
**Severity:** MAJOR
**Path:** `crates/cloud-providers/src/keystore.rs:6-18`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
**Labels:** release-blocker, major, unsafe-api, cloud
## Problem
`store_api_key` is declared as a safe `pub fn`. Its implementation relies on `std::env::set_var`, which is documented as Undefined Behaviour outside single-threaded initialisation. The file's module comment acknowledges the precondition but the function signature does not enforce it — any caller can invoke it from any thread, and the compiler won't object.
## Acceptance
Choose one:
1. **Use an OS keychain backend** (e.g. `keyring` crate) so there is no `set_var` involvement. Preferred — actually secret-safe, cross-platform.
2. **Use a process-global `OnceLock` or `Mutex<HashMap>`** inside the module instead of `set_var`. Removes the UB, trades persistence.
3. **Mark `store_api_key` as `unsafe`** and document the "call once before threads spawn" contract at the signature level. Ugly but honest.
Whichever path, update the signature and doc comments to match the safety properties actually provided.
## Fix scope
Medium. Option 1 is the right long-term answer but adds a dep and platform-specific auth prompts (macOS Keychain asks the user on first access). Option 2 is fastest. Option 3 is cosmetic.
## Dependencies
- None — standalone fix.
- Coupled with future BYO LLM endpoint work (storing API keys safely is a prerequisite).

View File

@@ -0,0 +1,24 @@
# RB-10 MAJOR: LLM prompts not preflighted against context window
**Severity:** MAJOR
**Path:** `crates/llm/src/lib.rs:143-166`, `:317-321`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
**Labels:** release-blocker, major, llm
## Problem
`generate` tokenises and batches the full prompt at runtime. `context_window_size` hard-caps context at 8192 tokens. Long transcripts (a 30-minute dictation session is easily 40006000 tokens after segment joining) reach inference with prompts already bigger than the available context — causing late runtime failure instead of a controlled early-exit path.
## Acceptance
- Before inference begins, the prompt token count is compared against the available context window (minus the expected response budget).
- Oversized prompts either (a) surface a typed error the caller can handle gracefully, or (b) are truncated with a logged warning — decide during the fix.
- Regression test: synthesise a transcript whose tokenised form exceeds 8192 tokens, assert the chosen behaviour (early error or truncated input).
## Fix scope
Medium. Tokeniser access is already on the LLM path; the check is cheap. Decision work is in what to do when a prompt is too long (fail hard vs truncate).
## Dependencies
- None — standalone fix.

View File

@@ -0,0 +1,65 @@
# RB-06 MAJOR: native capture worker is detached, can outlive stop/start
**Severity:** MAJOR
**Path:** `src-tauri/src/commands/audio.rs:46-228`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
**Labels:** release-blocker, major, concurrency, audio
**Status:** RESOLVED (2026-04-22)
## Resolution
Introduced `CaptureWorker { stop_tx, join: JoinHandle<()> }` as the
single handle type retained in state. `NativeCaptureState.stop_tx`
(a `std::sync::Mutex<Option<Sender>>`) became `worker:
tokio::sync::Mutex<Option<CaptureWorker>>` — the async mutex is
required because the stop path awaits the join while holding the
lock, and holding a blocking mutex across an await is a bug pattern
we don't want to ship.
New helper `stop_worker(worker)` sends the stop signal, drops the
sender, then `join.await`s the task. Errors from join (panic /
cancellation) are logged and swallowed; the caller needs the
synchronisation barrier, not the task's return value.
Both lifecycle paths route through the helper:
- `start_native_capture` — before opening a new capture, if a
previous worker is resident, stop it and await termination.
This removes the race where the old worker's final flush could
append to `all_samples` after the new path cleared it.
- `stop_native_capture` — take the worker, stop_worker, then read
`all_samples`. The previous 50ms sleep is no longer needed — the
join barrier is exact.
Two regression tests in `commands::audio::tests`:
- `stop_worker_awaits_full_termination_no_writes_after_join`
synthetic worker bumps an atomic counter in a loop, applies a
flush marker at exit. Post-stop-worker the flush marker must be
set and no further writes must appear on a subsequent sleep.
- `stop_worker_is_idempotent_on_a_worker_that_has_already_exited`
a task that finished on its own must still be join-able without
hang or panic.
The full cpal-backed start→stop→start integration test the original
issue asks for is not feasible in a Linux CI without an audio
device. The component test above covers the underlying invariant
the real workflow depends on.
## Problem
`start_native_capture` and `stop_native_capture` coordinate through a channel but never retain the spawned worker handle. A previous capture can still be flushing / appending after `stop_native_capture` clears `all_samples` and before a new `start_native_capture` takes it — output can be truncated or contaminated with cross-session samples.
## Acceptance
- Store the worker's `JoinHandle` in the native capture state.
- `stop_native_capture` awaits the handle before returning — start/stop/start is fully serialised.
- Regression test: rapid start → stop → start sequence produces two distinct samples vectors with no cross-session leakage.
## Fix scope
Medium. Requires adding `JoinHandle` storage and making the stop path `await` cleanly — probably needs a small refactor of the native capture state struct.
## Dependencies
- Independent of other items, though the fix pattern (retain handles, join on stop) mirrors what RB-04 will do for the live-session worker.

View File

@@ -0,0 +1,24 @@
# RB-05 MAJOR: poll_inference treats IPC listener loss as session-fatal
**Severity:** MAJOR
**Path:** `src-tauri/src/commands/live.rs:721-813`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
**Labels:** release-blocker, major, ipc-lifecycle
## Problem
`result_channel.send(...)` propagates with `?`, so closing the listening frontend or reloading the webview terminates the whole live session — even when capture and inference are healthy. Tauri channel-lifecycle events are not transcription failures and should not kill the worker.
## Acceptance
- Channel-send errors log a warning and continue the session (if recoverable) or terminate gracefully (if the session was going to end anyway).
- The distinction between "transcription failed" and "no listener to report to" is explicit in the error handling.
- Regression test: simulate channel close mid-session, assert the worker keeps capturing and produces a valid WAV file.
## Fix scope
Medium. Isolated to `poll_inference` and its error handling; interacts with RB-04 (monolith refactor) since that restructures the same function family.
## Dependencies
- **Related:** RB-04.

View File

@@ -0,0 +1,27 @@
# RB-08 MAJOR: PowerAssertion is a non-functional stub on macOS
**Severity:** MAJOR (macOS only)
**Path:** `src-tauri/src/commands/power.rs:41-121`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md), originally deferred during A.1 #9
**Labels:** release-blocker, major, macos, platform
## Problem
`begin_activity` always returns `Err` on macOS, so `PowerAssertion::begin` converts to `None` and the guard never acquires an `NSProcessInfo beginActivityWithOptions:reason:` assertion. Live recording and LLM cleanup therefore run without App Nap protection on the one platform where it matters.
The stub was deliberate (A.1 #9 acceptance concession — untestable on Linux without a macOS build host). Re-flagged here so it is not forgotten before the first macOS ship.
## Acceptance
- `objc2` + `objc2-foundation` deps added to the kon crate, gated `cfg(target_os = "macos")`.
- `begin_activity` calls `[NSProcessInfo processInfo] beginActivityWithOptions:(NSActivityUserInitiated | NSActivityLatencyCritical) reason:reason]` and retains the returned activity handle.
- `end_activity` calls `endActivity:` on the retained handle.
- Manual-test on a real macOS box: 10-minute background live session completes without throttling; `pmset -g assertions` shows Kon's activity during capture.
## Fix scope
Medium. Dep addition + FFI glue + manual verification. Can be done from Linux with `cargo check --target=aarch64-apple-darwin` for compile validation, but runtime behaviour needs a macOS machine.
## Dependencies
- **Hard blocker:** before first macOS build/ship.

View File

@@ -0,0 +1,27 @@
# RB-04 MAJOR: run_live_session is a 200+ line multi-responsibility monolith
**Severity:** MAJOR
**Path:** `src-tauri/src/commands/live.rs:349-579`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
**Labels:** release-blocker, major, refactor, concurrency
## Problem
`run_live_session` owns mic startup, runtime error draining, resampling, progressive WAV persistence, overload dropping, inference scheduling, and shutdown/finalisation in one 200-line function. The state machine is spread across mutable locals — hard to audit, hard to reason about under concurrency, and already contributing to lifecycle bugs nearby (RB-01, RB-05, RB-06).
## Acceptance
- Split into focused types / functions: capture setup, streaming state, inference scheduler, WAV writer lifecycle, shutdown handler.
- Each function ≤ 30 lines, single responsibility.
- State machine explicit — not implicit in the interleaved mutable locals.
- Lock discipline documented: what must be held when, across what `await` boundaries.
- Existing behaviour preserved — 193 workspace lib tests still green; manual dogfood smoke test of a 30-second live dictation.
## Fix scope
Large. Probably one dedicated session.
## Dependencies
- **Unblocks:** RB-01 (live session race fix becomes tractable once the state machine is small).
- **Related:** RB-05, RB-06 (both lifecycle bugs in the same function).

View File

@@ -0,0 +1,63 @@
# RB-07 MAJOR: get_runtime_capabilities advertises wrong accelerators
**Severity:** MAJOR
**Path:** `src-tauri/src/commands/models.rs:435-489`
**Source:** [2026-04-22 code review](../code-review-2026-04-22.md)
**Labels:** release-blocker, major, ui-integrity
**Status:** RESOLVED (2026-04-22)
## Resolution
Added a pure `compose_accelerators(whisper_enabled, loader_available,
target) -> Vec<String>` helper. It always emits `"cpu"` first and
appends `"metal"` (macOS) or `"vulkan"` (Linux/Windows) only when
whisper is compiled in *and* `vulkan_loader_available()` resolves the
platform's loader shim.
`supported_accelerators()` reads `cfg!(feature = "whisper")`, runs the
live loader probe, and delegates to the helper.
`get_runtime_capabilities` calls `supported_accelerators()` in place
of the hard-coded `vec!["cpu", "vulkan"]`. Whisper's `supports_gpu`
is now `cfg!(feature = "whisper")` — false on whisper-disabled
builds.
Five regression tests in `commands::models::tests` cover:
- `cpu_only_when_whisper_disabled` (both targets)
- `cpu_only_when_loader_missing` (both targets)
- `macos_with_loader_advertises_metal`
- `non_macos_with_loader_advertises_vulkan`
- `cpu_is_always_first_entry` (contract the frontend relies on)
Both `cargo test -p kon --lib` and `cargo test -p kon --lib
--no-default-features` pass the new suite; both `cargo build -p kon`
and `cargo build -p kon --no-default-features` compile clean.
Runtime GUI verification on a real macOS box is still on the ship-gate
checklist — the detection logic is correct in code; Metal-loader
resolution on hardware is not something we can unit-test from a Linux
CI.
## Problem
The IPC response hard-codes `accelerators = ["cpu", "vulkan"]` and `supports_gpu = true` for Whisper, even when:
- `detect_active_compute_device` would report `metal` on macOS (via MoltenVK).
- The binary was compiled without the `whisper` feature — in which case Whisper `supports_gpu` is meaningless because there is no Whisper backend at all.
The frontend uses this response to render Settings toggles (GPU selection, active-device badge, feature availability). Wrong values mean wrong UI states on exactly the builds this function is meant to describe.
## Acceptance
- `accelerators` is derived from actual build configuration and runtime probe, not hard-coded.
- On macOS, `accelerators` includes `"metal"` when the Metal loader resolves.
- On whisper-disabled builds, Whisper entries advertise `supports_gpu = false` (or the engine is omitted from the response entirely).
- Regression tests cover each platform variant via cfg-gated test cases.
## Fix scope
Medium. The detection helpers already exist (`detect_active_compute_device`, `vulkan_loader_available`); this is about wiring their output into the RuntimeCapabilities struct honestly.
## Dependencies
- None — standalone fix.

View File

@@ -9,14 +9,28 @@ edition = "2021"
name = "kon_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[features]
# Default build includes the Whisper backend. Disabling this feature
# also drops it from kon-transcription (see Cargo.toml in that crate)
# so a --no-default-features workspace build does not pull whisper-rs-sys.
# load_model_from_disk returns a runtime error for Engine::Whisper when
# this feature is off; Parakeet continues to work.
default = ["whisper"]
whisper = ["kon-transcription/whisper"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
# Used by the CSP regression guard in build.rs to parse tauri.conf.json
# and pull out app.security.csp rather than substring-matching the raw
# file. Keeps the guard robust against unrelated JSON values that
# happen to mention a localhost URL.
serde_json = "1"
[dependencies]
# Workspace crates
kon-core = { path = "../crates/core" }
kon-audio = { path = "../crates/audio" }
kon-transcription = { path = "../crates/transcription" }
kon-transcription = { path = "../crates/transcription", default-features = false }
kon-ai-formatting = { path = "../crates/ai-formatting" }
kon-storage = { path = "../crates/storage" }
kon-cloud-providers = { path = "../crates/cloud-providers" }

View File

@@ -9,5 +9,65 @@ fn main() {
println!("cargo:rustc-link-arg=-Wl,--allow-multiple-definition");
}
assert_localhost_llm_csp();
tauri_build::build()
}
/// Regression guard for brief item #2 (pre-emptive localhost LLM scope).
///
/// Kon's bundled llama.cpp server and any BYO Ollama install speak HTTP
/// on `127.0.0.1:*`. If the `connect-src` CSP ever drops those entries,
/// `fetch()` from the webview to the local LLM silently 404s with an
/// opaque scope error (Vibe #438 / #487). We keep the current permit
/// pinned at build time so a stray edit can't regress it unnoticed.
///
/// Parses `tauri.conf.json` properly and inspects the `connect-src`
/// directive of the CSP, rather than substring-searching the whole
/// file — the latter would both false-pass on unrelated values
/// containing a localhost URL and false-fail on JSON-escaped forward
/// slashes.
fn assert_localhost_llm_csp() {
let conf_path = std::path::Path::new("tauri.conf.json");
println!("cargo:rerun-if-changed=tauri.conf.json");
let raw = std::fs::read_to_string(conf_path)
.expect("build.rs: failed to read tauri.conf.json for CSP regression guard");
let conf: serde_json::Value = serde_json::from_str(&raw)
.expect("build.rs: tauri.conf.json is not valid JSON");
let csp = conf
.pointer("/app/security/csp")
.and_then(|v| v.as_str())
.expect("build.rs: tauri.conf.json missing app.security.csp string");
// Split the CSP into directives (`default-src 'self'; script-src ...`)
// and find the one whose directive NAME is exactly "connect-src".
// A prefix match would also accept hypothetical future directives
// like "connect-src-elem" (2026-04-22 review MINOR).
let connect_src = csp
.split(';')
.map(str::trim)
.find_map(|directive| {
let (name, rest) = directive.split_once(char::is_whitespace)?;
if name == "connect-src" {
Some(rest.trim())
} else {
None
}
})
.expect(
"build.rs: tauri.conf.json CSP missing connect-src directive — \
local LLM fetches will be blocked (brief item #2)",
);
let tokens: Vec<&str> = connect_src.split_whitespace().collect();
for required in ["http://127.0.0.1:*", "ws://127.0.0.1:*"] {
assert!(
tokens.iter().any(|t| *t == required),
"build.rs: tauri.conf.json CSP connect-src must permit {required} \
for local LLM connectivity (brief item #2). Current connect-src: \
{connect_src:?}"
);
}
}

View File

@@ -2,7 +2,8 @@ use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tauri::{Emitter, Manager};
use tokio::sync::mpsc as tokio_mpsc;
use tokio::sync::{mpsc as tokio_mpsc, Mutex as AsyncMutex};
use tokio::task::JoinHandle;
use kon_audio::{DeviceInfo, MicrophoneCapture};
use kon_core::constants::WHISPER_SAMPLE_RATE;
@@ -19,10 +20,37 @@ pub async fn list_audio_devices() -> Result<Vec<DeviceInfo>, String> {
.map_err(|e| e.to_string())
}
/// A running native-capture accumulator worker, held so the command
/// layer can both signal it to stop and `await` its termination. RB-06
/// replaced a fire-and-forget `tokio::spawn` that let the previous
/// worker keep flushing and appending samples after `stop_native_capture`
/// returned — a rapid start → stop → start could contaminate the new
/// session's samples vector with tail writes from the old one.
struct CaptureWorker {
stop_tx: tokio_mpsc::Sender<()>,
join: JoinHandle<()>,
}
/// Send the stop signal and await full worker termination. Consumes
/// `CaptureWorker` because the contained handles are single-use.
/// Errors from `join.await` (task panicked or was cancelled) are
/// logged and swallowed — the caller only needs the synchronisation
/// barrier, not the worker's return value.
async fn stop_worker(worker: CaptureWorker) {
let _ = worker.stop_tx.send(()).await;
drop(worker.stop_tx);
if let Err(e) = worker.join.await {
eprintln!("[native-capture] worker task did not terminate cleanly: {e}");
}
}
/// Shared state for native microphone capture.
pub struct NativeCaptureState {
/// Stop signal sender — dropping this stops the accumulator task.
stop_tx: Mutex<Option<tokio_mpsc::Sender<()>>>,
/// The running accumulator worker, if any. `tokio::sync::Mutex`
/// because the fastest-moving consumer (`stop_worker`) awaits while
/// holding the lock — a `std::sync::Mutex` would have to be released
/// and reacquired around each await point.
worker: AsyncMutex<Option<CaptureWorker>>,
/// All captured samples (16kHz mono) for save_audio.
all_samples: Arc<Mutex<Vec<f32>>>,
}
@@ -30,7 +58,7 @@ pub struct NativeCaptureState {
impl NativeCaptureState {
pub fn new() -> Self {
Self {
stop_tx: Mutex::new(None),
worker: AsyncMutex::new(None),
all_samples: Arc::new(Mutex::new(Vec::new())),
}
}
@@ -53,13 +81,12 @@ pub async fn start_native_capture(
device_name.as_deref().unwrap_or("<auto>")
);
// Stop any existing capture: send an explicit stop signal first, then
// drop the sender. The accumulator task watches for `Disconnected` too,
// but signalling explicitly avoids the brief race window.
// (Codex review 2026/04/17 D1)
if let Some(tx) = state.stop_tx.lock().unwrap().take() {
let _ = tx.try_send(());
drop(tx);
// Stop any in-flight worker and AWAIT its termination before opening
// a new capture. Without the join we would race a draining worker
// against the `all_samples.clear()` below, leaving old-session
// samples in the new-session vector (RB-06).
if let Some(existing) = state.worker.lock().await.take() {
stop_worker(existing).await;
}
// `MicrophoneCapture::start()` is synchronous and may spend up to
@@ -91,13 +118,13 @@ pub async fn start_native_capture(
all_samples.lock().unwrap().clear();
let (stop_tx, mut stop_rx) = tokio_mpsc::channel::<()>(1);
*state.stop_tx.lock().unwrap() = Some(stop_tx);
let all_samples_clone = all_samples.clone();
// Spawn a task that reads cpal chunks, downsamples to 16kHz mono,
// and emits events to the frontend
tokio::spawn(async move {
// and emits events to the frontend. The JoinHandle is retained in
// `state.worker` so `stop_native_capture` can await full termination.
let join = tokio::spawn(async move {
let mut pcm_buffer: Vec<f32> = Vec::new();
let chunk_size = 8000_usize; // ~0.5s at 16kHz
@@ -203,23 +230,24 @@ pub async fn start_native_capture(
}
});
*state.worker.lock().await = Some(CaptureWorker { stop_tx, join });
Ok(())
}
/// Stop native microphone capture. Returns all captured samples (16kHz mono).
///
/// Awaits full worker termination before reading `all_samples`, so the
/// returned vector contains every sample the worker flushed — and
/// nothing from a worker that technically outlived the call (RB-06).
#[tauri::command]
pub async fn stop_native_capture(
state: tauri::State<'_, NativeCaptureState>,
) -> Result<Vec<f32>, String> {
// Extract the stop sender without holding the guard across an await
let stop_tx = state.stop_tx.lock().unwrap().take();
if let Some(tx) = stop_tx {
let _ = tx.send(()).await;
if let Some(worker) = state.worker.lock().await.take() {
stop_worker(worker).await;
}
// Brief delay to let the accumulator flush
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let samples = {
let mut all = state.all_samples.lock().unwrap();
std::mem::take(&mut *all)
@@ -228,36 +256,187 @@ 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)
.map_err(|e| format!("Failed to create recordings dir: {e}"))?;
let timestamp = std::time::SystemTime::now()
Ok(recordings_dir.join(recording_filename()))
}
/// Deterministic recording filename generator. Combines three fields
/// for absolute uniqueness across rapid calls:
///
/// - wall-clock seconds since the epoch — human-readable and
/// sortable;
/// - the sub-second nanosecond component — defeats same-second
/// collisions;
/// - a process-lifetime atomic counter — defeats even same-nanosecond
/// collisions, which `SystemTime::now()` alone cannot guarantee
/// (two calls in the same clock tick can return identical nanos).
///
/// Format: `kon-<secs>-<nanos_in_sec>-<counter>.wav`, e.g.
/// `kon-1776828000-123456789-0000.wav`.
fn recording_filename() -> String {
let duration = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let filename = format!("kon-{timestamp}.wav");
let path = recordings_dir.join(&filename);
.unwrap_or_default();
let secs = duration.as_secs();
let nanos = duration.subsec_nanos();
let counter = RECORDING_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
format!("kon-{secs}-{nanos:09}-{counter:04}.wav")
}
/// Process-lifetime monotonic counter for `recording_filename`. Starts
/// at 0 on each Kon launch; wall-clock secs/nanos still advance across
/// restarts, so cross-launch collisions are already impossible — the
/// counter is the last-mile guarantee against within-launch same-tick
/// collisions.
static RECORDING_COUNTER: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
#[cfg(test)]
mod tests {
use super::{recording_filename, stop_worker, CaptureWorker};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use tokio::sync::mpsc;
#[test]
fn recording_filenames_are_unique_across_rapid_calls() {
// Regression for the 2026-04-22 review AND the review-of-
// review MINOR: SystemTime::now() alone cannot guarantee
// uniqueness under tight loops on every OS clock resolution,
// so the filename now includes a process-lifetime atomic
// counter. With the counter, uniqueness is absolute across
// any number of in-process calls.
let mut names = std::collections::HashSet::new();
for _ in 0..1024 {
names.insert(recording_filename());
}
assert_eq!(
names.len(),
1024,
"every filename must be unique (counter-backed guarantee)"
);
}
#[test]
fn recording_filename_has_expected_shape() {
let name = recording_filename();
assert!(name.starts_with("kon-"));
assert!(name.ends_with(".wav"));
// Shape: kon-<digits>-<9 digits>-<>=4 digits>.wav
let rest = name
.strip_prefix("kon-")
.and_then(|s| s.strip_suffix(".wav"))
.expect("shape prefix/suffix");
let parts: Vec<&str> = rest.split('-').collect();
assert_eq!(
parts.len(),
3,
"expected three '-' separated parts, got {parts:?}"
);
assert!(parts[0].chars().all(|c| c.is_ascii_digit()), "secs is digits");
assert_eq!(parts[1].len(), 9, "nanos component is zero-padded to 9 digits");
assert!(parts[1].chars().all(|c| c.is_ascii_digit()));
assert!(parts[2].len() >= 4, "counter component is zero-padded to >=4 digits");
assert!(parts[2].chars().all(|c| c.is_ascii_digit()));
}
// RB-06 regression: after `stop_worker(worker).await` completes, the
// underlying task must have exited — no lingering writes to shared
// state can leak past the stop point. The real native-capture
// worker drains a capture queue and appends to `all_samples`; this
// test swaps that for a synthetic worker that bumps an atomic
// counter in a loop and applies a distinct "flush" marker at exit.
// The assertions mirror the real-world invariant a caller needs:
// (a) after stop_worker returns, the worker has run its flush;
// (b) subsequent sleeps see the counter frozen — no writes occur
// after the join barrier.
// Pre-fix behaviour (fire-and-forget `tokio::spawn`) failed both:
// a start→stop→start cycle could observe tail writes from the
// previous worker in the new session's vector.
#[tokio::test]
async fn stop_worker_awaits_full_termination_no_writes_after_join() {
let counter = Arc::new(AtomicU32::new(0));
let counter_task = counter.clone();
let (stop_tx, mut stop_rx) = mpsc::channel::<()>(1);
let join = tokio::spawn(async move {
loop {
if stop_rx.try_recv().is_ok() {
break;
}
counter_task.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
}
// Flush marker — mirrors the final pcm_buffer drain in the
// real worker. Setting a value with a distinctive high bit
// so the test can prove the flush ran.
counter_task.fetch_or(0x8000_0000, Ordering::SeqCst);
});
// Let the worker accumulate a few bumps before we signal stop.
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
stop_worker(CaptureWorker { stop_tx, join }).await;
let after_stop = counter.load(Ordering::SeqCst);
assert!(
after_stop & 0x8000_0000 != 0,
"flush marker must be set post-stop (got {after_stop:#x})"
);
// Post-join, no further writes are possible because the task
// has ended. Sleep briefly and re-read to confirm.
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let later = counter.load(Ordering::SeqCst);
assert_eq!(
later, after_stop,
"no writes must happen after stop_worker returns"
);
}
#[tokio::test]
async fn stop_worker_is_idempotent_on_a_worker_that_has_already_exited() {
// A worker that stops itself (channel disconnected, capture
// dead, etc.) must still be join-able cleanly by stop_worker —
// the helper should swallow any expected "task already done"
// condition without panicking.
let (stop_tx, _stop_rx) = mpsc::channel::<()>(1);
let join = tokio::spawn(async { /* exit immediately */ });
// Give the task a tick to finish.
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
// This must not hang or panic.
stop_worker(CaptureWorker { stop_tx, join }).await;
}
}
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>,
@@ -122,7 +122,6 @@ pub struct LiveResultMessage {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "type")]
#[allow(dead_code)]
pub enum LiveStatusMessage {
Warning {
session_id: u64,
@@ -147,7 +146,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 +191,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 +251,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 +285,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 +316,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 +349,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 +375,39 @@ 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())
} else {
None
// 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,
};
// `reported_audio_path` is decided at end-of-session based on
// whether the writer finalised successfully, not at open time.
// This way a writer that dies mid-session (append error clearing
// wav_writer, or finalise returning Err) does not leak a stale
// path back to the frontend that might point to a file whose
// header is out of sync with its data chunk.
let mut buffer_start_sample: u64 = 0;
let mut dropped_audio_ms: u64 = 0;
let mut chunk_id: u32 = 0;
@@ -412,7 +457,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 +493,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 +548,62 @@ fn run_live_session(
thread::sleep(Duration::from_millis(10));
}
// Finalise the progressive WAV writer and decide whether to
// report a path to the frontend. Only a clean finalise produces a
// reported path: a writer that died mid-session (wav_writer was
// already None) or a finalise that itself errored both yield
// `None`, so `StopLiveTranscriptionResponse.audio_path` reflects
// "recording is known-good" rather than "recording was attempted".
let audio_path = match wav_writer.take() {
Some(writer) => match writer.finalize() {
Ok(()) => audio_path.as_ref().map(|p| p.to_string_lossy().to_string()),
Err(e) => {
let _ = status_channel.send(LiveStatusMessage::Warning {
session_id,
message: format!(
"WAV finalise failed: {e}. A partial recording may still be on disk from earlier flushes."
),
});
None
}
},
None => None,
};
Ok(LiveSessionSummary {
session_id,
dropped_audio_ms,
audio_samples: kept_audio,
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;
}
}
}

View File

@@ -9,7 +9,9 @@ use kon_core::hardware::{self, CpuFeatures};
use kon_core::model_registry::{self, Engine, LanguageSupport, ModelEntry};
use kon_core::types::{AudioSamples, ModelId, TranscriptionOptions};
use kon_transcription::model_manager;
use kon_transcription::{load_parakeet, load_whisper, LocalEngine};
use kon_transcription::{load_parakeet, LocalEngine, Transcriber};
#[cfg(feature = "whisper")]
use kon_transcription::load_whisper;
/// Map legacy size strings to ModelId.
fn whisper_model_id(size: &str) -> ModelId {
@@ -73,11 +75,12 @@ fn model_capability(
pub fn load_model_from_disk(
model_id: &ModelId,
) -> Result<kon_transcription::SpeechBackend, String> {
) -> Result<Box<dyn Transcriber + Send>, String> {
let entry =
model_registry::find_model(model_id).ok_or_else(|| format!("Unknown model: {model_id}"))?;
match entry.engine {
#[cfg(feature = "whisper")]
Engine::Whisper => {
let dir = model_manager::model_dir(model_id);
let model_file = entry
@@ -90,6 +93,11 @@ pub fn load_model_from_disk(
}
load_whisper(&model_file).map_err(|e| e.to_string())
}
#[cfg(not(feature = "whisper"))]
Engine::Whisper => Err(format!(
"Whisper backend not compiled in this build (kon built without the \"whisper\" feature); \
cannot load {model_id}"
)),
Engine::Parakeet => {
let dir = model_manager::model_dir(model_id);
if !dir.exists() {
@@ -333,6 +341,51 @@ fn vulkan_loader_available() -> bool {
false
}
/// Compile-time target signalling used by `compose_accelerators`.
/// Split out so the pure-function behaviour is testable without `cfg!`
/// appearing in the test matrix.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AcceleratorTarget {
Macos,
Other,
}
/// Pure helper: produce the `accelerators` list from the
/// whisper-compiled-in flag, runtime loader availability, and target
/// family. Always starts with `cpu`; appends the platform-appropriate
/// GPU name only when whisper is compiled in AND the Vulkan loader
/// resolves. RB-07 replaced a hard-coded `["cpu", "vulkan"]`.
fn compose_accelerators(
whisper_enabled: bool,
loader_available: bool,
target: AcceleratorTarget,
) -> Vec<String> {
let mut accelerators = vec!["cpu".into()];
if whisper_enabled && loader_available {
let gpu = match target {
AcceleratorTarget::Macos => "metal",
AcceleratorTarget::Other => "vulkan",
};
accelerators.push(gpu.into());
}
accelerators
}
/// Public wrapper around `compose_accelerators` that reads the real
/// `cfg(feature = "whisper")`, runtime loader probe, and target OS.
fn supported_accelerators() -> Vec<String> {
let target = if cfg!(target_os = "macos") {
AcceleratorTarget::Macos
} else {
AcceleratorTarget::Other
};
compose_accelerators(
cfg!(feature = "whisper"),
vulkan_loader_available(),
target,
)
}
/// Report which backend whisper.cpp was actually able to initialise
/// against. The whisper-rs build here is compiled with the `vulkan`
/// feature unconditionally; on macOS that's still Metal via MoltenVK,
@@ -445,12 +498,14 @@ pub fn get_runtime_capabilities(
let active_compute_device = detect_active_compute_device();
let cpu_features: CpuFeaturesInfo = hardware::probe_cpu_features().into();
// Kon's desktop build links transcribe-rs with the `whisper-vulkan`
// feature unconditionally (see crates/transcription/Cargo.toml), so
// whisper.cpp boots with Vulkan backend on any machine with a Vulkan
// loader + ICD. Parakeet (ONNX) still runs on CPU. Reflect both.
// Accelerator list is now derived from the live build configuration
// and runtime loader probe — see `compose_accelerators`. Parakeet
// (ONNX) stays CPU-only; Whisper advertises `supports_gpu` only
// when it was actually compiled in for this binary.
let whisper_supports_gpu = cfg!(feature = "whisper");
Ok(RuntimeCapabilities {
accelerators: vec!["cpu".into(), "vulkan".into()],
accelerators: supported_accelerators(),
engines: vec![
EngineRuntimeCapabilities {
id: "whisper".into(),
@@ -458,7 +513,7 @@ pub fn get_runtime_capabilities(
loaded_model_id: whisper
.loaded_model_id()
.map(|model_id| model_id.to_string()),
supports_gpu: true,
supports_gpu: whisper_supports_gpu,
models: whisper_models,
},
EngineRuntimeCapabilities {
@@ -586,3 +641,66 @@ pub async fn load_parakeet_model(
pub fn check_parakeet_engine(state: tauri::State<'_, AppState>) -> Result<bool, String> {
Ok(state.parakeet_engine.is_loaded())
}
#[cfg(test)]
mod tests {
use super::*;
// RB-07: the accelerator list is now derived from compile flags +
// runtime loader probe + target. These cover the permutations.
//
// Pre-fix behaviour was `vec!["cpu".into(), "vulkan".into()]`
// regardless of whisper feature, loader availability, or platform —
// so a macOS build falsely advertised "vulkan", and a no-whisper
// build falsely advertised a GPU path with no engine to use it.
#[test]
fn cpu_only_when_whisper_disabled() {
assert_eq!(
compose_accelerators(false, true, AcceleratorTarget::Macos),
vec!["cpu".to_string()]
);
assert_eq!(
compose_accelerators(false, true, AcceleratorTarget::Other),
vec!["cpu".to_string()]
);
}
#[test]
fn cpu_only_when_loader_missing() {
assert_eq!(
compose_accelerators(true, false, AcceleratorTarget::Macos),
vec!["cpu".to_string()]
);
assert_eq!(
compose_accelerators(true, false, AcceleratorTarget::Other),
vec!["cpu".to_string()]
);
}
#[test]
fn macos_with_loader_advertises_metal() {
assert_eq!(
compose_accelerators(true, true, AcceleratorTarget::Macos),
vec!["cpu".to_string(), "metal".to_string()]
);
}
#[test]
fn non_macos_with_loader_advertises_vulkan() {
assert_eq!(
compose_accelerators(true, true, AcceleratorTarget::Other),
vec!["cpu".to_string(), "vulkan".to_string()]
);
}
#[test]
fn cpu_is_always_first_entry() {
// Frontend relies on index-0 being the fallback; preserve that
// contract regardless of which GPU extras are added.
for target in [AcceleratorTarget::Macos, AcceleratorTarget::Other] {
let full = compose_accelerators(true, true, target);
assert_eq!(full.first(), Some(&"cpu".to_string()));
}
}
}

View File

@@ -72,18 +72,7 @@ pub async fn paste_text(app: tauri::AppHandle, text: String) -> Result<PasteOutc
message: None,
};
// Snapshot the user's existing clipboard text BEFORE we stomp it
// with the transcript, so a background task can restore it after
// the paste keystroke has landed. `arboard::Clipboard::get_text`
// returns Err when the clipboard holds non-text content (images,
// files, empty) — in those cases we skip the restore step
// entirely rather than trying to coerce. This is the "never
// silently clobber the user's clipboard" contract from Handy
// #921 (Workstream B brief item #3).
let prior_clipboard: Option<String> = match Clipboard::new() {
Ok(mut cb) => cb.get_text().ok(),
Err(_) => None,
};
let prior_clipboard = snapshot_clipboard_text();
match Clipboard::new().and_then(|mut cb| cb.set_text(&text)) {
Ok(()) => outcome.copied = true,
@@ -121,25 +110,40 @@ pub async fn paste_text(app: tauri::AppHandle, text: String) -> Result<PasteOutc
Err(err) => outcome.message = Some(err),
}
// Fire-and-forget: restore the prior clipboard in the background
// after CLIPBOARD_RESTORE_MS, regardless of whether the paste
// succeeded. If paste failed, the transcript is still on the
// clipboard and Kon's own "Copy" button remains the user's
// recovery path; overwriting it with the restore would undo that.
// So only restore when the keystroke actually fired.
if outcome.pasted {
if let Some(prior) = prior_clipboard.clone() {
let transcript_clone = text.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(CLIPBOARD_RESTORE_MS)).await;
restore_prior_clipboard(&prior, &transcript_clone);
});
}
schedule_clipboard_restore(prior_clipboard, text);
}
Ok(outcome)
}
/// Snapshot the clipboard's current text, or `None` when the clipboard
/// holds non-text content (images, files, empty). Shared between
/// `paste_text` and `paste_text_replacing` so both halves of the
/// dictation flow obey the "never silently clobber the user's
/// clipboard" contract from Handy #921 (brief item #3).
fn snapshot_clipboard_text() -> Option<String> {
match Clipboard::new() {
Ok(mut cb) => cb.get_text().ok(),
Err(_) => None,
}
}
/// Fire-and-forget: restore `prior` to the clipboard after
/// `CLIPBOARD_RESTORE_MS`, but only if the clipboard still holds the
/// `transcript` we set (i.e., the user hasn't copied something new in
/// the window). No-op when `prior` is `None` — nothing safe to
/// restore.
fn schedule_clipboard_restore(prior: Option<String>, transcript: String) {
let Some(prior) = prior else {
return;
};
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(CLIPBOARD_RESTORE_MS)).await;
restore_prior_clipboard(&prior, &transcript);
});
}
/// Write `prior` back to the clipboard only if the current clipboard
/// content is still the transcript we just set — i.e. the user hasn't
/// already copied something new themselves in the last 300 ms. This
@@ -189,6 +193,14 @@ pub async fn paste_text_replacing(
message: None,
};
// Same clipboard-preservation contract as `paste_text`: snapshot
// the user's prior clipboard before we write the raw transcript,
// restore it in the background after the paste keystroke fires.
// Without this, a successful revert leaves the raw transcript on
// the clipboard permanently — stomping whatever the user had
// copied before invoking replace-with-raw.
let prior_clipboard = snapshot_clipboard_text();
match Clipboard::new().and_then(|mut cb| cb.set_text(&text)) {
Ok(()) => outcome.copied = true,
Err(err) => {
@@ -214,6 +226,10 @@ pub async fn paste_text_replacing(
Err(err) => outcome.message = Some(err),
}
if outcome.pasted {
schedule_clipboard_restore(prior_clipboard, text);
}
Ok(outcome)
}

View File

@@ -70,9 +70,11 @@ function playChord(frequencies: number[], volume: number, stagger = 0): void {
function resolveVolume(userVolume: number | undefined, enabled: boolean): number {
if (!enabled) return 0;
const v = typeof userVolume === "number" ? userVolume : DEFAULT_VOLUME;
// Clamp: 0 → silent, 1 → loud. Protect against bad settings.
if (v <= 0) return 0;
if (v >= 1) return 1;
if (!Number.isFinite(v) || v <= 0) return 0;
// Any value >1 means a scale drift somewhere upstream (e.g. a 0100
// slider leaking through unscaled). Clamping to 1 would blast the
// user at full volume; fall back to DEFAULT_VOLUME instead.
if (v > 1) return DEFAULT_VOLUME;
return v;
}