Hardware mic-switch dictation, legal vocabulary boost, and docs truth-up #4729
29 changed files+973−90
ModifiedAUDIT.md+7−0View fileUnifiedSplit
@@ -1,5 +1,12 @@
11# Voxlen Product Audit Report
22
3> **⚠️ HISTORICAL SNAPSHOT — superseded.** This audit describes the codebase
4> as of 2026-04-14 (~75% complete, v1.0-era). Almost every gap it lists has
5> since been closed: Whisper Local, keyring secure storage, tests (260+ TS,
6> 43 Rust), Android keyboard, iOS Deepgram voice input, error boundaries,
7> CSP, Nova-3, and more. Do **not** treat its findings as current — see
8> `CLAUDE.md` (gap list) and `CHANGELOG.md` for live status.
9
310**Date:** 2026-04-14
411**Scope:** Full end-to-end codebase audit, launch readiness, AlecRae.com Email Client integration, cross-device strategy
512
ModifiedCHANGELOG.md+32−8View fileUnifiedSplit
@@ -1,5 +1,37 @@
11# Changelog
22
3## [Unreleased]
4
5### Added
6- **Hardware mic-switch mode**: the physical mute/power switch on an external mic (Razer, Yeti, Elgato Wave…) now starts and stops dictation directly — flip on to dictate, flip off to finalize. Detection is digital-silence based, works with every STT engine (including privileged fully-local), and survives mics whose switch powers the USB interface off.
7- **Legal vocabulary pack**: Legal Mode now boosts recognition of ~90 legal terms of art (Latin phrases, procedure, property, probate) plus jurisdiction-specific courts and instruments (UK/US/AU/CA/NZ) via Deepgram Nova-3 keyterm prompting. User, client-matter, and flywheel-learned terms always win the keyterm budget.
8
9### Fixed
10- Documentation truth-up: Nova-2 → Nova-3 references, grammar model (Claude Sonnet 4.6, not Haiku), keychain key storage (no longer plaintext), unwired auto-updater claims, test-suite docs, SDK example, and stale gap-list entries (Android keyboard and API proxy are shipped).
11
12## [1.2.0] - 2026-07
13
14### Added
15- Legal secretary review queue: file-based firm-storage sync (`pending_review` → `in_review` → `finalized`), zero Voxlen servers.
16- Auto-document pipeline: every dictation can write a per-client/matter `.docx` (atomic writes, opt-in).
17- Dragon vocabulary import (`.txt`/`.voc`) into custom vocabulary + flywheel.
18- Stripe checkout, signature-verified webhook, and KV plan entitlement on the landing site.
19- Onboarding "Your practice" step: default rate, rounding, first client.
20
21### Changed
22- Completed the Marco Reid Voice → Voxlen rebrand.
23- Web SDK Voxlen-API mode now targets the real voxlen.ai contract.
24
25## [1.1.0] - 2026-06
26
27### Added
28- Fully offline Whisper Local engine (whisper-rs) with on-demand model manager.
29- On-device grammar: Tier-1 rules engine + Tier-2 Qwen3-4B LLM (llama.cpp) — Privileged Mode now corrects locally instead of no-op.
30- Bot-free meeting transcription: WASAPI loopback + mic dual-channel capture with Rust-side consent gate and indicator window; task/deadline extraction.
31- Ambient billing: session-end draft time entries, 0.1 hr rounding, LEDES 1998B/Clio CSV export, matter auto-match.
32- API keys moved to the OS keychain (Windows Credential Manager / macOS Keychain) via the `keyring` crate.
33- Deepgram Nova-3 (upgraded from Nova-2), speaker diarization, real-time translation, analytics dashboard, flywheel UI panel, per-client matter tracking, SEO landing pages.
34
335## [1.0.5] - 2026-04-17
436
537### Fixed
@@ -51,14 +83,6 @@
5183### Changed
5284- Version bump for fresh release tag (no code changes beyond version strings; the 1.0.0 tag existed from earlier failed CI runs without any published installers).
5385
54## [Unreleased]
55
56### Planned for v1.1
57- Fully offline Whisper Local engine (whisper-rs integration)
58- iOS keyboard extension App Store build
59- Webhook emitter for enterprise integrations
60- Batched grammar API calls for cost optimization
61
6286## [1.0.0] - 2026-04-09
6387
6488### Added
ModifiedCLAUDE.md+6−4View fileUnifiedSplit
@@ -44,6 +44,8 @@ GateTest is a separate product (testing loop) that will be integrated later. It
4444- **Desktop app:** Tauri v2, Rust backend (`src-tauri/`), React/TS frontend (`src/`)
4545- **State management:** Zustand stores (`src/stores/`)
4646- **STT engines:** Deepgram Nova-3 (streaming, default), OpenAI Whisper (cloud), Whisper Local (on-device, whisper-rs)
47- **Hands-free modes:** Always-Ready (`src-tauri/src/stt/gate.rs`, VAD-gated cloud sessions) + hardware mic-switch mode (`src-tauri/src/stt/switch.rs`, physical mute/power switch on external mics starts/stops dictation via digital-silence detection; works with every engine incl. privileged local)
48- **Legal vocabulary pack:** `src/lib/legalVocab.ts` — jurisdiction-aware legal keyterm boost merged into STT config when Legal Mode is on (user/client/flywheel terms win the 100-keyterm budget)
4749- **Grammar engines:** cloud (Claude Sonnet 4.6 / GPT-4o-mini, voxlen.ai proxy or BYOK), local rules (`src-tauri/src/grammar/rules.rs`), local LLM (Qwen3-4B via llama-cpp-2, `dynamic-link` feature — static ggml collides with whisper-rs at link time)
4850- **Meeting capture:** `src-tauri/src/meeting/` — WASAPI loopback + mic dual-channel (= You/Remote diarization), Whisper Local forced, Rust-side consent gate + indicator window
4951- **Billing:** `src/lib/billing.ts` (round-UP 0.1hr convention) + clients store draft/approve entries + LEDES 1998B/Clio CSV export
@@ -65,9 +67,9 @@ GateTest is a separate product (testing loop) that will be integrated later. It
6567- [x] Bot-free meeting capture (Windows loopback, consent-gated) + task/deadline extraction ✓
6668- [ ] macOS meeting capture backend (ScreenCaptureKit; `meeting_capture_supported()` gates it)
6769- [ ] iOS local STT — the Apple Speech fallback is currently a stub (removed in commit `77d9e93` when Deepgram STT landed), so the `requiresOnDeviceRecognition` quick-win note no longer applies; the real task is restoring an `SFSpeechRecognizer` on-device path
68- [ ] Android keyboard extension
69- [ ] Stripe payment links — `/api/checkout`, `/api/stripe-webhook`, and KV plan entitlement shipped 2026-07-18; only real `STRIPE_*` env vars in Vercel remain
70- [ ] api.voxlen.com backend (proxy server to hold provider keys + metering)
70- [x] Android keyboard extension — `android/app/.../keyboard/` (VoxlenKeyboardService + Deepgram/Grammar clients, ~850 lines Kotlin) ✓
71- [x] Stripe payment links — `/api/checkout`, `/api/stripe-webhook`, and KV plan entitlement shipped 2026-07-18 ✓ (only real `STRIPE_*` env vars in Vercel remain — ops task, not code)
72- [x] API proxy backend — serverless functions under `landing/api/` (`stt`, `grammar`, `translate`, `deepgram-token`, `me`, `generate-key`, …) hold provider keys on voxlen.ai/api ✓ (dedicated api.voxlen.com host remains optional ops work)
7173- [ ] Clio API integration (matters pull + time entry push) — export formats shipped as the base
7274- [x] Noise suppression — high-pass filter + noise gate in capture pipeline ✓
7375- [x] Payment system — Stripe integration on landing page ✓
@@ -77,7 +79,7 @@ GateTest is a separate product (testing loop) that will be integrated later. It
7779- [x] Speaker diarization ✓
7880- [x] Real-time translation ✓
7981- [x] Analytics dashboard ✓
80- [x] Tests (250 TS + 37 Rust) ✓
82- [x] Tests (259 TS + 43 Rust) ✓
8183- [x] Flywheel UI panel ✓
8284- [x] Per-client matter tracking + billable time ✓
8385- [x] SEO landing pages (19 static pages, 40k+ searches/month targeted) ✓
ModifiedREADME.md+11−9View fileUnifiedSplit
@@ -12,11 +12,13 @@ Voxlen is a desktop voice dictation tool that transcribes your speech in real ti
1212
1313## Features
1414
15- Real-time streaming transcription via Deepgram Nova-2 (sub-300ms latency)
16- Cloud transcription via OpenAI Whisper; offline Whisper Local scaffolded for v1.1
17- AI grammar correction powered by Anthropic Claude Haiku or OpenAI GPT-4o-mini
15- Real-time streaming transcription via Deepgram Nova-3 (sub-300ms latency)
16- Cloud transcription via OpenAI Whisper; fully offline on-device Whisper Local (whisper.cpp)
17- AI grammar correction powered by Anthropic Claude Sonnet 4.6 or OpenAI GPT-4o-mini, plus a fully local rules + on-device LLM engine for privileged work
1818- Universal text injection into any application (keyboard simulation or clipboard paste)
19- Hardware mic-switch mode: the physical mute/power switch on an external mic (Razer, Yeti, Wave…) starts and stops dictation — no hotkey needed
1920- Voice commands: new line, period, comma, delete that, stop listening, and more
21- Legal Mode: jurisdiction-aware legal vocabulary boost, Latin phrase recognition, and legal smart formatting
2022- 20+ languages with optional auto-detection (roadmap extends coverage to 90+)
2123- Five writing styles: Professional, Casual, Academic, Creative, Technical
2224- Global hotkeys, system tray with quick actions, waveform visualizer
@@ -70,13 +72,13 @@ npm run tauri build # produce a release bundle for the host platform
7072
7173## Configuration
7274
73Voxlen needs API keys for its cloud providers. Enter them in **Settings** (or during the first-run Onboarding Wizard). Keys are persisted locally via `tauri-plugin-store`.
75Voxlen needs API keys for its cloud providers (or a Voxlen account key that proxies them). Enter them in **Settings** (or during the first-run Onboarding Wizard). Keys are stored in the OS keychain (Windows Credential Manager / macOS Keychain / Secret Service); non-secret settings persist via `tauri-plugin-store`.
7476
75| Provider | Used for | Where to get a key |
76| ---------- | --------------------- | ---------------------------------------------------- |
77| Deepgram | Streaming STT | https://console.deepgram.com |
78| OpenAI | Cloud Whisper + GPT | https://platform.openai.com/api-keys |
79| Anthropic | Claude Haiku grammar | https://console.anthropic.com/settings/keys |
77| Provider | Used for | Where to get a key |
78| ---------- | ---------------------------- | ---------------------------------------------------- |
79| Deepgram | Streaming STT (Nova-3) | https://console.deepgram.com |
80| OpenAI | Cloud Whisper + GPT | https://platform.openai.com/api-keys |
81| Anthropic | Claude Sonnet 4.6 grammar | https://console.anthropic.com/settings/keys |
8082
8183You only need the keys for the providers you actually enable.
8284
Modifieddocs/API.md+3−3View fileUnifiedSplit
@@ -410,14 +410,14 @@ The Web SDK (`sdk/`) exposes a JavaScript API for embedding voice dictation in w
410410### Key types
411411
412412```ts
413// Initialise the SDK
414const voxlen = new VoxlenSDK({ apiKey: "...", engine: "deepgram" });
413// Initialise the SDK (Voxlen-API mode; BYOK fields also supported — see sdk/README.md)
414const voxlen = new VoxlenSDK({ voxlenKey: "vxl_..." });
415415
416416// Start dictation
417417await voxlen.startDictation({ language: "en", onPartial, onFinal });
418418
419419// Stop
420await voxlen.stopDictation();
420await voxlen.stop();
421421
422422// Grammar correction
423423const result = await voxlen.correctGrammar(text, { style: "professional" });
Modifieddocs/ARCHITECTURE.md+24−16View fileUnifiedSplit
@@ -15,18 +15,17 @@ Module layout:
1515```
1616src-tauri/src/
1717├── lib.rs # Tauri builder, plugin registration, tray, invoke handler
18├── audio/ # Device enumeration + microphone capture (cpal)
19├── stt/ # Speech-to-text engines (Deepgram streaming, Whisper cloud, Whisper local stub)
18├── models.rs # On-demand model download manager (Whisper / grammar LLM)
19├── audio/ # Device enumeration + microphone capture (cpal) + WASAPI loopback
20├── stt/ # STT engines: Deepgram streaming, Whisper cloud, Whisper local
21│ # (whisper-rs), Always-Ready VAD gate, hardware mic-switch mode
22├── grammar/ # On-device grammar: rules engine + local LLM (llama.cpp)
23├── meeting/ # Bot-free meeting capture (dual-channel, consent-gated)
2024├── text_injection/ # Platform-specific typing and clipboard paste
2125└── commands/ # Tauri command handlers exposed to the frontend
22 ├── audio.rs
23 ├── dictation.rs
24 ├── grammar.rs
25 ├── history.rs
26 ├── settings.rs
27 ├── stt.rs
28 ├── text_injection.rs
29 └── window.rs
26 ├── audio.rs, dictation.rs, documents.rs, grammar.rs, history.rs,
27 ├── keyring.rs, meeting.rs, permissions.rs, review.rs, settings.rs,
28 └── stt.rs, text_injection.rs, translate.rs, whisper.rs, window.rs
3029```
3130
3231### Runtime state
@@ -41,7 +40,7 @@ Settings and session history are persisted to `tauri-plugin-store` files on disk
4140
4241### Plugins enabled
4342
44`global-shortcut`, `notification`, `store`, `shell`, `updater`, `dialog`, `fs`.
43`global-shortcut`, `notification`, `store`, `shell`, `dialog`, `fs`, `autostart`. (No updater plugin is registered yet — see RELEASE.md.)
4544
4645## React frontend (`src/`)
4746
@@ -70,7 +69,7 @@ src/
7069└── styles/
7170```
7271
73Four primary views are rendered through `App.tsx`: `dictation`, `grammar`, `history`, `settings`. An `OnboardingWizard` preempts the main UI on first launch.
72Primary views rendered through `App.tsx`: `dictation`, `grammar`, `history`, `settings`, plus `analytics`, `clauses`, `clients`, `flywheel`, `meeting`, and `review` panels (see `src/stores/navigation.ts` and `src/components/`). An `OnboardingWizard` preempts the main UI on first launch, and an `ErrorBoundary` wraps the app. The tree above shows the core skeleton only — the full component/lib/store inventory is larger (billing, autoDoc, reviewPacket, flywheel, clients, vocab, legalVocab, …).
7473
7574## Data flow
7675
@@ -114,12 +113,21 @@ Four primary views are rendered through `App.tsx`: `dictation`, `grammar`, `hist
114113
115114Commands are registered in `src-tauri/src/lib.rs` via `tauri::generate_handler!`. Full parameter and return signatures live in [API.md](./API.md).
116115
117### Commands (27 total)
116### Commands (~59 registered)
118117
119- **Audio:** `list_audio_devices`, `get_selected_device`, `set_audio_device`, `get_input_level`
120- **Dictation:** `start_dictation`, `stop_dictation`, `pause_dictation`, `get_dictation_status`
118Core groups (see `lib.rs` `generate_handler!` for the authoritative list):
119
120- **Audio:** `list_audio_devices`, `get_selected_device`, `set_audio_device`, `get_input_level`, `get_active_device`
121- **Dictation:** `start_dictation`, `stop_dictation`, `pause_dictation`, `resume_dictation`, `get_dictation_status`, `get_always_ready_state`, `get_mic_switch_state`
121122- **STT:** `get_stt_engines`, `set_stt_engine`, `get_stt_config`, `set_stt_config`
122- **Grammar:** `correct_grammar`, `get_grammar_config`, `set_grammar_config`
123- **Whisper local:** model list/download/delete + engine management (`commands/whisper.rs`)
124- **Grammar:** `correct_grammar`, `get_grammar_config`, `set_grammar_config`, local LLM model management
125- **Translation:** translate config + calls (`commands/translate.rs`)
126- **Meeting capture:** consent-gated start/stop, support detection (`commands/meeting.rs`)
127- **Documents:** per-matter .docx auto-writing (`commands/documents.rs`)
128- **Review:** secretary review queue (`commands/review.rs`)
129- **Keyring:** OS-keychain secret read/write (`commands/keyring.rs`)
130- **Permissions:** mic/accessibility checks (`commands/permissions.rs`)
123131- **Text injection:** `inject_text`, `get_injection_mode`, `set_injection_mode`
124132- **Settings:** `get_settings`, `update_settings`, `reset_settings`, `load_settings_from_disk`
125133- **History:** `save_session`, `get_history`, `get_session`, `delete_session`, `clear_history`, `search_history`
Modifieddocs/CONTRIBUTING.md+7−5View fileUnifiedSplit
@@ -56,13 +56,15 @@ Breaking changes: append `!` after the type/scope and include a `BREAKING CHANGE
5656
5757## Testing
5858
59Automated tests are in progress. For now:
59The project has a full automated test suite (Vitest for TypeScript, `cargo test` for Rust):
6060
61- Run `npm run build` to verify the TypeScript frontend compiles cleanly.
62- Run `cargo check` and `cargo test` inside `src-tauri/` for Rust changes.
63- Exercise your change manually via `npm run tauri dev`.
61- `npm run test` — run the frontend test suite (Vitest).
62- `npx tsc --noEmit` — type-check the frontend.
63- `cargo test` inside `src-tauri/` — run the Rust unit tests.
64- `cargo check` inside `src-tauri/` — fast type-check for Rust changes.
65- Exercise UI changes manually via `npm run tauri dev`.
6466
65Once the test suite lands we will document how to run it here.
67All TypeScript changes must pass `tsc --noEmit` and `npm run test`; all Rust changes must pass `cargo check` and `cargo test` before a PR is opened.
6668
6769## Code style
6870
Modifieddocs/PRIVACY.md+3−3View fileUnifiedSplit
@@ -26,12 +26,12 @@ Everything Voxlen retains lives on your machine, under the Tauri app data direct
2626
2727| File | Contents |
2828| ----------------- | ----------------------------------------------------------------- |
29| `settings.json` | All user preferences, including API keys (**plaintext**, see below) |
29| `settings.json` | Non-secret user preferences (API keys are **not** stored here) |
3030| `history.json` | Up to 500 most recent dictation sessions with transcripts |
3131
32### Plaintext API keys
32### API keys live in the OS keychain
3333
34API keys are written to `settings.json` in plaintext. They are not encrypted at rest. Anyone with read access to your user profile can read them. Integrating a hardware-backed keystore is on the roadmap — see [SECURITY.md](./SECURITY.md).
34API keys are stored in the operating system's secure credential store (Windows Credential Manager, macOS Keychain, Linux Secret Service) via the `keyring` crate — encrypted at rest by the OS and scoped to your user account. See [SECURITY.md](./SECURITY.md).
3535
3636### Deleting your data
3737
Modifieddocs/RELEASE.md+5−2View fileUnifiedSplit
@@ -17,7 +17,10 @@ This produces:
1717
1818### Wire up the public key
1919
20Open `src-tauri/tauri.conf.json` and replace the placeholder in the updater block:
20> **Status:** the updater plugin is not yet registered and `tauri.conf.json`
21> currently has an empty `"plugins": {}` block. To enable auto-update, add
22> `tauri-plugin-updater` to `Cargo.toml`/`lib.rs` first, then add the block
23> below to `tauri.conf.json`:
2124
2225```json
2326"updater": {
@@ -68,7 +71,7 @@ The updater polls:
6871https://releases.voxlen.ai/{{target}}/{{current_version}}
6972```
7073
71Your release server must respond with a signed JSON manifest per the [Tauri updater spec](https://v2.tauri.app/plugin/updater/#server-support). Until that host exists, the updater is wired but no updates will be delivered.
74Your release server must respond with a signed JSON manifest per the [Tauri updater spec](https://v2.tauri.app/plugin/updater/#server-support). Until the updater plugin is registered **and** that host exists, no updates will be delivered — users install releases manually.
7275
7376## Troubleshooting
7477
Modifieddocs/SECURITY.md+5−4View fileUnifiedSplit
@@ -19,10 +19,11 @@ We will acknowledge receipt within 3 business days and aim to provide an initial
1919
2020| Version | Supported |
2121| -------- | ------------------ |
22| 1.0.x | Yes |
22| 1.2.x | Yes |
23| 1.0.x–1.1.x | Upgrade to 1.2.x |
2324| < 1.0 | No (pre-release) |
2425
25Security fixes are shipped in the next 1.0.x patch release. The auto-updater delivers these to all installed clients once the release is promoted out of draft.
26Security fixes are shipped in the next 1.2.x patch release. Auto-update is not yet wired (no updater plugin is registered); until it is, users must install patch releases manually.
2627
2728## Scope
2829
@@ -41,11 +42,11 @@ Out of scope:
4142
4243Please read these before reporting — they are documented trade-offs, not undisclosed issues:
4344
44- **API keys are stored in plaintext** on disk via `tauri-plugin-store` (`settings.json`). They are not encrypted at rest and are readable by any process running as the current user. Hardware-backed keystore integration is on the roadmap.
45- **API keys are stored in the OS keychain** (Windows Credential Manager, macOS Keychain, Linux Secret Service) via the `keyring` crate — never in plain JSON. Non-secret settings remain in `settings.json` via `tauri-plugin-store`.
4546- **Transcripts are stored in plaintext** in `history.json`. Users can delete sessions or clear the history from the History panel.
4647- **Audio and text are sent to the providers you configure.** Voxlen itself does not proxy or log this data, but the upstream vendor's privacy policy applies to any data you send them.
4748- **No telemetry** is collected by Voxlen.
4849
4950## Signed releases
5051
51Release binaries are signed. Auto-updater payloads are verified against the embedded public key configured in `src-tauri/tauri.conf.json` (`plugins.updater.pubkey`). Report any validation bypass.
52Release binaries are signed. The auto-updater is **not yet wired** — `tauri.conf.json` has no updater block and the updater plugin is not registered. When it lands, updater payloads will be verified against an embedded public key; see [RELEASE.md](./RELEASE.md).
Modifiedios/README.md+9−9View fileUnifiedSplit
@@ -1,6 +1,6 @@
11# Voxlen iOS Keyboard Extension
22
3AI-powered voice dictation and grammar correction keyboard for iPhone and iPad, supporting 20+ languages. Powered by Deepgram Nova-2 for real-time speech-to-text.
3AI-powered voice dictation and grammar correction keyboard for iPhone and iPad, supporting 20+ languages. Powered by Deepgram Nova-3 for real-time speech-to-text.
44
55## Setup in Xcode
66
@@ -16,7 +16,7 @@ AI-powered voice dictation and grammar correction keyboard for iPhone and iPad,
1616
1717To use voice dictation and grammar correction, users need to provide their own API keys in the app settings:
1818
19- **Deepgram API key** — Required for voice dictation. Get one at [console.deepgram.com](https://console.deepgram.com). Deepgram Nova-2 provides the real-time speech-to-text engine.
19- **Deepgram API key** — Required for voice dictation. Get one at [console.deepgram.com](https://console.deepgram.com). Deepgram Nova-3 provides the real-time speech-to-text engine.
2020- **Claude or OpenAI API key** — Required for grammar correction. Provide a Claude API key (Anthropic) or an OpenAI API key to power the "Polish" grammar correction feature.
2121
2222Keys are stored locally on-device via App Groups and are never sent to Voxlen servers.
@@ -43,7 +43,7 @@ xcodebuild -scheme VoxKeyboard -destination 'generic/platform=iOS' archive
4343- The main app (`VoxApp`) provides settings management and API key configuration
4444- The keyboard extension (`VoxKeyboardExtension`) is a custom keyboard
4545- Settings are shared via App Groups (`group.com.voxlen.app`)
46- Voice dictation uses Deepgram Nova-2 via WebSocket streaming for low-latency, real-time transcription in 20+ languages
46- Voice dictation uses Deepgram Nova-3 via WebSocket streaming for low-latency, real-time transcription in 20+ languages
4747- The "Polish" button in the keyboard bar sends text to Claude/OpenAI for grammar correction
4848- Corrected text replaces the original directly in any text field
4949
@@ -53,13 +53,13 @@ The keyboard extension works on both iPhone and iPad. The layout adapts automati
5353
5454## API Usage & Cost
5555
56Using Claude Haiku for grammar correction:
57- ~$0.25 per 1M input tokens, ~$1.25 per 1M output tokens
58- Average text correction: ~100 tokens = ~$0.00003 per correction
59- 1000 corrections per month = ~$0.03/month
60- This is **100x cheaper** than a Grammarly subscription ($12/month)
56Using Claude Sonnet 4.6 for grammar correction:
57- $3 per 1M input tokens, $15 per 1M output tokens
58- Average text correction: ~150 tokens round-trip ≈ $0.0005 per correction
59- 1000 corrections per month ≈ $0.50/month
60- Still **20x+ cheaper** than a Grammarly subscription ($12/month)
6161
62Using Deepgram Nova-2 for voice dictation:
62Using Deepgram Nova-3 for voice dictation:
6363- $0.0043 per minute (pay-as-you-go)
6464- 10 minutes of dictation per day = ~$1.29/month
6565- Combined with grammar correction, total cost stays well under $2/month for typical usage
Modifiedlanding/README.md+1−1View fileUnifiedSplit
@@ -45,5 +45,5 @@ vercel --prod # promote to production
4545
4646## Notes
4747
48- The landing site has no runtime API keys; it is fully static.
48- The site is a full-stack Vercel deployment: the static Vite front-end plus serverless functions under `landing/api/` (`stt`, `grammar`, `translate`, `checkout`, `stripe-webhook`, `deepgram-token`, …) that hold provider and Stripe keys **server-side** as Vercel environment variables. No keys ship to the browser.
4949- Assets live in `landing/src/` and the Vite entry is `landing/index.html`.
Modifiedpackage-lock.json+2−2View fileUnifiedSplit
@@ -1,12 +1,12 @@
11{
22 "name": "voxlen",
3 "version": "1.1.0",
3 "version": "1.2.0",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "voxlen",
9 "version": "1.1.0",
9 "version": "1.2.0",
1010 "dependencies": {
1111 "@radix-ui/react-dialog": "^1.1.4",
1212 "@radix-ui/react-dropdown-menu": "^2.1.4",
Modifiedsrc-tauri/src/commands/dictation.rs+83−6View fileUnifiedSplit
@@ -2,13 +2,25 @@ use std::sync::atomic::{AtomicBool, Ordering};
22use std::time::Duration;
33use tauri::{State, Emitter, Manager};
44use crate::audio::{AudioState, DictationStatus};
5use crate::stt::{SttState, SttEngineType, SttSessionState, streaming, processor, gate};
5use crate::stt::{SttState, SttEngineType, SttSessionState, streaming, processor, gate, switch};
66
77/// True while Always-Ready mode is armed. While armed, the watchdog treats
88/// Idle/Error as recoverable (re-arms capture) instead of terminal, so the
99/// mic pipeline survives stops, device unplugs, and sleep/wake indefinitely.
1010pub static ALWAYS_READY_ARMED: AtomicBool = AtomicBool::new(false);
1111
12/// True while hardware mic-switch mode is armed: the physical mute/power
13/// switch on the user's external mic (Razer, Yeti, …) starts and stops
14/// dictation. Shares the Always-Ready watchdog semantics — capture must
15/// survive device unplugs (switches that power the USB interface off) and
16/// come back up the moment the mic re-enumerates.
17pub static MIC_SWITCH_ARMED: AtomicBool = AtomicBool::new(false);
18
19/// Either hands-free mode counts as armed for watchdog recovery purposes.
20fn hands_free_armed() -> bool {
21 ALWAYS_READY_ARMED.load(Ordering::SeqCst) || MIC_SWITCH_ARMED.load(Ordering::SeqCst)
22}
23
1224/// Retry cadence once normal recovery has repeatedly failed while armed —
1325/// slow enough not to spam a missing device, fast enough to pick a replugged
1426/// mic up promptly.
@@ -52,6 +64,16 @@ fn start_dictation_internal(
5264 let config = stt_state.0.read().get_config();
5365 let status_arc = audio_state.0.read().status.clone();
5466
67 // Hardware mic-switch mode takes precedence and covers every engine:
68 // the physical switch on the mic is the start/stop control, and the
69 // switch task opens/closes engine sessions (streaming or local batch)
70 // itself as the switch is flipped.
71 if s.mic_switch_mode {
72 let handle = switch::start_switch(receiver, stt_state.0.clone(), status_arc, app.clone());
73 session_state.set_switched(handle);
74 return Ok(active_device);
75 }
76
5577 match config.engine {
5678 SttEngineType::DeepgramCloud => {
5779 if s.always_ready_mode && !crate::commands::settings::get_privileged_mode() {
@@ -132,7 +154,7 @@ fn spawn_capture_watchdog(app: tauri::AppHandle) {
132154 // UNLESS Always-Ready is armed, in which case Idle/Error means
133155 // the pipeline must be brought back up.
134156 DictationStatus::Idle | DictationStatus::Error => {
135 if !ALWAYS_READY_ARMED.load(Ordering::SeqCst) {
157 if !hands_free_armed() {
136158 break;
137159 }
138160 // Pace retries: normal settle first, slow cadence once
@@ -143,7 +165,7 @@ fn spawn_capture_watchdog(app: tauri::AppHandle) {
143165 std::thread::sleep(RECOVERY_SETTLE);
144166 }
145167 // Re-check: user may have disarmed while we slept.
146 if !ALWAYS_READY_ARMED.load(Ordering::SeqCst) {
168 if !hands_free_armed() {
147169 break;
148170 }
149171 match restart_capture(&app) {
@@ -166,8 +188,17 @@ fn spawn_capture_watchdog(app: tauri::AppHandle) {
166188 }
167189 // Paused: keep the watchdog alive but don't restart capture —
168190 // recovering here would silently resume a session the user
169 // deliberately paused.
170 DictationStatus::Paused => continue,
191 // deliberately paused. EXCEPTION: in mic-switch mode "Paused"
192 // means the physical switch is off while capture keeps running
193 // to watch for the flip back on — if the device itself dies in
194 // that state (a switch that powers the USB interface off),
195 // fall through to the fault check so capture is re-armed and
196 // the flip-on is still detected when the mic re-enumerates.
197 DictationStatus::Paused => {
198 if !MIC_SWITCH_ARMED.load(Ordering::SeqCst) {
199 continue;
200 }
201 }
171202 // Listening, or Processing (batch mode sets Processing during
172203 // HTTP transcription): keep watching for device faults.
173204 DictationStatus::Listening | DictationStatus::Processing => {}
@@ -203,7 +234,7 @@ fn spawn_capture_watchdog(app: tauri::AppHandle) {
203234 audio_state.0.read().mark_error();
204235 }
205236 let _ = app.emit("audio-recovery-giveup", ());
206 if ALWAYS_READY_ARMED.load(Ordering::SeqCst) {
237 if hands_free_armed() {
207238 // Status is now Error; the Idle/Error arm above
208239 // keeps retrying on the slow cadence while armed.
209240 continue;
@@ -274,6 +305,52 @@ pub fn get_always_ready_state() -> Result<bool, String> {
274305 Ok(ALWAYS_READY_ARMED.load(Ordering::SeqCst))
275306}
276307
308/// Arm hardware mic-switch mode: bring the capture pipeline up and keep the
309/// watchdog alive so it survives faults, stops, and mics whose physical
310/// switch powers the whole USB interface off. Idempotent; called on the
311/// false→true edge of the setting from `update_settings`.
312pub fn arm_mic_switch(app: &tauri::AppHandle) {
313 if MIC_SWITCH_ARMED
314 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
315 .is_err()
316 {
317 return; // Already armed.
318 }
319 log::info!("Hardware mic-switch mode armed");
320 let app = app.clone();
321 std::thread::spawn(move || {
322 if let Err(e) = restart_capture(&app) {
323 // Watchdog will keep retrying — just surface the first failure.
324 log::warn!("Mic-switch initial start failed (watchdog will retry): {}", e);
325 }
326 spawn_capture_watchdog(app);
327 });
328}
329
330/// Disarm hardware mic-switch mode and tear the pipeline down. Idempotent.
331pub fn disarm_mic_switch(app: &tauri::AppHandle) {
332 if MIC_SWITCH_ARMED
333 .compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst)
334 .is_err()
335 {
336 return; // Already disarmed.
337 }
338 log::info!("Hardware mic-switch mode disarmed");
339 if let Some(session_state) = app.try_state::<SttSessionState>() {
340 session_state.stop();
341 }
342 if let Some(audio_state) = app.try_state::<AudioState>() {
343 let _ = audio_state.0.read().stop_capture();
344 }
345 let _ = app.emit("mic-switch-state", "off");
346}
347
348/// Whether mic-switch mode is currently armed — for frontend hydration.
349
350pub fn get_mic_switch_state() -> Result<bool, String> {
351 Ok(MIC_SWITCH_ARMED.load(Ordering::SeqCst))
352}
353
277354
278355pub fn stop_dictation(
279356 audio_state: State<'_, AudioState>,
Modifiedsrc-tauri/src/commands/settings.rs+23−3View fileUnifiedSplit
@@ -52,6 +52,11 @@ pub struct AppSettings {
5252 /// voice-activity gate opens the cloud session only during speech.
5353
5454 pub always_ready_mode: bool,
55 /// Hardware mic-switch mode: the physical mute/power switch on an
56 /// external mic (Razer, Yeti, …) starts and stops dictation. Works with
57 /// every STT engine, including privileged mode's forced-local engine.
58
59 pub mic_switch_mode: bool,
5560
5661 // Text injection
5762 pub injection_mode: String,
@@ -146,6 +151,7 @@ impl Default for AppSettings {
146151 smart_format: true,
147152 voice_commands_enabled: true,
148153 always_ready_mode: false,
154 mic_switch_mode: false,
149155
150156 injection_mode: "keyboard".to_string(),
151157
@@ -219,9 +225,12 @@ pub fn update_settings(
219225) -> Result<(), String> {
220226 // Edge-detect the effective Always-Ready state (privileged mode forces
221227 // it off — the gate must never open a cloud session for privileged work).
222 let was_armed = {
228 let (was_armed, was_switch_armed) = {
223229 let prev = get_settings_store().read();
224 prev.always_ready_mode && !prev.privileged_mode
230 (
231 prev.always_ready_mode && !prev.privileged_mode,
232 prev.mic_switch_mode,
233 )
225234 };
226235 *get_settings_store().write() = settings.clone();
227236 // The frontend (schedulePersist) owns the Tauri store and writes it in camelCase.
@@ -240,6 +249,16 @@ pub fn update_settings(
240249 } else if !want_armed && was_armed {
241250 crate::commands::dictation::disarm_always_ready(&app);
242251 }
252
253 // Mic-switch mode arms in every mode (privileged included — the switch
254 // task inherits whatever engine is configured, which privileged mode
255 // forces to fully-local Whisper).
256 let want_switch = settings.mic_switch_mode;
257 if want_switch && !was_switch_armed {
258 crate::commands::dictation::arm_mic_switch(&app);
259 } else if !want_switch && was_switch_armed {
260 crate::commands::dictation::disarm_mic_switch(&app);
261 }
243262 Ok(())
244263}
245264
@@ -254,8 +273,9 @@ pub fn reset_settings(
254273 apply_settings_to_engines(&stt_state.0, &audio_state, &defaults);
255274 apply_autostart(&app, defaults.launch_at_login);
256275 apply_injection_mode(&app, &defaults.injection_mode);
257 // Defaults have Always-Ready off — tear it down if it was armed.
276 // Defaults have both hands-free modes off — tear them down if armed.
258277 crate::commands::dictation::disarm_always_ready(&app);
278 crate::commands::dictation::disarm_mic_switch(&app);
259279 Ok(defaults)
260280}
261281
Modifiedsrc-tauri/src/lib.rs+1−0View fileUnifiedSplit
@@ -198,6 +198,7 @@ pub fn run() {
198198 commands::dictation::resume_dictation,
199199 commands::dictation::get_dictation_status,
200200 commands::dictation::get_always_ready_state,
201 commands::dictation::get_mic_switch_state,
201202 commands::documents::save_document,
202203 commands::review::create_review_packet,
203204 commands::review::list_review_packets,
Modifiedsrc-tauri/src/stt/mod.rs+10−2View fileUnifiedSplit
@@ -2,6 +2,7 @@ pub mod cloud;
22pub mod gate;
33pub mod processor;
44pub mod streaming;
5pub mod switch;
56pub mod whisper_local;
67
78use std::sync::Arc;
@@ -164,11 +165,13 @@ impl SttState {
164165 }
165166}
166167
167/// The active STT session — either a direct streaming session (classic
168/// dictation) or the Always-Ready speech gate that manages sessions itself.
168/// The active STT session — a direct streaming session (classic dictation),
169/// the Always-Ready speech gate, or the hardware mic-switch controller —
170/// the latter two manage their own inner sessions.
169171pub enum ActiveSession {
170172 Direct(streaming::StreamingSession),
171173 Gated(gate::GateHandle),
174 Switched(switch::SwitchHandle),
172175}
173176
174177/// Holds the active real-time session so it can be stopped on demand.
@@ -187,11 +190,16 @@ impl SttSessionState {
187190 *self.0.write() = Some(ActiveSession::Gated(gate));
188191 }
189192
193 pub fn set_switched(&self, s: switch::SwitchHandle) {
194 *self.0.write() = Some(ActiveSession::Switched(s));
195 }
196
190197 pub fn stop(&self) {
191198 if let Some(session) = self.0.write().take() {
192199 match session {
193200 ActiveSession::Direct(s) => s.stop(),
194201 ActiveSession::Gated(g) => g.stop(),
202 ActiveSession::Switched(s) => s.stop(),
195203 }
196204 }
197205 }
Addedsrc-tauri/src/stt/switch.rs+364−0View fileUnifiedSplit
@@ -0,0 +1,364 @@
1//! Hardware mic-switch mode.
2//!
3//! Lets the physical mute/power switch on an external microphone (Razer,
4//! Blue Yeti, Shure, Elgato Wave, …) drive dictation directly: flip the
5//! switch on and Voxlen starts transcribing, flip it off and the session
6//! finalizes — no Win+H, no keyboard shortcut, no clicking.
7//!
8//! Detection principle: a hardware-muted mic delivers *digital silence* —
9//! sample peaks at or near exactly zero (at most ±1 LSB of dither, ≈3e-5
10//! for 16-bit). A live analog capsule always carries a noise floor well
11//! above that, even in a quiet room and even after Voxlen's noise gate
12//! attenuates ambience. So "sustained peaks below −80 dBFS" reliably means
13//! the switch is off, and the first frames back above it mean it was
14//! flipped on. Mics whose switch powers the USB interface off entirely are
15//! covered too: the capture watchdog re-arms capture when the device
16//! re-enumerates, and this task starts over.
17//!
18//! Like the Always-Ready gate, the FSM (`SwitchFsm`) is a pure struct with
19//! no tokio/tauri dependencies so it is directly unit-testable; the IO
20//! wrapper owns the relay into an `AudioProcessor`, which already routes
21//! Deepgram to streaming and Whisper (cloud or fully local) to batch — so
22//! the switch works identically across every STT engine, including
23//! privileged mode's forced-local engine.
24
25use std::sync::Arc;
26use std::sync::atomic::{AtomicBool, Ordering};
27use std::time::Duration;
28
29use crossbeam_channel::Receiver;
30use parking_lot::RwLock;
31use tauri::{AppHandle, Emitter};
32
33use crate::audio::{AudioChunk, DictationStatus};
34use super::processor::AudioProcessor;
35use super::SttEngine;
36
37// --- Tunables --------------------------------------------------------------
38
39/// Peak amplitude below which a chunk counts as digital silence (−80 dBFS).
40/// Above ±1 LSB of 16-bit dither (≈3e-5), below any live analog noise floor
41/// even after the capture pipeline's noise gate attenuates ambience to 0.1×.
42const DEAD_PEAK: f32 = 1e-4;
43/// Consecutive dead audio required before declaring the switch off. Long
44/// enough that a driver hiccup or a single zeroed buffer can't false-mute.
45const MUTE_CONFIRM_SECS: f32 = 0.6;
46/// Consecutive live chunks required before declaring the switch on — two
47/// chunks (~200ms) so an isolated dither spike while muted can't false-arm.
48const LIVE_CONFIRM_CHUNKS: u32 = 2;
49/// After the switch goes off, keep the STT relay open (without forwarding)
50/// this long so the processor's silence-flush transcribes the tail of the
51/// last utterance before the session is torn down.
52const DRAIN_SECS: f32 = 2.5;
53
54// --- Pure FSM --------------------------------------------------------------
55
56
57pub enum SwitchPhase {
58 /// Switch is on — audio is flowing to the STT session.
59 Live,
60 /// Switch is off — mic delivers digital silence; watching for flip-on.
61 Muted,
62}
63
64
65pub enum SwitchAction {
66 None,
67 /// Sustained digital silence — the physical switch was flipped off.
68 WentMuted,
69 /// Real signal returned — the physical switch was flipped on.
70 WentLive,
71}
72
73pub struct SwitchFsm {
74 phase: SwitchPhase,
75 dead_secs: f32,
76 live_chunks: u32,
77}
78
79impl SwitchFsm {
80 pub fn new() -> Self {
81 Self {
82 phase: SwitchPhase::Live,
83 dead_secs: 0.0,
84 live_chunks: 0,
85 }
86 }
87
88 pub fn phase(&self) -> SwitchPhase {
89 self.phase
90 }
91
92 /// Feed one chunk's peak amplitude and duration. Time accounting is
93 /// sample-count-based (callers derive `chunk_secs` from the chunk),
94 /// never wall-clock — absence of chunks (capture paused) is not fed.
95 pub fn feed(&mut self, peak: f32, chunk_secs: f32) -> SwitchAction {
96 let dead = peak < DEAD_PEAK;
97
98 match self.phase {
99 SwitchPhase::Live => {
100 if dead {
101 self.dead_secs += chunk_secs;
102 if self.dead_secs >= MUTE_CONFIRM_SECS {
103 self.phase = SwitchPhase::Muted;
104 self.dead_secs = 0.0;
105 self.live_chunks = 0;
106 return SwitchAction::WentMuted;
107 }
108 } else {
109 self.dead_secs = 0.0;
110 }
111 SwitchAction::None
112 }
113 SwitchPhase::Muted => {
114 if dead {
115 self.live_chunks = 0;
116 } else {
117 self.live_chunks += 1;
118 if self.live_chunks >= LIVE_CONFIRM_CHUNKS {
119 self.phase = SwitchPhase::Live;
120 self.dead_secs = 0.0;
121 self.live_chunks = 0;
122 return SwitchAction::WentLive;
123 }
124 }
125 SwitchAction::None
126 }
127 }
128 }
129}
130
131// --- IO wrapper ------------------------------------------------------------
132
133pub struct SwitchHandle {
134 stop_flag: Arc<AtomicBool>,
135}
136
137impl SwitchHandle {
138 pub fn stop(&self) {
139 self.stop_flag.store(true, Ordering::Relaxed);
140 }
141}
142
143fn emit_state(app: &AppHandle, state: &str) {
144 let _ = app.emit("mic-switch-state", state);
145 if let Some(tray) = app.tray_by_id(crate::TRAY_ID) {
146 let tooltip = match state {
147 "live" => "Voxlen — mic switch on, dictating",
148 "muted" => "Voxlen — mic switched off (flip the switch to dictate)",
149 _ => "Voxlen — AI voice dictation for legal and accounting professionals",
150 };
151 let _ = tray.set_tooltip(Some(tooltip));
152 }
153}
154
155fn peak_amplitude(samples: &[f32]) -> f32 {
156 samples.iter().fold(0.0_f32, |m, s| m.max(s.abs()))
157}
158
159/// Spawn the switch task over an always-alive capture receiver. Opens an
160/// `AudioProcessor` relay while the physical switch is on, drains and closes
161/// it when the switch goes off, and reopens on the next flip-on.
162pub fn start_switch(
163 audio_receiver: Receiver<AudioChunk>,
164 stt_state: Arc<RwLock<SttEngine>>,
165 status: Arc<RwLock<DictationStatus>>,
166 app_handle: AppHandle,
167) -> SwitchHandle {
168 let stop_flag = Arc::new(AtomicBool::new(false));
169 let stop = stop_flag.clone();
170
171 tauri::async_runtime::spawn(async move {
172 let mut fsm = SwitchFsm::new();
173 // Relay into the engine-agnostic processor. Some(_) while a session
174 // is open or draining; None while fully muted.
175 let mut relay: Option<crossbeam_channel::Sender<AudioChunk>> = None;
176 // Seconds of drain remaining after a mute before the relay is dropped.
177 let mut drain_remaining: f32 = 0.0;
178
179 let open_relay = |relay: &mut Option<crossbeam_channel::Sender<AudioChunk>>| {
180 let (tx, rx) = crossbeam_channel::bounded::<AudioChunk>(512);
181 AudioProcessor::new(app_handle.clone(), stt_state.clone(), status.clone()).start(rx);
182 *relay = Some(tx);
183 };
184
185 // Assume the switch is on at start (capture just started); if the mic
186 // is actually muted the FSM flips to Muted within MUTE_CONFIRM_SECS.
187 open_relay(&mut relay);
188 emit_state(&app_handle, "live");
189 log::info!("Mic-switch mode active (hardware switch controls dictation)");
190
191 loop {
192 if stop.load(Ordering::Relaxed) {
193 break;
194 }
195
196 match audio_receiver.recv_timeout(Duration::from_millis(100)) {
197 Ok(chunk) => {
198 let denom = (chunk.sample_rate as f32 * chunk.channels as f32).max(1.0);
199 let chunk_secs = chunk.samples.len() as f32 / denom;
200 let peak = peak_amplitude(&chunk.samples);
201
202 match fsm.feed(peak, chunk_secs) {
203 SwitchAction::WentMuted => {
204 // Stop forwarding; keep the relay open briefly so
205 // the processor's silence-flush transcribes the
206 // tail, then drop it below as drain time elapses.
207 drain_remaining = DRAIN_SECS;
208 *status.write() = DictationStatus::Paused;
209 emit_state(&app_handle, "muted");
210 log::info!("Mic switch flipped OFF — finalizing session");
211 }
212 SwitchAction::WentLive => {
213 if relay.is_none() {
214 open_relay(&mut relay);
215 }
216 drain_remaining = 0.0;
217 *status.write() = DictationStatus::Listening;
218 emit_state(&app_handle, "live");
219 log::info!("Mic switch flipped ON — dictation live");
220 if let Some(tx) = &relay {
221 let _ = tx.try_send(chunk);
222 }
223 }
224 SwitchAction::None => match fsm.phase() {
225 SwitchPhase::Live => {
226 if let Some(tx) = &relay {
227 let _ = tx.try_send(chunk);
228 }
229 }
230 SwitchPhase::Muted => {
231 if drain_remaining > 0.0 {
232 drain_remaining -= chunk_secs;
233 if drain_remaining <= 0.0 {
234 relay = None; // processor sees Disconnected → finalizes
235 }
236 }
237 }
238 },
239 }
240 }
241 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
242 // No chunks (capture paused, or muted device delivering no
243 // callbacks) — only the drain countdown advances. The FSM
244 // itself never moves on absence of audio: paused ≠ muted.
245 if drain_remaining > 0.0 {
246 drain_remaining -= 0.1;
247 if drain_remaining <= 0.0 {
248 relay = None;
249 }
250 }
251 }
252 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
253 // Capture torn down (stop, or device unplugged → watchdog
254 // will restart capture and re-enter switch mode).
255 break;
256 }
257 }
258 }
259
260 drop(relay); // close any open session
261 emit_state(&app_handle, "off");
262 log::info!("Mic-switch task exited");
263 });
264
265 SwitchHandle { stop_flag }
266}
267
268// --- Tests ------------------------------------------------------------------
269
270
271mod tests {
272 use super::*;
273
274 const CHUNK: f32 = 0.1; // 100ms chunks
275 const SPEECH: f32 = 0.2;
276 const ROOM_NOISE: f32 = 0.002; // quiet room analog floor, post-gate
277 const DITHER: f32 = 3.0e-5; // ±1 LSB @ 16-bit — hardware muted
278 const ZERO: f32 = 0.0;
279
280
281 fn mutes_after_sustained_digital_silence() {
282 let mut fsm = SwitchFsm::new();
283 assert_eq!(fsm.phase(), SwitchPhase::Live);
284 let confirm_chunks = (MUTE_CONFIRM_SECS / CHUNK) as usize;
285 for _ in 0..confirm_chunks - 1 {
286 assert_eq!(fsm.feed(ZERO, CHUNK), SwitchAction::None);
287 }
288 assert_eq!(fsm.feed(ZERO, CHUNK), SwitchAction::WentMuted);
289 assert_eq!(fsm.phase(), SwitchPhase::Muted);
290 }
291
292
293 fn dither_counts_as_muted_but_room_noise_does_not() {
294 let mut fsm = SwitchFsm::new();
295 // Hardware mute with 1-LSB dither still reads as muted.
296 for _ in 0..20 {
297 fsm.feed(DITHER, CHUNK);
298 }
299 assert_eq!(fsm.phase(), SwitchPhase::Muted);
300
301 // A live mic in a silent room (no speech) must NOT read as muted.
302 let mut fsm = SwitchFsm::new();
303 for _ in 0..600 {
304 assert_eq!(fsm.feed(ROOM_NOISE, CHUNK), SwitchAction::None);
305 }
306 assert_eq!(fsm.phase(), SwitchPhase::Live);
307 }
308
309
310 fn brief_zero_buffer_does_not_false_mute() {
311 let mut fsm = SwitchFsm::new();
312 for _ in 0..50 {
313 // Alternating: driver delivers an occasional zeroed buffer.
314 assert_eq!(fsm.feed(ZERO, CHUNK), SwitchAction::None);
315 assert_eq!(fsm.feed(ROOM_NOISE, CHUNK), SwitchAction::None);
316 }
317 assert_eq!(fsm.phase(), SwitchPhase::Live);
318 }
319
320
321 fn flip_on_goes_live_after_confirm() {
322 let mut fsm = SwitchFsm::new();
323 for _ in 0..10 {
324 fsm.feed(ZERO, CHUNK);
325 }
326 assert_eq!(fsm.phase(), SwitchPhase::Muted);
327 // Switch flipped on: analog floor returns on every chunk.
328 assert_eq!(fsm.feed(ROOM_NOISE, CHUNK), SwitchAction::None); // 1 of 2
329 assert_eq!(fsm.feed(ROOM_NOISE, CHUNK), SwitchAction::WentLive);
330 assert_eq!(fsm.phase(), SwitchPhase::Live);
331 }
332
333
334 fn isolated_spike_while_muted_does_not_arm() {
335 let mut fsm = SwitchFsm::new();
336 for _ in 0..10 {
337 fsm.feed(ZERO, CHUNK);
338 }
339 for _ in 0..50 {
340 assert_eq!(fsm.feed(SPEECH, CHUNK), SwitchAction::None); // spike
341 assert_eq!(fsm.feed(ZERO, CHUNK), SwitchAction::None);
342 }
343 assert_eq!(fsm.phase(), SwitchPhase::Muted);
344 }
345
346
347 fn full_toggle_cycle() {
348 let mut fsm = SwitchFsm::new();
349 // Dictating.
350 for _ in 0..30 {
351 assert_eq!(fsm.feed(SPEECH, CHUNK), SwitchAction::None);
352 }
353 // Flip off.
354 let confirm_chunks = (MUTE_CONFIRM_SECS / CHUNK) as usize;
355 for _ in 0..confirm_chunks - 1 {
356 fsm.feed(ZERO, CHUNK);
357 }
358 assert_eq!(fsm.feed(ZERO, CHUNK), SwitchAction::WentMuted);
359 // Flip back on and speak.
360 fsm.feed(ROOM_NOISE, CHUNK);
361 assert_eq!(fsm.feed(SPEECH, CHUNK), SwitchAction::WentLive);
362 assert_eq!(fsm.phase(), SwitchPhase::Live);
363 }
364}
Modifiedsrc/components/dictation/DictationPanel.tsx+18−7View fileUnifiedSplit
@@ -141,6 +141,7 @@ export function DictationPanel() {
141141 const restoreDraft = useDictationStore((s) => s.restoreDraft);
142142 const discardDraft = useDictationStore((s) => s.discardDraft);
143143 const alwaysReadyPhase = useDictationStore((s) => s.alwaysReadyPhase);
144 const micSwitchPhase = useDictationStore((s) => s.micSwitchPhase);
144145
145146 const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
146147 const sessionStartRef = useRef<Date | null>(null);
@@ -183,10 +184,16 @@ export function DictationPanel() {
183184 }, [status, incrementDuration]);
184185
185186 const handleToggleDictation = useCallback(async () => {
186 // In Always-Ready mode the supervisor owns start/stop (the watchdog
187 // would immediately re-arm a stop). The button pauses/resumes instead —
188 // pause hard-gates audio at the capture callback.
189 if (alwaysReadyPhase !== "off") {
187 // In mic-switch mode the physical switch on the mic is the control.
188 // While it's off, the button can't start anything — tell the user.
189 if (micSwitchPhase === "muted") {
190 toast("Mic switch is off — flip the switch on your microphone to dictate.", "info", 4000);
191 return;
192 }
193 // In Always-Ready or mic-switch mode the supervisor owns start/stop
194 // (the watchdog would immediately re-arm a stop). The button
195 // pauses/resumes instead — pause hard-gates audio at the capture callback.
196 if (alwaysReadyPhase !== "off" || micSwitchPhase !== "off") {
190197 try {
191198 const { invoke } = await import("@tauri-apps/api/core");
192199 if (status === "paused") {
@@ -258,7 +265,7 @@ export function DictationPanel() {
258265 setActiveDeviceName(null);
259266 isStoppingRef.current = false;
260267 }
261 }, [status, alwaysReadyPhase, setStatus, setActiveDeviceName]);
268 }, [status, alwaysReadyPhase, micSwitchPhase, setStatus, setActiveDeviceName]);
262269
263270 const handlePause = useCallback(async () => {
264271 if (status === "listening") {
@@ -581,11 +588,15 @@ export function DictationPanel() {
581588 )}
582589 {status === "listening" && (alwaysReadyPhase === "off" || alwaysReadyPhase === "error") && (
583590 <>
584 Listening<span className="text-brass-400">.</span>
591 {micSwitchPhase === "live" ? "Mic switch on — dictating" : "Listening"}
592 <span className="text-brass-400">.</span>
585593 </>
586594 )}
587595 {status === "processing" && "Processing speech"}
588 {status === "paused" && "Paused"}
596 {status === "paused" &&
597 (micSwitchPhase === "muted"
598 ? "Mic switched off — flip to dictate"
599 : "Paused")}
589600 {status === "error" && "Couldn't start dictation"}
590601 </h2>
591602 {status === "error" && errorMessage && (
Modifiedsrc/components/settings/SettingsPanel.tsx+9−0View fileUnifiedSplit
@@ -350,6 +350,15 @@ function AudioSettings() {
350350 onChange={(v) => settings.updateSetting("noiseSuppression", v)}
351351 />
352352 </SettingRow>
353
354 <SettingRow>
355 <Switch
356 label="Hardware Mic Switch"
357 description="The physical mute/power switch on your mic (Razer, Yeti, Wave…) starts and stops dictation — no keyboard shortcut needed. Flip on to dictate, flip off to finish."
358 checked={settings.micSwitchMode}
359 onChange={(v) => settings.updateSetting("micSwitchMode", v)}
360 />
361 </SettingRow>
353362 </div>
354363 );
355364}
Modifiedsrc/hooks/useGlobalShortcuts.ts+10−3View fileUnifiedSplit
@@ -67,13 +67,20 @@ export function useGlobalShortcuts(enabled: boolean): void {
6767 }
6868 }
6969
70 // Toggle — on Pressed, flip listening/idle. In Always-Ready mode the
71 // supervisor owns start/stop, so the hotkey pauses/resumes instead.
70 // Toggle — on Pressed, flip listening/idle. In Always-Ready or
71 // mic-switch mode the supervisor owns start/stop, so the hotkey
72 // pauses/resumes instead.
7273 if (shortcutToggle) {
7374 await registerOne(shortcutToggle, (event) => {
7475 if (event.state !== "Pressed") return;
7576 const dictation = useDictationStore.getState();
76 if (dictation.alwaysReadyPhase !== "off") {
77 // While the physical mic switch is off, it is the only control —
78 // the hotkey can't start a hardware-muted mic.
79 if (dictation.micSwitchPhase === "muted") {
80 toast("Mic switch is off — flip the switch on your microphone to dictate.", "info", 4000);
81 return;
82 }
83 if (dictation.alwaysReadyPhase !== "off" || dictation.micSwitchPhase !== "off") {
7784 const resuming = dictation.status === "paused";
7885 dictation.setStatus(resuming ? "listening" : "paused");
7986 (async () => {
Modifiedsrc/hooks/useTauriEvents.ts+33−0View fileUnifiedSplit
@@ -515,6 +515,9 @@ export function useTauriEvents(): void {
515515 // every silence hangover — that disconnect is routine, not terminal.
516516 const unlistenDisconnected = await listen("streaming-disconnected", () => {
517517 if (useDictationStore.getState().alwaysReadyPhase !== "off") return;
518 // Same for mic-switch mode: sessions close every time the physical
519 // switch is flipped off — routine, not terminal.
520 if (useDictationStore.getState().micSwitchPhase !== "off") return;
518521 const s = useDictationStore.getState().status;
519522 if (s === "listening" || s === "processing" || s === "paused") {
520523 useDictationStore.getState().setStatus("idle");
@@ -545,6 +548,27 @@ export function useTauriEvents(): void {
545548 useSettingsStore.getState().updateSetting("alwaysReadyMode", !!event.payload);
546549 });
547550
551 // Hardware mic-switch lifecycle. "live" = physical switch on and
552 // dictating; "muted" = switch flipped off, session finalized, waiting
553 // for the flip back on. Keep status coherent with the phase so the
554 // start button and status pill reflect the hardware state.
555 const unlistenMicSwitchState = await listen<string>("mic-switch-state", (event) => {
556 const phase = event.payload as "off" | "live" | "muted";
557 const dictation = useDictationStore.getState();
558 dictation.setMicSwitchPhase(phase);
559 if (phase === "live") {
560 if (dictation.status !== "listening") dictation.setStatus("listening");
561 } else if (phase === "muted") {
562 if (dictation.status === "listening" || dictation.status === "processing") {
563 dictation.setStatus("paused");
564 }
565 } else {
566 if (dictation.status === "listening" || dictation.status === "processing" || dictation.status === "paused") {
567 dictation.setStatus("idle");
568 }
569 }
570 });
571
548572 unlisten = () => {
549573 unlistenLevel();
550574 unlistenWaveform();
@@ -560,6 +584,7 @@ export function useTauriEvents(): void {
560584 unlistenDisconnected();
561585 unlistenAlwaysReadyState();
562586 unlistenAlwaysReadyToggle();
587 unlistenMicSwitchState();
563588 };
564589
565590 // Hydrate the gate phase — the backend may have armed Always-Ready
@@ -572,6 +597,14 @@ export function useTauriEvents(): void {
572597 dictation.setAlwaysReadyPhase("armed");
573598 if (dictation.status === "idle") dictation.setStatus("listening");
574599 }
600 // Same hydration for mic-switch mode — armed from the persisted
601 // setting before the webview loaded means the pipeline is already up.
602 const switchArmed = await invoke<boolean>("get_mic_switch_state");
603 if (switchArmed) {
604 const dictation = useDictationStore.getState();
605 dictation.setMicSwitchPhase("live");
606 if (dictation.status === "idle") dictation.setStatus("listening");
607 }
575608 } catch {
576609 // Command unavailable (tests / old backend).
577610 }
Addedsrc/lib/legalVocab.test.ts+77−0View fileUnifiedSplit
@@ -0,0 +1,77 @@
1import { describe, it, expect } from "vitest";
2import {
3 legalTermsForJurisdiction,
4 mergeVocabulary,
5 KEYTERM_CAP,
6} from "./legalVocab";
7
8describe("legalTermsForJurisdiction", () => {
9 it("always includes the common-law core", () => {
10 for (const j of ["uk", "us", "australia", "canada", "nz", "global"] as const) {
11 const terms = legalTermsForJurisdiction(j);
12 expect(terms).toContain("estoppel");
13 expect(terms).toContain("res ipsa loquitur");
14 expect(terms).toContain("voir dire");
15 }
16 });
17
18 it("adds jurisdiction-specific terms", () => {
19 expect(legalTermsForJurisdiction("uk")).toContain("CPR Part 36");
20 expect(legalTermsForJurisdiction("us")).toContain("Rule 12(b)(6)");
21 expect(legalTermsForJurisdiction("nz")).toContain(
22 "the High Court of New Zealand"
23 );
24 expect(legalTermsForJurisdiction("australia")).toContain("Calderbank offer");
25 expect(legalTermsForJurisdiction("canada")).toContain("factum");
26 });
27
28 it("does not leak one jurisdiction's terms into another", () => {
29 expect(legalTermsForJurisdiction("us")).not.toContain("CPR Part 36");
30 expect(legalTermsForJurisdiction("uk")).not.toContain("Rule 12(b)(6)");
31 });
32
33 it("stays within the keyterm cap on its own", () => {
34 for (const j of ["uk", "us", "australia", "canada", "nz", "global"] as const) {
35 expect(legalTermsForJurisdiction(j).length).toBeLessThanOrEqual(KEYTERM_CAP);
36 }
37 });
38
39 it("has no duplicate terms within a pack", () => {
40 for (const j of ["uk", "us", "australia", "canada", "nz"] as const) {
41 const terms = legalTermsForJurisdiction(j).map((t) => t.toLowerCase());
42 expect(new Set(terms).size).toBe(terms.length);
43 }
44 });
45});
46
47describe("mergeVocabulary", () => {
48 it("preserves priority order and dedupes case-insensitively", () => {
49 const out = mergeVocabulary([
50 ["Estoppel", "EBITDA"],
51 ["estoppel", "amortisation"],
52 ["ebitda", "voir dire"],
53 ]);
54 expect(out).toEqual(["Estoppel", "EBITDA", "amortisation", "voir dire"]);
55 });
56
57 it("earlier sources win the budget when the cap is hit", () => {
58 const user = Array.from({ length: 98 }, (_, i) => `user${i}`);
59 const pack = ["packA", "packB", "packC"];
60 const out = mergeVocabulary([user, pack], 100);
61 expect(out).toHaveLength(100);
62 expect(out.slice(0, 98)).toEqual(user);
63 expect(out).toContain("packA");
64 expect(out).toContain("packB");
65 expect(out).not.toContain("packC");
66 });
67
68 it("skips blank and whitespace-only entries without spending budget", () => {
69 const out = mergeVocabulary([["", " ", "estoppel"]], 2);
70 expect(out).toEqual(["estoppel"]);
71 });
72
73 it("defaults to the keyterm cap", () => {
74 const many = Array.from({ length: 300 }, (_, i) => `term${i}`);
75 expect(mergeVocabulary([many])).toHaveLength(KEYTERM_CAP);
76 });
77});
Addedsrc/lib/legalVocab.ts+200−0View fileUnifiedSplit
@@ -0,0 +1,200 @@
1/**
2 * Built-in legal vocabulary pack, merged into the STT keyterm boost when
3 * Legal Mode is on. Nova-3 keyterm prompting dramatically improves
4 * first-pass recognition of terms of art that general models mis-hear
5 * ("estoppel" → "a stop hole", "certiorari" → "surrender rari"), so
6 * lawyers get accurate transcripts without hand-curating hundreds of
7 * custom vocabulary entries themselves.
8 *
9 * The pack is jurisdiction-aware: a common-law core shared everywhere,
10 * plus courts/roles/instruments specific to the user's configured
11 * jurisdiction. User-entered, client-matter, and flywheel-learned terms
12 * always take priority over this pack when the keyterm cap is reached
13 * (see `mergeVocabulary`).
14 */
15
16import type { AppSettings } from "@/stores/settings";
17
18/** Deepgram Nova-3 accepts a bounded number of keyterms per request —
19 * beyond this the extra terms are ignored (and very large lists degrade
20 * boosting quality). User terms always win the budget over pack terms. */
21export const KEYTERM_CAP = 100;
22
23/** Latin and common-law terms of art shared across all jurisdictions. */
24const CORE_LEGAL_TERMS: string[] = [
25 // Latin terms of art
26 "res ipsa loquitur",
27 "res judicata",
28 "estoppel",
29 "certiorari",
30 "voir dire",
31 "habeas corpus",
32 "mens rea",
33 "actus reus",
34 "prima facie",
35 "inter alia",
36 "ultra vires",
37 "sub judice",
38 "obiter dicta",
39 "ratio decidendi",
40 "stare decisis",
41 "de novo",
42 "ex parte",
43 "in camera",
44 "amicus curiae",
45 "subpoena duces tecum",
46 "quantum meruit",
47 "caveat emptor",
48 "force majeure",
49 "pro rata",
50 "pari passu",
51 "bona fide",
52 "mutatis mutandis",
53 "locus standi",
54 "functus officio",
55 "non est factum",
56 "uberrimae fidei",
57 "contra proferentem",
58 // Procedure and practice
59 "interlocutory",
60 "interrogatories",
61 "affidavit",
62 "deponent",
63 "tortfeasor",
64 "joinder",
65 "misjoinder",
66 "rejoinder",
67 "demurrer",
68 "estopped",
69 "indemnitor",
70 "indemnitee",
71 "subrogation",
72 "novation",
73 "rescission",
74 "severability",
75 "recital",
76 "testamentary",
77 "intestacy",
78 "codicil",
79 "probate",
80 "conveyancing",
81 "encumbrance",
82 "easement",
83 "chattel",
84 "bailment",
85 "lien",
86 "usufruct",
87 "fiduciary",
88 "mortgagor",
89 "mortgagee",
90 "lessor",
91 "lessee",
92 "assignor",
93 "assignee",
94 "garnishee",
95 "adjournment",
96 "arraignment",
97 "recognizance",
98 "injunction",
99 "mandamus",
100 "replevin",
101 "laches",
102 "tolling",
103 "remittitur",
104];
105
106/** Jurisdiction-specific courts, officers, and instruments. */
107const JURISDICTION_TERMS: Record<AppSettings["jurisdiction"], string[]> = {
108 uk: [
109 "King's Counsel",
110 "solicitor-advocate",
111 "barrister",
112 "the Crown Court",
113 "the King's Bench Division",
114 "the Chancery Division",
115 "Master of the Rolls",
116 "CPR Part 36",
117 "without prejudice save as to costs",
118 "Tomlin order",
119 "Part 20 claim",
120 "litigant in person",
121 ],
122 us: [
123 "Federal Rules of Civil Procedure",
124 "Rule 12(b)(6)",
125 "summary judgment",
126 "Daubert motion",
127 "Chapter 11",
128 "Chapter 7",
129 "the Second Circuit",
130 "the Ninth Circuit",
131 "SCOTUS",
132 "en banc",
133 "removal jurisdiction",
134 "diversity jurisdiction",
135 ],
136 australia: [
137 "the Federal Court of Australia",
138 "the High Court of Australia",
139 "Senior Counsel",
140 "the Fair Work Commission",
141 "Calderbank offer",
142 "the Corporations Act",
143 "ASIC",
144 "unconscionable conduct",
145 ],
146 canada: [
147 "the Supreme Court of Canada",
148 "the Federal Court of Appeal",
149 "the Charter",
150 "King's Counsel",
151 "factum",
152 "examination for discovery",
153 "the Divisional Court",
154 ],
155 nz: [
156 "the High Court of New Zealand",
157 "the Court of Appeal",
158 "the Employment Relations Authority",
159 "the Disputes Tribunal",
160 "King's Counsel",
161 "the Companies Act 1993",
162 "the Property Law Act",
163 "the Resource Management Act",
164 ],
165 global: [],
166};
167
168/** The full pack for a jurisdiction: shared core + local terms. */
169export function legalTermsForJurisdiction(
170 jurisdiction: AppSettings["jurisdiction"]
171): string[] {
172 return [...CORE_LEGAL_TERMS, ...(JURISDICTION_TERMS[jurisdiction] ?? [])];
173}
174
175/**
176 * Merge vocabulary sources into a deduplicated keyterm list under the cap.
177 * Priority order (earlier wins the budget): user custom terms, client
178 * matter terms, flywheel-learned terms, then the built-in legal pack.
179 * Dedupe is case-insensitive — a user-entered "Estoppel" suppresses the
180 * pack's "estoppel" rather than spending two keyterm slots.
181 */
182export function mergeVocabulary(
183 sources: string[][],
184 cap: number = KEYTERM_CAP
185): string[] {
186 const seen = new Set<string>();
187 const out: string[] = [];
188 for (const source of sources) {
189 for (const term of source) {
190 const trimmed = term.trim();
191 if (!trimmed) continue;
192 const key = trimmed.toLowerCase();
193 if (seen.has(key)) continue;
194 if (out.length >= cap) return out;
195 seen.add(key);
196 out.push(trimmed);
197 }
198 }
199 return out;
200}
Modifiedsrc/lib/settings.test.ts+2−0View fileUnifiedSplit
@@ -25,6 +25,7 @@ const DEFAULTS: AppSettings = {
2525 smartFormat: true,
2626 voiceCommandsEnabled: true,
2727 alwaysReadyMode: false,
28 micSwitchMode: false,
2829 translationEnabled: false,
2930 translationTargetLanguage: "en",
3031 injectionMode: "keyboard",
@@ -85,6 +86,7 @@ describe("toBackendSettings", () => {
8586 expect(backend.jurisdiction).toBe("global");
8687 expect(backend.voxlen_context).toBe("general");
8788 expect(backend.always_ready_mode).toBe(false);
89 expect(backend.mic_switch_mode).toBe(false);
8890 });
8991
9092 it("passes customVocabulary array through unchanged", () => {
Modifiedsrc/lib/settings.ts+3−0View fileUnifiedSplit
@@ -35,6 +35,7 @@ export interface BackendAppSettings {
3535 smart_format: boolean;
3636 voice_commands_enabled: boolean;
3737 always_ready_mode: boolean;
38 mic_switch_mode: boolean;
3839
3940 // Text injection
4041 injection_mode: "keyboard" | "clipboard" | "buffer";
@@ -112,6 +113,7 @@ export function toBackendSettings(s: AppSettings): BackendAppSettings {
112113 smart_format: s.smartFormat,
113114 voice_commands_enabled: s.voiceCommandsEnabled,
114115 always_ready_mode: s.alwaysReadyMode,
116 mic_switch_mode: s.micSwitchMode,
115117
116118 injection_mode: s.injectionMode,
117119
@@ -185,6 +187,7 @@ export function fromBackendSettings(
185187 if (s.smart_format !== undefined) out.smartFormat = s.smart_format;
186188 if (s.voice_commands_enabled !== undefined) out.voiceCommandsEnabled = s.voice_commands_enabled;
187189 if (s.always_ready_mode !== undefined) out.alwaysReadyMode = s.always_ready_mode;
190 if (s.mic_switch_mode !== undefined) out.micSwitchMode = s.mic_switch_mode;
188191
189192 if (s.injection_mode !== undefined) out.injectionMode = s.injection_mode;
190193
Modifiedsrc/lib/vocab.ts+12−3View fileUnifiedSplit
@@ -9,6 +9,7 @@
99import { useSettingsStore } from "@/stores/settings";
1010import { useClientsStore } from "@/stores/clients";
1111import { useFlywheelStore } from "@/stores/flywheel";
12import { legalTermsForJurisdiction, mergeVocabulary } from "@/lib/legalVocab";
1213
1314export function collectVocabulary(): string[] {
1415 const settings = useSettingsStore.getState();
@@ -21,9 +22,17 @@ export function collectVocabulary(): string[] {
2122 .vocabulary.filter((v) => v.frequency >= 2)
2223 .map((v) => v.word)
2324 : [];
24 return Array.from(
25 new Set([...settings.customVocabulary, ...clientVocab, ...flywheelVocab])
26 );
25 // Legal Mode adds the built-in jurisdiction-aware term pack, lowest
26 // priority so user/client/learned terms always win the keyterm budget.
27 const legalPack = settings.legalMode
28 ? legalTermsForJurisdiction(settings.jurisdiction)
29 : [];
30 return mergeVocabulary([
31 settings.customVocabulary,
32 clientVocab,
33 flywheelVocab,
34 legalPack,
35 ]);
2736}
2837
2938/** Convenience for `correct_grammar` calls: undefined when empty. */
Modifiedsrc/stores/dictation.ts+7−0View fileUnifiedSplit
@@ -121,6 +121,10 @@ interface DictationState {
121121 * disabled; "armed" = watching for speech locally, nothing streaming;
122122 * "streaming" = cloud session open. */
123123 alwaysReadyPhase: "off" | "armed" | "streaming" | "error";
124 /** Hardware mic-switch phase (backend-driven). "off" when the mode is
125 * disabled; "live" = physical switch on, dictating; "muted" = switch
126 * off, waiting for the user to flip it back on. */
127 micSwitchPhase: "off" | "live" | "muted";
124128 /** Id of the draft billing entry created when the last session ended —
125129 * drives the post-session review banner. */
126130 lastDraftEntryId: string | null;
@@ -129,6 +133,7 @@ interface DictationState {
129133 setStatus: (status: DictationStatus) => void;
130134 setLastDraftEntryId: (id: string | null) => void;
131135 setAlwaysReadyPhase: (phase: "off" | "armed" | "streaming" | "error") => void;
136 setMicSwitchPhase: (phase: "off" | "live" | "muted") => void;
132137 addSegment: (segment: TranscriptionSegment) => void;
133138 updateSegment: (id: string, updates: Partial<TranscriptionSegment>) => void;
134139 popLastSegment: () => void;
@@ -164,9 +169,11 @@ export const useDictationStore = create<DictationState>((set, get) => ({
164169 sessionStartedAtMs: null,
165170 capsLock: false,
166171 alwaysReadyPhase: "off",
172 micSwitchPhase: "off",
167173 lastDraftEntryId: null,
168174
169175 setAlwaysReadyPhase: (phase) => set({ alwaysReadyPhase: phase }),
176 setMicSwitchPhase: (phase) => set({ micSwitchPhase: phase }),
170177 setLastDraftEntryId: (id) => set({ lastDraftEntryId: id }),
171178
172179 setStatus: (status) =>
Modifiedsrc/stores/settings.ts+6−0View fileUnifiedSplit
@@ -39,6 +39,10 @@ export interface AppSettings {
3939 * local voice-activity gate opens the cloud session only while speech is
4040 * present. The mic's hardware mute button becomes the only control. */
4141 alwaysReadyMode: boolean;
42 /** Hardware mic-switch mode: the physical mute/power switch on an
43 * external mic (Razer, Yeti, …) starts and stops dictation directly —
44 * no Win+H, no keyboard shortcut. Works with every STT engine. */
45 micSwitchMode: boolean;
4246
4347 // Translation
4448 translationEnabled: boolean;
@@ -152,6 +156,7 @@ const defaultSettings: AppSettings = {
152156 smartFormat: true,
153157 voiceCommandsEnabled: true,
154158 alwaysReadyMode: false,
159 micSwitchMode: false,
155160
156161 translationEnabled: false,
157162 translationTargetLanguage: "en",
@@ -232,6 +237,7 @@ function schedulePersist() {
232237 smartFormat: state.smartFormat,
233238 voiceCommandsEnabled: state.voiceCommandsEnabled,
234239 alwaysReadyMode: state.alwaysReadyMode,
240 micSwitchMode: state.micSwitchMode,
235241 translationEnabled: state.translationEnabled,
236242 translationTargetLanguage: state.translationTargetLanguage,
237243 injectionMode: state.injectionMode,
238244
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts