python-voiceio 0.8.2__tar.gz → 0.9.0__tar.gz
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/python_voiceio.egg-info → python_voiceio-0.9.0}/PKG-INFO +1 -1
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/pyproject.toml +1 -1
- {python_voiceio-0.8.2 → python_voiceio-0.9.0/python_voiceio.egg-info}/PKG-INFO +1 -1
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/python_voiceio.egg-info/SOURCES.txt +5 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_adjudicate.py +14 -1
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_audio_quality.py +25 -4
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_correct_batch.py +85 -3
- python_voiceio-0.9.0/tests/test_evaluate.py +198 -0
- python_voiceio-0.9.0/tests/test_tokens.py +107 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_transcriber.py +19 -4
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_vocabulary.py +102 -6
- python_voiceio-0.9.0/voiceio/__init__.py +1 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/app.py +63 -21
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/audit.py +37 -16
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/cli.py +169 -5
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/config.py +11 -0
- python_voiceio-0.9.0/voiceio/evaluate.py +322 -0
- python_voiceio-0.9.0/voiceio/tokens.py +138 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/transcriber.py +10 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/typers/clipboard.py +26 -1
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/typers/ibus.py +34 -4
- python_voiceio-0.9.0/voiceio/vocab_stats.py +159 -0
- python_voiceio-0.9.0/voiceio/vocabulary.py +316 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/wizard.py +4 -2
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/worker.py +17 -9
- python_voiceio-0.8.2/voiceio/__init__.py +0 -1
- python_voiceio-0.8.2/voiceio/vocabulary.py +0 -186
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/LICENSE +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/README.md +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/python_voiceio.egg-info/entry_points.txt +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/setup.cfg +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_app_wiring.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_audit.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_autocorrect.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_autocorrect_state.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_backend_probes.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_cli.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_clipboard_read.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_commands.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_concurrency_lockdown.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_config.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_corrections.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_fallback.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_health.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_hints.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_history.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_ibus_pending.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_ibus_ping.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_ibus_typer.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_llm.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_llm_api.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_numbers.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_platform.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_postcorrect.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_postprocess.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_prebuffer.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_prompt.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_retention.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_robustness.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_security_hardening.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_snapshots.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_streaming.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_tts.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_vad.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_wizard.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_wordfreq.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/__main__.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/autocorrect.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/autocorrect_state.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/backends.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/clipboard_read.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/commands.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/consent.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/corrections.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/demo.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/feedback.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/health.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/hints.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/history.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/hotkeys/evdev.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/ibus/__init__.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/ibus/engine.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/ibus/pending.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/llm.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/llm_api.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/models/__init__.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/models/silero_vad.onnx +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/numbers.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/pidlock.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/platform.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/postcorrect.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/postprocess.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/prompt.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/recorder.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/retention.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/service.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/snapshots.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/sounds/commit.wav +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/sounds/start.wav +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/streaming.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/tray/__init__.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/tray/_icons.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/tray/_indicator.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/tray/_pystray.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/tts/__init__.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/tts/base.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/tts/chain.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/tts/edge_engine.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/tts/espeak.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/tts/piper_engine.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/tts/player.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/typers/base.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/typers/chain.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/vad.py +0 -0
- {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/wordfreq.py +0 -0
|
@@ -21,6 +21,7 @@ tests/test_concurrency_lockdown.py
|
|
|
21
21
|
tests/test_config.py
|
|
22
22
|
tests/test_correct_batch.py
|
|
23
23
|
tests/test_corrections.py
|
|
24
|
+
tests/test_evaluate.py
|
|
24
25
|
tests/test_fallback.py
|
|
25
26
|
tests/test_health.py
|
|
26
27
|
tests/test_hints.py
|
|
@@ -42,6 +43,7 @@ tests/test_robustness.py
|
|
|
42
43
|
tests/test_security_hardening.py
|
|
43
44
|
tests/test_snapshots.py
|
|
44
45
|
tests/test_streaming.py
|
|
46
|
+
tests/test_tokens.py
|
|
45
47
|
tests/test_transcriber.py
|
|
46
48
|
tests/test_tts.py
|
|
47
49
|
tests/test_vad.py
|
|
@@ -62,6 +64,7 @@ voiceio/config.py
|
|
|
62
64
|
voiceio/consent.py
|
|
63
65
|
voiceio/corrections.py
|
|
64
66
|
voiceio/demo.py
|
|
67
|
+
voiceio/evaluate.py
|
|
65
68
|
voiceio/feedback.py
|
|
66
69
|
voiceio/health.py
|
|
67
70
|
voiceio/hints.py
|
|
@@ -79,8 +82,10 @@ voiceio/retention.py
|
|
|
79
82
|
voiceio/service.py
|
|
80
83
|
voiceio/snapshots.py
|
|
81
84
|
voiceio/streaming.py
|
|
85
|
+
voiceio/tokens.py
|
|
82
86
|
voiceio/transcriber.py
|
|
83
87
|
voiceio/vad.py
|
|
88
|
+
voiceio/vocab_stats.py
|
|
84
89
|
voiceio/vocabulary.py
|
|
85
90
|
voiceio/wizard.py
|
|
86
91
|
voiceio/wordfreq.py
|
|
@@ -212,6 +212,17 @@ def _suspicious():
|
|
|
212
212
|
|
|
213
213
|
|
|
214
214
|
class TestBatchFlow:
|
|
215
|
+
"""The adjudication flow. Correction mining is retired by default
|
|
216
|
+
([autocorrect] mine_corrections = false, see test_correct_batch.py), so
|
|
217
|
+
these opt it back in — the machinery still has to be correct for anyone who
|
|
218
|
+
turns it on, and for the vocabulary path that shares it."""
|
|
219
|
+
|
|
220
|
+
def _cfg(self):
|
|
221
|
+
from voiceio.config import load
|
|
222
|
+
cfg = load()
|
|
223
|
+
cfg.autocorrect.mine_corrections = True
|
|
224
|
+
return cfg
|
|
225
|
+
|
|
215
226
|
def _run(self, tmp_path, adj, *, review=None, suspicious=None, notify=None):
|
|
216
227
|
from voiceio.autocorrect import ReviewResult
|
|
217
228
|
cd = CorrectionDict(path=tmp_path / "c.json")
|
|
@@ -219,7 +230,8 @@ class TestBatchFlow:
|
|
|
219
230
|
ask_user=[{"wrong": "mantekka", "right": "", "reason": "?"}],
|
|
220
231
|
)
|
|
221
232
|
suspicious = suspicious if suspicious is not None else _suspicious()
|
|
222
|
-
with patch("voiceio.
|
|
233
|
+
with patch("voiceio.config.load", return_value=self._cfg()), \
|
|
234
|
+
patch("voiceio.llm_api.resolve_api_key", return_value="sk-x"), \
|
|
223
235
|
patch("voiceio.history.read", return_value=_entries()), \
|
|
224
236
|
patch("voiceio.autocorrect.find_suspicious_words",
|
|
225
237
|
return_value=suspicious), \
|
|
@@ -274,6 +286,7 @@ class TestBatchFlow:
|
|
|
274
286
|
|
|
275
287
|
cd = CorrectionDict(path=tmp_path / "c.json")
|
|
276
288
|
with caplog.at_level(logging.INFO, logger="voiceio.cli"), \
|
|
289
|
+
patch("voiceio.config.load", return_value=self._cfg()), \
|
|
277
290
|
patch("voiceio.llm_api.resolve_api_key", return_value="sk-x"), \
|
|
278
291
|
patch("voiceio.history.read", return_value=_entries()), \
|
|
279
292
|
patch("voiceio.autocorrect.find_suspicious_words", return_value=many), \
|
|
@@ -125,10 +125,31 @@ class TestPromptBudget:
|
|
|
125
125
|
output. Oversized bias inputs truncated real transcriptions mid-utterance
|
|
126
126
|
(2026-07-05 regression). These bounds must hold."""
|
|
127
127
|
|
|
128
|
-
def
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
128
|
+
def test_hotwords_budget_leaves_room_to_transcribe(self):
|
|
129
|
+
"""The bound that actually matters, in the unit that actually matters.
|
|
130
|
+
|
|
131
|
+
The old assertion was `_HOTWORDS_MAX_CHARS <= 700` — but chars are the
|
|
132
|
+
wrong unit: proper nouns tokenize at ~2.6 chars/token, so 600 chars was
|
|
133
|
+
really 238 tokens, already past faster-whisper's 223 cap.
|
|
134
|
+
"""
|
|
135
|
+
from voiceio.app import _HOTWORDS_TOKEN_BUDGET
|
|
136
|
+
from voiceio.tokens import (
|
|
137
|
+
HOTWORDS_TOKEN_CAP,
|
|
138
|
+
MAX_SEQUENCE_TOKENS,
|
|
139
|
+
OUTPUT_RESERVE_TOKENS,
|
|
140
|
+
PROMPT_TOKEN_BUDGET,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Past this, faster-whisper silently discards the tail of the list.
|
|
144
|
+
assert _HOTWORDS_TOKEN_BUDGET <= HOTWORDS_TOKEN_CAP
|
|
145
|
+
# The invariant that actually protects the user: hotwords + prompt must
|
|
146
|
+
# leave enough to transcribe with. Violating it truncated freeze chunks
|
|
147
|
+
# mid-sentence (~37 words of room for a 45-60 word chunk) and, worst
|
|
148
|
+
# case, drove the decode budget to <=0 (ValueError).
|
|
149
|
+
overhead = 4 # sot_prev + sot_sequence
|
|
150
|
+
left = (MAX_SEQUENCE_TOKENS - _HOTWORDS_TOKEN_BUDGET
|
|
151
|
+
- PROMPT_TOKEN_BUDGET - overhead)
|
|
152
|
+
assert left >= OUTPUT_RESERVE_TOKENS
|
|
132
153
|
|
|
133
154
|
def test_prompt_builder_default_budget_small(self):
|
|
134
155
|
from voiceio.prompt import PromptBuilder
|
|
@@ -20,6 +20,14 @@ def _suspicious():
|
|
|
20
20
|
contexts=["check the mantekka transfers"])]
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
def _cfg_with_mining():
|
|
24
|
+
"""Config with the retired correction mining explicitly opted back in."""
|
|
25
|
+
from voiceio.config import load
|
|
26
|
+
cfg = load()
|
|
27
|
+
cfg.autocorrect.mine_corrections = True
|
|
28
|
+
return cfg
|
|
29
|
+
|
|
30
|
+
|
|
23
31
|
class TestBatchMode:
|
|
24
32
|
def test_no_api_key_bails_quietly(self, tmp_path, capsys):
|
|
25
33
|
cd = CorrectionDict(path=tmp_path / "c.json")
|
|
@@ -29,7 +37,11 @@ class TestBatchMode:
|
|
|
29
37
|
assert "skipping batch" in capsys.readouterr().out.lower()
|
|
30
38
|
|
|
31
39
|
def test_ambiguous_item_deferred_no_notify_cursor_advances(self, tmp_path):
|
|
32
|
-
"""No consensus → silently deferred; no queue, no notification, cursor advances.
|
|
40
|
+
"""No consensus → silently deferred; no queue, no notification, cursor advances.
|
|
41
|
+
|
|
42
|
+
Opts mining back in: it's retired by default (see
|
|
43
|
+
TestCorrectionMiningRetired) but the machinery must still be correct.
|
|
44
|
+
"""
|
|
33
45
|
cd = CorrectionDict(path=tmp_path / "c.json")
|
|
34
46
|
review = ReviewResult()
|
|
35
47
|
review.ask_user.append({"wrong": "mantekka", "right": "Manteca",
|
|
@@ -37,7 +49,8 @@ class TestBatchMode:
|
|
|
37
49
|
adj = AdjudicationResult(
|
|
38
50
|
deferred=[{"wrong": "mantekka", "right": "Manteca", "votes": []}],
|
|
39
51
|
)
|
|
40
|
-
with patch("voiceio.
|
|
52
|
+
with patch("voiceio.config.load", return_value=_cfg_with_mining()), \
|
|
53
|
+
patch("voiceio.llm_api.resolve_api_key", return_value="sk-x"), \
|
|
41
54
|
patch("voiceio.history.read", return_value=_entries()), \
|
|
42
55
|
patch("voiceio.autocorrect.find_suspicious_words",
|
|
43
56
|
return_value=_suspicious()), \
|
|
@@ -54,11 +67,13 @@ class TestBatchMode:
|
|
|
54
67
|
assert "mantekka" in state.deferred
|
|
55
68
|
|
|
56
69
|
def test_unanimous_correction_applied_and_notified(self, tmp_path):
|
|
70
|
+
"""Mining opted back in — it's retired by default, not deleted."""
|
|
57
71
|
cd = CorrectionDict(path=tmp_path / "c.json")
|
|
58
72
|
review = ReviewResult()
|
|
59
73
|
review.ask_user.append({"wrong": "mantekka", "right": "", "reason": "?"})
|
|
60
74
|
adj = AdjudicationResult(apply=[{"wrong": "mantekka", "right": "manteca"}])
|
|
61
|
-
with patch("voiceio.
|
|
75
|
+
with patch("voiceio.config.load", return_value=_cfg_with_mining()), \
|
|
76
|
+
patch("voiceio.llm_api.resolve_api_key", return_value="sk-x"), \
|
|
62
77
|
patch("voiceio.history.read", return_value=_entries()), \
|
|
63
78
|
patch("voiceio.autocorrect.find_suspicious_words",
|
|
64
79
|
return_value=_suspicious()), \
|
|
@@ -127,3 +142,70 @@ class TestProtectLanguages:
|
|
|
127
142
|
assert gate_correction("ngroc", "ngrok",
|
|
128
143
|
vocabulary={"ngrok"},
|
|
129
144
|
protect_languages=["es", "ca"]) is None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class TestCorrectionMiningRetired:
|
|
148
|
+
"""Correction mining is off by default.
|
|
149
|
+
|
|
150
|
+
Measured over months: 387 rules mined, 4 ever fired (1%), while the runtime
|
|
151
|
+
postcorrect pass applied 288 edits. Rules are exact-string matches, so they
|
|
152
|
+
only fire if Whisper repeats a misrecognition verbatim — and the errors
|
|
153
|
+
worth fixing ("nuance" for "neurons") are common words the safety gate must
|
|
154
|
+
refuse. One run learned rules that destroyed real Spanish words. Vocabulary
|
|
155
|
+
mining is a different story and stays on.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def _review(self):
|
|
159
|
+
# "compain" -> "company" passes gate_correction: the target is a common
|
|
160
|
+
# word. ("olamma" -> "Ollama" would be rejected because the target is
|
|
161
|
+
# neither common nor already in the vocabulary — pre-existing gate
|
|
162
|
+
# behaviour, unrelated to mining being retired.)
|
|
163
|
+
from voiceio.autocorrect import ReviewResult
|
|
164
|
+
return ReviewResult(
|
|
165
|
+
auto_fix=[{"wrong": "compain", "right": "company", "reason": "typo"}],
|
|
166
|
+
ask_user=[{"wrong": "wordal", "right": "Wordle", "reason": "maybe"}],
|
|
167
|
+
vocabulary=["Kubernetes"],
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def test_default_is_off(self):
|
|
171
|
+
from voiceio.config import AutocorrectConfig
|
|
172
|
+
assert AutocorrectConfig().mine_corrections is False
|
|
173
|
+
|
|
174
|
+
def test_no_rules_learned_by_default(self):
|
|
175
|
+
"""The whole point: a batch run must not grow corrections.json."""
|
|
176
|
+
from voiceio.corrections import CorrectionDict
|
|
177
|
+
|
|
178
|
+
cd = CorrectionDict()
|
|
179
|
+
with patch("voiceio.llm_api.resolve_api_key", return_value="sk-x"), \
|
|
180
|
+
patch("voiceio.history.read", return_value=_entries()), \
|
|
181
|
+
patch("voiceio.autocorrect.find_suspicious_words", return_value=_suspicious()), \
|
|
182
|
+
patch("voiceio.autocorrect.review_suspicious", return_value=self._review()), \
|
|
183
|
+
patch("voiceio.autocorrect.adjudicate") as adj, \
|
|
184
|
+
patch("voiceio.vocabulary.add_terms", return_value=1) as add_terms, \
|
|
185
|
+
patch("voiceio.feedback.notify"):
|
|
186
|
+
_cmd_correct_auto(cd, batch=True)
|
|
187
|
+
|
|
188
|
+
assert cd.list_all() == {} # no rules learned
|
|
189
|
+
assert not adj.called # and no LLM spend adjudicating them
|
|
190
|
+
assert add_terms.called # vocabulary mining still runs
|
|
191
|
+
|
|
192
|
+
def test_opt_in_still_mines(self):
|
|
193
|
+
"""The machinery is retired, not deleted — it must still work if asked."""
|
|
194
|
+
from voiceio.autocorrect import AdjudicationResult
|
|
195
|
+
from voiceio.corrections import CorrectionDict
|
|
196
|
+
|
|
197
|
+
cfg = _cfg_with_mining()
|
|
198
|
+
cd = CorrectionDict()
|
|
199
|
+
adj = AdjudicationResult(apply=[], vocabulary=[], deferred=[])
|
|
200
|
+
with patch("voiceio.config.load", return_value=cfg), \
|
|
201
|
+
patch("voiceio.llm_api.resolve_api_key", return_value="sk-x"), \
|
|
202
|
+
patch("voiceio.history.read", return_value=_entries()), \
|
|
203
|
+
patch("voiceio.autocorrect.find_suspicious_words", return_value=_suspicious()), \
|
|
204
|
+
patch("voiceio.autocorrect.review_suspicious", return_value=self._review()), \
|
|
205
|
+
patch("voiceio.autocorrect.adjudicate", return_value=adj) as adjudicate, \
|
|
206
|
+
patch("voiceio.vocabulary.add_terms", return_value=1), \
|
|
207
|
+
patch("voiceio.feedback.notify"):
|
|
208
|
+
_cmd_correct_auto(cd, batch=True)
|
|
209
|
+
|
|
210
|
+
assert "compain" in cd.list_all() # gate-passing auto_fix applied
|
|
211
|
+
assert adjudicate.called # ambiguous ones still adjudicated
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Tests for the decoder-config eval harness.
|
|
2
|
+
|
|
3
|
+
The harness exists because every decoder knob was set from a plausible story and
|
|
4
|
+
one example. Its own detectors must not be in the same position — hence real
|
|
5
|
+
assertions on the hallucination/repetition counters, using the exact shapes seen
|
|
6
|
+
in production.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import wave
|
|
11
|
+
from unittest.mock import patch
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from voiceio.evaluate import (
|
|
16
|
+
count_hallucinations,
|
|
17
|
+
count_repetitions,
|
|
18
|
+
default_matrix,
|
|
19
|
+
evaluate,
|
|
20
|
+
sample_clips,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestHallucinationDetection:
|
|
25
|
+
def test_catches_whisper_filler(self):
|
|
26
|
+
assert count_hallucinations([" Thank you."]) == 1
|
|
27
|
+
assert count_hallucinations(["Thanks for watching!"]) == 1
|
|
28
|
+
assert count_hallucinations([" Thank you.", " Thank you."]) == 2
|
|
29
|
+
|
|
30
|
+
def test_ignores_real_speech(self):
|
|
31
|
+
assert count_hallucinations(["Thank you for the detailed review"]) == 0
|
|
32
|
+
assert count_hallucinations(["so I want you to own this end to end"]) == 0
|
|
33
|
+
|
|
34
|
+
def test_matches_whole_segment_not_substring(self):
|
|
35
|
+
"""'thank you' inside a sentence is speech, not a hallucination."""
|
|
36
|
+
assert count_hallucinations(["I say thank you to the team"]) == 0
|
|
37
|
+
|
|
38
|
+
def test_empty(self):
|
|
39
|
+
assert count_hallucinations([]) == 0
|
|
40
|
+
assert count_hallucinations(["", " "]) == 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestRepetitionDetection:
|
|
44
|
+
def test_catches_the_real_loop(self):
|
|
45
|
+
"""Verbatim from a 422s dictation that looped four times."""
|
|
46
|
+
text = ("make a hypothesis why intelligence is not working out "
|
|
47
|
+
+ "because it should not work out in the complex world " * 4
|
|
48
|
+
+ "so we have seen simpler simulations")
|
|
49
|
+
assert count_repetitions(text) >= 1
|
|
50
|
+
|
|
51
|
+
def test_ignores_normal_speech(self):
|
|
52
|
+
text = ("So on each generation we have an algorithm to determine the "
|
|
53
|
+
"general shape of the brain and once that is determined we add "
|
|
54
|
+
"one or two nodes or more hidden nodes and more connections")
|
|
55
|
+
assert count_repetitions(text) == 0
|
|
56
|
+
|
|
57
|
+
def test_ignores_short_text(self):
|
|
58
|
+
assert count_repetitions("testing testing one two three") == 0
|
|
59
|
+
|
|
60
|
+
def test_needs_a_real_run_not_two(self):
|
|
61
|
+
"""Saying something twice is emphasis; three-plus is a decode loop."""
|
|
62
|
+
twice = "the quick brown fox the quick brown fox and then it stopped"
|
|
63
|
+
assert count_repetitions(twice) == 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestDefaultMatrix:
|
|
67
|
+
def test_includes_shipped_baseline(self):
|
|
68
|
+
names = [c.name for c in default_matrix()]
|
|
69
|
+
assert "shipped" in names
|
|
70
|
+
|
|
71
|
+
def test_shipped_matches_the_worker(self):
|
|
72
|
+
"""If worker.py's decode params drift, this baseline is a lie."""
|
|
73
|
+
shipped = next(c for c in default_matrix() if c.name == "shipped")
|
|
74
|
+
src = (__import__("pathlib").Path(__file__).resolve().parent.parent
|
|
75
|
+
/ "voiceio" / "worker.py").read_text()
|
|
76
|
+
assert "condition_on_previous_text=False" in src
|
|
77
|
+
assert "vad_filter=True" in src
|
|
78
|
+
assert shipped.condition_on_previous_text is False
|
|
79
|
+
assert shipped.vad_filter is True
|
|
80
|
+
|
|
81
|
+
def test_never_pairs_cond_with_hotwords(self):
|
|
82
|
+
"""They share faster-whisper's sot_prev slot: 223 + 223 + sot > 448,
|
|
83
|
+
which raises 'maximum decoding length must be > 0'. Not a config."""
|
|
84
|
+
for c in default_matrix():
|
|
85
|
+
assert not (c.condition_on_previous_text and c.hotwords), c.name
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _write_wav(path, secs=1.0):
|
|
89
|
+
with wave.open(str(path), "wb") as w:
|
|
90
|
+
w.setnchannels(1)
|
|
91
|
+
w.setsampwidth(2)
|
|
92
|
+
w.setframerate(16000)
|
|
93
|
+
w.writeframes((np.zeros(int(16000 * secs), dtype=np.int16)).tobytes())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestSampleClips:
|
|
97
|
+
def test_respects_audio_budget(self, tmp_path, monkeypatch):
|
|
98
|
+
rec = tmp_path / "recordings"
|
|
99
|
+
rec.mkdir()
|
|
100
|
+
for i in range(10):
|
|
101
|
+
_write_wav(rec / f"c{i}.wav", secs=2.0)
|
|
102
|
+
monkeypatch.setattr("voiceio.config.RECORDINGS_DIR", rec)
|
|
103
|
+
got = sample_clips(5.0)
|
|
104
|
+
assert 0 < len(got) <= 3 # 2s each, budget 5s
|
|
105
|
+
|
|
106
|
+
def test_no_recordings_dir(self, tmp_path, monkeypatch):
|
|
107
|
+
monkeypatch.setattr("voiceio.config.RECORDINGS_DIR", tmp_path / "nope")
|
|
108
|
+
assert sample_clips(100) == []
|
|
109
|
+
|
|
110
|
+
def test_includes_long_clips(self, tmp_path, monkeypatch):
|
|
111
|
+
"""The bias this replaced: newest-first sampling was ALL short clips
|
|
112
|
+
(8 clips, 22s avg), so condition_on_previous_text's repetition loops —
|
|
113
|
+
which only happen on long dictation — could not appear, and the harness
|
|
114
|
+
happily recommended cond=True."""
|
|
115
|
+
rec = tmp_path / "recordings"
|
|
116
|
+
rec.mkdir()
|
|
117
|
+
for i in range(30): # many short, newest
|
|
118
|
+
_write_wav(rec / f"short{i:02d}.wav", secs=10.0)
|
|
119
|
+
_write_wav(rec / "long.wav", secs=120.0)
|
|
120
|
+
monkeypatch.setattr("voiceio.config.RECORDINGS_DIR", rec)
|
|
121
|
+
|
|
122
|
+
got = sample_clips(300)
|
|
123
|
+
names = [p.name for p in got]
|
|
124
|
+
assert "long.wav" in names
|
|
125
|
+
assert any(n.startswith("short") for n in names)
|
|
126
|
+
|
|
127
|
+
def test_all_short_still_fills_budget(self, tmp_path, monkeypatch):
|
|
128
|
+
"""No long recordings must not mean a half-empty sample."""
|
|
129
|
+
rec = tmp_path / "recordings"
|
|
130
|
+
rec.mkdir()
|
|
131
|
+
for i in range(20):
|
|
132
|
+
_write_wav(rec / f"s{i:02d}.wav", secs=10.0)
|
|
133
|
+
monkeypatch.setattr("voiceio.config.RECORDINGS_DIR", rec)
|
|
134
|
+
got = sample_clips(100)
|
|
135
|
+
assert len(got) >= 9 # ~10 x 10s, not capped at the 50% short share
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class TestEvaluate:
|
|
139
|
+
def test_no_audio_returns_empty(self, tmp_path, monkeypatch):
|
|
140
|
+
monkeypatch.setattr("voiceio.config.RECORDINGS_DIR", tmp_path / "nope")
|
|
141
|
+
from voiceio.config import Config
|
|
142
|
+
assert evaluate(Config(), default_matrix(), teacher=lambda p: "x") == []
|
|
143
|
+
|
|
144
|
+
def test_teacher_silence_returns_empty(self, tmp_path, monkeypatch):
|
|
145
|
+
"""A teacher that hears nothing gives nothing to score against."""
|
|
146
|
+
rec = tmp_path / "recordings"
|
|
147
|
+
rec.mkdir()
|
|
148
|
+
_write_wav(rec / "a.wav")
|
|
149
|
+
monkeypatch.setattr("voiceio.config.RECORDINGS_DIR", rec)
|
|
150
|
+
from voiceio.config import Config
|
|
151
|
+
assert evaluate(Config(), default_matrix(), teacher=lambda p: "") == []
|
|
152
|
+
|
|
153
|
+
def test_scores_and_ranks_by_wer(self, tmp_path, monkeypatch):
|
|
154
|
+
rec = tmp_path / "recordings"
|
|
155
|
+
rec.mkdir()
|
|
156
|
+
_write_wav(rec / "a.wav", secs=1.0)
|
|
157
|
+
monkeypatch.setattr("voiceio.config.RECORDINGS_DIR", rec)
|
|
158
|
+
|
|
159
|
+
class _Seg:
|
|
160
|
+
def __init__(self, t):
|
|
161
|
+
self.text = t
|
|
162
|
+
|
|
163
|
+
class _Model:
|
|
164
|
+
def transcribe(self, audio, **kw):
|
|
165
|
+
# The no-vad config "hears" the reference exactly; shipped drops
|
|
166
|
+
# a word. Ranking must follow WER, not declaration order.
|
|
167
|
+
if kw.get("vad_filter"):
|
|
168
|
+
return iter([_Seg("hello there")]), None
|
|
169
|
+
return iter([_Seg("hello there world")]), None
|
|
170
|
+
|
|
171
|
+
from voiceio.config import Config
|
|
172
|
+
with patch("voiceio.worker.load_model", return_value=_Model()), \
|
|
173
|
+
patch("voiceio.vocabulary.load_terms", return_value=[]):
|
|
174
|
+
scores = evaluate(Config(), default_matrix(),
|
|
175
|
+
teacher=lambda p: "hello there world")
|
|
176
|
+
|
|
177
|
+
assert scores
|
|
178
|
+
assert scores[0].wer <= scores[-1].wer # sorted best-first
|
|
179
|
+
assert scores[0].config.vad_filter is False # the exact-match config
|
|
180
|
+
assert all(s.clips == 1 for s in scores)
|
|
181
|
+
|
|
182
|
+
def test_a_crashing_config_is_a_result_not_an_error(self, tmp_path, monkeypatch):
|
|
183
|
+
rec = tmp_path / "recordings"
|
|
184
|
+
rec.mkdir()
|
|
185
|
+
_write_wav(rec / "a.wav")
|
|
186
|
+
monkeypatch.setattr("voiceio.config.RECORDINGS_DIR", rec)
|
|
187
|
+
|
|
188
|
+
class _Model:
|
|
189
|
+
def transcribe(self, audio, **kw):
|
|
190
|
+
raise ValueError("The maximum decoding length must be > 0")
|
|
191
|
+
|
|
192
|
+
from voiceio.config import Config
|
|
193
|
+
with patch("voiceio.worker.load_model", return_value=_Model()), \
|
|
194
|
+
patch("voiceio.vocabulary.load_terms", return_value=[]):
|
|
195
|
+
scores = evaluate(Config(), default_matrix(), teacher=lambda p: "hi")
|
|
196
|
+
|
|
197
|
+
assert all(s.crashes == 1 for s in scores)
|
|
198
|
+
assert all(s.clips == 0 for s in scores)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Tests for exact Whisper token counting and the shared 448-token budget.
|
|
2
|
+
|
|
3
|
+
Every budget here used to be a character count with a guessed chars/token ratio.
|
|
4
|
+
The guess was wrong in the direction that mattered: proper nouns tokenize at
|
|
5
|
+
~2.6 chars/token vs prose's ~4.0, so the old 600-char hotwords cap was really
|
|
6
|
+
238 tokens — past faster-whisper's 223 cap, which then silently dropped terms
|
|
7
|
+
and starved the output budget.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from unittest.mock import patch
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from voiceio.tokens import (
|
|
16
|
+
HOTWORDS_TOKEN_CAP,
|
|
17
|
+
MAX_SEQUENCE_TOKENS,
|
|
18
|
+
OUTPUT_RESERVE_TOKENS,
|
|
19
|
+
PROMPT_TOKEN_BUDGET,
|
|
20
|
+
_tokenizer,
|
|
21
|
+
count_tokens,
|
|
22
|
+
truncate_to_tokens,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Most tests here are self-consistent — they count and assert with the same
|
|
26
|
+
# function, so the estimate fallback is fine. A few assert properties of the
|
|
27
|
+
# REAL tokenizer and are meaningless against a fixed chars/token constant; those
|
|
28
|
+
# need a cached model, which CI runners don't have.
|
|
29
|
+
needs_tokenizer = pytest.mark.skipif(
|
|
30
|
+
_tokenizer("small") is None,
|
|
31
|
+
reason="no cached whisper tokenizer (CI runners have no model cache)",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestCountTokens:
|
|
36
|
+
def test_empty(self):
|
|
37
|
+
assert count_tokens("", "small") == 0
|
|
38
|
+
|
|
39
|
+
def test_counts_real_tokens(self):
|
|
40
|
+
n = count_tokens("Grafana, Hetzner, OpenRouter", "small")
|
|
41
|
+
assert 5 < n < 30
|
|
42
|
+
|
|
43
|
+
@needs_tokenizer
|
|
44
|
+
def test_proper_nouns_cost_more_than_prose_per_char(self):
|
|
45
|
+
"""The measured fact the whole redesign rests on.
|
|
46
|
+
|
|
47
|
+
Only meaningful against the real tokenizer: the fallback is a fixed
|
|
48
|
+
chars/token constant, which makes both ratios equal by construction.
|
|
49
|
+
"""
|
|
50
|
+
nouns = "Kalshi, Metaculus, HyperNEAT, Kubernetes, Hetzner, Grafana"
|
|
51
|
+
prose = "the quick brown fox jumps over the lazy dog and then it runs"
|
|
52
|
+
nouns_ratio = len(nouns) / count_tokens(nouns, "small")
|
|
53
|
+
prose_ratio = len(prose) / count_tokens(prose, "small")
|
|
54
|
+
assert nouns_ratio < prose_ratio
|
|
55
|
+
|
|
56
|
+
def test_falls_back_to_estimate_without_tokenizer(self):
|
|
57
|
+
"""No tokenizer must degrade to an estimate, never break dictation."""
|
|
58
|
+
import voiceio.tokens as t
|
|
59
|
+
with patch.dict(t._cache, {"bogus-model": None}, clear=False):
|
|
60
|
+
n = count_tokens("hello world", "bogus-model")
|
|
61
|
+
assert n > 0
|
|
62
|
+
|
|
63
|
+
def test_survives_a_broken_tokenizer(self):
|
|
64
|
+
import voiceio.tokens as t
|
|
65
|
+
|
|
66
|
+
class _Boom:
|
|
67
|
+
def encode(self, _):
|
|
68
|
+
raise RuntimeError("corrupt")
|
|
69
|
+
|
|
70
|
+
with patch.dict(t._cache, {"broken": _Boom()}, clear=False):
|
|
71
|
+
assert count_tokens("hello world", "broken") > 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TestTruncateToTokens:
|
|
75
|
+
def test_noop_when_within_budget(self):
|
|
76
|
+
assert truncate_to_tokens("short text", 100, "small") == "short text"
|
|
77
|
+
|
|
78
|
+
def test_trims_to_budget(self):
|
|
79
|
+
text = "word " * 500
|
|
80
|
+
out = truncate_to_tokens(text, 50, "small")
|
|
81
|
+
assert count_tokens(out, "small") <= 50
|
|
82
|
+
assert out # not emptied
|
|
83
|
+
|
|
84
|
+
def test_keeps_the_end(self):
|
|
85
|
+
"""Prompts condition the audio that follows, so the OLDEST context goes."""
|
|
86
|
+
text = "alpha beta gamma delta " * 50 + "FINALWORD"
|
|
87
|
+
out = truncate_to_tokens(text, 20, "small")
|
|
88
|
+
assert "FINALWORD" in out
|
|
89
|
+
|
|
90
|
+
def test_zero_budget_is_empty(self):
|
|
91
|
+
assert truncate_to_tokens("anything", 0, "small") == ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TestSharedBudget:
|
|
95
|
+
def test_budgets_leave_room_to_transcribe(self):
|
|
96
|
+
"""hotwords + prompt + output must fit 448, with output usable.
|
|
97
|
+
|
|
98
|
+
Before this, the live config spent 223 (hotwords) + 171 (prompt) and
|
|
99
|
+
left ~50 tokens — about 37 words — to transcribe a freeze chunk holding
|
|
100
|
+
45-60. Worst case it went negative and faster-whisper raised
|
|
101
|
+
"The maximum decoding length must be > 0".
|
|
102
|
+
"""
|
|
103
|
+
from voiceio.app import _HOTWORDS_TOKEN_BUDGET
|
|
104
|
+
|
|
105
|
+
total = _HOTWORDS_TOKEN_BUDGET + PROMPT_TOKEN_BUDGET + OUTPUT_RESERVE_TOKENS
|
|
106
|
+
assert total + 4 <= MAX_SEQUENCE_TOKENS
|
|
107
|
+
assert _HOTWORDS_TOKEN_BUDGET <= HOTWORDS_TOKEN_CAP
|
|
@@ -233,13 +233,11 @@ class TestDecodeTimeoutIsLazy:
|
|
|
233
233
|
class TestWorkerModelLoad:
|
|
234
234
|
"""A cached model must load without touching the network."""
|
|
235
235
|
|
|
236
|
-
ARGS = {"model": "small", "device": "auto", "compute_type": "int8"}
|
|
237
|
-
|
|
238
236
|
def test_prefers_local_cache(self):
|
|
239
237
|
from voiceio import worker
|
|
240
238
|
|
|
241
239
|
with patch("voiceio.worker.WhisperModel") as wm:
|
|
242
|
-
worker.
|
|
240
|
+
worker.load_model("small", device="auto", compute_type="int8")
|
|
243
241
|
assert wm.call_args.kwargs["local_files_only"] is True
|
|
244
242
|
|
|
245
243
|
def test_falls_back_to_network_when_not_cached(self):
|
|
@@ -248,6 +246,23 @@ class TestWorkerModelLoad:
|
|
|
248
246
|
sentinel = object()
|
|
249
247
|
with patch("voiceio.worker.WhisperModel",
|
|
250
248
|
side_effect=[OSError("not cached"), sentinel]) as wm:
|
|
251
|
-
got = worker.
|
|
249
|
+
got = worker.load_model("small")
|
|
252
250
|
assert got is sentinel
|
|
253
251
|
assert "local_files_only" not in wm.call_args.kwargs
|
|
252
|
+
|
|
253
|
+
def test_every_whisper_load_goes_through_the_guarded_helper(self):
|
|
254
|
+
"""A raw WhisperModel(...) anywhere else reintroduces the hang: the
|
|
255
|
+
teacher audit and the wizard both load models on this same box."""
|
|
256
|
+
import re
|
|
257
|
+
from pathlib import Path
|
|
258
|
+
|
|
259
|
+
src = Path(__file__).resolve().parent.parent / "voiceio"
|
|
260
|
+
offenders = []
|
|
261
|
+
for py in src.rglob("*.py"):
|
|
262
|
+
for i, line in enumerate(py.read_text().splitlines(), 1):
|
|
263
|
+
if re.search(r"(?<!def )\bWhisperModel\(", line):
|
|
264
|
+
# worker.load_model is the one sanctioned construction site.
|
|
265
|
+
if py.name == "worker.py":
|
|
266
|
+
continue
|
|
267
|
+
offenders.append(f"{py.relative_to(src)}:{i}")
|
|
268
|
+
assert not offenders, f"unguarded WhisperModel loads: {offenders}"
|