python-voiceio 0.4.1__tar.gz → 0.5.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.5.0}/PKG-INFO +1 -1
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/pyproject.toml +1 -1
- {python_voiceio-0.4.1 → python_voiceio-0.5.0/python_voiceio.egg-info}/PKG-INFO +1 -1
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_app_wiring.py +52 -1
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_clipboard_read.py +54 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_config.py +13 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_robustness.py +53 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_streaming.py +54 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_transcriber.py +8 -3
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_wizard.py +3 -1
- python_voiceio-0.5.0/voiceio/__init__.py +1 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/app.py +63 -1
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/clipboard_read.py +47 -1
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/config.py +6 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/recorder.py +11 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/streaming.py +13 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/wizard.py +1 -1
- python_voiceio-0.4.1/voiceio/__init__.py +0 -1
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/LICENSE +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/README.md +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/python_voiceio.egg-info/SOURCES.txt +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/python_voiceio.egg-info/entry_points.txt +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/setup.cfg +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_adjudicate.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_audio_quality.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_audit.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_autocorrect.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_autocorrect_state.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_backend_probes.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_cli.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_commands.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_concurrency_lockdown.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_correct_batch.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_corrections.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_fallback.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_health.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_hints.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_history.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_ibus_pending.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_ibus_typer.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_llm.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_llm_api.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_numbers.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_platform.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_postcorrect.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_postprocess.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_prebuffer.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_prompt.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_retention.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_security_hardening.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_snapshots.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_tts.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_vad.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_vocabulary.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_wordfreq.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/__main__.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/audit.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/autocorrect.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/autocorrect_state.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/backends.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/cli.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/commands.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/consent.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/corrections.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/demo.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/feedback.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/health.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/hints.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/history.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/hotkeys/evdev.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/ibus/__init__.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/ibus/engine.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/ibus/pending.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/llm.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/llm_api.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/models/__init__.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/models/silero_vad.onnx +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/numbers.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/pidlock.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/platform.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/postcorrect.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/postprocess.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/prompt.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/retention.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/service.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/snapshots.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/sounds/commit.wav +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/sounds/start.wav +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/transcriber.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/tray/__init__.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/tray/_icons.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/tray/_indicator.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/tray/_pystray.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/tts/__init__.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/tts/base.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/tts/chain.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/tts/edge_engine.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/tts/espeak.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/tts/piper_engine.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/tts/player.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/typers/base.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/typers/chain.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/typers/clipboard.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/typers/ibus.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/vad.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/vocabulary.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/wordfreq.py +0 -0
- {python_voiceio-0.4.1 → python_voiceio-0.5.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"
|
|
@@ -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
|
|
@@ -595,3 +595,57 @@ class TestWorkerLoop:
|
|
|
595
595
|
session._worker_thread.join(timeout=5)
|
|
596
596
|
|
|
597
597
|
assert transcriber.transcribe.call_count < 5
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
# --- on_interim callback ---
|
|
601
|
+
|
|
602
|
+
class TestOnInterim:
|
|
603
|
+
def _make_session(self, cb):
|
|
604
|
+
recorder = MagicMock()
|
|
605
|
+
recorder.sample_rate = 16000
|
|
606
|
+
transcriber = MagicMock()
|
|
607
|
+
session = StreamingSession(
|
|
608
|
+
transcriber=transcriber,
|
|
609
|
+
typer=MagicMock(spec=TyperBackend),
|
|
610
|
+
recorder=recorder,
|
|
611
|
+
on_interim=cb,
|
|
612
|
+
)
|
|
613
|
+
recorder.get_audio_so_far.return_value = np.zeros(16000 * 2, dtype=np.float32)
|
|
614
|
+
return session, transcriber
|
|
615
|
+
|
|
616
|
+
def test_fires_on_new_text(self):
|
|
617
|
+
calls = []
|
|
618
|
+
s, tr = self._make_session(calls.append)
|
|
619
|
+
tr.transcribe.return_value = "Hello world"
|
|
620
|
+
s._transcribe_and_apply()
|
|
621
|
+
assert calls == ["Hello world"]
|
|
622
|
+
|
|
623
|
+
def test_no_fire_when_text_unchanged(self):
|
|
624
|
+
calls = []
|
|
625
|
+
s, tr = self._make_session(calls.append)
|
|
626
|
+
tr.transcribe.return_value = "Hello world"
|
|
627
|
+
s._transcribe_and_apply()
|
|
628
|
+
s._transcribe_and_apply()
|
|
629
|
+
assert calls == ["Hello world"]
|
|
630
|
+
|
|
631
|
+
def test_no_fire_on_final(self):
|
|
632
|
+
calls = []
|
|
633
|
+
s, tr = self._make_session(calls.append)
|
|
634
|
+
tr.transcribe.return_value = "Hello world"
|
|
635
|
+
s._final_audio = np.zeros(16000 * 2, dtype=np.float32)
|
|
636
|
+
s._transcribe_and_apply(min_seconds=0.5, final=True)
|
|
637
|
+
assert calls == []
|
|
638
|
+
assert s.interim_text == "Hello world"
|
|
639
|
+
|
|
640
|
+
def test_callback_error_is_swallowed(self):
|
|
641
|
+
def boom(_text):
|
|
642
|
+
raise RuntimeError("clipboard exploded")
|
|
643
|
+
s, tr = self._make_session(boom)
|
|
644
|
+
tr.transcribe.return_value = "Hello world"
|
|
645
|
+
s._transcribe_and_apply() # must not raise
|
|
646
|
+
assert s.interim_text == "Hello world"
|
|
647
|
+
|
|
648
|
+
def test_interim_text_property(self):
|
|
649
|
+
s, _ = self._make_session(None)
|
|
650
|
+
s._apply_correction("Hello world")
|
|
651
|
+
assert s.interim_text == "Hello world"
|
|
@@ -104,12 +104,17 @@ class TestRestartBudgetReset:
|
|
|
104
104
|
import time as _time
|
|
105
105
|
t, mock_proc = self._make()
|
|
106
106
|
|
|
107
|
-
# Simulate having already hit the ceiling long ago.
|
|
107
|
+
# Simulate having already hit the ceiling long ago. Patch monotonic
|
|
108
|
+
# instead of subtracting from it: on a freshly-booted machine (CI),
|
|
109
|
+
# monotonic() is small and the subtraction would go negative,
|
|
110
|
+
# disabling the reset guard entirely.
|
|
108
111
|
t._restarts = MAX_RESTARTS
|
|
109
|
-
t._last_crash_time =
|
|
112
|
+
t._last_crash_time = 1.0
|
|
110
113
|
mock_proc.poll.return_value = 1 # worker died again
|
|
111
114
|
|
|
112
|
-
with patch("voiceio.transcriber.subprocess.Popen", return_value=mock_proc)
|
|
115
|
+
with patch("voiceio.transcriber.subprocess.Popen", return_value=mock_proc), \
|
|
116
|
+
patch("voiceio.transcriber.time") as mock_time:
|
|
117
|
+
mock_time.monotonic.return_value = 1.0 + RESTART_RESET_SECS + 60
|
|
113
118
|
t._ensure_worker() # must NOT raise — counter reset then incremented
|
|
114
119
|
|
|
115
120
|
assert t._restarts == 1
|
|
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
from unittest.mock import patch
|
|
5
5
|
|
|
6
|
+
import os
|
|
7
|
+
|
|
6
8
|
import pytest
|
|
7
9
|
|
|
8
10
|
import voiceio.config as config
|
|
@@ -72,8 +74,8 @@ def test_write_config_roundtrips_through_loader(cfg_paths):
|
|
|
72
74
|
assert loaded.output.voice_input_prefix == "[voice]"
|
|
73
75
|
|
|
74
76
|
|
|
77
|
+
@pytest.mark.skipif(os.name != "posix", reason="POSIX permissions only")
|
|
75
78
|
def test_write_config_restricts_permissions(cfg_paths):
|
|
76
|
-
import os
|
|
77
79
|
wizard._write_config(
|
|
78
80
|
model="small", language="en", hotkey="ctrl+alt+v", method="auto",
|
|
79
81
|
streaming=True, backend="evdev", quiet=True,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.0"
|
|
@@ -337,7 +337,31 @@ class VoiceIO:
|
|
|
337
337
|
log.exception("Cannot reopen audio stream, aborting recording")
|
|
338
338
|
return
|
|
339
339
|
|
|
340
|
-
if
|
|
340
|
+
if self.recorder.is_zombie():
|
|
341
|
+
# Full pre-buffer of digital zeros with a "healthy" stream is the
|
|
342
|
+
# post-suspend zombie signature (callbacks fire, capture node
|
|
343
|
+
# gone). Reopening fixes it; a failed reopen means no audio is
|
|
344
|
+
# possible, so abort rather than record silence.
|
|
345
|
+
log.warning("Zombie audio stream (all-zero pre-buffer) — reopening")
|
|
346
|
+
try:
|
|
347
|
+
self.recorder.reopen_stream()
|
|
348
|
+
time.sleep(0.4) # let the new stream fill some pre-buffer
|
|
349
|
+
except Exception:
|
|
350
|
+
log.exception("Audio stream reopen failed, aborting recording")
|
|
351
|
+
from voiceio import feedback
|
|
352
|
+
feedback.notify(
|
|
353
|
+
"VoiceIO: microphone unavailable",
|
|
354
|
+
"Could not reopen the audio stream — check your input device.",
|
|
355
|
+
)
|
|
356
|
+
return
|
|
357
|
+
if not self.recorder.has_signal():
|
|
358
|
+
log.warning("Mic still silent after reopen — muted or wrong device?")
|
|
359
|
+
from voiceio import feedback
|
|
360
|
+
feedback.notify(
|
|
361
|
+
"VoiceIO: microphone appears silent",
|
|
362
|
+
"Check that your mic is unmuted and the right input device is selected.",
|
|
363
|
+
)
|
|
364
|
+
elif not self.recorder.has_signal():
|
|
341
365
|
log.warning("Mic appears silent or muted (pre-buffer is all zeros)")
|
|
342
366
|
|
|
343
367
|
self._state = _State.RECORDING
|
|
@@ -368,6 +392,7 @@ class VoiceIO:
|
|
|
368
392
|
llm=self._llm,
|
|
369
393
|
voice_input_prefix=self._voice_input_prefix,
|
|
370
394
|
on_typer_broken=self._on_typer_broken,
|
|
395
|
+
on_interim=self._on_interim_text,
|
|
371
396
|
)
|
|
372
397
|
self._session.start()
|
|
373
398
|
log.info("Recording... press [%s] again to stop", self.cfg.hotkey.key)
|
|
@@ -492,12 +517,19 @@ class VoiceIO:
|
|
|
492
517
|
) -> None:
|
|
493
518
|
"""Run final transcription and commit in background thread."""
|
|
494
519
|
t_final = time.monotonic()
|
|
520
|
+
# Snappy clipboard: make the best-so-far text pasteable the moment
|
|
521
|
+
# the user stops, instead of after the (possibly long) final decode.
|
|
522
|
+
if (self.cfg.output.copy_to_clipboard == "live"
|
|
523
|
+
and self._typer.name != "clipboard" and session.interim_text):
|
|
524
|
+
from voiceio import clipboard_read
|
|
525
|
+
clipboard_read.copy_text(session.interim_text)
|
|
495
526
|
extra = self._retention_extra(audio)
|
|
496
527
|
final_text = session.stop(audio)
|
|
497
528
|
if self._generation != gen:
|
|
498
529
|
log.debug("Finalize cancelled (gen %d, current %d)", gen, self._generation)
|
|
499
530
|
return
|
|
500
531
|
if final_text:
|
|
532
|
+
self._copy_result_async(final_text)
|
|
501
533
|
self._play_feedback(final_text)
|
|
502
534
|
stored = self._strip_voice_prefix(final_text)
|
|
503
535
|
self._prompt_builder.add_transcript(stored)
|
|
@@ -565,6 +597,7 @@ class VoiceIO:
|
|
|
565
597
|
# Superseded by a newer recording — do not touch the typer.
|
|
566
598
|
if text and self._generation == gen:
|
|
567
599
|
self._type_with_fallback(text)
|
|
600
|
+
self._copy_result_async(text)
|
|
568
601
|
self._play_feedback(text)
|
|
569
602
|
stored = self._strip_voice_prefix(text)
|
|
570
603
|
self._prompt_builder.add_transcript(stored)
|
|
@@ -660,6 +693,35 @@ class VoiceIO:
|
|
|
660
693
|
from voiceio.feedback import play_record_stop
|
|
661
694
|
play_record_stop()
|
|
662
695
|
|
|
696
|
+
def _on_interim_text(self, text: str) -> None:
|
|
697
|
+
"""Streaming update: mirror the best-so-far text to the clipboard.
|
|
698
|
+
|
|
699
|
+
Runs on the streaming worker thread (a copy is a few ms). Skipped for
|
|
700
|
+
the clipboard typer, which pastes FROM the clipboard — overwriting it
|
|
701
|
+
here could race a pending Ctrl+V and paste the full text twice.
|
|
702
|
+
"""
|
|
703
|
+
if self.cfg.output.copy_to_clipboard != "live":
|
|
704
|
+
return
|
|
705
|
+
if self._typer.name == "clipboard":
|
|
706
|
+
return
|
|
707
|
+
from voiceio import clipboard_read
|
|
708
|
+
clipboard_read.copy_text(text)
|
|
709
|
+
|
|
710
|
+
def _copy_result_async(self, text: str) -> None:
|
|
711
|
+
"""Mirror the final text to the clipboard, off the commit hot path."""
|
|
712
|
+
if self.cfg.output.copy_to_clipboard not in ("final", "live"):
|
|
713
|
+
return
|
|
714
|
+
|
|
715
|
+
def _copy() -> None:
|
|
716
|
+
# Give a just-sent paste keystroke time to consume the clipboard
|
|
717
|
+
# before we overwrite it with the full text.
|
|
718
|
+
if self._typer.name == "clipboard":
|
|
719
|
+
time.sleep(0.3)
|
|
720
|
+
from voiceio import clipboard_read
|
|
721
|
+
clipboard_read.copy_text(text)
|
|
722
|
+
|
|
723
|
+
threading.Thread(target=_copy, daemon=True).start()
|
|
724
|
+
|
|
663
725
|
def _play_feedback(self, text: str) -> None:
|
|
664
726
|
if self.cfg.feedback.sound_enabled:
|
|
665
727
|
from voiceio.feedback import play_commit_sound
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Read text from the system clipboard or primary selection."""
|
|
1
|
+
"""Read/write text from/to the system clipboard or primary selection."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import logging
|
|
@@ -67,3 +67,49 @@ def read_text() -> str | None:
|
|
|
67
67
|
|
|
68
68
|
log.debug("No clipboard tool found")
|
|
69
69
|
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _copy_via(cmd: list[str], text: str) -> bool:
|
|
73
|
+
"""Pipe text into a copy command. Returns True on success."""
|
|
74
|
+
try:
|
|
75
|
+
result = subprocess.run(
|
|
76
|
+
cmd, input=text.encode(), capture_output=True, timeout=_TIMEOUT,
|
|
77
|
+
)
|
|
78
|
+
return result.returncode == 0
|
|
79
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def copy_text(text: str) -> bool:
|
|
84
|
+
"""Copy text to the system CLIPBOARD selection.
|
|
85
|
+
|
|
86
|
+
Best-effort: returns False (never raises) when no tool is available
|
|
87
|
+
or the copy fails.
|
|
88
|
+
"""
|
|
89
|
+
if not text:
|
|
90
|
+
return False
|
|
91
|
+
p = detect()
|
|
92
|
+
|
|
93
|
+
if p.is_windows:
|
|
94
|
+
try:
|
|
95
|
+
import pyperclip
|
|
96
|
+
pyperclip.copy(text)
|
|
97
|
+
return True
|
|
98
|
+
except Exception:
|
|
99
|
+
log.debug("pyperclip not available for clipboard write")
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
if p.is_mac:
|
|
103
|
+
return _copy_via(["pbcopy"], text)
|
|
104
|
+
|
|
105
|
+
if p.is_wayland:
|
|
106
|
+
if shutil.which("wl-copy"):
|
|
107
|
+
return _copy_via(["wl-copy", "--"], text)
|
|
108
|
+
else:
|
|
109
|
+
if shutil.which("xclip"):
|
|
110
|
+
return _copy_via(["xclip", "-selection", "clipboard"], text)
|
|
111
|
+
if shutil.which("xsel"):
|
|
112
|
+
return _copy_via(["xsel", "-i", "--clipboard"], text)
|
|
113
|
+
|
|
114
|
+
log.debug("No clipboard tool found for copy")
|
|
115
|
+
return False
|
|
@@ -79,6 +79,12 @@ class OutputConfig:
|
|
|
79
79
|
punctuation_cleanup: bool = True
|
|
80
80
|
number_conversion: bool = True
|
|
81
81
|
voice_input_prefix: str = "" # e.g. "[voice]" — empty disables
|
|
82
|
+
# Mirror transcribed text to the system clipboard so it can be pasted:
|
|
83
|
+
# "off" — never
|
|
84
|
+
# "final" — the corrected final text, once ready
|
|
85
|
+
# "live" — also the best-so-far text at each streaming update and the
|
|
86
|
+
# instant recording stops (pasteable while the final decode runs)
|
|
87
|
+
copy_to_clipboard: str = "final"
|
|
82
88
|
|
|
83
89
|
|
|
84
90
|
@dataclass
|
|
@@ -162,6 +162,17 @@ class AudioRecorder:
|
|
|
162
162
|
return False, f"no audio callback for {stale:.1f}s"
|
|
163
163
|
return True, ""
|
|
164
164
|
|
|
165
|
+
def is_zombie(self) -> bool:
|
|
166
|
+
"""Stream firing callbacks but delivering only digital zeros.
|
|
167
|
+
|
|
168
|
+
The post-suspend PortAudio failure mode: the heartbeat stays healthy
|
|
169
|
+
while the capture node is gone. Distinct from a merely-empty ring
|
|
170
|
+
(fresh stream) or a muted mic (noise floor > 0 on real hardware).
|
|
171
|
+
"""
|
|
172
|
+
if self._ring._filled < self._ring._max:
|
|
173
|
+
return False # ring not yet full — can't judge
|
|
174
|
+
return not self.has_signal()
|
|
175
|
+
|
|
165
176
|
def has_signal(self) -> bool:
|
|
166
177
|
"""Check if the pre-buffer ring contains non-silence audio.
|
|
167
178
|
|
|
@@ -84,6 +84,7 @@ class StreamingSession:
|
|
|
84
84
|
voice_input_prefix: str = "",
|
|
85
85
|
on_typer_broken: Callable[[], None] | None = None,
|
|
86
86
|
is_current: Callable[[], bool] | None = None,
|
|
87
|
+
on_interim: Callable[[str], None] | None = None,
|
|
87
88
|
):
|
|
88
89
|
self._transcriber = transcriber
|
|
89
90
|
self._typer = typer
|
|
@@ -104,6 +105,7 @@ class StreamingSession:
|
|
|
104
105
|
self._llm = llm
|
|
105
106
|
self._voice_input_prefix = voice_input_prefix
|
|
106
107
|
self._on_typer_broken = on_typer_broken
|
|
108
|
+
self._on_interim = on_interim
|
|
107
109
|
self._typer_fail_count = 0
|
|
108
110
|
self._typer_broken_signalled = False
|
|
109
111
|
self._typed_text = ""
|
|
@@ -116,6 +118,11 @@ class StreamingSession:
|
|
|
116
118
|
self.final_latency: dict = {}
|
|
117
119
|
self.final_segments: list[dict] = []
|
|
118
120
|
|
|
121
|
+
@property
|
|
122
|
+
def interim_text(self) -> str:
|
|
123
|
+
"""Best-so-far text (post-pipeline). Safe to read while finalizing."""
|
|
124
|
+
return self._typed_text
|
|
125
|
+
|
|
119
126
|
def set_is_current(self, is_current: Callable[[], bool]) -> None:
|
|
120
127
|
"""Install the output-ownership gate (see __init__).
|
|
121
128
|
|
|
@@ -280,7 +287,13 @@ class StreamingSession:
|
|
|
280
287
|
return
|
|
281
288
|
|
|
282
289
|
if text:
|
|
290
|
+
prev = self._typed_text
|
|
283
291
|
self._apply_correction(text, final=final)
|
|
292
|
+
if not final and self._on_interim and self._typed_text != prev:
|
|
293
|
+
try:
|
|
294
|
+
self._on_interim(self._typed_text)
|
|
295
|
+
except Exception:
|
|
296
|
+
log.debug("on_interim callback failed", exc_info=True)
|
|
284
297
|
|
|
285
298
|
def _commit_interim_on_final(self) -> None:
|
|
286
299
|
"""Commit whatever interim text we have when the final pass yields none.
|
|
@@ -680,7 +680,7 @@ def _write_config(
|
|
|
680
680
|
"model": autocorrect_model or "moonshotai/kimi-k2-0905",
|
|
681
681
|
})
|
|
682
682
|
|
|
683
|
-
CONFIG_PATH.write_text(_dump_toml(cfg))
|
|
683
|
+
CONFIG_PATH.write_text(_dump_toml(cfg), encoding="utf-8")
|
|
684
684
|
_secure_config_permissions()
|
|
685
685
|
if not quiet:
|
|
686
686
|
print(f"\n {GREEN}✓{RESET} Config saved to {DIM}{CONFIG_PATH}{RESET}")
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.4.1"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|