python-voiceio 1.8.2__py3-none-any.whl → 1.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_voiceio-1.8.2.dist-info → python_voiceio-1.9.0.dist-info}/METADATA +1 -1
- {python_voiceio-1.8.2.dist-info → python_voiceio-1.9.0.dist-info}/RECORD +20 -19
- voiceio/__init__.py +1 -1
- voiceio/app.py +31 -5
- voiceio/cli/learn.py +14 -1
- voiceio/config.py +4 -0
- voiceio/context_terms.py +253 -0
- voiceio/core/bias.py +52 -10
- voiceio/core/engine.py +10 -4
- voiceio/health.py +6 -0
- voiceio/learn/teacher.py +1 -1
- voiceio/postprocess.py +32 -3
- voiceio/streaming.py +8 -0
- voiceio/tokens.py +17 -0
- voiceio/vocabulary.py +6 -15
- voiceio/wordfreq.py +15 -3
- {python_voiceio-1.8.2.dist-info → python_voiceio-1.9.0.dist-info}/WHEEL +0 -0
- {python_voiceio-1.8.2.dist-info → python_voiceio-1.9.0.dist-info}/entry_points.txt +0 -0
- {python_voiceio-1.8.2.dist-info → python_voiceio-1.9.0.dist-info}/licenses/LICENSE +0 -0
- {python_voiceio-1.8.2.dist-info → python_voiceio-1.9.0.dist-info}/top_level.txt +0 -0
|
@@ -1,19 +1,20 @@
|
|
|
1
|
-
python_voiceio-1.
|
|
2
|
-
voiceio/__init__.py,sha256=
|
|
1
|
+
python_voiceio-1.9.0.dist-info/licenses/LICENSE,sha256=Gz61o8jFTAvZUZyB3nWDB3DQQVuipjfPkVu9W8hBHM0,1072
|
|
2
|
+
voiceio/__init__.py,sha256=ljy9wwnU6FSzJrHgJSWUcZ_nIboERJauUm7HTpNQfNg,22
|
|
3
3
|
voiceio/__main__.py,sha256=xT5QCGGreYMisHO7Lh_Y-xAQ2TOzG2D7npKknGNcWSY,53
|
|
4
|
-
voiceio/app.py,sha256=
|
|
4
|
+
voiceio/app.py,sha256=ce3bf2Es-7KgciMZLYJO2qpJgZId_hOmqdmTFGcun1w,43668
|
|
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=Pv8Wx_ieY4y6xUJqP6fLkpBzCsiuKNcW8G8ox0Eit2M,22971
|
|
10
10
|
voiceio/consent.py,sha256=3stv3iZvYl_Dt4GUe-GUVOHEGi-2fOl-QqF3pDjraS4,1862
|
|
11
|
+
voiceio/context_terms.py,sha256=I5EkkxCR8VrzeUX89-JM4I0QgoYGPtn62DfgXO8hrP8,9906
|
|
11
12
|
voiceio/corrections.py,sha256=IIRViwjL9kkMy26-1dd0DtTfT0Tiedn6pmmHoaeayMQ,10047
|
|
12
13
|
voiceio/demo.py,sha256=jA282e7UWDwtP1IXT2bPHH5wl6eKdBNvOJcoNDWzZlQ,8766
|
|
13
14
|
voiceio/display.py,sha256=ZGBNRjuvg3chkJjduyPJs4s5ZY5vqC_mcaov94PIPzo,5313
|
|
14
15
|
voiceio/feedback.py,sha256=Q4zlWotRl4Lh3whr7P6YsvJVoNknmTap93ab_utimts,8890
|
|
15
16
|
voiceio/focus.py,sha256=PGj839UUZE7ZPwaiLDTAX3S8d_QjwaT4y6ugXyylL9M,4304
|
|
16
|
-
voiceio/health.py,sha256=
|
|
17
|
+
voiceio/health.py,sha256=R8Gh5BNtELOAHLnbnCtzJ55KIJoZMAJp4FNLEc9RavI,22804
|
|
17
18
|
voiceio/hints.py,sha256=dmoiMAJ0YGTWwFc9kIPvEs_BQJN7UEUiwYuLUD_GoQQ,1415
|
|
18
19
|
voiceio/history.py,sha256=8RN6qyJZMLgyNIdYuu5WVNjad4d_4TKFz2ohg5KW0F8,5965
|
|
19
20
|
voiceio/idle_audio.py,sha256=0GEbm0g-a2uVxIMWEKpaYus06mnrc8qvWr09a85FWS0,7225
|
|
@@ -23,7 +24,7 @@ voiceio/numbers.py,sha256=CXJtlMhmV6_9Z1FtKAF4-RXycw8voV-BYHPBpdw1I6Y,13571
|
|
|
23
24
|
voiceio/pidlock.py,sha256=OZLNskUyv9_F3WgyHZWw8WmKt0W7ioxH6xgHitOwCww,1216
|
|
24
25
|
voiceio/platform.py,sha256=uG_5KeYey_29GCn4nSOQJ1UpoCrBC_m2DR7IeoRnuL4,19142
|
|
25
26
|
voiceio/postcorrect.py,sha256=JN5l9xnyGfYq2_owSUwkJ513QiQQKxCrT3hjU5aJCpg,17322
|
|
26
|
-
voiceio/postprocess.py,sha256=
|
|
27
|
+
voiceio/postprocess.py,sha256=9vpth8u2xOIKU6SimY-hsEDrs4X9YJSI67ZO_f2PpxM,6048
|
|
27
28
|
voiceio/power.py,sha256=mKiG5ioHbzgtW2S-OKGFOPzZFbG0HwUUyMn8kE5JRoQ,681
|
|
28
29
|
voiceio/privacy.py,sha256=FqnA9jx9RCqWW-i6Dh7JcT7vzz97kbUGyQEvsHLvpqc,573
|
|
29
30
|
voiceio/prompt.py,sha256=9iza8KoQxvO_U9NPMJ8Ep7j-zkIs9nW8N5sacCRpsEk,2904
|
|
@@ -31,28 +32,28 @@ voiceio/quiet_audio.py,sha256=AapeuhrJwR20fsl-Ksgv4qpptrcQezk6A2_i4Kr18Ks,11451
|
|
|
31
32
|
voiceio/recorder.py,sha256=E3YwZecJqd06GGoRSjFi2Q-7GoRzBkAMBGspKVkF9rc,16746
|
|
32
33
|
voiceio/retention.py,sha256=5ydw2dJ2gTQ39BYdg3Bk4mk-KNJkJcGUKzssndYwdY8,6137
|
|
33
34
|
voiceio/service.py,sha256=q4_Mk8m3KCG14CGZ468ZrFvNvyXuWSOjKFUjRnksgAI,16391
|
|
34
|
-
voiceio/streaming.py,sha256=
|
|
35
|
+
voiceio/streaming.py,sha256=67hHvAQ7Z99iwtwy6BXPBPqE0q9mNpoP0Lhh8-RufIk,25968
|
|
35
36
|
voiceio/suggest.py,sha256=jX4bCpnddyrteKfRBPbnUgf-Rk5145LFaSTat0_Ll2E,4836
|
|
36
|
-
voiceio/tokens.py,sha256=
|
|
37
|
+
voiceio/tokens.py,sha256=y9KBDgLukUMieO576rgyBn8qEQqElra66spQwoWXDXk,6321
|
|
37
38
|
voiceio/ui.py,sha256=pwuH3zr9kMC7TovLwJwa3E3H8J5Fbt2VF4-DDTvqboQ,461
|
|
38
39
|
voiceio/vad.py,sha256=VPeefjJ0Ml5hOuRE2V6BWoBXmzawxLB7if5UP3vBKiM,6030
|
|
39
40
|
voiceio/vocab_stats.py,sha256=md_85Bs0UsM-tmSZDEyQFe0C7z4UnsZ5QSV5vuZ8A5w,5408
|
|
40
|
-
voiceio/vocabulary.py,sha256=
|
|
41
|
+
voiceio/vocabulary.py,sha256=V74eubSYn6hAQGIgRJ74p22ztvxw2dtal-IbB4cVBr8,10895
|
|
41
42
|
voiceio/watchdog.py,sha256=XnrpM3ULeUoJ-sMt_aNNzJSLqOuK4ocJAMHQ00knYuw,7838
|
|
42
|
-
voiceio/wordfreq.py,sha256=
|
|
43
|
+
voiceio/wordfreq.py,sha256=AxmVgpQRfm2YAYQpHCRRrBUTpa12y_ngiFNScrGr4GM,3543
|
|
43
44
|
voiceio/cli/__init__.py,sha256=EM2Qa1COWkqSQ9hFuPKdf8_OD5JoGsY3QCpQA-waEJQ,8353
|
|
44
45
|
voiceio/cli/configcmd.py,sha256=YF7t-LkwEYEGH4qcO86xehWVX0LvltqxjDFpMF5ljjA,2858
|
|
45
46
|
voiceio/cli/corrections.py,sha256=OBjgiOhtWlTQpQq0HLAvk5-EfKJ7NNt-hnNBHm7obEc,4247
|
|
46
47
|
voiceio/cli/doctor.py,sha256=P8bOn4xedMistlKHhNeeGxRsmogV6jBVKc-CUbo0LXs,7769
|
|
47
48
|
voiceio/cli/history.py,sha256=7FvEEgdw8jf5XXvC7-4S7iIpcjBbn7dx4czIijg61C0,2597
|
|
48
|
-
voiceio/cli/learn.py,sha256=
|
|
49
|
+
voiceio/cli/learn.py,sha256=ajVP7RRFBLXa0Sf-pbYrccPpuB58oHjd4wR9JkcOwtw,32914
|
|
49
50
|
voiceio/cli/models.py,sha256=fye_mHbsXLY11uRC1BCDlucCHPny9ftoehAeUJPMJv0,4263
|
|
50
51
|
voiceio/cli/servicecmd.py,sha256=2St2WfLkt06EpC51I5i9Ykv_pvLKzu_dz01M-29O3-4,4830
|
|
51
52
|
voiceio/cli/uninstall.py,sha256=Vqnq6-4hjhDrxqkU_1wtXkY5BU_Tqa-TjZA0Qpf12e4,8921
|
|
52
53
|
voiceio/cli/vocab.py,sha256=lxxuSukefvIHV4fFSduCQQewXY0_Bvf5sDnG4bqLWH8,4418
|
|
53
54
|
voiceio/core/__init__.py,sha256=K0b4duczohS3IrIMLp9fYhfb-GeuyQaOVH_8teYGsQM,277
|
|
54
|
-
voiceio/core/bias.py,sha256=
|
|
55
|
-
voiceio/core/engine.py,sha256=
|
|
55
|
+
voiceio/core/bias.py,sha256=Kxxy7UJOZbA4gYJJbJVL27-1mMaMCm8WTujDNJR9WUY,6093
|
|
56
|
+
voiceio/core/engine.py,sha256=7g3oXXo731Rcxem60xTWq7UXqTkKth2m_GyycUdJiUE,8665
|
|
56
57
|
voiceio/core/pipeline.py,sha256=p164xjnuUXlNQQoRvJVNNEahoklqNGYfwx8o7y6i8tM,7049
|
|
57
58
|
voiceio/core/decoders/__init__.py,sha256=CmbkAucTNjzDIzTMdbMggoibHsQ_H03eHrMJhODpplg,2473
|
|
58
59
|
voiceio/core/decoders/base.py,sha256=D-umP8Eqk6GLg6IzTPA6Q1BomlHutU2AqsVRn4SS4XU,3787
|
|
@@ -85,7 +86,7 @@ voiceio/learn/metrics.py,sha256=b2xWUzPpNpXHn6zgS1upPbEcpW3ExH9SmSLIUzPhStQ,5174
|
|
|
85
86
|
voiceio/learn/quiet.py,sha256=5Ov3mvE1G3CjhwFQOuJvGeju242zFASi29tketnUyew,1523
|
|
86
87
|
voiceio/learn/review.py,sha256=X5qs5yEf20Ao3gMhsbW3Eur62hSqy9AYlv08Pcrllxo,5572
|
|
87
88
|
voiceio/learn/store.py,sha256=MauWhGaI2drMbiChIZOblpPS_uVAw-1sNWGyGACHYw8,12816
|
|
88
|
-
voiceio/learn/teacher.py,sha256=
|
|
89
|
+
voiceio/learn/teacher.py,sha256=FhCv08Js3chs4Vbh0VN3ZI9p05HFxeeeFJF8jTjjzPQ,3751
|
|
89
90
|
voiceio/learn/train.py,sha256=FtZMa4ddsPMRUxkyqC-Qf9OveZmgRMHiGXx5WHj_jT8,18214
|
|
90
91
|
voiceio/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
91
92
|
voiceio/models/silero_vad.onnx,sha256=GhU6IvRQnikqlOZ9b5uF6N6yW0mIaCt-F0xlJ52HiOM,2327524
|
|
@@ -132,8 +133,8 @@ voiceio/typers/pynput_type.py,sha256=f3w131YWJfNGd4IAcWtuKd2Gcwu3467QwGW3OBUEXNI
|
|
|
132
133
|
voiceio/typers/wtype.py,sha256=11vOAKHjMat-fITd4sUoUWwgniyVp65EbH3i4mL6Vhg,1762
|
|
133
134
|
voiceio/typers/xdotool.py,sha256=OuT_zW_z07jSAqu9Cmn_gL1mJfQsiX62ecqFRvpAarQ,1332
|
|
134
135
|
voiceio/typers/ydotool.py,sha256=oY5EXB-nI7PvxTL_5Tb3JjfvyhmLNYNnUFvkb1XUn_U,5978
|
|
135
|
-
python_voiceio-1.
|
|
136
|
-
python_voiceio-1.
|
|
137
|
-
python_voiceio-1.
|
|
138
|
-
python_voiceio-1.
|
|
139
|
-
python_voiceio-1.
|
|
136
|
+
python_voiceio-1.9.0.dist-info/METADATA,sha256=t77vZeISZAjr-72w7Ef9mhi_rDOTzcU5Ha4odNvwlng,7519
|
|
137
|
+
python_voiceio-1.9.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
138
|
+
python_voiceio-1.9.0.dist-info/entry_points.txt,sha256=iDIDmhvQ3xeN_CWsFne1t9STZ08I25I_wp0xmde_vMs,94
|
|
139
|
+
python_voiceio-1.9.0.dist-info/top_level.txt,sha256=piwtn309lD6uexQyXdZ-efAVBJF9y6Wfr48Z-8zkNhg,8
|
|
140
|
+
python_voiceio-1.9.0.dist-info/RECORD,,
|
voiceio/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "1.
|
|
1
|
+
__version__ = "1.9.0"
|
voiceio/app.py
CHANGED
|
@@ -168,6 +168,10 @@ class VoiceIO:
|
|
|
168
168
|
threading.Thread(
|
|
169
169
|
target=retention.prune, args=(cfg.data,), daemon=True,
|
|
170
170
|
).start()
|
|
171
|
+
if cfg.model.screen_vocabulary:
|
|
172
|
+
# Word frequency tables load in ~1s; not on the first dictation.
|
|
173
|
+
from voiceio.context_terms import warm
|
|
174
|
+
threading.Thread(target=warm, args=(cfg.model.language,), daemon=True).start()
|
|
171
175
|
|
|
172
176
|
# Hotkey deduplication
|
|
173
177
|
self._hotkey_lock = threading.Lock()
|
|
@@ -557,10 +561,12 @@ class VoiceIO:
|
|
|
557
561
|
# `voiceio vocab`/`corrections` apply from the next recording.
|
|
558
562
|
bias = self.engine.bias()
|
|
559
563
|
self._recording_bias = bias
|
|
560
|
-
if self.cfg.data.capture_context:
|
|
561
|
-
# Snapshot the dictation target now — focus may change by finalize
|
|
564
|
+
if self.cfg.data.capture_context or self.cfg.model.screen_vocabulary:
|
|
565
|
+
# Snapshot the dictation target now — focus may change by finalize.
|
|
566
|
+
# Off this thread: the chime and the mic never wait on it.
|
|
562
567
|
self._context_title = None
|
|
563
|
-
threading.Thread(target=self._capture_context,
|
|
568
|
+
threading.Thread(target=self._capture_context, args=(self._recording,),
|
|
569
|
+
daemon=True, name="screen-context").start()
|
|
564
570
|
self._record_start = time.monotonic()
|
|
565
571
|
self.recorder.start()
|
|
566
572
|
self.recorder.set_on_auto_stop(self._on_auto_stop)
|
|
@@ -627,9 +633,29 @@ class VoiceIO:
|
|
|
627
633
|
# No finalizer will run to release the input source, so do it here.
|
|
628
634
|
self._typers.end(rid)
|
|
629
635
|
|
|
630
|
-
def _capture_context(self) -> None:
|
|
636
|
+
def _capture_context(self, rid: int) -> None:
|
|
631
637
|
from voiceio import retention
|
|
632
|
-
|
|
638
|
+
if not self.cfg.model.screen_vocabulary:
|
|
639
|
+
self._context_title = retention.active_window_title()
|
|
640
|
+
return
|
|
641
|
+
from voiceio import context_terms
|
|
642
|
+
t0 = time.monotonic()
|
|
643
|
+
screen = context_terms.capture()
|
|
644
|
+
if self.cfg.data.capture_context:
|
|
645
|
+
self._context_title = screen.title or None
|
|
646
|
+
terms = self.engine.screen_terms([screen.title, screen.selection])
|
|
647
|
+
if not terms:
|
|
648
|
+
return
|
|
649
|
+
bias = self.engine.bias(screen_terms=terms)
|
|
650
|
+
with self._hotkey_lock:
|
|
651
|
+
if self._recording != rid or self._state != _State.RECORDING:
|
|
652
|
+
return
|
|
653
|
+
self._recording_bias = bias
|
|
654
|
+
if self._session is not None:
|
|
655
|
+
self._session.set_bias(bias)
|
|
656
|
+
# A count only: these are words from the user's screen.
|
|
657
|
+
log.debug("Screen vocabulary: %d term(s) from %s, ready %.0f ms after start",
|
|
658
|
+
len(terms), "+".join(screen.sources()), (time.monotonic() - t0) * 1000)
|
|
633
659
|
|
|
634
660
|
def _retention_extra(self, audio: np.ndarray | None) -> dict:
|
|
635
661
|
"""Save the utterance audio (before transcription, so it survives
|
voiceio/cli/learn.py
CHANGED
|
@@ -417,6 +417,16 @@ def cmd_train(args: argparse.Namespace) -> None:
|
|
|
417
417
|
f" (compare with the current model, then `voiceio learn promote {run.name}`)")
|
|
418
418
|
|
|
419
419
|
|
|
420
|
+
def _train_language() -> str:
|
|
421
|
+
"""The language a fine-tune is trained under: the one dictation decodes
|
|
422
|
+
with. "auto" has no single answer, so it has to be set first."""
|
|
423
|
+
lang = _cfg().model.language or "en"
|
|
424
|
+
if lang == "auto":
|
|
425
|
+
raise SystemExit("Fine-tuning needs one language: set it with "
|
|
426
|
+
"`voiceio config set model.language es` (your dictation language).")
|
|
427
|
+
return lang
|
|
428
|
+
|
|
429
|
+
|
|
420
430
|
def _train(ds: Path, *, base: str = "small", epochs: int = 4, synthetic_share: float = 0.3,
|
|
421
431
|
full: bool = False, remote: bool = False) -> Path | None:
|
|
422
432
|
"""Export `ds` and fine-tune on it; returns the run dir (None for --remote)."""
|
|
@@ -433,8 +443,11 @@ def _train(ds: Path, *, base: str = "small", epochs: int = 4, synthetic_share: f
|
|
|
433
443
|
return None
|
|
434
444
|
train.require_train_deps()
|
|
435
445
|
hotwords = (folder / train.HOTWORDS_FILE).read_text(encoding="utf-8")
|
|
446
|
+
# The language token the model is trained under must be the one it decodes
|
|
447
|
+
# with: training a Spanish voice under "en" taught it the wrong prompt.
|
|
436
448
|
hf = train.train(folder, run, train.TrainConfig(
|
|
437
|
-
base=base, epochs=epochs, decoder_only=False if full else None, hotwords=hotwords
|
|
449
|
+
base=base, epochs=epochs, decoder_only=False if full else None, hotwords=hotwords,
|
|
450
|
+
language=_train_language()))
|
|
438
451
|
train.convert(hf, run / "ct2")
|
|
439
452
|
return run
|
|
440
453
|
|
voiceio/config.py
CHANGED
|
@@ -90,6 +90,10 @@ class ModelConfig:
|
|
|
90
90
|
# "modified_beam_search" (required for hotwords).
|
|
91
91
|
decoding: str = ""
|
|
92
92
|
vocabulary_file: str = "" # path to vocabulary.txt
|
|
93
|
+
# Bias each dictation toward rare names on screen: the focused window's
|
|
94
|
+
# title and the selected text (voiceio/context_terms.py). Read locally
|
|
95
|
+
# when recording starts; never stored or sent anywhere.
|
|
96
|
+
screen_vocabulary: bool = True
|
|
93
97
|
# Beam width of the passes whose text you keep (freezes and the final
|
|
94
98
|
# tail); interim previews are always greedy. 1 is greedy: faster stop.
|
|
95
99
|
beam_size: int = 5
|
voiceio/context_terms.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""On-screen vocabulary: bias one dictation toward the names you can see.
|
|
2
|
+
|
|
3
|
+
Most misrecognitions are proper nouns and jargon, and the word you are about
|
|
4
|
+
to say is often already on screen: in the focused window's title or in the
|
|
5
|
+
text you just selected. This reads those two sources when a recording starts,
|
|
6
|
+
picks out the terms Whisper is likely to miss (rare words, CamelCase,
|
|
7
|
+
acronyms, multiword names) and hands them to `core.bias` for this one
|
|
8
|
+
recording, inside a small share of the hotword budget.
|
|
9
|
+
|
|
10
|
+
Capture happens off the hotkey path, under a hard deadline, and degrades to
|
|
11
|
+
"no terms" on any failure. Nothing captured here is logged or written to
|
|
12
|
+
disk; only a term count is logged.
|
|
13
|
+
|
|
14
|
+
Not used: AT-SPI (the focused widget's text). It needs PyGObject, which the
|
|
15
|
+
venv can't import, so a system-Python helper; a one-shot walk of the
|
|
16
|
+
accessibility tree took 120-900 ms on GNOME, and Chromium/Electron apps expose
|
|
17
|
+
their text only while a screen reader is on.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from collections.abc import Callable, Iterable
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
log = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
# Capture deadline. Long enough for two ~20 ms subprocess reads under load,
|
|
34
|
+
# short enough that the terms are in place before the first interim pass.
|
|
35
|
+
CAPTURE_TIMEOUT_SECS = 0.15
|
|
36
|
+
# Only the head of a large selection is scanned: extraction stays ~10 ms.
|
|
37
|
+
MAX_SCAN_CHARS = 8000
|
|
38
|
+
MAX_TERMS = 8
|
|
39
|
+
|
|
40
|
+
# Zipf thresholds (see wordfreq.py: 2.0 ≈ top 30K words). A capitalized word
|
|
41
|
+
# is a name, and Whisper misses names it has seen far more often than generic
|
|
42
|
+
# rare words (Kosh 2.1, Wayland 2.7), so names get a higher bar.
|
|
43
|
+
_ZIPF_LOWER = 2.0
|
|
44
|
+
_ZIPF_NAME = 3.0
|
|
45
|
+
_ZIPF_MIXED = 3.5
|
|
46
|
+
_ZIPF_COMMON = 4.0
|
|
47
|
+
|
|
48
|
+
_TOKEN = re.compile(r"[\w'’.\-/@#:+]+")
|
|
49
|
+
_WORD = re.compile(r"^[A-Za-zÀ-ɏ][A-Za-zÀ-ɏ'’\-]*[A-Za-zÀ-ɏ]$")
|
|
50
|
+
_MIXED = re.compile(r"[a-zà-ÿ][A-ZÀ-Þ]|[A-ZÀ-Þ]{2}[a-zà-ÿ]")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class ScreenContext:
|
|
55
|
+
title: str = ""
|
|
56
|
+
selection: str = ""
|
|
57
|
+
secs: float = 0.0
|
|
58
|
+
|
|
59
|
+
def sources(self) -> list[str]:
|
|
60
|
+
return [n for n, v in (("title", self.title), ("selection", self.selection)) if v]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _kind(word: str, zipf: Callable[[str], float]) -> int:
|
|
64
|
+
"""How much a word looks like something Whisper will miss: 3 = CamelCase
|
|
65
|
+
or rare name, 2 = acronym, 1 = rare lowercase word, 0 = not a term."""
|
|
66
|
+
if not _WORD.match(word) or len(word) < 3:
|
|
67
|
+
return 0
|
|
68
|
+
if _MIXED.search(word):
|
|
69
|
+
return 3 if len(word) <= 20 and zipf(word) < _ZIPF_MIXED else 0
|
|
70
|
+
if word.isupper():
|
|
71
|
+
return 2 if len(word) <= 6 and zipf(word) < _ZIPF_NAME else 0
|
|
72
|
+
if word[0].isupper():
|
|
73
|
+
return 3 if zipf(word) < _ZIPF_NAME else 0
|
|
74
|
+
return 1 if len(word) >= 4 and zipf(word) < _ZIPF_LOWER else 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _words(text: str) -> list[list[str]]:
|
|
78
|
+
"""Runs of plain words; an identifier, URL or number ends a run, and its
|
|
79
|
+
letter parts (voiceio.dev → voiceio) stand alone."""
|
|
80
|
+
runs: list[list[str]] = [[]]
|
|
81
|
+
for line in text.splitlines():
|
|
82
|
+
runs.append([])
|
|
83
|
+
for tok in _TOKEN.findall(line):
|
|
84
|
+
stripped = tok.strip("'’.-:")
|
|
85
|
+
if _WORD.match(stripped):
|
|
86
|
+
runs[-1].append(stripped)
|
|
87
|
+
if tok.rstrip("'’") != stripped: # trailing punctuation ends the run
|
|
88
|
+
runs.append([])
|
|
89
|
+
continue
|
|
90
|
+
runs.append([])
|
|
91
|
+
for part in re.split(r"[^A-Za-zÀ-ɏ'’]+", tok):
|
|
92
|
+
if _WORD.match(part):
|
|
93
|
+
runs[-1].append(part)
|
|
94
|
+
runs.append([])
|
|
95
|
+
return [r for r in runs if r]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _name_end(run: list[str], kinds: list[int], i: int,
|
|
99
|
+
zipf: Callable[[str], float]) -> int:
|
|
100
|
+
"""End of a multiword name starting at run[i] (2-3 capitalized words,
|
|
101
|
+
one of them rare), or i when there is none. Very common words don't
|
|
102
|
+
start or end a name: "Hey Kosh" is a greeting, not a name."""
|
|
103
|
+
j = i
|
|
104
|
+
while j < len(run) and j - i < 3 and run[j][0].isupper() and not run[j].isupper():
|
|
105
|
+
j += 1
|
|
106
|
+
if j - i < 2 or zipf(run[i]) >= _ZIPF_COMMON or run[i] == run[i + 1]:
|
|
107
|
+
return i
|
|
108
|
+
while j - i >= 2 and zipf(run[j - 1]) >= _ZIPF_COMMON:
|
|
109
|
+
j -= 1
|
|
110
|
+
return j if j - i >= 2 and any(k == 3 for k in kinds[i:j]) else i
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def extract_terms(
|
|
114
|
+
texts: Iterable[str],
|
|
115
|
+
*,
|
|
116
|
+
language: str = "en",
|
|
117
|
+
exclude: Iterable[str] = (),
|
|
118
|
+
prefer: Iterable[str] = (),
|
|
119
|
+
max_terms: int = MAX_TERMS,
|
|
120
|
+
) -> list[str]:
|
|
121
|
+
"""Candidate hotwords from on-screen text, best first.
|
|
122
|
+
|
|
123
|
+
`texts` in priority order (title before selection). `exclude` holds terms
|
|
124
|
+
already biased (case-insensitive); `prefer` the user's full vocabulary,
|
|
125
|
+
whose long tail never fits the budget — seeing one of them on screen is
|
|
126
|
+
the strongest signal there is.
|
|
127
|
+
"""
|
|
128
|
+
from functools import lru_cache
|
|
129
|
+
|
|
130
|
+
from voiceio.wordfreq import _zipf
|
|
131
|
+
|
|
132
|
+
zipf = lru_cache(maxsize=4096)(lambda w: _zipf(w, language))
|
|
133
|
+
excluded = {t.lower() for t in exclude}
|
|
134
|
+
preferred = {t.lower() for t in prefer}
|
|
135
|
+
# lowercased term → [score, first seen, spelling as first seen]
|
|
136
|
+
scores: dict[str, list] = {}
|
|
137
|
+
|
|
138
|
+
def add(term: str, score: float) -> None:
|
|
139
|
+
key = term.lower()
|
|
140
|
+
if key in excluded:
|
|
141
|
+
return
|
|
142
|
+
if key in scores:
|
|
143
|
+
# Repeats add a little, so the term the page is about outranks a
|
|
144
|
+
# one-off, without letting a long page flood the list.
|
|
145
|
+
scores[key][0] += 0.5
|
|
146
|
+
return
|
|
147
|
+
scores[key] = [score + (3 if key in preferred else 0), len(scores), term]
|
|
148
|
+
|
|
149
|
+
texts = list(texts)
|
|
150
|
+
for rank, text in enumerate(texts):
|
|
151
|
+
weight = (len(texts) - rank) * 0.1 # earlier source wins ties
|
|
152
|
+
for run in _words((text or "")[:MAX_SCAN_CHARS]):
|
|
153
|
+
kinds = [_kind(w, zipf) for w in run]
|
|
154
|
+
i = 0
|
|
155
|
+
while i < len(run):
|
|
156
|
+
j = _name_end(run, kinds, i, zipf)
|
|
157
|
+
if j > i + 1:
|
|
158
|
+
add(" ".join(run[i:j]), 3 + weight)
|
|
159
|
+
i = j
|
|
160
|
+
continue
|
|
161
|
+
if kinds[i]:
|
|
162
|
+
add(run[i], kinds[i] + weight)
|
|
163
|
+
i += 1
|
|
164
|
+
|
|
165
|
+
ranked = sorted(scores.values(), key=lambda v: (-v[0], v[1]))
|
|
166
|
+
out: list[str] = []
|
|
167
|
+
seen_words: set[str] = set()
|
|
168
|
+
for _, _, term in ranked:
|
|
169
|
+
parts = term.lower().split()
|
|
170
|
+
if seen_words.intersection(parts):
|
|
171
|
+
continue # a name and its parts cost tokens twice for one word
|
|
172
|
+
out.append(term)
|
|
173
|
+
seen_words.update(parts)
|
|
174
|
+
if len(out) >= max_terms:
|
|
175
|
+
break
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def warm(language: str = "en") -> None:
|
|
180
|
+
"""Load the word frequency tables now, not on the first dictation."""
|
|
181
|
+
extract_terms(["Warmup"], language=language)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ── capture ────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _read_primary(timeout: float) -> str:
|
|
188
|
+
"""The PRIMARY selection (highlighted text). xclip through XWayland first:
|
|
189
|
+
on GNOME, wl-paste can take keyboard focus for a frame, which drops an
|
|
190
|
+
IBus preedit — never acceptable at the start of a dictation."""
|
|
191
|
+
from voiceio.platform import detect
|
|
192
|
+
p = detect()
|
|
193
|
+
if os.environ.get("DISPLAY") and shutil.which("xclip"):
|
|
194
|
+
argv = ["xclip", "-o", "-selection", "primary"]
|
|
195
|
+
elif p.is_wayland and p.desktop != "gnome" and shutil.which("wl-paste"):
|
|
196
|
+
argv = ["wl-paste", "-p", "--no-newline"]
|
|
197
|
+
else:
|
|
198
|
+
return ""
|
|
199
|
+
try:
|
|
200
|
+
out = subprocess.run(argv, capture_output=True, timeout=timeout)
|
|
201
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
202
|
+
return ""
|
|
203
|
+
if out.returncode != 0:
|
|
204
|
+
return ""
|
|
205
|
+
return out.stdout[:MAX_SCAN_CHARS * 4].decode("utf-8", "replace")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _read_title(_timeout: float) -> str:
|
|
209
|
+
from voiceio import retention
|
|
210
|
+
return retention.active_window_title() or ""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def capture(timeout: float = CAPTURE_TIMEOUT_SECS, *,
|
|
214
|
+
readers: dict[str, Callable[[float], str]] | None = None) -> ScreenContext:
|
|
215
|
+
"""Read the window title and selection in parallel; whatever isn't back
|
|
216
|
+
by `timeout` is dropped (its thread finishes on its own). Never raises."""
|
|
217
|
+
readers = readers or {"title": _read_title, "selection": _read_primary}
|
|
218
|
+
t0 = time.monotonic()
|
|
219
|
+
results: dict[str, str] = {}
|
|
220
|
+
|
|
221
|
+
def run(name: str, fn: Callable[[float], str]) -> None:
|
|
222
|
+
try:
|
|
223
|
+
results[name] = fn(timeout) or ""
|
|
224
|
+
except Exception: # noqa: BLE001 — context is a bonus
|
|
225
|
+
log.debug("screen context: %s failed", name, exc_info=True)
|
|
226
|
+
|
|
227
|
+
threads = [threading.Thread(target=run, args=item, daemon=True,
|
|
228
|
+
name=f"screen-{item[0]}") for item in readers.items()]
|
|
229
|
+
for t in threads:
|
|
230
|
+
t.start()
|
|
231
|
+
deadline = t0 + timeout
|
|
232
|
+
for t in threads:
|
|
233
|
+
t.join(max(0.0, deadline - time.monotonic()))
|
|
234
|
+
got = dict(results) # snapshot: a late reader must not change it
|
|
235
|
+
return ScreenContext(title=got.get("title", ""), selection=got.get("selection", ""),
|
|
236
|
+
secs=time.monotonic() - t0)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def describe() -> str:
|
|
240
|
+
"""For `voiceio doctor`: which sources this session can read."""
|
|
241
|
+
from voiceio.focus import focused_window
|
|
242
|
+
from voiceio.platform import detect
|
|
243
|
+
p = detect()
|
|
244
|
+
sources = []
|
|
245
|
+
if os.environ.get("DISPLAY") and shutil.which("xclip"):
|
|
246
|
+
sources.append("selection (xclip)")
|
|
247
|
+
elif p.is_wayland and p.desktop != "gnome" and shutil.which("wl-paste"):
|
|
248
|
+
sources.append("selection (wl-paste)")
|
|
249
|
+
if focused_window(xwayland=True) is not None:
|
|
250
|
+
sources.append("window title")
|
|
251
|
+
elif p.is_wayland and p.desktop in ("gnome", "kde"):
|
|
252
|
+
sources.append("window title of X11 apps only")
|
|
253
|
+
return ", ".join(sources) or "no source available"
|
voiceio/core/bias.py
CHANGED
|
@@ -7,10 +7,11 @@ back so the limited hotword budget follows what the user actually dictates.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import logging
|
|
10
|
+
from collections.abc import Sequence
|
|
10
11
|
from typing import TYPE_CHECKING
|
|
11
12
|
|
|
12
13
|
from voiceio.core.decoders.base import Bias, BiasMode, Capabilities
|
|
13
|
-
from voiceio.tokens import HOTWORDS_TOKEN_BUDGET
|
|
14
|
+
from voiceio.tokens import HOTWORDS_TOKEN_BUDGET, count_tokens, fit_prefix
|
|
14
15
|
|
|
15
16
|
if TYPE_CHECKING:
|
|
16
17
|
from voiceio.corrections import CorrectionDict
|
|
@@ -22,6 +23,15 @@ log = logging.getLogger(__name__)
|
|
|
22
23
|
# Used when a bounded decoder doesn't declare its own budget.
|
|
23
24
|
DEFAULT_HOTWORDS_TOKEN_BUDGET = HOTWORDS_TOKEN_BUDGET
|
|
24
25
|
|
|
26
|
+
# Most of the bounded budget on-screen terms may take (context_terms.py). One
|
|
27
|
+
# proper noun costs 4-6 tokens, so this is the 4-5 terms that matter for one
|
|
28
|
+
# dictation, and it only displaces the static vocabulary's lowest-ranked ~20%
|
|
29
|
+
# (~5-7 of ~34 terms) — and only while the screen actually offers terms. The
|
|
30
|
+
# vocabulary + screen part stays inside the same budget, so the hotwords (and
|
|
31
|
+
# decode cost) barely grow: only a displaced term that is also a correction
|
|
32
|
+
# target comes back in the tail.
|
|
33
|
+
SCREEN_TOKEN_SHARE = 24
|
|
34
|
+
|
|
25
35
|
|
|
26
36
|
class BiasProvider:
|
|
27
37
|
def __init__(
|
|
@@ -46,13 +56,29 @@ class BiasProvider:
|
|
|
46
56
|
"""The last `n` committed utterances (oldest first)."""
|
|
47
57
|
return self._prompts.recent(n) if self._prompts is not None else []
|
|
48
58
|
|
|
49
|
-
def
|
|
59
|
+
def screen_terms(self, texts: Sequence[str], caps: Capabilities) -> list[str]:
|
|
60
|
+
"""Terms worth biasing from on-screen text (window title, selection),
|
|
61
|
+
minus what the hotwords already carry. Never raises."""
|
|
62
|
+
if not any(texts) or not caps.bias & (BiasMode.HOTWORDS | BiasMode.UNBOUNDED):
|
|
63
|
+
return []
|
|
64
|
+
try:
|
|
65
|
+
from voiceio.context_terms import extract_terms
|
|
66
|
+
return extract_terms(
|
|
67
|
+
texts, language=self._language,
|
|
68
|
+
exclude=self._static_terms(caps) + self._rare_correction_targets(),
|
|
69
|
+
prefer=self._vocab.get_all(),
|
|
70
|
+
)
|
|
71
|
+
except Exception: # noqa: BLE001
|
|
72
|
+
log.debug("screen term extraction failed", exc_info=True)
|
|
73
|
+
return []
|
|
74
|
+
|
|
75
|
+
def bias_for(self, caps: Capabilities, screen_terms: Sequence[str] = ()) -> Bias:
|
|
50
76
|
"""A fresh Bias for one recording/request. Never raises: biasing is a
|
|
51
77
|
bonus and must not be able to block a dictation."""
|
|
52
78
|
hotwords = None
|
|
53
79
|
if caps.bias & (BiasMode.HOTWORDS | BiasMode.UNBOUNDED):
|
|
54
80
|
try:
|
|
55
|
-
hotwords = self._hotwords(caps) or None
|
|
81
|
+
hotwords = self._hotwords(caps, screen_terms) or None
|
|
56
82
|
except Exception: # noqa: BLE001
|
|
57
83
|
log.debug("hotword selection failed", exc_info=True)
|
|
58
84
|
prompt = None
|
|
@@ -72,16 +98,31 @@ class BiasProvider:
|
|
|
72
98
|
except Exception: # noqa: BLE001 — a counter must never break dictation
|
|
73
99
|
log.debug("vocab usage update failed", exc_info=True)
|
|
74
100
|
|
|
75
|
-
def
|
|
101
|
+
def _budget(self, caps: Capabilities) -> int:
|
|
102
|
+
return caps.max_bias_tokens or DEFAULT_HOTWORDS_TOKEN_BUDGET
|
|
103
|
+
|
|
104
|
+
def _static_terms(self, caps: Capabilities) -> list[str]:
|
|
76
105
|
if caps.bias & BiasMode.UNBOUNDED:
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
106
|
+
return self._vocab.get_all()
|
|
107
|
+
return self._vocab.get_selected(token_budget=self._budget(caps))
|
|
108
|
+
|
|
109
|
+
def _hotwords(self, caps: Capabilities, screen_terms: Sequence[str] = ()) -> str:
|
|
110
|
+
terms = self._static_terms(caps)
|
|
111
|
+
taken = {t.lower() for t in terms}
|
|
112
|
+
screen = [t for t in screen_terms if t.lower() not in taken]
|
|
113
|
+
if screen and not caps.bias & BiasMode.UNBOUNDED:
|
|
114
|
+
# Screen terms take their share off the TAIL of the ranked
|
|
115
|
+
# vocabulary, so the total stays inside the budget.
|
|
116
|
+
model = self._vocab.model_name
|
|
117
|
+
screen = fit_prefix(screen, SCREEN_TOKEN_SHARE, model)
|
|
118
|
+
if screen:
|
|
119
|
+
used = count_tokens(", ".join(screen), model)
|
|
120
|
+
terms = fit_prefix(terms, self._budget(caps) - used, model)
|
|
81
121
|
# Only RARE correction targets: common words add no bias and hundreds
|
|
82
122
|
# of them blow Whisper's shared prompt+output budget.
|
|
83
|
-
extra = [t for t in self._rare_correction_targets()
|
|
84
|
-
|
|
123
|
+
extra = [t for t in self._rare_correction_targets()
|
|
124
|
+
if t not in terms and t not in screen]
|
|
125
|
+
return ", ".join(terms + screen + extra)
|
|
85
126
|
|
|
86
127
|
def _rare_correction_targets(self) -> list[str]:
|
|
87
128
|
"""Cached against corrections.json's mtime (it rarely changes)."""
|
|
@@ -96,3 +137,4 @@ class BiasProvider:
|
|
|
96
137
|
]
|
|
97
138
|
self._rare_targets_mtime = mtime
|
|
98
139
|
return self._rare_targets
|
|
140
|
+
|
voiceio/core/engine.py
CHANGED
|
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import logging
|
|
12
12
|
import time
|
|
13
|
-
from collections.abc import Callable
|
|
13
|
+
from collections.abc import Callable, Sequence
|
|
14
14
|
from dataclasses import dataclass, field
|
|
15
15
|
from typing import TYPE_CHECKING
|
|
16
16
|
|
|
@@ -107,11 +107,17 @@ class Engine:
|
|
|
107
107
|
def recent(self, n: int = 3) -> list[str]:
|
|
108
108
|
return self._bias.recent(n)
|
|
109
109
|
|
|
110
|
-
def bias(self) -> Bias:
|
|
110
|
+
def bias(self, screen_terms: Sequence[str] = ()) -> Bias:
|
|
111
111
|
"""Biasing for the next recording/request. Corrections and vocabulary
|
|
112
|
-
are mtime-cached, so edits apply without a restart.
|
|
112
|
+
are mtime-cached, so edits apply without a restart. `screen_terms`
|
|
113
|
+
(from `screen_terms()`) take a small share of the hotword budget."""
|
|
113
114
|
self.corrections.load()
|
|
114
|
-
return self._bias.bias_for(self.decoder.capabilities)
|
|
115
|
+
return self._bias.bias_for(self.decoder.capabilities, screen_terms)
|
|
116
|
+
|
|
117
|
+
def screen_terms(self, texts: Sequence[str]) -> list[str]:
|
|
118
|
+
"""On-screen terms worth biasing this dictation toward (see
|
|
119
|
+
context_terms), minus what the hotwords already carry."""
|
|
120
|
+
return self._bias.screen_terms(texts, self.decoder.capabilities)
|
|
115
121
|
|
|
116
122
|
def record_usage(self, text: str) -> None:
|
|
117
123
|
"""Feed a committed utterance back: prompt history + vocab usage."""
|
voiceio/health.py
CHANGED
|
@@ -224,6 +224,12 @@ def _check_features() -> FeatureGroups:
|
|
|
224
224
|
else:
|
|
225
225
|
groups.accuracy["Vocabulary"] = "none"
|
|
226
226
|
|
|
227
|
+
if cfg.model.screen_vocabulary:
|
|
228
|
+
from voiceio.context_terms import describe as describe_screen
|
|
229
|
+
groups.accuracy["Screen vocabulary"] = f"on ({describe_screen()})"
|
|
230
|
+
else:
|
|
231
|
+
groups.accuracy["Screen vocabulary"] = "off"
|
|
232
|
+
|
|
227
233
|
groups.accuracy.update(_learning_status(cfg))
|
|
228
234
|
|
|
229
235
|
if cfg.postcorrect.enabled:
|
voiceio/learn/teacher.py
CHANGED
|
@@ -45,7 +45,7 @@ class WhisperTeacher:
|
|
|
45
45
|
# A background batch job (see cli.learn._yield_cpu): use every core idle.
|
|
46
46
|
self._model = load_model(model, device=device, compute_type=compute_type,
|
|
47
47
|
cpu_threads=max(4, (os.cpu_count() or 8) // 2))
|
|
48
|
-
self._language = language
|
|
48
|
+
self._language = None if language in ("", "auto") else language # None: detect
|
|
49
49
|
terms = [t for t in vocabulary if t]
|
|
50
50
|
# Prompt, not hotwords: hotwords are capped at 223 tokens; a prompt
|
|
51
51
|
# of the most important terms still biases spelling. The tokenizer
|
voiceio/postprocess.py
CHANGED
|
@@ -23,6 +23,21 @@ _NO_CASE_LANGUAGES = frozenset({"zh", "ja", "ko", "ar", "he", "th", "hi", "bn",
|
|
|
23
23
|
_FILLER_RE = re.compile(
|
|
24
24
|
r"[^\S\n]*,?[^\S\n]*\b(?:[Uu]h[-\s]?huh|[Mm]hm|[Uu]h+m*|[Uu]m+|[Hh]m{2,})\b[^\S\n]*,?[^\S\n]*",
|
|
25
25
|
)
|
|
26
|
+
# A word (2+ letters, not part of a dotted name or an ellipsis) ending in "."
|
|
27
|
+
# and followed by a lowercase word: a segment boundary, not a sentence end.
|
|
28
|
+
_SEGMENT_STOP_RE = re.compile(r"(?<![.\w])([A-Za-zÀ-ɏ]{2,})\.(\s+)(?=[a-zà-ÿ])")
|
|
29
|
+
_ABBREVIATIONS = frozenset({
|
|
30
|
+
"etc", "vs", "approx", "cf", "al", "mr", "mrs", "ms", "dr", "st", "jr", "sr",
|
|
31
|
+
"inc", "ltd", "co", "fig", "eq", "vol", "no", "pp", "ca", "min", "max", "sec",
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _not_an_ending(word: str) -> bool:
|
|
36
|
+
"""The word before a "." is an abbreviation or a dotted name, not a
|
|
37
|
+
sentence's last word."""
|
|
38
|
+
return "." in word or word.lower() in _ABBREVIATIONS
|
|
39
|
+
|
|
40
|
+
|
|
26
41
|
# A re-decode artifact is a whole duplicated sentence; require this many words
|
|
27
42
|
# so emphatic short repeats ("No. No.", "Stop. Stop.") are preserved.
|
|
28
43
|
_MIN_DEDUP_WORDS = 4
|
|
@@ -95,14 +110,28 @@ def cleanup(text: str, language: str = "en") -> str:
|
|
|
95
110
|
text = re.sub(r"\s+([.,;:?!])", r"\1", text)
|
|
96
111
|
|
|
97
112
|
if language not in _NO_CASE_LANGUAGES:
|
|
113
|
+
# Whisper closes every segment with "." even mid-sentence, and the next
|
|
114
|
+
# segment then carries on in lowercase ("a bit bigger. and remove the
|
|
115
|
+
# latch"): on real dictation that was 1 in 4 notes. A lowercase
|
|
116
|
+
# continuation means the sentence did not end, so it gets a comma —
|
|
117
|
+
# before the capitalization below would make the false stop permanent.
|
|
118
|
+
text = _SEGMENT_STOP_RE.sub(
|
|
119
|
+
lambda m: m.group(0) if m.group(1).lower() in _ABBREVIATIONS
|
|
120
|
+
else m.group(1) + "," + m.group(2),
|
|
121
|
+
text,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
98
125
|
# Capitalize first character
|
|
99
126
|
text = text[0].upper() + text[1:]
|
|
100
127
|
|
|
101
128
|
# Capitalize after sentence-ending punctuation (an ellipsis is a
|
|
102
|
-
# pause, not an ending
|
|
129
|
+
# pause, not an ending, and so is the dot of "etc.", "e.g." or a name
|
|
130
|
+
# like "hugo0.com")
|
|
103
131
|
text = re.sub(
|
|
104
|
-
r"(?<!\.\.)([.?!]\s+)([a-z\u00E0-\u00FF])",
|
|
105
|
-
lambda m: m.group(
|
|
132
|
+
r"(\S*)(?<!\.\.)([.?!]\s+)([a-z\u00E0-\u00FF])",
|
|
133
|
+
lambda m: m.group(0) if m.group(2)[0] == "." and _not_an_ending(m.group(1))
|
|
134
|
+
else m.group(1) + m.group(2) + m.group(3).upper(),
|
|
106
135
|
text,
|
|
107
136
|
)
|
|
108
137
|
|
voiceio/streaming.py
CHANGED
|
@@ -156,6 +156,8 @@ class StreamingSession:
|
|
|
156
156
|
self._decoder = decoder
|
|
157
157
|
# Captured once per session: vocabulary/prompt changes made while this
|
|
158
158
|
# recording runs apply to the next one, never halfway through this.
|
|
159
|
+
# The one exception is set_bias(): on-screen terms read just after
|
|
160
|
+
# the start.
|
|
159
161
|
self._bias = bias or Bias()
|
|
160
162
|
self._display = TypedText(typer)
|
|
161
163
|
# Output-ownership gate: the app sets this so a superseded session (its
|
|
@@ -278,6 +280,12 @@ class StreamingSession:
|
|
|
278
280
|
log.debug("Streaming session stopped, typed: %s", describe_transcript(self._display.text))
|
|
279
281
|
return self._display.text
|
|
280
282
|
|
|
283
|
+
def set_bias(self, bias: Bias) -> None:
|
|
284
|
+
"""Bias for passes that START from now on (one in flight keeps the
|
|
285
|
+
one it began with): the on-screen terms, captured off the hotkey path
|
|
286
|
+
after the session started."""
|
|
287
|
+
self._bias = bias
|
|
288
|
+
|
|
281
289
|
def cancel(self) -> bool:
|
|
282
290
|
"""Abandon the dictation: stop decoding and take back what was shown
|
|
283
291
|
(IBus preedit cleared, typed interim words deleted). False when the
|
voiceio/tokens.py
CHANGED
|
@@ -70,6 +70,23 @@ def truncate_to_tokens(text: str, max_tokens: int, model_name: str) -> str:
|
|
|
70
70
|
space = cut.find(" ")
|
|
71
71
|
return cut[space + 1:] if 0 <= space < 20 else cut
|
|
72
72
|
|
|
73
|
+
def fit_prefix(terms: list[str], budget: int, model_name: str) -> list[str]:
|
|
74
|
+
"""The longest prefix of `terms` whose ", "-joined form fits `budget`.
|
|
75
|
+
|
|
76
|
+
Measured joined, never summed per term: BPE merges across the separators,
|
|
77
|
+
so per-term counts double-count the joins and spend the budget ~2x too
|
|
78
|
+
fast. A binary search needs ~log2(n) encodes instead of n.
|
|
79
|
+
"""
|
|
80
|
+
lo, hi = 0, len(terms)
|
|
81
|
+
while lo < hi:
|
|
82
|
+
mid = (lo + hi + 1) // 2
|
|
83
|
+
if count_tokens(", ".join(terms[:mid]), model_name) <= budget:
|
|
84
|
+
lo = mid
|
|
85
|
+
else:
|
|
86
|
+
hi = mid - 1
|
|
87
|
+
return terms[:lo]
|
|
88
|
+
|
|
89
|
+
|
|
73
90
|
# Fallback ratio when no tokenizer is available. Measured over Hugo's actual
|
|
74
91
|
# vocabulary (proper nouns/technical terms), NOT prose — deliberately
|
|
75
92
|
# pessimistic so an estimate never over-fills the budget.
|
voiceio/vocabulary.py
CHANGED
|
@@ -110,7 +110,7 @@ def select_terms(
|
|
|
110
110
|
"""
|
|
111
111
|
import time
|
|
112
112
|
|
|
113
|
-
from voiceio.tokens import count_tokens
|
|
113
|
+
from voiceio.tokens import count_tokens, fit_prefix
|
|
114
114
|
|
|
115
115
|
if not terms or token_budget <= 0:
|
|
116
116
|
return []
|
|
@@ -121,20 +121,7 @@ def select_terms(
|
|
|
121
121
|
key=lambda it: (-_score(it[1], stats, now), it[0]),
|
|
122
122
|
)]
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
# one and summing overcounts badly — BPE merges across the ", " separators,
|
|
126
|
-
# so per-term counts double-count the joins and spend the budget ~2x too
|
|
127
|
-
# fast. Measuring the joined string is the only honest count, and a prefix
|
|
128
|
-
# search needs ~log2(n) encodes instead of n.
|
|
129
|
-
lo, hi = 0, len(ranked)
|
|
130
|
-
while lo < hi:
|
|
131
|
-
mid = (lo + hi + 1) // 2
|
|
132
|
-
if count_tokens(", ".join(ranked[:mid]), model_name) <= token_budget:
|
|
133
|
-
lo = mid
|
|
134
|
-
else:
|
|
135
|
-
hi = mid - 1
|
|
136
|
-
|
|
137
|
-
selected = ranked[:lo]
|
|
124
|
+
selected = fit_prefix(ranked, token_budget, model_name)
|
|
138
125
|
# Emit in ranked order — faster-whisper truncates hotwords from the tail, so
|
|
139
126
|
# anything dropped downstream is the lowest-scoring term.
|
|
140
127
|
if len(selected) < len(terms):
|
|
@@ -277,6 +264,10 @@ class VocabularyLoader:
|
|
|
277
264
|
self._loaded_all = True
|
|
278
265
|
self._selection_valid = False # terms changed → re-rank
|
|
279
266
|
|
|
267
|
+
@property
|
|
268
|
+
def model_name(self) -> str:
|
|
269
|
+
return self._model_cfg.name
|
|
270
|
+
|
|
280
271
|
def get_all(self) -> list[str]:
|
|
281
272
|
"""Every term, untruncated — for postcorrect, which has no token budget."""
|
|
282
273
|
self._ensure_all()
|
voiceio/wordfreq.py
CHANGED
|
@@ -41,14 +41,26 @@ _CONTRACTIONS = frozenset({
|
|
|
41
41
|
})
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
def _zipf(word: str, language: str) -> float:
|
|
45
|
+
"""Zipf frequency, 0.0 for a language wordfreq has no list for. "auto"
|
|
46
|
+
(detect per dictation) has no single list either: score it as English,
|
|
47
|
+
which is where the jargon in a vocabulary list usually comes from."""
|
|
48
|
+
if language in ("", "auto"):
|
|
49
|
+
language = "en"
|
|
50
|
+
try:
|
|
51
|
+
return zipf_frequency(word.lower(), language)
|
|
52
|
+
except LookupError:
|
|
53
|
+
return 0.0
|
|
54
|
+
|
|
55
|
+
|
|
44
56
|
def is_common(word: str, language: str = "en", threshold: float = _ZIPF_COMMON_THRESHOLD) -> bool:
|
|
45
57
|
"""Check if a word is common (high frequency). Used for suggesting corrections."""
|
|
46
|
-
return
|
|
58
|
+
return _zipf(word, language) >= threshold
|
|
47
59
|
|
|
48
60
|
|
|
49
61
|
def rank(word: str, language: str = "en") -> int | None:
|
|
50
62
|
"""Get approximate frequency rank. None if not found."""
|
|
51
|
-
z =
|
|
63
|
+
z = _zipf(word, language)
|
|
52
64
|
if z == 0.0:
|
|
53
65
|
return None
|
|
54
66
|
# Convert zipf to approximate rank (higher zipf = lower rank number)
|
|
@@ -61,7 +73,7 @@ def is_known(word: str, language: str = "en") -> bool:
|
|
|
61
73
|
wl = word.lower()
|
|
62
74
|
if wl in _CONTRACTIONS:
|
|
63
75
|
return True
|
|
64
|
-
return
|
|
76
|
+
return _zipf(wl, language) >= _ZIPF_KNOWN_THRESHOLD
|
|
65
77
|
|
|
66
78
|
|
|
67
79
|
def extract_words(text: str) -> list[str]:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|