python-voiceio 1.0.0__py3-none-any.whl → 1.2.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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 1.0.0
3
+ Version: 1.2.0
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -140,7 +140,7 @@ uv run voiceio setup # also links voiceio into ~/.local/bin
140
140
 
141
141
  ## Your data
142
142
 
143
- Everything voiceio keeps is in three directories (they follow `XDG_CONFIG_HOME`, `XDG_STATE_HOME` and `XDG_DATA_HOME`). Files are created `0600`, directories `0700`. Nothing is uploaded.
143
+ Everything voiceio keeps is in three directories (they follow `XDG_CONFIG_HOME`, `XDG_STATE_HOME` and `XDG_DATA_HOME`). Files are created `0600`, directories `0700`. Nothing is uploaded. Recordings, decode traces and window titles are **off unless you turn them on** — `voiceio setup` asks whether to keep recordings so voiceio can learn your voice. Logs record sizes and timings, never your words.
144
144
 
145
145
  | Path | Holds | Retention |
146
146
  |---|---|---|
@@ -150,14 +150,13 @@ Everything voiceio keeps is in three directories (they follow `XDG_CONFIG_HOME`,
150
150
  | `vocab_stats.json` | Per-term usage counts used to rank the vocabulary | Rebuilt as you dictate |
151
151
  | `corrections.json` | Find/replace rules (`post grass` → `PostgreSQL`) | — |
152
152
  | `flagged.txt` | Words flagged with the "correct that" voice command, for review | — |
153
- | `snapshots/` | Copies of corrections + vocabulary from before 1.0's cleanup (safe to delete) | Last 8 kept |
153
+ | `snapshots/` | Copies of corrections + vocabulary from before 1.0 (safe to delete) | — |
154
154
  | `consent.json` | Whether you allowed cloud LLM calls (text only) | — |
155
155
  | `hints.json` | Which CLI tips you have already seen | — |
156
156
  | **`~/.local/state/voiceio/`** | *What you said* | |
157
- | `history.jsonl` | Every committed transcript: time, raw and final text, focused-window title | `[history] enabled`, `max_entries`, `max_age_days` (0 = keep forever) |
158
- | `recordings/*.wav` | The audio of each utterance, for `voiceio learn` | `[data] retain_audio`, `max_audio_mb` (default 4096), `min_free_gb` (default 5) |
159
- | `streaming_trace.jsonl`, `postcorrect_pairs.jsonl` | Per-pass decode traces and LLM before/after pairs | `[data] capture_intermediates`; each capped at 64 MB |
160
- | `corrections_audit.jsonl` | When each correction rule fired (shows which rules earn their keep) | — |
157
+ | `history.jsonl` | Every committed transcript: time, raw and final text (and the focused-window title with `[data] capture_context`) | `[history] enabled`, `max_entries`, `max_age_days` (0 = keep forever) |
158
+ | `recordings/*.wav` | The audio of each utterance, for `voiceio learn` — **opt-in** | `[data] retain_audio` (default off), `max_audio_mb` (default 4096), `min_free_gb` (default 5) |
159
+ | `streaming_trace.jsonl`, `postcorrect_pairs.jsonl` | Per-pass decode text and timings, LLM before/after pairs — **opt-in**, for debugging | `[data] capture_intermediates` (default off); each capped at 64 MB |
161
160
  | `voiceio.log`, `worker.log`, `ibus-engine-stderr.log` | Logs | `voiceio.log` rotates at 2 MB × 3 |
162
161
  | **`~/.local/share/voiceio/learn/`** | *Personalization corpus* (only exists once you run `voiceio learn`) | Never pruned; delete freely |
163
162
  | `labels.jsonl` | Reference transcripts from the teacher model or your review | — |
@@ -191,6 +190,7 @@ voiceio learn eval # score the current model on your held-out clips: WER a
191
190
  voiceio learn train # LoRA fine-tune (needs the `train` extra; decoder-only on CPU, --full on a GPU)
192
191
  voiceio learn promote <run> # dictate with the fine-tune; `voiceio learn rollback` undoes it
193
192
  voiceio learn maintain # later: label what's new, and once 50+ clips piled up, retrain and compare
193
+ voiceio learn schedule on # …or let it run `maintain` every night, and notify you when a model wins
194
194
  ```
195
195
 
196
196
  Labeling, training and offline eval run in an idle-priority cgroup, so they never slow dictation down. Everything stays on your machine unless you choose `train --remote`.
@@ -292,7 +292,7 @@ There is a best-effort pynput path for both (hotkeys and typing, no live preview
292
292
  | Hotkey does nothing on Wayland | `voiceio doctor --fix` installs the keyboard udev rule, or bind `voiceio toggle` in your compositor |
293
293
  | `sounddevice is not installed` | You installed without the desktop extra: `pipx install --force 'python-voiceio[desktop]'` |
294
294
  | evdev fails to build | Install a C toolchain and Python headers (see Quickstart), then reinstall |
295
- | Too slow | `voiceio doctor` shows your real stop→text; turn off cloud postcorrect, or `voiceio models use small` (or a GPU with `large-v3-turbo`) |
295
+ | Too slow | `voiceio doctor` shows your real stop→text. Turn off cloud postcorrect; use `voiceio models use small` (or a GPU with `large-v3-turbo`); or narrow the beam of the passes you keep: on the author's 44 clips `[model] beam_size = 2` matched the default 5 (WER 4.9% vs 5.4%, vocabulary 28/32 vs 29/32) while decoding faster; greedy (`1`) lost vocabulary (26/32). Measure on yours: `voiceio learn eval --beam 2` |
296
296
  | Words come out wrong in a pattern | `voiceio learn eval --pipeline` and `voiceio corrections audit` show which stage or rule did it |
297
297
  | Start over | `voiceio uninstall`, then `voiceio setup` |
298
298
 
@@ -300,8 +300,8 @@ Logs: `journalctl --user -u voiceio` or `~/.local/state/voiceio/voiceio.log`.
300
300
 
301
301
  ## Roadmap
302
302
 
303
- - **A landing page** — voiceio as local, open-source, private dictation that learns from you.
304
- - **Continuous learning** — `learn maintain` on a timer: label new dictation while idle, retrain when enough piles up, and promote only what wins on your held-out clips.
303
+ - **A landing page** — voiceio as local, open-source, private dictation that learns from you. A draft is in [`site/index.html`](site/index.html), ready for GitHub Pages.
304
+ - **Hands-off promotion** — `learn schedule` already retrains nightly and tells you when a model wins; next is switching to it automatically, with one-command rollback.
305
305
  - **Unbounded vocabulary** — move to a decoder whose biasing has no token cap (see [model choice](CONTRIBUTING.md#model-choice-why-whisper-and-what-would-change-it)).
306
306
  - **One model everywhere** — sync your fine-tune to your phone server and laptops, peer to peer.
307
307
 
@@ -1,17 +1,18 @@
1
- python_voiceio-1.0.0.dist-info/licenses/LICENSE,sha256=Gz61o8jFTAvZUZyB3nWDB3DQQVuipjfPkVu9W8hBHM0,1072
2
- voiceio/__init__.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
1
+ python_voiceio-1.2.0.dist-info/licenses/LICENSE,sha256=Gz61o8jFTAvZUZyB3nWDB3DQQVuipjfPkVu9W8hBHM0,1072
2
+ voiceio/__init__.py,sha256=MpAT5hgNoHnTtG1XRD_GV_A7QrHVU6vJjGSw_8qMGA4,22
3
3
  voiceio/__main__.py,sha256=xT5QCGGreYMisHO7Lh_Y-xAQ2TOzG2D7npKknGNcWSY,53
4
- voiceio/app.py,sha256=U2NuunPzwK5dnqoKOHyQaFGkThQ7wTCoPJDXWHcs1cY,31369
4
+ voiceio/app.py,sha256=_FfrMN1MFWk8H5KciGu2niJm3fZJlNR2Ju9mP9s5brk,31798
5
5
  voiceio/audio_source.py,sha256=rR6NRtNzmZ8uqmNPxareBWTJmuBaOxIGI5QINR_l-qc,9102
6
6
  voiceio/backends.py,sha256=-rs1YhPblub4NjFb6qCD7Ih9FqBRXtPuvjVv9bQoQ1w,368
7
7
  voiceio/clipboard_read.py,sha256=lz75CkRGh7pyvO6xvoUmxJDl-Gwie1xlXQHK70PO84I,4261
8
8
  voiceio/commands.py,sha256=Jz-txXSw32Adf8B912M8CzQIh4UMmEKRCZBPXU4bBaU,6014
9
- voiceio/config.py,sha256=n2-PlVu-2aB9g8M-uLhS3lqVAotglCoMyZGsWWAPe3k,18965
9
+ voiceio/config.py,sha256=2QrttnGz0Cc7fLrlmhLA0-Nfvz1jJv9ea4kfY6icOEI,19522
10
10
  voiceio/consent.py,sha256=3stv3iZvYl_Dt4GUe-GUVOHEGi-2fOl-QqF3pDjraS4,1862
11
11
  voiceio/corrections.py,sha256=IIRViwjL9kkMy26-1dd0DtTfT0Tiedn6pmmHoaeayMQ,10047
12
12
  voiceio/demo.py,sha256=jA282e7UWDwtP1IXT2bPHH5wl6eKdBNvOJcoNDWzZlQ,8766
13
+ voiceio/display.py,sha256=ZGBNRjuvg3chkJjduyPJs4s5ZY5vqC_mcaov94PIPzo,5313
13
14
  voiceio/feedback.py,sha256=i1Hkd_bydOOQ8p19Bz4bYougjQtozKGLjfKc5DKRDT0,5711
14
- voiceio/health.py,sha256=p5y3DrAhvEWUind0yB27eQg5pTkp5hmKz11a6q3LjK4,20834
15
+ voiceio/health.py,sha256=r0nMDozuhk1nZNpCiVhmezC_7P6ElQpaBB_IomzmXF8,22166
15
16
  voiceio/hints.py,sha256=dmoiMAJ0YGTWwFc9kIPvEs_BQJN7UEUiwYuLUD_GoQQ,1415
16
17
  voiceio/history.py,sha256=cD3Ysa39qt6uKbrcLeRQt-FOJcYCoqie4UNsUWF7DuM,5763
17
18
  voiceio/llm.py,sha256=cO2RHM2zHxTD4r5mFFNatB-pjOSl29Wx_I00f7ywYCw,9161
@@ -25,25 +26,25 @@ voiceio/privacy.py,sha256=FqnA9jx9RCqWW-i6Dh7JcT7vzz97kbUGyQEvsHLvpqc,573
25
26
  voiceio/prompt.py,sha256=9iza8KoQxvO_U9NPMJ8Ep7j-zkIs9nW8N5sacCRpsEk,2904
26
27
  voiceio/recorder.py,sha256=k51Py6av0YFkc1ENXsrXuFIL3G9bmsAijGq3X5r7pG4,15734
27
28
  voiceio/retention.py,sha256=TH_D29EUYyzEPl5FK0q32mpsSk0-0bx56JwAH83FBHI,6272
28
- voiceio/service.py,sha256=LAXWwbQfX-MHOMl3fdlYWgDv9-Vazx8QIIsKYR2O5cA,12074
29
- voiceio/streaming.py,sha256=ieE17_gHdadbjf3ahzbvk70ebOc7fVrs_C5ElMfdKLg,28102
29
+ voiceio/service.py,sha256=_cTb1AQ57cF8-eW3SKvDkCP46S5yJqhty1fe_eI8zbQ,14324
30
+ voiceio/streaming.py,sha256=hKak7ULkZbkHl5Fw0SnnZsEK3alv2s8dcuCcyBzpIQY,24700
30
31
  voiceio/suggest.py,sha256=jX4bCpnddyrteKfRBPbnUgf-Rk5145LFaSTat0_Ll2E,4836
31
32
  voiceio/tokens.py,sha256=m5tusR3zEBX7fRKaDUj4z-DSv8NcMhE-02HviAziENQ,5710
32
33
  voiceio/ui.py,sha256=pwuH3zr9kMC7TovLwJwa3E3H8J5Fbt2VF4-DDTvqboQ,461
33
34
  voiceio/vad.py,sha256=VPeefjJ0Ml5hOuRE2V6BWoBXmzawxLB7if5UP3vBKiM,6030
34
35
  voiceio/vocab_stats.py,sha256=md_85Bs0UsM-tmSZDEyQFe0C7z4UnsZ5QSV5vuZ8A5w,5408
35
36
  voiceio/vocabulary.py,sha256=i2RRYGmT81tJcYhnZ4sVuVbmZjClqFZVlNUUFa35tuY,11351
36
- voiceio/watchdog.py,sha256=MSDFyE3jCPDXA45iVL6JoJcF0pnH3IY_9CnU7GMLAXs,7419
37
+ voiceio/watchdog.py,sha256=UVwcvtdXaimbUmqlfQME_ZpEGIqvP0UjlIM2OBd3Tho,7351
37
38
  voiceio/wordfreq.py,sha256=PTAoaEUX1D2Y12jjd8A9cFTqsZkDDZt6eesBBL_4toA,3151
38
39
  voiceio/cli/__init__.py,sha256=vU6THxcynHLhdMisVBPEyig_m_EF_XrU2HXi6Tq4iSE,7713
39
40
  voiceio/cli/configcmd.py,sha256=YF7t-LkwEYEGH4qcO86xehWVX0LvltqxjDFpMF5ljjA,2858
40
41
  voiceio/cli/corrections.py,sha256=OBjgiOhtWlTQpQq0HLAvk5-EfKJ7NNt-hnNBHm7obEc,4247
41
42
  voiceio/cli/doctor.py,sha256=P8bOn4xedMistlKHhNeeGxRsmogV6jBVKc-CUbo0LXs,7769
42
43
  voiceio/cli/history.py,sha256=IWyFIozX5PUwtrU17JlkNea9nP_oKLsXfkxIVHjkfD0,1516
43
- voiceio/cli/learn.py,sha256=qhbbW-UP83ucziKHTUhlYZ361WzMiNfMksQgl9zFrzo,22456
44
+ voiceio/cli/learn.py,sha256=cQ3HOf0LXybRf-40QA6Jvb54gy7rpCCz_2xavPJE7wk,24859
44
45
  voiceio/cli/models.py,sha256=fye_mHbsXLY11uRC1BCDlucCHPny9ftoehAeUJPMJv0,4263
45
- voiceio/cli/servicecmd.py,sha256=zPGMlq8InKLCNb2r8YmYKBAGgEe4t8ha78OeAih8Eck,3135
46
- voiceio/cli/uninstall.py,sha256=Qxgf32Yoikv36sawWBTtfWs3aGOG1BMY-JE7pHlc4Ys,8770
46
+ voiceio/cli/servicecmd.py,sha256=hcaQD1NL38fvkrHtId1307MIgyi2O2xBP6YvG8CTngc,4219
47
+ voiceio/cli/uninstall.py,sha256=Vqnq6-4hjhDrxqkU_1wtXkY5BU_Tqa-TjZA0Qpf12e4,8921
47
48
  voiceio/cli/vocab.py,sha256=lxxuSukefvIHV4fFSduCQQewXY0_Bvf5sDnG4bqLWH8,4418
48
49
  voiceio/core/__init__.py,sha256=K0b4duczohS3IrIMLp9fYhfb-GeuyQaOVH_8teYGsQM,277
49
50
  voiceio/core/bias.py,sha256=butCKgKlCFKsKXGmnqK-816wNmWGtf64E6k9QJEiTnA,3892
@@ -55,7 +56,7 @@ voiceio/core/decoders/catalog.py,sha256=L4zcwgNAe5LRDVv6KvwzReA25mJuJ8nPjOWimoFP
55
56
  voiceio/core/decoders/lanes.py,sha256=lkq7ue0Pbt-rirGd72T6_i77A6uJPD4PYzD-JPLRuWk,1452
56
57
  voiceio/core/decoders/sherpa.py,sha256=ceLPEGC2E1F2OUaPz9Fe74mJYzL8yXh5iYgSyeWZr6g,4338
57
58
  voiceio/core/decoders/whispercpp.py,sha256=IJvsdSrc324FuQ1JbqXu_fWUZP64JnIWX9fOxyL_oKM,3264
58
- voiceio/core/decoders/faster_whisper/__init__.py,sha256=NjxBTVvNIB4yosXzCzsZKWmGBEu81I-S7OuI8Ev2YG4,13035
59
+ voiceio/core/decoders/faster_whisper/__init__.py,sha256=yZl1-kdyqrpgRQ5GnsckP4VkCORFfqv2eloGKW1ecaU,13172
59
60
  voiceio/core/decoders/faster_whisper/worker.py,sha256=x7hIrLygVIhrkzvDKN5n2deVF4QPX4_9tRKyQSNyvOk,7905
60
61
  voiceio/data/60-voiceio-uaccess.rules,sha256=X3DvKhkevKwDzKgYaA42GmpGlWh_orr8wei3mfl_SYo,397
61
62
  voiceio/data/__init__.py,sha256=kbyKP4Jaz3Q-rGkUPnVq5zo5d3dii254q7qIDCG4vHw,59
@@ -80,7 +81,7 @@ voiceio/learn/metrics.py,sha256=b2xWUzPpNpXHn6zgS1upPbEcpW3ExH9SmSLIUzPhStQ,5174
80
81
  voiceio/learn/review.py,sha256=p11kRY-PF9YhS7aYjcYpDUJavUn8OXXz4d6S-MGfCGE,4575
81
82
  voiceio/learn/store.py,sha256=MauWhGaI2drMbiChIZOblpPS_uVAw-1sNWGyGACHYw8,12816
82
83
  voiceio/learn/teacher.py,sha256=uF4hQkrdjv012TTBRSwrhQqqAeCFNoidABcrgzSVUoI,3696
83
- voiceio/learn/train.py,sha256=9vOV-sGlsaYxZ3Pbnmh0gdMZ24K3TtFMHBGOAK8oCxg,17473
84
+ voiceio/learn/train.py,sha256=zTyftCsUV6hYy9WA8ftZtqjVbYJ-C7py0EoxavXWr7I,17628
84
85
  voiceio/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
85
86
  voiceio/models/silero_vad.onnx,sha256=GhU6IvRQnikqlOZ9b5uF6N6yW0mIaCt-F0xlJ52HiOM,2327524
86
87
  voiceio/server/__init__.py,sha256=ybYb7CWgyMUhB-8AKypIWyMwCg17D3iNOaWOkqUd_8s,1751
@@ -89,12 +90,12 @@ voiceio/server/app.py,sha256=pHC2mmj5VOsgE6mbW_p9kQEEALwQDOS-f8fbByBr7ig,15772
89
90
  voiceio/server/config.py,sha256=BXcLNmZMlErLvD2E7D8_5zm3xk8KqzlMq1wMNXf9L-Q,5284
90
91
  voiceio/server/runtime.py,sha256=7Cv1kJNjRtJx8LfbXAfZbfGvo5XmYyq62udp3bjdq3s,12186
91
92
  voiceio/setup/__init__.py,sha256=cXNo-LYhkoVA6pEKcnaAKEGma_A9Aiqffa3VRvV6Jm8,430
92
- voiceio/setup/configfile.py,sha256=2ULtbyHNflhblJ52z9MqpJTR8ZiKTpjZQS2yBbtvXDk,4716
93
+ voiceio/setup/configfile.py,sha256=H5MCW8Uyx1qL9bI0qfKErgA5JQlc6tDlKZy9J3kg04s,4876
93
94
  voiceio/setup/extras.py,sha256=NGk_uAWl4GQAPwRfw_0nEPsPvma7ics9DnZqjoHgCUY,9685
94
95
  voiceio/setup/hotkey.py,sha256=xlDp2vWzsF73wbpvPSrwaqgjtiWnv294nzjlvHy0Ets,6473
95
- voiceio/setup/noninteractive.py,sha256=0I_s2_tJ9moLY7z5WM4Ao9pXB_1dyGY5Eodxvofngrs,10331
96
+ voiceio/setup/noninteractive.py,sha256=PF5dG6aOOYxdknbRGZD32xlv_kV2Mg8BRf_TfYI5bpY,10522
96
97
  voiceio/setup/speech.py,sha256=DahGBybPzkNryLjxtCkxojrUhECBksox2RKSwGr7a9I,7688
97
- voiceio/setup/steps.py,sha256=JW4-lW2_bXm4jvinhL2cFDvCrXtBmrNIbVYrYbRFzfg,26134
98
+ voiceio/setup/steps.py,sha256=IXzP4zaeZg7EUAPNFnp1VQSbEyeW8PsKNJ6hpgfDSew,27688
98
99
  voiceio/setup/system.py,sha256=Cb1BJGCgGbZFAfqrS8eXRW2AKW7jNLq82DWWg4_fdAs,6019
99
100
  voiceio/setup/ui.py,sha256=h1gmerT_8EfWftSONUn5K5Ri19elNTRP2lwFAuxsGPc,13401
100
101
  voiceio/sounds/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -119,13 +120,13 @@ voiceio/typers/clipboard.py,sha256=8MJp3fEWCe3bvagqwevd3SgVVD830--AfEY0k87dkW0,7
119
120
  voiceio/typers/collecting.py,sha256=Tsuy-2B-6poYa-5pfrLVqDlS4ThyX71pLTr2hTl-Yws,2872
120
121
  voiceio/typers/fcitx_bridge.py,sha256=GxKDEOk-tsaXDbUh6uCCC7lA6OkMFvkqHojsqrg8ZU0,2635
121
122
  voiceio/typers/ibus.py,sha256=7Fyk2Figwa95ouiOa9gN6c5XTT6mBJkh1RmKdyXN3VM,10769
122
- voiceio/typers/manager.py,sha256=NyWuA-lJDiZ2Ft8qiukEFhsRqJ16tHZRmWckjLIh70I,14422
123
+ voiceio/typers/manager.py,sha256=W7u3iJ8ErDRGWqIsyXJXzeOiW1-YCupvC4MIhJSf0Zw,14423
123
124
  voiceio/typers/pynput_type.py,sha256=Rzkixn08iaIqSCZ5vRybaHpn05lkx4FiEa_OakaiM1A,1951
124
125
  voiceio/typers/wtype.py,sha256=d1wG-HZdYDQxXIysV_Xvk9HixDpaJgl3VEoKbP5vhIs,1786
125
126
  voiceio/typers/xdotool.py,sha256=dh1zhPqUT8ihJahJ7ZKm6PtfYY087UAzADx664DvQOM,1356
126
127
  voiceio/typers/ydotool.py,sha256=e19GvDHL9GS-jgh_hzW3PGBFPCc4IJCWsBWEzU_LqGs,5934
127
- python_voiceio-1.0.0.dist-info/METADATA,sha256=86SXSQf0s7DmodzBvBIrzT-HtyHg7B1jEoR7ijlehoE,20494
128
- python_voiceio-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
129
- python_voiceio-1.0.0.dist-info/entry_points.txt,sha256=iDIDmhvQ3xeN_CWsFne1t9STZ08I25I_wp0xmde_vMs,94
130
- python_voiceio-1.0.0.dist-info/top_level.txt,sha256=piwtn309lD6uexQyXdZ-efAVBJF9y6Wfr48Z-8zkNhg,8
131
- python_voiceio-1.0.0.dist-info/RECORD,,
128
+ python_voiceio-1.2.0.dist-info/METADATA,sha256=zEVw8vdKC4meGy8FxLLwba5SoZAb3LPu5OfTYlsusYo,21153
129
+ python_voiceio-1.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
130
+ python_voiceio-1.2.0.dist-info/entry_points.txt,sha256=iDIDmhvQ3xeN_CWsFne1t9STZ08I25I_wp0xmde_vMs,94
131
+ python_voiceio-1.2.0.dist-info/top_level.txt,sha256=piwtn309lD6uexQyXdZ-efAVBJF9y6Wfr48Z-8zkNhg,8
132
+ python_voiceio-1.2.0.dist-info/RECORD,,
voiceio/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "1.0.0"
1
+ __version__ = "1.2.0"
voiceio/app.py CHANGED
@@ -36,6 +36,20 @@ class _State(enum.Enum):
36
36
 
37
37
 
38
38
  class VoiceIO:
39
+ @property
40
+ def _state(self) -> _State:
41
+ return self.__state
42
+
43
+ @_state.setter
44
+ def _state(self, value: _State) -> None:
45
+ self.__state = value
46
+ try: # best-effort: only `service restart` reads it
47
+ tmp = config.STATE_PATH.with_suffix(".tmp")
48
+ tmp.write_text(value.value)
49
+ tmp.replace(config.STATE_PATH)
50
+ except OSError:
51
+ pass
52
+
39
53
  def __init__(self, cfg: config.Config):
40
54
  self.cfg = cfg
41
55
 
@@ -82,7 +96,7 @@ class VoiceIO:
82
96
 
83
97
  print(f"Loading model '{cfg.model.name}'...", end="", flush=True)
84
98
  t0 = time.monotonic()
85
- self.transcriber = build_decoder(cfg.model)
99
+ self.decoder = build_decoder(cfg.model)
86
100
  print(f" ready ({time.monotonic() - t0:.1f}s)")
87
101
 
88
102
  # VAD, vocabulary, commands
@@ -119,7 +133,7 @@ class VoiceIO:
119
133
  # corrections, prompt history and text pipeline (same as the server).
120
134
  from voiceio.core.engine import Engine
121
135
  self.engine = Engine.from_config(
122
- cfg, decoder=self.transcriber, commands=self._command_processor,
136
+ cfg, decoder=self.decoder, commands=self._command_processor,
123
137
  postcorrect=self._postcorrect, llm=self._llm,
124
138
  )
125
139
  self._corrections = self.engine.corrections
@@ -189,14 +203,14 @@ class VoiceIO:
189
203
  )
190
204
  self._watchdog = Watchdog(
191
205
  cfg,
192
- transcriber=self.transcriber,
206
+ decoder=self.decoder,
193
207
  recorder=self.recorder,
194
208
  typers=self._typers,
195
209
  ibus=self._ibus,
196
210
  session=lambda: self._session,
197
211
  is_idle=self._is_idle,
198
212
  is_recording=lambda: self._state == _State.RECORDING,
199
- on_transcriber_failed=self._on_transcriber_failed,
213
+ on_decoder_failed=self._on_decoder_failed,
200
214
  on_hotkey=self.on_hotkey,
201
215
  shutdown=self._shutdown,
202
216
  )
@@ -302,7 +316,7 @@ class VoiceIO:
302
316
  # Attempt recovery: try to restart worker, then start recording
303
317
  log.info("Attempting recovery from error state")
304
318
  try:
305
- self.transcriber.recover()
319
+ self.decoder.recover()
306
320
  self._state = _State.IDLE
307
321
  tray.set_error(False)
308
322
  self._do_start()
@@ -313,7 +327,7 @@ class VoiceIO:
313
327
  # The generation counter ensures the old finalizer exits cleanly.
314
328
  self._do_start()
315
329
 
316
- def _on_transcriber_failed(self) -> None:
330
+ def _on_decoder_failed(self) -> None:
317
331
  """Watchdog: the worker could not be recovered."""
318
332
  with self._hotkey_lock:
319
333
  if self._state != _State.RECORDING:
@@ -708,9 +722,10 @@ class VoiceIO:
708
722
  if self._tts_engine:
709
723
  self._tts_engine.shutdown()
710
724
  self.recorder.close_stream()
711
- self.transcriber.close()
725
+ self.decoder.close()
712
726
  tray.stop()
713
727
  self._ibus.shutdown()
714
728
  self._pid_fd.close()
715
729
  PID_PATH.unlink(missing_ok=True)
730
+ config.STATE_PATH.unlink(missing_ok=True)
716
731
  log.info("voiceio stopped")
voiceio/cli/learn.py CHANGED
@@ -63,6 +63,8 @@ def register(sub: argparse._SubParsersAction) -> None:
63
63
  e.add_argument("--pipeline", action="store_true",
64
64
  help="no decoding: score what each text stage (corrections, numbers, "
65
65
  "cloud postcorrect…) did to past dictations")
66
+ e.add_argument("--beam", type=int, default=None,
67
+ help="beam width of the passes you keep (default: [model] beam_size)")
66
68
  e.add_argument("--speed", type=float, default=1.0,
67
69
  help="streaming replay speed; >1 is faster but skews latency")
68
70
  e.set_defaults(func=cmd_eval)
@@ -84,6 +86,10 @@ def register(sub: argparse._SubParsersAction) -> None:
84
86
  mt.add_argument("--epochs", type=int, default=4)
85
87
  mt.set_defaults(func=cmd_maintain)
86
88
 
89
+ sc = lsub.add_parser("schedule", help="Run `maintain` every night (systemd timer)")
90
+ sc.add_argument("state", nargs="?", choices=("on", "off"), help="omit to show")
91
+ sc.set_defaults(func=cmd_schedule)
92
+
87
93
  pr = lsub.add_parser("promote", help="Use a trained model for dictation")
88
94
  pr.add_argument("run", help="run name under learn/models (see `learn status`)")
89
95
  pr.add_argument("--force", action="store_true",
@@ -176,6 +182,8 @@ def cmd_status(args: argparse.Namespace) -> None:
176
182
  print(f"models {', '.join(runs)}")
177
183
  active = _cfg().model.name
178
184
  print(f"active {active}")
185
+ from voiceio import service
186
+ print(f"nightly {'on' if service.learn_timer_enabled() else 'off (voiceio learn schedule on)'}")
179
187
  promotable = _print_scores(ds, active, runs) if ds else None
180
188
  if promotable:
181
189
  nxt = f"promote {promotable}"
@@ -328,12 +336,12 @@ def cmd_eval(args: argparse.Namespace) -> None:
328
336
  if ds is None or not ds.exists():
329
337
  raise SystemExit("no dataset — run `voiceio learn build` first")
330
338
  _run_eval(args.model, ds, limit=args.limit, streaming=args.streaming, lanes=args.lanes,
331
- freeze=args.freeze, speed=args.speed)
339
+ freeze=args.freeze, speed=args.speed, beam=args.beam)
332
340
 
333
341
 
334
342
  def _run_eval(model: str | None, ds: Path, *, limit: int | None = None,
335
343
  streaming: bool = False, lanes: int | None = None,
336
- freeze: float | None = None, speed: float = 1.0):
344
+ freeze: float | None = None, speed: float = 1.0, beam: int | None = None):
337
345
  """Score `model` (default: the configured one) on `ds`; print and save."""
338
346
  from voiceio.core.bias import BiasProvider
339
347
  from voiceio.core.decoders import build_decoder
@@ -342,6 +350,8 @@ def _run_eval(model: str | None, ds: Path, *, limit: int | None = None,
342
350
  cfg = _cfg()
343
351
  if model:
344
352
  cfg.model.name = model
353
+ if beam:
354
+ cfg.model.beam_size = beam
345
355
  print(f"Loading {cfg.model.name}…", flush=True)
346
356
  # Offline scoring needs one lane; streaming mirrors the live setup.
347
357
  decoder = build_decoder(cfg.model, lanes=lanes if lanes is not None else
@@ -422,6 +432,12 @@ def cmd_maintain(args: argparse.Namespace) -> None:
422
432
  _yield_cpu()
423
433
  from voiceio.learn import evaluate
424
434
  cmd_label(argparse.Namespace(teacher=None, limit=None, relabel=False))
435
+ from voiceio.learn import train
436
+ if missing := train.missing_train_deps():
437
+ # Labeling alone is worth it; retraining waits for the extra. Exit 0:
438
+ # a nightly timer must not fail every night over an optional install.
439
+ print(f"labeled; fine-tuning needs {', '.join(missing)}: {train.TRAIN_HINT}")
440
+ return
425
441
  labels = store.load_labels()
426
442
  labeled = {c.audio for c in store.clips() if c.audio in labels}
427
443
  ds = store.latest_dataset()
@@ -438,6 +454,33 @@ def cmd_maintain(args: argparse.Namespace) -> None:
438
454
  print(f"\n{_cfg().model.name} → {run.name} on {ds.name}:\n{table}")
439
455
  print(f"better or equal on everything — `voiceio learn promote {run.name}`" if ok
440
456
  else "not better on everything — keeping the current model")
457
+ if ok: # often runs unattended at night: say so where it will be seen
458
+ from voiceio import feedback
459
+ feedback.notify("VoiceIO: a better model is ready",
460
+ f"Trained on your dictation. Use it: voiceio learn promote {run.name}",
461
+ urgent=True)
462
+
463
+
464
+ def cmd_schedule(args: argparse.Namespace) -> None:
465
+ from voiceio import config, service
466
+ if args.state == "on":
467
+ if not config.load().data.retain_audio:
468
+ print("note: [data] retain_audio is off, so there is nothing new to learn from;\n"
469
+ " turn it on with: voiceio config set data.retain_audio true")
470
+ from voiceio.learn import train
471
+ if missing := train.missing_train_deps():
472
+ print(f"note: until {', '.join(missing)} are installed it only labels new clips;\n"
473
+ f" to also retrain: {train.TRAIN_HINT}")
474
+ if not service.install_learn_timer():
475
+ raise SystemExit("could not enable the timer (needs systemd --user)")
476
+ print("on: every night ~03:30, `voiceio learn maintain` labels new dictation at idle "
477
+ "priority, retrains once 50+ clips are new, and tells you if the result is better. "
478
+ "It never switches models by itself.")
479
+ elif args.state == "off":
480
+ service.uninstall_learn_timer()
481
+ print("off")
482
+ else:
483
+ print("on" if service.learn_timer_enabled() else "off — enable: voiceio learn schedule on")
441
484
 
442
485
 
443
486
  def _scores(result) -> dict:
voiceio/cli/servicecmd.py CHANGED
@@ -10,6 +10,8 @@ def register(sub: argparse._SubParsersAction) -> None:
10
10
  p.add_argument("action", nargs="?", default="status",
11
11
  choices=["install", "uninstall", "start", "stop", "restart",
12
12
  "status", "logs"])
13
+ p.add_argument("--now", action="store_true",
14
+ help="stop/restart: don't wait for a dictation in progress to finish")
13
15
  p.add_argument("-f", "--follow", action="store_true", help="logs: follow")
14
16
  p.add_argument("-n", "--lines", type=int, default=50, help="logs: line count")
15
17
  p.set_defaults(func=_cmd_service)
@@ -61,6 +63,8 @@ def _cmd_service(args: argparse.Namespace) -> None:
61
63
  sys.exit(1)
62
64
 
63
65
  elif action in ("stop", "restart"):
66
+ if not getattr(args, "now", False):
67
+ wait_until_idle()
64
68
  try:
65
69
  subprocess.run(["systemctl", "--user", action, "voiceio.service"],
66
70
  capture_output=True, timeout=10, check=True)
@@ -79,3 +83,25 @@ def _cmd_service(args: argparse.Namespace) -> None:
79
83
  subprocess.call(cmd)
80
84
  except KeyboardInterrupt:
81
85
  pass
86
+
87
+
88
+ def wait_until_idle(timeout: float = 300.0, poll: float = 0.25) -> bool:
89
+ """Wait for the daemon to finish any dictation in progress (stopping it
90
+ mid-recording loses what was said). True once idle or not running."""
91
+ import time
92
+
93
+ from voiceio.config import STATE_PATH
94
+ deadline, told = time.monotonic() + timeout, False
95
+ while time.monotonic() < deadline:
96
+ try:
97
+ state = STATE_PATH.read_text().strip()
98
+ except OSError:
99
+ return True # not running (or pre-1.1): nothing to wait for
100
+ if state in ("idle", "error"):
101
+ return True
102
+ if not told:
103
+ print(f"Waiting for the dictation in progress to finish ({state})…", flush=True)
104
+ told = True
105
+ time.sleep(poll)
106
+ print("Still busy; going ahead. (--now skips the wait.)", file=sys.stderr)
107
+ return False
voiceio/cli/uninstall.py CHANGED
@@ -101,6 +101,9 @@ def _cmd_uninstall() -> None:
101
101
  if _service.CORRECT_TIMER_PATH.exists() or _service.CORRECT_SERVICE_PATH.exists():
102
102
  _service.uninstall_correct_timer()
103
103
  removed.append(str(_service.CORRECT_TIMER_PATH))
104
+ if _service.LEARN_TIMER_PATH.exists():
105
+ _service.uninstall_learn_timer()
106
+ removed.append(str(_service.LEARN_TIMER_PATH))
104
107
 
105
108
  # 2. Remove IBus component and launcher
106
109
  ibus_component = home / ".local" / "share" / "ibus" / "component" / "voiceio.xml"
voiceio/config.py CHANGED
@@ -34,6 +34,9 @@ HISTORY_PATH = LOG_DIR / "history.jsonl"
34
34
  RECORDINGS_DIR = LOG_DIR / "recordings"
35
35
  LOG_PATH = LOG_DIR / "voiceio.log"
36
36
  PID_PATH = LOG_DIR / "voiceio.pid"
37
+ # The daemon's state (idle/recording/finalizing), so `voiceio service
38
+ # restart` can wait for a dictation to finish instead of killing it.
39
+ STATE_PATH = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "voiceio.state"
37
40
  # Intermediate pipeline data kept for debugging/profiling and future training:
38
41
  # per-pass streaming decode traces and postcorrect before/after pairs. These
39
42
  # live in their own JSONL files (not the rotating log) so they survive.
@@ -76,6 +79,9 @@ class ModelConfig:
76
79
  # "modified_beam_search" (required for hotwords).
77
80
  decoding: str = ""
78
81
  vocabulary_file: str = "" # path to vocabulary.txt
82
+ # Beam width of the passes whose text you keep (freezes and the final
83
+ # tail); interim previews are always greedy. 1 is greedy: faster stop.
84
+ beam_size: int = 5
79
85
  # Other languages you dictate in: a correction rule that would rewrite a
80
86
  # real word in one of these is flagged (e.g. ["es"] protects "harina").
81
87
  protect_languages: list[str] = field(default_factory=list)
@@ -218,20 +224,23 @@ class HealthConfig:
218
224
  class DataConfig:
219
225
  """Local data retention for diagnostics and self-improvement.
220
226
 
227
+ Opt-in: nothing here is kept unless you turn it on (`voiceio setup` asks).
221
228
  Everything stays on disk under ~/.local/state/voiceio/ — nothing is
222
229
  uploaded. retain_audio keeps a WAV per utterance so (audio, final text)
223
- pairs accumulate for later analysis or fine-tuning.
230
+ pairs accumulate for `voiceio learn` to fine-tune on.
224
231
  """
225
- retain_audio: bool = True
232
+ retain_audio: bool = False
226
233
  max_audio_mb: int = 4096 # prune oldest recordings beyond this
227
234
  # Never let retention squeeze the disk: skip saving and prune harder
228
235
  # when the filesystem has less than this much free space.
229
236
  min_free_gb: float = 5.0
230
- capture_context: bool = True # best-effort active-window title
237
+ # Active-window title, stored with each history row and given to the
238
+ # cloud pass as context when that is on.
239
+ capture_context: bool = False
231
240
  # Per-pass streaming decode traces + postcorrect before/after pairs
232
241
  # (streaming_trace.jsonl / postcorrect_pairs.jsonl) for debugging,
233
242
  # profiling, and future fine-tuning.
234
- capture_intermediates: bool = True
243
+ capture_intermediates: bool = False
235
244
 
236
245
 
237
246
  @dataclass
@@ -93,6 +93,8 @@ class Transcriber:
93
93
  def __init__(self, cfg: ModelConfig):
94
94
  self._cfg = cfg
95
95
  self.name = f"faster-whisper:{cfg.name}"
96
+ if cfg.beam_size != 5: # scored separately from the default
97
+ self.name += f"@beam{cfg.beam_size}"
96
98
  if _PROMPTLESS.search(cfg.name):
97
99
  self.capabilities = Capabilities(bias=BiasMode.NONE)
98
100
  self._proc: subprocess.Popen | None = None
@@ -205,7 +207,7 @@ class Transcriber:
205
207
 
206
208
  audio = normalize_audio(audio)
207
209
  audio_b64 = base64.b64encode(audio.tobytes()).decode("ascii")
208
- req = {"audio_b64": audio_b64, "options": {"beam_size": 5 if final else 1}}
210
+ req = {"audio_b64": audio_b64, "options": {"beam_size": self._cfg.beam_size if final else 1}}
209
211
  # Cap the combined prompt. initial_prompt (recent transcripts) and
210
212
  # `context` (streaming's frozen-text tail) are both bounded in CHARS
211
213
  # by their callers, but nothing bounded their SUM in tokens — and
voiceio/display.py ADDED
@@ -0,0 +1,150 @@
1
+ """What a streaming session has put on screen, and how to change it.
2
+
3
+ Text reaches the focused app one of two ways. An IBus typer shows a preedit
4
+ (an underlined preview the app can't see yet) and commits once; the session
5
+ just replaces the preview. A keystroke typer has already typed every word it
6
+ showed, so a revision must be applied as an edit: append when the new text
7
+ extends what's there, and on the final pass a character-level diff that
8
+ deletes back to the first difference and retypes the rest.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import re
14
+ import time
15
+ from typing import TYPE_CHECKING
16
+
17
+ from voiceio.privacy import describe_transcript
18
+ from voiceio.typers.base import StreamingTyper
19
+
20
+ if TYPE_CHECKING:
21
+ from voiceio.typers.base import TyperBackend
22
+
23
+ log = logging.getLogger(__name__)
24
+ DELETE_SETTLE_SECS = 0.05 # delay between delete and type for ydotool reliability
25
+
26
+
27
+ def _common_prefix_len(a: str, b: str) -> int:
28
+ """Length of the longest common prefix between two strings."""
29
+ limit = min(len(a), len(b))
30
+ for i in range(limit):
31
+ if a[i] != b[i]:
32
+ return i
33
+ return limit
34
+
35
+
36
+ def _clean_word(w: str) -> str:
37
+ """Strip punctuation for fuzzy word matching."""
38
+ return re.sub(r'[^\w]', '', w).lower()
39
+
40
+
41
+ def _word_match_len(old_words: list[str], new_words: list[str]) -> int:
42
+ """Count matching leading words, ignoring punctuation/case."""
43
+ count = 0
44
+ for o, n in zip(old_words, new_words):
45
+ if _clean_word(o) == _clean_word(n):
46
+ count += 1
47
+ else:
48
+ break
49
+ return count
50
+
51
+
52
+ class TypedText:
53
+ """The text a session has shown through `typer`."""
54
+
55
+ def __init__(self, typer: TyperBackend):
56
+ self.typer = typer
57
+ self.text = ""
58
+
59
+ def commit_preview(self) -> None:
60
+ """Commit a preview that only exists as a preedit — when the final
61
+ pass yields nothing, so a decode timeout doesn't drop it. Keystroke
62
+ typers already typed it."""
63
+ if self.text and isinstance(self.typer, StreamingTyper):
64
+ log.warning("Final transcription empty; committing interim preedit text (%d chars)",
65
+ len(self.text))
66
+ self.typer.commit_text(self.text)
67
+
68
+ def clear(self) -> None:
69
+ """Take back everything shown (a voice command discarded it)."""
70
+ if isinstance(self.typer, StreamingTyper):
71
+ self.typer.clear_preedit()
72
+ elif self.text:
73
+ self.typer.delete_chars(len(self.text))
74
+ self.text = ""
75
+
76
+ def apply(self, new_text: str, final: bool = False) -> None:
77
+ """Show `new_text` in place of what is on screen.
78
+
79
+ With StreamingTyper (IBus): use preedit during streaming, commit on final.
80
+ Without: append-only via word-level matching, char-level diff on final.
81
+ """
82
+ old = self.text
83
+
84
+ # Preedit path: trivial, just replace the preview text
85
+ if isinstance(self.typer, StreamingTyper):
86
+ if final:
87
+ self.typer.commit_text(new_text)
88
+ self.text = new_text
89
+ log.debug("Preedit commit: %s", describe_transcript(new_text))
90
+ elif new_text != old:
91
+ self.typer.update_preedit(new_text)
92
+ self.text = new_text
93
+ log.debug("Preedit update: %s", describe_transcript(new_text))
94
+ return
95
+
96
+ if new_text == old:
97
+ return
98
+
99
+ if final:
100
+ self._final_correction(new_text)
101
+ return
102
+
103
+ if not old:
104
+ # First transcription, just type it
105
+ self.typer.type_text(new_text)
106
+ self.text = new_text
107
+ log.debug("Initial: %s", describe_transcript(new_text))
108
+ return
109
+
110
+ # Short text: allow full replacement (Whisper still warming up)
111
+ if len(old.split()) <= 2:
112
+ self.typer.delete_chars(len(old))
113
+ time.sleep(DELETE_SETTLE_SECS)
114
+ self.typer.type_text(new_text)
115
+ self.text = new_text
116
+ log.debug("Replaced short text: %s", describe_transcript(new_text))
117
+ return
118
+
119
+ # Word-level append-only
120
+ old_words = old.split()
121
+ new_words = new_text.split()
122
+ matched = _word_match_len(old_words, new_words)
123
+
124
+ if matched >= len(old_words) and len(new_words) > matched:
125
+ new_tail = " ".join(new_words[matched:])
126
+ to_type = " " + new_tail
127
+ self.typer.type_text(to_type)
128
+ self.text = old + to_type
129
+ log.debug("Appended: %s", describe_transcript(to_type.strip()))
130
+ else:
131
+ log.debug(
132
+ "Skipping (matched %d/%d words)",
133
+ matched, len(old_words),
134
+ )
135
+
136
+ def _final_correction(self, new_text: str) -> None:
137
+ """Final pass: char-level diff to fix accumulated drift."""
138
+ old = self.text
139
+ prefix_len = _common_prefix_len(old, new_text)
140
+ to_delete = len(old) - prefix_len
141
+ to_type = new_text[prefix_len:]
142
+
143
+ if to_delete > 0:
144
+ log.debug("Final correction: delete %d, type %s", to_delete, describe_transcript(to_type))
145
+ self.typer.delete_chars(to_delete)
146
+ time.sleep(DELETE_SETTLE_SECS)
147
+ if to_type:
148
+ self.typer.type_text(to_type)
149
+
150
+ self.text = new_text
voiceio/health.py CHANGED
@@ -217,6 +217,8 @@ def _check_features() -> FeatureGroups:
217
217
  else:
218
218
  groups.accuracy["Vocabulary"] = "none"
219
219
 
220
+ groups.accuracy.update(_learning_status(cfg))
221
+
220
222
  if cfg.postcorrect.enabled:
221
223
  model = cfg.postcorrect.model or cfg.cloud.model
222
224
  groups.accuracy["Cloud postcorrect"] = f"on ({model}), adds up to {cfg.postcorrect.timeout_secs:g}s"
@@ -225,6 +227,30 @@ def _check_features() -> FeatureGroups:
225
227
  return groups
226
228
 
227
229
 
230
+ def _learning_status(cfg) -> dict[str, str]:
231
+ """Whether voiceio is learning your voice: the model in use, whether
232
+ recordings are kept to learn from, and whether it retrains nightly."""
233
+ from pathlib import Path
234
+
235
+ from voiceio import config, service
236
+ from voiceio.learn.store import learn_dir
237
+ out: dict[str, str] = {}
238
+ models = learn_dir() / "models"
239
+ name = cfg.model.name
240
+ if models in Path(name).expanduser().parents:
241
+ out["Voice model"] = f"fine-tuned on you ({Path(name).parent.name})"
242
+ else:
243
+ out["Voice model"] = f"stock ({name})"
244
+ if cfg.data.retain_audio:
245
+ n = sum(1 for _ in config.RECORDINGS_DIR.glob("*.wav")) \
246
+ if config.RECORDINGS_DIR.exists() else 0
247
+ out["Recordings"] = f"on ({n} kept to learn from)"
248
+ else:
249
+ out["Recordings"] = "off (nothing to learn from)"
250
+ out["Nightly learning"] = "on" if service.learn_timer_enabled() else "off"
251
+ return out
252
+
253
+
228
254
  def _check_ibus() -> IBusStatus:
229
255
  """Run detailed IBus health checks."""
230
256
  from pathlib import Path
@@ -325,6 +351,10 @@ def _accuracy_tips(accuracy: dict[str, str]) -> list[str]:
325
351
  tips.append("voiceio corrections add WRONG RIGHT — fix a word that keeps being misheard")
326
352
  if vocab == "none":
327
353
  tips.append("Add names/jargon to ~/.config/voiceio/vocabulary.txt")
354
+ if accuracy.get("Recordings", "").startswith("off"):
355
+ tips.append("voiceio config set data.retain_audio true — keep recordings so voiceio can learn your voice")
356
+ elif accuracy.get("Nightly learning") == "off":
357
+ tips.append("voiceio learn schedule on — retrain on your voice every night")
328
358
  return tips
329
359
 
330
360
 
@@ -391,7 +421,7 @@ def format_report(report: HealthReport) -> str:
391
421
  # Accuracy / learning stack
392
422
  if report.features.accuracy:
393
423
  lines.append(_section("Accuracy"))
394
- lines.append(f" {DIM}Learns from your dictation history to fix recurring mistakes.{RESET}")
424
+ lines.append(f" {DIM}What voiceio knows about your words, and whether it keeps learning.{RESET}")
395
425
  for name, status in report.features.accuracy.items():
396
426
  lines.append(_feature_line(name, status))
397
427
  # Show actionable tips at the bottom
voiceio/learn/train.py CHANGED
@@ -76,13 +76,17 @@ _DECODER_TARGETS = (r".*decoder\.layers\.\d+\.(self_attn\.(q_proj|k_proj|v_proj|
76
76
  r"|encoder_attn\.(q_proj|out_proj)|fc1|fc2)")
77
77
 
78
78
 
79
+ TRAIN_HINT = "pip install 'python-voiceio[train]'"
80
+
81
+
82
+ def missing_train_deps() -> list[str]:
83
+ """Training packages that aren't installed (the `train` extra)."""
84
+ import importlib.util
85
+ return [m for m in ("torch", "transformers", "peft") if importlib.util.find_spec(m) is None]
86
+
87
+
79
88
  def require_train_deps() -> None:
80
- missing = []
81
- for mod in ("torch", "transformers", "peft"):
82
- try:
83
- __import__(mod)
84
- except ImportError:
85
- missing.append(mod)
89
+ missing = missing_train_deps()
86
90
  if missing:
87
91
  raise RuntimeError(
88
92
  f"training needs {', '.join(missing)}: pip install 'python-voiceio[train]'")
voiceio/service.py CHANGED
@@ -27,6 +27,12 @@ SERVICE_NAME = "voiceio.service"
27
27
  SERVICE_DIR = Path.home() / ".config" / "systemd" / "user"
28
28
  SERVICE_PATH = SERVICE_DIR / SERVICE_NAME
29
29
 
30
+ # Nightly `voiceio learn maintain` (opt-in: `voiceio learn schedule on`)
31
+ LEARN_SERVICE_NAME = "voiceio-learn.service"
32
+ LEARN_TIMER_NAME = "voiceio-learn.timer"
33
+ LEARN_SERVICE_PATH = SERVICE_DIR / LEARN_SERVICE_NAME
34
+ LEARN_TIMER_PATH = SERVICE_DIR / LEARN_TIMER_NAME
35
+
30
36
  # Pre-1.0 weekly cloud mining timer — only referenced to remove it
31
37
  CORRECT_SERVICE_NAME = "voiceio-correct.service"
32
38
  CORRECT_TIMER_NAME = "voiceio-correct.timer"
@@ -80,6 +86,71 @@ WantedBy=graphical-session.target
80
86
  """
81
87
 
82
88
 
89
+ def _learn_units(bin_path: str) -> tuple[str, str]:
90
+ """(service, timer) for nightly learning on this machine."""
91
+ service = f"""\
92
+ [Unit]
93
+ Description=VoiceIO - learn from today's dictation (label, retrain when enough is new)
94
+ Documentation=https://github.com/Hugo0/voiceio#make-it-yours
95
+
96
+ [Service]
97
+ Type=oneshot
98
+ ExecStart={bin_path} learn maintain
99
+ # Background work: it gets the CPU only when nothing else wants it, so
100
+ # dictation and everything else stay fast. The unit's own cgroup already
101
+ # does what `learn` would otherwise re-exec into a scope for.
102
+ CPUWeight=idle
103
+ IOWeight=10
104
+ Nice=19
105
+ Environment=VOICEIO_IDLE_SCOPE=1 PYTHONUNBUFFERED=1
106
+ """
107
+ timer = """\
108
+ [Unit]
109
+ Description=VoiceIO - nightly learning
110
+
111
+ [Timer]
112
+ OnCalendar=*-*-* 03:30
113
+ RandomizedDelaySec=30min
114
+ # A laptop that was asleep at 03:30 catches up when it next runs.
115
+ Persistent=true
116
+
117
+ [Install]
118
+ WantedBy=timers.target
119
+ """
120
+ return service, timer
121
+
122
+
123
+ def _systemctl(*args: str) -> bool:
124
+ try:
125
+ return subprocess.run(["systemctl", "--user", *args], capture_output=True,
126
+ timeout=10).returncode == 0
127
+ except (FileNotFoundError, subprocess.TimeoutExpired):
128
+ return False
129
+
130
+
131
+ def install_learn_timer() -> bool:
132
+ """Run `voiceio learn maintain` nightly. True when the timer is enabled."""
133
+ if _IS_WINDOWS or not has_systemd():
134
+ return False
135
+ service, timer = _learn_units(_find_voiceio_bin())
136
+ SERVICE_DIR.mkdir(parents=True, exist_ok=True)
137
+ LEARN_SERVICE_PATH.write_text(service)
138
+ LEARN_TIMER_PATH.write_text(timer)
139
+ _systemctl("daemon-reload")
140
+ return _systemctl("enable", "--now", LEARN_TIMER_NAME)
141
+
142
+
143
+ def uninstall_learn_timer() -> None:
144
+ _systemctl("disable", "--now", LEARN_TIMER_NAME)
145
+ for p in (LEARN_TIMER_PATH, LEARN_SERVICE_PATH):
146
+ p.unlink(missing_ok=True)
147
+ _systemctl("daemon-reload")
148
+
149
+
150
+ def learn_timer_enabled() -> bool:
151
+ return LEARN_TIMER_PATH.exists() and _systemctl("is-enabled", "--quiet", LEARN_TIMER_NAME)
152
+
153
+
83
154
  def uninstall_correct_timer() -> None:
84
155
  """Remove the pre-1.0 weekly cloud-mining timer (`correct --auto --batch`).
85
156
  It is gone in 1.0: mining moved to the local, opt-in `voiceio learn`."""
@@ -75,6 +75,7 @@ def _write_config(
75
75
  tts_engine: str = "auto",
76
76
  tts_speed: float = 1.0,
77
77
  voice_input_prefix: str = "",
78
+ retain_audio: bool | None = None,
78
79
  quiet: bool = False,
79
80
  ) -> None:
80
81
  """Merge wizard answers into the existing config, preserving everything else.
@@ -113,6 +114,9 @@ def _write_config(
113
114
  "speed": tts_speed,
114
115
  })
115
116
 
117
+ if retain_audio is not None: # only when the user was asked
118
+ section("data")["retain_audio"] = retain_audio
119
+
116
120
  # Cloud endpoint: only touch these keys when the wizard collected a NEW
117
121
  # key this run. Otherwise a saved secret / hand-edited endpoint is kept.
118
122
  if cloud_api_key:
@@ -21,6 +21,7 @@ _ALLOWED_ANSWER_KEYS = {
21
21
  "sound_enabled", "notify_clipboard", "commands_enabled",
22
22
  "punctuation_cleanup", "number_conversion", "voice_input_prefix",
23
23
  "tray_enabled", "install_service", "download_model", "install_system_deps",
24
+ "retain_audio",
24
25
  }
25
26
 
26
27
 
@@ -160,6 +161,8 @@ def run_setup_noninteractive(answers: dict | None = None, *, install_deps: bool
160
161
  cloud_api_key=cloud_key,
161
162
  cloud_base_url=str(answers.get("cloud_base_url", "")),
162
163
  cloud_model=str(answers.get("cloud_model", "")),
164
+ # Keeping recordings is the user's call; an agent only sets it if told.
165
+ retain_audio=bool(answers["retain_audio"]) if "retain_audio" in answers else None,
163
166
  tts_enabled=bool(answers.get("tts_enabled", True)),
164
167
  tts_engine=str(answers.get("tts_engine", "auto")),
165
168
  tts_speed=float(answers.get("tts_speed", 1.0)),
voiceio/setup/steps.py CHANGED
@@ -64,6 +64,7 @@ class SetupState:
64
64
  tts_enabled: bool = True
65
65
  tts_engine: str = "auto"
66
66
  tts_speed: float = 1.0
67
+ retain_audio: bool | None = None # None: not asked, leave the config as is
67
68
 
68
69
  @property
69
70
  def is_rerun(self) -> bool:
@@ -90,6 +91,7 @@ class SetupState:
90
91
  cloud_model=self.cloud_model,
91
92
  tts_enabled=self.tts_enabled, tts_engine=self.tts_engine,
92
93
  tts_speed=self.tts_speed,
94
+ retain_audio=self.retain_audio,
93
95
  )
94
96
 
95
97
 
@@ -497,7 +499,13 @@ def _step_advanced(state: SetupState) -> None:
497
499
 
498
500
  def _step_finalize(state: SetupState) -> None:
499
501
  _print_step(8, _TOTAL_STEPS, "Finalize")
502
+ print(f" {DIM}voiceio can learn your voice: keep a recording of each dictation on this")
503
+ print(f" machine (never uploaded) and fine-tune on them with `voiceio learn`.{RESET}")
504
+ state.retain_audio = _ask_yn("Keep recordings so voiceio can learn your voice?",
505
+ default=state.cur.data.retain_audio if state.cur else True)
500
506
  _write_config(**state.config_kwargs())
507
+ if state.retain_audio:
508
+ _offer_nightly_learning(state)
501
509
  _setup_desktop_shortcut(state)
502
510
  _install_autostart(state)
503
511
  _verify_hotkey(state)
@@ -505,6 +513,24 @@ def _step_finalize(state: SetupState) -> None:
505
513
  _start_service()
506
514
 
507
515
 
516
+ def _offer_nightly_learning(state: SetupState) -> None:
517
+ """The recordings are only worth keeping if something learns from them."""
518
+ from voiceio import service
519
+ if not state.checks["is_linux"] or not service.has_systemd() or service.learn_timer_enabled():
520
+ return
521
+ print(f" {DIM}Every night at idle priority: transcribe new recordings with a bigger model,")
522
+ print(f" retrain once enough are new, and tell you if the result is better.{RESET}")
523
+ if not _ask_yn("Learn from them every night?", default=True):
524
+ return
525
+ if service.install_learn_timer():
526
+ print(f" {GREEN}✓{RESET} {DIM}Nightly learning on (voiceio learn schedule off to stop){RESET}")
527
+ from voiceio.learn import train
528
+ if missing := train.missing_train_deps():
529
+ print(f" {DIM}Retraining also needs {', '.join(missing)}: {train.TRAIN_HINT}{RESET}")
530
+ else:
531
+ print(f" {YELLOW}⚠{RESET} {DIM}Could not enable it; try: voiceio learn schedule on{RESET}")
532
+
533
+
508
534
  def _setup_desktop_shortcut(state: SetupState) -> None:
509
535
  """Set up DE shortcut if on GNOME + socket backend (Linux only)."""
510
536
  if not state.checks["is_linux"]:
voiceio/streaming.py CHANGED
@@ -1,4 +1,9 @@
1
- """Streaming transcription with word-level append and final correction."""
1
+ """Streaming transcription: when to decode what, while the user speaks.
2
+
3
+ Interim passes decode the un-frozen tail about once a second; long tails are
4
+ frozen at speech pauses so stop only decodes the rest; the final pass runs
5
+ when the user stops. What reaches the screen, and how, is display.TypedText.
6
+ """
2
7
  from __future__ import annotations
3
8
 
4
9
  import logging
@@ -13,7 +18,7 @@ import numpy as np
13
18
  from voiceio.core.decoders.base import Bias
14
19
  from voiceio.core.decoders.base import transcribe_timeout
15
20
  from voiceio.privacy import describe_transcript
16
- from voiceio.typers.base import StreamingTyper
21
+ from voiceio.display import TypedText
17
22
 
18
23
  if TYPE_CHECKING:
19
24
  from voiceio.audio_source import AudioSource
@@ -22,7 +27,6 @@ if TYPE_CHECKING:
22
27
  from voiceio.typers.base import TyperBackend
23
28
 
24
29
  log = logging.getLogger(__name__)
25
- DELETE_SETTLE_SECS = 0.05 # delay between delete and type for ydotool reliability
26
30
 
27
31
  # Incremental finalization: a freeze cut is only safe at a speech pause.
28
32
  _FREEZE_SILENCE_WINDOW_SECS = 0.3
@@ -138,7 +142,7 @@ class StreamingSession:
138
142
 
139
143
  def __init__(
140
144
  self,
141
- transcriber: Decoder,
145
+ decoder: Decoder,
142
146
  typer: TyperBackend,
143
147
  recorder: AudioSource,
144
148
  generation: int = 0,
@@ -149,11 +153,11 @@ class StreamingSession:
149
153
  freeze_secs: float = 15.0,
150
154
  bias: Bias | None = None,
151
155
  ):
152
- self._transcriber = transcriber
156
+ self._decoder = decoder
153
157
  # Captured once per session: vocabulary/prompt changes made while this
154
158
  # recording runs apply to the next one, never halfway through this.
155
159
  self._bias = bias or Bias()
156
- self._typer = typer
160
+ self._display = TypedText(typer)
157
161
  # Output-ownership gate: the app sets this so a superseded session (its
158
162
  # generation was overtaken by a newer recording) stops emitting ANY
159
163
  # typer output on the final path, which would otherwise corrupt the new
@@ -170,7 +174,6 @@ class StreamingSession:
170
174
  self._on_interim = on_interim
171
175
  self._typer_fail_count = 0
172
176
  self._typer_broken_signalled = False
173
- self._typed_text = ""
174
177
  self._pending = threading.Event()
175
178
  self._stop_event = threading.Event()
176
179
  # The final pass runs in stop()'s thread, concurrently with whatever
@@ -211,7 +214,7 @@ class StreamingSession:
211
214
  @property
212
215
  def interim_text(self) -> str:
213
216
  """Best-so-far text (post-pipeline). Safe to read while finalizing."""
214
- return self._typed_text
217
+ return self._display.text
215
218
 
216
219
  def set_is_current(self, is_current: Callable[[], bool]) -> None:
217
220
  """Install the output-ownership gate (see __init__).
@@ -229,8 +232,7 @@ class StreamingSession:
229
232
  Resets the typed-text tracking: the old backend's on-screen state
230
233
  (e.g. a stranded IBus preedit) no longer belongs to us.
231
234
  """
232
- self._typer = typer
233
- self._typed_text = ""
235
+ self._display = TypedText(typer)
234
236
 
235
237
  def start(self) -> None:
236
238
  """Begin streaming. Recorder must already be started by caller."""
@@ -271,15 +273,15 @@ class StreamingSession:
271
273
  if self._recorder is not None:
272
274
  self._recorder.set_on_speech_pause(None)
273
275
  self._recorder = None
274
- log.debug("Streaming session stopped, typed: %s", describe_transcript(self._typed_text))
275
- return self._typed_text
276
+ log.debug("Streaming session stopped, typed: %s", describe_transcript(self._display.text))
277
+ return self._display.text
276
278
 
277
279
  def _decode_past_pending_freeze(self, audio: np.ndarray | None):
278
280
  """If a freeze is decoding on the final lane, decode the audio after
279
281
  its cut on the draft lane meanwhile, so stop waits for the longer of
280
282
  the two instead of their sum. Returns (start_sample, result) or None."""
281
283
  end = self._freeze_pending_end
282
- draft = getattr(self._transcriber, "draft", None)
284
+ draft = getattr(self._decoder, "draft", None)
283
285
  if end is None or draft is None or audio is None or len(audio) <= end:
284
286
  return None
285
287
  try:
@@ -308,7 +310,7 @@ class StreamingSession:
308
310
  self._typer_broken_signalled = True
309
311
  log.warning(
310
312
  "Typer '%s' failed %d times in streaming, requesting re-probe",
311
- self._typer.name, self._typer_fail_count,
313
+ self._display.typer.name, self._typer_fail_count,
312
314
  )
313
315
  if self._on_typer_broken:
314
316
  self._on_typer_broken()
@@ -332,7 +334,7 @@ class StreamingSession:
332
334
  """
333
335
  t0 = time.monotonic()
334
336
  try:
335
- result = self._transcriber.decode(chunk, final=True, bias=self._tail_bias())
337
+ result = self._decoder.decode(chunk, final=True, bias=self._tail_bias())
336
338
  except Exception:
337
339
  log.exception("Freeze decode failed (boundary unchanged)")
338
340
  return
@@ -417,7 +419,7 @@ class StreamingSession:
417
419
  if pre is not None and pre[0] == self._frozen_samples:
418
420
  result = pre[1] # decoded in parallel with the last freeze
419
421
  else:
420
- result = self._transcriber.decode(tail, final=final, bias=self._tail_bias())
422
+ result = self._decoder.decode(tail, final=final, bias=self._tail_bias())
421
423
  except Exception:
422
424
  # Decode FAILED (timeout/crash) — this is not silence. Never
423
425
  # advance the freeze boundary or char-diff against a partial
@@ -506,119 +508,29 @@ class StreamingSession:
506
508
  # A superseded session must not touch the typer on the final
507
509
  # path (would corrupt the newer session's output).
508
510
  if final and not self._is_current():
509
- self._typed_text = ""
511
+ self._display.text = ""
510
512
  return
511
- if isinstance(self._typer, StreamingTyper):
512
- self._typer.clear_preedit()
513
- elif self._typed_text:
514
- self._typer.delete_chars(len(self._typed_text))
515
- self._typed_text = ""
513
+ self._display.clear()
516
514
  return
517
515
 
518
516
  if text:
519
- prev = self._typed_text
517
+ prev = self._display.text
520
518
  self._apply_correction(text, final=final)
521
- if not final and self._on_interim and self._typed_text != prev:
519
+ if not final and self._on_interim and self._display.text != prev:
522
520
  try:
523
- self._on_interim(self._typed_text)
521
+ self._on_interim(self._display.text)
524
522
  except Exception:
525
523
  log.debug("on_interim callback failed", exc_info=True)
526
524
 
527
525
  def _commit_interim_on_final(self) -> None:
528
- """Commit whatever interim text we have when the final pass yields none.
529
-
530
- For the IBus preedit path the interim text lives only in the preedit
531
- (uncommitted) — commit it so a final-pass timeout doesn't drop it. For
532
- keystroke typers the interim text is already in the target app, so
533
- there is nothing to do.
534
- """
535
- if not self._typed_text or not self._is_current():
536
- return
537
- if isinstance(self._typer, StreamingTyper):
538
- log.warning(
539
- "Final transcription empty; committing interim preedit text (%d chars)",
540
- len(self._typed_text),
541
- )
542
- self._typer.commit_text(self._typed_text)
526
+ """Commit whatever interim text we have when the final pass yields none."""
527
+ if self._is_current():
528
+ self._display.commit_preview()
543
529
 
544
530
  def _apply_correction(self, new_text: str, final: bool = False) -> None:
545
- """Apply correction to typed text.
546
-
547
- With StreamingTyper (IBus): use preedit during streaming, commit on final.
548
- Without: append-only via word-level matching, char-level diff on final.
549
- """
550
- old = self._typed_text
551
-
552
531
  # A superseded session must emit no typer output on the final path.
553
532
  if final and not self._is_current():
554
533
  log.debug("Final output suppressed: session superseded")
555
- self._typed_text = new_text
534
+ self._display.text = new_text
556
535
  return
557
-
558
- # Preedit path: trivial, just replace the preview text
559
- if isinstance(self._typer, StreamingTyper):
560
- if final:
561
- self._typer.commit_text(new_text)
562
- self._typed_text = new_text
563
- log.debug("Preedit commit: %s", describe_transcript(new_text))
564
- elif new_text != old:
565
- self._typer.update_preedit(new_text)
566
- self._typed_text = new_text
567
- log.debug("Preedit update: %s", describe_transcript(new_text))
568
- return
569
-
570
- if new_text == old:
571
- return
572
-
573
- if final:
574
- self._apply_final_correction(new_text)
575
- return
576
-
577
- if not old:
578
- # First transcription, just type it
579
- self._typer.type_text(new_text)
580
- self._typed_text = new_text
581
- log.debug("Initial: %s", describe_transcript(new_text))
582
- return
583
-
584
- # Short text: allow full replacement (Whisper still warming up)
585
- if len(old.split()) <= 2:
586
- self._typer.delete_chars(len(old))
587
- time.sleep(DELETE_SETTLE_SECS)
588
- self._typer.type_text(new_text)
589
- self._typed_text = new_text
590
- log.debug("Replaced short text: %s", describe_transcript(new_text))
591
- return
592
-
593
- # Word-level append-only
594
- old_words = old.split()
595
- new_words = new_text.split()
596
- matched = _word_match_len(old_words, new_words)
597
-
598
- if matched >= len(old_words) and len(new_words) > matched:
599
- new_tail = " ".join(new_words[matched:])
600
- to_type = " " + new_tail
601
- self._typer.type_text(to_type)
602
- self._typed_text = old + to_type
603
- log.debug("Appended: %s", describe_transcript(to_type.strip()))
604
- else:
605
- log.debug(
606
- "Skipping (matched %d/%d words)",
607
- matched, len(old_words),
608
- )
609
-
610
- def _apply_final_correction(self, new_text: str) -> None:
611
- """Final pass: char-level diff to fix accumulated drift."""
612
- old = self._typed_text
613
- prefix_len = _common_prefix_len(old, new_text)
614
- to_delete = len(old) - prefix_len
615
- to_type = new_text[prefix_len:]
616
-
617
- if to_delete > 0:
618
- log.debug("Final correction: delete %d, type %s", to_delete, describe_transcript(to_type))
619
- self._typer.delete_chars(to_delete)
620
- time.sleep(DELETE_SETTLE_SECS)
621
- if to_type:
622
- self._typer.type_text(to_type)
623
-
624
- self._typed_text = new_text
536
+ self._display.apply(new_text, final=final)
voiceio/typers/manager.py CHANGED
@@ -157,7 +157,7 @@ class TyperManager:
157
157
 
158
158
  Defers the re-probe+upgrade to a background thread that waits
159
159
  for the state to reach IDLE. We cannot hot-swap mid-recording:
160
- the streaming session tracks ``_typed_text`` in terms of the old
160
+ the streaming session's TypedText tracks text in terms of the old
161
161
  typer's behavior (char-level for clipboard vs preedit for ibus),
162
162
  and mid-stream swapping would leave stale or duplicated chars.
163
163
  The current recording is already broken — accept that, fix it
voiceio/watchdog.py CHANGED
@@ -26,11 +26,11 @@ _RESUME_THRESHOLD = 30 # seconds
26
26
 
27
27
 
28
28
  class Watchdog:
29
- """Every ``_HEALTH_CHECK_INTERVAL``: transcriber worker, audio stream,
29
+ """Every ``_HEALTH_CHECK_INTERVAL``: decoder worker, audio stream,
30
30
  tray, typer and IBus engine; a full re-probe after suspend/resume.
31
31
 
32
32
  It never mutates the daemon's state machine itself: ``is_idle`` /
33
- ``is_recording`` read it, ``on_transcriber_failed`` asks the daemon to
33
+ ``is_recording`` read it, ``on_decoder_failed`` asks the daemon to
34
34
  enter ERROR, and typer/platform changes go through ``typers``' IDLE-gated
35
35
  swap helpers.
36
36
  """
@@ -39,26 +39,26 @@ class Watchdog:
39
39
  self,
40
40
  cfg: config.Config,
41
41
  *,
42
- transcriber: Decoder,
42
+ decoder: Decoder,
43
43
  recorder: AudioRecorder,
44
44
  typers: TyperManager,
45
45
  ibus: IBusSession,
46
46
  session: Callable[[], StreamingSession | None],
47
47
  is_idle: Callable[[], bool],
48
48
  is_recording: Callable[[], bool],
49
- on_transcriber_failed: Callable[[], None],
49
+ on_decoder_failed: Callable[[], None],
50
50
  on_hotkey: Callable[[], None],
51
51
  shutdown: threading.Event,
52
52
  ):
53
53
  self._cfg = cfg
54
- self._transcriber = transcriber
54
+ self._decoder = decoder
55
55
  self._recorder = recorder
56
56
  self._typers = typers
57
57
  self._ibus = ibus
58
58
  self._session = session
59
59
  self._is_idle = is_idle
60
60
  self._is_recording = is_recording
61
- self._on_transcriber_failed = on_transcriber_failed
61
+ self._on_decoder_failed = on_decoder_failed
62
62
  self._on_hotkey = on_hotkey
63
63
  self._shutdown = shutdown
64
64
 
@@ -72,7 +72,7 @@ class Watchdog:
72
72
  ).start()
73
73
 
74
74
  def _loop(self) -> None:
75
- """Periodic health check: transcriber worker, IBus engine, audio stream."""
75
+ """Periodic health check: decoder worker, IBus engine, audio stream."""
76
76
  last_check = time.monotonic()
77
77
  while not self._shutdown.is_set():
78
78
  self._shutdown.wait(_HEALTH_CHECK_INTERVAL)
@@ -120,25 +120,25 @@ class Watchdog:
120
120
  tray.restart(self._on_hotkey)
121
121
 
122
122
  # Transcriber: ensure worker is alive
123
- if not self._transcriber.healthy():
123
+ if not self._decoder.healthy():
124
124
  try:
125
- self._transcriber.recover()
126
- log.info("Resume: transcriber worker restarted")
125
+ self._decoder.recover()
126
+ log.info("Resume: decoder worker restarted")
127
127
  except RuntimeError:
128
- log.error("Resume: transcriber worker failed")
128
+ log.error("Resume: decoder worker failed")
129
129
 
130
130
  def check(self) -> None:
131
131
  """Run one health check cycle."""
132
- # Check transcriber worker
133
- if not self._transcriber.healthy():
132
+ # Check decoder worker
133
+ if not self._decoder.healthy():
134
134
  if self._is_recording():
135
135
  log.warning("Transcriber worker died during recording")
136
136
  try:
137
- self._transcriber.recover()
137
+ self._decoder.recover()
138
138
  log.info("Transcriber worker recovered")
139
139
  except RuntimeError:
140
140
  log.error("Transcriber worker permanently failed")
141
- self._on_transcriber_failed()
141
+ self._on_decoder_failed()
142
142
 
143
143
  # Check audio stream (covers ALSA underrun, PulseAudio/PipeWire
144
144
  # restart, device disconnect, stale callback heartbeat)