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.
Files changed (124) hide show
  1. {python_voiceio-0.4.1/python_voiceio.egg-info → python_voiceio-0.5.0}/PKG-INFO +1 -1
  2. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/pyproject.toml +1 -1
  3. {python_voiceio-0.4.1 → python_voiceio-0.5.0/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_app_wiring.py +52 -1
  5. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_clipboard_read.py +54 -0
  6. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_config.py +13 -0
  7. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_robustness.py +53 -0
  8. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_streaming.py +54 -0
  9. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_transcriber.py +8 -3
  10. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_wizard.py +3 -1
  11. python_voiceio-0.5.0/voiceio/__init__.py +1 -0
  12. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/app.py +63 -1
  13. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/clipboard_read.py +47 -1
  14. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/config.py +6 -0
  15. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/recorder.py +11 -0
  16. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/streaming.py +13 -0
  17. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/wizard.py +1 -1
  18. python_voiceio-0.4.1/voiceio/__init__.py +0 -1
  19. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/LICENSE +0 -0
  20. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/README.md +0 -0
  21. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/python_voiceio.egg-info/SOURCES.txt +0 -0
  22. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
  23. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/python_voiceio.egg-info/entry_points.txt +0 -0
  24. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/python_voiceio.egg-info/requires.txt +0 -0
  25. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/python_voiceio.egg-info/top_level.txt +0 -0
  26. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/setup.cfg +0 -0
  27. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_adjudicate.py +0 -0
  28. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_audio_quality.py +0 -0
  29. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_audit.py +0 -0
  30. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_autocorrect.py +0 -0
  31. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_autocorrect_state.py +0 -0
  32. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_backend_probes.py +0 -0
  33. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_cli.py +0 -0
  34. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_commands.py +0 -0
  35. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_concurrency_lockdown.py +0 -0
  36. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_correct_batch.py +0 -0
  37. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_corrections.py +0 -0
  38. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_fallback.py +0 -0
  39. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_health.py +0 -0
  40. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_hints.py +0 -0
  41. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_history.py +0 -0
  42. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_ibus_pending.py +0 -0
  43. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_ibus_typer.py +0 -0
  44. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_llm.py +0 -0
  45. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_llm_api.py +0 -0
  46. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_numbers.py +0 -0
  47. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_platform.py +0 -0
  48. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_postcorrect.py +0 -0
  49. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_postprocess.py +0 -0
  50. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_prebuffer.py +0 -0
  51. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_prompt.py +0 -0
  52. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_recorder_integration.py +0 -0
  53. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_retention.py +0 -0
  54. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_security_hardening.py +0 -0
  55. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_snapshots.py +0 -0
  56. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_tts.py +0 -0
  57. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_vad.py +0 -0
  58. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_vocabulary.py +0 -0
  59. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/tests/test_wordfreq.py +0 -0
  60. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/__main__.py +0 -0
  61. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/audit.py +0 -0
  62. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/autocorrect.py +0 -0
  63. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/autocorrect_state.py +0 -0
  64. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/backends.py +0 -0
  65. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/cli.py +0 -0
  66. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/commands.py +0 -0
  67. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/consent.py +0 -0
  68. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/corrections.py +0 -0
  69. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/demo.py +0 -0
  70. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/feedback.py +0 -0
  71. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/health.py +0 -0
  72. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/hints.py +0 -0
  73. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/history.py +0 -0
  74. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/hotkeys/__init__.py +0 -0
  75. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/hotkeys/base.py +0 -0
  76. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/hotkeys/chain.py +0 -0
  77. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/hotkeys/evdev.py +0 -0
  78. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/hotkeys/pynput_backend.py +0 -0
  79. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/hotkeys/socket_backend.py +0 -0
  80. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/ibus/__init__.py +0 -0
  81. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/ibus/engine.py +0 -0
  82. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/ibus/pending.py +0 -0
  83. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/llm.py +0 -0
  84. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/llm_api.py +0 -0
  85. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/models/__init__.py +0 -0
  86. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/models/silero_vad.onnx +0 -0
  87. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/numbers.py +0 -0
  88. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/pidlock.py +0 -0
  89. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/platform.py +0 -0
  90. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/postcorrect.py +0 -0
  91. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/postprocess.py +0 -0
  92. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/prompt.py +0 -0
  93. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/retention.py +0 -0
  94. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/service.py +0 -0
  95. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/snapshots.py +0 -0
  96. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/sounds/__init__.py +0 -0
  97. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/sounds/commit.wav +0 -0
  98. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/sounds/start.wav +0 -0
  99. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/sounds/stop.wav +0 -0
  100. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/transcriber.py +0 -0
  101. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/tray/__init__.py +0 -0
  102. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/tray/_icons.py +0 -0
  103. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/tray/_indicator.py +0 -0
  104. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/tray/_pystray.py +0 -0
  105. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/tts/__init__.py +0 -0
  106. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/tts/base.py +0 -0
  107. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/tts/chain.py +0 -0
  108. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/tts/edge_engine.py +0 -0
  109. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/tts/espeak.py +0 -0
  110. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/tts/piper_engine.py +0 -0
  111. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/tts/player.py +0 -0
  112. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/typers/__init__.py +0 -0
  113. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/typers/base.py +0 -0
  114. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/typers/chain.py +0 -0
  115. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/typers/clipboard.py +0 -0
  116. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/typers/ibus.py +0 -0
  117. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/typers/pynput_type.py +0 -0
  118. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/typers/wtype.py +0 -0
  119. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/typers/xdotool.py +0 -0
  120. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/typers/ydotool.py +0 -0
  121. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/vad.py +0 -0
  122. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/vocabulary.py +0 -0
  123. {python_voiceio-0.4.1 → python_voiceio-0.5.0}/voiceio/wordfreq.py +0 -0
  124. {python_voiceio-0.4.1 → python_voiceio-0.5.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.5.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.5.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.5.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"
@@ -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 = _time.monotonic() - (RESTART_RESET_SECS + 60)
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 not self.recorder.has_signal():
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