Vox Axis STT Accuracy Design (2026-08-01)
Vox Axis STT Accuracy Design (2026-08-01)
Section titled “Vox Axis STT Accuracy Design (2026-08-01)”Problem
Section titled “Problem”Voice dictation into the Vox Axis GUI chat composer (the Loquela surface) goes through a real, fairly mature pipeline — Candle Whisper capture, VAD, a rule-based + optional-LLM correction pass, contextual bias, a lexicon — but a code audit found the pipeline is unmeasured and has several concrete, fixable defects. This design fixes the defects, replaces the default ASR engine with a faster and more accurate one, and unblocks a code-dictation capability that already has an implementation but is not reachable from the shipped GUI.
Audit findings (input to this design)
Section titled “Audit findings (input to this design)”-
Code-dictation symbol expansion never fires in the GUI.
speech_normalize.rshas real “open paren” →(, “camel case get user name” →getUserNamelogic, but it is only applied as an alternate n-best candidate insidetranscript_rerank::build_transcript_candidates(crates/vox-speech/src/transcript_rerank.rs:25-29). Candidate selection (pick_best_transcript_index_with_raw) is a no-op returning index 0 unless thecompiler-rerankCargo feature is enabled (transcript_rerank.rs:101-116).vox-gui/Cargo.toml:40enablesvox-speechwith onlyfeatures = ["stt-candle"]—compiler-rerankis neither enabled nor a default feature (crates/vox-speech/Cargo.toml:16,default = []). Net effect: dictating code produces literal words, not symbols/casing, in the shipped app. -
The eval corpus is never run.
crates/vox-speech/tests/fixtures/eval_manifests/has three manifests, including a purpose-built code-dictation set (vox_code_corpus_v1.jsonl), andeval.rshas WER/CER scoring functions — but nothing incargo testor CI reads these manifests. They’re exercised only by a manual CLI eval command (crates/vox-ml-cli/src/commands/oratio_cmd.rs:636-637). No automated regression signal exists for ASR accuracy. -
Broken correction rules — and unreachable at runtime. In
crates/vox-speech/src/refine/rules.rs,code_confusion_map()’s entries —("mut self", "mut self")(line 43, an identity no-op),("impl for", "impl for ")(line 42, only appends a trailing space),("box dine", "Box<dyn ")(line 31, an unbalanced<with no closing logic), and("print len", "println!")/("print el in", "println!")(lines 39-40, unvalidated phonetic guesses) — are all keyed on multi-word phrases.refine_transcript’s matching loop (rules.rs:115-153) only looks up single whitespace-split tokens viaconfusion.get(lower.as_str()), so none of these multi-word keys can ever match: the map is dead code today, not a set of rules that misfire at runtime. Confirmed empirically —refine_transcript("box dine error", &ctx)returns the input unchanged on the current code. Any fix to the mapped values must be paired with a fix to this token-vs-phrase mismatch in the matching logic, or the corrected entries remain equally unreachable. -
Lexicon collisions with common English words.
default_domain_lexicon(rules.rs:47-64) includes generic words"status"and"workflow", which get force-lowercased mid-transcript bydomain_lexicon_case(rules.rs:140-151) regardless of normal English capitalization context. -
Default ASR model chosen without benchmarking.
candle_whisper.rs:210,462defaults toopenai/whisper-tiny.enwith no comments or docs discussing an accuracy/latency tradeoff, despite the WER/CER infra in finding #2 existing to measure exactly this choice. -
oratio.rs/oratioVoiceInput.tsare unwired, but not dead code — correction from an earlier pass of this audit.crates/vox-gui/src/commands/oratio.rshas zero live frontend callers (confirmed via exhaustive search ofvox-gui/ui/srcforinvoke('oratio_transcribe'...)— only its own tests reference it), so an initial pass of this audit called it dead code. On closer inspection it is not: it routes throughvox_plugin_host::cached_code_plugin("oratio")(thevox-plugin-speechplugin, seecrates/vox-plugin-speech/Plugin.toml— “Extracted from vox-speech in Unit 4”), which is the exact same plugincrates/vox-orchestrator-mcp/src/oratio_tools.rsalready uses in production for agent-facing voice tools, and is the literal target of the TODO incrates/vox-speech/Cargo.toml:21(“drop stt-candle after audio-ingress rewire/retirement… once ingress is rewired throughvox_plugin_host::load_code_plugin+as_speech_to_text()”). It is a correct implementation of the intended target architecture that the frontend was simply never switched to — not throwaway code. It is genuinely unwired for a real reason, though:oratio_transcribe(seconds: f32)records a fixed duration, while Loquela’s actual dictation UX (start_mic_capture/stop_mic_capture_and_transcribeinmic.rs) is push-to-talk (user-controlled start/stop). Reconciling that interaction-model mismatch is a real, separable migration (routingmic.rs’s capture through the plugin’sSpeechToText/AudioCaptureaccessors instead of callingvox_speech::transcribe_path_detailedin-process) — out of scope for this design and tracked as a separate follow-up (task_f971226b). This design does not touchoratio.rsororatioVoiceInput.ts. -
No GUI Settings exposure. ~30
VOX_ORATIO_*env vars (crates/vox-speech/src/runtime_config.rs) control STT behavior, butSettings/SettingsView.tsxhas no voice/STT section — every knob requires editing environment variables or a TOML file outside the app.
External research: is there a better ASR model than Whisper-tiny?
Section titled “External research: is there a better ASR model than Whisper-tiny?”Yes. NVIDIA Parakeet-TDT-0.6B (v2/v3), a non-autoregressive NeMo transducer model:
- 6.32% WER vs Whisper large-v3’s 7.44% on the Open ASR Leaderboard —
and a much larger margin over
whisper-tiny.en, the model Vox actually ships. - Orders of magnitude faster at inference (RTFx in the thousands vs Whisper’s
tens/hundreds) — public benchmarks describe transcribing 60 minutes of audio
in ~1 second on suitable hardware. This matters for a real-time dictation
button, not just batch transcription. Caveat: that RTFx figure is
steady-state batch throughput and does not include model-load/session-init
time. In the current codebase, every dictation stop re-instantiates the ASR
backend from scratch —
create_backend()is called fresh per transcription with no caching (crates/vox-speech/src/traits.rs,crates/vox-speech/src/backend_dispatch.rs; noOnceCell/LazyLock/static backend cache exists anywhere invox-speech). If Parakeet becomes the default under this same call pattern, resolving the ~671MB int8 ONNX model and constructing a freshOfflineRecognizerONNX Runtime session happens on every utterance, not once at startup — overhead this design does not measure and must account for before shipping (see the Phase 2 gate below). - CC-BY-4.0 license — commercial use is permitted, but CC-BY-4.0 is not an unconditional grant: it conditions all use, commercial or not, on attribution (credit to the creator, a link to the license, and indication of any changes made). Neither this design nor the current product has an attribution mechanism (a NOTICE/third-party-licenses file, an About/Licenses panel in Settings, or README credit) to satisfy this for a shipped-default model; Phase 2 or Phase 4 needs to add one before Parakeet ships as the default.
- English-only (v2) or 25-language (v3, still English-strong) — sufficient for a coding-agent product where English technical dictation is the primary workload.
- Pre-built int8-quantized ONNX exports already exist for sherpa-onnx
(e.g.
sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8on Hugging Face), using ~3GB RAM unquantized, less when quantized.
NVIDIA Canary-1B-v2 was also evaluated (also CC-BY-4.0, ONNX exports exist) but rejected for this use case: it’s multilingual/translation-focused and nearly 2x the parameter count, which is unnecessary for English technical dictation and would cost more latency/memory for no accuracy benefit here.
Why this is cheap to ship: crates/vox-speech/src/backends/sherpa_onnx.rs
already exists and already wraps the sherpa-onnx Rust crate — it is
currently hardcoded to OfflineWhisperModelConfig. The sherpa-onnx bindings
also expose OfflineTransducerModelConfig, which is what NeMo transducer
models (Parakeet) need. The GUI does not currently compile this backend in at
all (vox-gui/Cargo.toml:40 enables only stt-candle). Switching the default
engine is a config/wiring change at the source-code-surface level, not a new
backend or new dependency — but “config/wiring change” describes the code
diff, not the runtime cost; see the per-utterance re-instantiation caveat
above and the model-distribution and platform risks below, none of which are
config/wiring-sized concerns.
Known risks, explicitly designed around below:
- Native-library packaging:
sherpa-onnxhas never been built into a shipped binary in this project — it is an optional dependency referenced only withinvox-speech’s ownCargo.toml(stt-sherpa = ["dep:sherpa-onnx"],crates/vox-speech/Cargo.toml:36), and that crate’s Cargo.toml carries a TODO suggesting the opposite migration was once planned (“drop stt-candle after audio-ingress rewire/retirement”,Cargo.toml:21). Unlike Candle (pure Rust, no native library),sherpa-onnxlinks against the ONNX Runtime native shared library, which the Tauri installer must bundle correctly on Windows, macOS, and Linux — untested for this project. This design treats “packages and runs on all three platforms” as an explicit gate before Parakeet becomes the shipped default, with Candle-Whisper retained as an automatic fallback. - Windows-specific CRT conflict, not currently in the gate: the pinned
sherpa-onnx-syscrate (v1.13.3) links ONNX Runtime statically by default using the MSVC static CRT (/MT), which is documented upstream (k2-fsa/sherpa-onnx discussion #1202, “Linking and search path”) to conflict with the dynamic CRT (/MD) used by typical production Rust/MSVC builds — a Windows packaging failure mode distinct from, and more specific than, the general “packages and runs on all three platforms” smoke test above. The cross-platform packaging gate must explicitly verify the Windows build links cleanly (or pin a build configuration that avoids the/MT//MDmismatch), not just that the app launches. - Model distribution/bundling is unaddressed:
resolve_sherpa_model_paths(and its Phase 2 extension for the joiner file) falls back to a live Hugging Face Hub API download whenever the model-directory env var isn’t set, with no offline-only mode or progress callback (crates/vox-speech/src/backends/sherpa_model_config.rs), andvox-gui/tauri.conf.json’s bundle section has noresourcesentry to ship the model with the installer (onlyexternalBinfor the CLI binary). Combined with the per-utterance re-instantiation risk above, making sherpa the tried-first default means a fresh install’s first dictation attempt has a live network dependency and, on a cold model cache, blocks synchronously on a multi-hundred-megabyte download with no progress UI, timeout, or documented offline failure mode. This design does not yet specify a bundling or pre-fetch strategy; one is needed before Phase 2 ships.
Design: phased plan
Section titled “Design: phased plan”Phase 0 — Eval harness + dead-code removal (foundation)
Section titled “Phase 0 — Eval harness + dead-code removal (foundation)”- Add an automated test (
crates/vox-speech/tests/eval_regression.rsor equivalent) that loads the three fixture manifests, runs them through the real pipeline, computes WER/CER via the existingeval.rsfunctions, and asserts against a checked-in baseline (fails on regression beyond a small tolerance).- Open question to resolve during implementation: confirm whether audio
files exist alongside the JSONL manifests, or whether they are
transcript-only. If no audio corpus exists, this phase includes sourcing
or synthesizing minimal audio fixtures for at least the code-dictation
manifest (
vox_code_corpus_v1.jsonl), since that’s the highest-value one to protect against regression.
- Open question to resolve during implementation: confirm whether audio
files exist alongside the JSONL manifests, or whether they are
transcript-only. If no audio corpus exists, this phase includes sourcing
or synthesizing minimal audio fixtures for at least the code-dictation
manifest (
- Output: a CI-visible accuracy signal that every later phase is graded against.
- (No dead-code removal in this phase — see the corrected finding #6 above;
oratio.rs/oratioVoiceInput.tsare left untouched, tracked separately.)
Phase 1 — Correction-rule bug fixes
Section titled “Phase 1 — Correction-rule bug fixes”- Remove or fix the no-op/broken rules in
rules.rs: drop themut selfidentity mapping; fix or dropimpl for(trailing-space-only); fixbox dine→Box<dynto also handle the closing>correctly (or scope it to a validated pattern with a following type token); remove the unvalidatedprint len/print el inphonetic guesses unless backed by real transcription samples showing they’re needed. These content fixes are necessary but not sufficient: per audit finding #3,code_confusion_map’s keys are multi-word phrases, but the matching loop that consults the map only looks up single whitespace-split tokens, so none of these entries can fire regardless of their mapped value. Fixing the map’s contents must be paired with fixing (or replacing) the matching logic to actually look up multi-word phrases against the transcript — otherwise the corrected entries stay unreachable dead code, same as today. - Remove
"status"and"workflow"fromdefault_domain_lexicon(or scope the case-forcing rule to only apply when the word appears in a clearly-technical context, e.g. adjacent to other domain terms). - Each fix gets a direct unit test reproducing the bug and asserting the fix.
Phase 2 — Parakeet-via-sherpa-onnx as default backend
Section titled “Phase 2 — Parakeet-via-sherpa-onnx as default backend”- Extend
sherpa_model_config.rs::resolve_sherpa_model_pathsto also resolve ajoiner.onnxpath (transducer models need encoder + decoder + joiner, vs. Whisper’s encoder + decoder), and change the default HF model ID fromk2-fsa/sherpa-onnx-whisper-tiny.ento the pre-built int8 Parakeet-TDT repo. Open question to resolve during implementation: pin the exact HF repo ID and filenames at implementation time (candidates found during research:sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8,sherpa-onnx-nemo-parakeet-unified-en-0.6b-int8-non-streaming) — verify which is actually published and maintained before wiring the default. - Extend
sherpa_onnx.rsto buildOfflineRecognizerConfigwithmodel_config.transducer = OfflineTransducerModelConfig{..}instead ofmodel_config.whisper, selected by which model files are present (or by an explicit model-kind field). - Flip
vox-gui/Cargo.toml:40to enablestt-sherpain addition tostt-candle(Candle stays compiled in as the fallback, not removed). backend_dispatch.rs: default backend selection tries sherpa+Parakeet first; on init failure (missing native runtime, unsupported platform, model download failure), fall back to Candle-Whisper and log the reason atwarnlevel so it’s diagnosable in the field.- Gate before this becomes the shipped default: the Phase-0 eval harness
must show Parakeet’s WER/CER beating current Candle-
tiny.enon all three fixture manifests, AND the Tauri build must package and run successfully on Windows (including verifying the/MT-vs-/MDCRT linking does not conflict), macOS, and Linux with the native ONNX Runtime dependency bundled, AND per-utterance latency under the existing no-cachingcreate_backend()call pattern (model resolution + freshOfflineRecognizersession construction on every dictation stop) must be measured and shown acceptable for real-time dictation, AND a model bundling/pre-fetch strategy (or a documented, user-visible offline/cold-cache behavior) must be in place so a fresh install does not silently block on an unbounded network download.
Phase 3 — Code-dictation symbol expansion
Section titled “Phase 3 — Code-dictation symbol expansion”- Remove the
compiler-rerankfeature gate from the reranking path that appliesspeech_normalize’s symbol/casing rules, either by making it a default feature ofvox-speechor by folding the always-useful part ofpick_best_transcript_index_with_rawinto the unconditional path. - Re-run the Phase-0 eval harness specifically against
vox_code_corpus_v1.jsonl, now on top of the Phase-2 Parakeet baseline, to confirm the symbol-expansion layer still measurably helps once the base ASR is more accurate — don’t assume it does, verify.
Phase 4 — GUI Settings surface
Section titled “Phase 4 — GUI Settings surface”- Add a small STT/voice section to
Settings/SettingsView.tsxexposing only the knobs an end user would plausibly want, not all ~30 env vars:- ASR backend selection (Auto / Parakeet / Whisper)
- Correction aggressiveness (the existing
Conservative/Balanced/AggressiveCorrectionContextprofiles) - Custom lexicon entries (simple add/remove list UI backed by the existing
speech_lexicon.rsJSON schema)
- This phase is UX-only and depends on Phases 1-3 having settled sane defaults first — it exposes tuning, it doesn’t fix correctness.
Testing / verification strategy
Section titled “Testing / verification strategy”- Phase 0’s eval harness is the acceptance gate for Phases 2 and 3: WER/CER must not regress versus baseline, and must measurably improve for the backend swap.
- Phase 1 rule fixes get one direct unit test per fixed rule, reproducing the specific bug found in the audit.
- Phase 2’s cross-platform packaging gate is verified by building and smoke-testing the Tauri app on all three target OSes before flipping the default — this cannot be verified by an automated browser preview (STT requires real microphone access and native library loading).
- Phase 4 is UI-only and verified by hand in the running app for the same reason (mic permission, native audio capture).
Out of scope
Section titled “Out of scope”- The separate “automatic prompt-engineering enhancer” question (per-model
prompt adaptation for Vox Mens / Qwen / Claude Sonnet / Opus) is a
independent research track, not covered by this design. See the harness
parity docs (
vox-harness-parity-plan-2026-07-30.mdand siblings) for related but distinct work on prompt/harness quality. - Mobile/Capacitor STT paths — already removed per an existing ADR
(desktop-only
vox-tauri-stt). - Full exposure of all ~30
VOX_ORATIO_*env vars in Settings — Phase 4 scopes to the user-relevant subset only.