python-voiceio 1.9.2__py3-none-any.whl → 1.9.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 1.9.2
3
+ Version: 1.9.3
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -98,7 +98,7 @@ voiceio learn schedule on # learn from your recordings while the mac
98
98
  voiceio learn status # what it learned and how each model scores
99
99
  ```
100
100
 
101
- Scheduled learning runs daily or weekly at a time you pick, only on an idle machine on mains power, with a notification you can stop. It never switches models unless the new one wins, and `voiceio learn rollback` undoes a switch. Details: [docs/learning.md](docs/learning.md).
101
+ Scheduled learning runs daily or weekly at a time you pick, on mains power and only while you're away from the computer: it stops within seconds when you come back and resumes later where it was. You get a notification with a button to stop it. It never switches models unless the new one wins, and `voiceio learn rollback` undoes a switch. Details: [docs/learning.md](docs/learning.md).
102
102
 
103
103
  ## Configure
104
104
 
@@ -1,12 +1,12 @@
1
- python_voiceio-1.9.2.dist-info/licenses/LICENSE,sha256=Gz61o8jFTAvZUZyB3nWDB3DQQVuipjfPkVu9W8hBHM0,1072
2
- voiceio/__init__.py,sha256=FTcek0yfdI0OEbhOiGIFqV7WG2mj3gjw9X-8TnopjBM,22
1
+ python_voiceio-1.9.3.dist-info/licenses/LICENSE,sha256=Gz61o8jFTAvZUZyB3nWDB3DQQVuipjfPkVu9W8hBHM0,1072
2
+ voiceio/__init__.py,sha256=ZYlCJvbc_dIjUJ4oc5xuJBjjOfmrYgkggw_9VNQvlvY,22
3
3
  voiceio/__main__.py,sha256=xT5QCGGreYMisHO7Lh_Y-xAQ2TOzG2D7npKknGNcWSY,53
4
- voiceio/app.py,sha256=5tqDibXeok_psz9ewKOD47ka6ErR_Y4LD3jpOd2zZMw,43650
4
+ voiceio/app.py,sha256=9xOLShYwBKq0M0WwBwM3LlYDii1LOOb1Pov5tvlqVlo,45140
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=7f5aX644Fiy2S6MXxADfSgRtvO3P1U-r_VkzEgWrwKQ,5376
8
8
  voiceio/commands.py,sha256=Jz-txXSw32Adf8B912M8CzQIh4UMmEKRCZBPXU4bBaU,6014
9
- voiceio/config.py,sha256=10TQpa-lo3TmHIpXScYpUr3AZUZ9tOsyc1B-T7hXnnk,22968
9
+ voiceio/config.py,sha256=bA-1sNUHUL1bHXAuxfyON1g2eSJHnxSApAaAJZ2_Y0M,23302
10
10
  voiceio/consent.py,sha256=3stv3iZvYl_Dt4GUe-GUVOHEGi-2fOl-QqF3pDjraS4,1862
11
11
  voiceio/context_terms.py,sha256=rbVzEoqahroWdkYqiOmn4PJVsl6ftk_PDa-lXdoTBvA,11747
12
12
  voiceio/corrections.py,sha256=IIRViwjL9kkMy26-1dd0DtTfT0Tiedn6pmmHoaeayMQ,10047
@@ -26,12 +26,13 @@ voiceio/platform.py,sha256=uG_5KeYey_29GCn4nSOQJ1UpoCrBC_m2DR7IeoRnuL4,19142
26
26
  voiceio/postcorrect.py,sha256=JN5l9xnyGfYq2_owSUwkJ513QiQQKxCrT3hjU5aJCpg,17322
27
27
  voiceio/postprocess.py,sha256=9vpth8u2xOIKU6SimY-hsEDrs4X9YJSI67ZO_f2PpxM,6048
28
28
  voiceio/power.py,sha256=mKiG5ioHbzgtW2S-OKGFOPzZFbG0HwUUyMn8kE5JRoQ,681
29
+ voiceio/presence.py,sha256=2zzhfDWdQHa7hCX_XB-tNxt0uybm6KYc8l9FYLYsCWo,6651
29
30
  voiceio/privacy.py,sha256=FqnA9jx9RCqWW-i6Dh7JcT7vzz97kbUGyQEvsHLvpqc,573
30
31
  voiceio/prompt.py,sha256=9iza8KoQxvO_U9NPMJ8Ep7j-zkIs9nW8N5sacCRpsEk,2904
31
32
  voiceio/quiet_audio.py,sha256=AapeuhrJwR20fsl-Ksgv4qpptrcQezk6A2_i4Kr18Ks,11451
32
- voiceio/recorder.py,sha256=E3YwZecJqd06GGoRSjFi2Q-7GoRzBkAMBGspKVkF9rc,16746
33
+ voiceio/recorder.py,sha256=B_db2kijunZCIAH6cqmEFB5Zuvt3W1e8BCCaQg2EsZM,18469
33
34
  voiceio/retention.py,sha256=5ydw2dJ2gTQ39BYdg3Bk4mk-KNJkJcGUKzssndYwdY8,6137
34
- voiceio/service.py,sha256=q4_Mk8m3KCG14CGZ468ZrFvNvyXuWSOjKFUjRnksgAI,16391
35
+ voiceio/service.py,sha256=0dB5vWBOhmW6fl3CZzVfHUK2zkPQYLzfSXuvaesrKfc,17402
35
36
  voiceio/streaming.py,sha256=PZoyAUVDjzdZsqQy3_XXDLdbDk7GeRHXY47hLHxSKVA,27970
36
37
  voiceio/suggest.py,sha256=jX4bCpnddyrteKfRBPbnUgf-Rk5145LFaSTat0_Ll2E,4836
37
38
  voiceio/tokens.py,sha256=y9KBDgLukUMieO576rgyBn8qEQqElra66spQwoWXDXk,6321
@@ -46,7 +47,7 @@ voiceio/cli/configcmd.py,sha256=YF7t-LkwEYEGH4qcO86xehWVX0LvltqxjDFpMF5ljjA,2858
46
47
  voiceio/cli/corrections.py,sha256=OBjgiOhtWlTQpQq0HLAvk5-EfKJ7NNt-hnNBHm7obEc,4247
47
48
  voiceio/cli/doctor.py,sha256=P8bOn4xedMistlKHhNeeGxRsmogV6jBVKc-CUbo0LXs,7769
48
49
  voiceio/cli/history.py,sha256=7FvEEgdw8jf5XXvC7-4S7iIpcjBbn7dx4czIijg61C0,2597
49
- voiceio/cli/learn.py,sha256=ajVP7RRFBLXa0Sf-pbYrccPpuB58oHjd4wR9JkcOwtw,32914
50
+ voiceio/cli/learn.py,sha256=DrOenyjT9fqdWgBd8vWErkFLIKdlWb4SCmky2D_FxEs,36955
50
51
  voiceio/cli/models.py,sha256=fye_mHbsXLY11uRC1BCDlucCHPny9ftoehAeUJPMJv0,4263
51
52
  voiceio/cli/servicecmd.py,sha256=2St2WfLkt06EpC51I5i9Ykv_pvLKzu_dz01M-29O3-4,4830
52
53
  voiceio/cli/uninstall.py,sha256=Vqnq6-4hjhDrxqkU_1wtXkY5BU_Tqa-TjZA0Qpf12e4,8921
@@ -80,14 +81,14 @@ voiceio/ibus/textlimit.py,sha256=SJ5aSBYgMsCdg1SEjw6vJv03pLCqPcSeQjhSloa32R8,317
80
81
  voiceio/learn/__init__.py,sha256=Q98-C809lkMqTS0OAV1xNVtqHz9NvuekivXiHiVHBWA,750
81
82
  voiceio/learn/augment.py,sha256=qyegRlg3cQF5CT0HV85Jm93Jd9xZpjWwbFVk_VzFdiI,4105
82
83
  voiceio/learn/clean.py,sha256=1ItIfIAsqxZLZ76T4oF7kSr4Poo-z_6GEzhrlt21uMo,4922
83
- voiceio/learn/evaluate.py,sha256=sW2lfV9jK22fpFFgxh9p6cp0pcTt5KjXV3eatsrg9sM,13233
84
+ voiceio/learn/evaluate.py,sha256=FwWI7A6vvKZu7u1Ex4RxQ9eV_GB6Butt61eYW_SL308,13355
84
85
  voiceio/learn/importer.py,sha256=uB-gHWwSewZYTx22j9h941ARtDE8BFHmf8p3HaHUC6g,3544
85
86
  voiceio/learn/metrics.py,sha256=b2xWUzPpNpXHn6zgS1upPbEcpW3ExH9SmSLIUzPhStQ,5174
86
- voiceio/learn/quiet.py,sha256=5Ov3mvE1G3CjhwFQOuJvGeju242zFASi29tketnUyew,1523
87
+ voiceio/learn/quiet.py,sha256=mJsJRZH0KUhkVQ-Hm95n8SiuEdKOIE16pt-YU44jXDg,7083
87
88
  voiceio/learn/review.py,sha256=X5qs5yEf20Ao3gMhsbW3Eur62hSqy9AYlv08Pcrllxo,5572
88
- voiceio/learn/store.py,sha256=MauWhGaI2drMbiChIZOblpPS_uVAw-1sNWGyGACHYw8,12816
89
- voiceio/learn/teacher.py,sha256=FhCv08Js3chs4Vbh0VN3ZI9p05HFxeeeFJF8jTjjzPQ,3751
90
- voiceio/learn/train.py,sha256=FtZMa4ddsPMRUxkyqC-Qf9OveZmgRMHiGXx5WHj_jT8,18214
89
+ voiceio/learn/store.py,sha256=_QXOmc6Qy_33o9Fh5P2qSf8V2I84haGZRlAa6RrNjXM,13090
90
+ voiceio/learn/teacher.py,sha256=ZCQTklZFfgS_JywMA7lopjpAmaRsGPFnu0xIATOCobc,3778
91
+ voiceio/learn/train.py,sha256=ad0qk_kqjTfcl_u5UWbe7E3mVCI-35NuNt-MI69U-vg,26472
91
92
  voiceio/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
92
93
  voiceio/models/silero_vad.onnx,sha256=GhU6IvRQnikqlOZ9b5uF6N6yW0mIaCt-F0xlJ52HiOM,2327524
93
94
  voiceio/server/__init__.py,sha256=ybYb7CWgyMUhB-8AKypIWyMwCg17D3iNOaWOkqUd_8s,1751
@@ -101,7 +102,7 @@ voiceio/setup/extras.py,sha256=NGk_uAWl4GQAPwRfw_0nEPsPvma7ics9DnZqjoHgCUY,9685
101
102
  voiceio/setup/hotkey.py,sha256=xlDp2vWzsF73wbpvPSrwaqgjtiWnv294nzjlvHy0Ets,6473
102
103
  voiceio/setup/noninteractive.py,sha256=PF5dG6aOOYxdknbRGZD32xlv_kV2Mg8BRf_TfYI5bpY,10522
103
104
  voiceio/setup/speech.py,sha256=DahGBybPzkNryLjxtCkxojrUhECBksox2RKSwGr7a9I,7688
104
- voiceio/setup/steps.py,sha256=IXzP4zaeZg7EUAPNFnp1VQSbEyeW8PsKNJ6hpgfDSew,27688
105
+ voiceio/setup/steps.py,sha256=N7E7SHTESWQWBppaFyz_6UF_5UFplcKx0EjK1p4bp1I,27695
105
106
  voiceio/setup/system.py,sha256=Cb1BJGCgGbZFAfqrS8eXRW2AKW7jNLq82DWWg4_fdAs,6019
106
107
  voiceio/setup/ui.py,sha256=h1gmerT_8EfWftSONUn5K5Ri19elNTRP2lwFAuxsGPc,13401
107
108
  voiceio/sounds/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -133,8 +134,8 @@ voiceio/typers/pynput_type.py,sha256=f3w131YWJfNGd4IAcWtuKd2Gcwu3467QwGW3OBUEXNI
133
134
  voiceio/typers/wtype.py,sha256=11vOAKHjMat-fITd4sUoUWwgniyVp65EbH3i4mL6Vhg,1762
134
135
  voiceio/typers/xdotool.py,sha256=OuT_zW_z07jSAqu9Cmn_gL1mJfQsiX62ecqFRvpAarQ,1332
135
136
  voiceio/typers/ydotool.py,sha256=oY5EXB-nI7PvxTL_5Tb3JjfvyhmLNYNnUFvkb1XUn_U,5978
136
- python_voiceio-1.9.2.dist-info/METADATA,sha256=WXmKdOKMoP9EdCj4LE6yGCHIw3U9Vl-3p8HzZLiDn40,7519
137
- python_voiceio-1.9.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
138
- python_voiceio-1.9.2.dist-info/entry_points.txt,sha256=iDIDmhvQ3xeN_CWsFne1t9STZ08I25I_wp0xmde_vMs,94
139
- python_voiceio-1.9.2.dist-info/top_level.txt,sha256=piwtn309lD6uexQyXdZ-efAVBJF9y6Wfr48Z-8zkNhg,8
140
- python_voiceio-1.9.2.dist-info/RECORD,,
137
+ python_voiceio-1.9.3.dist-info/METADATA,sha256=jjNupEWDzxHL9ibmedy156_z8OqpNks44UUMOZpNuV8,7630
138
+ python_voiceio-1.9.3.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
139
+ python_voiceio-1.9.3.dist-info/entry_points.txt,sha256=iDIDmhvQ3xeN_CWsFne1t9STZ08I25I_wp0xmde_vMs,94
140
+ python_voiceio-1.9.3.dist-info/top_level.txt,sha256=piwtn309lD6uexQyXdZ-efAVBJF9y6Wfr48Z-8zkNhg,8
141
+ python_voiceio-1.9.3.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (84.0.0)
2
+ Generator: setuptools (84.2.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
voiceio/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "1.9.2"
1
+ __version__ = "1.9.3"
voiceio/app.py CHANGED
@@ -359,6 +359,7 @@ class VoiceIO:
359
359
  rid = self._recording
360
360
  self._quiet.stop(then=lambda: self._play_record_cue(start=False))
361
361
  self.recorder.set_on_auto_stop(None)
362
+ self.recorder.set_on_dead_stream(None)
362
363
  self.recorder.stop() # the audio is dropped here
363
364
  tray.set_recording(False)
364
365
  session, self._session = self._session, None
@@ -415,6 +416,35 @@ class VoiceIO:
415
416
  except Exception:
416
417
  log.exception("paste-last failed")
417
418
 
419
+ def _revive_mic(self, rid: int) -> None:
420
+ """The mic stream died mid-recording (real audio, then exact zeros):
421
+ reopen it and keep recording. Once it fails to come back, say so —
422
+ recording a dead stream to the end lost a 104 s dictation after a
423
+ suspend/resume."""
424
+ with self._hotkey_lock:
425
+ if self._state != _State.RECORDING or self._recording != rid:
426
+ return
427
+ log.warning("Mic stream went silent mid-recording (exact zeros) — reopening")
428
+ try:
429
+ self.recorder.revive_stream()
430
+ except Exception:
431
+ log.exception("Mic reopen failed")
432
+ time.sleep(1.5)
433
+ with self._hotkey_lock:
434
+ if self._state != _State.RECORDING or self._recording != rid:
435
+ return
436
+ dead = self.recorder.silent_signal_secs >= 1.0
437
+ if dead:
438
+ log.warning("Mic still silent after reopening")
439
+ from voiceio import feedback
440
+ feedback.notify(
441
+ "VoiceIO: microphone stopped sending audio",
442
+ "Your words so far are kept. Stop, check the input device, and try again.",
443
+ urgent=True,
444
+ )
445
+ else:
446
+ log.info("Mic stream recovered mid-recording")
447
+
418
448
  def _on_auto_stop(self, reason: str = "silence") -> None:
419
449
  """Called from the audio thread when the recorder auto-stops.
420
450
 
@@ -570,6 +600,8 @@ class VoiceIO:
570
600
  self._record_start = time.monotonic()
571
601
  self.recorder.start()
572
602
  self.recorder.set_on_auto_stop(self._on_auto_stop)
603
+ rid = self._recording
604
+ self.recorder.set_on_dead_stream(lambda: self._revive_mic(rid))
573
605
  # Ducking lowers the whole output, voiceio's chimes included: it waits
574
606
  # for the start chime to play out, and _do_stop restores the volume
575
607
  # before the stop chime plays.
@@ -598,6 +630,7 @@ class VoiceIO:
598
630
  # restore (see _do_start), off this thread.
599
631
  self._quiet.stop(then=lambda: self._play_record_cue(start=False))
600
632
  self.recorder.set_on_auto_stop(None)
633
+ self.recorder.set_on_dead_stream(None)
601
634
  audio = self.recorder.stop()
602
635
  tray.set_recording(False)
603
636
  self._idle_audio.touch()
voiceio/cli/learn.py CHANGED
@@ -87,8 +87,9 @@ def register(sub: argparse._SubParsersAction) -> None:
87
87
  mt.add_argument("--auto-promote", action="store_true",
88
88
  help="switch to the fine-tune when it wins, and restart the daemon")
89
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")
90
+ help="work only while you're away from the computer, on mains power "
91
+ "([learn] away_minutes, max_load, wait_hours): stop when you're "
92
+ "back, resume later; the scheduled run uses this")
92
93
  mt.set_defaults(func=cmd_maintain)
93
94
 
94
95
  sc = lsub.add_parser("schedule", help="Run `maintain` on a schedule (systemd timer)")
@@ -257,7 +258,8 @@ def cmd_label(args: argparse.Namespace) -> None:
257
258
  cfg = _cfg()
258
259
  name = args.teacher or DEFAULT_TEACHER
259
260
  print(f"Loading teacher {name}…", flush=True)
260
- teacher = WhisperTeacher(name, vocabulary=_vocabulary(), language=cfg.model.language)
261
+ teacher = WhisperTeacher(name, vocabulary=_vocabulary(), language=cfg.model.language,
262
+ threads=cfg.learn.threads)
261
263
  clips = sorted(store.clips(), key=lambda c: c.duration)
262
264
  t0 = time.monotonic()
263
265
 
@@ -427,17 +429,41 @@ def _train_language() -> str:
427
429
  return lang
428
430
 
429
431
 
432
+ def _runs_of(ds: Path, base: str) -> list[Path]:
433
+ """Run dirs that fine-tuned `base` on `ds`, oldest first."""
434
+ import re
435
+ pattern = re.compile(rf"{re.escape(ds.name)}-{re.escape(Path(base).name)}-\d{{8}}-\d{{4}}")
436
+ root = _models_dir()
437
+ return sorted(p for p in root.glob(f"{ds.name}-*") if pattern.fullmatch(p.name)) \
438
+ if root.exists() else []
439
+
440
+
441
+ def _finished(run: Path) -> bool:
442
+ return (run / "ct2" / "model.bin").exists()
443
+
444
+
445
+ def _finished_run(ds: Path, base: str) -> Path | None:
446
+ return next((r for r in reversed(_runs_of(ds, base)) if _finished(r)), None)
447
+
448
+
430
449
  def _train(ds: Path, *, base: str = "small", epochs: int = 4, synthetic_share: float = 0.3,
431
450
  full: bool = False, remote: bool = False) -> Path | None:
432
- """Export `ds` and fine-tune on it; returns the run dir (None for --remote)."""
451
+ """Export `ds` and fine-tune on it; returns the run dir (None for --remote).
452
+ An interrupted run of the same dataset and base carries on where it was."""
433
453
  from voiceio.learn import augment, train
434
- real = len(store.read_split(ds, "train"))
435
- synth = augment.rows(max_share=synthetic_share, real_rows=real)
436
454
  folder = store.learn_dir() / "exports" / ds.name
437
- counts = train.export(ds, folder, extra_train_rows=synth, hotwords=_live_hotwords())
438
- print(f"exported {counts} windows ({len(synth)} synthetic) to {folder}")
455
+ unfinished = [r for r in _runs_of(ds, base) if not _finished(r)]
456
+ run = unfinished[-1] if unfinished and not remote else None
457
+ if run is not None and all((folder / f).exists()
458
+ for f in ("train.jsonl", "dev.jsonl", train.HOTWORDS_FILE)):
459
+ print(f"resuming {run.name} (windows exported before)")
460
+ else:
461
+ real = len(store.read_split(ds, "train"))
462
+ synth = augment.rows(max_share=synthetic_share, real_rows=real)
463
+ counts = train.export(ds, folder, extra_train_rows=synth, hotwords=_live_hotwords())
464
+ print(f"exported {counts} windows ({len(synth)} synthetic) to {folder}")
439
465
  # A Hugging Face id or path names the run by its last part: one directory.
440
- run = _models_dir() / f"{ds.name}-{Path(base).name}-{time.strftime('%Y%m%d-%H%M')}"
466
+ run = run or _models_dir() / f"{ds.name}-{Path(base).name}-{time.strftime('%Y%m%d-%H%M')}"
441
467
  if remote:
442
468
  print("\n" + train.remote_recipe(folder, run, base))
443
469
  return None
@@ -447,42 +473,106 @@ def _train(ds: Path, *, base: str = "small", epochs: int = 4, synthetic_share: f
447
473
  # with: training a Spanish voice under "en" taught it the wrong prompt.
448
474
  hf = train.train(folder, run, train.TrainConfig(
449
475
  base=base, epochs=epochs, decoder_only=False if full else None, hotwords=hotwords,
450
- language=_train_language()))
476
+ language=_train_language(), threads=_cfg().learn.threads))
451
477
  train.convert(hf, run / "ct2")
478
+ train.discard_resume_state(run)
452
479
  return run
453
480
 
454
481
 
482
+ # A dataset `maintain` built and has not finished with (trained, scored,
483
+ # decided): a stopped run picks it up again instead of waiting for min_new
484
+ # more clips past it.
485
+ def _pending_path() -> Path:
486
+ return store.learn_dir() / "maintain-pending.json"
487
+
488
+
489
+ def _pending_dataset() -> Path | None:
490
+ try:
491
+ name = json.loads(_pending_path().read_text())["dataset"]
492
+ except (OSError, ValueError, KeyError, TypeError):
493
+ return None
494
+ ds = store.learn_dir() / "datasets" / name
495
+ if not (ds / "manifest.json").exists():
496
+ _set_pending(None)
497
+ return None
498
+ return ds
499
+
500
+
501
+ def _set_pending(ds: Path | None) -> None:
502
+ path = _pending_path()
503
+ if ds is None:
504
+ path.unlink(missing_ok=True)
505
+ return
506
+ tmp = path.with_name(path.name + ".tmp")
507
+ tmp.write_text(json.dumps({"dataset": ds.name}) + "\n")
508
+ os.replace(tmp, path)
509
+
510
+
455
511
  def cmd_maintain(args: argparse.Namespace) -> None:
456
512
  """Label what's new; once enough has piled up, rebuild, retrain and say
457
- whether the result beats the current model. Never promotes by itself."""
513
+ whether the result beats the current model. Never promotes by itself.
514
+
515
+ With --when-idle this process only watches: the work runs in a child
516
+ that is stopped the moment you're back and started again (it resumes)
517
+ once you're away (voiceio.learn.quiet)."""
458
518
  _yield_cpu()
459
- from voiceio.learn import evaluate, quiet
519
+ from voiceio.learn import quiet
460
520
  learn_cfg = _maintain_settings(args)
461
- if getattr(args, "when_idle", False) and not quiet.wait_until_quiet(
462
- learn_cfg.max_load, learn_cfg.wait_hours):
521
+ if getattr(args, "when_idle", False) and os.environ.get(quiet.WORKER_ENV) != "1":
522
+ store.learn_dir().mkdir(parents=True, exist_ok=True)
523
+ code = quiet.run_while_away(
524
+ quiet.worker_argv(), max_load=learn_cfg.max_load,
525
+ away_minutes=learn_cfg.away_minutes, wait_hours=learn_cfg.wait_hours,
526
+ threads=learn_cfg.threads, hold_file=store.learn_dir() / "maintain.hold")
527
+ if code:
528
+ raise SystemExit(code)
463
529
  return
464
- _announce_learning(learn_cfg.min_new)
530
+ _maintain(args, learn_cfg)
531
+
532
+
533
+ def _maintain(args: argparse.Namespace, learn_cfg) -> None:
534
+ from voiceio.learn import evaluate, quiet, train
535
+ if not os.environ.get(quiet.RESUMED_ENV):
536
+ _announce_learning(learn_cfg.min_new)
465
537
  cmd_label(argparse.Namespace(teacher=learn_cfg.teacher or None, limit=None, relabel=False))
466
- from voiceio.learn import train
467
538
  if missing := train.missing_train_deps():
468
539
  # Labeling alone is worth it; retraining waits for the extra. Exit 0:
469
540
  # a nightly timer must not fail every night over an optional install.
470
541
  print(f"labeled; fine-tuning needs {', '.join(missing)}: {train.TRAIN_HINT}")
471
542
  return
472
- labels = store.load_labels()
473
- labeled = {c.audio for c in store.clips() if c.audio in labels}
474
- ds = store.latest_dataset()
475
- new = len(labeled - store.dataset_clips(ds)) if ds else len(labeled)
476
- if new < learn_cfg.min_new: # the first dataset too: a handful of clips trains nothing
477
- since = f" since {ds.name}" if ds else ""
478
- print(f"{new} new labeled clips{since}; retraining waits for {learn_cfg.min_new}.")
479
- return
480
- ds = store.build_dataset()
481
- print(f"built {ds.name} ({new} new clips)")
482
- current = _run_eval(None, ds)
483
- run = _train(ds, base=learn_cfg.base, epochs=learn_cfg.epochs)
484
- candidate = _run_eval(str(run / "ct2"), ds)
485
- ok, table = evaluate.promotion_verdict(_scores(candidate), _scores(current))
543
+ ds = _pending_dataset()
544
+ if ds is not None:
545
+ print(f"carrying on with {ds.name}, which a stopped run had not finished")
546
+ else:
547
+ labels = store.load_labels()
548
+ labeled = {c.audio for c in store.clips() if c.audio in labels}
549
+ latest = store.latest_dataset()
550
+ new = len(labeled - store.dataset_clips(latest)) if latest else len(labeled)
551
+ if new < learn_cfg.min_new: # the first dataset too: a handful of clips trains nothing
552
+ since = f" since {latest.name}" if latest else ""
553
+ print(f"{new} new labeled clips{since}; retraining waits for {learn_cfg.min_new}.")
554
+ return
555
+ ds = store.build_dataset()
556
+ _set_pending(ds)
557
+ print(f"built {ds.name} ({new} new clips)")
558
+ # Scores already saved for this dataset are reused: a resumed run doesn't
559
+ # spend its time again on evals it finished before it was stopped.
560
+ beam = _cfg().model.beam_size
561
+ current = evaluate.latest(_cfg().model.name, ds.name, beam=beam) or _scores(_run_eval(None, ds))
562
+ run = _finished_run(ds, learn_cfg.base) or _train(ds, base=learn_cfg.base,
563
+ epochs=learn_cfg.epochs)
564
+ model = str(run / "ct2")
565
+ candidate = evaluate.latest(model, ds.name, beam=beam) or _scores(_run_eval(model, ds))
566
+ with quiet.hold(): # from here on: seconds of work that must not be torn
567
+ _set_pending(None)
568
+ _decide(args, run, ds, candidate, current)
569
+
570
+
571
+ def _decide(args: argparse.Namespace, run: Path, ds: Path, candidate: dict,
572
+ current: dict) -> None:
573
+ """Report the comparison; switch models or say how to."""
574
+ from voiceio.learn import evaluate
575
+ ok, table = evaluate.promotion_verdict(candidate, current)
486
576
  print(f"\n{_cfg().model.name} → {run.name} on {ds.name}:\n{table}")
487
577
  print(f"better or equal on everything — `voiceio learn promote {run.name}`" if ok
488
578
  else "not better on everything — keeping the current model")
@@ -539,7 +629,7 @@ def _announce_learning(min_new: int) -> None:
539
629
  to_label = sum(1 for a in clips if a not in labels)
540
630
  ds = store.latest_dataset()
541
631
  new = len(set(clips) - (store.dataset_clips(ds) if ds else set()))
542
- retrain = new >= min_new
632
+ retrain = new >= min_new or _pending_dataset() is not None
543
633
  if not to_label and not retrain:
544
634
  return
545
635
  from voiceio import feedback
@@ -547,7 +637,7 @@ def _announce_learning(min_new: int) -> None:
547
637
  (", then retraining" if to_label and retrain else "Retraining" if retrain else "")
548
638
  feedback.notify_actions(
549
639
  "VoiceIO is learning your voice",
550
- f"{what}, on this computer at idle priority; dictation stays fast.",
640
+ f"{what}, on this computer while you're away; it stops the moment you're back.",
551
641
  {"stop": "Stop for now", "weekly": "Weekly instead"},
552
642
  _on_learning_action)
553
643
 
@@ -611,10 +701,12 @@ def cmd_schedule(args: argparse.Namespace) -> None:
611
701
  if not service.install_learn_timer(auto_promote=args.auto_promote):
612
702
  raise SystemExit("could not enable the timer (needs systemd --user)")
613
703
  learn = config.load().learn
614
- print(f"on, {service.learn_schedule_summary()}: once the machine is quiet (load per CPU "
615
- f"≤ {learn.max_load:g}, on mains power; waits up to {learn.wait_hours:g} h), "
704
+ print(f"on, {service.learn_schedule_summary()}: once you've been away from the computer "
705
+ f"{learn.away_minutes:g} min (or locked the screen), on mains power and with load "
706
+ f"per CPU ≤ {learn.max_load:g} (waiting up to {learn.wait_hours:g} h), "
616
707
  "`voiceio learn maintain` labels new dictation at idle priority, retrains once "
617
- f"{min_new}+ clips are new, and "
708
+ f"{min_new}+ clips are new — stopping within seconds when you're back and "
709
+ "resuming where it was — and "
618
710
  + ("switches to the result when it wins on your held-out clips "
619
711
  "(undo: voiceio learn rollback)." if args.auto_promote else
620
712
  "tells you if the result is better. It never switches models by itself."))
voiceio/config.py CHANGED
@@ -291,11 +291,17 @@ class LearnConfig:
291
291
  # and "day" or "week" (weekly runs on Sunday).
292
292
  at: str = "03:30"
293
293
  every: str = "day"
294
- # It only starts on a quiet machine on mains power: 1-minute load per CPU
295
- # at most max_load. It waits up to wait_hours for that, then tries again
296
- # at the next scheduled run.
294
+ # It works only while nobody is using the computer: no keyboard or mouse
295
+ # input for away_minutes (or the screen locked), on mains power, and
296
+ # starts only when the 1-minute load per CPU is at most max_load. When
297
+ # you come back it stops within seconds and later resumes where it
298
+ # stopped. It waits up to wait_hours each time, then tries again at the
299
+ # next scheduled run.
300
+ away_minutes: float = 10.0
297
301
  max_load: float = 0.25
298
302
  wait_hours: float = 3.0
303
+ # CPU threads for labeling and training; 0 = half the logical CPUs.
304
+ threads: int = 0
299
305
  # What a scheduled run trains (`voiceio learn maintain`'s flags override):
300
306
  # the Whisper size or Hugging Face checkpoint to fine-tune, e.g. "medium.en";
301
307
  # the model that labels new clips (empty: large-v3-turbo); epochs per run;
voiceio/learn/evaluate.py CHANGED
@@ -20,6 +20,7 @@ from __future__ import annotations
20
20
  import collections
21
21
  import difflib
22
22
  import json
23
+ import os
23
24
  import statistics
24
25
  import time
25
26
  from dataclasses import asdict, dataclass, field
@@ -152,7 +153,9 @@ def save(result: EvalResult) -> Path:
152
153
  "rtf": result.rtf, "metrics": metrics.METRICS_VERSION}
153
154
  if result.waits:
154
155
  data["stop_to_text_median"] = statistics.median(result.waits)
155
- path.write_text(json.dumps(data, indent=2) + "\n")
156
+ tmp = path.with_name(f".{path.name}.tmp") # whole or not at all: runs are reused
157
+ tmp.write_text(json.dumps(data, indent=2) + "\n")
158
+ os.replace(tmp, path)
156
159
  return path
157
160
 
158
161
 
voiceio/learn/quiet.py CHANGED
@@ -1,41 +1,172 @@
1
- """Is the machine quiet enough for background learning?
1
+ """Background learning that never competes with the person at the computer.
2
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.
3
+ Labeling, evaluating and training are CPU-heavy for an hour or more. Idle CPU
4
+ priority is not enough on its own (see voiceio.presence), so a scheduled run
5
+ only works while nobody is using the machine:
6
+
7
+ * it starts once you have been away `[learn] away_minutes` (no input, or
8
+ the screen locked), on mains power, with the machine otherwise quiet;
9
+ * the work runs in a child process, and the parent checks every couple of
10
+ seconds; when you come back (or unplug) the child is stopped at once;
11
+ * every stage resumes where it stopped: labels are saved per clip,
12
+ training checkpoints its progress (voiceio.learn.train), finished evals
13
+ are reused. The next time you are away it carries on, within the same
14
+ run while it waits, else at the next scheduled run.
15
+
16
+ Load is checked only before starting: while the work runs, the load is
17
+ mostly the work itself, and a check that its own evidence can trip would
18
+ stop every run.
6
19
  """
7
20
  from __future__ import annotations
8
21
 
22
+ import contextlib
9
23
  import os
24
+ import re
25
+ import signal
26
+ import subprocess
27
+ import sys
10
28
  import time
11
- from collections.abc import Callable
29
+ from collections.abc import Callable, Iterator
30
+ from pathlib import Path
12
31
 
32
+ from voiceio import presence
13
33
  from voiceio.power import on_battery
14
34
 
35
+ # Set in the child that does the work; its absence makes `maintain
36
+ # --when-idle` the watcher instead.
37
+ WORKER_ENV = "VOICEIO_LEARN_WORKER"
38
+ # A later child after an interruption: carry on quietly (no second "learning" notice).
39
+ RESUMED_ENV = "VOICEIO_LEARN_RESUMED"
40
+ # Path of a file the child holds while it must not be cut off (switching models).
41
+ HOLD_ENV = "VOICEIO_LEARN_HOLD"
42
+
15
43
 
16
- def busy_reason(max_load: float) -> str | None:
17
- """Why learning should wait right now, or None when the machine is quiet."""
44
+ def busy_reason(max_load: float, away_after: float | None = None) -> str | None:
45
+ """Why learning should wait right now, or None when it may start."""
46
+ if on_battery():
47
+ return "on battery"
48
+ if away_after is not None:
49
+ p = presence.check(away_after)
50
+ if p.here:
51
+ return p.why
18
52
  load = os.getloadavg()[0] / (os.cpu_count() or 1)
19
53
  if load > max_load:
20
54
  return f"busy (load {load:.2f} per CPU, waiting for {max_load:g})"
55
+ return None
56
+
57
+
58
+ def stop_reason(away_after: float) -> str | None:
59
+ """Why running work must stop now (you're back, or on battery), else None."""
21
60
  if on_battery():
22
61
  return "on battery"
23
- return None
62
+ p = presence.check(away_after)
63
+ return p.why if p.here else None
24
64
 
25
65
 
26
- def wait_until_quiet(max_load: float, wait_hours: float, *, poll: float = 300,
66
+ def wait_until_quiet(max_load: float, wait_hours: float, *, away_after: float | None = None,
67
+ poll: float = 300,
27
68
  sleep: Callable[[float], None] = time.sleep,
28
69
  clock: Callable[[], float] = time.monotonic,
29
70
  say: Callable[[str], None] = print) -> bool:
30
- """Block until the machine is quiet. False when it stayed busy for wait_hours."""
71
+ """Block until learning may start. False when it couldn't for wait_hours."""
31
72
  deadline = clock() + wait_hours * 3600
32
73
  last = None
33
- while (reason := busy_reason(max_load)) is not None:
34
- if reason != last:
74
+ while (reason := busy_reason(max_load, away_after)) is not None:
75
+ if (kind := re.sub(r"[\d.]+", "#", reason)) != last: # once per reason, not per reading
35
76
  say(f"waiting: {reason}")
36
- last = reason
77
+ last = kind
37
78
  if clock() + poll > deadline:
38
79
  say(f"still {reason} after {wait_hours:g} h; trying again at the next scheduled run")
39
80
  return False
40
81
  sleep(poll)
41
82
  return True
83
+
84
+
85
+ def worker_threads(threads: int) -> int:
86
+ """`[learn] threads`, with 0 meaning half the logical CPUs (the physical
87
+ cores on an SMT machine): fast on an idle machine, never all of it."""
88
+ return threads if threads > 0 else max(1, (os.cpu_count() or 2) // 2)
89
+
90
+
91
+ def worker_argv() -> list[str]:
92
+ """This same command, for the child. orig_argv keeps "-m voiceio": re-
93
+ running __main__.py as a script would put voiceio/ first on sys.path."""
94
+ return [sys.executable, *sys.orig_argv[1:]]
95
+
96
+
97
+ def _spawn(argv: list[str], env: dict[str, str]) -> subprocess.Popen:
98
+ # Own process group: stopping it takes the decoder subprocesses along.
99
+ return subprocess.Popen(argv, env=env, start_new_session=True)
100
+
101
+
102
+ def _stop(proc: subprocess.Popen, grace: float = 5.0) -> None:
103
+ """End the work now. Nothing is lost that a later run needs: every stage
104
+ saves its progress as it goes, so there is no shutdown to wait for."""
105
+ for sig, wait in ((signal.SIGTERM, grace), (signal.SIGKILL, None)):
106
+ try:
107
+ os.killpg(proc.pid, sig)
108
+ except ProcessLookupError:
109
+ break
110
+ try:
111
+ proc.wait(timeout=wait)
112
+ break
113
+ except subprocess.TimeoutExpired:
114
+ continue
115
+ with contextlib.suppress(ProcessLookupError): # stragglers left in the group
116
+ os.killpg(proc.pid, signal.SIGKILL)
117
+
118
+
119
+ def run_while_away(argv: list[str], *, max_load: float, away_minutes: float,
120
+ wait_hours: float, threads: int, hold_file: Path,
121
+ poll: float = 2.0, wait_poll: float = 30.0,
122
+ spawn: Callable[[list[str], dict[str, str]], subprocess.Popen] = _spawn,
123
+ stop: Callable[[subprocess.Popen], None] = _stop,
124
+ sleep: Callable[[float], None] = time.sleep,
125
+ clock: Callable[[], float] = time.monotonic,
126
+ say: Callable[[str], None] = print) -> int:
127
+ """Run `argv` (the work) only while nobody is using the machine: stop it
128
+ within `poll` seconds when that changes, and start it again (it resumes)
129
+ once they are away again. Returns its exit status; 0 when it gave up
130
+ waiting (the next scheduled run continues)."""
131
+ away_after = away_minutes * 60
132
+ n = str(worker_threads(threads))
133
+ # torch, CTranslate2 and numpy size their thread pools from these.
134
+ env = {**os.environ, WORKER_ENV: "1", HOLD_ENV: str(hold_file),
135
+ "OMP_NUM_THREADS": n, "MKL_NUM_THREADS": n}
136
+ resumed = False
137
+ while True:
138
+ if not wait_until_quiet(max_load, wait_hours, away_after=away_after, poll=wait_poll,
139
+ sleep=sleep, clock=clock, say=say):
140
+ return 0
141
+ hold_file.unlink(missing_ok=True)
142
+ proc = spawn(argv, {**env, RESUMED_ENV: "1"} if resumed else env)
143
+ reason = None
144
+ while proc.poll() is None:
145
+ sleep(poll)
146
+ if hold_file.exists(): # switching models: seconds, and must not be torn
147
+ continue
148
+ reason = stop_reason(away_after)
149
+ if reason is not None and proc.poll() is None:
150
+ stop(proc)
151
+ break
152
+ else:
153
+ hold_file.unlink(missing_ok=True)
154
+ return proc.returncode
155
+ say(f"paused: {reason}; it resumes where it stopped once you've been away "
156
+ f"{away_minutes:g} min")
157
+ resumed = True
158
+
159
+
160
+ @contextlib.contextmanager
161
+ def hold() -> Iterator[None]:
162
+ """Don't let the watcher stop the work inside this block (a model switch:
163
+ config, history and the daemon restart belong together)."""
164
+ path = os.environ.get(HOLD_ENV)
165
+ if not path:
166
+ yield
167
+ return
168
+ Path(path).touch()
169
+ try:
170
+ yield
171
+ finally:
172
+ Path(path).unlink(missing_ok=True)