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.
- {python_voiceio-0.4.1/python_voiceio.egg-info → python_voiceio-0.6.0}/PKG-INFO +1 -1
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/pyproject.toml +1 -1
- {python_voiceio-0.4.1 → python_voiceio-0.6.0/python_voiceio.egg-info}/PKG-INFO +1 -1
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_app_wiring.py +52 -1
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_clipboard_read.py +54 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_config.py +13 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_postcorrect.py +116 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_retention.py +69 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_robustness.py +53 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_streaming.py +274 -2
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_transcriber.py +44 -3
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_wizard.py +3 -1
- python_voiceio-0.6.0/voiceio/__init__.py +1 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/app.py +74 -1
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/clipboard_read.py +47 -1
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/config.py +25 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/postcorrect.py +75 -6
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/recorder.py +11 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/retention.py +65 -1
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/streaming.py +153 -12
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/transcriber.py +22 -6
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/wizard.py +1 -1
- python_voiceio-0.4.1/voiceio/__init__.py +0 -1
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/LICENSE +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/README.md +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/python_voiceio.egg-info/SOURCES.txt +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/python_voiceio.egg-info/entry_points.txt +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/setup.cfg +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_adjudicate.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_audio_quality.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_audit.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_autocorrect.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_autocorrect_state.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_backend_probes.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_cli.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_commands.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_concurrency_lockdown.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_correct_batch.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_corrections.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_fallback.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_health.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_hints.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_history.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_ibus_pending.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_ibus_typer.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_llm.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_llm_api.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_numbers.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_platform.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_postprocess.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_prebuffer.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_prompt.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_security_hardening.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_snapshots.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_tts.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_vad.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_vocabulary.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/tests/test_wordfreq.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/__main__.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/audit.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/autocorrect.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/autocorrect_state.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/backends.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/cli.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/commands.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/consent.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/corrections.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/demo.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/feedback.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/health.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/hints.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/history.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/hotkeys/evdev.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/ibus/__init__.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/ibus/engine.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/ibus/pending.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/llm.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/llm_api.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/models/__init__.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/models/silero_vad.onnx +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/numbers.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/pidlock.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/platform.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/postprocess.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/prompt.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/service.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/snapshots.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/sounds/commit.wav +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/sounds/start.wav +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/tray/__init__.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/tray/_icons.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/tray/_indicator.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/tray/_pystray.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/tts/__init__.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/tts/base.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/tts/chain.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/tts/edge_engine.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/tts/espeak.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/tts/piper_engine.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/tts/player.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/typers/base.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/typers/chain.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/typers/clipboard.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/typers/ibus.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/vad.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/vocabulary.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/wordfreq.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.6.0}/voiceio/worker.py +0 -0
|
@@ -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
|
-
|
|
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
|