python-voiceio 0.4.1__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.4.1/python_voiceio.egg-info → python_voiceio-0.6.0}/PKG-INFO +1 -1
  2. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/pyproject.toml +1 -1
  3. {python_voiceio-0.4.1 → python_voiceio-0.6.0/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_app_wiring.py +52 -1
  5. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_clipboard_read.py +54 -0
  6. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_config.py +13 -0
  7. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_postcorrect.py +116 -0
  8. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_retention.py +69 -0
  9. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_robustness.py +53 -0
  10. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_streaming.py +274 -2
  11. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_transcriber.py +44 -3
  12. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_wizard.py +3 -1
  13. python_voiceio-0.6.0/voiceio/__init__.py +1 -0
  14. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/app.py +74 -1
  15. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/clipboard_read.py +47 -1
  16. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/config.py +25 -0
  17. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/postcorrect.py +75 -6
  18. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/recorder.py +11 -0
  19. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/retention.py +65 -1
  20. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/streaming.py +153 -12
  21. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/transcriber.py +22 -6
  22. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/wizard.py +1 -1
  23. python_voiceio-0.4.1/voiceio/__init__.py +0 -1
  24. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/LICENSE +0 -0
  25. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/README.md +0 -0
  26. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/python_voiceio.egg-info/SOURCES.txt +0 -0
  27. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
  28. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/python_voiceio.egg-info/entry_points.txt +0 -0
  29. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/python_voiceio.egg-info/requires.txt +0 -0
  30. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/python_voiceio.egg-info/top_level.txt +0 -0
  31. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/setup.cfg +0 -0
  32. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_adjudicate.py +0 -0
  33. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_audio_quality.py +0 -0
  34. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_audit.py +0 -0
  35. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_autocorrect.py +0 -0
  36. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_autocorrect_state.py +0 -0
  37. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_backend_probes.py +0 -0
  38. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_cli.py +0 -0
  39. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_commands.py +0 -0
  40. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_concurrency_lockdown.py +0 -0
  41. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_correct_batch.py +0 -0
  42. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_corrections.py +0 -0
  43. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_fallback.py +0 -0
  44. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_health.py +0 -0
  45. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_hints.py +0 -0
  46. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_history.py +0 -0
  47. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_ibus_pending.py +0 -0
  48. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_ibus_typer.py +0 -0
  49. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_llm.py +0 -0
  50. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_llm_api.py +0 -0
  51. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_numbers.py +0 -0
  52. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_platform.py +0 -0
  53. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_postprocess.py +0 -0
  54. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_prebuffer.py +0 -0
  55. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_prompt.py +0 -0
  56. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_recorder_integration.py +0 -0
  57. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_security_hardening.py +0 -0
  58. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_snapshots.py +0 -0
  59. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_tts.py +0 -0
  60. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_vad.py +0 -0
  61. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_vocabulary.py +0 -0
  62. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_wordfreq.py +0 -0
  63. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/__main__.py +0 -0
  64. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/audit.py +0 -0
  65. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/autocorrect.py +0 -0
  66. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/autocorrect_state.py +0 -0
  67. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/backends.py +0 -0
  68. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/cli.py +0 -0
  69. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/commands.py +0 -0
  70. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/consent.py +0 -0
  71. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/corrections.py +0 -0
  72. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/demo.py +0 -0
  73. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/feedback.py +0 -0
  74. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/health.py +0 -0
  75. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/hints.py +0 -0
  76. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/history.py +0 -0
  77. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/hotkeys/__init__.py +0 -0
  78. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/hotkeys/base.py +0 -0
  79. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/hotkeys/chain.py +0 -0
  80. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/hotkeys/evdev.py +0 -0
  81. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/hotkeys/pynput_backend.py +0 -0
  82. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/hotkeys/socket_backend.py +0 -0
  83. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/ibus/__init__.py +0 -0
  84. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/ibus/engine.py +0 -0
  85. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/ibus/pending.py +0 -0
  86. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/llm.py +0 -0
  87. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/llm_api.py +0 -0
  88. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/models/__init__.py +0 -0
  89. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/models/silero_vad.onnx +0 -0
  90. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/numbers.py +0 -0
  91. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/pidlock.py +0 -0
  92. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/platform.py +0 -0
  93. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/postprocess.py +0 -0
  94. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/prompt.py +0 -0
  95. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/service.py +0 -0
  96. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/snapshots.py +0 -0
  97. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/sounds/__init__.py +0 -0
  98. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/sounds/commit.wav +0 -0
  99. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/sounds/start.wav +0 -0
  100. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/sounds/stop.wav +0 -0
  101. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/tray/__init__.py +0 -0
  102. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/tray/_icons.py +0 -0
  103. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/tray/_indicator.py +0 -0
  104. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/tray/_pystray.py +0 -0
  105. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/tts/__init__.py +0 -0
  106. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/tts/base.py +0 -0
  107. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/tts/chain.py +0 -0
  108. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/tts/edge_engine.py +0 -0
  109. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/tts/espeak.py +0 -0
  110. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/tts/piper_engine.py +0 -0
  111. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/tts/player.py +0 -0
  112. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/typers/__init__.py +0 -0
  113. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/typers/base.py +0 -0
  114. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/typers/chain.py +0 -0
  115. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/typers/clipboard.py +0 -0
  116. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/typers/ibus.py +0 -0
  117. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/typers/pynput_type.py +0 -0
  118. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/typers/wtype.py +0 -0
  119. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/typers/xdotool.py +0 -0
  120. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/typers/ydotool.py +0 -0
  121. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/vad.py +0 -0
  122. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/vocabulary.py +0 -0
  123. {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/wordfreq.py +0 -0
  124. {python_voiceio-0.4.1 → 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.4.1
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.4.1"
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.4.1
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
@@ -27,7 +27,10 @@ def _make_vio(mock_transcriber=None):
27
27
  mock_detect.return_value = MagicMock(display_server="wayland", desktop="gnome")
28
28
 
29
29
  from voiceio.app import VoiceIO
30
- vio = VoiceIO(Config())
30
+ cfg = Config()
31
+ # Tests must never touch the real system clipboard
32
+ cfg.output.copy_to_clipboard = "off"
33
+ vio = VoiceIO(cfg)
31
34
  mock_stream = MagicMock()
32
35
  mock_stream.active = True
33
36
  mock_stream.closed = False
@@ -209,3 +212,51 @@ class TestStateMachine:
209
212
  vio._request_stop()
210
213
  assert vio._state == _State.IDLE
211
214
  assert not vio.recorder.is_recording
215
+
216
+
217
+ class TestClipboardMirror:
218
+ """copy_to_clipboard: interim/final text mirrored to the clipboard."""
219
+
220
+ def test_interim_copy_skipped_for_clipboard_typer(self):
221
+ vio, mock_typer, _ = _make_vio()
222
+ mock_typer.name = "clipboard"
223
+ vio.cfg.output.copy_to_clipboard = "live"
224
+ with patch("voiceio.clipboard_read.copy_text") as mock_copy:
225
+ vio._on_interim_text("hello")
226
+ mock_copy.assert_not_called()
227
+
228
+ def test_interim_copy_fires_for_other_typers(self):
229
+ vio, mock_typer, _ = _make_vio()
230
+ mock_typer.name = "ibus"
231
+ vio.cfg.output.copy_to_clipboard = "live"
232
+ with patch("voiceio.clipboard_read.copy_text") as mock_copy:
233
+ vio._on_interim_text("hello")
234
+ mock_copy.assert_called_once_with("hello")
235
+
236
+ def test_interim_copy_off_unless_live(self):
237
+ vio, mock_typer, _ = _make_vio()
238
+ mock_typer.name = "ibus"
239
+ vio.cfg.output.copy_to_clipboard = "final"
240
+ with patch("voiceio.clipboard_read.copy_text") as mock_copy:
241
+ vio._on_interim_text("hello")
242
+ mock_copy.assert_not_called()
243
+
244
+ def test_final_copy_respects_off(self):
245
+ vio, mock_typer, _ = _make_vio()
246
+ mock_typer.name = "ibus"
247
+ vio.cfg.output.copy_to_clipboard = "off"
248
+ with patch("voiceio.clipboard_read.copy_text") as mock_copy:
249
+ vio._copy_result_async("hello")
250
+ time.sleep(0.1)
251
+ mock_copy.assert_not_called()
252
+
253
+ def test_final_copy_runs_async(self):
254
+ vio, mock_typer, _ = _make_vio()
255
+ mock_typer.name = "ibus"
256
+ vio.cfg.output.copy_to_clipboard = "final"
257
+ done = threading.Event()
258
+ with patch("voiceio.clipboard_read.copy_text",
259
+ side_effect=lambda t: done.set()) as mock_copy:
260
+ vio._copy_result_async("hello world")
261
+ assert done.wait(timeout=2)
262
+ mock_copy.assert_called_once_with("hello world")
@@ -89,3 +89,57 @@ def test_read_text_no_tools():
89
89
  from voiceio.clipboard_read import read_text
90
90
  result = read_text()
91
91
  assert result is None
92
+
93
+
94
+ # --- copy_text ---
95
+
96
+ def test_copy_text_wayland():
97
+ """Wayland copies via wl-copy with text on stdin."""
98
+ with patch("voiceio.clipboard_read.detect", return_value=_mock_platform(display_server="wayland")), \
99
+ patch("shutil.which", return_value="/usr/bin/wl-copy"), \
100
+ patch("subprocess.run") as mock_run:
101
+ mock_run.return_value = MagicMock(returncode=0)
102
+ from voiceio.clipboard_read import copy_text
103
+ assert copy_text("hello") is True
104
+ args = mock_run.call_args[0][0]
105
+ assert "wl-copy" in args
106
+ assert mock_run.call_args.kwargs["input"] == b"hello"
107
+
108
+
109
+ def test_copy_text_x11_xclip():
110
+ """X11 copies to the CLIPBOARD selection via xclip."""
111
+ with patch("voiceio.clipboard_read.detect", return_value=_mock_platform(display_server="x11")), \
112
+ patch("shutil.which", lambda t: "/usr/bin/xclip" if t == "xclip" else None), \
113
+ patch("subprocess.run") as mock_run:
114
+ mock_run.return_value = MagicMock(returncode=0)
115
+ from voiceio.clipboard_read import copy_text
116
+ assert copy_text("hello") is True
117
+ args = mock_run.call_args[0][0]
118
+ assert args[:1] == ["xclip"]
119
+ assert "clipboard" in args
120
+
121
+
122
+ def test_copy_text_no_tools():
123
+ """Returns False (no raise) when no clipboard tools available."""
124
+ with patch("voiceio.clipboard_read.detect", return_value=_mock_platform(display_server="x11")), \
125
+ patch("shutil.which", return_value=None):
126
+ from voiceio.clipboard_read import copy_text
127
+ assert copy_text("hello") is False
128
+
129
+
130
+ def test_copy_text_empty():
131
+ """Empty text is never copied."""
132
+ with patch("subprocess.run") as mock_run:
133
+ from voiceio.clipboard_read import copy_text
134
+ assert copy_text("") is False
135
+ mock_run.assert_not_called()
136
+
137
+
138
+ def test_copy_text_command_failure():
139
+ """Nonzero exit from the copy tool reports False."""
140
+ with patch("voiceio.clipboard_read.detect", return_value=_mock_platform(display_server="wayland")), \
141
+ patch("shutil.which", return_value="/usr/bin/wl-copy"), \
142
+ patch("subprocess.run") as mock_run:
143
+ mock_run.return_value = MagicMock(returncode=1)
144
+ from voiceio.clipboard_read import copy_text
145
+ assert copy_text("hello") is False
@@ -61,3 +61,16 @@ def test_migrate_x11_to_pynput():
61
61
  raw = {"hotkey": {"backend": "x11"}}
62
62
  result = _migrate_v1(raw)
63
63
  assert result["hotkey"]["backend"] == "pynput"
64
+
65
+
66
+ def test_copy_to_clipboard_default_and_override(tmp_path):
67
+ cfg = load(path=tmp_path / "nonexistent.toml")
68
+ assert cfg.output.copy_to_clipboard == "final"
69
+
70
+ config_file = tmp_path / "config.toml"
71
+ config_file.write_text(textwrap.dedent("""\
72
+ [output]
73
+ copy_to_clipboard = "live"
74
+ """))
75
+ cfg = load(path=config_file)
76
+ assert cfg.output.copy_to_clipboard == "live"
@@ -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
@@ -722,3 +722,56 @@ class TestBootRaceAndReProbe:
722
722
  patch("voiceio.app.time.sleep"):
723
723
  vio._deferred_typer_upgrade()
724
724
  mock_upgrade.assert_called_once_with(reason="streaming-failure")
725
+
726
+
727
+ class TestZombieStreamRecovery:
728
+ """A 'healthy' stream delivering all zeros (post-suspend zombie) must be
729
+ reopened at recording start, not just logged (2026-07-06 field failure)."""
730
+
731
+ def test_zombie_stream_triggers_reopen(self):
732
+ from unittest.mock import MagicMock, patch
733
+ from voiceio import app as app_mod
734
+
735
+ app = MagicMock()
736
+ app.recorder.stream_health.return_value = (True, "")
737
+ app.recorder.is_zombie.return_value = True
738
+ app.recorder.has_signal.return_value = True # healthy after reopen
739
+ app._streaming = False
740
+ app.cfg.data.capture_context = False
741
+ with patch.object(app_mod.time, "sleep"):
742
+ app_mod.VoiceIO._do_start(app)
743
+ app.recorder.reopen_stream.assert_called_once()
744
+ app.recorder.start.assert_called_once()
745
+
746
+ def test_zombie_reopen_failure_aborts_recording(self):
747
+ from unittest.mock import MagicMock, patch
748
+ from voiceio import app as app_mod
749
+
750
+ app = MagicMock()
751
+ app.recorder.stream_health.return_value = (True, "")
752
+ app.recorder.is_zombie.return_value = True
753
+ app.recorder.reopen_stream.side_effect = OSError("no device")
754
+ with patch.object(app_mod.time, "sleep"):
755
+ app_mod.VoiceIO._do_start(app)
756
+ app.recorder.start.assert_not_called()
757
+
758
+ def test_empty_ring_is_not_zombie(self):
759
+ """A fresh/headless stream (ring not yet full) must not trigger the
760
+ reopen path — that broke CI machines with no audio device."""
761
+ from voiceio.config import AudioConfig
762
+ from voiceio.recorder import AudioRecorder
763
+
764
+ rec = AudioRecorder(AudioConfig())
765
+ assert rec.is_zombie() is False
766
+
767
+ def test_full_ring_of_zeros_is_zombie(self):
768
+ import numpy as np
769
+ from voiceio.config import AudioConfig
770
+ from voiceio.recorder import AudioRecorder
771
+
772
+ rec = AudioRecorder(AudioConfig())
773
+ rec._ring.append(np.zeros(rec._ring._max, dtype=np.float32))
774
+ assert rec.is_zombie() is True
775
+ # any real signal → not a zombie
776
+ rec._ring.append(np.full(rec._ring._max, 0.1, dtype=np.float32))
777
+ assert rec.is_zombie() is False