python-voiceio 0.8.1__tar.gz → 0.8.3__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 (129) hide show
  1. {python_voiceio-0.8.1/python_voiceio.egg-info → python_voiceio-0.8.3}/PKG-INFO +1 -1
  2. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/pyproject.toml +1 -1
  3. {python_voiceio-0.8.1 → python_voiceio-0.8.3/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/python_voiceio.egg-info/SOURCES.txt +3 -0
  5. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_audio_quality.py +25 -4
  6. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_ibus_ping.py +6 -0
  7. python_voiceio-0.8.3/tests/test_tokens.py +107 -0
  8. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_transcriber.py +100 -0
  9. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_vocabulary.py +102 -6
  10. python_voiceio-0.8.3/voiceio/__init__.py +1 -0
  11. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/app.py +63 -21
  12. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/audit.py +37 -16
  13. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/cli.py +53 -0
  14. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/service.py +3 -0
  15. python_voiceio-0.8.3/voiceio/tokens.py +138 -0
  16. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/transcriber.py +28 -5
  17. python_voiceio-0.8.3/voiceio/vocab_stats.py +159 -0
  18. python_voiceio-0.8.3/voiceio/vocabulary.py +316 -0
  19. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/wizard.py +4 -2
  20. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/worker.py +25 -1
  21. python_voiceio-0.8.1/voiceio/__init__.py +0 -1
  22. python_voiceio-0.8.1/voiceio/vocabulary.py +0 -186
  23. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/LICENSE +0 -0
  24. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/README.md +0 -0
  25. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/python_voiceio.egg-info/dependency_links.txt +0 -0
  26. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/python_voiceio.egg-info/entry_points.txt +0 -0
  27. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/python_voiceio.egg-info/requires.txt +0 -0
  28. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/python_voiceio.egg-info/top_level.txt +0 -0
  29. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/setup.cfg +0 -0
  30. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_adjudicate.py +0 -0
  31. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_app_wiring.py +0 -0
  32. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_audit.py +0 -0
  33. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_autocorrect.py +0 -0
  34. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_autocorrect_state.py +0 -0
  35. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_backend_probes.py +0 -0
  36. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_cli.py +0 -0
  37. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_clipboard_read.py +0 -0
  38. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_commands.py +0 -0
  39. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_concurrency_lockdown.py +0 -0
  40. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_config.py +0 -0
  41. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_correct_batch.py +0 -0
  42. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_corrections.py +0 -0
  43. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_fallback.py +0 -0
  44. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_health.py +0 -0
  45. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_hints.py +0 -0
  46. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_history.py +0 -0
  47. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_ibus_pending.py +0 -0
  48. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_ibus_typer.py +0 -0
  49. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_llm.py +0 -0
  50. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_llm_api.py +0 -0
  51. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_numbers.py +0 -0
  52. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_platform.py +0 -0
  53. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_postcorrect.py +0 -0
  54. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_postprocess.py +0 -0
  55. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_prebuffer.py +0 -0
  56. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_prompt.py +0 -0
  57. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_recorder_integration.py +0 -0
  58. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_retention.py +0 -0
  59. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_robustness.py +0 -0
  60. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_security_hardening.py +0 -0
  61. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_snapshots.py +0 -0
  62. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_streaming.py +0 -0
  63. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_tts.py +0 -0
  64. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_vad.py +0 -0
  65. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_wizard.py +0 -0
  66. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/tests/test_wordfreq.py +0 -0
  67. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/__main__.py +0 -0
  68. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/autocorrect.py +0 -0
  69. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/autocorrect_state.py +0 -0
  70. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/backends.py +0 -0
  71. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/clipboard_read.py +0 -0
  72. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/commands.py +0 -0
  73. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/config.py +0 -0
  74. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/consent.py +0 -0
  75. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/corrections.py +0 -0
  76. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/demo.py +0 -0
  77. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/feedback.py +0 -0
  78. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/health.py +0 -0
  79. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/hints.py +0 -0
  80. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/history.py +0 -0
  81. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/hotkeys/__init__.py +0 -0
  82. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/hotkeys/base.py +0 -0
  83. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/hotkeys/chain.py +0 -0
  84. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/hotkeys/evdev.py +0 -0
  85. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/hotkeys/pynput_backend.py +0 -0
  86. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/hotkeys/socket_backend.py +0 -0
  87. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/ibus/__init__.py +0 -0
  88. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/ibus/engine.py +0 -0
  89. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/ibus/pending.py +0 -0
  90. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/llm.py +0 -0
  91. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/llm_api.py +0 -0
  92. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/models/__init__.py +0 -0
  93. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/models/silero_vad.onnx +0 -0
  94. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/numbers.py +0 -0
  95. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/pidlock.py +0 -0
  96. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/platform.py +0 -0
  97. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/postcorrect.py +0 -0
  98. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/postprocess.py +0 -0
  99. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/prompt.py +0 -0
  100. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/recorder.py +0 -0
  101. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/retention.py +0 -0
  102. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/snapshots.py +0 -0
  103. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/sounds/__init__.py +0 -0
  104. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/sounds/commit.wav +0 -0
  105. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/sounds/start.wav +0 -0
  106. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/sounds/stop.wav +0 -0
  107. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/streaming.py +0 -0
  108. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/tray/__init__.py +0 -0
  109. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/tray/_icons.py +0 -0
  110. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/tray/_indicator.py +0 -0
  111. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/tray/_pystray.py +0 -0
  112. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/tts/__init__.py +0 -0
  113. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/tts/base.py +0 -0
  114. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/tts/chain.py +0 -0
  115. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/tts/edge_engine.py +0 -0
  116. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/tts/espeak.py +0 -0
  117. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/tts/piper_engine.py +0 -0
  118. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/tts/player.py +0 -0
  119. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/typers/__init__.py +0 -0
  120. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/typers/base.py +0 -0
  121. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/typers/chain.py +0 -0
  122. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/typers/clipboard.py +0 -0
  123. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/typers/ibus.py +0 -0
  124. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/typers/pynput_type.py +0 -0
  125. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/typers/wtype.py +0 -0
  126. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/typers/xdotool.py +0 -0
  127. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/typers/ydotool.py +0 -0
  128. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/vad.py +0 -0
  129. {python_voiceio-0.8.1 → python_voiceio-0.8.3}/voiceio/wordfreq.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.8.1
3
+ Version: 0.8.3
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-voiceio"
7
- version = "0.8.1"
7
+ version = "0.8.3"
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.1
3
+ Version: 0.8.3
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -42,6 +42,7 @@ tests/test_robustness.py
42
42
  tests/test_security_hardening.py
43
43
  tests/test_snapshots.py
44
44
  tests/test_streaming.py
45
+ tests/test_tokens.py
45
46
  tests/test_transcriber.py
46
47
  tests/test_tts.py
47
48
  tests/test_vad.py
@@ -79,8 +80,10 @@ voiceio/retention.py
79
80
  voiceio/service.py
80
81
  voiceio/snapshots.py
81
82
  voiceio/streaming.py
83
+ voiceio/tokens.py
82
84
  voiceio/transcriber.py
83
85
  voiceio/vad.py
86
+ voiceio/vocab_stats.py
84
87
  voiceio/vocabulary.py
85
88
  voiceio/wizard.py
86
89
  voiceio/wordfreq.py
@@ -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
@@ -4,10 +4,16 @@ socket path silently starves the healthy instance)."""
4
4
  from __future__ import annotations
5
5
 
6
6
  import socket
7
+ import sys
7
8
  import threading
8
9
 
10
+ import pytest
11
+
9
12
  import voiceio.ibus as ibus_mod
10
13
 
14
+ # IBus (and AF_UNIX DGRAM + Linux abstract autobind) is Linux-only.
15
+ pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="IBus is Linux-only")
16
+
11
17
 
12
18
  def test_no_socket_returns_false(tmp_path, monkeypatch):
13
19
  monkeypatch.setattr(ibus_mod, "SOCKET_PATH", tmp_path / "nope.sock")
@@ -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
@@ -166,3 +166,103 @@ class TestTimeoutRaises:
166
166
  with pytest.raises(TranscriptionError):
167
167
  t.transcribe(np.zeros(16000, dtype=np.float32))
168
168
  assert t.last_segments == []
169
+
170
+
171
+ class TestWorkerStartTimeout:
172
+ """A worker that never reports READY must not wedge the daemon.
173
+
174
+ faster-whisper's model load contacts huggingface_hub even for a cached
175
+ model; on a blackholed route that connect hangs indefinitely. An unbounded
176
+ READY handshake then holds the transcribe lock forever and every later
177
+ dictation silently returns "".
178
+ """
179
+
180
+ def test_start_raises_instead_of_hanging(self):
181
+ import threading
182
+ from voiceio.transcriber import Transcriber, TranscriptionError
183
+
184
+ mock_proc = MagicMock()
185
+ mock_proc.poll.return_value = None
186
+ mock_proc.stdout.readline.side_effect = lambda: threading.Event().wait()
187
+
188
+ with patch("voiceio.transcriber.subprocess.Popen", return_value=mock_proc), \
189
+ patch("voiceio.transcriber.WORKER_START_TIMEOUT", 0.1):
190
+ with pytest.raises(TranscriptionError, match="failed to start"):
191
+ Transcriber(ModelConfig())
192
+
193
+ # The hung process is reaped, not leaked.
194
+ assert mock_proc.terminate.called or mock_proc.kill.called
195
+
196
+ def test_start_raises_on_garbage_handshake(self):
197
+ from voiceio.transcriber import Transcriber, TranscriptionError
198
+
199
+ mock_proc = MagicMock()
200
+ mock_proc.poll.return_value = None
201
+ mock_proc.stdout.readline.return_value = "Traceback: boom\n"
202
+
203
+ with patch("voiceio.transcriber.subprocess.Popen", return_value=mock_proc):
204
+ with pytest.raises(TranscriptionError, match="failed to start"):
205
+ Transcriber(ModelConfig())
206
+
207
+
208
+ class TestDecodeTimeoutIsLazy:
209
+ """The timed-out call fails regardless, so reloading the model inline would
210
+ hold the lock through a multi-second start and starve the final pass."""
211
+
212
+ def test_decode_timeout_does_not_reload_inline(self):
213
+ import numpy as np
214
+ from voiceio.transcriber import Transcriber, TranscriptionError
215
+
216
+ mock_proc = MagicMock()
217
+ mock_proc.stdout.readline.return_value = "READY\n"
218
+ mock_proc.poll.return_value = None
219
+ with patch("voiceio.transcriber.subprocess.Popen", return_value=mock_proc):
220
+ t = Transcriber(ModelConfig())
221
+
222
+ with patch.object(t, "_read_with_timeout", return_value=None), \
223
+ patch.object(t, "_kill_worker") as kill, \
224
+ patch.object(t, "_ensure_worker") as ensure:
225
+ with pytest.raises(TranscriptionError):
226
+ t.transcribe(np.zeros(16000, dtype=np.float32))
227
+
228
+ assert kill.called
229
+ # Only the entry call — never a second, restarting one.
230
+ assert ensure.call_count == 1
231
+
232
+
233
+ class TestWorkerModelLoad:
234
+ """A cached model must load without touching the network."""
235
+
236
+ def test_prefers_local_cache(self):
237
+ from voiceio import worker
238
+
239
+ with patch("voiceio.worker.WhisperModel") as wm:
240
+ worker.load_model("small", device="auto", compute_type="int8")
241
+ assert wm.call_args.kwargs["local_files_only"] is True
242
+
243
+ def test_falls_back_to_network_when_not_cached(self):
244
+ from voiceio import worker
245
+
246
+ sentinel = object()
247
+ with patch("voiceio.worker.WhisperModel",
248
+ side_effect=[OSError("not cached"), sentinel]) as wm:
249
+ got = worker.load_model("small")
250
+ assert got is sentinel
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}"
@@ -1,7 +1,8 @@
1
- """Tests for vocabulary file loading."""
1
+ """Tests for vocabulary file loading, ranking and token-budgeted selection."""
2
2
  from __future__ import annotations
3
3
 
4
4
  import os
5
+ import time
5
6
 
6
7
  from voiceio.config import ModelConfig
7
8
  from voiceio.vocabulary import VocabularyLoader, add_terms, load_vocabulary
@@ -41,14 +42,21 @@ class TestLoadVocabulary:
41
42
  result = load_vocabulary(cfg)
42
43
  assert result == ""
43
44
 
44
- def test_truncation(self, tmp_path):
45
+ def test_load_is_untruncated(self, tmp_path):
46
+ """load_vocabulary returns EVERY term — truncating here was the bug.
47
+
48
+ Budgeting moved to select_terms (token-aware, ranked). The callers that
49
+ need the whole list — postcorrect's LLM, and the mining gate's "is this
50
+ already in your vocabulary?" check — were silently reading a truncated
51
+ view before.
52
+ """
45
53
  vocab_file = tmp_path / "vocabulary.txt"
46
- # Create a very long vocabulary list
47
54
  terms = [f"LongTechnicalTerm{i:04d}" for i in range(100)]
48
55
  vocab_file.write_text("\n".join(terms))
49
56
  cfg = ModelConfig(vocabulary_file=str(vocab_file))
50
57
  result = load_vocabulary(cfg)
51
- assert len(result) <= 800
58
+ assert len(result) > 800
59
+ assert result.count(",") == 99
52
60
 
53
61
  def test_no_config_uses_default_location(self, tmp_path, monkeypatch):
54
62
  """With empty vocabulary_file, checks CONFIG_DIR/vocabulary.txt."""
@@ -145,13 +153,15 @@ class TestVocabularyLoader:
145
153
 
146
154
  import voiceio.vocabulary as vocab_mod
147
155
  calls = {"n": 0}
148
- orig = vocab_mod.load_vocabulary
156
+ orig = vocab_mod.load_terms
149
157
 
150
158
  def _counting(model_cfg):
151
159
  calls["n"] += 1
152
160
  return orig(model_cfg)
153
161
 
154
- monkeypatch.setattr(vocab_mod, "load_vocabulary", _counting)
162
+ # load_terms is the real read now; patching load_vocabulary would pass
163
+ # vacuously since the loader no longer calls it.
164
+ monkeypatch.setattr(vocab_mod, "load_terms", _counting)
155
165
  loader.get()
156
166
  loader.get()
157
167
  assert calls["n"] == 0 # mtime unchanged → no reload
@@ -160,3 +170,89 @@ class TestVocabularyLoader:
160
170
  cfg = ModelConfig(vocabulary_file=str(tmp_path / "nope.txt"))
161
171
  loader = VocabularyLoader(cfg)
162
172
  assert loader.get() == ""
173
+
174
+
175
+ class TestSelectTerms:
176
+ """Selection is permanent: the hotword budget holds ~40-60 terms and the
177
+ vocabulary only grows, so who gets a slot is the whole game."""
178
+
179
+ def _stats(self, tmp_path, data):
180
+ from voiceio.vocab_stats import VocabStats
181
+ s = VocabStats(tmp_path / "vocab_stats.json")
182
+ s._stats = {k.lower(): v for k, v in data.items()}
183
+ return s
184
+
185
+ def test_respects_token_budget(self):
186
+ from voiceio.vocabulary import select_terms
187
+ from voiceio.tokens import count_tokens
188
+
189
+ terms = [f"LongTechnicalTerm{i:04d}" for i in range(100)]
190
+ got = select_terms(terms, token_budget=50, model_name="small")
191
+ assert got # something fits
192
+ assert len(got) < len(terms) # but not everything
193
+ assert count_tokens(", ".join(got), "small") <= 50
194
+
195
+ def test_actually_fills_the_budget(self):
196
+ """Guards the mistake this replaced: costing terms one at a time and
197
+ summing double-counts the ", " joins, so the budget was only ~half
198
+ spent (19 terms / 64 of 120 tokens on the real vocabulary)."""
199
+ from voiceio.vocabulary import select_terms
200
+ from voiceio.tokens import count_tokens
201
+
202
+ terms = [f"Term{i:04d}" for i in range(200)]
203
+ budget = 100
204
+ got = select_terms(terms, token_budget=budget, model_name="small")
205
+ used = count_tokens(", ".join(got), "small")
206
+ assert used <= budget
207
+ # Within one term's cost of the budget — no silent under-fill.
208
+ assert used >= budget - 8
209
+ # And adding the next-ranked term must genuinely overflow.
210
+ nxt = [t for t in terms if t not in got][0]
211
+ assert count_tokens(", ".join(got + [nxt]), "small") > budget
212
+
213
+ def test_never_emits_a_partial_term(self):
214
+ """The old code did vocab[:600] — a raw char slice that could cut a term
215
+ in half and feed the decoder a fragment."""
216
+ from voiceio.vocabulary import select_terms
217
+
218
+ terms = ["Kubernetes", "Grafana", "Hetzner", "OpenRouter", "Metaculus"]
219
+ got = select_terms(terms, token_budget=12, model_name="small")
220
+ assert all(t in terms for t in got)
221
+
222
+ def test_ranks_by_usage(self, tmp_path):
223
+ from voiceio.vocabulary import select_terms
224
+
225
+ terms = ["Alpha", "Beta", "Gamma"]
226
+ stats = self._stats(tmp_path, {
227
+ "Gamma": {"hits": 50, "last_seen_ts": time.time()},
228
+ })
229
+ # Budget for roughly one term: the used one must win despite file order.
230
+ got = select_terms(terms, token_budget=6, model_name="small", stats=stats)
231
+ assert got[0] == "Gamma"
232
+
233
+ def test_recency_beats_stale_volume(self, tmp_path):
234
+ """A term used heavily months ago should lose to one used today."""
235
+ from voiceio.vocabulary import select_terms
236
+
237
+ now = time.time()
238
+ stats = self._stats(tmp_path, {
239
+ "Stale": {"hits": 100, "last_seen_ts": now - 365 * 86400},
240
+ "Fresh": {"hits": 5, "last_seen_ts": now},
241
+ })
242
+ got = select_terms(["Stale", "Fresh"], token_budget=6,
243
+ model_name="small", stats=stats, now=now)
244
+ assert got[0] == "Fresh"
245
+
246
+ def test_cold_start_is_file_order(self):
247
+ """No stats → degrade to exactly the previous behaviour, never worse."""
248
+ from voiceio.vocabulary import select_terms
249
+
250
+ terms = ["First", "Second", "Third"]
251
+ got = select_terms(terms, token_budget=1000, model_name="small", stats=None)
252
+ assert got == terms
253
+
254
+ def test_empty_and_zero_budget(self):
255
+ from voiceio.vocabulary import select_terms
256
+
257
+ assert select_terms([], token_budget=100, model_name="small") == []
258
+ assert select_terms(["A"], token_budget=0, model_name="small") == []
@@ -0,0 +1 @@
1
+ __version__ = "0.8.3"
@@ -97,10 +97,20 @@ def _import_graphical_env() -> None:
97
97
  _HEALTH_CHECK_INTERVAL = 10 # seconds between health checks
98
98
 
99
99
  # Whisper's decoder has a hard 448-token sequence budget shared by hotwords,
100
- # initial_prompt AND the transcription output. Hotwords + prompt must stay
101
- # well under half of it or output gets truncated mid-utterance.
102
- # ~600 chars ≈ 150 tokens.
103
- _HOTWORDS_MAX_CHARS = 600
100
+ # initial_prompt AND the transcription output. Hotwords + prompt must stay well
101
+ # under half of it or output gets truncated mid-utterance.
102
+ #
103
+ # This is a TOKEN budget, counted for real (voiceio.tokens) — the old 600-char
104
+ # proxy assumed ~4 chars/token, but proper nouns (the whole point of a custom
105
+ # vocabulary) run ~2.6, so "600 chars" was really 238 tokens: already past
106
+ # faster-whisper's own 223-token hotwords cap, which then silently dropped the
107
+ # tail.
108
+ #
109
+ # Derived, not guessed: 448 total - 200 output reserve - 120 prompt - 4 overhead
110
+ # = 124. At ~3.5 tokens/term that is ~34 terms. Fewer terms than the old config
111
+ # pretended to carry, but they are real: the old 223-token list left ~50 tokens
112
+ # to transcribe with and truncated freeze chunks mid-sentence.
113
+ _HOTWORDS_TOKEN_BUDGET = 120
104
114
 
105
115
 
106
116
  class _State(enum.Enum):
@@ -309,30 +319,56 @@ class VoiceIO:
309
319
  self._do_start()
310
320
 
311
321
  def _refresh_hotwords(self) -> None:
312
- """Rebuild Whisper hotwords from vocabulary + correction targets.
322
+ """Rebuild Whisper hotwords: rank by usage, fill the token budget.
313
323
 
314
- The vocabulary read is mtime-cached (only a `stat` when unchanged), and
315
- `set_hotwords` is called only when the merged string actually changed,
316
- so this stays cheap enough to run on every recording start.
324
+ Selection is mandatory and permanent the budget holds ~40-60 terms and
325
+ the vocabulary only grows — so the slots go to the terms actually being
326
+ dictated (voiceio.vocab_stats), highest-scoring first.
327
+
328
+ Both reads are mtime-cached and `set_hotwords` only fires when the string
329
+ changed, so the steady-state cost here is a couple of `stat` calls. That
330
+ matters: this runs synchronously before `recorder.start()`.
317
331
  """
318
- vocab = self._vocab_loader.get()
332
+ terms = self._vocab_loader.get_selected(token_budget=_HOTWORDS_TOKEN_BUDGET)
319
333
  # Only merge RARE correction targets (proper nouns, technical terms):
320
334
  # common-word targets ("review", "company") add nothing to decoder bias
321
335
  # and hundreds of them blow Whisper's 448-token prompt+output budget,
322
336
  # which truncates transcriptions mid-utterance.
323
- from voiceio.wordfreq import is_common
324
- vocab_terms = [
325
- t for t in self._corrections.vocabulary_terms()
326
- if not is_common(t, self.cfg.model.language)
327
- ]
328
- if vocab_terms:
329
- extra = ", ".join(vocab_terms)
330
- vocab = f"{vocab}, {extra}" if vocab else extra
331
- vocab = vocab[:_HOTWORDS_MAX_CHARS]
337
+ vocab = ", ".join(terms + self._rare_correction_targets())
332
338
  if vocab != self._hotwords:
333
339
  self._hotwords = vocab
334
- if vocab:
335
- self.transcriber.set_hotwords(vocab)
340
+ # Set unconditionally: an emptied vocabulary must CLEAR the hotwords,
341
+ # not leave the previous string biasing every future decode.
342
+ self.transcriber.set_hotwords(vocab or None)
343
+
344
+ def _record_vocab_usage(self, text: str) -> None:
345
+ """Record which vocabulary terms this utterance used (finalize path only).
346
+
347
+ This is the feedback signal that decides who gets the hotword budget next
348
+ time. Deliberately not on the recording-start path, and never fatal — a
349
+ usage counter must not be able to break a dictation.
350
+ """
351
+ try:
352
+ from voiceio import vocab_stats
353
+ vocab_stats.update_from_text(text, self._vocab_loader.get_all())
354
+ except Exception: # noqa: BLE001
355
+ log.debug("vocab usage update failed", exc_info=True)
356
+
357
+ def _rare_correction_targets(self) -> list[str]:
358
+ """Correction targets worth biasing, cached against corrections mtime.
359
+
360
+ `is_common` over every target used to be recomputed on each recording
361
+ start; the result only changes when corrections.json does.
362
+ """
363
+ mtime = getattr(self._corrections, "_mtime", None)
364
+ if getattr(self, "_rare_targets_mtime", object()) != mtime:
365
+ from voiceio.wordfreq import is_common
366
+ self._rare_targets = [
367
+ t for t in self._corrections.vocabulary_terms()
368
+ if not is_common(t, self.cfg.model.language)
369
+ ]
370
+ self._rare_targets_mtime = mtime
371
+ return self._rare_targets
336
372
 
337
373
  def _do_start(self) -> None:
338
374
  """Transition to RECORDING."""
@@ -432,8 +468,13 @@ class VoiceIO:
432
468
  if self._postcorrect is not None:
433
469
  # Freshest context for the finalize pass: this recording's window
434
470
  # title (captured at start) and the latest transcripts/vocabulary.
471
+ #
472
+ # The FULL vocabulary, not the hotwords string: the decoder can only
473
+ # be biased with ~40-60 terms, but this is an LLM with no 448-token
474
+ # budget. The long tail that can never fit in hotwords gets its only
475
+ # chance to be corrected here.
435
476
  self._postcorrect.set_context(
436
- vocabulary=self._hotwords,
477
+ vocabulary=", ".join(self._vocab_loader.get_all()),
437
478
  recent=self._prompt_builder.recent(3),
438
479
  title=self._context_title,
439
480
  )
@@ -552,6 +593,7 @@ class VoiceIO:
552
593
  self._play_feedback(final_text)
553
594
  stored = self._strip_voice_prefix(final_text)
554
595
  self._prompt_builder.add_transcript(stored)
596
+ self._record_vocab_usage(stored)
555
597
  latency = dict(session.final_latency)
556
598
  # stop-to-commit: what the user actually waits for
557
599
  latency["finalize_total"] = round(time.monotonic() - t_final, 3)
@@ -169,9 +169,11 @@ def _read_metrics(path: Path | None = None) -> list[dict]:
169
169
 
170
170
  def _default_teacher():
171
171
  """Lazily build the offline teacher transcriber (distil-large-v3)."""
172
- from faster_whisper import WhisperModel
172
+ from voiceio.worker import load_model
173
173
 
174
- model = WhisperModel("distil-large-v3", device="cpu", compute_type="int8")
174
+ # Cache-first: the module docstring's "nothing here uses the network" only
175
+ # holds if the load itself can't reach out and hang (see load_model).
176
+ model = load_model("distil-large-v3")
175
177
 
176
178
  def _transcribe(wav_path: Path) -> str:
177
179
  segments, _ = model.transcribe(
@@ -323,14 +325,42 @@ def run_audit(
323
325
  return report
324
326
 
325
327
 
328
+ def _last_seen_map(terms: list[str]) -> dict[str, float]:
329
+ """When each term was last dictated.
330
+
331
+ Prefers vocab_stats.json, which the runtime maintains per utterance. Falls
332
+ back to scanning history only when no stats exist yet (fresh install, or a
333
+ daemon that hasn't finalized a dictation since stats landed) — that scan is
334
+ O(entries x terms) over the whole history, which at 6k entries and 300+
335
+ terms is not worth running when the answer is already recorded.
336
+ """
337
+ from voiceio.vocab_stats import VocabStats
338
+
339
+ stats = VocabStats()
340
+ stats.load()
341
+ if stats.as_dict():
342
+ return {t: float(stats.get(t).get("last_seen_ts", 0.0) or 0.0) for t in terms}
343
+
344
+ from voiceio import history
345
+
346
+ last_seen: dict[str, float] = {}
347
+ for e in history.read(limit=0):
348
+ text = (e.get("text") or "").lower()
349
+ ts = e.get("ts", 0.0)
350
+ for term in terms:
351
+ if term.lower() in text and ts > last_seen.get(term, 0.0):
352
+ last_seen[term] = ts
353
+ return last_seen
354
+
355
+
326
356
  def _audit_vocabulary(cfg, teacher_texts, now, report) -> None:
327
357
  """Confirm vocab terms the teacher heard; age out terms unseen for 90 days.
328
358
 
329
- Aging moves a term to the bottom of vocabulary.txt rather than deleting it:
330
- the 800-char loader truncates the tail, so stale terms fall out of the
331
- hotword budget but can rejoin if used again.
359
+ Aging moves a term to the bottom of vocabulary.txt rather than deleting it.
360
+ File order is now only the tie-break for terms with no usage signal —
361
+ vocabulary.select_terms ranks by vocab_stats first but keeping stale terms
362
+ at the bottom still means they lose ties, and they rejoin on next use.
332
363
  """
333
- from voiceio import history
334
364
  from voiceio.vocabulary import _read_terms, resolve_vocab_path
335
365
 
336
366
  path = resolve_vocab_path(cfg.model)
@@ -341,16 +371,7 @@ def _audit_vocabulary(cfg, teacher_texts, now, report) -> None:
341
371
  return
342
372
 
343
373
  teacher_blob = "\n".join(teacher_texts)
344
- # Last time each term appeared in a live transcript.
345
- entries = history.read(limit=0)
346
- last_seen: dict[str, float] = {}
347
- for e in entries:
348
- text = e.get("text", "")
349
- ts = e.get("ts", 0.0)
350
- low = text.lower()
351
- for term in terms:
352
- if term.lower() in low and ts > last_seen.get(term, 0.0):
353
- last_seen[term] = ts
374
+ last_seen = _last_seen_map(terms)
354
375
 
355
376
  fresh: list[str] = []
356
377
  aged: list[str] = []