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.
Files changed (131) hide show
  1. {python_voiceio-0.8.2/python_voiceio.egg-info → python_voiceio-0.9.0}/PKG-INFO +1 -1
  2. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/pyproject.toml +1 -1
  3. {python_voiceio-0.8.2 → python_voiceio-0.9.0/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/python_voiceio.egg-info/SOURCES.txt +5 -0
  5. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_adjudicate.py +14 -1
  6. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_audio_quality.py +25 -4
  7. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_correct_batch.py +85 -3
  8. python_voiceio-0.9.0/tests/test_evaluate.py +198 -0
  9. python_voiceio-0.9.0/tests/test_tokens.py +107 -0
  10. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_transcriber.py +19 -4
  11. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_vocabulary.py +102 -6
  12. python_voiceio-0.9.0/voiceio/__init__.py +1 -0
  13. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/app.py +63 -21
  14. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/audit.py +37 -16
  15. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/cli.py +169 -5
  16. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/config.py +11 -0
  17. python_voiceio-0.9.0/voiceio/evaluate.py +322 -0
  18. python_voiceio-0.9.0/voiceio/tokens.py +138 -0
  19. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/transcriber.py +10 -0
  20. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/typers/clipboard.py +26 -1
  21. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/typers/ibus.py +34 -4
  22. python_voiceio-0.9.0/voiceio/vocab_stats.py +159 -0
  23. python_voiceio-0.9.0/voiceio/vocabulary.py +316 -0
  24. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/wizard.py +4 -2
  25. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/worker.py +17 -9
  26. python_voiceio-0.8.2/voiceio/__init__.py +0 -1
  27. python_voiceio-0.8.2/voiceio/vocabulary.py +0 -186
  28. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/LICENSE +0 -0
  29. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/README.md +0 -0
  30. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
  31. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/python_voiceio.egg-info/entry_points.txt +0 -0
  32. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/python_voiceio.egg-info/requires.txt +0 -0
  33. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/python_voiceio.egg-info/top_level.txt +0 -0
  34. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/setup.cfg +0 -0
  35. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_app_wiring.py +0 -0
  36. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_audit.py +0 -0
  37. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_autocorrect.py +0 -0
  38. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_autocorrect_state.py +0 -0
  39. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_backend_probes.py +0 -0
  40. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_cli.py +0 -0
  41. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_clipboard_read.py +0 -0
  42. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_commands.py +0 -0
  43. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_concurrency_lockdown.py +0 -0
  44. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_config.py +0 -0
  45. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_corrections.py +0 -0
  46. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_fallback.py +0 -0
  47. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_health.py +0 -0
  48. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_hints.py +0 -0
  49. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_history.py +0 -0
  50. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_ibus_pending.py +0 -0
  51. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_ibus_ping.py +0 -0
  52. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_ibus_typer.py +0 -0
  53. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_llm.py +0 -0
  54. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_llm_api.py +0 -0
  55. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_numbers.py +0 -0
  56. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_platform.py +0 -0
  57. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_postcorrect.py +0 -0
  58. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_postprocess.py +0 -0
  59. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_prebuffer.py +0 -0
  60. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_prompt.py +0 -0
  61. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_recorder_integration.py +0 -0
  62. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_retention.py +0 -0
  63. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_robustness.py +0 -0
  64. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_security_hardening.py +0 -0
  65. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_snapshots.py +0 -0
  66. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_streaming.py +0 -0
  67. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_tts.py +0 -0
  68. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_vad.py +0 -0
  69. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_wizard.py +0 -0
  70. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/tests/test_wordfreq.py +0 -0
  71. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/__main__.py +0 -0
  72. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/autocorrect.py +0 -0
  73. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/autocorrect_state.py +0 -0
  74. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/backends.py +0 -0
  75. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/clipboard_read.py +0 -0
  76. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/commands.py +0 -0
  77. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/consent.py +0 -0
  78. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/corrections.py +0 -0
  79. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/demo.py +0 -0
  80. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/feedback.py +0 -0
  81. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/health.py +0 -0
  82. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/hints.py +0 -0
  83. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/history.py +0 -0
  84. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/hotkeys/__init__.py +0 -0
  85. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/hotkeys/base.py +0 -0
  86. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/hotkeys/chain.py +0 -0
  87. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/hotkeys/evdev.py +0 -0
  88. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/hotkeys/pynput_backend.py +0 -0
  89. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/hotkeys/socket_backend.py +0 -0
  90. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/ibus/__init__.py +0 -0
  91. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/ibus/engine.py +0 -0
  92. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/ibus/pending.py +0 -0
  93. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/llm.py +0 -0
  94. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/llm_api.py +0 -0
  95. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/models/__init__.py +0 -0
  96. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/models/silero_vad.onnx +0 -0
  97. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/numbers.py +0 -0
  98. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/pidlock.py +0 -0
  99. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/platform.py +0 -0
  100. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/postcorrect.py +0 -0
  101. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/postprocess.py +0 -0
  102. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/prompt.py +0 -0
  103. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/recorder.py +0 -0
  104. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/retention.py +0 -0
  105. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/service.py +0 -0
  106. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/snapshots.py +0 -0
  107. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/sounds/__init__.py +0 -0
  108. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/sounds/commit.wav +0 -0
  109. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/sounds/start.wav +0 -0
  110. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/sounds/stop.wav +0 -0
  111. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/streaming.py +0 -0
  112. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/tray/__init__.py +0 -0
  113. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/tray/_icons.py +0 -0
  114. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/tray/_indicator.py +0 -0
  115. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/tray/_pystray.py +0 -0
  116. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/tts/__init__.py +0 -0
  117. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/tts/base.py +0 -0
  118. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/tts/chain.py +0 -0
  119. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/tts/edge_engine.py +0 -0
  120. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/tts/espeak.py +0 -0
  121. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/tts/piper_engine.py +0 -0
  122. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/tts/player.py +0 -0
  123. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/typers/__init__.py +0 -0
  124. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/typers/base.py +0 -0
  125. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/typers/chain.py +0 -0
  126. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/typers/pynput_type.py +0 -0
  127. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/typers/wtype.py +0 -0
  128. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/typers/xdotool.py +0 -0
  129. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/typers/ydotool.py +0 -0
  130. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/vad.py +0 -0
  131. {python_voiceio-0.8.2 → python_voiceio-0.9.0}/voiceio/wordfreq.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.8.2
3
+ Version: 0.9.0
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-voiceio"
7
- version = "0.8.2"
7
+ version = "0.9.0"
8
8
  description = "Voice dictation for Linux. Speak → text, locally, instantly."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.8.2
3
+ Version: 0.9.0
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -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.llm_api.resolve_api_key", return_value="sk-x"), \
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 test_hotwords_capped(self):
129
- from unittest.mock import MagicMock, patch
130
- from voiceio.app import _HOTWORDS_MAX_CHARS
131
- assert _HOTWORDS_MAX_CHARS <= 700
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.llm_api.resolve_api_key", return_value="sk-x"), \
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.llm_api.resolve_api_key", return_value="sk-x"), \
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._load_model(dict(self.ARGS))
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._load_model(dict(self.ARGS))
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}"