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.
- {python_voiceio-1.9.2.dist-info → python_voiceio-1.9.3.dist-info}/METADATA +2 -2
- {python_voiceio-1.9.2.dist-info → python_voiceio-1.9.3.dist-info}/RECORD +19 -18
- {python_voiceio-1.9.2.dist-info → python_voiceio-1.9.3.dist-info}/WHEEL +1 -1
- voiceio/__init__.py +1 -1
- voiceio/app.py +33 -0
- voiceio/cli/learn.py +127 -35
- voiceio/config.py +9 -3
- voiceio/learn/evaluate.py +4 -1
- voiceio/learn/quiet.py +144 -13
- voiceio/learn/store.py +9 -3
- voiceio/learn/teacher.py +3 -3
- voiceio/learn/train.py +219 -44
- voiceio/presence.py +160 -0
- voiceio/recorder.py +38 -0
- voiceio/service.py +28 -6
- voiceio/setup/steps.py +1 -1
- {python_voiceio-1.9.2.dist-info → python_voiceio-1.9.3.dist-info}/entry_points.txt +0 -0
- {python_voiceio-1.9.2.dist-info → python_voiceio-1.9.3.dist-info}/licenses/LICENSE +0 -0
- {python_voiceio-1.9.2.dist-info → python_voiceio-1.9.3.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-voiceio
|
|
3
|
-
Version: 1.9.
|
|
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
|
|
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
|
-
voiceio/__init__.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
33
|
+
voiceio/recorder.py,sha256=B_db2kijunZCIAH6cqmEFB5Zuvt3W1e8BCCaQg2EsZM,18469
|
|
33
34
|
voiceio/retention.py,sha256=5ydw2dJ2gTQ39BYdg3Bk4mk-KNJkJcGUKzssndYwdY8,6137
|
|
34
|
-
voiceio/service.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
89
|
-
voiceio/learn/teacher.py,sha256=
|
|
90
|
-
voiceio/learn/train.py,sha256=
|
|
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=
|
|
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.
|
|
137
|
-
python_voiceio-1.9.
|
|
138
|
-
python_voiceio-1.9.
|
|
139
|
-
python_voiceio-1.9.
|
|
140
|
-
python_voiceio-1.9.
|
|
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,,
|
voiceio/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "1.9.
|
|
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="
|
|
91
|
-
"wait_hours)
|
|
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
|
-
|
|
438
|
-
|
|
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
|
|
519
|
+
from voiceio.learn import quiet
|
|
460
520
|
learn_cfg = _maintain_settings(args)
|
|
461
|
-
if getattr(args, "when_idle", False) and
|
|
462
|
-
|
|
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
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
|
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
|
|
615
|
-
f"
|
|
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
|
|
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
|
|
295
|
-
#
|
|
296
|
-
#
|
|
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.
|
|
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
|
-
"""
|
|
1
|
+
"""Background learning that never competes with the person at the computer.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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
|
-
|
|
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, *,
|
|
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
|
|
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 =
|
|
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)
|