python-voiceio 0.3.3__tar.gz → 0.3.4__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.4}/PKG-INFO +1 -1
  2. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/pyproject.toml +1 -1
  3. {python_voiceio-0.3.3 → python_voiceio-0.3.4/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/python_voiceio.egg-info/SOURCES.txt +1 -0
  5. python_voiceio-0.3.4/tests/test_robustness.py +526 -0
  6. python_voiceio-0.3.4/voiceio/__init__.py +1 -0
  7. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/cli.py +2 -2
  8. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/service.py +1 -0
  9. python_voiceio-0.3.3/voiceio/__init__.py +0 -1
  10. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/LICENSE +0 -0
  11. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/README.md +0 -0
  12. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/python_voiceio.egg-info/dependency_links.txt +0 -0
  13. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/python_voiceio.egg-info/entry_points.txt +0 -0
  14. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/python_voiceio.egg-info/requires.txt +0 -0
  15. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/python_voiceio.egg-info/top_level.txt +0 -0
  16. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/setup.cfg +0 -0
  17. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_app_wiring.py +0 -0
  18. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_autocorrect.py +0 -0
  19. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_backend_probes.py +0 -0
  20. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_clipboard_read.py +0 -0
  21. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_commands.py +0 -0
  22. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_config.py +0 -0
  23. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_corrections.py +0 -0
  24. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_fallback.py +0 -0
  25. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_health.py +0 -0
  26. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_hints.py +0 -0
  27. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_history.py +0 -0
  28. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_ibus_typer.py +0 -0
  29. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_llm.py +0 -0
  30. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_llm_api.py +0 -0
  31. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_numbers.py +0 -0
  32. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_platform.py +0 -0
  33. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_postprocess.py +0 -0
  34. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_prebuffer.py +0 -0
  35. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_prompt.py +0 -0
  36. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_recorder_integration.py +0 -0
  37. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_streaming.py +0 -0
  38. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_transcriber.py +0 -0
  39. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_tts.py +0 -0
  40. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_vad.py +0 -0
  41. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_vocabulary.py +0 -0
  42. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/tests/test_wordfreq.py +0 -0
  43. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/__main__.py +0 -0
  44. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/app.py +0 -0
  45. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/autocorrect.py +0 -0
  46. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/backends.py +0 -0
  47. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/clipboard_read.py +0 -0
  48. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/commands.py +0 -0
  49. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/config.py +0 -0
  50. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/corrections.py +0 -0
  51. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/demo.py +0 -0
  52. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/feedback.py +0 -0
  53. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/health.py +0 -0
  54. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/hints.py +0 -0
  55. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/history.py +0 -0
  56. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/hotkeys/__init__.py +0 -0
  57. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/hotkeys/base.py +0 -0
  58. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/hotkeys/chain.py +0 -0
  59. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/hotkeys/evdev.py +0 -0
  60. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/hotkeys/pynput_backend.py +0 -0
  61. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/hotkeys/socket_backend.py +0 -0
  62. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/ibus/__init__.py +0 -0
  63. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/ibus/engine.py +0 -0
  64. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/llm.py +0 -0
  65. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/llm_api.py +0 -0
  66. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/models/__init__.py +0 -0
  67. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/models/silero_vad.onnx +0 -0
  68. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/numbers.py +0 -0
  69. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/pidlock.py +0 -0
  70. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/platform.py +0 -0
  71. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/postprocess.py +0 -0
  72. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/prompt.py +0 -0
  73. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/recorder.py +0 -0
  74. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/sounds/__init__.py +0 -0
  75. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/sounds/commit.wav +0 -0
  76. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/sounds/start.wav +0 -0
  77. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/sounds/stop.wav +0 -0
  78. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/streaming.py +0 -0
  79. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/transcriber.py +0 -0
  80. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/tray/__init__.py +0 -0
  81. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/tray/_icons.py +0 -0
  82. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/tray/_indicator.py +0 -0
  83. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/tray/_pystray.py +0 -0
  84. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/tts/__init__.py +0 -0
  85. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/tts/base.py +0 -0
  86. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/tts/chain.py +0 -0
  87. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/tts/edge_engine.py +0 -0
  88. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/tts/espeak.py +0 -0
  89. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/tts/piper_engine.py +0 -0
  90. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/tts/player.py +0 -0
  91. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/typers/__init__.py +0 -0
  92. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/typers/base.py +0 -0
  93. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/typers/chain.py +0 -0
  94. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/typers/clipboard.py +0 -0
  95. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/typers/ibus.py +0 -0
  96. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/typers/pynput_type.py +0 -0
  97. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/typers/wtype.py +0 -0
  98. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/typers/xdotool.py +0 -0
  99. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/typers/ydotool.py +0 -0
  100. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/vad.py +0 -0
  101. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/vocabulary.py +0 -0
  102. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/wizard.py +0 -0
  103. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/voiceio/wordfreq.py +0 -0
  104. {python_voiceio-0.3.3 → python_voiceio-0.3.4}/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.4
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.4"
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.4
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.4"
@@ -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]
@@ -1 +0,0 @@
1
- __version__ = "0.3.3"
File without changes
File without changes
File without changes