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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 1.8.2
3
+ Version: 1.9.0
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -1,19 +1,20 @@
1
- python_voiceio-1.8.2.dist-info/licenses/LICENSE,sha256=Gz61o8jFTAvZUZyB3nWDB3DQQVuipjfPkVu9W8hBHM0,1072
2
- voiceio/__init__.py,sha256=d6593s-XBNvVxri9lr2qLUDZQ3Zk3-VXHEwdb4pj8qA,22
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=kOvdXNDovKInC1B5jCNx1iyjasFzLTQy_ZEizEF3YkI,42268
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=h70Pgsk9gYVkvFud71VMIFm39fG35MAmXHFz7TpLqBs,22725
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=0tsikLYK-COj4ILquLWci9-3kA7KoC-JMKlCtW9IKFw,22539
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=rzKmf_ZCskhGnqLEbHaiQX-Sz9yutdJwcwposz7GJ4Q,4661
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=Cfkr3b0vM8x9q7XYeXWl1KaG2xs5-t7lpsMrfL7KlmU,25610
35
+ voiceio/streaming.py,sha256=67hHvAQ7Z99iwtwy6BXPBPqE0q9mNpoP0Lhh8-RufIk,25968
35
36
  voiceio/suggest.py,sha256=jX4bCpnddyrteKfRBPbnUgf-Rk5145LFaSTat0_Ll2E,4836
36
- voiceio/tokens.py,sha256=m5tusR3zEBX7fRKaDUj4z-DSv8NcMhE-02HviAziENQ,5710
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=i2RRYGmT81tJcYhnZ4sVuVbmZjClqFZVlNUUFa35tuY,11351
41
+ voiceio/vocabulary.py,sha256=V74eubSYn6hAQGIgRJ74p22ztvxw2dtal-IbB4cVBr8,10895
41
42
  voiceio/watchdog.py,sha256=XnrpM3ULeUoJ-sMt_aNNzJSLqOuK4ocJAMHQ00knYuw,7838
42
- voiceio/wordfreq.py,sha256=PTAoaEUX1D2Y12jjd8A9cFTqsZkDDZt6eesBBL_4toA,3151
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=9i1mW4XUbSN74_afZjt1gbL77flx8WzctLHW0oKHArs,32300
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=butCKgKlCFKsKXGmnqK-816wNmWGtf64E6k9QJEiTnA,3892
55
- voiceio/core/engine.py,sha256=jWmFsgx3-Nx6AhuDW60jSWCPsJC2Cc4mKhKO9jVqxK8,8247
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=uF4hQkrdjv012TTBRSwrhQqqAeCFNoidABcrgzSVUoI,3696
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.8.2.dist-info/METADATA,sha256=2SK3PktKq9k4l5bG9ki71XPCxzDES_Nt4SIzJZtjoFk,7519
136
- python_voiceio-1.8.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
137
- python_voiceio-1.8.2.dist-info/entry_points.txt,sha256=iDIDmhvQ3xeN_CWsFne1t9STZ08I25I_wp0xmde_vMs,94
138
- python_voiceio-1.8.2.dist-info/top_level.txt,sha256=piwtn309lD6uexQyXdZ-efAVBJF9y6Wfr48Z-8zkNhg,8
139
- python_voiceio-1.8.2.dist-info/RECORD,,
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.8.2"
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, daemon=True).start()
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
- self._context_title = retention.active_window_title()
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
@@ -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 bias_for(self, caps: Capabilities) -> Bias:
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 _hotwords(self, caps: Capabilities) -> str:
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
- terms = self._vocab.get_all()
78
- else:
79
- budget = caps.max_bias_tokens or DEFAULT_HOTWORDS_TOKEN_BUDGET
80
- terms = self._vocab.get_selected(token_budget=budget)
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() if t not in terms]
84
- return ", ".join(terms + extra)
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(1) + m.group(2).upper(),
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
- # Binary-search the longest ranked prefix that fits. Costing terms one by
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 zipf_frequency(word.lower(), language) >= threshold
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 = zipf_frequency(word.lower(), language)
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 zipf_frequency(wl, language) >= _ZIPF_KNOWN_THRESHOLD
76
+ return _zipf(wl, language) >= _ZIPF_KNOWN_THRESHOLD
65
77
 
66
78
 
67
79
  def extract_words(text: str) -> list[str]: