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.
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 1.2.0
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://github.com/Hugo0/voiceio
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
  [![Python](https://img.shields.io/pypi/pyversions/python-voiceio)](https://pypi.org/project/python-voiceio/)
59
59
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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 # once: verify the held-out clips, so eval scores against the truth
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` every night, and notify you when a model wins
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.0.dist-info/licenses/LICENSE,sha256=Gz61o8jFTAvZUZyB3nWDB3DQQVuipjfPkVu9W8hBHM0,1072
2
- voiceio/__init__.py,sha256=MpAT5hgNoHnTtG1XRD_GV_A7QrHVU6vJjGSw_8qMGA4,22
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=_FfrMN1MFWk8H5KciGu2niJm3fZJlNR2Ju9mP9s5brk,31798
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=2QrttnGz0Cc7fLrlmhLA0-Nfvz1jJv9ea4kfY6icOEI,19522
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=i1Hkd_bydOOQ8p19Bz4bYougjQtozKGLjfKc5DKRDT0,5711
15
- voiceio/health.py,sha256=r0nMDozuhk1nZNpCiVhmezC_7P6ElQpaBB_IomzmXF8,22166
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=RInlF_8H7wR0nqkdlhUfxYs7OGzHIvb5p-XDjjQSTNg,619
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=_cTb1AQ57cF8-eW3SKvDkCP46S5yJqhty1fe_eI8zbQ,14324
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=cQ3HOf0LXybRf-40QA6Jvb54gy7rpCCz_2xavPJE7wk,24859
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=hcaQD1NL38fvkrHtId1307MIgyi2O2xBP6YvG8CTngc,4219
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=_a1sIum_T2f5lEVJ9-4VjgXOwmLh-B8R1PI7C7zmWH8,12600
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/review.py,sha256=p11kRY-PF9YhS7aYjcYpDUJavUn8OXXz4d6S-MGfCGE,4575
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.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,,
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.2.0"
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` every night (systemd timer)")
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
- from voiceio import service
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 ds is not None and new < args.min_new:
446
- print(f"{new} new labeled clips since {ds.name}; retraining waits for {args.min_new}.")
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: # often runs unattended at night: say so where it will be seen
458
- from voiceio import feedback
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
- 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.")
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("on" if service.learn_timer_enabled() else "off — enable: voiceio learn schedule on")
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) -> bool:
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
- 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
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["Nightly learning"] = "on" if service.learn_timer_enabled() else "off"
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("Nightly learning") == "off":
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) on
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]) -> bool:
30
- """Whether the segment's words occur (nearly) contiguously in the live text."""
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 - _DISAGREE_WER:
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
- """Every segment of every unreviewed clip — for building a gold test set,
88
- where a reference is only trustworthy if all of it was checked."""
89
- return [Item(clip, labels[clip.audio], i, "test set")
90
- for clip in clips
91
- if clip.audio in labels and not labels[clip.audio].reviewed
92
- for i in range(len(labels[clip.audio].segments))]
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
- def _learn_units(bin_path: str) -> tuple[str, str]:
90
- """(service, timer) for nightly learning on this machine."""
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 - nightly learning
126
+ Description=VoiceIO - scheduled learning
110
127
 
111
128
  [Timer]
112
- OnCalendar=*-*-* 03:30
113
- RandomizedDelaySec=30min
114
- # A laptop that was asleep at 03:30 catches up when it next runs.
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` nightly. True when the timer is enabled."""
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
- service, timer = _learn_units(_find_voiceio_bin())
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`."""