python-voiceio 0.5.0__tar.gz → 0.6.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 (124) hide show
  1. {python_voiceio-0.5.0/python_voiceio.egg-info → python_voiceio-0.6.0}/PKG-INFO +1 -1
  2. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/pyproject.toml +1 -1
  3. {python_voiceio-0.5.0 → python_voiceio-0.6.0/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_postcorrect.py +116 -0
  5. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_retention.py +69 -0
  6. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_streaming.py +220 -2
  7. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_transcriber.py +36 -0
  8. python_voiceio-0.6.0/voiceio/__init__.py +1 -0
  9. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/app.py +11 -0
  10. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/config.py +19 -0
  11. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/postcorrect.py +75 -6
  12. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/retention.py +65 -1
  13. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/streaming.py +140 -12
  14. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/transcriber.py +22 -6
  15. python_voiceio-0.5.0/voiceio/__init__.py +0 -1
  16. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/LICENSE +0 -0
  17. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/README.md +0 -0
  18. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/python_voiceio.egg-info/SOURCES.txt +0 -0
  19. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
  20. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/python_voiceio.egg-info/entry_points.txt +0 -0
  21. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/python_voiceio.egg-info/requires.txt +0 -0
  22. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/python_voiceio.egg-info/top_level.txt +0 -0
  23. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/setup.cfg +0 -0
  24. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_adjudicate.py +0 -0
  25. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_app_wiring.py +0 -0
  26. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_audio_quality.py +0 -0
  27. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_audit.py +0 -0
  28. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_autocorrect.py +0 -0
  29. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_autocorrect_state.py +0 -0
  30. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_backend_probes.py +0 -0
  31. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_cli.py +0 -0
  32. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_clipboard_read.py +0 -0
  33. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_commands.py +0 -0
  34. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_concurrency_lockdown.py +0 -0
  35. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_config.py +0 -0
  36. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_correct_batch.py +0 -0
  37. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_corrections.py +0 -0
  38. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_fallback.py +0 -0
  39. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_health.py +0 -0
  40. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_hints.py +0 -0
  41. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_history.py +0 -0
  42. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_ibus_pending.py +0 -0
  43. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_ibus_typer.py +0 -0
  44. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_llm.py +0 -0
  45. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_llm_api.py +0 -0
  46. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_numbers.py +0 -0
  47. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_platform.py +0 -0
  48. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_postprocess.py +0 -0
  49. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_prebuffer.py +0 -0
  50. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_prompt.py +0 -0
  51. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_recorder_integration.py +0 -0
  52. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_robustness.py +0 -0
  53. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_security_hardening.py +0 -0
  54. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_snapshots.py +0 -0
  55. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_tts.py +0 -0
  56. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_vad.py +0 -0
  57. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_vocabulary.py +0 -0
  58. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_wizard.py +0 -0
  59. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/tests/test_wordfreq.py +0 -0
  60. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/__main__.py +0 -0
  61. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/audit.py +0 -0
  62. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/autocorrect.py +0 -0
  63. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/autocorrect_state.py +0 -0
  64. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/backends.py +0 -0
  65. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/cli.py +0 -0
  66. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/clipboard_read.py +0 -0
  67. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/commands.py +0 -0
  68. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/consent.py +0 -0
  69. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/corrections.py +0 -0
  70. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/demo.py +0 -0
  71. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/feedback.py +0 -0
  72. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/health.py +0 -0
  73. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/hints.py +0 -0
  74. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/history.py +0 -0
  75. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/hotkeys/__init__.py +0 -0
  76. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/hotkeys/base.py +0 -0
  77. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/hotkeys/chain.py +0 -0
  78. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/hotkeys/evdev.py +0 -0
  79. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/hotkeys/pynput_backend.py +0 -0
  80. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/hotkeys/socket_backend.py +0 -0
  81. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/ibus/__init__.py +0 -0
  82. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/ibus/engine.py +0 -0
  83. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/ibus/pending.py +0 -0
  84. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/llm.py +0 -0
  85. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/llm_api.py +0 -0
  86. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/models/__init__.py +0 -0
  87. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/models/silero_vad.onnx +0 -0
  88. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/numbers.py +0 -0
  89. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/pidlock.py +0 -0
  90. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/platform.py +0 -0
  91. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/postprocess.py +0 -0
  92. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/prompt.py +0 -0
  93. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/recorder.py +0 -0
  94. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/service.py +0 -0
  95. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/snapshots.py +0 -0
  96. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/sounds/__init__.py +0 -0
  97. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/sounds/commit.wav +0 -0
  98. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/sounds/start.wav +0 -0
  99. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/sounds/stop.wav +0 -0
  100. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/tray/__init__.py +0 -0
  101. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/tray/_icons.py +0 -0
  102. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/tray/_indicator.py +0 -0
  103. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/tray/_pystray.py +0 -0
  104. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/tts/__init__.py +0 -0
  105. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/tts/base.py +0 -0
  106. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/tts/chain.py +0 -0
  107. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/tts/edge_engine.py +0 -0
  108. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/tts/espeak.py +0 -0
  109. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/tts/piper_engine.py +0 -0
  110. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/tts/player.py +0 -0
  111. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/typers/__init__.py +0 -0
  112. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/typers/base.py +0 -0
  113. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/typers/chain.py +0 -0
  114. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/typers/clipboard.py +0 -0
  115. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/typers/ibus.py +0 -0
  116. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/typers/pynput_type.py +0 -0
  117. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/typers/wtype.py +0 -0
  118. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/typers/xdotool.py +0 -0
  119. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/typers/ydotool.py +0 -0
  120. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/vad.py +0 -0
  121. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/vocabulary.py +0 -0
  122. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/wizard.py +0 -0
  123. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/wordfreq.py +0 -0
  124. {python_voiceio-0.5.0 → python_voiceio-0.6.0}/voiceio/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.5.0
3
+ Version: 0.6.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.5.0"
7
+ version = "0.6.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.5.0
3
+ Version: 0.6.0
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -161,3 +161,119 @@ def test_pipeline_runs_postcorrect_only_on_final():
161
161
  )
162
162
  assert text == "Crisp chat is the best tool"
163
163
  assert not abort
164
+
165
+
166
+ # ── wall-clock deadline ──────────────────────────────────────────────────
167
+
168
+
169
+ def test_deadline_exceeded_keeps_original():
170
+ """A chat call slower than timeout_secs is abandoned at the deadline."""
171
+ import time as _time
172
+ cfg = _cfg()
173
+ cfg.postcorrect.timeout_secs = 0.2
174
+
175
+ def slow_chat(*a, **kw):
176
+ _time.sleep(2.0)
177
+ return "Crisp chat is the tool"
178
+
179
+ pc = PostCorrector(cfg)
180
+ with patch("voiceio.llm_api.chat", side_effect=slow_chat):
181
+ t0 = _time.monotonic()
182
+ assert pc.correct("Chris chat is the tool") == "Chris chat is the tool"
183
+ elapsed = _time.monotonic() - t0
184
+ assert elapsed < 1.0 # returned at the deadline, not after the full call
185
+ assert pc.last_secs is not None
186
+
187
+
188
+ def test_within_deadline_applies_correction():
189
+ cfg = _cfg()
190
+ cfg.postcorrect.timeout_secs = 5.0
191
+ pc = PostCorrector(cfg)
192
+ with patch("voiceio.llm_api.chat", return_value="Crisp chat is the tool"):
193
+ assert pc.correct("Chris chat is the tool") == "Crisp chat is the tool"
194
+
195
+
196
+ # ── before/after pair persistence ────────────────────────────────────────
197
+
198
+
199
+ def _read_pairs(path):
200
+ import json
201
+ return [json.loads(line) for line in path.read_text().splitlines()]
202
+
203
+
204
+ def test_applied_correction_recorded(tmp_path, monkeypatch):
205
+ import voiceio.postcorrect as pcmod
206
+ from voiceio import config as cfgmod
207
+ pairs = tmp_path / "pairs.jsonl"
208
+ monkeypatch.setattr(cfgmod, "POSTCORRECT_PAIRS_PATH", pairs)
209
+ pc = PostCorrector(_cfg())
210
+ with patch("voiceio.llm_api.chat", return_value="Crisp chat is the tool"):
211
+ pc.correct("Chris chat is the tool")
212
+ entries = _read_pairs(pairs)
213
+ assert len(entries) == 1
214
+ assert entries[0]["outcome"] == "applied"
215
+ assert entries[0]["before"] == "Chris chat is the tool"
216
+ assert entries[0]["after"] == "Crisp chat is the tool"
217
+ assert entries[0]["secs"] is not None
218
+
219
+
220
+ def test_rejected_rewrite_recorded(tmp_path, monkeypatch):
221
+ from voiceio import config as cfgmod
222
+ pairs = tmp_path / "pairs.jsonl"
223
+ monkeypatch.setattr(cfgmod, "POSTCORRECT_PAIRS_PATH", pairs)
224
+ pc = PostCorrector(_cfg())
225
+ with patch("voiceio.llm_api.chat", return_value="Completely different rewritten sentence with many other words entirely"):
226
+ out = pc.correct("Chris chat is the tool")
227
+ assert out == "Chris chat is the tool"
228
+ entries = _read_pairs(pairs)
229
+ assert len(entries) == 1
230
+ assert entries[0]["outcome"].startswith("rejected")
231
+
232
+
233
+ def test_no_recording_when_disabled(tmp_path, monkeypatch):
234
+ from voiceio import config as cfgmod
235
+ pairs = tmp_path / "pairs.jsonl"
236
+ monkeypatch.setattr(cfgmod, "POSTCORRECT_PAIRS_PATH", pairs)
237
+ cfg = _cfg()
238
+ cfg.data.capture_intermediates = False
239
+ pc = PostCorrector(cfg)
240
+ with patch("voiceio.llm_api.chat", return_value="Crisp chat is the tool"):
241
+ pc.correct("Chris chat is the tool")
242
+ assert not pairs.exists()
243
+
244
+
245
+ def test_hung_request_blocks_next_call_not_thread_pileup(tmp_path, monkeypatch):
246
+ """A worker abandoned at the deadline makes the NEXT call skip the LLM."""
247
+ import threading as _threading
248
+ import time as _time
249
+ from voiceio import config as cfgmod
250
+ monkeypatch.setattr(cfgmod, "POSTCORRECT_PAIRS_PATH", tmp_path / "pairs.jsonl")
251
+ cfg = _cfg()
252
+ cfg.postcorrect.timeout_secs = 0.1
253
+ pc = PostCorrector(cfg)
254
+ release = _threading.Event()
255
+
256
+ def hung_chat(*a, **kw):
257
+ release.wait(5)
258
+ return "x"
259
+
260
+ with patch("voiceio.llm_api.chat", side_effect=hung_chat) as mock_chat:
261
+ assert pc.correct("Chris chat is the tool") == "Chris chat is the tool"
262
+ assert mock_chat.call_count == 1
263
+ # Second call while the first worker is still hung: no new thread
264
+ assert pc.correct("Chris chat is the tool") == "Chris chat is the tool"
265
+ assert mock_chat.call_count == 1
266
+ release.set()
267
+ entries = _read_pairs(tmp_path / "pairs.jsonl")
268
+ assert [e["outcome"] for e in entries] == ["timeout", "skipped_busy"]
269
+
270
+
271
+ def test_recorded_context_is_effective_context(tmp_path, monkeypatch):
272
+ from voiceio import config as cfgmod
273
+ monkeypatch.setattr(cfgmod, "POSTCORRECT_PAIRS_PATH", tmp_path / "pairs.jsonl")
274
+ pc = PostCorrector(_cfg())
275
+ pc.set_context(title="Stale Window")
276
+ with patch("voiceio.llm_api.chat", return_value="Crisp chat is the tool"):
277
+ pc.correct("Chris chat is the tool", context="Fresh Window")
278
+ entries = _read_pairs(tmp_path / "pairs.jsonl")
279
+ assert entries[0]["context"] == "Fresh Window"
@@ -97,3 +97,72 @@ class TestHistoryExtra:
97
97
  entry = json.loads(p.read_text())
98
98
  assert entry["text"] == "hello"
99
99
  assert entry["ts"] != 0
100
+
101
+
102
+ # ── intermediate data capture + disk floor ──────────────────────────────
103
+
104
+
105
+ class TestAppendJsonl:
106
+ def test_appends_lines(self, tmp_path):
107
+ from voiceio import retention
108
+ p = tmp_path / "pairs.jsonl"
109
+ retention.append_jsonl(p, {"a": 1})
110
+ retention.append_jsonl(p, {"b": 2})
111
+ import json
112
+ lines = [json.loads(line) for line in p.read_text().splitlines()]
113
+ assert lines == [{"a": 1}, {"b": 2}]
114
+
115
+ def test_never_raises_on_bad_entry(self, tmp_path):
116
+ from voiceio import retention
117
+ retention.append_jsonl(tmp_path / "x.jsonl", {"bad": object()}) # not JSON-able
118
+
119
+
120
+ class TestSaveTrace:
121
+ def test_writes_when_enabled(self, tmp_path, monkeypatch):
122
+ from voiceio import retention
123
+ from voiceio.config import DataConfig
124
+ monkeypatch.setattr(retention, "TRACES_PATH", tmp_path / "trace.jsonl")
125
+ retention.save_trace(DataConfig(), {"ts": 1, "passes": []})
126
+ assert (tmp_path / "trace.jsonl").exists()
127
+
128
+ def test_skips_when_disabled(self, tmp_path, monkeypatch):
129
+ from voiceio import retention
130
+ from voiceio.config import DataConfig
131
+ monkeypatch.setattr(retention, "TRACES_PATH", tmp_path / "trace.jsonl")
132
+ retention.save_trace(DataConfig(capture_intermediates=False), {"ts": 1})
133
+ assert not (tmp_path / "trace.jsonl").exists()
134
+
135
+
136
+ class TestDiskFloor:
137
+ def test_save_audio_skips_below_floor(self, monkeypatch):
138
+ import numpy as np
139
+ from voiceio import retention
140
+ from voiceio.config import DataConfig
141
+ monkeypatch.setattr(retention, "_free_gb", lambda p: 1.0)
142
+ name = retention.save_audio(
143
+ np.zeros(1600, dtype=np.float32), 0.0, DataConfig(min_free_gb=5.0),
144
+ )
145
+ assert name is None
146
+
147
+ def test_save_audio_ok_above_floor(self, tmp_path, monkeypatch):
148
+ import numpy as np
149
+ from voiceio import retention
150
+ from voiceio.config import DataConfig
151
+ monkeypatch.setattr(retention, "RECORDINGS_DIR", tmp_path)
152
+ monkeypatch.setattr(retention, "_free_gb", lambda p: 100.0)
153
+ name = retention.save_audio(
154
+ np.zeros(1600, dtype=np.float32), 0.0, DataConfig(min_free_gb=5.0),
155
+ )
156
+ assert name is not None and (tmp_path / name).exists()
157
+
158
+ def test_prune_shrinks_budget_below_floor(self, tmp_path, monkeypatch):
159
+ from voiceio import retention
160
+ from voiceio.config import DataConfig
161
+ monkeypatch.setattr(retention, "RECORDINGS_DIR", tmp_path)
162
+ monkeypatch.setattr(retention, "_free_gb", lambda p: 1.0)
163
+ for i in range(4):
164
+ (tmp_path / f"r{i}.wav").write_bytes(b"x" * 1000)
165
+ retention.prune(DataConfig(max_audio_mb=1024, min_free_gb=5.0))
166
+ # Budget halved from 4000 bytes total → keep <= 2000 bytes
167
+ remaining = sum(p.stat().st_size for p in tmp_path.glob("*.wav"))
168
+ assert remaining <= 2000
@@ -5,7 +5,7 @@ import json
5
5
  import threading
6
6
  import time
7
7
  from pathlib import Path
8
- from unittest.mock import MagicMock
8
+ from unittest.mock import MagicMock, patch
9
9
 
10
10
  from voiceio.typers.base import TyperBackend
11
11
 
@@ -566,7 +566,7 @@ class TestWorkerLoop:
566
566
  call_count = 0
567
567
  transcriber = MagicMock()
568
568
 
569
- def slow_transcribe(audio, final=False):
569
+ def slow_transcribe(audio, final=False, context=None):
570
570
  nonlocal call_count
571
571
  call_count += 1
572
572
  time.sleep(0.2)
@@ -649,3 +649,221 @@ class TestOnInterim:
649
649
  s, _ = self._make_session(None)
650
650
  s._apply_correction("Hello world")
651
651
  assert s.interim_text == "Hello world"
652
+
653
+
654
+ # --- incremental finalization (segment freezing) ---
655
+
656
+ SR = 16000
657
+
658
+
659
+ def _silent(secs):
660
+ return np.zeros(int(SR * secs), dtype=np.float32)
661
+
662
+
663
+ def _loud(secs):
664
+ return np.full(int(SR * secs), 0.5, dtype=np.float32)
665
+
666
+
667
+ class TestSegmentFreeze:
668
+ def _make_session(self, freeze_secs=2.0):
669
+ recorder = MagicMock()
670
+ recorder.sample_rate = SR
671
+ transcriber = MagicMock()
672
+ transcriber.last_segments = []
673
+ session = StreamingSession(
674
+ transcriber=transcriber,
675
+ typer=MagicMock(spec=TyperBackend),
676
+ recorder=recorder,
677
+ freeze_secs=freeze_secs,
678
+ )
679
+ return session, transcriber, recorder
680
+
681
+ def test_freeze_triggers_on_long_silent_tail(self):
682
+ s, tr, rec = self._make_session(freeze_secs=2.0)
683
+ rec.get_audio_so_far.return_value = _silent(3)
684
+ tr.transcribe.return_value = "hello there friend"
685
+ s._transcribe_and_apply()
686
+ assert s._frozen_raw == "hello there friend"
687
+ assert s._frozen_samples == SR * 3
688
+ # Freeze pass gets beam search (final=True)
689
+ assert tr.transcribe.call_args.kwargs["final"] is True
690
+
691
+ def test_no_freeze_when_tail_is_speech(self):
692
+ s, tr, rec = self._make_session(freeze_secs=2.0)
693
+ rec.get_audio_so_far.return_value = _loud(3)
694
+ tr.transcribe.return_value = "hello there friend"
695
+ s._transcribe_and_apply()
696
+ assert s._frozen_raw == ""
697
+ assert s._frozen_samples == 0
698
+ assert tr.transcribe.call_args.kwargs["final"] is False
699
+
700
+ def test_no_freeze_when_disabled(self):
701
+ s, tr, rec = self._make_session(freeze_secs=0)
702
+ rec.get_audio_so_far.return_value = _silent(30)
703
+ tr.transcribe.return_value = "hello there friend"
704
+ s._transcribe_and_apply()
705
+ assert s._frozen_raw == ""
706
+
707
+ def test_interim_after_freeze_decodes_only_tail(self):
708
+ s, tr, rec = self._make_session(freeze_secs=2.0)
709
+ s._frozen_raw = "hello there friend"
710
+ s._frozen_samples = SR * 3
711
+ rec.get_audio_so_far.return_value = np.concatenate([_silent(3), _loud(1.5)])
712
+ tr.transcribe.return_value = "how are"
713
+ s._transcribe_and_apply()
714
+ sent_audio = tr.transcribe.call_args.args[0]
715
+ assert len(sent_audio) == SR * 1.5 # only the un-frozen tail
716
+ assert "hello there friend" in tr.transcribe.call_args.kwargs["context"]
717
+ assert s._typed_text == "hello there friend how are"
718
+
719
+ def test_final_decodes_only_tail_and_merges(self):
720
+ s, tr, rec = self._make_session(freeze_secs=2.0)
721
+ s._frozen_raw = "hello there friend"
722
+ s._frozen_samples = SR * 3
723
+ s._final_audio = np.concatenate([_silent(3), _loud(2)])
724
+ tr.transcribe.return_value = "how are you"
725
+ s._transcribe_and_apply(min_seconds=0.5, final=True)
726
+ sent_audio = tr.transcribe.call_args.args[0]
727
+ assert len(sent_audio) == SR * 2
728
+ assert s.raw_final_text == "hello there friend how are you"
729
+ assert s.final_latency["frozen_secs"] == 3.0
730
+ assert s.final_latency["tail_secs"] == 2.0
731
+ assert s._typed_text == "hello there friend how are you"
732
+
733
+ def test_final_with_silent_tail_commits_frozen_text(self):
734
+ s, tr, rec = self._make_session(freeze_secs=2.0)
735
+ s._frozen_raw = "hello there friend"
736
+ s._frozen_samples = SR * 3
737
+ # Tiny trailing tail is still decoded (a clipped last word must not
738
+ # be dropped); here it is genuinely silent, so frozen text commits.
739
+ s._final_audio = np.concatenate([_silent(3), _silent(0.1)])
740
+ tr.transcribe.return_value = ""
741
+ s._transcribe_and_apply(min_seconds=0.5, final=True)
742
+ tr.transcribe.assert_called_once()
743
+ assert s.raw_final_text == "hello there friend"
744
+ assert s._typed_text == "hello there friend"
745
+
746
+ def test_frozen_segments_accumulate_into_final(self):
747
+ s, tr, rec = self._make_session(freeze_secs=2.0)
748
+ rec.get_audio_so_far.return_value = _silent(3)
749
+ tr.transcribe.return_value = "hello there friend"
750
+ tr.last_segments = [{"text": "hello there friend", "avg_logprob": -0.2}]
751
+ s._transcribe_and_apply() # freeze pass
752
+ s._final_audio = np.concatenate([_silent(3), _loud(2)])
753
+ tr.transcribe.return_value = "how are you"
754
+ tr.last_segments = [{"text": "how are you", "avg_logprob": -0.3}]
755
+ s._transcribe_and_apply(min_seconds=0.5, final=True)
756
+ assert [seg["text"] for seg in s.final_segments] == [
757
+ "hello there friend", "how are you",
758
+ ]
759
+
760
+ def test_abort_discards_frozen_text(self):
761
+ s, tr, rec = self._make_session(freeze_secs=2.0)
762
+ s._frozen_raw = "hello there friend"
763
+ s._frozen_samples = SR * 3
764
+ s._typed_text = "hello there friend"
765
+ rec.get_audio_so_far.return_value = np.concatenate([_silent(3), _loud(1.5)])
766
+ tr.transcribe.return_value = "scratch that"
767
+ with patch("voiceio.postprocess.apply_pipeline", return_value=("", True)):
768
+ s._transcribe_and_apply()
769
+ assert s._frozen_raw == ""
770
+ assert s._typed_text == ""
771
+ # Frozen boundary advances so the wiped audio is never re-decoded
772
+ assert s._frozen_samples == SR * 4.5
773
+
774
+
775
+ class TestDecodeTrace:
776
+ def test_trace_records_pass_kinds(self):
777
+ recorder = MagicMock()
778
+ recorder.sample_rate = SR
779
+ transcriber = MagicMock()
780
+ transcriber.last_segments = []
781
+ s = StreamingSession(
782
+ transcriber=transcriber,
783
+ typer=MagicMock(spec=TyperBackend),
784
+ recorder=recorder,
785
+ freeze_secs=2.0,
786
+ )
787
+ transcriber.transcribe.return_value = "hello there friend"
788
+ rec_audio = _silent(3)
789
+ recorder.get_audio_so_far.return_value = rec_audio
790
+ s._transcribe_and_apply() # freeze pass
791
+ s._final_audio = np.concatenate([_silent(3), _loud(1)])
792
+ transcriber.transcribe.return_value = "bye"
793
+ s._transcribe_and_apply(min_seconds=0.5, final=True)
794
+ kinds = [p["kind"] for p in s.trace]
795
+ assert kinds == ["freeze", "final"]
796
+ assert s.trace[0]["text"] == "hello there friend"
797
+ assert s.trace[1]["tail_secs"] == 1.0
798
+ assert all("secs" in p for p in s.trace)
799
+
800
+
801
+ # --- code-review regressions: decode failures must never lose audio/text ---
802
+
803
+ class TestDecodeFailureSafety:
804
+ def _make_session(self, freeze_secs=2.0, typer_spec=TyperBackend):
805
+ recorder = MagicMock()
806
+ recorder.sample_rate = SR
807
+ transcriber = MagicMock()
808
+ transcriber.last_segments = []
809
+ s = StreamingSession(
810
+ transcriber=transcriber,
811
+ typer=MagicMock(spec=typer_spec),
812
+ recorder=recorder,
813
+ freeze_secs=freeze_secs,
814
+ )
815
+ return s, transcriber, recorder
816
+
817
+ def test_failed_freeze_decode_does_not_advance_boundary(self):
818
+ s, tr, rec = self._make_session()
819
+ rec.get_audio_so_far.return_value = _silent(3)
820
+ tr.transcribe.side_effect = RuntimeError("decode timed out")
821
+ s._transcribe_and_apply()
822
+ assert s._frozen_samples == 0 # audio NOT discarded
823
+ assert s._frozen_raw == ""
824
+
825
+ def test_failed_final_decode_commits_interim_text(self):
826
+ s, tr, rec = self._make_session(typer_spec=StreamingTyper)
827
+ s._frozen_raw = "hello there friend"
828
+ s._frozen_samples = SR * 3
829
+ s._typed_text = "hello there friend how are you" # shown via preedit
830
+ s._final_audio = np.concatenate([_silent(3), _loud(2)])
831
+ tr.transcribe.side_effect = RuntimeError("decode timed out")
832
+ s._transcribe_and_apply(min_seconds=0.5, final=True)
833
+ # Interim text committed, not char-diffed down to frozen-only text
834
+ s._typer.commit_text.assert_called_once_with("hello there friend how are you")
835
+ assert s.final_latency.get("decode_failed") is True
836
+
837
+ def test_final_decodes_sub_300ms_tail(self):
838
+ s, tr, rec = self._make_session()
839
+ s._frozen_raw = "hello there friend"
840
+ s._frozen_samples = SR * 3
841
+ s._final_audio = np.concatenate([_silent(3), _loud(0.25)])
842
+ tr.transcribe.return_value = "yes"
843
+ s._transcribe_and_apply(min_seconds=0.5, final=True)
844
+ tr.transcribe.assert_called_once() # short last word not dropped
845
+ assert s.raw_final_text == "hello there friend yes"
846
+
847
+ def test_empty_interim_decode_keeps_display(self):
848
+ s, tr, rec = self._make_session(freeze_secs=0)
849
+ s._frozen_raw = "hello there friend"
850
+ s._frozen_samples = SR * 3
851
+ s._typed_text = "hello there friend how are"
852
+ rec.get_audio_so_far.return_value = np.concatenate([_silent(3), _loud(1.5)])
853
+ tr.transcribe.return_value = "" # transient decoder flake
854
+ s._transcribe_and_apply()
855
+ s._typer.type_text.assert_not_called()
856
+ s._typer.delete_chars.assert_not_called()
857
+ assert s._typed_text == "hello there friend how are"
858
+
859
+ def test_quiet_mic_speech_is_not_silence(self):
860
+ from voiceio.streaming import _tail_ends_in_silence
861
+ # Continuous speech on a low-gain mic: quiet in absolute terms but
862
+ # uniform — trailing window matches overall level, so NOT a pause.
863
+ quiet_speech = np.full(SR * 3, 0.005, dtype=np.float32)
864
+ assert not _tail_ends_in_silence(quiet_speech, SR)
865
+ # Same quiet mic with a real trailing pause IS a pause.
866
+ with_pause = np.concatenate([
867
+ np.full(int(SR * 2.5), 0.005, dtype=np.float32), _silent(0.5),
868
+ ])
869
+ assert _tail_ends_in_silence(with_pause, SR)
@@ -130,3 +130,39 @@ class TestRestartBudgetReset:
130
130
 
131
131
  with pytest.raises(RuntimeError, match="crashed"):
132
132
  t._ensure_worker()
133
+
134
+
135
+ class TestTimeoutRaises:
136
+ """Timeout/invalid response raise TranscriptionError (never fake silence)."""
137
+
138
+ def _make(self):
139
+ from voiceio.transcriber import Transcriber
140
+ mock_proc = MagicMock()
141
+ mock_proc.stdout.readline.return_value = "READY\n"
142
+ mock_proc.poll.return_value = None
143
+ with patch("voiceio.transcriber.subprocess.Popen", return_value=mock_proc):
144
+ t = Transcriber(ModelConfig())
145
+ return t, mock_proc
146
+
147
+ def test_timeout_raises_and_clears_segments(self):
148
+ import numpy as np
149
+ import pytest
150
+ from voiceio.transcriber import TranscriptionError
151
+ t, mock_proc = self._make()
152
+ t.last_segments = [{"text": "stale"}]
153
+ with patch.object(t, "_read_with_timeout", return_value=None), \
154
+ patch.object(t, "_kill_worker"), patch.object(t, "_ensure_worker"):
155
+ with pytest.raises(TranscriptionError):
156
+ t.transcribe(np.zeros(16000, dtype=np.float32))
157
+ assert t.last_segments == []
158
+
159
+ def test_invalid_response_raises_and_clears_segments(self):
160
+ import numpy as np
161
+ import pytest
162
+ from voiceio.transcriber import TranscriptionError
163
+ t, mock_proc = self._make()
164
+ t.last_segments = [{"text": "stale"}]
165
+ with patch.object(t, "_read_with_timeout", return_value="not json{"):
166
+ with pytest.raises(TranscriptionError):
167
+ t.transcribe(np.zeros(16000, dtype=np.float32))
168
+ assert t.last_segments == []
@@ -0,0 +1 @@
1
+ __version__ = "0.6.0"
@@ -393,6 +393,7 @@ class VoiceIO:
393
393
  voice_input_prefix=self._voice_input_prefix,
394
394
  on_typer_broken=self._on_typer_broken,
395
395
  on_interim=self._on_interim_text,
396
+ freeze_secs=self.cfg.output.streaming_freeze_secs,
396
397
  )
397
398
  self._session.start()
398
399
  log.info("Recording... press [%s] again to stop", self.cfg.hotkey.key)
@@ -545,6 +546,16 @@ class VoiceIO:
545
546
  duration=elapsed,
546
547
  extra=extra,
547
548
  )
549
+ from voiceio import retention
550
+ retention.save_trace(self.cfg.data, {
551
+ "ts": time.time(),
552
+ "audio": extra.get("audio"),
553
+ "duration": round(elapsed, 2),
554
+ "latency": latency,
555
+ # Snapshot: a timed-out worker join means the thread may
556
+ # still be appending while we serialize.
557
+ "passes": list(session.trace),
558
+ })
548
559
  log.info("Streaming done (%.1fs): '%s'", elapsed, final_text)
549
560
  # Release the IBus input source now that the final commit is done.
550
561
  # Generation-checked inside: if a newer recording started, it already
@@ -33,6 +33,11 @@ PID_PATH = LOG_DIR / "voiceio.pid"
33
33
  # and the retired-rule state consulted by the mining side.
34
34
  CORRECTIONS_AUDIT_PATH = LOG_DIR / "corrections_audit.jsonl"
35
35
  METRICS_PATH = LOG_DIR / "metrics.jsonl"
36
+ # Intermediate pipeline data kept for debugging/profiling and future training:
37
+ # per-pass streaming decode traces and postcorrect before/after pairs. These
38
+ # live in their own JSONL files (not the rotating log) so they survive.
39
+ TRACES_PATH = LOG_DIR / "streaming_trace.jsonl"
40
+ POSTCORRECT_PAIRS_PATH = LOG_DIR / "postcorrect_pairs.jsonl"
36
41
  SNAPSHOTS_DIR = CONFIG_DIR / "snapshots"
37
42
  AUDIT_STATE_PATH = CONFIG_DIR / "audit_state.json"
38
43
  AUTOCORRECT_STATE_PATH = CONFIG_DIR / "autocorrect_state.json"
@@ -79,6 +84,11 @@ class OutputConfig:
79
84
  punctuation_cleanup: bool = True
80
85
  number_conversion: bool = True
81
86
  voice_input_prefix: str = "" # e.g. "[voice]" — empty disables
87
+ # Incremental finalization: once the un-finalized audio tail grows past
88
+ # this many seconds AND ends in silence, beam-decode and freeze it during
89
+ # recording, so interim passes and the stop-time final decode only cover
90
+ # the short remaining tail. 0 disables (full re-decode at stop).
91
+ streaming_freeze_secs: float = 25.0
82
92
  # Mirror transcribed text to the system clipboard so it can be pasted:
83
93
  # "off" — never
84
94
  # "final" — the corrected final text, once ready
@@ -179,7 +189,14 @@ class DataConfig:
179
189
  """
180
190
  retain_audio: bool = True
181
191
  max_audio_mb: int = 4096 # prune oldest recordings beyond this
192
+ # Never let retention squeeze the disk: skip saving and prune harder
193
+ # when the filesystem has less than this much free space.
194
+ min_free_gb: float = 5.0
182
195
  capture_context: bool = True # best-effort active-window title
196
+ # Per-pass streaming decode traces + postcorrect before/after pairs
197
+ # (streaming_trace.jsonl / postcorrect_pairs.jsonl) for debugging,
198
+ # profiling, and future fine-tuning.
199
+ capture_intermediates: bool = True
183
200
 
184
201
 
185
202
  @dataclass
@@ -283,6 +300,8 @@ def _content_files() -> list[Path]:
283
300
  HISTORY_PATH,
284
301
  CORRECTIONS_AUDIT_PATH,
285
302
  METRICS_PATH,
303
+ TRACES_PATH,
304
+ POSTCORRECT_PAIRS_PATH,
286
305
  AUDIT_STATE_PATH,
287
306
  AUTOCORRECT_STATE_PATH,
288
307
  CONSENT_PATH,