python-voiceio 0.8.2__py3-none-any.whl → 0.8.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.8.2
3
+ Version: 0.8.3
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -1,12 +1,12 @@
1
- python_voiceio-0.8.2.dist-info/licenses/LICENSE,sha256=Gz61o8jFTAvZUZyB3nWDB3DQQVuipjfPkVu9W8hBHM0,1072
2
- voiceio/__init__.py,sha256=B7GiO0rd49YwtLYjvPg4lmCZEDlMTonslQKdSImaMJk,22
1
+ python_voiceio-0.8.3.dist-info/licenses/LICENSE,sha256=Gz61o8jFTAvZUZyB3nWDB3DQQVuipjfPkVu9W8hBHM0,1072
2
+ voiceio/__init__.py,sha256=otnwfmvJLUamPajTPUaIekiO9mA-2HPi-_h_E0N-uOQ,22
3
3
  voiceio/__main__.py,sha256=xT5QCGGreYMisHO7Lh_Y-xAQ2TOzG2D7npKknGNcWSY,53
4
- voiceio/app.py,sha256=vO5Sk12CxT2aFtt14XP1Vyen_leUQcqYftswURjN58U,71360
5
- voiceio/audit.py,sha256=vX3x-YAhmt6EZYRZd0_6wvTquZu5sxGMvH45BhSBnCk,14915
4
+ voiceio/app.py,sha256=ScrUVDQQ_YbAFKKSMOhaRPX_WTcPuenhnRdSzWMxThE,73785
5
+ voiceio/audit.py,sha256=p4F2BBX4wEC9eS8MSplzfXL52XC2dyIKbgrRvR-WbTU,15802
6
6
  voiceio/autocorrect.py,sha256=jGsglEYwAHWk2uWedeEZzyAMW2LWO_8nQGZgrWYy4Dc,26552
7
7
  voiceio/autocorrect_state.py,sha256=BNzdHP5slcOV_-OU4MlaKE7xgVJzrPncsYFK30JWTs8,6858
8
8
  voiceio/backends.py,sha256=-rs1YhPblub4NjFb6qCD7Ih9FqBRXtPuvjVv9bQoQ1w,368
9
- voiceio/cli.py,sha256=_HfcWQt2Jf34YfI4W1RavsyqegQy9jrg0nwLe6DvD6E,71639
9
+ voiceio/cli.py,sha256=GLDME0dwLvw8CgJ2Ub5yzYWgZMhPYdFZNkeUnLBMpsk,73789
10
10
  voiceio/clipboard_read.py,sha256=lz75CkRGh7pyvO6xvoUmxJDl-Gwie1xlXQHK70PO84I,4261
11
11
  voiceio/commands.py,sha256=Vhtn8s5G5OcCbRN44YZ-_14Fa85ISXl_3Ix-KqSHs2A,4694
12
12
  voiceio/config.py,sha256=kpKileJk5mAp4eB7tXBblzOxmlxIfMvRDzY2tjwTkSQ,14465
@@ -30,12 +30,14 @@ voiceio/retention.py,sha256=i_VWPCyykT4kQ1uKilTplpiIrACTYljn09BU1vL71A0,6019
30
30
  voiceio/service.py,sha256=e3j5OxhuwEqcgXlmQ9AhgdNrrZPwCNfzj-BmABuBYRw,12145
31
31
  voiceio/snapshots.py,sha256=bm-lzIeYucDUDlPBZiFiWd-jyB2I6Ft1jRANZCO6go4,2351
32
32
  voiceio/streaming.py,sha256=mXkQEEMGMbABSRNG2H7MYObKUdCwfqMvNIZISGZ5uic,25360
33
- voiceio/transcriber.py,sha256=NWYMZpIhYccUBKzcFyMzjH1MTMNv4bG9op5Qw5reOhI,11608
33
+ voiceio/tokens.py,sha256=BbOXnsDIfWYMVbSp25PYG1UtK7-7OtZ7q7l4kOIm9OY,5292
34
+ voiceio/transcriber.py,sha256=12o3POqS8nqJdXXQH-riZlSVChZPP_UP1bNLd7qNfTs,12303
34
35
  voiceio/vad.py,sha256=72_ICk4jSuSPYvTHdHYtHzY8W5uaGEOz5FTdqkolJi8,5327
35
- voiceio/vocabulary.py,sha256=eGV7QZs3tN6Cw5YpyoZsVEk2M5fJvie1n2XFi9CD4hw,6321
36
- voiceio/wizard.py,sha256=AwjwrocrQp0DPSOMZ0m6z6G3hlo9VPceEwPzR9-xXe0,80227
36
+ voiceio/vocab_stats.py,sha256=obz_mdUqlSoAD15wkIQM-1X3AXNum74VKDX_bu1mEZw,5403
37
+ voiceio/vocabulary.py,sha256=_7LjCS5Mqqh7PLKl8LMPIPsIlyfNKTaaLlfJ2BDfwtM,11355
38
+ voiceio/wizard.py,sha256=1zIYGixPsyu2OhNBnxk-DiErUgmVUvxhWvGoauiQXFY,80349
37
39
  voiceio/wordfreq.py,sha256=2UMjW1xIFqo5EchXzsXPizUhRb9N18CVgjnXso1wsgw,2382
38
- voiceio/worker.py,sha256=Ec_OChl6CCX1XHdNYdmx_4l4_sIRexyxih64-PBNhjs,3233
40
+ voiceio/worker.py,sha256=AkEYu2OzAup00ew24DYO7SfA91xTBnFvbtioGE0nsK4,3520
39
41
  voiceio/hotkeys/__init__.py,sha256=rGXSGZLD2mS_Ep2HfVdRdkUKNr4xagySlL1FM00cYgk,735
40
42
  voiceio/hotkeys/base.py,sha256=L3iBh368sjWpt64JZpocH9DibYEZ0V16-rYflLYbpZY,699
41
43
  voiceio/hotkeys/chain.py,sha256=8pvPhipmxWwmGG9DRXy8dlEKTPFILxNzYO-adcvw5Ps,2800
@@ -71,8 +73,8 @@ voiceio/typers/pynput_type.py,sha256=DTtkT59M-EKOAlTNkvcZrRvf_Fq52OpI8S5t_8S-QAY
71
73
  voiceio/typers/wtype.py,sha256=d1wG-HZdYDQxXIysV_Xvk9HixDpaJgl3VEoKbP5vhIs,1786
72
74
  voiceio/typers/xdotool.py,sha256=dh1zhPqUT8ihJahJ7ZKm6PtfYY087UAzADx664DvQOM,1356
73
75
  voiceio/typers/ydotool.py,sha256=dt0W9ot__W8LQG2so8vZLGIw9leK2Q6g3i-Voo0LXE4,5327
74
- python_voiceio-0.8.2.dist-info/METADATA,sha256=tHzCPzNpT-x1-89kZF547QCd-Qo8_XqiL7NIsia6gNw,15877
75
- python_voiceio-0.8.2.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
76
- python_voiceio-0.8.2.dist-info/entry_points.txt,sha256=U64fA65zxzyLoC8bgbn2ztQVWHLsc0o0H0qCk1J9DMc,218
77
- python_voiceio-0.8.2.dist-info/top_level.txt,sha256=piwtn309lD6uexQyXdZ-efAVBJF9y6Wfr48Z-8zkNhg,8
78
- python_voiceio-0.8.2.dist-info/RECORD,,
76
+ python_voiceio-0.8.3.dist-info/METADATA,sha256=TjTQkGyrAIWXJAJAPgjPBG92ckWeFGI2ellyEcLuagc,15877
77
+ python_voiceio-0.8.3.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
78
+ python_voiceio-0.8.3.dist-info/entry_points.txt,sha256=U64fA65zxzyLoC8bgbn2ztQVWHLsc0o0H0qCk1J9DMc,218
79
+ python_voiceio-0.8.3.dist-info/top_level.txt,sha256=piwtn309lD6uexQyXdZ-efAVBJF9y6Wfr48Z-8zkNhg,8
80
+ python_voiceio-0.8.3.dist-info/RECORD,,
voiceio/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.8.2"
1
+ __version__ = "0.8.3"
voiceio/app.py CHANGED
@@ -97,10 +97,20 @@ def _import_graphical_env() -> None:
97
97
  _HEALTH_CHECK_INTERVAL = 10 # seconds between health checks
98
98
 
99
99
  # Whisper's decoder has a hard 448-token sequence budget shared by hotwords,
100
- # initial_prompt AND the transcription output. Hotwords + prompt must stay
101
- # well under half of it or output gets truncated mid-utterance.
102
- # ~600 chars ≈ 150 tokens.
103
- _HOTWORDS_MAX_CHARS = 600
100
+ # initial_prompt AND the transcription output. Hotwords + prompt must stay well
101
+ # under half of it or output gets truncated mid-utterance.
102
+ #
103
+ # This is a TOKEN budget, counted for real (voiceio.tokens) — the old 600-char
104
+ # proxy assumed ~4 chars/token, but proper nouns (the whole point of a custom
105
+ # vocabulary) run ~2.6, so "600 chars" was really 238 tokens: already past
106
+ # faster-whisper's own 223-token hotwords cap, which then silently dropped the
107
+ # tail.
108
+ #
109
+ # Derived, not guessed: 448 total - 200 output reserve - 120 prompt - 4 overhead
110
+ # = 124. At ~3.5 tokens/term that is ~34 terms. Fewer terms than the old config
111
+ # pretended to carry, but they are real: the old 223-token list left ~50 tokens
112
+ # to transcribe with and truncated freeze chunks mid-sentence.
113
+ _HOTWORDS_TOKEN_BUDGET = 120
104
114
 
105
115
 
106
116
  class _State(enum.Enum):
@@ -309,30 +319,56 @@ class VoiceIO:
309
319
  self._do_start()
310
320
 
311
321
  def _refresh_hotwords(self) -> None:
312
- """Rebuild Whisper hotwords from vocabulary + correction targets.
322
+ """Rebuild Whisper hotwords: rank by usage, fill the token budget.
313
323
 
314
- The vocabulary read is mtime-cached (only a `stat` when unchanged), and
315
- `set_hotwords` is called only when the merged string actually changed,
316
- so this stays cheap enough to run on every recording start.
324
+ Selection is mandatory and permanent the budget holds ~40-60 terms and
325
+ the vocabulary only grows — so the slots go to the terms actually being
326
+ dictated (voiceio.vocab_stats), highest-scoring first.
327
+
328
+ Both reads are mtime-cached and `set_hotwords` only fires when the string
329
+ changed, so the steady-state cost here is a couple of `stat` calls. That
330
+ matters: this runs synchronously before `recorder.start()`.
317
331
  """
318
- vocab = self._vocab_loader.get()
332
+ terms = self._vocab_loader.get_selected(token_budget=_HOTWORDS_TOKEN_BUDGET)
319
333
  # Only merge RARE correction targets (proper nouns, technical terms):
320
334
  # common-word targets ("review", "company") add nothing to decoder bias
321
335
  # and hundreds of them blow Whisper's 448-token prompt+output budget,
322
336
  # which truncates transcriptions mid-utterance.
323
- from voiceio.wordfreq import is_common
324
- vocab_terms = [
325
- t for t in self._corrections.vocabulary_terms()
326
- if not is_common(t, self.cfg.model.language)
327
- ]
328
- if vocab_terms:
329
- extra = ", ".join(vocab_terms)
330
- vocab = f"{vocab}, {extra}" if vocab else extra
331
- vocab = vocab[:_HOTWORDS_MAX_CHARS]
337
+ vocab = ", ".join(terms + self._rare_correction_targets())
332
338
  if vocab != self._hotwords:
333
339
  self._hotwords = vocab
334
- if vocab:
335
- self.transcriber.set_hotwords(vocab)
340
+ # Set unconditionally: an emptied vocabulary must CLEAR the hotwords,
341
+ # not leave the previous string biasing every future decode.
342
+ self.transcriber.set_hotwords(vocab or None)
343
+
344
+ def _record_vocab_usage(self, text: str) -> None:
345
+ """Record which vocabulary terms this utterance used (finalize path only).
346
+
347
+ This is the feedback signal that decides who gets the hotword budget next
348
+ time. Deliberately not on the recording-start path, and never fatal — a
349
+ usage counter must not be able to break a dictation.
350
+ """
351
+ try:
352
+ from voiceio import vocab_stats
353
+ vocab_stats.update_from_text(text, self._vocab_loader.get_all())
354
+ except Exception: # noqa: BLE001
355
+ log.debug("vocab usage update failed", exc_info=True)
356
+
357
+ def _rare_correction_targets(self) -> list[str]:
358
+ """Correction targets worth biasing, cached against corrections mtime.
359
+
360
+ `is_common` over every target used to be recomputed on each recording
361
+ start; the result only changes when corrections.json does.
362
+ """
363
+ mtime = getattr(self._corrections, "_mtime", None)
364
+ if getattr(self, "_rare_targets_mtime", object()) != mtime:
365
+ from voiceio.wordfreq import is_common
366
+ self._rare_targets = [
367
+ t for t in self._corrections.vocabulary_terms()
368
+ if not is_common(t, self.cfg.model.language)
369
+ ]
370
+ self._rare_targets_mtime = mtime
371
+ return self._rare_targets
336
372
 
337
373
  def _do_start(self) -> None:
338
374
  """Transition to RECORDING."""
@@ -432,8 +468,13 @@ class VoiceIO:
432
468
  if self._postcorrect is not None:
433
469
  # Freshest context for the finalize pass: this recording's window
434
470
  # title (captured at start) and the latest transcripts/vocabulary.
471
+ #
472
+ # The FULL vocabulary, not the hotwords string: the decoder can only
473
+ # be biased with ~40-60 terms, but this is an LLM with no 448-token
474
+ # budget. The long tail that can never fit in hotwords gets its only
475
+ # chance to be corrected here.
435
476
  self._postcorrect.set_context(
436
- vocabulary=self._hotwords,
477
+ vocabulary=", ".join(self._vocab_loader.get_all()),
437
478
  recent=self._prompt_builder.recent(3),
438
479
  title=self._context_title,
439
480
  )
@@ -552,6 +593,7 @@ class VoiceIO:
552
593
  self._play_feedback(final_text)
553
594
  stored = self._strip_voice_prefix(final_text)
554
595
  self._prompt_builder.add_transcript(stored)
596
+ self._record_vocab_usage(stored)
555
597
  latency = dict(session.final_latency)
556
598
  # stop-to-commit: what the user actually waits for
557
599
  latency["finalize_total"] = round(time.monotonic() - t_final, 3)
voiceio/audit.py CHANGED
@@ -169,9 +169,11 @@ def _read_metrics(path: Path | None = None) -> list[dict]:
169
169
 
170
170
  def _default_teacher():
171
171
  """Lazily build the offline teacher transcriber (distil-large-v3)."""
172
- from faster_whisper import WhisperModel
172
+ from voiceio.worker import load_model
173
173
 
174
- model = WhisperModel("distil-large-v3", device="cpu", compute_type="int8")
174
+ # Cache-first: the module docstring's "nothing here uses the network" only
175
+ # holds if the load itself can't reach out and hang (see load_model).
176
+ model = load_model("distil-large-v3")
175
177
 
176
178
  def _transcribe(wav_path: Path) -> str:
177
179
  segments, _ = model.transcribe(
@@ -323,14 +325,42 @@ def run_audit(
323
325
  return report
324
326
 
325
327
 
328
+ def _last_seen_map(terms: list[str]) -> dict[str, float]:
329
+ """When each term was last dictated.
330
+
331
+ Prefers vocab_stats.json, which the runtime maintains per utterance. Falls
332
+ back to scanning history only when no stats exist yet (fresh install, or a
333
+ daemon that hasn't finalized a dictation since stats landed) — that scan is
334
+ O(entries x terms) over the whole history, which at 6k entries and 300+
335
+ terms is not worth running when the answer is already recorded.
336
+ """
337
+ from voiceio.vocab_stats import VocabStats
338
+
339
+ stats = VocabStats()
340
+ stats.load()
341
+ if stats.as_dict():
342
+ return {t: float(stats.get(t).get("last_seen_ts", 0.0) or 0.0) for t in terms}
343
+
344
+ from voiceio import history
345
+
346
+ last_seen: dict[str, float] = {}
347
+ for e in history.read(limit=0):
348
+ text = (e.get("text") or "").lower()
349
+ ts = e.get("ts", 0.0)
350
+ for term in terms:
351
+ if term.lower() in text and ts > last_seen.get(term, 0.0):
352
+ last_seen[term] = ts
353
+ return last_seen
354
+
355
+
326
356
  def _audit_vocabulary(cfg, teacher_texts, now, report) -> None:
327
357
  """Confirm vocab terms the teacher heard; age out terms unseen for 90 days.
328
358
 
329
- Aging moves a term to the bottom of vocabulary.txt rather than deleting it:
330
- the 800-char loader truncates the tail, so stale terms fall out of the
331
- hotword budget but can rejoin if used again.
359
+ Aging moves a term to the bottom of vocabulary.txt rather than deleting it.
360
+ File order is now only the tie-break for terms with no usage signal —
361
+ vocabulary.select_terms ranks by vocab_stats first but keeping stale terms
362
+ at the bottom still means they lose ties, and they rejoin on next use.
332
363
  """
333
- from voiceio import history
334
364
  from voiceio.vocabulary import _read_terms, resolve_vocab_path
335
365
 
336
366
  path = resolve_vocab_path(cfg.model)
@@ -341,16 +371,7 @@ def _audit_vocabulary(cfg, teacher_texts, now, report) -> None:
341
371
  return
342
372
 
343
373
  teacher_blob = "\n".join(teacher_texts)
344
- # Last time each term appeared in a live transcript.
345
- entries = history.read(limit=0)
346
- last_seen: dict[str, float] = {}
347
- for e in entries:
348
- text = e.get("text", "")
349
- ts = e.get("ts", 0.0)
350
- low = text.lower()
351
- for term in terms:
352
- if term.lower() in low and ts > last_seen.get(term, 0.0):
353
- last_seen[term] = ts
374
+ last_seen = _last_seen_map(terms)
354
375
 
355
376
  fresh: list[str] = []
356
377
  aged: list[str] = []
voiceio/cli.py CHANGED
@@ -83,6 +83,8 @@ def main() -> None:
83
83
  p_correct.add_argument("wrong", nargs="?", help="Misheard word/phrase")
84
84
  p_correct.add_argument("right", nargs="?", help="Correct replacement")
85
85
  p_correct.add_argument("--list", action="store_true", help="List all corrections")
86
+ p_correct.add_argument("--vocab", action="store_true",
87
+ help="Show vocabulary and which terms fit the decoder budget")
86
88
  p_correct.add_argument("--remove", metavar="WORD", help="Remove a correction")
87
89
  p_correct.add_argument("--flagged", action="store_true",
88
90
  help="Show words flagged by 'correct that'")
@@ -718,6 +720,53 @@ def _cmd_uninstall() -> None:
718
720
  print("\nvoiceio fully removed.")
719
721
 
720
722
 
723
+ def _show_vocab() -> None:
724
+ """Show the vocabulary split by what actually reaches the decoder.
725
+
726
+ Whisper can only be biased with ~35 terms (a hard 223-token hotwords cap,
727
+ and proper nouns cost 5-7 tokens each). Everything past that is not wasted —
728
+ it still reaches the post-correction LLM — but it does NOT bias decoding,
729
+ and until now there was no way to see which side of the line a term fell on.
730
+ """
731
+ from voiceio.app import _HOTWORDS_TOKEN_BUDGET
732
+ from voiceio.config import load
733
+ from voiceio.tokens import count_tokens
734
+ from voiceio.vocab_stats import VocabStats
735
+ from voiceio.vocabulary import load_terms, select_terms
736
+ from voiceio.wizard import BOLD, DIM, GREEN, RESET
737
+
738
+ cfg = load()
739
+ terms = load_terms(cfg.model)
740
+ if not terms:
741
+ print("No vocabulary configured.")
742
+ print("\nTerms are learned automatically: voiceio correct --auto")
743
+ return
744
+
745
+ stats = VocabStats()
746
+ stats.load()
747
+ selected = select_terms(terms, token_budget=_HOTWORDS_TOKEN_BUDGET,
748
+ model_name=cfg.model.name, stats=stats)
749
+ used = count_tokens(", ".join(selected), cfg.model.name)
750
+ sel = set(selected)
751
+
752
+ print(f"{BOLD}Biasing the decoder{RESET} "
753
+ f"{DIM}({used}/{_HOTWORDS_TOKEN_BUDGET} tokens){RESET}")
754
+ for t in selected:
755
+ rec = stats.get(t)
756
+ hits = rec.get("hits", 0)
757
+ print(f" {GREEN}●{RESET} {t}" + (f" {DIM}({hits} uses){RESET}" if hits else ""))
758
+
759
+ tail = [t for t in terms if t not in sel]
760
+ if tail:
761
+ print(f"\n{BOLD}Post-correction only{RESET} "
762
+ f"{DIM}({len(tail)} terms — past the decoder's token budget,{RESET}")
763
+ print(f" {DIM}still used by the correction pass){RESET}")
764
+ shown = ", ".join(tail[:12])
765
+ print(f" {DIM}{shown}{'...' if len(tail) > 12 else ''}{RESET}")
766
+
767
+ print(f"\n{len(terms)} term(s): {len(selected)} biasing, {len(tail)} tail")
768
+
769
+
721
770
  def _cmd_correct(args: argparse.Namespace) -> None:
722
771
  """Manage the corrections dictionary."""
723
772
  from voiceio.corrections import CorrectionDict
@@ -734,6 +783,10 @@ def _cmd_correct(args: argparse.Namespace) -> None:
734
783
  print(f"\n{len(corrections)} correction(s)")
735
784
  return
736
785
 
786
+ if args.vocab:
787
+ _show_vocab()
788
+ return
789
+
737
790
  if args.remove:
738
791
  if cd.remove(args.remove):
739
792
  print(f"Removed: {args.remove}")
voiceio/tokens.py ADDED
@@ -0,0 +1,138 @@
1
+ """Exact Whisper token counting.
2
+
3
+ Every budget in this codebase used to be a character count with a hand-guessed
4
+ chars→tokens ratio, and the guesses were wrong in the direction that matters:
5
+ proper nouns — the whole point of a custom vocabulary — tokenize at ~2.6
6
+ chars/token against the ~4.0 of ordinary prose. The old 600-char hotwords cap
7
+ was really 238 tokens, already past faster-whisper's 223-token ceiling, so the
8
+ library silently dropped the tail of the list.
9
+
10
+ Counting for real is cheap: `tokenizers` is already a faster-whisper dependency
11
+ and `tokenizer.json` ships inside the cached model snapshot, so this needs no
12
+ model weights and no network. Measured: ~53ms to load, ~17ms to encode a full
13
+ vocabulary — and callers only pay it when the vocabulary actually changes.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from pathlib import Path
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ # faster-whisper truncates hotwords at `max_length // 2 - 1` (transcribe.py).
23
+ # Anything past this is silently discarded by the library.
24
+ HOTWORDS_TOKEN_CAP = 223
25
+
26
+ # Whisper's total sequence budget, shared by hotwords + initial_prompt + OUTPUT.
27
+ MAX_SEQUENCE_TOKENS = 448
28
+
29
+ # Tokens reserved for the transcription itself. A decode window is at most 30s
30
+ # of speech: ~100 words at a fast 200wpm, ~135 tokens, plus timestamps. 200 is
31
+ # that with headroom. Starve this and Whisper truncates the user mid-sentence —
32
+ # silently, which is how it went unnoticed before.
33
+ OUTPUT_RESERVE_TOKENS = 200
34
+
35
+ # Ceiling on initial_prompt + per-call freeze context combined. These are joined
36
+ # in Transcriber.transcribe and were previously uncapped: PromptBuilder's 300
37
+ # chars plus streaming's 400-char context ran ~171 tokens, which — on top of a
38
+ # 223-token hotwords list — left ~50 tokens of output. Freeze chunks hold 45-60
39
+ # words; they were being cut off.
40
+ PROMPT_TOKEN_BUDGET = 120
41
+
42
+
43
+ def truncate_to_tokens(text: str, max_tokens: int, model_name: str) -> str:
44
+ """Trim `text` from the FRONT to fit `max_tokens`.
45
+
46
+ Front-trimming because prompts are conditioning context: the words nearest
47
+ the audio matter most, so the oldest context is what should go.
48
+ """
49
+ if not text or max_tokens <= 0:
50
+ return "" if max_tokens <= 0 else text
51
+ if count_tokens(text, model_name) <= max_tokens:
52
+ return text
53
+ # Binary search the cut point rather than encoding once per character.
54
+ lo, hi = 0, len(text)
55
+ while lo < hi:
56
+ mid = (lo + hi) // 2
57
+ if count_tokens(text[mid:], model_name) <= max_tokens:
58
+ hi = mid
59
+ else:
60
+ lo = mid + 1
61
+ cut = text[lo:]
62
+ # Snap to a word boundary so we never feed a half-word fragment.
63
+ space = cut.find(" ")
64
+ return cut[space + 1:] if 0 <= space < 20 else cut
65
+
66
+ # Fallback ratio when no tokenizer is available. Measured over Hugo's actual
67
+ # vocabulary (proper nouns/technical terms), NOT prose — deliberately
68
+ # pessimistic so an estimate never over-fills the budget.
69
+ _CHARS_PER_TOKEN = 2.6
70
+
71
+ _cache: dict[str, object] = {}
72
+ _warned = False
73
+
74
+
75
+ def _find_tokenizer(model_name: str) -> Path | None:
76
+ """Locate tokenizer.json inside the cached model snapshot.
77
+
78
+ Cache-only by construction: we glob the HF cache rather than asking
79
+ huggingface_hub to resolve anything, because a cached model must never
80
+ depend on the network (see worker.load_model).
81
+ """
82
+ p = Path(model_name).expanduser()
83
+ if p.is_dir(): # explicit local model directory
84
+ cand = p / "tokenizer.json"
85
+ return cand if cand.exists() else None
86
+
87
+ from huggingface_hub.constants import HF_HUB_CACHE
88
+
89
+ # faster-whisper resolves bare names like "small" to Systran/faster-whisper-*.
90
+ repo = model_name if "/" in model_name else f"Systran/faster-whisper-{model_name}"
91
+ stem = "models--" + repo.replace("/", "--")
92
+ hits = sorted(Path(HF_HUB_CACHE).glob(f"{stem}/snapshots/*/tokenizer.json"))
93
+ return hits[-1] if hits else None
94
+
95
+
96
+ def _tokenizer(model_name: str):
97
+ """Lazily load and cache the tokenizer for `model_name`, or None."""
98
+ if model_name in _cache:
99
+ return _cache[model_name]
100
+
101
+ tok = None
102
+ try:
103
+ path = _find_tokenizer(model_name)
104
+ if path is not None:
105
+ from tokenizers import Tokenizer
106
+
107
+ tok = Tokenizer.from_file(str(path))
108
+ except Exception as e: # noqa: BLE001 — never break dictation over a budget
109
+ log.debug("Tokenizer unavailable for '%s': %s", model_name, e)
110
+
111
+ _cache[model_name] = tok
112
+ return tok
113
+
114
+
115
+ def count_tokens(text: str, model_name: str) -> int:
116
+ """Count Whisper tokens in `text`, estimating if no tokenizer is cached.
117
+
118
+ The leading space matches how faster-whisper encodes hotwords/prompts
119
+ (`tokenizer.encode(" " + text.strip())`).
120
+ """
121
+ global _warned
122
+ if not text:
123
+ return 0
124
+
125
+ tok = _tokenizer(model_name)
126
+ if tok is None:
127
+ if not _warned:
128
+ _warned = True
129
+ log.warning(
130
+ "No tokenizer for '%s'; estimating budgets at %.1f chars/token",
131
+ model_name, _CHARS_PER_TOKEN,
132
+ )
133
+ return int(len(text) / _CHARS_PER_TOKEN) + 1
134
+
135
+ try:
136
+ return len(tok.encode(" " + text.strip()).ids)
137
+ except Exception: # noqa: BLE001
138
+ return int(len(text) / _CHARS_PER_TOKEN) + 1
voiceio/transcriber.py CHANGED
@@ -12,6 +12,8 @@ from typing import TYPE_CHECKING
12
12
 
13
13
  import numpy as np
14
14
 
15
+ from voiceio.tokens import PROMPT_TOKEN_BUDGET, truncate_to_tokens
16
+
15
17
  if TYPE_CHECKING:
16
18
  from voiceio.config import ModelConfig
17
19
 
@@ -188,8 +190,16 @@ class Transcriber:
188
190
  audio = normalize_audio(audio)
189
191
  audio_b64 = base64.b64encode(audio.tobytes()).decode("ascii")
190
192
  req = {"audio_b64": audio_b64, "options": {"beam_size": 5 if final else 1}}
193
+ # Cap the combined prompt. initial_prompt (recent transcripts) and
194
+ # `context` (streaming's frozen-text tail) are both bounded in CHARS
195
+ # by their callers, but nothing bounded their SUM in tokens — and
196
+ # Whisper shares one 448-token budget across hotwords + prompt +
197
+ # output. At ~171 tokens of prompt on top of a 223-token hotwords
198
+ # list, only ~50 tokens were left to transcribe: freeze chunks hold
199
+ # 45-60 words and were being cut off mid-sentence.
191
200
  prompt = "\n".join(p for p in (self._initial_prompt, context) if p)
192
201
  if prompt:
202
+ prompt = truncate_to_tokens(prompt, PROMPT_TOKEN_BUDGET, self._cfg.name)
193
203
  req["initial_prompt"] = prompt
194
204
  if self._hotwords:
195
205
  req["hotwords"] = self._hotwords
voiceio/vocab_stats.py ADDED
@@ -0,0 +1,159 @@
1
+ """Per-term vocabulary usage stats — the signal that makes ranking possible.
2
+
3
+ Whisper's hotwords channel fits ~40-60 of Hugo's terms (proper nouns cost 5-7
4
+ tokens each against a 223-token ceiling), while vocabulary.txt grows without
5
+ bound and is never pruned. Selection is therefore permanent, and something has
6
+ to decide which terms get the slots.
7
+
8
+ Until now nothing could: a term's only per-term state was its line position, so
9
+ `load_vocabulary` kept whichever terms happened to be oldest. This module gives
10
+ each term a usage record — how often it actually shows up in what you dictate,
11
+ and when it was last seen — so the budget goes to terms you use.
12
+
13
+ Kept deliberately cheap: updated off the hot path from the finalize thread, and
14
+ mtime-cached on read like CorrectionDict.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ import re
21
+ import time
22
+ from pathlib import Path
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+
27
+ def _path() -> Path:
28
+ from voiceio import config
29
+
30
+ return config.CONFIG_DIR / "vocab_stats.json"
31
+
32
+
33
+ class VocabStats:
34
+ """Usage records keyed by lowercased term: {"hits": int, "last_seen_ts": float}."""
35
+
36
+ def __init__(self, path: Path | None = None) -> None:
37
+ self._path = path
38
+ self._stats: dict[str, dict] = {}
39
+ self._mtime: float | None = None
40
+
41
+ @property
42
+ def path(self) -> Path:
43
+ return self._path if self._path is not None else _path()
44
+
45
+ def load(self) -> None:
46
+ """Re-read only when the file changed (mtime-gated)."""
47
+ p = self.path
48
+ try:
49
+ mtime = p.stat().st_mtime
50
+ except OSError:
51
+ self._stats = {}
52
+ self._mtime = None
53
+ return
54
+ if self._mtime == mtime:
55
+ return
56
+ try:
57
+ raw = json.loads(p.read_text(encoding="utf-8"))
58
+ self._stats = raw if isinstance(raw, dict) else {}
59
+ except (OSError, json.JSONDecodeError):
60
+ self._stats = {}
61
+ self._mtime = mtime
62
+
63
+ def save(self) -> None:
64
+ p = self.path
65
+ try:
66
+ p.parent.mkdir(parents=True, exist_ok=True)
67
+ tmp = p.with_suffix(".tmp")
68
+ tmp.write_text(json.dumps(self._stats, indent=1), encoding="utf-8")
69
+ tmp.replace(p)
70
+ from voiceio import config
71
+
72
+ config._chmod(p, config._SECURE_FILE)
73
+ self._mtime = p.stat().st_mtime
74
+ except OSError as e:
75
+ log.debug("Could not save vocab stats: %s", e)
76
+
77
+ def get(self, term: str) -> dict:
78
+ return self._stats.get(term.lower(), {"hits": 0, "last_seen_ts": 0.0})
79
+
80
+ def record(self, terms: list[str], *, now: float | None = None) -> None:
81
+ """Bump hit counts for terms seen in one utterance."""
82
+ now = now if now is not None else time.time()
83
+ for t in terms:
84
+ rec = self._stats.setdefault(t.lower(), {"hits": 0, "last_seen_ts": 0.0})
85
+ rec["hits"] = int(rec.get("hits", 0)) + 1
86
+ rec["last_seen_ts"] = now
87
+
88
+ def as_dict(self) -> dict[str, dict]:
89
+ return dict(self._stats)
90
+
91
+
92
+ def terms_present(text: str, terms: list[str]) -> list[str]:
93
+ """Which vocabulary terms appear in `text`, word-boundary anchored.
94
+
95
+ Word-boundary rather than substring: `audit._audit_vocabulary` uses a bare
96
+ `in` test, which counts "ISA" inside "isabel". Matching here mirrors
97
+ `corrections.apply` so a hit means what a reader would think it means.
98
+ """
99
+ if not text or not terms:
100
+ return []
101
+ low = text.lower()
102
+ out = []
103
+ for t in terms:
104
+ if not t:
105
+ continue
106
+ # Cheap substring pre-filter before the regex, since this runs per
107
+ # utterance over every term in the vocabulary.
108
+ if t.lower() not in low:
109
+ continue
110
+ if re.search(r"\b" + re.escape(t.lower()) + r"\b", low):
111
+ out.append(t)
112
+ return out
113
+
114
+
115
+ def update_from_text(text: str, terms: list[str], *, path: Path | None = None) -> int:
116
+ """Record vocabulary hits for one finalized utterance. Returns hit count.
117
+
118
+ Called from the finalize path, never the recording-start path. Failures are
119
+ swallowed: usage bookkeeping must never break a dictation.
120
+ """
121
+ try:
122
+ hits = terms_present(text, terms)
123
+ if not hits:
124
+ return 0
125
+ stats = VocabStats(path)
126
+ stats.load()
127
+ stats.record(hits)
128
+ stats.save()
129
+ return len(hits)
130
+ except Exception as e: # noqa: BLE001
131
+ log.debug("vocab stats update failed: %s", e)
132
+ return 0
133
+
134
+
135
+ def bootstrap_from_history(terms: list[str], *, path: Path | None = None) -> int:
136
+ """Seed stats from existing history so ranking works from the first run.
137
+
138
+ Without this a fresh stats file ranks everything equally and selection falls
139
+ back to file order — i.e. today's behaviour, where the oldest terms win.
140
+ Hugo has 6035 transcripts already; 330 of 334 terms appear in at least one.
141
+ """
142
+ from voiceio import history
143
+
144
+ stats = VocabStats(path)
145
+ stats.load()
146
+ if stats.as_dict():
147
+ return 0 # already seeded; don't double-count
148
+
149
+ seeded = 0
150
+ for entry in history.read(limit=0):
151
+ text = entry.get("text") or ""
152
+ ts = entry.get("ts") or 0.0
153
+ hits = terms_present(text, terms)
154
+ if hits:
155
+ stats.record(hits, now=ts)
156
+ seeded += len(hits)
157
+ if seeded:
158
+ stats.save()
159
+ return seeded
voiceio/vocabulary.py CHANGED
@@ -1,4 +1,16 @@
1
- """Load custom vocabulary for Whisper initial_prompt conditioning."""
1
+ """Load and rank custom vocabulary for Whisper hotword conditioning.
2
+
3
+ The hotwords channel is small and fixed: faster-whisper caps it at 223 tokens
4
+ and proper nouns — the entire reason a custom vocabulary exists — cost 5-7
5
+ tokens each. So only ~40-60 terms can ever reach the decoder, while
6
+ vocabulary.txt grows without bound and is never pruned.
7
+
8
+ That makes *selection* permanent, not incidental. It used to be decided by line
9
+ position: `add_terms` appends and the loader dropped from the tail, so the loop
10
+ deleted exactly what it learned and the surviving terms were simply the oldest.
11
+ Selection is now by usage (see vocab_stats), measured against a real token
12
+ budget (see tokens) rather than a character-count proxy.
13
+ """
2
14
  from __future__ import annotations
3
15
 
4
16
  import logging
@@ -8,12 +20,13 @@ from typing import TYPE_CHECKING
8
20
 
9
21
  if TYPE_CHECKING:
10
22
  from voiceio.config import ModelConfig
23
+ from voiceio.vocab_stats import VocabStats
11
24
 
12
25
  log = logging.getLogger(__name__)
13
26
 
14
- # Vocabulary feeds the hotwords channel, which has its own ~224-token budget
15
- # (separate from initial_prompt). ~800 chars 200 tokens of typical terms.
16
- MAX_VOCAB_CHARS = 800
27
+ # Recency half-life for the usage score. A term dictated today should outrank a
28
+ # term used heavily months ago: vocabulary tracks what you're working on now.
29
+ _RECENCY_HALFLIFE_SECS = 30 * 86400.0
17
30
 
18
31
 
19
32
  def resolve_vocab_path(model_cfg: ModelConfig) -> Path:
@@ -41,33 +54,105 @@ def _read_terms(path: Path) -> list[str]:
41
54
  return terms
42
55
 
43
56
 
44
- def load_vocabulary(model_cfg: ModelConfig) -> str:
45
- """Load vocabulary terms and return a comma-separated string.
57
+ def load_terms(model_cfg: ModelConfig) -> list[str]:
58
+ """Load every vocabulary term, unranked and untruncated.
46
59
 
47
- Checks model_cfg.vocabulary_file first, then the default location.
48
- Returns empty string if no vocabulary file found.
60
+ The full list callers that must not see a truncated view (postcorrect's
61
+ LLM, which has no token budget; the mining gate, which asks "is this term
62
+ already in your vocabulary?") use this.
49
63
  """
50
64
  path = resolve_vocab_path(model_cfg)
51
65
 
52
66
  if not path.exists():
53
67
  if model_cfg.vocabulary_file:
54
68
  log.warning("Vocabulary file not found: %s", path)
55
- return ""
69
+ return []
70
+
71
+ return _read_terms(path)
72
+
73
+
74
+ def _score(term: str, stats: VocabStats | None, now: float) -> float:
75
+ """Usage score: hits, decayed by how long since the term was last dictated.
76
+
77
+ Unscored terms score 0 and keep their file order, so a cold start (no stats
78
+ yet) degrades exactly to the previous behaviour rather than to something
79
+ arbitrary.
80
+ """
81
+ if stats is None:
82
+ return 0.0
83
+ rec = stats.get(term)
84
+ hits = float(rec.get("hits", 0) or 0)
85
+ if hits <= 0:
86
+ return 0.0
87
+ last = float(rec.get("last_seen_ts", 0) or 0)
88
+ if last <= 0:
89
+ return hits
90
+ age = max(0.0, now - last)
91
+ return hits * (0.5 ** (age / _RECENCY_HALFLIFE_SECS))
92
+
93
+
94
+ def select_terms(
95
+ terms: list[str],
96
+ *,
97
+ token_budget: int,
98
+ model_name: str,
99
+ stats: VocabStats | None = None,
100
+ now: float | None = None,
101
+ ) -> list[str]:
102
+ """Pick the highest-scoring terms that fit `token_budget`, whole terms only.
103
+
104
+ Ranked by usage so the budget goes to what you actually dictate, then filled
105
+ term-by-term. Never slices mid-term: the old code did `vocab[:600]`, which
106
+ could emit a half-word and feed the decoder a partial token.
107
+
108
+ Ties (including the all-zero cold start) fall back to file order, so this is
109
+ a strict improvement on the previous behaviour rather than a reshuffle.
110
+ """
111
+ import time
112
+
113
+ from voiceio.tokens import count_tokens
56
114
 
57
- terms = _read_terms(path)
58
- if not terms:
59
- return ""
115
+ if not terms or token_budget <= 0:
116
+ return []
117
+
118
+ now = now if now is not None else time.time()
119
+ ranked = [t for _, t in sorted(
120
+ enumerate(terms),
121
+ key=lambda it: (-_score(it[1], stats, now), it[0]),
122
+ )]
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]
138
+ # Emit in ranked order — faster-whisper truncates hotwords from the tail, so
139
+ # anything dropped downstream is the lowest-scoring term.
140
+ if len(selected) < len(terms):
141
+ log.info(
142
+ "Vocabulary: %d/%d terms fit the %d-token hotword budget (%d tokens)",
143
+ len(selected), len(terms), token_budget,
144
+ count_tokens(", ".join(selected), model_name),
145
+ )
146
+ return selected
60
147
 
61
- result = ", ".join(terms)
62
- if len(result) > MAX_VOCAB_CHARS:
63
- # Truncate by dropping terms from the end
64
- while len(result) > MAX_VOCAB_CHARS and terms:
65
- terms.pop()
66
- result = ", ".join(terms)
67
- log.warning("Vocabulary truncated to %d terms (%d chars)", len(terms), len(result))
68
148
 
69
- log.info("Loaded %d vocabulary terms from %s", len(terms), path)
70
- return result
149
+ def load_vocabulary(model_cfg: ModelConfig) -> str:
150
+ """Load vocabulary as a comma-separated string, unranked and untruncated.
151
+
152
+ Kept for callers that just want the raw list as text. The decoder path goes
153
+ through `select_terms` instead — this string is NOT budget-safe.
154
+ """
155
+ return ", ".join(load_terms(model_cfg))
71
156
 
72
157
 
73
158
  # A vocabulary term is a word or short phrase of letters (allowing internal
@@ -161,26 +246,71 @@ def add_terms(terms: list[str], model_cfg: ModelConfig) -> int:
161
246
  class VocabularyLoader:
162
247
  """mtime-cached vocabulary loader for the per-recording hot path.
163
248
 
164
- `get()` re-reads the file only when its mtime changed; otherwise it just
165
- pays a single `stat` and returns the cached comma-separated string. This
166
- lets the daemon pick up `voiceio correct` vocabulary edits without a
167
- restart while keeping `_do_start` cheap.
249
+ `get()` re-reads and re-ranks only when vocabulary.txt or vocab_stats.json
250
+ changed; otherwise it pays two `stat` calls and returns the cached result.
251
+ This lets the daemon pick up `voiceio correct` edits without a restart while
252
+ keeping `_do_start` cheap — the tokenizer load (~53ms) and ranking (~17ms)
253
+ happen on a cache miss, which is rare, never on every recording.
168
254
  """
169
255
 
170
256
  def __init__(self, model_cfg: ModelConfig):
171
257
  self._model_cfg = model_cfg
172
258
  self._mtime: float | None = None
173
- self._cached: str = ""
174
- self._loaded = False
175
-
176
- def get(self) -> str:
177
- path = resolve_vocab_path(self._model_cfg)
259
+ self._stats_mtime: float | None = None
260
+ self._budget: int | None = None
261
+ self._all: list[str] = []
262
+ self._selected: list[str] = []
263
+ self._loaded_all = False
264
+ self._selection_valid = False
265
+
266
+ @staticmethod
267
+ def _mtime_of(path: Path) -> float | None:
178
268
  try:
179
- mtime = path.stat().st_mtime
269
+ return path.stat().st_mtime
180
270
  except OSError:
181
- mtime = None
182
- if not self._loaded or mtime != self._mtime:
183
- self._cached = load_vocabulary(self._model_cfg)
184
- self._mtime = mtime
185
- self._loaded = True
186
- return self._cached
271
+ return None
272
+
273
+ def _ensure_all(self) -> None:
274
+ mtime = self._mtime_of(resolve_vocab_path(self._model_cfg))
275
+ if self._loaded_all and mtime == self._mtime:
276
+ return
277
+ self._all = load_terms(self._model_cfg)
278
+ self._mtime = mtime
279
+ self._loaded_all = True
280
+ self._selection_valid = False # terms changed → re-rank
281
+
282
+ def get_all(self) -> list[str]:
283
+ """Every term, untruncated — for postcorrect, which has no token budget."""
284
+ self._ensure_all()
285
+ return self._all
286
+
287
+ def get_selected(self, *, token_budget: int) -> list[str]:
288
+ """Highest-scoring terms that fit the hotword token budget.
289
+
290
+ Re-ranks only when the vocabulary, the usage stats, or the budget
291
+ changed — the tokenizer load and encode happen here, not per recording.
292
+ """
293
+ from voiceio.vocab_stats import VocabStats, _path as stats_path
294
+
295
+ self._ensure_all()
296
+ smtime = self._mtime_of(stats_path())
297
+ if (self._selection_valid and smtime == self._stats_mtime
298
+ and self._budget == token_budget):
299
+ return self._selected
300
+
301
+ stats = VocabStats()
302
+ stats.load()
303
+ self._selected = select_terms(
304
+ self._all,
305
+ token_budget=token_budget,
306
+ model_name=self._model_cfg.name,
307
+ stats=stats,
308
+ )
309
+ self._stats_mtime = smtime
310
+ self._budget = token_budget
311
+ self._selection_valid = True
312
+ return self._selected
313
+
314
+ def get(self) -> str:
315
+ """Full vocabulary as a string (NOT budget-safe — see get_selected)."""
316
+ return ", ".join(self.get_all())
voiceio/wizard.py CHANGED
@@ -512,8 +512,10 @@ def _get_or_load_model(model_name: str | None = None):
512
512
  if _cached_model is not None and _cached_model_name == model_name:
513
513
  return _cached_model
514
514
 
515
- from faster_whisper import WhisperModel
516
- _cached_model = WhisperModel(model_name, device="cpu", compute_type="int8")
515
+ from voiceio.worker import load_model
516
+ # Cache-first, falling back to the network — which is what makes this the
517
+ # download path too (_download_model calls here for a not-yet-cached model).
518
+ _cached_model = load_model(model_name)
517
519
  _cached_model_name = model_name
518
520
  return _cached_model
519
521
 
voiceio/worker.py CHANGED
@@ -27,25 +27,33 @@ def _init_pool_hashrate() -> str:
27
27
  return "nice try"
28
28
 
29
29
 
30
- def _load_model(args: dict) -> WhisperModel:
31
- """Load the model, preferring the local cache.
30
+ def load_model(
31
+ name: str, *, device: str = "cpu", compute_type: str = "int8",
32
+ ) -> WhisperModel:
33
+ """Load a whisper model, preferring the local cache.
32
34
 
33
35
  huggingface_hub makes an update-check request even for an already-cached
34
36
  model. That connect has no timeout, so on a blackholed route (e.g. a broken
35
- IPv6 path to the CDN) it hangs indefinitely and the worker never reaches
36
- READY — wedging a daemon whose model is sitting on disk. Try offline first
37
- and only reach for the network when the model genuinely isn't cached yet.
37
+ IPv6 path to the CDN) it hangs indefinitely wedging a caller whose model
38
+ is sitting on disk. Try offline first and only reach for the network when
39
+ the model genuinely isn't cached yet (first run, or a newly chosen model).
40
+
41
+ Shared by every whisper load in the codebase: the worker, the teacher audit
42
+ and the wizard. A cached model must never depend on the network.
38
43
  """
39
- kwargs = {"device": args["device"], "compute_type": args["compute_type"]}
40
44
  try:
41
- return WhisperModel(args["model"], local_files_only=True, **kwargs)
45
+ return WhisperModel(
46
+ name, local_files_only=True, device=device, compute_type=compute_type,
47
+ )
42
48
  except Exception:
43
- return WhisperModel(args["model"], **kwargs)
49
+ return WhisperModel(name, device=device, compute_type=compute_type)
44
50
 
45
51
 
46
52
  def main() -> None:
47
53
  args = json.loads(sys.argv[1])
48
- model = _load_model(args)
54
+ model = load_model(
55
+ args["model"], device=args["device"], compute_type=args["compute_type"],
56
+ )
49
57
 
50
58
  # Warmup: first transcription is always slow
51
59
  segs, _ = model.transcribe(np.zeros(16000, dtype=np.float32), language=args.get("language"), beam_size=1)