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.
- {python_voiceio-1.0.0.dist-info → python_voiceio-1.2.0.dist-info}/METADATA +10 -10
- {python_voiceio-1.0.0.dist-info → python_voiceio-1.2.0.dist-info}/RECORD +23 -22
- voiceio/__init__.py +1 -1
- voiceio/app.py +22 -7
- voiceio/cli/learn.py +45 -2
- voiceio/cli/servicecmd.py +26 -0
- voiceio/cli/uninstall.py +3 -0
- voiceio/config.py +13 -4
- voiceio/core/decoders/faster_whisper/__init__.py +3 -1
- voiceio/display.py +150 -0
- voiceio/health.py +31 -1
- voiceio/learn/train.py +10 -6
- voiceio/service.py +71 -0
- voiceio/setup/configfile.py +4 -0
- voiceio/setup/noninteractive.py +3 -0
- voiceio/setup/steps.py +26 -0
- voiceio/streaming.py +28 -116
- voiceio/typers/manager.py +1 -1
- voiceio/watchdog.py +15 -15
- {python_voiceio-1.0.0.dist-info → python_voiceio-1.2.0.dist-info}/WHEEL +0 -0
- {python_voiceio-1.0.0.dist-info → python_voiceio-1.2.0.dist-info}/entry_points.txt +0 -0
- {python_voiceio-1.0.0.dist-info → python_voiceio-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {python_voiceio-1.0.0.dist-info → python_voiceio-1.2.0.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-voiceio
|
|
3
|
-
Version: 1.
|
|
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
|
|
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
|
|
158
|
-
| `recordings/*.wav` | The audio of each utterance, for `voiceio learn` | `[data] retain_audio
|
|
159
|
-
| `streaming_trace.jsonl`, `postcorrect_pairs.jsonl` | Per-pass decode
|
|
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
|
|
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
|
-
- **
|
|
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.
|
|
2
|
-
voiceio/__init__.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
29
|
-
voiceio/streaming.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
46
|
-
voiceio/cli/uninstall.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
128
|
-
python_voiceio-1.
|
|
129
|
-
python_voiceio-1.
|
|
130
|
-
python_voiceio-1.
|
|
131
|
-
python_voiceio-1.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
230
|
+
pairs accumulate for `voiceio learn` to fine-tune on.
|
|
224
231
|
"""
|
|
225
|
-
retain_audio: bool =
|
|
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
|
-
|
|
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 =
|
|
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":
|
|
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}
|
|
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`."""
|
voiceio/setup/configfile.py
CHANGED
|
@@ -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:
|
voiceio/setup/noninteractive.py
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
275
|
-
return self.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
511
|
+
self._display.text = ""
|
|
510
512
|
return
|
|
511
|
-
|
|
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.
|
|
517
|
+
prev = self._display.text
|
|
520
518
|
self._apply_correction(text, final=final)
|
|
521
|
-
if not final and self._on_interim and self.
|
|
519
|
+
if not final and self._on_interim and self._display.text != prev:
|
|
522
520
|
try:
|
|
523
|
-
self._on_interim(self.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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``:
|
|
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, ``
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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:
|
|
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.
|
|
123
|
+
if not self._decoder.healthy():
|
|
124
124
|
try:
|
|
125
|
-
self.
|
|
126
|
-
log.info("Resume:
|
|
125
|
+
self._decoder.recover()
|
|
126
|
+
log.info("Resume: decoder worker restarted")
|
|
127
127
|
except RuntimeError:
|
|
128
|
-
log.error("Resume:
|
|
128
|
+
log.error("Resume: decoder worker failed")
|
|
129
129
|
|
|
130
130
|
def check(self) -> None:
|
|
131
131
|
"""Run one health check cycle."""
|
|
132
|
-
# Check
|
|
133
|
-
if not self.
|
|
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.
|
|
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.
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|