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.
- {python_voiceio-0.3.3/python_voiceio.egg-info → python_voiceio-0.3.5}/PKG-INFO +1 -1
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/pyproject.toml +1 -1
- {python_voiceio-0.3.3 → python_voiceio-0.3.5/python_voiceio.egg-info}/PKG-INFO +1 -1
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/python_voiceio.egg-info/SOURCES.txt +1 -0
- python_voiceio-0.3.5/tests/test_robustness.py +526 -0
- python_voiceio-0.3.5/voiceio/__init__.py +1 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/app.py +86 -1
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/cli.py +2 -2
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/service.py +1 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/typers/ibus.py +14 -2
- python_voiceio-0.3.3/voiceio/__init__.py +0 -1
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/LICENSE +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/README.md +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/python_voiceio.egg-info/entry_points.txt +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/setup.cfg +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_app_wiring.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_autocorrect.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_backend_probes.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_clipboard_read.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_commands.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_config.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_corrections.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_fallback.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_health.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_hints.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_history.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_ibus_typer.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_llm.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_llm_api.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_numbers.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_platform.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_postprocess.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_prebuffer.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_prompt.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_streaming.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_transcriber.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_tts.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_vad.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_vocabulary.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/tests/test_wordfreq.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/__main__.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/autocorrect.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/backends.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/clipboard_read.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/commands.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/config.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/corrections.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/demo.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/feedback.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/health.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/hints.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/history.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/hotkeys/evdev.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/ibus/__init__.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/ibus/engine.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/llm.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/llm_api.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/models/__init__.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/models/silero_vad.onnx +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/numbers.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/pidlock.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/platform.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/postprocess.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/prompt.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/recorder.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/sounds/commit.wav +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/sounds/start.wav +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/streaming.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/transcriber.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/tray/__init__.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/tray/_icons.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/tray/_indicator.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/tray/_pystray.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/tts/__init__.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/tts/base.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/tts/chain.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/tts/edge_engine.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/tts/espeak.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/tts/piper_engine.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/tts/player.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/typers/base.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/typers/chain.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/typers/clipboard.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/vad.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/vocabulary.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/wizard.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/wordfreq.py +0 -0
- {python_voiceio-0.3.3 → python_voiceio-0.3.5}/voiceio/worker.py +0 -0
|
@@ -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.
|
|
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
|
|
@@ -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 (
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|