python-voiceio 0.3.3__tar.gz → 0.3.5__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 (104) hide show
  1. {python_voiceio-0.3.3/python_voiceio.egg-info → python_voiceio-0.3.5}/PKG-INFO +1 -1
  2. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/pyproject.toml +1 -1
  3. {python_voiceio-0.3.3 → python_voiceio-0.3.5/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/python_voiceio.egg-info/SOURCES.txt +1 -0
  5. python_voiceio-0.3.5/tests/test_robustness.py +526 -0
  6. python_voiceio-0.3.5/voiceio/__init__.py +1 -0
  7. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/app.py +86 -1
  8. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/cli.py +2 -2
  9. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/service.py +1 -0
  10. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/typers/ibus.py +14 -2
  11. python_voiceio-0.3.3/voiceio/__init__.py +0 -1
  12. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/LICENSE +0 -0
  13. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/README.md +0 -0
  14. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/python_voiceio.egg-info/dependency_links.txt +0 -0
  15. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/python_voiceio.egg-info/entry_points.txt +0 -0
  16. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/python_voiceio.egg-info/requires.txt +0 -0
  17. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/python_voiceio.egg-info/top_level.txt +0 -0
  18. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/setup.cfg +0 -0
  19. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_app_wiring.py +0 -0
  20. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_autocorrect.py +0 -0
  21. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_backend_probes.py +0 -0
  22. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_clipboard_read.py +0 -0
  23. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_commands.py +0 -0
  24. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_config.py +0 -0
  25. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_corrections.py +0 -0
  26. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_fallback.py +0 -0
  27. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_health.py +0 -0
  28. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_hints.py +0 -0
  29. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_history.py +0 -0
  30. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_ibus_typer.py +0 -0
  31. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_llm.py +0 -0
  32. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_llm_api.py +0 -0
  33. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_numbers.py +0 -0
  34. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_platform.py +0 -0
  35. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_postprocess.py +0 -0
  36. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_prebuffer.py +0 -0
  37. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_prompt.py +0 -0
  38. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_recorder_integration.py +0 -0
  39. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_streaming.py +0 -0
  40. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_transcriber.py +0 -0
  41. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_tts.py +0 -0
  42. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_vad.py +0 -0
  43. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_vocabulary.py +0 -0
  44. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_wordfreq.py +0 -0
  45. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/__main__.py +0 -0
  46. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/autocorrect.py +0 -0
  47. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/backends.py +0 -0
  48. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/clipboard_read.py +0 -0
  49. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/commands.py +0 -0
  50. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/config.py +0 -0
  51. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/corrections.py +0 -0
  52. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/demo.py +0 -0
  53. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/feedback.py +0 -0
  54. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/health.py +0 -0
  55. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/hints.py +0 -0
  56. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/history.py +0 -0
  57. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/hotkeys/__init__.py +0 -0
  58. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/hotkeys/base.py +0 -0
  59. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/hotkeys/chain.py +0 -0
  60. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/hotkeys/evdev.py +0 -0
  61. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/hotkeys/pynput_backend.py +0 -0
  62. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/hotkeys/socket_backend.py +0 -0
  63. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/ibus/__init__.py +0 -0
  64. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/ibus/engine.py +0 -0
  65. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/llm.py +0 -0
  66. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/llm_api.py +0 -0
  67. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/models/__init__.py +0 -0
  68. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/models/silero_vad.onnx +0 -0
  69. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/numbers.py +0 -0
  70. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/pidlock.py +0 -0
  71. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/platform.py +0 -0
  72. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/postprocess.py +0 -0
  73. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/prompt.py +0 -0
  74. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/recorder.py +0 -0
  75. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/sounds/__init__.py +0 -0
  76. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/sounds/commit.wav +0 -0
  77. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/sounds/start.wav +0 -0
  78. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/sounds/stop.wav +0 -0
  79. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/streaming.py +0 -0
  80. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/transcriber.py +0 -0
  81. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/tray/__init__.py +0 -0
  82. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/tray/_icons.py +0 -0
  83. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/tray/_indicator.py +0 -0
  84. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/tray/_pystray.py +0 -0
  85. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/tts/__init__.py +0 -0
  86. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/tts/base.py +0 -0
  87. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/tts/chain.py +0 -0
  88. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/tts/edge_engine.py +0 -0
  89. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/tts/espeak.py +0 -0
  90. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/tts/piper_engine.py +0 -0
  91. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/tts/player.py +0 -0
  92. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/typers/__init__.py +0 -0
  93. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/typers/base.py +0 -0
  94. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/typers/chain.py +0 -0
  95. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/typers/clipboard.py +0 -0
  96. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/typers/pynput_type.py +0 -0
  97. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/typers/wtype.py +0 -0
  98. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/typers/xdotool.py +0 -0
  99. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/typers/ydotool.py +0 -0
  100. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/vad.py +0 -0
  101. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/vocabulary.py +0 -0
  102. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/wizard.py +0 -0
  103. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/wordfreq.py +0 -0
  104. {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.3.3
3
+ Version: 0.3.5
4
4
  Summary: 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.3.3"
7
+ version = "0.3.5"
8
8
  description = "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.3.3
3
+ Version: 0.3.5
4
4
  Summary: Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -27,6 +27,7 @@ tests/test_postprocess.py
27
27
  tests/test_prebuffer.py
28
28
  tests/test_prompt.py
29
29
  tests/test_recorder_integration.py
30
+ tests/test_robustness.py
30
31
  tests/test_streaming.py
31
32
  tests/test_transcriber.py
32
33
  tests/test_tts.py
@@ -0,0 +1,526 @@
1
+ """Tests for robustness features: stream health, tray watchdog, audio backoff."""
2
+ from __future__ import annotations
3
+
4
+ import threading
5
+ import time
6
+ from unittest.mock import MagicMock, patch
7
+
8
+ import numpy as np
9
+ from voiceio.config import Config
10
+
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Helpers (same pattern as test_app_wiring.py)
14
+ # ---------------------------------------------------------------------------
15
+
16
+ def _make_vio(mock_transcriber=None):
17
+ """Create a VoiceIO instance with mocked backends."""
18
+ mock_hotkey = MagicMock()
19
+ mock_hotkey.name = "socket"
20
+ mock_typer = MagicMock()
21
+ mock_typer.name = "clipboard"
22
+ if mock_transcriber is None:
23
+ mock_transcriber = MagicMock()
24
+
25
+ with patch("voiceio.app.hotkey_chain.select", return_value=mock_hotkey), \
26
+ patch("voiceio.app.typer_chain.select", return_value=mock_typer), \
27
+ patch("voiceio.app.Transcriber", return_value=mock_transcriber), \
28
+ patch("voiceio.app.plat.detect") as mock_detect:
29
+ mock_detect.return_value = MagicMock(display_server="wayland", desktop="gnome")
30
+
31
+ from voiceio.app import VoiceIO
32
+ vio = VoiceIO(Config())
33
+ mock_stream = MagicMock()
34
+ mock_stream.active = True
35
+ mock_stream.closed = False
36
+ mock_stream.stopped = False
37
+ vio.recorder._stream = mock_stream
38
+ vio.recorder._last_callback_time = time.monotonic()
39
+ return vio, mock_typer, mock_transcriber
40
+
41
+
42
+ def _feed_audio(vio, chunks=20):
43
+ """Feed fake audio data into the recorder."""
44
+ for _ in range(chunks):
45
+ data = np.full((1024, 1), 0.3, dtype=np.float32)
46
+ vio.recorder._callback(data, 1024, None, None)
47
+
48
+
49
+ def _allow_stop(vio):
50
+ """Set timestamps far back so debounce allows stop."""
51
+ vio._record_start = time.monotonic() - 5.0
52
+ vio._last_hotkey = time.monotonic() - 5.0
53
+
54
+
55
+ def _make_recorder():
56
+ """Create a standalone AudioRecorder with mocked dependencies."""
57
+ cfg = MagicMock()
58
+ cfg.sample_rate = 16000
59
+ cfg.device = "default"
60
+ cfg.prebuffer_secs = 1.0
61
+ cfg.silence_threshold = 0.01
62
+ cfg.silence_duration = 0.6
63
+ cfg.auto_stop_silence_secs = 5.0
64
+
65
+ with patch("voiceio.recorder.sd"):
66
+ from voiceio.recorder import AudioRecorder
67
+ vad = MagicMock()
68
+ vad.is_speech.return_value = False
69
+ rec = AudioRecorder(cfg, vad=vad)
70
+ return rec
71
+
72
+
73
+ # ===========================================================================
74
+ # 1. stream_health() tests
75
+ # ===========================================================================
76
+
77
+ class TestStreamHealth:
78
+ def test_healthy_stream(self):
79
+ rec = _make_recorder()
80
+ mock_stream = MagicMock()
81
+ mock_stream.active = True
82
+ mock_stream.closed = False
83
+ mock_stream.stopped = False
84
+ rec._stream = mock_stream
85
+ rec._last_callback_time = time.monotonic()
86
+
87
+ ok, reason = rec.stream_health()
88
+ assert ok is True
89
+ assert reason == ""
90
+
91
+ def test_stream_is_none(self):
92
+ rec = _make_recorder()
93
+ rec._stream = None
94
+
95
+ ok, reason = rec.stream_health()
96
+ assert ok is False
97
+ assert reason == "stream is None"
98
+
99
+ def test_stream_closed(self):
100
+ rec = _make_recorder()
101
+ mock_stream = MagicMock()
102
+ mock_stream.closed = True
103
+ rec._stream = mock_stream
104
+
105
+ ok, reason = rec.stream_health()
106
+ assert ok is False
107
+ assert reason == "stream closed"
108
+
109
+ def test_stream_not_active(self):
110
+ rec = _make_recorder()
111
+ mock_stream = MagicMock()
112
+ mock_stream.closed = False
113
+ mock_stream.active = False
114
+ rec._stream = mock_stream
115
+
116
+ ok, reason = rec.stream_health()
117
+ assert ok is False
118
+ assert reason == "stream not active"
119
+
120
+ def test_stream_stopped(self):
121
+ rec = _make_recorder()
122
+ mock_stream = MagicMock()
123
+ mock_stream.closed = False
124
+ mock_stream.active = True
125
+ mock_stream.stopped = True
126
+ rec._stream = mock_stream
127
+
128
+ ok, reason = rec.stream_health()
129
+ assert ok is False
130
+ assert reason == "stream stopped"
131
+
132
+ def test_stale_heartbeat(self):
133
+ rec = _make_recorder()
134
+ mock_stream = MagicMock()
135
+ mock_stream.active = True
136
+ mock_stream.closed = False
137
+ mock_stream.stopped = False
138
+ rec._stream = mock_stream
139
+ # Set heartbeat to 10 seconds ago (exceeds _HEARTBEAT_TIMEOUT of 5s)
140
+ rec._last_callback_time = time.monotonic() - 10.0
141
+
142
+ ok, reason = rec.stream_health()
143
+ assert ok is False
144
+ assert "no audio callback for" in reason
145
+
146
+ def test_healthy_with_no_heartbeat_yet(self):
147
+ """Before any callback fires, heartbeat is 0.0 — should be healthy."""
148
+ rec = _make_recorder()
149
+ mock_stream = MagicMock()
150
+ mock_stream.active = True
151
+ mock_stream.closed = False
152
+ mock_stream.stopped = False
153
+ rec._stream = mock_stream
154
+ rec._last_callback_time = 0.0 # never set
155
+
156
+ ok, reason = rec.stream_health()
157
+ assert ok is True
158
+ assert reason == ""
159
+
160
+
161
+ # ===========================================================================
162
+ # 2. has_signal() tests
163
+ # ===========================================================================
164
+
165
+ class TestHasSignal:
166
+ def test_empty_ring_buffer(self):
167
+ rec = _make_recorder()
168
+ # Ring buffer is empty by default (no audio fed)
169
+ assert rec.has_signal() is False
170
+
171
+ def test_all_zeros(self):
172
+ rec = _make_recorder()
173
+ # Feed silent audio (all zeros)
174
+ silent = np.zeros((1024, 1), dtype=np.float32)
175
+ rec._ring.append(silent)
176
+
177
+ assert rec.has_signal() is False
178
+
179
+ def test_real_audio(self):
180
+ rec = _make_recorder()
181
+ # Feed audio with real signal
182
+ audio = np.full((1024, 1), 0.05, dtype=np.float32)
183
+ rec._ring.append(audio)
184
+
185
+ assert rec.has_signal() is True
186
+
187
+ def test_barely_above_threshold(self):
188
+ """Signal just above 1e-4 threshold should be detected."""
189
+ rec = _make_recorder()
190
+ audio = np.full((1024, 1), 2e-4, dtype=np.float32)
191
+ rec._ring.append(audio)
192
+
193
+ assert rec.has_signal() is True
194
+
195
+ def test_barely_below_threshold(self):
196
+ """Signal at exactly 1e-4 or below should not be detected."""
197
+ rec = _make_recorder()
198
+ audio = np.full((1024, 1), 1e-5, dtype=np.float32)
199
+ rec._ring.append(audio)
200
+
201
+ assert rec.has_signal() is False
202
+
203
+
204
+ # ===========================================================================
205
+ # 3. reopen_stream() tests
206
+ # ===========================================================================
207
+
208
+ class TestReopenStream:
209
+ def test_closes_and_reopens(self):
210
+ rec = _make_recorder()
211
+ mock_stream = MagicMock()
212
+ mock_stream.active = True
213
+ mock_stream.closed = False
214
+ mock_stream.stopped = False
215
+ rec._stream = mock_stream
216
+ rec._last_callback_time = time.monotonic()
217
+
218
+ with patch("voiceio.recorder.sd") as mock_sd:
219
+ new_stream = MagicMock()
220
+ mock_sd.InputStream.return_value = new_stream
221
+ rec.reopen_stream()
222
+
223
+ # Old stream should have been stopped and closed
224
+ mock_stream.stop.assert_called_once()
225
+ mock_stream.close.assert_called_once()
226
+ # New stream should be opened and started
227
+ new_stream.start.assert_called_once()
228
+ assert rec._stream is new_stream
229
+
230
+ def test_resets_heartbeat(self):
231
+ rec = _make_recorder()
232
+ mock_stream = MagicMock()
233
+ rec._stream = mock_stream
234
+ rec._last_callback_time = time.monotonic() - 100.0
235
+
236
+ with patch("voiceio.recorder.sd") as mock_sd:
237
+ mock_sd.InputStream.return_value = MagicMock()
238
+ rec.reopen_stream()
239
+
240
+ assert rec._last_callback_time == 0.0
241
+
242
+
243
+ # ===========================================================================
244
+ # 4. tray is_alive() tests
245
+ # ===========================================================================
246
+
247
+ class TestTrayIsAlive:
248
+ def test_indicator_alive(self):
249
+ mock_proc = MagicMock()
250
+ mock_proc.poll.return_value = None # still running
251
+
252
+ with patch("voiceio.tray._proc", mock_proc), \
253
+ patch("voiceio.tray._backend", "indicator"):
254
+ from voiceio import tray
255
+ assert tray.is_alive() is True
256
+
257
+ def test_indicator_dead(self):
258
+ mock_proc = MagicMock()
259
+ mock_proc.poll.return_value = 1 # exited with code 1
260
+
261
+ with patch("voiceio.tray._proc", mock_proc), \
262
+ patch("voiceio.tray._backend", "indicator"):
263
+ from voiceio import tray
264
+ assert tray.is_alive() is False
265
+
266
+ def test_indicator_proc_none(self):
267
+ with patch("voiceio.tray._proc", None), \
268
+ patch("voiceio.tray._backend", "indicator"):
269
+ from voiceio import tray
270
+ assert tray.is_alive() is False
271
+
272
+ def test_pystray_alive(self):
273
+ with patch("voiceio.tray._backend", "pystray"):
274
+ from voiceio import tray
275
+ assert tray.is_alive() is True
276
+
277
+ def test_no_backend(self):
278
+ with patch("voiceio.tray._backend", None):
279
+ from voiceio import tray
280
+ assert tray.is_alive() is False
281
+
282
+
283
+ # ===========================================================================
284
+ # 5. Health watchdog audio backoff tests
285
+ # ===========================================================================
286
+
287
+ class TestHealthWatchdogAudioBackoff:
288
+ def test_stream_failure_triggers_reopen(self):
289
+ vio, _, _ = _make_vio()
290
+ vio.recorder.stream_health = MagicMock(return_value=(False, "stream not active"))
291
+ vio.recorder.reopen_stream = MagicMock()
292
+ vio.transcriber.is_worker_alive = MagicMock(return_value=True)
293
+
294
+ with patch("voiceio.app.tray"):
295
+ vio._check_health()
296
+
297
+ vio.recorder.reopen_stream.assert_called_once()
298
+
299
+ def test_successful_recovery_resets_backoff(self):
300
+ vio, _, _ = _make_vio()
301
+ vio._stream_fail_count = 3
302
+ vio._next_stream_retry = 0 # allow retry
303
+ vio.recorder.stream_health = MagicMock(return_value=(False, "stream not active"))
304
+ vio.recorder.reopen_stream = MagicMock() # succeeds (no exception)
305
+ vio.transcriber.is_worker_alive = MagicMock(return_value=True)
306
+
307
+ with patch("voiceio.app.tray"):
308
+ vio._check_health()
309
+
310
+ assert vio._stream_fail_count == 0
311
+ assert vio._next_stream_retry == 0
312
+
313
+ def test_failed_recovery_increments_backoff(self):
314
+ vio, _, _ = _make_vio()
315
+ vio._stream_fail_count = 0
316
+ vio._next_stream_retry = 0
317
+ vio.recorder.stream_health = MagicMock(return_value=(False, "stream not active"))
318
+ vio.recorder.reopen_stream = MagicMock(side_effect=OSError("device gone"))
319
+ vio.transcriber.is_worker_alive = MagicMock(return_value=True)
320
+
321
+ with patch("voiceio.app.tray"):
322
+ vio._check_health()
323
+
324
+ assert vio._stream_fail_count == 1
325
+ assert vio._next_stream_retry > 0
326
+
327
+ def test_backoff_skips_retry(self):
328
+ """When in backoff, _check_health should not attempt reopen."""
329
+ vio, _, _ = _make_vio()
330
+ vio._stream_fail_count = 2
331
+ vio._next_stream_retry = time.monotonic() + 999 # far future
332
+ vio.recorder.stream_health = MagicMock(return_value=(False, "stream not active"))
333
+ vio.recorder.reopen_stream = MagicMock()
334
+ vio.transcriber.is_worker_alive = MagicMock(return_value=True)
335
+
336
+ with patch("voiceio.app.tray"):
337
+ vio._check_health()
338
+
339
+ vio.recorder.reopen_stream.assert_not_called()
340
+
341
+ def test_external_recovery_resets_backoff(self):
342
+ """If stream becomes healthy on its own, backoff resets."""
343
+ vio, _, _ = _make_vio()
344
+ vio._stream_fail_count = 3
345
+ vio._next_stream_retry = time.monotonic() + 100
346
+ vio.recorder.stream_health = MagicMock(return_value=(True, ""))
347
+ vio.transcriber.is_worker_alive = MagicMock(return_value=True)
348
+
349
+ with patch("voiceio.app.tray"):
350
+ vio._check_health()
351
+
352
+ assert vio._stream_fail_count == 0
353
+ assert vio._next_stream_retry == 0
354
+
355
+ def test_repeated_failures_increase_delay(self):
356
+ """Each failure should increase the backoff delay."""
357
+ vio, _, _ = _make_vio()
358
+ vio.recorder.stream_health = MagicMock(return_value=(False, "stream not active"))
359
+ vio.recorder.reopen_stream = MagicMock(side_effect=OSError("device gone"))
360
+ vio.transcriber.is_worker_alive = MagicMock(return_value=True)
361
+
362
+ delays = []
363
+ with patch("voiceio.app.tray"):
364
+ for _ in range(3):
365
+ vio._next_stream_retry = 0 # allow retry each time
366
+ before = time.monotonic()
367
+ vio._check_health()
368
+ delays.append(vio._next_stream_retry - before)
369
+
370
+ # Each delay should be larger (10, 20, 40)
371
+ assert delays[0] < delays[1] < delays[2]
372
+
373
+
374
+ # ===========================================================================
375
+ # 6. Pre-flight stream check in _do_start()
376
+ # ===========================================================================
377
+
378
+ class TestPreFlightStreamCheck:
379
+ def test_unhealthy_stream_gets_reopened(self):
380
+ vio, _, _ = _make_vio()
381
+ vio.recorder.stream_health = MagicMock(return_value=(False, "stream not active"))
382
+ vio.recorder.reopen_stream = MagicMock()
383
+ vio.recorder.has_signal = MagicMock(return_value=True)
384
+
385
+ vio.on_hotkey()
386
+
387
+ vio.recorder.reopen_stream.assert_called_once()
388
+ assert vio.recorder.is_recording
389
+
390
+ def test_recording_aborts_if_reopen_fails(self):
391
+ from voiceio.app import _State
392
+
393
+ vio, _, _ = _make_vio()
394
+ vio.recorder.stream_health = MagicMock(return_value=(False, "stream not active"))
395
+ vio.recorder.reopen_stream = MagicMock(side_effect=OSError("no device"))
396
+
397
+ vio.on_hotkey()
398
+
399
+ # Recording should NOT have started
400
+ assert not vio.recorder.is_recording
401
+ assert vio._state == _State.IDLE
402
+
403
+ def test_healthy_stream_no_reopen(self):
404
+ vio, _, _ = _make_vio()
405
+ vio.recorder.stream_health = MagicMock(return_value=(True, ""))
406
+ vio.recorder.reopen_stream = MagicMock()
407
+ vio.recorder.has_signal = MagicMock(return_value=True)
408
+
409
+ vio.on_hotkey()
410
+
411
+ vio.recorder.reopen_stream.assert_not_called()
412
+ assert vio.recorder.is_recording
413
+
414
+
415
+ # ===========================================================================
416
+ # 7. Streaming worker exception handling
417
+ # ===========================================================================
418
+
419
+ class TestStreamingWorkerExceptionHandling:
420
+ def test_worker_catches_exception_and_continues(self):
421
+ """Worker loop should catch exceptions in _transcribe_and_apply and keep running."""
422
+ mock_transcriber = MagicMock()
423
+ mock_typer = MagicMock()
424
+ mock_typer.name = "clipboard"
425
+ mock_recorder = MagicMock()
426
+ mock_recorder.sample_rate = 16000
427
+ mock_recorder.get_audio_so_far.return_value = np.zeros(32000, dtype=np.float32)
428
+
429
+ from voiceio.streaming import StreamingSession
430
+
431
+ session = StreamingSession(
432
+ mock_transcriber, mock_typer, mock_recorder,
433
+ generation=0,
434
+ )
435
+
436
+ call_count = 0
437
+
438
+ def flaky_transcribe(*args, **kwargs):
439
+ nonlocal call_count
440
+ call_count += 1
441
+ if call_count <= 2:
442
+ raise RuntimeError("transient error")
443
+ # On 3rd call, stop the session to let the test finish
444
+ session._stop_event.set()
445
+
446
+ session._transcribe_and_apply = flaky_transcribe
447
+
448
+ # Start the worker (runs in its own thread)
449
+ session._stop_event = threading.Event()
450
+ session._pending = threading.Event()
451
+ session._pending.set() # trigger immediate processing
452
+
453
+ worker = threading.Thread(target=session._worker_loop, daemon=True)
454
+ worker.start()
455
+
456
+ # Wait for the worker to process through the exceptions
457
+ worker.join(timeout=5)
458
+ assert not worker.is_alive(), "Worker thread should have exited"
459
+ # Worker called our function multiple times without dying
460
+ assert call_count >= 2, f"Expected at least 2 calls, got {call_count}"
461
+
462
+ def test_final_transcription_exception_does_not_crash(self):
463
+ """Exception during final transcription should be caught."""
464
+ mock_transcriber = MagicMock()
465
+ mock_transcriber.transcribe.side_effect = RuntimeError("model crash")
466
+ mock_typer = MagicMock()
467
+ mock_typer.name = "clipboard"
468
+ mock_recorder = MagicMock()
469
+ mock_recorder.sample_rate = 16000
470
+ mock_recorder.get_audio_so_far.return_value = np.zeros(32000, dtype=np.float32)
471
+
472
+ from voiceio.streaming import StreamingSession
473
+
474
+ session = StreamingSession(
475
+ mock_transcriber, mock_typer, mock_recorder,
476
+ generation=0,
477
+ )
478
+
479
+ # Set up for immediate stop (skip the main loop, just do final pass)
480
+ session._stop_event.set()
481
+ session._pending.set()
482
+ session._final_audio = np.zeros(32000, dtype=np.float32)
483
+
484
+ # Should not raise — exceptions caught inside _worker_loop
485
+ worker = threading.Thread(target=session._worker_loop, daemon=True)
486
+ worker.start()
487
+ worker.join(timeout=5)
488
+ assert not worker.is_alive(), "Worker thread should have exited"
489
+
490
+
491
+ # ===========================================================================
492
+ # 8. Tray watchdog in _check_health()
493
+ # ===========================================================================
494
+
495
+ class TestTrayWatchdog:
496
+ def test_tray_restart_when_dead(self):
497
+ vio, _, _ = _make_vio()
498
+ vio.cfg.tray.enabled = True
499
+ vio.recorder.stream_health = MagicMock(return_value=(True, ""))
500
+ vio.transcriber.is_worker_alive = MagicMock(return_value=True)
501
+
502
+ with patch("voiceio.app.tray") as mock_tray:
503
+ mock_tray.is_alive.return_value = False
504
+ vio._check_health()
505
+ mock_tray.restart.assert_called_once_with(vio.on_hotkey)
506
+
507
+ def test_tray_not_restarted_when_alive(self):
508
+ vio, _, _ = _make_vio()
509
+ vio.cfg.tray.enabled = True
510
+ vio.recorder.stream_health = MagicMock(return_value=(True, ""))
511
+ vio.transcriber.is_worker_alive = MagicMock(return_value=True)
512
+
513
+ with patch("voiceio.app.tray") as mock_tray:
514
+ mock_tray.is_alive.return_value = True
515
+ vio._check_health()
516
+ mock_tray.restart.assert_not_called()
517
+
518
+ def test_tray_not_checked_when_disabled(self):
519
+ vio, _, _ = _make_vio()
520
+ vio.cfg.tray.enabled = False
521
+ vio.recorder.stream_health = MagicMock(return_value=(True, ""))
522
+ vio.transcriber.is_worker_alive = MagicMock(return_value=True)
523
+
524
+ with patch("voiceio.app.tray") as mock_tray:
525
+ vio._check_health()
526
+ mock_tray.is_alive.assert_not_called()
@@ -0,0 +1 @@
1
+ __version__ = "0.3.5"
@@ -473,6 +473,34 @@ class VoiceIO:
473
473
  self._set_gnome_input_source_index(0)
474
474
  log.info("VoiceIO IBus engine ready (dormant until recording)")
475
475
 
476
+ def _reactivate_ibus_if_stale(self) -> None:
477
+ """Re-activate IBus engine if it lost registration (e.g. after hibernate).
478
+
479
+ After suspend/hibernate, the IBus daemon may forget about our engine.
480
+ Check by querying the current active engine; if it's not 'voiceio',
481
+ re-run ``ibus engine voiceio`` to re-register.
482
+ """
483
+ from voiceio.typers.ibus import _ibus_env
484
+ try:
485
+ result = subprocess.run(
486
+ ["ibus", "engine"], capture_output=True, text=True,
487
+ timeout=3, env=_ibus_env(),
488
+ )
489
+ current = result.stdout.strip() if result.returncode == 0 else ""
490
+ except (FileNotFoundError, subprocess.TimeoutExpired):
491
+ return
492
+ if current == "voiceio":
493
+ return # still registered, nothing to do
494
+ log.warning("IBus engine stale (current=%r), re-activating", current)
495
+ try:
496
+ subprocess.run(
497
+ ["ibus", "engine", "voiceio"],
498
+ capture_output=True, timeout=5, env=_ibus_env(),
499
+ )
500
+ log.info("IBus engine re-activated")
501
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
502
+ log.warning("IBus re-activation failed: %s", e)
503
+
476
504
  def _wait_for_ibus(self, chain: list[str]) -> TyperBackend | None:
477
505
  """Wait for IBus daemon to become available and switch to IBus typer.
478
506
 
@@ -584,17 +612,70 @@ class VoiceIO:
584
612
 
585
613
  # ── Health watchdog ─────────────────────────────────────────────
586
614
 
615
+ # If the gap between health checks exceeds this, we probably resumed
616
+ # from suspend/hibernate and should re-probe everything.
617
+ _RESUME_THRESHOLD = 30 # seconds
618
+
587
619
  def _health_loop(self) -> None:
588
620
  """Periodic health check: transcriber worker, IBus engine, audio stream."""
621
+ last_check = time.monotonic()
589
622
  while not self._shutdown.is_set():
590
623
  self._shutdown.wait(_HEALTH_CHECK_INTERVAL)
591
624
  if self._shutdown.is_set():
592
625
  break
626
+
627
+ now = time.monotonic()
628
+ gap = now - last_check
629
+ last_check = now
630
+
593
631
  try:
632
+ if gap > self._RESUME_THRESHOLD:
633
+ log.info("System resume detected (%.0fs gap), re-probing all backends", gap)
634
+ self._on_resume()
594
635
  self._check_health()
595
636
  except Exception:
596
637
  log.debug("Health check error", exc_info=True)
597
638
 
639
+ def _on_resume(self) -> None:
640
+ """Re-probe all backends after system suspend/hibernate.
641
+
642
+ Sleep/hibernate breaks connections to system services (IBus, audio,
643
+ tray D-Bus, ydotoold) across all platforms. Instead of catching each
644
+ failure individually, do a single sweep to restore everything.
645
+ """
646
+ # Audio: reopen stream (device may have changed or died)
647
+ try:
648
+ self.recorder.reopen_stream()
649
+ log.info("Resume: audio stream reopened")
650
+ except Exception:
651
+ log.warning("Resume: audio stream reopen failed", exc_info=True)
652
+
653
+ # IBus: re-activate engine registration
654
+ if self._typer.name == "ibus" and self._engine_proc is not None:
655
+ if self._engine_proc.poll() is not None:
656
+ # Engine process died during sleep
657
+ self._engine_proc = None
658
+ try:
659
+ self._ensure_ibus_engine()
660
+ log.info("Resume: IBus engine restarted")
661
+ except Exception:
662
+ log.exception("Resume: IBus engine restart failed")
663
+ else:
664
+ self._reactivate_ibus_if_stale()
665
+
666
+ # Tray: restart if subprocess died
667
+ if self.cfg.tray.enabled and not tray.is_alive():
668
+ log.info("Resume: restarting tray")
669
+ tray.restart(self.on_hotkey)
670
+
671
+ # Transcriber: ensure worker is alive
672
+ if not self.transcriber.is_worker_alive():
673
+ try:
674
+ self.transcriber._ensure_worker()
675
+ log.info("Resume: transcriber worker restarted")
676
+ except RuntimeError:
677
+ log.error("Resume: transcriber worker failed")
678
+
598
679
  def _check_health(self) -> None:
599
680
  """Run one health check cycle."""
600
681
  # Check transcriber worker
@@ -644,7 +725,7 @@ class VoiceIO:
644
725
  log.warning("Tray subprocess died, restarting")
645
726
  tray.restart(self.on_hotkey)
646
727
 
647
- # Check IBus engine (restart if died)
728
+ # Check IBus engine (restart if died, re-activate if stale after resume)
648
729
  if self._typer.name == "ibus" and self._engine_proc is not None:
649
730
  if self._engine_proc.poll() is not None:
650
731
  log.warning("IBus engine process died (rc=%d), restarting",
@@ -655,6 +736,10 @@ class VoiceIO:
655
736
  log.info("IBus engine recovered")
656
737
  except Exception:
657
738
  log.exception("IBus engine recovery failed")
739
+ elif self._state == _State.IDLE:
740
+ # Engine alive but may have lost IBus registration after
741
+ # hibernate/suspend. Re-activate so preedit works next time.
742
+ self._reactivate_ibus_if_stale()
658
743
 
659
744
  # ── Main loop ───────────────────────────────────────────────────────
660
745
 
@@ -142,12 +142,12 @@ def _cmd_run(args: argparse.Namespace) -> None:
142
142
  elif args.no_notify_clipboard:
143
143
  cfg.feedback.notify_clipboard = False
144
144
 
145
- # Console: show voiceio messages at configured level
145
+ # Console: show voiceio messages at configured level (visible in journalctl)
146
146
  console = logging.StreamHandler()
147
147
  console.setFormatter(logging.Formatter(
148
148
  "%(asctime)s %(name)s %(levelname)s %(message)s", datefmt="%H:%M:%S",
149
149
  ))
150
- console.setLevel(logging.WARNING)
150
+ console.setLevel(getattr(logging, cfg.daemon.log_level))
151
151
 
152
152
  # File: always log DEBUG to rotating file
153
153
  from voiceio.config import LOG_DIR, LOG_PATH
@@ -57,6 +57,7 @@ Type=simple
57
57
  ExecStart={bin_path}
58
58
  Restart=on-failure
59
59
  RestartSec=3
60
+ Environment=PYTHONUNBUFFERED=1
60
61
  PassEnvironment=DISPLAY WAYLAND_DISPLAY XDG_SESSION_TYPE XDG_RUNTIME_DIR
61
62
 
62
63
  [Install]
@@ -384,10 +384,22 @@ class IBusTyper:
384
384
  except (OSError, socket.timeout):
385
385
  return True # assume focused on error (safe default: no extra paste)
386
386
 
387
+ @staticmethod
388
+ def _ydotoold_running() -> bool:
389
+ """Check if ydotoold daemon is running (required for reliable ydotool)."""
390
+ try:
391
+ result = subprocess.run(
392
+ ["pgrep", "-x", "ydotoold"],
393
+ capture_output=True, timeout=2,
394
+ )
395
+ return result.returncode == 0
396
+ except (FileNotFoundError, subprocess.TimeoutExpired):
397
+ return False
398
+
387
399
  def _simulate_paste(self) -> None:
388
400
  """Simulate Ctrl+V to paste clipboard into non-IBus apps (terminals)."""
389
401
  try:
390
- if self._ydotool:
402
+ if self._ydotool and self._ydotoold_running():
391
403
  # ydotool key scancodes: 29=LCtrl, 47=V
392
404
  subprocess.run(
393
405
  [self._ydotool, "key", "29:1", "47:1", "47:0", "29:0"],
@@ -399,7 +411,7 @@ class IBusTyper:
399
411
  capture_output=True, timeout=3,
400
412
  )
401
413
  else:
402
- log.debug("No paste tool available (ydotool/wtype)")
414
+ log.debug("No paste tool available (ydotoold not running, wtype not found)")
403
415
  except (subprocess.TimeoutExpired, OSError) as e:
404
416
  log.debug("Paste simulation failed: %s", e)
405
417
 
@@ -1 +0,0 @@
1
- __version__ = "0.3.3"
File without changes
File without changes
File without changes