python-voiceio 0.3.12__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,20 +1,18 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.3.12
4
- Summary: Speak → text, locally, instantly.
3
+ Version: 0.4.0
4
+ Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/Hugo0/voiceio
8
8
  Project-URL: Repository, https://github.com/Hugo0/voiceio
9
9
  Project-URL: Issues, https://github.com/Hugo0/voiceio/issues
10
10
  Project-URL: Changelog, https://github.com/Hugo0/voiceio/releases
11
- Keywords: voice,speech-to-text,whisper,linux,windows,dictation,wayland,ibus
11
+ Keywords: voice,speech-to-text,whisper,linux,dictation,wayland,ibus
12
12
  Classifier: Development Status :: 4 - Beta
13
13
  Classifier: Environment :: X11 Applications
14
14
  Classifier: Intended Audience :: End Users/Desktop
15
15
  Classifier: Operating System :: POSIX :: Linux
16
- Classifier: Operating System :: Microsoft :: Windows
17
- Classifier: Operating System :: MacOS
18
16
  Classifier: Programming Language :: Python :: 3
19
17
  Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
20
18
  Requires-Python: >=3.11
@@ -61,10 +59,18 @@ Dynamic: license-file
61
59
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
62
60
  [![Downloads](https://img.shields.io/pepy/dt/python-voiceio)](https://pepy.tech/projects/python-voiceio)
63
61
 
64
- Speak text, locally, instantly.
62
+ Voice dictation for Linux. Local, yours, and it learns how you speak.
65
63
 
66
64
  https://github.com/user-attachments/assets/9cf5d1ac-b4bb-4cf8-b775-7a66dc16b376
67
65
 
66
+ Open-source, sovereign voice input/output. Your speech is transcribed on your own machine, everything it learns about you stays in local files you own, and it gets better the more you use it.
67
+
68
+ - **Local & sovereign** — speech is transcribed on-device with [faster-whisper](https://github.com/SYSTRAN/faster-whisper); audio never leaves the machine. Your history, retained audio, corrections, and vocabulary all live in plain local files (JSONL / TOML / txt) you can read, edit, and delete. Zero telemetry. The one honest nuance: two *optional, off-by-default* features send text (never audio) to a cloud LLM you configure yourself — final-transcript polish (`[postcorrect]`) and the weekly correction-mining review.
69
+ - **Improves with use — automatically** — every utterance teaches it your words and names. See [How it learns](#how-it-learns).
70
+ - **Linux-first & hackable** — Wayland/X11, GNOME/KDE/sway/i3, chosen automatically by chain-and-probe backends. It's plain Python you can read in an afternoon and [contribute to](CLAUDE.md).
71
+
72
+ > **Linux-first.** voiceio is developed and tested daily on Linux (GNOME/Wayland). Windows and macOS ship as **experimental, untested** targets — the code paths exist but are unmaintained and likely broken. See [Experimental platforms](#experimental-platforms).
73
+
68
74
  ## Quick start
69
75
 
70
76
  ```bash
@@ -102,28 +108,9 @@ voiceio setup
102
108
  </details>
103
109
 
104
110
  <details>
105
- <summary><strong>Windows</strong></summary>
106
-
107
- ```powershell
108
- # Option A: Install with pip (requires Python 3.11+)
109
- pip install python-voiceio
110
- voiceio setup
111
-
112
- # Option B: Download the installer from GitHub Releases (no Python needed)
113
- # https://github.com/Hugo0/voiceio/releases
114
- # Also available as a portable .zip if you prefer no installation.
115
- ```
111
+ <summary><strong>Windows / macOS (experimental)</strong></summary>
116
112
 
117
- Windows uses pynput for hotkeys and text injection. No extra system dependencies required.
118
- </details>
119
-
120
- <details>
121
- <summary><strong>macOS</strong></summary>
122
-
123
- ```bash
124
- pipx install python-voiceio
125
- voiceio setup
126
- ```
113
+ See [Experimental platforms](#experimental-platforms) below these builds are untested and unmaintained.
127
114
  </details>
128
115
 
129
116
  <details>
@@ -152,7 +139,35 @@ hotkey → mic capture → whisper (local) → text at cursor
152
139
  pre-buffered streaming IBus / clipboard
153
140
  ```
154
141
 
155
- Press your hotkey to start recording (1s pre-buffer catches the first syllable). Text streams into the focused app as an underlined preview. Press again to commit. Transcription runs locally via [faster-whisper](https://github.com/SYSTRAN/faster-whisper), text is injected through IBus (any GTK/Qt app) with clipboard fallback for terminals.
142
+ Press your hotkey to start recording (1s pre-buffer catches the first syllable). Text streams into the focused app as an underlined preview. Press again to commit. Transcription runs locally via [faster-whisper](https://github.com/SYSTRAN/faster-whisper), text is injected through IBus (any GTK/Qt app) with clipboard fallback for terminals. It runs in real time on a modern CPU and ships with model tiers from `tiny` to `large-v3`.
143
+
144
+ ## How it learns
145
+
146
+ Most dictation tools transcribe the same way on day 100 as on day 1. voiceio adapts to *you* — your jargon, names, and accent — entirely from data that never leaves your machine.
147
+
148
+ ```mermaid
149
+ flowchart LR
150
+ subgraph live ["every utterance"]
151
+ A["🎙️ dictate"] --> B["store audio + raw text\n+ confidence (local)"]
152
+ A --> C["hotwords + recent context\nbias the Whisper decoder"]
153
+ A --> D["optional LLM pass fixes\nmisheard proper nouns"]
154
+ end
155
+ subgraph weekly ["weekly, in the background"]
156
+ E["mine history for recurring errors\n→ learn corrections + vocabulary\n(safety-gated, multi-vote)"] --> F["teacher model replays your audio\n→ audits what was learned"]
157
+ F -->|"bad rule"| G["auto-retired"]
158
+ F -->|"quality regressed"| H["whole week rolled back"]
159
+ end
160
+ B --> E
161
+ F -->|"confirmed"| C
162
+ ```
163
+
164
+ 1. **Capture** — every utterance stores its audio, raw text, and confidence in local files.
165
+ 2. **Bias** — your vocabulary and recent context steer the Whisper decoder (hotwords / prompt) on every recording.
166
+ 3. **Contextual fix** — an optional LLM pass repairs misheard proper nouns using surrounding context.
167
+ 4. **Mine** — a weekly background job scans your history for recurring errors and auto-learns corrections and vocabulary. Multi-vote adjudication and a protected-languages guard (for bilingual users) keep it safe; it never asks you to triage.
168
+ 5. **Audit** — a teacher model (a larger Whisper) replays your retained audio to verify what was learned. Bad rules are retired automatically, and a system-level drift metric rolls back an entire week of learning if quality regressed.
169
+
170
+ Rules are always **probationary, never tenured** — anything that stops helping is dropped. Your only touchpoint is an occasional desktop notification telling you what was learned.
156
171
 
157
172
  ## Features
158
173
 
@@ -202,7 +217,7 @@ voiceio uninstall Remove all system integrations
202
217
  `voiceio setup` handles everything interactively. To tweak later, edit the config file or override at runtime:
203
218
 
204
219
  - Linux/macOS: `~/.config/voiceio/config.toml`
205
- - Windows: `%LOCALAPPDATA%\voiceio\config\config.toml`
220
+ - Windows: `%LOCALAPPDATA%\voiceio\config\config.toml` (see [Experimental platforms](#experimental-platforms))
206
221
 
207
222
  ```bash
208
223
  voiceio --model large-v3 --language auto -v
@@ -224,12 +239,12 @@ voiceio logs # check debug output
224
239
  | Hotkey doesn't work on Wayland | `sudo usermod -aG input $USER` then log out and back in |
225
240
  | Transcription too slow | Use a smaller model: `voiceio --model tiny` |
226
241
  | Want to start fresh | `voiceio uninstall` then `voiceio setup` |
227
- | Windows: antivirus blocks hotkeys | pynput uses global keyboard hooksadd an exception for voiceio |
228
- | Windows: no sound feedback | Check `voiceio logs` for audio device info |
229
- | macOS issues | Experimental — consider [aquavoice.com](https://aquavoice.com/) or contribute a PR |
242
+ | Windows / macOS issues | These platforms are experimental and untested see [Experimental platforms](#experimental-platforms) |
230
243
 
231
244
  ## Platform support
232
245
 
246
+ voiceio targets **Linux**. That's what it's developed and tested against.
247
+
233
248
  | Platform | Status | Text injection | Hotkeys | Streaming preview |
234
249
  |----------|--------|---------------|---------|-------------------|
235
250
  | Ubuntu / Debian (GNOME, Wayland) | **Tested daily** | IBus | evdev / GNOME shortcut | Yes |
@@ -237,11 +252,22 @@ voiceio logs # check debug output
237
252
  | Fedora (GNOME) | Supported | IBus | evdev / GNOME shortcut | Yes |
238
253
  | Arch Linux | Supported | IBus | evdev | Yes |
239
254
  | KDE / Sway / Hyprland | Should work | IBus / ydotool / wtype | evdev | Yes |
240
- | Windows 10/11 | Experimental | pynput / clipboard | pynput | Type-and-correct (no preedit) |
241
- | macOS | Experimental | pynput / clipboard | pynput | Type-and-correct (no preedit) |
242
255
 
243
256
  voiceio auto-detects your platform and picks the best available backends. Run `voiceio doctor` to see what's working on your system.
244
257
 
258
+ ## Experimental platforms
259
+
260
+ Windows and macOS code paths exist, but they are **experimental, untested, and unmaintained** — the maintainer only develops on Linux, so they may be broken at any given time. No parity with Linux is promised. Contributions are welcome, but please don't file bugs expecting a fix.
261
+
262
+ | Platform | Status | Text injection | Hotkeys | Streaming preview |
263
+ |----------|--------|---------------|---------|-------------------|
264
+ | Windows 10/11 | Experimental / untested | pynput / clipboard | pynput | Type-and-correct (no preedit) |
265
+ | macOS | Experimental / untested | pynput / clipboard | pynput | Type-and-correct (no preedit) |
266
+
267
+ - **Windows:** `pip install python-voiceio` then `voiceio setup` (pynput handles hotkeys + text injection; no system deps). Prebuilt installers may appear on [GitHub Releases](https://github.com/Hugo0/voiceio/releases).
268
+ - **macOS:** `pipx install python-voiceio` then `voiceio setup`. If it doesn't work for you, consider [aquavoice.com](https://aquavoice.com/) or contribute a PR.
269
+ - Config lives at `%LOCALAPPDATA%\voiceio\config\config.toml` (Windows) or `~/.config/voiceio/config.toml` (macOS).
270
+
245
271
  ## Uninstall
246
272
 
247
273
  ```bash
@@ -275,7 +301,7 @@ Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) and [open issues](
275
301
  - [x] Voice commands — "new line", "new paragraph", "scratch that", punctuation by name
276
302
  - [x] Custom vocabulary / personal dictionary (bias Whisper via `initial_prompt`)
277
303
  - [x] Smart punctuation & capitalization post-processing
278
- - [x] Windows support
304
+ - [x] Windows support (experimental, untested)
279
305
  - [x] System tray icon with animated states
280
306
  - [x] Auto-stop on silence
281
307
 
@@ -1,35 +1,40 @@
1
- python_voiceio-0.3.12.dist-info/licenses/LICENSE,sha256=Gz61o8jFTAvZUZyB3nWDB3DQQVuipjfPkVu9W8hBHM0,1072
2
- voiceio/__init__.py,sha256=uzDtUmN3o6XGGV0Pkoi7vJCijlJJCOYCmLl8900CcXg,23
1
+ python_voiceio-0.4.0.dist-info/licenses/LICENSE,sha256=Gz61o8jFTAvZUZyB3nWDB3DQQVuipjfPkVu9W8hBHM0,1072
2
+ voiceio/__init__.py,sha256=42STGor_9nKYXumfeV5tiyD_M8VdcddX7CEexmibPBk,22
3
3
  voiceio/__main__.py,sha256=xT5QCGGreYMisHO7Lh_Y-xAQ2TOzG2D7npKknGNcWSY,53
4
- voiceio/app.py,sha256=AfpGBpk-uT4QFTFys9mm-O3cOl_V1TESJLoJJYfC1sY,46427
5
- voiceio/autocorrect.py,sha256=qEug82DDrxKamnU0HSbp-Llo08RvecZreLSwA2HRJmE,9982
4
+ voiceio/app.py,sha256=V6XGwWvvB6C6detltWW4OGMKWYUZPcAFcWUL4gXdqBY,53130
5
+ voiceio/audit.py,sha256=vX3x-YAhmt6EZYRZd0_6wvTquZu5sxGMvH45BhSBnCk,14915
6
+ voiceio/autocorrect.py,sha256=jGsglEYwAHWk2uWedeEZzyAMW2LWO_8nQGZgrWYy4Dc,26552
7
+ voiceio/autocorrect_state.py,sha256=BNzdHP5slcOV_-OU4MlaKE7xgVJzrPncsYFK30JWTs8,6858
6
8
  voiceio/backends.py,sha256=-rs1YhPblub4NjFb6qCD7Ih9FqBRXtPuvjVv9bQoQ1w,368
7
- voiceio/cli.py,sha256=lTNhKiEsq0TKm6sdRx4GN_7mdAiNOacXwNuOSYWQyEg,46993
9
+ voiceio/cli.py,sha256=a27O_1IIn6jaV3Q3f5w9BXtsg3zAAdNi0nDlGx2drU4,65822
8
10
  voiceio/clipboard_read.py,sha256=266u_QoJb_n3iLuy5fbccPZJfpYpPkjsNtZxjDNmqAE,2062
9
11
  voiceio/commands.py,sha256=Vhtn8s5G5OcCbRN44YZ-_14Fa85ISXl_3Ix-KqSHs2A,4694
10
- voiceio/config.py,sha256=VNOBOQmTYXyzP0l_FVvrKJAAF9el_yABN5i4VHasGSg,5975
11
- voiceio/corrections.py,sha256=pSTmk4QVoc419GuOIcy05Z99U2K8HWR1-nVzyTvI8Ak,6164
12
+ voiceio/config.py,sha256=TORjFrY6VDb36CbX9W-U4uDwRaEdcUQY369-uV_EF8U,8137
13
+ voiceio/corrections.py,sha256=7moKhkVvo5CZU1mZobV2-MajkcHiwliKBY1dSBFh0w0,7445
12
14
  voiceio/demo.py,sha256=lhVMdBC-9uh25-7AXl30CVrmpL53KeGrZhY4HAtuxDI,8626
13
- voiceio/feedback.py,sha256=gku30BFjOKk-5I2Ba6ZUWxWqlz2ImGwbhSl1FVLtHls,5016
15
+ voiceio/feedback.py,sha256=TyUy_wy5HNTO0u3opx58aaPXz7mAqeN98UbNdLfnoiw,5136
14
16
  voiceio/health.py,sha256=aJcULea1F06iUjjmREGcD-uO_JF_7zar3b5CFIR_uUM,15204
15
17
  voiceio/hints.py,sha256=dmoiMAJ0YGTWwFc9kIPvEs_BQJN7UEUiwYuLUD_GoQQ,1415
16
- voiceio/history.py,sha256=2r1sP5DrjVK-PH_s9GpLzGWitoy6MimMZSZBbC23BsY,1891
18
+ voiceio/history.py,sha256=bku01AmAkbpl376ud9Ldjp-F-gYKyH1RXoHV5_cxK5s,2699
17
19
  voiceio/llm.py,sha256=_m13jodMVB1iEtze3VGkBCafkg85OssTPBEVyaRabgA,9250
18
- voiceio/llm_api.py,sha256=2YqPoHllCKQ6DcQElrI_p61XY-vNbmI8wbNpfiRmoIk,5426
20
+ voiceio/llm_api.py,sha256=Vjs7x_qphd95TmdPELbMlUdenKdtblusZVFm2G8FQJE,6159
19
21
  voiceio/numbers.py,sha256=MP8jag4_F2OqUU5H14KzOmR9L8gWg48yQhLoUbGg_YI,8376
20
22
  voiceio/pidlock.py,sha256=RInlF_8H7wR0nqkdlhUfxYs7OGzHIvb5p-XDjjQSTNg,619
21
23
  voiceio/platform.py,sha256=5Z_bUVDrU3-tuGQW6zaW_Q5EAER4_y07-wDS3d132fU,7870
22
- voiceio/postprocess.py,sha256=3xN5lDLxHGcauYQs6yj5G6lPjghpRew1Ssa54MtZSxQ,2524
23
- voiceio/prompt.py,sha256=mVq2U7srTf2d_vmIpA_VkzxHMzvqjn78G8-meLFj7Tw,2499
24
- voiceio/recorder.py,sha256=jIXcEdW_OWuzAfaAnT4hB-1AGdOtovazloelKQhBf4w,10878
25
- voiceio/service.py,sha256=JFJ1_GuZA49pojs9xs2NNhz7jR8-u8YBI7ZDnfyQoQ4,9923
26
- voiceio/streaming.py,sha256=TLBqLDZjJbCDWslvJWDA0YDqAFC9HVQVa2y-EZiwQaA,11039
27
- voiceio/transcriber.py,sha256=tZT7FWA3s1LD0Yy3mmbdzq9Cn-WvnuLJCs61ica7hJ0,6186
28
- voiceio/vad.py,sha256=k821wp9dO8SxAAiiuEcreZL-6d4vLLk0DyjXhiAp8EU,4310
29
- voiceio/vocabulary.py,sha256=1sGjMSZx4up9MTxgEH0qcet9DnKQaVMVwPm2bD1QDPc,1731
30
- voiceio/wizard.py,sha256=4ql9fH6Tq16msEo8SV0TM-RtV5KsZI0_nA_bX3Fuc6s,61823
24
+ voiceio/postcorrect.py,sha256=S4aIbMjeYny8-oo7kUQM-6YZB8IDAy7vi80-F1qjwRc,7860
25
+ voiceio/postprocess.py,sha256=9grkq3I6XgiQV6ZQbIw2_fOUe57tzC7GBPvLXqhhdTs,2979
26
+ voiceio/prompt.py,sha256=9iza8KoQxvO_U9NPMJ8Ep7j-zkIs9nW8N5sacCRpsEk,2904
27
+ voiceio/recorder.py,sha256=sTxkKpaGoixmnWnNmygb7dwifu5aZZAXGqRgH1pketE,12026
28
+ voiceio/retention.py,sha256=MhXW3BOw8m50-q6ty6zjXic95VfLjmr6nUQytRuVPK0,2838
29
+ voiceio/service.py,sha256=jqX1opZyQh_dOJMK4bVf8Aoei0zOuuk1Ogta9BXTWGY,12106
30
+ voiceio/snapshots.py,sha256=bm-lzIeYucDUDlPBZiFiWd-jyB2I6Ft1jRANZCO6go4,2351
31
+ voiceio/streaming.py,sha256=epPaQbJLDP8U18RR7LJaQ_KuYmlx2E1gv2lJsJNkloo,11702
32
+ voiceio/transcriber.py,sha256=dslLArdozpqYe2ddTh7KZCXT35TnDpv1dCnRsVZkDjQ,8109
33
+ voiceio/vad.py,sha256=72_ICk4jSuSPYvTHdHYtHzY8W5uaGEOz5FTdqkolJi8,5327
34
+ voiceio/vocabulary.py,sha256=eGV7QZs3tN6Cw5YpyoZsVEk2M5fJvie1n2XFi9CD4hw,6321
35
+ voiceio/wizard.py,sha256=GEG0ykw52kF81CIA9JlatA0rSTxIxapmCMbTuOzA4EM,63350
31
36
  voiceio/wordfreq.py,sha256=2UMjW1xIFqo5EchXzsXPizUhRb9N18CVgjnXso1wsgw,2382
32
- voiceio/worker.py,sha256=Xo1GK85MrGhuZj4fXQEBtI0UFiZvWAgjYqKzxIIAzR4,1656
37
+ voiceio/worker.py,sha256=M6e4s6P0-4UGQzw0j8Mq68nypvwZleOSwuRhjuOqlVE,2563
33
38
  voiceio/hotkeys/__init__.py,sha256=rGXSGZLD2mS_Ep2HfVdRdkUKNr4xagySlL1FM00cYgk,735
34
39
  voiceio/hotkeys/base.py,sha256=L3iBh368sjWpt64JZpocH9DibYEZ0V16-rYflLYbpZY,699
35
40
  voiceio/hotkeys/chain.py,sha256=8pvPhipmxWwmGG9DRXy8dlEKTPFILxNzYO-adcvw5Ps,2800
@@ -64,8 +69,8 @@ voiceio/typers/pynput_type.py,sha256=DTtkT59M-EKOAlTNkvcZrRvf_Fq52OpI8S5t_8S-QAY
64
69
  voiceio/typers/wtype.py,sha256=d1wG-HZdYDQxXIysV_Xvk9HixDpaJgl3VEoKbP5vhIs,1786
65
70
  voiceio/typers/xdotool.py,sha256=dh1zhPqUT8ihJahJ7ZKm6PtfYY087UAzADx664DvQOM,1356
66
71
  voiceio/typers/ydotool.py,sha256=OQPNmaA2DxTAaJKBYpWzh_R6JnPSfum9OpmF7KvKYD4,3728
67
- python_voiceio-0.3.12.dist-info/METADATA,sha256=XoDDYdAaq41kV6lQmxVgOZ50CNCVL7_qx4nD303-rjc,11562
68
- python_voiceio-0.3.12.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
69
- python_voiceio-0.3.12.dist-info/entry_points.txt,sha256=U64fA65zxzyLoC8bgbn2ztQVWHLsc0o0H0qCk1J9DMc,218
70
- python_voiceio-0.3.12.dist-info/top_level.txt,sha256=piwtn309lD6uexQyXdZ-efAVBJF9y6Wfr48Z-8zkNhg,8
71
- python_voiceio-0.3.12.dist-info/RECORD,,
72
+ python_voiceio-0.4.0.dist-info/METADATA,sha256=u1G5NlIoTB4K8BDw_phICZPzzQR9ov4WaSadS3kbMn8,15381
73
+ python_voiceio-0.4.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
74
+ python_voiceio-0.4.0.dist-info/entry_points.txt,sha256=U64fA65zxzyLoC8bgbn2ztQVWHLsc0o0H0qCk1J9DMc,218
75
+ python_voiceio-0.4.0.dist-info/top_level.txt,sha256=piwtn309lD6uexQyXdZ-efAVBJF9y6Wfr48Z-8zkNhg,8
76
+ python_voiceio-0.4.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (83.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
voiceio/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.3.12"
1
+ __version__ = "0.4.0"
voiceio/app.py CHANGED
@@ -22,7 +22,7 @@ from voiceio.streaming import StreamingSession
22
22
  from voiceio.transcriber import Transcriber
23
23
  from voiceio.typers import chain as typer_chain
24
24
  from voiceio.vad import load_vad
25
- from voiceio.vocabulary import load_vocabulary
25
+ from voiceio.vocabulary import VocabularyLoader
26
26
 
27
27
  if TYPE_CHECKING:
28
28
  from voiceio.typers.base import TyperBackend
@@ -92,6 +92,12 @@ def _import_graphical_env() -> None:
92
92
 
93
93
  _HEALTH_CHECK_INTERVAL = 10 # seconds between health checks
94
94
 
95
+ # Whisper's decoder has a hard 448-token sequence budget shared by hotwords,
96
+ # initial_prompt AND the transcription output. Hotwords + prompt must stay
97
+ # well under half of it or output gets truncated mid-utterance.
98
+ # ~600 chars ≈ 150 tokens.
99
+ _HOTWORDS_MAX_CHARS = 600
100
+
95
101
 
96
102
  class _State(enum.Enum):
97
103
  IDLE = "idle"
@@ -154,19 +160,21 @@ class VoiceIO:
154
160
  vad = load_vad(cfg.audio)
155
161
  self.recorder = AudioRecorder(cfg.audio, vad=vad)
156
162
 
157
- vocab = load_vocabulary(cfg.model)
158
163
  self._corrections = CorrectionDict()
159
- # Merge correction targets into vocabulary for Whisper conditioning
160
- vocab_terms = self._corrections.vocabulary_terms()
161
- if vocab_terms:
162
- extra = ", ".join(vocab_terms)
163
- vocab = f"{vocab}, {extra}" if vocab else extra
164
- if vocab:
165
- self.transcriber.set_initial_prompt(vocab)
164
+ # mtime-cached loader so `voiceio correct` vocabulary edits are picked
165
+ # up per-recording without a daemon restart.
166
+ self._vocab_loader = VocabularyLoader(cfg.model)
167
+ # Vocabulary biases the decoder via hotwords; initial_prompt carries
168
+ # recent-transcript context (rebuilt per recording via PromptBuilder).
169
+ self._hotwords = ""
170
+ self._refresh_hotwords()
171
+ from voiceio.prompt import PromptBuilder
172
+ self._prompt_builder = PromptBuilder()
166
173
 
167
174
  self._command_processor = CommandProcessor(enabled=cfg.commands.enabled, editing=cfg.commands.editing)
168
175
  self._cleanup = cfg.output.punctuation_cleanup
169
176
  self._number_conversion = cfg.output.number_conversion
177
+ self._voice_input_prefix = cfg.output.voice_input_prefix
170
178
  self._streaming = cfg.output.streaming
171
179
 
172
180
  # LLM post-processing (optional, requires Ollama)
@@ -180,11 +188,28 @@ class VoiceIO:
180
188
  else:
181
189
  log.warning("LLM enabled but Ollama not available (will retry)")
182
190
 
191
+ # Constrained LLM post-correction (optional, cloud API, final pass only)
192
+ self._postcorrect = None
193
+ if cfg.postcorrect.enabled:
194
+ from voiceio.postcorrect import PostCorrector
195
+ self._postcorrect = PostCorrector(cfg)
196
+ if self._postcorrect.is_available():
197
+ log.info("PostCorrect: %s", cfg.postcorrect.model or cfg.autocorrect.model)
198
+ else:
199
+ log.warning("PostCorrect enabled but no API key resolved (disabled)")
200
+
183
201
  # Explicit state machine
184
202
  self._state = _State.IDLE
185
203
  self._generation = 0 # incremented on every stop; leaked threads check this
186
204
  self._session: StreamingSession | None = None
187
205
  self._record_start: float = 0
206
+ self._last_clip_warn: float = 0
207
+ self._context_title: str | None = None
208
+ # Prune retained recordings over the size cap (startup housekeeping)
209
+ from voiceio import retention
210
+ threading.Thread(
211
+ target=retention.prune, args=(cfg.data,), daemon=True,
212
+ ).start()
188
213
 
189
214
  # Hotkey deduplication
190
215
  self._hotkey_lock = threading.Lock()
@@ -265,6 +290,32 @@ class VoiceIO:
265
290
  # The generation counter ensures the old finalizer exits cleanly.
266
291
  self._do_start()
267
292
 
293
+ def _refresh_hotwords(self) -> None:
294
+ """Rebuild Whisper hotwords from vocabulary + correction targets.
295
+
296
+ The vocabulary read is mtime-cached (only a `stat` when unchanged), and
297
+ `set_hotwords` is called only when the merged string actually changed,
298
+ so this stays cheap enough to run on every recording start.
299
+ """
300
+ vocab = self._vocab_loader.get()
301
+ # Only merge RARE correction targets (proper nouns, technical terms):
302
+ # common-word targets ("review", "company") add nothing to decoder bias
303
+ # and hundreds of them blow Whisper's 448-token prompt+output budget,
304
+ # which truncates transcriptions mid-utterance.
305
+ from voiceio.wordfreq import is_common
306
+ vocab_terms = [
307
+ t for t in self._corrections.vocabulary_terms()
308
+ if not is_common(t, self.cfg.model.language)
309
+ ]
310
+ if vocab_terms:
311
+ extra = ", ".join(vocab_terms)
312
+ vocab = f"{vocab}, {extra}" if vocab else extra
313
+ vocab = vocab[:_HOTWORDS_MAX_CHARS]
314
+ if vocab != self._hotwords:
315
+ self._hotwords = vocab
316
+ if vocab:
317
+ self.transcriber.set_hotwords(vocab)
318
+
268
319
  def _do_start(self) -> None:
269
320
  """Transition to RECORDING."""
270
321
  # Pre-flight: ensure audio stream is healthy before recording
@@ -283,6 +334,12 @@ class VoiceIO:
283
334
  self._state = _State.RECORDING
284
335
  self._activate_ibus()
285
336
  self._corrections.load() # hot-reload corrections on each recording
337
+ self._refresh_hotwords() # hot-reload vocabulary (mtime-cached)
338
+ self.transcriber.set_initial_prompt(self._prompt_builder.build())
339
+ if self.cfg.data.capture_context:
340
+ # Snapshot the dictation target now — focus may change by finalize
341
+ self._context_title = None
342
+ threading.Thread(target=self._capture_context, daemon=True).start()
286
343
  self._record_start = time.monotonic()
287
344
  self.recorder.start()
288
345
  self.recorder.set_on_auto_stop(self._on_auto_stop)
@@ -297,7 +354,9 @@ class VoiceIO:
297
354
  language=self.cfg.model.language,
298
355
  commands=self._command_processor,
299
356
  corrections=self._corrections,
357
+ postcorrect=self._postcorrect,
300
358
  llm=self._llm,
359
+ voice_input_prefix=self._voice_input_prefix,
301
360
  on_typer_broken=self._on_typer_broken,
302
361
  )
303
362
  self._session.start()
@@ -314,6 +373,16 @@ class VoiceIO:
314
373
  self.recorder.set_on_auto_stop(None)
315
374
  audio = self.recorder.stop()
316
375
  tray.set_recording(False)
376
+ self._warn_if_clipping()
377
+
378
+ if self._postcorrect is not None:
379
+ # Freshest context for the finalize pass: this recording's window
380
+ # title (captured at start) and the latest transcripts/vocabulary.
381
+ self._postcorrect.set_context(
382
+ vocabulary=self._hotwords,
383
+ recent=self._prompt_builder.recent(3),
384
+ title=self._context_title,
385
+ )
317
386
 
318
387
  if self._streaming and self._session is not None:
319
388
  self._state = _State.FINALIZING
@@ -343,6 +412,61 @@ class VoiceIO:
343
412
 
344
413
  self._deactivate_ibus()
345
414
 
415
+ def _capture_context(self) -> None:
416
+ from voiceio import retention
417
+ self._context_title = retention.active_window_title()
418
+
419
+ def _retention_extra(self, audio: np.ndarray | None) -> dict:
420
+ """Save the utterance audio (before transcription, so it survives
421
+ worker timeouts) and collect context for the history entry."""
422
+ from voiceio import retention
423
+ audio_name = None
424
+ if audio is not None:
425
+ audio_name = retention.save_audio(audio, time.time(), self.cfg.data)
426
+ return {
427
+ "audio": audio_name,
428
+ "context": self._context_title if self.cfg.data.capture_context else None,
429
+ "model": self.cfg.model.name,
430
+ }
431
+
432
+ def _strip_voice_prefix(self, text: str) -> str:
433
+ """Remove the configured voice-input prefix before persisting.
434
+
435
+ The prefix is presentation for the target app, not dictated content;
436
+ storing it would pollute vocabulary mining and correction analysis.
437
+ """
438
+ if self._voice_input_prefix and text.startswith(self._voice_input_prefix):
439
+ return text[len(self._voice_input_prefix):].lstrip()
440
+ return text
441
+
442
+ _CLIP_RATIO_WARN = 0.005 # >0.5% of samples in flat-top runs
443
+ _CLIP_WARN_INTERVAL = 300 # seconds between warnings
444
+
445
+ def _warn_if_clipping(self) -> None:
446
+ """Warn (rate-limited) when the mic input is saturating the ADC.
447
+
448
+ Clipping cannot be repaired in software — the gain must come down
449
+ before the ADC — so tell the user how to lower it.
450
+ """
451
+ meter = self.recorder.get_meter()
452
+ if meter["clip_ratio"] < self._CLIP_RATIO_WARN:
453
+ return
454
+ now = time.monotonic()
455
+ if now - self._last_clip_warn < self._CLIP_WARN_INTERVAL:
456
+ return
457
+ self._last_clip_warn = now
458
+ log.warning(
459
+ "Microphone is clipping (%.1f%% of samples saturated, peak %.2f) — "
460
+ "transcription quality suffers. Lower the input gain, e.g.: "
461
+ "wpctl set-volume @DEFAULT_AUDIO_SOURCE@ 10%%-",
462
+ meter["clip_ratio"] * 100, meter["peak"],
463
+ )
464
+ from voiceio import feedback
465
+ feedback.notify(
466
+ "VoiceIO: microphone too loud",
467
+ "Input is clipping — lower your mic gain in system sound settings.",
468
+ )
469
+
346
470
  # ── Background work ─────────────────────────────────────────────────
347
471
 
348
472
  def _finalize_streaming(
@@ -350,14 +474,23 @@ class VoiceIO:
350
474
  elapsed: float, gen: int,
351
475
  ) -> None:
352
476
  """Run final transcription and commit in background thread."""
477
+ extra = self._retention_extra(audio)
353
478
  final_text = session.stop(audio)
354
479
  if self._generation != gen:
355
480
  log.debug("Finalize cancelled (gen %d, current %d)", gen, self._generation)
356
481
  return
357
482
  if final_text:
358
483
  self._play_feedback(final_text)
484
+ stored = self._strip_voice_prefix(final_text)
485
+ self._prompt_builder.add_transcript(stored)
359
486
  from voiceio import history
360
- history.append(final_text)
487
+ history.append(
488
+ stored,
489
+ raw=session.raw_final_text,
490
+ segments=session.final_segments,
491
+ duration=elapsed,
492
+ extra=extra,
493
+ )
361
494
  log.info("Streaming done (%.1fs): '%s'", elapsed, final_text)
362
495
  # Transition to IDLE under lock to avoid racing with _toggle
363
496
  with self._hotkey_lock:
@@ -370,7 +503,10 @@ class VoiceIO:
370
503
  try:
371
504
  if self._generation != gen:
372
505
  return
373
- text = self.transcriber.transcribe(audio)
506
+ extra = self._retention_extra(audio)
507
+ raw = self.transcriber.transcribe(audio, final=True)
508
+ segments = self.transcriber.last_segments
509
+ text = raw
374
510
  if self._generation != gen:
375
511
  return
376
512
  if text:
@@ -382,7 +518,9 @@ class VoiceIO:
382
518
  language=self.cfg.model.language,
383
519
  commands=self._command_processor,
384
520
  corrections=self._corrections,
521
+ postcorrect=self._postcorrect,
385
522
  llm=self._llm,
523
+ voice_input_prefix=self._voice_input_prefix,
386
524
  final=True,
387
525
  )
388
526
  if abort:
@@ -390,8 +528,16 @@ class VoiceIO:
390
528
  if text:
391
529
  self._type_with_fallback(text)
392
530
  self._play_feedback(text)
531
+ stored = self._strip_voice_prefix(text)
532
+ self._prompt_builder.add_transcript(stored)
393
533
  from voiceio import history
394
- history.append(text)
534
+ history.append(
535
+ stored,
536
+ raw=raw,
537
+ segments=segments,
538
+ duration=len(audio) / self.recorder.sample_rate,
539
+ extra=extra,
540
+ )
395
541
  log.info("Typed: '%s'", text)
396
542
  except Exception:
397
543
  log.exception("Processing failed")