python-voiceio 1.2.0__py3-none-any.whl → 1.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_voiceio-1.2.0.dist-info → python_voiceio-1.4.0.dist-info}/METADATA +13 -7
- {python_voiceio-1.2.0.dist-info → python_voiceio-1.4.0.dist-info}/RECORD +18 -17
- voiceio/__init__.py +1 -1
- voiceio/app.py +8 -1
- voiceio/cli/learn.py +151 -19
- voiceio/cli/servicecmd.py +29 -17
- voiceio/config.py +19 -0
- voiceio/feedback.py +34 -0
- voiceio/health.py +2 -2
- voiceio/learn/evaluate.py +15 -5
- voiceio/learn/quiet.py +54 -0
- voiceio/learn/review.py +30 -9
- voiceio/pidlock.py +18 -0
- voiceio/service.py +65 -11
- {python_voiceio-1.2.0.dist-info → python_voiceio-1.4.0.dist-info}/WHEEL +0 -0
- {python_voiceio-1.2.0.dist-info → python_voiceio-1.4.0.dist-info}/entry_points.txt +0 -0
- {python_voiceio-1.2.0.dist-info → python_voiceio-1.4.0.dist-info}/licenses/LICENSE +0 -0
- {python_voiceio-1.2.0.dist-info → python_voiceio-1.4.0.dist-info}/top_level.txt +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-voiceio
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: Voice dictation for Linux. Speak → text, locally, instantly.
|
|
5
5
|
Author: Hugo Montenegro
|
|
6
6
|
License-Expression: MIT
|
|
7
|
-
Project-URL: Homepage, https://
|
|
7
|
+
Project-URL: Homepage, https://voiceio.dev/
|
|
8
8
|
Project-URL: Repository, https://github.com/Hugo0/voiceio
|
|
9
9
|
Project-URL: Issues, https://github.com/Hugo0/voiceio/issues
|
|
10
10
|
Project-URL: Changelog, https://github.com/Hugo0/voiceio/releases
|
|
@@ -58,7 +58,7 @@ Dynamic: license-file
|
|
|
58
58
|
[](https://pypi.org/project/python-voiceio/)
|
|
59
59
|
[](LICENSE)
|
|
60
60
|
|
|
61
|
-
**Voice dictation that learns you.** Local, open source, private — and it fine-tunes itself on your own voice, on your own machine.
|
|
61
|
+
**Voice dictation that learns you.** ([website](https://voiceio.dev/)) Local, open source, private — and it fine-tunes itself on your own voice, on your own machine.
|
|
62
62
|
|
|
63
63
|
Off-the-shelf speech models stumble on exactly the words you say most: your colleagues' names, your project, your jargon. voiceio keeps (with your permission) the audio of what you dictate, has a bigger model transcribe it overnight, and trains a personal model from it — nothing uploaded, no GPU required. On the author's dictation, one overnight run on a laptop CPU cut word errors from **12.1% to 5.0%** on held-out clips and recognized more of the author's vocabulary, at the same speed. Every model is scored on clips it never trained on, and it only replaces the current one when it wins.
|
|
64
64
|
|
|
@@ -182,7 +182,8 @@ Most dictation errors are proper nouns and jargon: `PostgreSQL` heard as `post g
|
|
|
182
182
|
voiceio learn import DIR --source phone # optional: add your own recordings from elsewhere
|
|
183
183
|
voiceio learn label # a stronger teacher model (large-v3-turbo) transcribes your clips
|
|
184
184
|
voiceio learn review # fix the teacher where it matters: your names and terms
|
|
185
|
-
voiceio learn review --test #
|
|
185
|
+
voiceio learn review --test # verify the held-out clips, so eval scores against the truth: it asks only
|
|
186
|
+
# where the teacher and your live decode disagree, quickest clips first
|
|
186
187
|
voiceio learn augment # synthesize extra examples of rare terms with local TTS
|
|
187
188
|
voiceio learn build # freeze a versioned train/dev/test dataset (the test split never trains)
|
|
188
189
|
voiceio learn eval # score the current model on your held-out clips: WER and term recall
|
|
@@ -190,9 +191,16 @@ voiceio learn eval # score the current model on your held-out clips: WER a
|
|
|
190
191
|
voiceio learn train # LoRA fine-tune (needs the `train` extra; decoder-only on CPU, --full on a GPU)
|
|
191
192
|
voiceio learn promote <run> # dictate with the fine-tune; `voiceio learn rollback` undoes it
|
|
192
193
|
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`
|
|
194
|
+
voiceio learn schedule on # …or let it run `maintain` on a schedule, and notify you when a model wins
|
|
195
|
+
# --at 02:00 --every week: when (default daily at 03:30)
|
|
196
|
+
# --auto-promote: switch to a winner by itself; `learn rollback` undoes it
|
|
197
|
+
voiceio learn stop # stop a run in progress (it goes again next time)
|
|
194
198
|
```
|
|
195
199
|
|
|
200
|
+
Scheduled runs wait for a quiet machine: they start only when the 1-minute load per CPU is at most `[learn] max_load` (0.25) and you're on mains power, and give up after `wait_hours` (3) until the next run. When one starts you get a notification with **Stop for now** and **Weekly instead**.
|
|
201
|
+
|
|
202
|
+
To keep a second machine (say, the server your phone dictates through) on the same model, set `[learn] on_promote = "scripts/push_model.sh HOST"`: after every switch it copies the model there and flips a `current` link the server points at.
|
|
203
|
+
|
|
196
204
|
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`.
|
|
197
205
|
|
|
198
206
|
`voiceio learn status` shows where you are, each model's scores, and suggests `promote` only when a fine-tune wins on everything. `promote` itself refuses a model that hasn't, and reports how many clips got better or worse with a confidence interval — a few dozen test clips can move an average by luck.
|
|
@@ -300,8 +308,6 @@ Logs: `journalctl --user -u voiceio` or `~/.local/state/voiceio/voiceio.log`.
|
|
|
300
308
|
|
|
301
309
|
## Roadmap
|
|
302
310
|
|
|
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
311
|
- **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
312
|
- **One model everywhere** — sync your fine-tune to your phone server and laptops, peer to peer.
|
|
307
313
|
|
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
python_voiceio-1.
|
|
2
|
-
voiceio/__init__.py,sha256=
|
|
1
|
+
python_voiceio-1.4.0.dist-info/licenses/LICENSE,sha256=Gz61o8jFTAvZUZyB3nWDB3DQQVuipjfPkVu9W8hBHM0,1072
|
|
2
|
+
voiceio/__init__.py,sha256=8UhoYEXHs1Oai7BW_ExBmuwWnRI-yMG_u1fQAXMizHQ,22
|
|
3
3
|
voiceio/__main__.py,sha256=xT5QCGGreYMisHO7Lh_Y-xAQ2TOzG2D7npKknGNcWSY,53
|
|
4
|
-
voiceio/app.py,sha256=
|
|
4
|
+
voiceio/app.py,sha256=K9vz9iZRRTavyWNYaZMYxwsvaPJBRYcQjFFyi4YOfOM,32164
|
|
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=X1W5Vc5HgegMsRgXD074h3txhNwewUf9DaKkRglmPf0,20337
|
|
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
13
|
voiceio/display.py,sha256=ZGBNRjuvg3chkJjduyPJs4s5ZY5vqC_mcaov94PIPzo,5313
|
|
14
|
-
voiceio/feedback.py,sha256=
|
|
15
|
-
voiceio/health.py,sha256=
|
|
14
|
+
voiceio/feedback.py,sha256=avTjD8wksjStPkGVc_Z2lTMpalhwn7NmI3RZ2USvOLc,7066
|
|
15
|
+
voiceio/health.py,sha256=6cdVMq4pr9WQS8YN7ikxtoCYESXLU4_z7gT3XYo-33Y,22154
|
|
16
16
|
voiceio/hints.py,sha256=dmoiMAJ0YGTWwFc9kIPvEs_BQJN7UEUiwYuLUD_GoQQ,1415
|
|
17
17
|
voiceio/history.py,sha256=cD3Ysa39qt6uKbrcLeRQt-FOJcYCoqie4UNsUWF7DuM,5763
|
|
18
18
|
voiceio/llm.py,sha256=cO2RHM2zHxTD4r5mFFNatB-pjOSl29Wx_I00f7ywYCw,9161
|
|
19
19
|
voiceio/llm_api.py,sha256=4jJ5U1Dop-3uoM9RM3bjUMxB4kYNNjwKbOwIIj0ll3U,9390
|
|
20
20
|
voiceio/numbers.py,sha256=CXJtlMhmV6_9Z1FtKAF4-RXycw8voV-BYHPBpdw1I6Y,13571
|
|
21
|
-
voiceio/pidlock.py,sha256=
|
|
21
|
+
voiceio/pidlock.py,sha256=OZLNskUyv9_F3WgyHZWw8WmKt0W7ioxH6xgHitOwCww,1216
|
|
22
22
|
voiceio/platform.py,sha256=uG_5KeYey_29GCn4nSOQJ1UpoCrBC_m2DR7IeoRnuL4,19142
|
|
23
23
|
voiceio/postcorrect.py,sha256=JN5l9xnyGfYq2_owSUwkJ513QiQQKxCrT3hjU5aJCpg,17322
|
|
24
24
|
voiceio/postprocess.py,sha256=rzKmf_ZCskhGnqLEbHaiQX-Sz9yutdJwcwposz7GJ4Q,4661
|
|
@@ -26,7 +26,7 @@ voiceio/privacy.py,sha256=FqnA9jx9RCqWW-i6Dh7JcT7vzz97kbUGyQEvsHLvpqc,573
|
|
|
26
26
|
voiceio/prompt.py,sha256=9iza8KoQxvO_U9NPMJ8Ep7j-zkIs9nW8N5sacCRpsEk,2904
|
|
27
27
|
voiceio/recorder.py,sha256=k51Py6av0YFkc1ENXsrXuFIL3G9bmsAijGq3X5r7pG4,15734
|
|
28
28
|
voiceio/retention.py,sha256=TH_D29EUYyzEPl5FK0q32mpsSk0-0bx56JwAH83FBHI,6272
|
|
29
|
-
voiceio/service.py,sha256=
|
|
29
|
+
voiceio/service.py,sha256=q4_Mk8m3KCG14CGZ468ZrFvNvyXuWSOjKFUjRnksgAI,16391
|
|
30
30
|
voiceio/streaming.py,sha256=hKak7ULkZbkHl5Fw0SnnZsEK3alv2s8dcuCcyBzpIQY,24700
|
|
31
31
|
voiceio/suggest.py,sha256=jX4bCpnddyrteKfRBPbnUgf-Rk5145LFaSTat0_Ll2E,4836
|
|
32
32
|
voiceio/tokens.py,sha256=m5tusR3zEBX7fRKaDUj4z-DSv8NcMhE-02HviAziENQ,5710
|
|
@@ -41,9 +41,9 @@ voiceio/cli/configcmd.py,sha256=YF7t-LkwEYEGH4qcO86xehWVX0LvltqxjDFpMF5ljjA,2858
|
|
|
41
41
|
voiceio/cli/corrections.py,sha256=OBjgiOhtWlTQpQq0HLAvk5-EfKJ7NNt-hnNBHm7obEc,4247
|
|
42
42
|
voiceio/cli/doctor.py,sha256=P8bOn4xedMistlKHhNeeGxRsmogV6jBVKc-CUbo0LXs,7769
|
|
43
43
|
voiceio/cli/history.py,sha256=IWyFIozX5PUwtrU17JlkNea9nP_oKLsXfkxIVHjkfD0,1516
|
|
44
|
-
voiceio/cli/learn.py,sha256=
|
|
44
|
+
voiceio/cli/learn.py,sha256=W-7FQIcrtVlCC8SBNHkhUnE8iK0m6WiHsE8qlxz0BE4,31056
|
|
45
45
|
voiceio/cli/models.py,sha256=fye_mHbsXLY11uRC1BCDlucCHPny9ftoehAeUJPMJv0,4263
|
|
46
|
-
voiceio/cli/servicecmd.py,sha256=
|
|
46
|
+
voiceio/cli/servicecmd.py,sha256=2St2WfLkt06EpC51I5i9Ykv_pvLKzu_dz01M-29O3-4,4830
|
|
47
47
|
voiceio/cli/uninstall.py,sha256=Vqnq6-4hjhDrxqkU_1wtXkY5BU_Tqa-TjZA0Qpf12e4,8921
|
|
48
48
|
voiceio/cli/vocab.py,sha256=lxxuSukefvIHV4fFSduCQQewXY0_Bvf5sDnG4bqLWH8,4418
|
|
49
49
|
voiceio/core/__init__.py,sha256=K0b4duczohS3IrIMLp9fYhfb-GeuyQaOVH_8teYGsQM,277
|
|
@@ -75,10 +75,11 @@ voiceio/ibus/textlimit.py,sha256=4Hmn2LDEOs7h-Hb2-cT1HkvIRepDgZq4KRI9KD3xiBQ,251
|
|
|
75
75
|
voiceio/learn/__init__.py,sha256=Q98-C809lkMqTS0OAV1xNVtqHz9NvuekivXiHiVHBWA,750
|
|
76
76
|
voiceio/learn/augment.py,sha256=qyegRlg3cQF5CT0HV85Jm93Jd9xZpjWwbFVk_VzFdiI,4105
|
|
77
77
|
voiceio/learn/clean.py,sha256=1ItIfIAsqxZLZ76T4oF7kSr4Poo-z_6GEzhrlt21uMo,4922
|
|
78
|
-
voiceio/learn/evaluate.py,sha256=
|
|
78
|
+
voiceio/learn/evaluate.py,sha256=sW2lfV9jK22fpFFgxh9p6cp0pcTt5KjXV3eatsrg9sM,13233
|
|
79
79
|
voiceio/learn/importer.py,sha256=uB-gHWwSewZYTx22j9h941ARtDE8BFHmf8p3HaHUC6g,3544
|
|
80
80
|
voiceio/learn/metrics.py,sha256=b2xWUzPpNpXHn6zgS1upPbEcpW3ExH9SmSLIUzPhStQ,5174
|
|
81
|
-
voiceio/learn/
|
|
81
|
+
voiceio/learn/quiet.py,sha256=TgKRlUMbXLCgiIv2i7ukcy0L0KbuhMPdD6tgvntZ1Ek,1973
|
|
82
|
+
voiceio/learn/review.py,sha256=X5qs5yEf20Ao3gMhsbW3Eur62hSqy9AYlv08Pcrllxo,5572
|
|
82
83
|
voiceio/learn/store.py,sha256=MauWhGaI2drMbiChIZOblpPS_uVAw-1sNWGyGACHYw8,12816
|
|
83
84
|
voiceio/learn/teacher.py,sha256=uF4hQkrdjv012TTBRSwrhQqqAeCFNoidABcrgzSVUoI,3696
|
|
84
85
|
voiceio/learn/train.py,sha256=zTyftCsUV6hYy9WA8ftZtqjVbYJ-C7py0EoxavXWr7I,17628
|
|
@@ -125,8 +126,8 @@ voiceio/typers/pynput_type.py,sha256=Rzkixn08iaIqSCZ5vRybaHpn05lkx4FiEa_OakaiM1A
|
|
|
125
126
|
voiceio/typers/wtype.py,sha256=d1wG-HZdYDQxXIysV_Xvk9HixDpaJgl3VEoKbP5vhIs,1786
|
|
126
127
|
voiceio/typers/xdotool.py,sha256=dh1zhPqUT8ihJahJ7ZKm6PtfYY087UAzADx664DvQOM,1356
|
|
127
128
|
voiceio/typers/ydotool.py,sha256=e19GvDHL9GS-jgh_hzW3PGBFPCc4IJCWsBWEzU_LqGs,5934
|
|
128
|
-
python_voiceio-1.
|
|
129
|
-
python_voiceio-1.
|
|
130
|
-
python_voiceio-1.
|
|
131
|
-
python_voiceio-1.
|
|
132
|
-
python_voiceio-1.
|
|
129
|
+
python_voiceio-1.4.0.dist-info/METADATA,sha256=zPfUJe5stD9z-kj9POKXJoW5WwCgeSztu_gIYTPIONg,21752
|
|
130
|
+
python_voiceio-1.4.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
131
|
+
python_voiceio-1.4.0.dist-info/entry_points.txt,sha256=iDIDmhvQ3xeN_CWsFne1t9STZ08I25I_wp0xmde_vMs,94
|
|
132
|
+
python_voiceio-1.4.0.dist-info/top_level.txt,sha256=piwtn309lD6uexQyXdZ-efAVBJF9y6Wfr48Z-8zkNhg,8
|
|
133
|
+
python_voiceio-1.4.0.dist-info/RECORD,,
|
voiceio/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "1.
|
|
1
|
+
__version__ = "1.4.0"
|
voiceio/app.py
CHANGED
|
@@ -43,6 +43,10 @@ class VoiceIO:
|
|
|
43
43
|
@_state.setter
|
|
44
44
|
def _state(self, value: _State) -> None:
|
|
45
45
|
self.__state = value
|
|
46
|
+
# Only the instance holding the PID lock speaks for the daemon: a
|
|
47
|
+
# second launch must not overwrite the running one's state.
|
|
48
|
+
if not getattr(self, "_publishes_state", False):
|
|
49
|
+
return
|
|
46
50
|
try: # best-effort: only `service restart` reads it
|
|
47
51
|
tmp = config.STATE_PATH.with_suffix(".tmp")
|
|
48
52
|
tmp.write_text(value.value)
|
|
@@ -660,7 +664,7 @@ class VoiceIO:
|
|
|
660
664
|
self._typers.redetect_at_start()
|
|
661
665
|
|
|
662
666
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
663
|
-
self._pid_fd = open(PID_PATH, "w"
|
|
667
|
+
self._pid_fd = open(PID_PATH, "a") # not "w": the running daemon's PID stays
|
|
664
668
|
try:
|
|
665
669
|
from voiceio.pidlock import lock_pid_file
|
|
666
670
|
lock_pid_file(self._pid_fd)
|
|
@@ -669,8 +673,11 @@ class VoiceIO:
|
|
|
669
673
|
log.error("Another voiceio instance is already running")
|
|
670
674
|
print("voiceio is already running. Stop it first: voiceio service stop")
|
|
671
675
|
return
|
|
676
|
+
self._pid_fd.truncate(0)
|
|
672
677
|
self._pid_fd.write(str(os.getpid()))
|
|
673
678
|
self._pid_fd.flush()
|
|
679
|
+
self._publishes_state = True
|
|
680
|
+
self._state = self._state
|
|
674
681
|
|
|
675
682
|
self._typers.prepare()
|
|
676
683
|
|
voiceio/cli/learn.py
CHANGED
|
@@ -84,12 +84,25 @@ def register(sub: argparse._SubParsersAction) -> None:
|
|
|
84
84
|
help="new labeled clips needed before retraining (default 50)")
|
|
85
85
|
mt.add_argument("--base", default="small")
|
|
86
86
|
mt.add_argument("--epochs", type=int, default=4)
|
|
87
|
+
mt.add_argument("--auto-promote", action="store_true",
|
|
88
|
+
help="switch to the fine-tune when it wins, and restart the daemon")
|
|
89
|
+
mt.add_argument("--when-idle", action="store_true",
|
|
90
|
+
help="first wait for a quiet machine on mains power ([learn] max_load, "
|
|
91
|
+
"wait_hours); the scheduled run uses this")
|
|
87
92
|
mt.set_defaults(func=cmd_maintain)
|
|
88
93
|
|
|
89
|
-
sc = lsub.add_parser("schedule", help="Run `maintain`
|
|
94
|
+
sc = lsub.add_parser("schedule", help="Run `maintain` on a schedule (systemd timer)")
|
|
90
95
|
sc.add_argument("state", nargs="?", choices=("on", "off"), help="omit to show")
|
|
96
|
+
sc.add_argument("--at", help="time of day, e.g. 02:00 (saved as [learn] at; default 03:30)")
|
|
97
|
+
sc.add_argument("--every", choices=("day", "week"),
|
|
98
|
+
help="daily, or weekly on Sunday (saved as [learn] every)")
|
|
99
|
+
sc.add_argument("--auto-promote", action="store_true",
|
|
100
|
+
help="with on: switch to a fine-tune that wins, without asking")
|
|
91
101
|
sc.set_defaults(func=cmd_schedule)
|
|
92
102
|
|
|
103
|
+
st = lsub.add_parser("stop", help="Stop a scheduled learning run that is in progress")
|
|
104
|
+
st.set_defaults(func=cmd_stop)
|
|
105
|
+
|
|
93
106
|
pr = lsub.add_parser("promote", help="Use a trained model for dictation")
|
|
94
107
|
pr.add_argument("run", help="run name under learn/models (see `learn status`)")
|
|
95
108
|
pr.add_argument("--force", action="store_true",
|
|
@@ -182,8 +195,7 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
182
195
|
print(f"models {', '.join(runs)}")
|
|
183
196
|
active = _cfg().model.name
|
|
184
197
|
print(f"active {active}")
|
|
185
|
-
|
|
186
|
-
print(f"nightly {'on' if service.learn_timer_enabled() else 'off (voiceio learn schedule on)'}")
|
|
198
|
+
print(f"nightly {_schedule_state()}")
|
|
187
199
|
promotable = _print_scores(ds, active, runs) if ds else None
|
|
188
200
|
if promotable:
|
|
189
201
|
nxt = f"promote {promotable}"
|
|
@@ -197,9 +209,9 @@ def _print_scores(ds: Path, active: str, runs: list[str]) -> str | None:
|
|
|
197
209
|
"""Scores on `ds` for the active model and each fine-tune; returns the
|
|
198
210
|
run that beats the active model on everything, if any."""
|
|
199
211
|
from voiceio.learn import evaluate
|
|
200
|
-
current = evaluate.latest(active, ds.name)
|
|
212
|
+
current = evaluate.latest(active, ds.name, beam=_cfg().model.beam_size)
|
|
201
213
|
rows = [(active + " (active)", current)] + [
|
|
202
|
-
(run, evaluate.latest(str(_models_dir() / run / "ct2"), ds.name)) for run in runs]
|
|
214
|
+
(run, evaluate.latest(str(_models_dir() / run / "ct2"), ds.name, beam=_cfg().model.beam_size)) for run in runs]
|
|
203
215
|
if not any(r for _, r in rows):
|
|
204
216
|
return None
|
|
205
217
|
print(f"\nscores on {ds.name} test clips:")
|
|
@@ -430,7 +442,12 @@ def cmd_maintain(args: argparse.Namespace) -> None:
|
|
|
430
442
|
"""Label what's new; once enough has piled up, rebuild, retrain and say
|
|
431
443
|
whether the result beats the current model. Never promotes by itself."""
|
|
432
444
|
_yield_cpu()
|
|
433
|
-
from voiceio.learn import evaluate
|
|
445
|
+
from voiceio.learn import evaluate, quiet
|
|
446
|
+
learn_cfg = _cfg().learn
|
|
447
|
+
if getattr(args, "when_idle", False) and not quiet.wait_until_quiet(
|
|
448
|
+
learn_cfg.max_load, learn_cfg.wait_hours):
|
|
449
|
+
return
|
|
450
|
+
_announce_learning(args.min_new)
|
|
434
451
|
cmd_label(argparse.Namespace(teacher=None, limit=None, relabel=False))
|
|
435
452
|
from voiceio.learn import train
|
|
436
453
|
if missing := train.missing_train_deps():
|
|
@@ -442,8 +459,9 @@ def cmd_maintain(args: argparse.Namespace) -> None:
|
|
|
442
459
|
labeled = {c.audio for c in store.clips() if c.audio in labels}
|
|
443
460
|
ds = store.latest_dataset()
|
|
444
461
|
new = len(labeled - store.dataset_clips(ds)) if ds else len(labeled)
|
|
445
|
-
if
|
|
446
|
-
|
|
462
|
+
if new < args.min_new: # the first dataset too: a handful of clips trains nothing
|
|
463
|
+
since = f" since {ds.name}" if ds else ""
|
|
464
|
+
print(f"{new} new labeled clips{since}; retraining waits for {args.min_new}.")
|
|
447
465
|
return
|
|
448
466
|
ds = store.build_dataset()
|
|
449
467
|
print(f"built {ds.name} ({new} new clips)")
|
|
@@ -454,15 +472,101 @@ def cmd_maintain(args: argparse.Namespace) -> None:
|
|
|
454
472
|
print(f"\n{_cfg().model.name} → {run.name} on {ds.name}:\n{table}")
|
|
455
473
|
print(f"better or equal on everything — `voiceio learn promote {run.name}`" if ok
|
|
456
474
|
else "not better on everything — keeping the current model")
|
|
457
|
-
if ok:
|
|
458
|
-
|
|
475
|
+
if not ok:
|
|
476
|
+
return
|
|
477
|
+
# Often runs unattended at night: say so where it will be seen.
|
|
478
|
+
from voiceio import feedback
|
|
479
|
+
if getattr(args, "auto_promote", False):
|
|
480
|
+
try:
|
|
481
|
+
cmd_promote(argparse.Namespace(run=run.name, force=False))
|
|
482
|
+
except SystemExit as refused: # the gate's own re-check said no
|
|
483
|
+
print(f"not promoted: {refused}")
|
|
484
|
+
return
|
|
485
|
+
from voiceio import service
|
|
486
|
+
if service.is_installed() and not service.restart_when_idle():
|
|
487
|
+
feedback.notify("VoiceIO: a better model is ready",
|
|
488
|
+
f"Switched to {run.name}; restart voiceio to use it "
|
|
489
|
+
"(a dictation was in progress). Undo: voiceio learn rollback",
|
|
490
|
+
urgent=True)
|
|
491
|
+
return
|
|
492
|
+
feedback.notify("VoiceIO: switched to a better model",
|
|
493
|
+
f"Trained on your dictation: {run.name}. Undo: voiceio learn rollback",
|
|
494
|
+
urgent=True)
|
|
495
|
+
else:
|
|
459
496
|
feedback.notify("VoiceIO: a better model is ready",
|
|
460
497
|
f"Trained on your dictation. Use it: voiceio learn promote {run.name}",
|
|
461
498
|
urgent=True)
|
|
462
499
|
|
|
463
500
|
|
|
501
|
+
def _announce_learning(min_new: int) -> None:
|
|
502
|
+
"""Tell the user a run is starting (it takes a while and uses the CPU),
|
|
503
|
+
with buttons to stop it or to learn weekly instead. Silent when there is
|
|
504
|
+
nothing to do."""
|
|
505
|
+
labels = store.load_labels()
|
|
506
|
+
clips = [c.audio for c in store.clips()]
|
|
507
|
+
to_label = sum(1 for a in clips if a not in labels)
|
|
508
|
+
ds = store.latest_dataset()
|
|
509
|
+
new = len(set(clips) - (store.dataset_clips(ds) if ds else set()))
|
|
510
|
+
retrain = new >= min_new
|
|
511
|
+
if not to_label and not retrain:
|
|
512
|
+
return
|
|
513
|
+
from voiceio import feedback
|
|
514
|
+
what = (f"Labeling {to_label} new recordings" if to_label else "") + \
|
|
515
|
+
(", then retraining" if to_label and retrain else "Retraining" if retrain else "")
|
|
516
|
+
feedback.notify_actions(
|
|
517
|
+
"VoiceIO is learning your voice",
|
|
518
|
+
f"{what}, on this computer at idle priority; dictation stays fast.",
|
|
519
|
+
{"stop": "Stop for now", "weekly": "Weekly instead"},
|
|
520
|
+
_on_learning_action)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _on_learning_action(key: str) -> None:
|
|
524
|
+
import signal
|
|
525
|
+
|
|
526
|
+
from voiceio import config, feedback, service
|
|
527
|
+
if key == "weekly":
|
|
528
|
+
config.set_value("learn", "every", "week")
|
|
529
|
+
service.install_learn_timer(auto_promote=service.learn_timer_auto_promotes())
|
|
530
|
+
feedback.notify("VoiceIO: learning weekly now",
|
|
531
|
+
f"{service.learn_schedule_summary()}. This run continues; "
|
|
532
|
+
"change it with voiceio learn schedule on --every day")
|
|
533
|
+
elif key == "stop":
|
|
534
|
+
# Under the timer, stop the unit so training subprocesses go too.
|
|
535
|
+
if not (os.environ.get("INVOCATION_ID") and service.stop_learning()):
|
|
536
|
+
os.kill(os.getpid(), signal.SIGINT)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _schedule_state() -> str:
|
|
540
|
+
from voiceio import service
|
|
541
|
+
summary = service.learn_schedule_summary()
|
|
542
|
+
return "off (voiceio learn schedule on)" if summary == "off" else f"on, {summary}"
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def cmd_stop(args: argparse.Namespace) -> None:
|
|
546
|
+
from voiceio import service
|
|
547
|
+
if not service.learning_now():
|
|
548
|
+
print("no learning run in progress")
|
|
549
|
+
elif service.stop_learning():
|
|
550
|
+
print("stopped; it runs again at the next scheduled time")
|
|
551
|
+
else:
|
|
552
|
+
raise SystemExit("could not stop voiceio-learn.service")
|
|
553
|
+
|
|
554
|
+
|
|
464
555
|
def cmd_schedule(args: argparse.Namespace) -> None:
|
|
465
556
|
from voiceio import config, service
|
|
557
|
+
if args.at or args.every:
|
|
558
|
+
learn = config.load().learn
|
|
559
|
+
try: # validate before saving: the timer is generated from these
|
|
560
|
+
service.learn_calendar(args.at or learn.at, args.every or learn.every)
|
|
561
|
+
except ValueError as e:
|
|
562
|
+
raise SystemExit(str(e)) from None
|
|
563
|
+
for key in ("at", "every"):
|
|
564
|
+
if getattr(args, key):
|
|
565
|
+
config.set_value("learn", key, getattr(args, key))
|
|
566
|
+
if args.state is None and service.learn_timer_enabled(): # reschedule in place
|
|
567
|
+
service.install_learn_timer(auto_promote=service.learn_timer_auto_promotes())
|
|
568
|
+
print(f"on, {service.learn_schedule_summary()}")
|
|
569
|
+
return
|
|
466
570
|
if args.state == "on":
|
|
467
571
|
if not config.load().data.retain_audio:
|
|
468
572
|
print("note: [data] retain_audio is off, so there is nothing new to learn from;\n"
|
|
@@ -471,16 +575,21 @@ def cmd_schedule(args: argparse.Namespace) -> None:
|
|
|
471
575
|
if missing := train.missing_train_deps():
|
|
472
576
|
print(f"note: until {', '.join(missing)} are installed it only labels new clips;\n"
|
|
473
577
|
f" to also retrain: {train.TRAIN_HINT}")
|
|
474
|
-
if not service.install_learn_timer():
|
|
578
|
+
if not service.install_learn_timer(auto_promote=args.auto_promote):
|
|
475
579
|
raise SystemExit("could not enable the timer (needs systemd --user)")
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
"
|
|
580
|
+
learn = config.load().learn
|
|
581
|
+
print(f"on, {service.learn_schedule_summary()}: once the machine is quiet (load per CPU "
|
|
582
|
+
f"≤ {learn.max_load:g}, on mains power; waits up to {learn.wait_hours:g} h), "
|
|
583
|
+
"`voiceio learn maintain` labels new dictation at idle priority, retrains once "
|
|
584
|
+
"50+ clips are new, and "
|
|
585
|
+
+ ("switches to the result when it wins on your held-out clips "
|
|
586
|
+
"(undo: voiceio learn rollback)." if args.auto_promote else
|
|
587
|
+
"tells you if the result is better. It never switches models by itself."))
|
|
479
588
|
elif args.state == "off":
|
|
480
589
|
service.uninstall_learn_timer()
|
|
481
590
|
print("off")
|
|
482
591
|
else:
|
|
483
|
-
print(
|
|
592
|
+
print(_schedule_state())
|
|
484
593
|
|
|
485
594
|
|
|
486
595
|
def _scores(result) -> dict:
|
|
@@ -500,22 +609,44 @@ def cmd_promote(args: argparse.Namespace) -> None:
|
|
|
500
609
|
f.write(json.dumps({"ts": time.time(), "from": _cfg().model.name, "to": str(ct2)}) + "\n")
|
|
501
610
|
set_value("model", "name", str(ct2))
|
|
502
611
|
print(f"now dictating with {args.run} — restart the daemon: voiceio service restart")
|
|
612
|
+
_on_promote(str(ct2))
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _on_promote(model: str) -> None:
|
|
616
|
+
"""Run `[learn] on_promote` for a model switch. Its failure is reported,
|
|
617
|
+
never fatal: the switch on this machine already happened."""
|
|
618
|
+
import os
|
|
619
|
+
import subprocess
|
|
620
|
+
cmd = _cfg().learn.on_promote
|
|
621
|
+
if not cmd:
|
|
622
|
+
return
|
|
623
|
+
print(f"on_promote: {cmd}", flush=True)
|
|
624
|
+
try:
|
|
625
|
+
rc = subprocess.run(cmd, shell=True, env={**os.environ, "VOICEIO_MODEL": model},
|
|
626
|
+
timeout=3600).returncode
|
|
627
|
+
except subprocess.TimeoutExpired:
|
|
628
|
+
rc = "timeout"
|
|
629
|
+
if rc != 0:
|
|
630
|
+
print(f"on_promote failed ({rc})")
|
|
631
|
+
from voiceio import feedback
|
|
632
|
+
feedback.notify("VoiceIO: model switched here, but on_promote failed",
|
|
633
|
+
f"`{cmd}` exited {rc}; run it again by hand.")
|
|
503
634
|
|
|
504
635
|
|
|
505
636
|
def _check_beats_current(ct2: Path) -> None:
|
|
506
637
|
"""Refuse a promotion the evals don't support (a fine-tune can regress)."""
|
|
507
638
|
from voiceio.learn import evaluate
|
|
508
639
|
ds = store.latest_dataset()
|
|
509
|
-
current = _cfg().model.name
|
|
640
|
+
current, beam = _cfg().model.name, _cfg().model.beam_size
|
|
510
641
|
if ds is None:
|
|
511
642
|
raise SystemExit("no dataset to compare on — `voiceio learn build`, or --force")
|
|
512
|
-
missing = [m for m in (current, str(ct2)) if evaluate.latest(m, ds.name) is None]
|
|
643
|
+
missing = [m for m in (current, str(ct2)) if evaluate.latest(m, ds.name, beam=beam) is None]
|
|
513
644
|
if missing:
|
|
514
645
|
raise SystemExit("evaluate both models on the same clips first:\n"
|
|
515
646
|
+ "".join(f" voiceio learn eval --model {m}\n" for m in missing)
|
|
516
647
|
+ "(or promote anyway with --force)")
|
|
517
|
-
ok, table = evaluate.promotion_verdict(evaluate.latest(str(ct2), ds.name),
|
|
518
|
-
evaluate.latest(current, ds.name))
|
|
648
|
+
ok, table = evaluate.promotion_verdict(evaluate.latest(str(ct2), ds.name, beam=beam),
|
|
649
|
+
evaluate.latest(current, ds.name, beam=beam))
|
|
519
650
|
print(f"on {ds.name} test clips, {current} → fine-tune:\n{table}")
|
|
520
651
|
if not ok:
|
|
521
652
|
raise SystemExit("the fine-tune is worse on something — not promoting (--force overrides)")
|
|
@@ -531,3 +662,4 @@ def cmd_rollback(args: argparse.Namespace) -> None:
|
|
|
531
662
|
set_value("model", "name", last["from"])
|
|
532
663
|
history.write_text("".join(r + "\n" for r in rows[:-1]))
|
|
533
664
|
print(f"back to {last['from']} — restart the daemon: voiceio service restart")
|
|
665
|
+
_on_promote(last["from"])
|
voiceio/cli/servicecmd.py
CHANGED
|
@@ -85,23 +85,35 @@ def _cmd_service(args: argparse.Namespace) -> None:
|
|
|
85
85
|
pass
|
|
86
86
|
|
|
87
87
|
|
|
88
|
-
def wait_until_idle(timeout: float = 300.0, poll: float = 0.25
|
|
88
|
+
def wait_until_idle(timeout: float = 300.0, poll: float = 0.25,
|
|
89
|
+
finalizing_timeout: float = 60.0) -> bool:
|
|
89
90
|
"""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
|
+
mid-recording loses what was said). True once idle or not running; False
|
|
92
|
+
when it stayed busy. A decode stuck finalizing gets a shorter wait: a
|
|
93
|
+
restart is how that is recovered."""
|
|
91
94
|
import time
|
|
92
95
|
|
|
93
|
-
from voiceio.config import STATE_PATH
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
96
|
+
from voiceio.config import PID_PATH, STATE_PATH
|
|
97
|
+
from voiceio.pidlock import daemon_alive
|
|
98
|
+
start, told = time.monotonic(), False
|
|
99
|
+
try:
|
|
100
|
+
while True:
|
|
101
|
+
if not daemon_alive(PID_PATH):
|
|
102
|
+
return True # crashed or stopped: its state file is stale
|
|
103
|
+
try:
|
|
104
|
+
state = STATE_PATH.read_text().strip()
|
|
105
|
+
except OSError:
|
|
106
|
+
return True # pre-1.1 daemon: nothing to wait for
|
|
107
|
+
if state in ("idle", "error"):
|
|
108
|
+
return True
|
|
109
|
+
waited = time.monotonic() - start
|
|
110
|
+
if waited > (finalizing_timeout if state == "finalizing" else timeout):
|
|
111
|
+
print(f"Still {state}; going ahead. (--now skips the wait.)", file=sys.stderr)
|
|
112
|
+
return False
|
|
113
|
+
if not told:
|
|
114
|
+
print(f"Waiting for the dictation in progress to finish ({state})…", flush=True)
|
|
115
|
+
told = True
|
|
116
|
+
time.sleep(poll)
|
|
117
|
+
except KeyboardInterrupt:
|
|
118
|
+
print("\nNot waiting.", file=sys.stderr)
|
|
119
|
+
sys.exit(130)
|
voiceio/config.py
CHANGED
|
@@ -243,6 +243,23 @@ class DataConfig:
|
|
|
243
243
|
capture_intermediates: bool = False
|
|
244
244
|
|
|
245
245
|
|
|
246
|
+
@dataclass
|
|
247
|
+
class LearnConfig:
|
|
248
|
+
# A command run after every model switch (promote, auto-promote,
|
|
249
|
+
# rollback), with the model in $VOICEIO_MODEL — e.g. to push a fine-tune
|
|
250
|
+
# to another machine: scripts/push_model.sh HOST. Empty: nothing runs.
|
|
251
|
+
on_promote: str = ""
|
|
252
|
+
# When nightly learning runs (`voiceio learn schedule on`): a time of day,
|
|
253
|
+
# and "day" or "week" (weekly runs on Sunday).
|
|
254
|
+
at: str = "03:30"
|
|
255
|
+
every: str = "day"
|
|
256
|
+
# It only starts on a quiet machine on mains power: 1-minute load per CPU
|
|
257
|
+
# at most max_load. It waits up to wait_hours for that, then tries again
|
|
258
|
+
# at the next scheduled run.
|
|
259
|
+
max_load: float = 0.25
|
|
260
|
+
wait_hours: float = 3.0
|
|
261
|
+
|
|
262
|
+
|
|
246
263
|
@dataclass
|
|
247
264
|
class Config:
|
|
248
265
|
hotkey: HotkeyConfig = field(default_factory=HotkeyConfig)
|
|
@@ -260,6 +277,7 @@ class Config:
|
|
|
260
277
|
health: HealthConfig = field(default_factory=HealthConfig)
|
|
261
278
|
data: DataConfig = field(default_factory=DataConfig)
|
|
262
279
|
history: HistoryConfig = field(default_factory=HistoryConfig)
|
|
280
|
+
learn: LearnConfig = field(default_factory=LearnConfig)
|
|
263
281
|
|
|
264
282
|
|
|
265
283
|
_REMOVED_KEYS = {
|
|
@@ -340,6 +358,7 @@ def load(path: Path | None = None) -> Config:
|
|
|
340
358
|
health=_build(HealthConfig, raw.get("health", {})),
|
|
341
359
|
data=_build(DataConfig, raw.get("data", {})),
|
|
342
360
|
history=_build(HistoryConfig, raw.get("history", {})),
|
|
361
|
+
learn=_build(LearnConfig, raw.get("learn", {})),
|
|
343
362
|
)
|
|
344
363
|
|
|
345
364
|
|
voiceio/feedback.py
CHANGED
|
@@ -14,6 +14,7 @@ import shutil
|
|
|
14
14
|
import subprocess
|
|
15
15
|
import threading
|
|
16
16
|
import wave
|
|
17
|
+
from collections.abc import Callable
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
|
|
19
20
|
import numpy as np
|
|
@@ -151,6 +152,39 @@ def _send_notification_linux(title: str, body: str, urgent: bool = False) -> Non
|
|
|
151
152
|
pass
|
|
152
153
|
|
|
153
154
|
|
|
155
|
+
def notify_actions(title: str, body: str, actions: dict[str, str],
|
|
156
|
+
on_action: Callable[[str], None]) -> None:
|
|
157
|
+
"""A notification with buttons ({key: label}); ``on_action(key)`` runs
|
|
158
|
+
when one is clicked. Linux only; elsewhere, or with a notify-send too old
|
|
159
|
+
for actions, it degrades to a plain notification. Never blocks."""
|
|
160
|
+
import sys
|
|
161
|
+
if sys.platform != "linux" or not _which("notify-send") or not _notify_send_has_actions():
|
|
162
|
+
notify(title, body)
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
def wait() -> None:
|
|
166
|
+
args = ["notify-send", "--app-name=VoiceIO", "--wait",
|
|
167
|
+
*(f"--action={k}={label}" for k, label in actions.items()), title, body]
|
|
168
|
+
try:
|
|
169
|
+
picked = subprocess.run(args, capture_output=True, text=True).stdout.strip()
|
|
170
|
+
except OSError:
|
|
171
|
+
return
|
|
172
|
+
if picked in actions:
|
|
173
|
+
on_action(picked)
|
|
174
|
+
|
|
175
|
+
threading.Thread(target=wait, daemon=True).start()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@functools.lru_cache(maxsize=1)
|
|
179
|
+
def _notify_send_has_actions() -> bool:
|
|
180
|
+
"""--action/--wait arrived in libnotify 0.7.9."""
|
|
181
|
+
try:
|
|
182
|
+
return "--action" in subprocess.run(["notify-send", "--help"], capture_output=True,
|
|
183
|
+
text=True, timeout=3).stdout
|
|
184
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
|
|
154
188
|
def _send_notification_windows(title: str, body: str) -> None:
|
|
155
189
|
try:
|
|
156
190
|
from win11toast import notify
|
voiceio/health.py
CHANGED
|
@@ -247,7 +247,7 @@ def _learning_status(cfg) -> dict[str, str]:
|
|
|
247
247
|
out["Recordings"] = f"on ({n} kept to learn from)"
|
|
248
248
|
else:
|
|
249
249
|
out["Recordings"] = "off (nothing to learn from)"
|
|
250
|
-
out["
|
|
250
|
+
out["Scheduled learning"] = service.learn_schedule_summary()
|
|
251
251
|
return out
|
|
252
252
|
|
|
253
253
|
|
|
@@ -353,7 +353,7 @@ def _accuracy_tips(accuracy: dict[str, str]) -> list[str]:
|
|
|
353
353
|
tips.append("Add names/jargon to ~/.config/voiceio/vocabulary.txt")
|
|
354
354
|
if accuracy.get("Recordings", "").startswith("off"):
|
|
355
355
|
tips.append("voiceio config set data.retain_audio true — keep recordings so voiceio can learn your voice")
|
|
356
|
-
elif accuracy.get("
|
|
356
|
+
elif accuracy.get("Scheduled learning") == "off":
|
|
357
357
|
tips.append("voiceio learn schedule on — retrain on your voice every night")
|
|
358
358
|
return tips
|
|
359
359
|
|
voiceio/learn/evaluate.py
CHANGED
|
@@ -156,11 +156,11 @@ def save(result: EvalResult) -> Path:
|
|
|
156
156
|
return path
|
|
157
157
|
|
|
158
158
|
|
|
159
|
-
def latest(model: str, dataset: str, mode: str = "offline") -> dict | None:
|
|
160
|
-
"""The newest saved run of `model` (a faster-whisper name or path)
|
|
161
|
-
`dataset` scored by the current metrics, or None."""
|
|
159
|
+
def latest(model: str, dataset: str, mode: str = "offline", beam: int = 5) -> dict | None:
|
|
160
|
+
"""The newest saved run of `model` (a faster-whisper name or path) at
|
|
161
|
+
beam width `beam` on `dataset` scored by the current metrics, or None."""
|
|
162
162
|
runs = learn_dir() / "runs"
|
|
163
|
-
want = f"faster-whisper:{model}"
|
|
163
|
+
want = f"faster-whisper:{model}" + (f"@beam{beam}" if beam != 5 else "")
|
|
164
164
|
for path in sorted(runs.glob("eval-*.json"), reverse=True) if runs.exists() else []:
|
|
165
165
|
try:
|
|
166
166
|
data = json.loads(path.read_text())
|
|
@@ -173,6 +173,9 @@ def latest(model: str, dataset: str, mode: str = "offline") -> dict | None:
|
|
|
173
173
|
return None
|
|
174
174
|
|
|
175
175
|
|
|
176
|
+
# Fewer held-out clips than this can't tell a better model from luck.
|
|
177
|
+
MIN_EVAL_CLIPS = 5
|
|
178
|
+
|
|
176
179
|
# A fine-tune may trade a hair of WER for vocabulary; it may not regress either.
|
|
177
180
|
WER_SLACK = 0.002
|
|
178
181
|
|
|
@@ -196,7 +199,10 @@ def paired(candidate: dict, current: dict, *, n_boot: int = 2000) -> dict | None
|
|
|
196
199
|
|
|
197
200
|
|
|
198
201
|
def promotion_verdict(candidate: dict, current: dict) -> tuple[bool, str]:
|
|
199
|
-
"""Is `candidate` at least as good as `current` on the same clips?
|
|
202
|
+
"""Is `candidate` at least as good as `current` on the same clips?
|
|
203
|
+
|
|
204
|
+
No verdict without evidence: too few clips, different clips, or a clip
|
|
205
|
+
the candidate failed to decode at all (a crashed decode scores 0% WER)."""
|
|
200
206
|
rows = [("WER", current["wer"], candidate["wer"], "{:.1%}"),
|
|
201
207
|
("vocab recall", current["term_recall"], candidate["term_recall"], "{:.1%}"),
|
|
202
208
|
("loops", current["repetitions"], candidate["repetitions"], "{}")]
|
|
@@ -208,6 +214,10 @@ def promotion_verdict(candidate: dict, current: dict) -> tuple[bool, str]:
|
|
|
208
214
|
f"WER change {p['mean']:+.1%} (90% interval {p['lo']:+.1%} … {p['hi']:+.1%})")
|
|
209
215
|
if p["lo"] < 0 < p["hi"]:
|
|
210
216
|
table += "\n (the interval spans zero: the WER difference may be luck)"
|
|
217
|
+
clips = candidate.get("clips", 0)
|
|
218
|
+
if candidate.get("failures", 0) or clips != current.get("clips", 0) or clips < MIN_EVAL_CLIPS:
|
|
219
|
+
return False, table + (f"\n not enough evidence: {clips} clips "
|
|
220
|
+
f"({candidate.get('failures', 0)} failed) vs {current.get('clips', 0)}")
|
|
211
221
|
ok = (candidate["wer"] <= current["wer"] + WER_SLACK
|
|
212
222
|
and candidate["term_recall"] >= current["term_recall"]
|
|
213
223
|
and candidate["repetitions"] <= current["repetitions"])
|
voiceio/learn/quiet.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Is the machine quiet enough for background learning?
|
|
2
|
+
|
|
3
|
+
Nightly learning is CPU-heavy for an hour or more. It runs at idle priority
|
|
4
|
+
regardless, but it should not even start while the machine is busy or on
|
|
5
|
+
battery: a laptop running on battery at 3am would just drain.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
POWER_SUPPLY = Path("/sys/class/power_supply")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def on_battery(root: Path = POWER_SUPPLY) -> bool:
|
|
18
|
+
"""True when the machine has mains supplies and none is online."""
|
|
19
|
+
mains = []
|
|
20
|
+
for supply in root.glob("*"):
|
|
21
|
+
try:
|
|
22
|
+
if (supply / "type").read_text().strip() == "Mains":
|
|
23
|
+
mains.append((supply / "online").read_text().strip() == "1")
|
|
24
|
+
except OSError:
|
|
25
|
+
continue
|
|
26
|
+
return bool(mains) and not any(mains)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def busy_reason(max_load: float) -> str | None:
|
|
30
|
+
"""Why learning should wait right now, or None when the machine is quiet."""
|
|
31
|
+
load = os.getloadavg()[0] / (os.cpu_count() or 1)
|
|
32
|
+
if load > max_load:
|
|
33
|
+
return f"busy (load {load:.2f} per CPU, waiting for {max_load:g})"
|
|
34
|
+
if on_battery():
|
|
35
|
+
return "on battery"
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def wait_until_quiet(max_load: float, wait_hours: float, *, poll: float = 300,
|
|
40
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
41
|
+
clock: Callable[[], float] = time.monotonic,
|
|
42
|
+
say: Callable[[str], None] = print) -> bool:
|
|
43
|
+
"""Block until the machine is quiet. False when it stayed busy for wait_hours."""
|
|
44
|
+
deadline = clock() + wait_hours * 3600
|
|
45
|
+
last = None
|
|
46
|
+
while (reason := busy_reason(max_load)) is not None:
|
|
47
|
+
if reason != last:
|
|
48
|
+
say(f"waiting: {reason}")
|
|
49
|
+
last = reason
|
|
50
|
+
if clock() + poll > deadline:
|
|
51
|
+
say(f"still {reason} after {wait_hours:g} h; trying again at the next scheduled run")
|
|
52
|
+
return False
|
|
53
|
+
sleep(poll)
|
|
54
|
+
return True
|
voiceio/learn/review.py
CHANGED
|
@@ -26,8 +26,10 @@ class Item:
|
|
|
26
26
|
reason: str
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def _contains(seg_words: list[str], live_words: list[str]
|
|
30
|
-
|
|
29
|
+
def _contains(seg_words: list[str], live_words: list[str],
|
|
30
|
+
tolerance: float = _DISAGREE_WER) -> bool:
|
|
31
|
+
"""Whether the segment's words occur (nearly, within `tolerance` of its
|
|
32
|
+
words) contiguously in the live text."""
|
|
31
33
|
n = len(seg_words)
|
|
32
34
|
if not n:
|
|
33
35
|
return True
|
|
@@ -36,7 +38,7 @@ def _contains(seg_words: list[str], live_words: list[str]) -> bool:
|
|
|
36
38
|
window = live_words[i:i + n]
|
|
37
39
|
match = sum(a == b for a, b in zip(seg_words, window)) / n
|
|
38
40
|
best = max(best, match)
|
|
39
|
-
if best >= 1 -
|
|
41
|
+
if best >= 1 - tolerance:
|
|
40
42
|
return True
|
|
41
43
|
return False
|
|
42
44
|
|
|
@@ -84,12 +86,31 @@ def live_span(segment_text: str, live: str) -> str | None:
|
|
|
84
86
|
|
|
85
87
|
|
|
86
88
|
def queue_all(clips: list[Clip], labels: dict[str, Label]) -> list[Item]:
|
|
87
|
-
"""
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
"""Segments to check for a gold test set, cheapest clips first.
|
|
90
|
+
|
|
91
|
+
A reference is only trustworthy if all of it was checked — but where the
|
|
92
|
+
teacher and the live decode agree word for word, two independent models
|
|
93
|
+
already did; those segments are kept as they are, and only the rest are
|
|
94
|
+
asked. Clips needing the fewest answers come first, so each minute of
|
|
95
|
+
review completes as many gold clips as possible. A clip with no live
|
|
96
|
+
decode (an import) has every segment asked; one where everything agrees
|
|
97
|
+
asks nothing and stays a teacher label.
|
|
98
|
+
"""
|
|
99
|
+
per_clip: list[list[Item]] = []
|
|
100
|
+
for clip in clips:
|
|
101
|
+
lab = labels.get(clip.audio)
|
|
102
|
+
if lab is None or lab.reviewed:
|
|
103
|
+
continue
|
|
104
|
+
live = metrics.words(clip.raw)
|
|
105
|
+
items = [Item(clip, lab, i, "test set: teacher and live disagree" if live else "test set")
|
|
106
|
+
for i, seg in enumerate(lab.segments)
|
|
107
|
+
# Word for word: a gold reference can't skip "kalchi" vs
|
|
108
|
+
# "Kalshi" in an otherwise matching sentence.
|
|
109
|
+
if not live or not _contains(metrics.words(seg.text), live, tolerance=0.0)]
|
|
110
|
+
if items:
|
|
111
|
+
per_clip.append(items)
|
|
112
|
+
per_clip.sort(key=len)
|
|
113
|
+
return [it for items in per_clip for it in items]
|
|
93
114
|
|
|
94
115
|
|
|
95
116
|
def run(items: list[Item], *, ask: Callable[[Item], str | None],
|
voiceio/pidlock.py
CHANGED
|
@@ -20,3 +20,21 @@ def lock_pid_file(fd) -> None:
|
|
|
20
20
|
import fcntl
|
|
21
21
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
22
22
|
log.debug("PID lock acquired (fcntl)")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def daemon_alive(path) -> bool:
|
|
26
|
+
"""Whether a voiceio daemon holds the PID lock at `path` (probes without
|
|
27
|
+
keeping the lock, and without truncating the file)."""
|
|
28
|
+
if sys.platform == "win32":
|
|
29
|
+
return True # no cheap probe; callers then trust their other signal
|
|
30
|
+
import fcntl
|
|
31
|
+
try:
|
|
32
|
+
with open(path, "a") as fd:
|
|
33
|
+
try:
|
|
34
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
35
|
+
except BlockingIOError:
|
|
36
|
+
return True
|
|
37
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
38
|
+
return False
|
|
39
|
+
except OSError:
|
|
40
|
+
return False
|
voiceio/service.py
CHANGED
|
@@ -86,8 +86,25 @@ WantedBy=graphical-session.target
|
|
|
86
86
|
"""
|
|
87
87
|
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
LEARN_EVERY = {"day": "*-*-*", "week": "Sun *-*-*"}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def learn_calendar(at: str, every: str) -> str:
|
|
93
|
+
"""systemd OnCalendar for `[learn] at` / `every`; ValueError when invalid."""
|
|
94
|
+
import re
|
|
95
|
+
if every not in LEARN_EVERY:
|
|
96
|
+
raise ValueError(f"every must be one of {', '.join(LEARN_EVERY)}, not {every!r}")
|
|
97
|
+
m = re.fullmatch(r"([01]?\d|2[0-3]):([0-5]\d)", at.strip())
|
|
98
|
+
if not m:
|
|
99
|
+
raise ValueError(f"at must be a time like 03:30, not {at!r}")
|
|
100
|
+
return f"{LEARN_EVERY[every]} {int(m[1]):02d}:{m[2]}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _learn_units(bin_path: str, auto_promote: bool = False,
|
|
104
|
+
calendar: str = "*-*-* 03:30") -> tuple[str, str]:
|
|
105
|
+
"""(service, timer) for scheduled learning on this machine."""
|
|
106
|
+
# --when-idle: wait for a quiet machine on mains power before starting.
|
|
107
|
+
flags = " --when-idle" + (" --auto-promote" if auto_promote else "")
|
|
91
108
|
service = f"""\
|
|
92
109
|
[Unit]
|
|
93
110
|
Description=VoiceIO - learn from today's dictation (label, retrain when enough is new)
|
|
@@ -95,7 +112,7 @@ Documentation=https://github.com/Hugo0/voiceio#make-it-yours
|
|
|
95
112
|
|
|
96
113
|
[Service]
|
|
97
114
|
Type=oneshot
|
|
98
|
-
ExecStart={bin_path} learn maintain
|
|
115
|
+
ExecStart={bin_path} learn maintain{flags}
|
|
99
116
|
# Background work: it gets the CPU only when nothing else wants it, so
|
|
100
117
|
# dictation and everything else stay fast. The unit's own cgroup already
|
|
101
118
|
# does what `learn` would otherwise re-exec into a scope for.
|
|
@@ -104,14 +121,14 @@ IOWeight=10
|
|
|
104
121
|
Nice=19
|
|
105
122
|
Environment=VOICEIO_IDLE_SCOPE=1 PYTHONUNBUFFERED=1
|
|
106
123
|
"""
|
|
107
|
-
timer = """\
|
|
124
|
+
timer = f"""\
|
|
108
125
|
[Unit]
|
|
109
|
-
Description=VoiceIO -
|
|
126
|
+
Description=VoiceIO - scheduled learning
|
|
110
127
|
|
|
111
128
|
[Timer]
|
|
112
|
-
OnCalendar
|
|
113
|
-
RandomizedDelaySec=
|
|
114
|
-
# A laptop that was asleep at
|
|
129
|
+
OnCalendar={calendar}
|
|
130
|
+
RandomizedDelaySec=15min
|
|
131
|
+
# A laptop that was asleep at that time catches up when it next runs.
|
|
115
132
|
Persistent=true
|
|
116
133
|
|
|
117
134
|
[Install]
|
|
@@ -128,11 +145,15 @@ def _systemctl(*args: str) -> bool:
|
|
|
128
145
|
return False
|
|
129
146
|
|
|
130
147
|
|
|
131
|
-
def install_learn_timer() -> bool:
|
|
132
|
-
"""Run `voiceio learn maintain`
|
|
148
|
+
def install_learn_timer(auto_promote: bool = False) -> bool:
|
|
149
|
+
"""Run `voiceio learn maintain` on the `[learn]` schedule. True when the
|
|
150
|
+
timer is enabled."""
|
|
133
151
|
if _IS_WINDOWS or not has_systemd():
|
|
134
152
|
return False
|
|
135
|
-
|
|
153
|
+
from voiceio import config
|
|
154
|
+
learn = config.load().learn
|
|
155
|
+
service, timer = _learn_units(_find_voiceio_bin(), auto_promote,
|
|
156
|
+
learn_calendar(learn.at, learn.every))
|
|
136
157
|
SERVICE_DIR.mkdir(parents=True, exist_ok=True)
|
|
137
158
|
LEARN_SERVICE_PATH.write_text(service)
|
|
138
159
|
LEARN_TIMER_PATH.write_text(timer)
|
|
@@ -151,6 +172,39 @@ def learn_timer_enabled() -> bool:
|
|
|
151
172
|
return LEARN_TIMER_PATH.exists() and _systemctl("is-enabled", "--quiet", LEARN_TIMER_NAME)
|
|
152
173
|
|
|
153
174
|
|
|
175
|
+
def learning_now() -> bool:
|
|
176
|
+
return _systemctl("is-active", "--quiet", LEARN_SERVICE_NAME)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def stop_learning() -> bool:
|
|
180
|
+
"""Stop a scheduled learning run, training included (its whole cgroup)."""
|
|
181
|
+
return _systemctl("stop", LEARN_SERVICE_NAME)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def learn_schedule_summary() -> str:
|
|
185
|
+
"""'off', or e.g. 'daily at 03:30 (auto-promote)'."""
|
|
186
|
+
if not learn_timer_enabled():
|
|
187
|
+
return "off"
|
|
188
|
+
from voiceio import config
|
|
189
|
+
learn = config.load().learn
|
|
190
|
+
when = ("weekly on Sunday" if learn.every == "week" else "daily") + f" at {learn.at}"
|
|
191
|
+
return when + (" (auto-promote)" if learn_timer_auto_promotes() else "")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def learn_timer_auto_promotes() -> bool:
|
|
195
|
+
try:
|
|
196
|
+
return "--auto-promote" in LEARN_SERVICE_PATH.read_text()
|
|
197
|
+
except OSError:
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def restart_when_idle() -> bool:
|
|
202
|
+
"""Restart the daemon once no dictation is in progress. False when it
|
|
203
|
+
stayed busy (never cut a dictation off) or the restart failed."""
|
|
204
|
+
from voiceio.cli.servicecmd import wait_until_idle
|
|
205
|
+
return wait_until_idle() and _systemctl("restart", SERVICE_NAME)
|
|
206
|
+
|
|
207
|
+
|
|
154
208
|
def uninstall_correct_timer() -> None:
|
|
155
209
|
"""Remove the pre-1.0 weekly cloud-mining timer (`correct --auto --batch`).
|
|
156
210
|
It is gone in 1.0: mining moved to the local, opt-in `voiceio learn`."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|