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.
- {python_voiceio-0.8.2.dist-info → python_voiceio-0.8.3.dist-info}/METADATA +1 -1
- {python_voiceio-0.8.2.dist-info → python_voiceio-0.8.3.dist-info}/RECORD +16 -14
- voiceio/__init__.py +1 -1
- voiceio/app.py +63 -21
- voiceio/audit.py +37 -16
- voiceio/cli.py +53 -0
- voiceio/tokens.py +138 -0
- voiceio/transcriber.py +10 -0
- voiceio/vocab_stats.py +159 -0
- voiceio/vocabulary.py +167 -37
- voiceio/wizard.py +4 -2
- voiceio/worker.py +17 -9
- {python_voiceio-0.8.2.dist-info → python_voiceio-0.8.3.dist-info}/WHEEL +0 -0
- {python_voiceio-0.8.2.dist-info → python_voiceio-0.8.3.dist-info}/entry_points.txt +0 -0
- {python_voiceio-0.8.2.dist-info → python_voiceio-0.8.3.dist-info}/licenses/LICENSE +0 -0
- {python_voiceio-0.8.2.dist-info → python_voiceio-0.8.3.dist-info}/top_level.txt +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
python_voiceio-0.8.
|
|
2
|
-
voiceio/__init__.py,sha256=
|
|
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=
|
|
5
|
-
voiceio/audit.py,sha256=
|
|
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=
|
|
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/
|
|
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/
|
|
36
|
-
voiceio/
|
|
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=
|
|
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.
|
|
75
|
-
python_voiceio-0.8.
|
|
76
|
-
python_voiceio-0.8.
|
|
77
|
-
python_voiceio-0.8.
|
|
78
|
-
python_voiceio-0.8.
|
|
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.
|
|
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
|
-
#
|
|
102
|
-
#
|
|
103
|
-
|
|
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
|
|
322
|
+
"""Rebuild Whisper hotwords: rank by usage, fill the token budget.
|
|
313
323
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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.
|
|
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
|
|
172
|
+
from voiceio.worker import load_model
|
|
173
173
|
|
|
174
|
-
|
|
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
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
15
|
-
#
|
|
16
|
-
|
|
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
|
|
45
|
-
"""Load vocabulary
|
|
57
|
+
def load_terms(model_cfg: ModelConfig) -> list[str]:
|
|
58
|
+
"""Load every vocabulary term, unranked and untruncated.
|
|
46
59
|
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
|
165
|
-
pays
|
|
166
|
-
lets the daemon pick up `voiceio correct`
|
|
167
|
-
|
|
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.
|
|
174
|
-
self.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
269
|
+
return path.stat().st_mtime
|
|
180
270
|
except OSError:
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
516
|
-
|
|
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
|
|
31
|
-
""
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
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(
|
|
45
|
+
return WhisperModel(
|
|
46
|
+
name, local_files_only=True, device=device, compute_type=compute_type,
|
|
47
|
+
)
|
|
42
48
|
except Exception:
|
|
43
|
-
return WhisperModel(
|
|
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 =
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|