python-voiceio 0.3.1__tar.gz → 0.3.3__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.1/python_voiceio.egg-info → python_voiceio-0.3.3}/PKG-INFO +10 -3
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/README.md +9 -2
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/pyproject.toml +1 -1
- {python_voiceio-0.3.1 → python_voiceio-0.3.3/python_voiceio.egg-info}/PKG-INFO +10 -3
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_app_wiring.py +6 -1
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_tts.py +2 -2
- python_voiceio-0.3.3/voiceio/__init__.py +1 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/app.py +85 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/config.py +1 -1
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/ibus/engine.py +10 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/numbers.py +9 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/recorder.py +54 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/service.py +1 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/streaming.py +8 -2
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/tray/__init__.py +48 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/tts/edge_engine.py +14 -1
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/tts/piper_engine.py +3 -2
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/typers/ibus.py +41 -1
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/wizard.py +310 -167
- python_voiceio-0.3.1/voiceio/__init__.py +0 -1
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/LICENSE +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/python_voiceio.egg-info/SOURCES.txt +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/python_voiceio.egg-info/entry_points.txt +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/setup.cfg +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_autocorrect.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_backend_probes.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_clipboard_read.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_commands.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_config.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_corrections.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_fallback.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_health.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_hints.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_history.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_ibus_typer.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_llm.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_llm_api.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_numbers.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_platform.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_postprocess.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_prebuffer.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_prompt.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_streaming.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_transcriber.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_vad.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_vocabulary.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/tests/test_wordfreq.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/__main__.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/autocorrect.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/backends.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/cli.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/clipboard_read.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/commands.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/corrections.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/demo.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/feedback.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/health.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/hints.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/history.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/hotkeys/evdev.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/ibus/__init__.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/llm.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/llm_api.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/models/__init__.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/models/silero_vad.onnx +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/pidlock.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/platform.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/postprocess.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/prompt.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/sounds/commit.wav +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/sounds/start.wav +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/transcriber.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/tray/_icons.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/tray/_indicator.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/tray/_pystray.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/tts/__init__.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/tts/base.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/tts/chain.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/tts/espeak.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/tts/player.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/typers/base.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/typers/chain.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/typers/clipboard.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/vad.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/vocabulary.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/voiceio/wordfreq.py +0 -0
- {python_voiceio-0.3.1 → python_voiceio-0.3.3}/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
|
+
Version: 0.3.3
|
|
4
4
|
Summary: Speak → text, locally, instantly.
|
|
5
5
|
Author: Hugo Montenegro
|
|
6
6
|
License-Expression: MIT
|
|
@@ -56,6 +56,7 @@ Dynamic: license-file
|
|
|
56
56
|
[](https://pypi.org/project/python-voiceio/)
|
|
57
57
|
[](https://pypi.org/project/python-voiceio/)
|
|
58
58
|
[](LICENSE)
|
|
59
|
+
[](https://pepy.tech/projects/python-voiceio)
|
|
59
60
|
|
|
60
61
|
Speak → text, locally, instantly.
|
|
61
62
|
|
|
@@ -153,6 +154,10 @@ Press your hotkey to start recording (1s pre-buffer catches the first syllable).
|
|
|
153
154
|
- **Works everywhere**: IBus input method for GUI apps, clipboard for terminals
|
|
154
155
|
- **Wayland + X11**: evdev hotkeys work on both, no root required
|
|
155
156
|
- **Pre-buffer**: never miss the first syllable
|
|
157
|
+
- **Voice commands**: "new line", "comma", "scratch that", punctuation by name
|
|
158
|
+
- **Autocorrect**: LLM-powered review of recurring Whisper mistakes (`voiceio correct`)
|
|
159
|
+
- **Text-to-speech**: hear selected text spoken back (Piper, eSpeak, Edge TTS)
|
|
160
|
+
- **Smart post-processing**: numbers ("twenty five" → "25"), punctuation, capitalization
|
|
156
161
|
- **Auto-healing**: falls back to the next working backend if one fails
|
|
157
162
|
- **Autostart**: optional systemd service, restarts on crash
|
|
158
163
|
- **Self-diagnosing**: `voiceio doctor` checks everything, `--fix` repairs it
|
|
@@ -176,7 +181,10 @@ voiceio Start the daemon
|
|
|
176
181
|
voiceio setup Interactive setup wizard
|
|
177
182
|
voiceio doctor Health check (--fix to auto-repair)
|
|
178
183
|
voiceio test Test microphone + live transcription
|
|
184
|
+
voiceio demo Interactive guided tour of all features
|
|
179
185
|
voiceio toggle Toggle recording on a running daemon
|
|
186
|
+
voiceio correct Review and fix recurring transcription errors
|
|
187
|
+
voiceio history View transcription history
|
|
180
188
|
voiceio update Update to latest version
|
|
181
189
|
voiceio service install Autostart on login (systemd / Windows Startup)
|
|
182
190
|
voiceio logs View recent logs
|
|
@@ -250,9 +258,8 @@ Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) and [open issues](
|
|
|
250
258
|
- [ ] Multiple engine backends (whisper.cpp for Vulkan/AMD, VOSK for low-end hardware)
|
|
251
259
|
- [ ] Echo cancellation (filter system audio for meeting use)
|
|
252
260
|
- [ ] Wake word activation ("Hey voiceio")
|
|
253
|
-
- [ ] Text-to-speech output (Piper/espeak-ng — completes the "io")
|
|
254
|
-
|
|
255
261
|
**Done**
|
|
262
|
+
- [x] Text-to-speech output (Piper/eSpeak/Edge TTS — completes the "io")
|
|
256
263
|
- [x] LLM auto-audit dictionary (`voiceio correct --auto` — scan history with LLM, interactive correction)
|
|
257
264
|
- [x] LLM post-processing via Ollama (grammar cleanup, spelling fixes on final pass)
|
|
258
265
|
- [x] Corrections dictionary — auto-replace misheard words, "correct that" voice command
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
[](https://pypi.org/project/python-voiceio/)
|
|
5
5
|
[](https://pypi.org/project/python-voiceio/)
|
|
6
6
|
[](LICENSE)
|
|
7
|
+
[](https://pepy.tech/projects/python-voiceio)
|
|
7
8
|
|
|
8
9
|
Speak → text, locally, instantly.
|
|
9
10
|
|
|
@@ -101,6 +102,10 @@ Press your hotkey to start recording (1s pre-buffer catches the first syllable).
|
|
|
101
102
|
- **Works everywhere**: IBus input method for GUI apps, clipboard for terminals
|
|
102
103
|
- **Wayland + X11**: evdev hotkeys work on both, no root required
|
|
103
104
|
- **Pre-buffer**: never miss the first syllable
|
|
105
|
+
- **Voice commands**: "new line", "comma", "scratch that", punctuation by name
|
|
106
|
+
- **Autocorrect**: LLM-powered review of recurring Whisper mistakes (`voiceio correct`)
|
|
107
|
+
- **Text-to-speech**: hear selected text spoken back (Piper, eSpeak, Edge TTS)
|
|
108
|
+
- **Smart post-processing**: numbers ("twenty five" → "25"), punctuation, capitalization
|
|
104
109
|
- **Auto-healing**: falls back to the next working backend if one fails
|
|
105
110
|
- **Autostart**: optional systemd service, restarts on crash
|
|
106
111
|
- **Self-diagnosing**: `voiceio doctor` checks everything, `--fix` repairs it
|
|
@@ -124,7 +129,10 @@ voiceio Start the daemon
|
|
|
124
129
|
voiceio setup Interactive setup wizard
|
|
125
130
|
voiceio doctor Health check (--fix to auto-repair)
|
|
126
131
|
voiceio test Test microphone + live transcription
|
|
132
|
+
voiceio demo Interactive guided tour of all features
|
|
127
133
|
voiceio toggle Toggle recording on a running daemon
|
|
134
|
+
voiceio correct Review and fix recurring transcription errors
|
|
135
|
+
voiceio history View transcription history
|
|
128
136
|
voiceio update Update to latest version
|
|
129
137
|
voiceio service install Autostart on login (systemd / Windows Startup)
|
|
130
138
|
voiceio logs View recent logs
|
|
@@ -198,9 +206,8 @@ Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) and [open issues](
|
|
|
198
206
|
- [ ] Multiple engine backends (whisper.cpp for Vulkan/AMD, VOSK for low-end hardware)
|
|
199
207
|
- [ ] Echo cancellation (filter system audio for meeting use)
|
|
200
208
|
- [ ] Wake word activation ("Hey voiceio")
|
|
201
|
-
- [ ] Text-to-speech output (Piper/espeak-ng — completes the "io")
|
|
202
|
-
|
|
203
209
|
**Done**
|
|
210
|
+
- [x] Text-to-speech output (Piper/eSpeak/Edge TTS — completes the "io")
|
|
204
211
|
- [x] LLM auto-audit dictionary (`voiceio correct --auto` — scan history with LLM, interactive correction)
|
|
205
212
|
- [x] LLM post-processing via Ollama (grammar cleanup, spelling fixes on final pass)
|
|
206
213
|
- [x] Corrections dictionary — auto-replace misheard words, "correct that" voice command
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-voiceio
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: Speak → text, locally, instantly.
|
|
5
5
|
Author: Hugo Montenegro
|
|
6
6
|
License-Expression: MIT
|
|
@@ -56,6 +56,7 @@ Dynamic: license-file
|
|
|
56
56
|
[](https://pypi.org/project/python-voiceio/)
|
|
57
57
|
[](https://pypi.org/project/python-voiceio/)
|
|
58
58
|
[](LICENSE)
|
|
59
|
+
[](https://pepy.tech/projects/python-voiceio)
|
|
59
60
|
|
|
60
61
|
Speak → text, locally, instantly.
|
|
61
62
|
|
|
@@ -153,6 +154,10 @@ Press your hotkey to start recording (1s pre-buffer catches the first syllable).
|
|
|
153
154
|
- **Works everywhere**: IBus input method for GUI apps, clipboard for terminals
|
|
154
155
|
- **Wayland + X11**: evdev hotkeys work on both, no root required
|
|
155
156
|
- **Pre-buffer**: never miss the first syllable
|
|
157
|
+
- **Voice commands**: "new line", "comma", "scratch that", punctuation by name
|
|
158
|
+
- **Autocorrect**: LLM-powered review of recurring Whisper mistakes (`voiceio correct`)
|
|
159
|
+
- **Text-to-speech**: hear selected text spoken back (Piper, eSpeak, Edge TTS)
|
|
160
|
+
- **Smart post-processing**: numbers ("twenty five" → "25"), punctuation, capitalization
|
|
156
161
|
- **Auto-healing**: falls back to the next working backend if one fails
|
|
157
162
|
- **Autostart**: optional systemd service, restarts on crash
|
|
158
163
|
- **Self-diagnosing**: `voiceio doctor` checks everything, `--fix` repairs it
|
|
@@ -176,7 +181,10 @@ voiceio Start the daemon
|
|
|
176
181
|
voiceio setup Interactive setup wizard
|
|
177
182
|
voiceio doctor Health check (--fix to auto-repair)
|
|
178
183
|
voiceio test Test microphone + live transcription
|
|
184
|
+
voiceio demo Interactive guided tour of all features
|
|
179
185
|
voiceio toggle Toggle recording on a running daemon
|
|
186
|
+
voiceio correct Review and fix recurring transcription errors
|
|
187
|
+
voiceio history View transcription history
|
|
180
188
|
voiceio update Update to latest version
|
|
181
189
|
voiceio service install Autostart on login (systemd / Windows Startup)
|
|
182
190
|
voiceio logs View recent logs
|
|
@@ -250,9 +258,8 @@ Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) and [open issues](
|
|
|
250
258
|
- [ ] Multiple engine backends (whisper.cpp for Vulkan/AMD, VOSK for low-end hardware)
|
|
251
259
|
- [ ] Echo cancellation (filter system audio for meeting use)
|
|
252
260
|
- [ ] Wake word activation ("Hey voiceio")
|
|
253
|
-
- [ ] Text-to-speech output (Piper/espeak-ng — completes the "io")
|
|
254
|
-
|
|
255
261
|
**Done**
|
|
262
|
+
- [x] Text-to-speech output (Piper/eSpeak/Edge TTS — completes the "io")
|
|
256
263
|
- [x] LLM auto-audit dictionary (`voiceio correct --auto` — scan history with LLM, interactive correction)
|
|
257
264
|
- [x] LLM post-processing via Ollama (grammar cleanup, spelling fixes on final pass)
|
|
258
265
|
- [x] Corrections dictionary — auto-replace misheard words, "correct that" voice command
|
|
@@ -28,7 +28,12 @@ def _make_vio(mock_transcriber=None):
|
|
|
28
28
|
|
|
29
29
|
from voiceio.app import VoiceIO
|
|
30
30
|
vio = VoiceIO(Config())
|
|
31
|
-
|
|
31
|
+
mock_stream = MagicMock()
|
|
32
|
+
mock_stream.active = True
|
|
33
|
+
mock_stream.closed = False
|
|
34
|
+
mock_stream.stopped = False
|
|
35
|
+
vio.recorder._stream = mock_stream # skip real audio
|
|
36
|
+
vio.recorder._last_callback_time = time.monotonic() # healthy heartbeat
|
|
32
37
|
return vio, mock_typer, mock_transcriber
|
|
33
38
|
|
|
34
39
|
|
|
@@ -142,7 +142,7 @@ def test_player_empty_audio():
|
|
|
142
142
|
|
|
143
143
|
def test_tts_config_defaults():
|
|
144
144
|
cfg = TTSConfig()
|
|
145
|
-
assert cfg.enabled is
|
|
145
|
+
assert cfg.enabled is True
|
|
146
146
|
assert cfg.engine == "auto"
|
|
147
147
|
assert cfg.hotkey == "ctrl+alt+s"
|
|
148
148
|
assert cfg.voice == ""
|
|
@@ -155,4 +155,4 @@ def test_tts_config_in_main_config():
|
|
|
155
155
|
cfg = Config()
|
|
156
156
|
assert hasattr(cfg, "tts")
|
|
157
157
|
assert isinstance(cfg.tts, TTSConfig)
|
|
158
|
-
assert cfg.tts.enabled is
|
|
158
|
+
assert cfg.tts.enabled is True
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.3"
|
|
@@ -8,6 +8,7 @@ import signal
|
|
|
8
8
|
import subprocess
|
|
9
9
|
import threading
|
|
10
10
|
import time
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
11
12
|
|
|
12
13
|
import numpy as np
|
|
13
14
|
|
|
@@ -22,6 +23,9 @@ from voiceio.transcriber import Transcriber
|
|
|
22
23
|
from voiceio.typers import chain as typer_chain
|
|
23
24
|
from voiceio.vad import load_vad
|
|
24
25
|
from voiceio.vocabulary import load_vocabulary
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from voiceio.typers.base import TyperBackend
|
|
25
29
|
log = logging.getLogger("voiceio")
|
|
26
30
|
_DEBOUNCE_SECS = 0.3
|
|
27
31
|
|
|
@@ -117,6 +121,10 @@ class VoiceIO:
|
|
|
117
121
|
self._engine_proc: subprocess.Popen | None = None
|
|
118
122
|
self._shutdown = threading.Event()
|
|
119
123
|
|
|
124
|
+
# Audio stream recovery backoff
|
|
125
|
+
self._stream_fail_count = 0
|
|
126
|
+
self._next_stream_retry: float = 0
|
|
127
|
+
|
|
120
128
|
def request_shutdown(self) -> None:
|
|
121
129
|
"""Request graceful shutdown from an external signal handler."""
|
|
122
130
|
self._shutdown.set()
|
|
@@ -169,6 +177,19 @@ class VoiceIO:
|
|
|
169
177
|
|
|
170
178
|
def _do_start(self) -> None:
|
|
171
179
|
"""Transition to RECORDING."""
|
|
180
|
+
# Pre-flight: ensure audio stream is healthy before recording
|
|
181
|
+
ok, reason = self.recorder.stream_health()
|
|
182
|
+
if not ok:
|
|
183
|
+
log.warning("Audio stream unhealthy before recording: %s — reopening", reason)
|
|
184
|
+
try:
|
|
185
|
+
self.recorder.reopen_stream()
|
|
186
|
+
except Exception:
|
|
187
|
+
log.exception("Cannot reopen audio stream, aborting recording")
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
if not self.recorder.has_signal():
|
|
191
|
+
log.warning("Mic appears silent or muted (pre-buffer is all zeros)")
|
|
192
|
+
|
|
172
193
|
self._state = _State.RECORDING
|
|
173
194
|
self._activate_ibus()
|
|
174
195
|
self._corrections.load() # hot-reload corrections on each recording
|
|
@@ -452,6 +473,30 @@ class VoiceIO:
|
|
|
452
473
|
self._set_gnome_input_source_index(0)
|
|
453
474
|
log.info("VoiceIO IBus engine ready (dormant until recording)")
|
|
454
475
|
|
|
476
|
+
def _wait_for_ibus(self, chain: list[str]) -> TyperBackend | None:
|
|
477
|
+
"""Wait for IBus daemon to become available and switch to IBus typer.
|
|
478
|
+
|
|
479
|
+
At session startup, voiceio may start before ibus-daemon is ready.
|
|
480
|
+
This polls briefly so we can use IBus instead of a broken fallback.
|
|
481
|
+
"""
|
|
482
|
+
from voiceio.typers.ibus import _ibus_daemon_running
|
|
483
|
+
|
|
484
|
+
log.info("IBus preferred but not available yet, waiting for ibus-daemon...")
|
|
485
|
+
for i in range(30): # up to ~15 seconds
|
|
486
|
+
time.sleep(0.5)
|
|
487
|
+
if _ibus_daemon_running():
|
|
488
|
+
log.info("IBus daemon ready after %.1fs, re-probing typers", (i + 1) * 0.5)
|
|
489
|
+
try:
|
|
490
|
+
typer = typer_chain.select(self.platform, self.cfg.output.method)
|
|
491
|
+
if typer.name == "ibus":
|
|
492
|
+
self._ensure_ibus_engine()
|
|
493
|
+
return typer
|
|
494
|
+
except RuntimeError:
|
|
495
|
+
pass
|
|
496
|
+
break
|
|
497
|
+
log.warning("IBus daemon did not start in time, using fallback typer: %s", self._typer.name)
|
|
498
|
+
return None
|
|
499
|
+
|
|
455
500
|
@staticmethod
|
|
456
501
|
def _kill_stale_engine(socket_path) -> None:
|
|
457
502
|
socket_path.unlink(missing_ok=True)
|
|
@@ -566,6 +611,39 @@ class VoiceIO:
|
|
|
566
611
|
self._state = _State.ERROR
|
|
567
612
|
tray.set_error(True)
|
|
568
613
|
|
|
614
|
+
# Check audio stream (covers ALSA underrun, PulseAudio/PipeWire
|
|
615
|
+
# restart, device disconnect, stale callback heartbeat)
|
|
616
|
+
ok, reason = self.recorder.stream_health()
|
|
617
|
+
if not ok:
|
|
618
|
+
now = time.monotonic()
|
|
619
|
+
if now < self._next_stream_retry:
|
|
620
|
+
return # backoff: skip this cycle
|
|
621
|
+
log.warning("Audio stream unhealthy: %s — reopening (attempt %d)",
|
|
622
|
+
reason, self._stream_fail_count + 1)
|
|
623
|
+
try:
|
|
624
|
+
self.recorder.reopen_stream()
|
|
625
|
+
self._stream_fail_count = 0
|
|
626
|
+
self._next_stream_retry = 0
|
|
627
|
+
tray.set_error(False)
|
|
628
|
+
log.info("Audio stream recovered")
|
|
629
|
+
except Exception:
|
|
630
|
+
self._stream_fail_count += 1
|
|
631
|
+
# Backoff: 10s, 20s, 40s, 80s, max 5min
|
|
632
|
+
delay = min(10 * (2 ** (self._stream_fail_count - 1)), 300)
|
|
633
|
+
self._next_stream_retry = now + delay
|
|
634
|
+
tray.set_error(True)
|
|
635
|
+
log.error("Audio stream recovery failed (retry in %ds)", delay)
|
|
636
|
+
elif self._stream_fail_count > 0:
|
|
637
|
+
# Stream recovered externally (e.g. device plugged back in)
|
|
638
|
+
self._stream_fail_count = 0
|
|
639
|
+
self._next_stream_retry = 0
|
|
640
|
+
tray.set_error(False)
|
|
641
|
+
|
|
642
|
+
# Check tray subprocess (restart if died / lost D-Bus registration)
|
|
643
|
+
if self.cfg.tray.enabled and not tray.is_alive():
|
|
644
|
+
log.warning("Tray subprocess died, restarting")
|
|
645
|
+
tray.restart(self.on_hotkey)
|
|
646
|
+
|
|
569
647
|
# Check IBus engine (restart if died)
|
|
570
648
|
if self._typer.name == "ibus" and self._engine_proc is not None:
|
|
571
649
|
if self._engine_proc.poll() is not None:
|
|
@@ -598,6 +676,13 @@ class VoiceIO:
|
|
|
598
676
|
|
|
599
677
|
if self._typer.name == "ibus":
|
|
600
678
|
self._ensure_ibus_engine()
|
|
679
|
+
else:
|
|
680
|
+
# IBus daemon may not be ready at startup (race with graphical
|
|
681
|
+
# session). If IBus is in the preferred chain but wasn't selected,
|
|
682
|
+
# wait briefly and re-probe.
|
|
683
|
+
chain = typer_chain._get_chain(self.platform)
|
|
684
|
+
if "ibus" in chain and self._typer.name != "ibus":
|
|
685
|
+
self._typer = self._wait_for_ibus(chain) or self._typer
|
|
601
686
|
|
|
602
687
|
self.recorder.open_stream()
|
|
603
688
|
|
|
@@ -105,7 +105,7 @@ class AutocorrectConfig:
|
|
|
105
105
|
|
|
106
106
|
@dataclass
|
|
107
107
|
class TTSConfig:
|
|
108
|
-
enabled: bool =
|
|
108
|
+
enabled: bool = True
|
|
109
109
|
engine: str = "auto" # "auto" | "piper" | "espeak" | "edge-tts"
|
|
110
110
|
hotkey: str = "ctrl+alt+s" # "s" for speak
|
|
111
111
|
voice: str = "" # empty = engine default
|
|
@@ -147,6 +147,16 @@ def _socket_listener(mainloop: GLib.MainLoop) -> None:
|
|
|
147
147
|
pass
|
|
148
148
|
continue
|
|
149
149
|
|
|
150
|
+
if msg == "focus?":
|
|
151
|
+
# Quick focus query — no need to go through GLib
|
|
152
|
+
if addr and _engine is not None:
|
|
153
|
+
reply = b"focused" if _engine._focused else b"unfocused"
|
|
154
|
+
try:
|
|
155
|
+
sock.sendto(reply, addr)
|
|
156
|
+
except OSError:
|
|
157
|
+
pass
|
|
158
|
+
continue
|
|
159
|
+
|
|
150
160
|
# Dispatch to engine on GLib main thread
|
|
151
161
|
GLib.idle_add(_handle_command, msg)
|
|
152
162
|
|
|
@@ -142,6 +142,7 @@ def convert_numbers(text: str, language: str = "en") -> str:
|
|
|
142
142
|
# Collect consecutive number words
|
|
143
143
|
if _is_number_word(low) and low != "a" and low != "and":
|
|
144
144
|
num_words = []
|
|
145
|
+
last_category = None # "ones", "tens", "scale"
|
|
145
146
|
j = i
|
|
146
147
|
while j < len(words):
|
|
147
148
|
w = words[j].lower().rstrip(".,;:?!")
|
|
@@ -153,6 +154,7 @@ def convert_numbers(text: str, language: str = "en") -> str:
|
|
|
153
154
|
# "a" at start: only if followed by scale word
|
|
154
155
|
if j + 1 < len(words) and words[j + 1].lower().rstrip(".,;:?!") in _SCALES:
|
|
155
156
|
num_words.append(w)
|
|
157
|
+
last_category = "ones"
|
|
156
158
|
j += 1
|
|
157
159
|
continue
|
|
158
160
|
break
|
|
@@ -163,7 +165,14 @@ def convert_numbers(text: str, language: str = "en") -> str:
|
|
|
163
165
|
j += 1
|
|
164
166
|
continue
|
|
165
167
|
break
|
|
168
|
+
# Two consecutive ones-words = separate numbers
|
|
169
|
+
# e.g. "one two three" should NOT become 6
|
|
170
|
+
# But "twenty three", "one hundred", "thirteen thousand" are valid
|
|
171
|
+
cat = "scale" if w in _SCALES else ("tens" if w in _TENS else "ones")
|
|
172
|
+
if cat == "ones" and last_category == "ones":
|
|
173
|
+
break
|
|
166
174
|
num_words.append(w)
|
|
175
|
+
last_category = cat
|
|
167
176
|
j += 1
|
|
168
177
|
else:
|
|
169
178
|
break
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import logging
|
|
5
5
|
import threading
|
|
6
|
+
import time
|
|
6
7
|
from typing import TYPE_CHECKING, Callable
|
|
7
8
|
|
|
8
9
|
import numpy as np
|
|
@@ -107,6 +108,9 @@ class AudioRecorder:
|
|
|
107
108
|
self._heard_speech = False
|
|
108
109
|
self._on_auto_stop: Callable[[], None] | None = None
|
|
109
110
|
|
|
111
|
+
# Heartbeat: updated by _callback, checked by health watchdog
|
|
112
|
+
self._last_callback_time: float = 0.0
|
|
113
|
+
|
|
110
114
|
def open_stream(self) -> None:
|
|
111
115
|
"""Start the always-on audio stream (feeds ring buffer)."""
|
|
112
116
|
if self._stream is not None:
|
|
@@ -121,6 +125,54 @@ class AudioRecorder:
|
|
|
121
125
|
self._stream.start()
|
|
122
126
|
log.debug("Audio stream opened (prebuffer=%.1fs)", self.prebuffer_secs)
|
|
123
127
|
|
|
128
|
+
# Maximum seconds between callbacks before we consider the stream dead.
|
|
129
|
+
# PortAudio typically fires every ~50-100ms; 5s covers long system sleeps.
|
|
130
|
+
_HEARTBEAT_TIMEOUT = 5.0
|
|
131
|
+
|
|
132
|
+
def stream_health(self) -> tuple[bool, str]:
|
|
133
|
+
"""Check audio stream health. Returns (ok, reason).
|
|
134
|
+
|
|
135
|
+
Failure modes detected:
|
|
136
|
+
1. Stream object gone or closed — device removed, close_stream() bug
|
|
137
|
+
2. stream.active is False — ALSA underrun, PulseAudio restart
|
|
138
|
+
3. stream.stopped is True — PortAudio callback abort/error
|
|
139
|
+
4. Callback heartbeat stale — stream "active" but no callbacks
|
|
140
|
+
(device silently disconnected, PipeWire graph change, driver bug)
|
|
141
|
+
"""
|
|
142
|
+
if self._stream is None:
|
|
143
|
+
return False, "stream is None"
|
|
144
|
+
if self._stream.closed:
|
|
145
|
+
return False, "stream closed"
|
|
146
|
+
if not self._stream.active:
|
|
147
|
+
return False, "stream not active"
|
|
148
|
+
if self._stream.stopped:
|
|
149
|
+
return False, "stream stopped"
|
|
150
|
+
if self._last_callback_time > 0:
|
|
151
|
+
stale = time.monotonic() - self._last_callback_time
|
|
152
|
+
if stale > self._HEARTBEAT_TIMEOUT:
|
|
153
|
+
return False, f"no audio callback for {stale:.1f}s"
|
|
154
|
+
return True, ""
|
|
155
|
+
|
|
156
|
+
def has_signal(self) -> bool:
|
|
157
|
+
"""Check if the pre-buffer ring contains non-silence audio.
|
|
158
|
+
|
|
159
|
+
Returns False if the ring is all zeros (mic muted, wrong device,
|
|
160
|
+
or stream not delivering real data).
|
|
161
|
+
"""
|
|
162
|
+
buf = self._ring.get()
|
|
163
|
+
if len(buf) == 0:
|
|
164
|
+
return False
|
|
165
|
+
# Check if peak amplitude is above a very low floor (~-80 dB).
|
|
166
|
+
# Even "silence" from a working mic has some noise > 1e-4.
|
|
167
|
+
return float(np.max(np.abs(buf))) > 1e-4
|
|
168
|
+
|
|
169
|
+
def reopen_stream(self) -> None:
|
|
170
|
+
"""Close and reopen the audio stream (recovery from audio errors)."""
|
|
171
|
+
log.info("Reopening audio stream")
|
|
172
|
+
self.close_stream()
|
|
173
|
+
self._last_callback_time = 0.0
|
|
174
|
+
self.open_stream()
|
|
175
|
+
|
|
124
176
|
def close_stream(self) -> None:
|
|
125
177
|
"""Stop the always-on audio stream."""
|
|
126
178
|
if self._stream is not None:
|
|
@@ -201,6 +253,8 @@ class AudioRecorder:
|
|
|
201
253
|
def _callback(
|
|
202
254
|
self, indata: np.ndarray, frames: int, time_info: object, status: object
|
|
203
255
|
) -> None:
|
|
256
|
+
self._last_callback_time = time.monotonic()
|
|
257
|
+
|
|
204
258
|
if status:
|
|
205
259
|
log.warning("Audio stream status: %s", status)
|
|
206
260
|
|
|
@@ -135,10 +135,16 @@ class StreamingSession:
|
|
|
135
135
|
self._pending.wait(timeout=1.0)
|
|
136
136
|
if self._stop_event.is_set():
|
|
137
137
|
break
|
|
138
|
-
|
|
138
|
+
try:
|
|
139
|
+
self._transcribe_and_apply()
|
|
140
|
+
except Exception:
|
|
141
|
+
log.exception("Streaming transcribe/apply error (non-fatal)")
|
|
139
142
|
|
|
140
143
|
# Final transcription on stop using the audio snapshot
|
|
141
|
-
|
|
144
|
+
try:
|
|
145
|
+
self._transcribe_and_apply(min_seconds=0.5, final=True)
|
|
146
|
+
except Exception:
|
|
147
|
+
log.exception("Final transcribe/apply error")
|
|
142
148
|
self._final_audio = None # release memory
|
|
143
149
|
|
|
144
150
|
def _transcribe_and_apply(
|
|
@@ -257,6 +257,54 @@ def set_error(error: bool) -> None:
|
|
|
257
257
|
set_title("voiceio - error" if error else "voiceio - idle")
|
|
258
258
|
|
|
259
259
|
|
|
260
|
+
def is_alive() -> bool:
|
|
261
|
+
"""Check if the tray subprocess is still running (indicator backend only)."""
|
|
262
|
+
if _backend == "indicator":
|
|
263
|
+
return _proc is not None and _proc.poll() is None
|
|
264
|
+
# pystray runs in-process, assume alive if backend is set
|
|
265
|
+
return _backend is not None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def restart(toggle_callback: Callable[[], None] | None = None) -> bool:
|
|
269
|
+
"""Restart a dead indicator subprocess. Returns True on success."""
|
|
270
|
+
global _proc, _stdout_thread
|
|
271
|
+
|
|
272
|
+
if _backend != "indicator":
|
|
273
|
+
return False
|
|
274
|
+
if _proc is not None and _proc.poll() is None:
|
|
275
|
+
return True # still alive
|
|
276
|
+
|
|
277
|
+
system_python = _find_system_python()
|
|
278
|
+
if not system_python or _theme_dir is None:
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
from voiceio.tray._icons import ANIM_INTERVAL_MS
|
|
282
|
+
# Read icon names from existing theme dir
|
|
283
|
+
apps_dir = _theme_dir / "hicolor"
|
|
284
|
+
icon_names: list[str] = []
|
|
285
|
+
for d in apps_dir.rglob("apps"):
|
|
286
|
+
icon_names = sorted(
|
|
287
|
+
p.stem for p in d.glob("*.png")
|
|
288
|
+
)
|
|
289
|
+
break
|
|
290
|
+
if not icon_names:
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
log.info("Restarting tray indicator subprocess")
|
|
294
|
+
_proc = _start_indicator(
|
|
295
|
+
_theme_dir, icon_names, ANIM_INTERVAL_MS, system_python,
|
|
296
|
+
)
|
|
297
|
+
if toggle_callback is not None:
|
|
298
|
+
_stdout_thread = threading.Thread(
|
|
299
|
+
target=_read_stdout,
|
|
300
|
+
args=(_proc, toggle_callback),
|
|
301
|
+
daemon=True,
|
|
302
|
+
name="tray-stdout",
|
|
303
|
+
)
|
|
304
|
+
_stdout_thread.start()
|
|
305
|
+
return True
|
|
306
|
+
|
|
307
|
+
|
|
260
308
|
def stop() -> None:
|
|
261
309
|
"""Stop the tray icon and clean up."""
|
|
262
310
|
global _proc, _backend, _stdout_thread
|
|
@@ -19,12 +19,25 @@ class EdgeEngine:
|
|
|
19
19
|
def probe(self) -> ProbeResult:
|
|
20
20
|
try:
|
|
21
21
|
import edge_tts # noqa: F401
|
|
22
|
-
return ProbeResult(ok=True)
|
|
23
22
|
except ImportError:
|
|
24
23
|
return ProbeResult(
|
|
25
24
|
ok=False, reason="edge-tts not installed",
|
|
26
25
|
fix_hint="pip install edge-tts",
|
|
27
26
|
)
|
|
27
|
+
try:
|
|
28
|
+
import soundfile # noqa: F401
|
|
29
|
+
return ProbeResult(ok=True)
|
|
30
|
+
except ImportError:
|
|
31
|
+
pass
|
|
32
|
+
try:
|
|
33
|
+
import pydub # noqa: F401
|
|
34
|
+
return ProbeResult(ok=True)
|
|
35
|
+
except ImportError:
|
|
36
|
+
return ProbeResult(
|
|
37
|
+
ok=False,
|
|
38
|
+
reason="edge-tts needs soundfile or pydub to decode audio",
|
|
39
|
+
fix_hint="pip install soundfile",
|
|
40
|
+
)
|
|
28
41
|
|
|
29
42
|
def synthesize(self, text: str, voice: str, speed: float) -> tuple[np.ndarray, int]:
|
|
30
43
|
import asyncio
|
|
@@ -22,10 +22,11 @@ class PiperEngine:
|
|
|
22
22
|
def probe(self) -> ProbeResult:
|
|
23
23
|
try:
|
|
24
24
|
import piper # noqa: F401
|
|
25
|
+
from piper.download import ensure_voice_exists, get_voices # noqa: F401
|
|
25
26
|
return ProbeResult(ok=True)
|
|
26
|
-
except ImportError:
|
|
27
|
+
except ImportError as e:
|
|
27
28
|
return ProbeResult(
|
|
28
|
-
ok=False, reason="piper-tts not installed",
|
|
29
|
+
ok=False, reason=f"piper-tts not fully installed: {e}",
|
|
29
30
|
fix_hint="pip install piper-tts",
|
|
30
31
|
)
|
|
31
32
|
|
|
@@ -274,13 +274,17 @@ class IBusTyper:
|
|
|
274
274
|
"""IBus preedit for streaming preview, IBus commit for final text.
|
|
275
275
|
|
|
276
276
|
Also copies committed text to clipboard so terminal users (where IBus
|
|
277
|
-
doesn't reach) can Ctrl+Shift+V to paste.
|
|
277
|
+
doesn't reach) can Ctrl+Shift+V to paste. When the IBus engine reports
|
|
278
|
+
that no client has focus (e.g. terminal emulators), we auto-paste via
|
|
279
|
+
ydotool/wtype so the user doesn't have to paste manually.
|
|
278
280
|
"""
|
|
279
281
|
|
|
280
282
|
name = "ibus"
|
|
281
283
|
|
|
282
284
|
def __init__(self, platform=None, **kwargs):
|
|
283
285
|
self._wl_copy = shutil.which("wl-copy")
|
|
286
|
+
self._ydotool = shutil.which("ydotool")
|
|
287
|
+
self._wtype = shutil.which("wtype")
|
|
284
288
|
self._sock: socket.socket | None = None
|
|
285
289
|
self._wl_copy_proc: subprocess.Popen | None = None
|
|
286
290
|
|
|
@@ -331,6 +335,8 @@ class IBusTyper:
|
|
|
331
335
|
return
|
|
332
336
|
self._send(f"commit:{text}")
|
|
333
337
|
self._copy_to_clipboard(text)
|
|
338
|
+
if not self._engine_has_focus():
|
|
339
|
+
self._simulate_paste()
|
|
334
340
|
|
|
335
341
|
def delete_chars(self, n: int) -> None:
|
|
336
342
|
pass # Not needed: preedit handles corrections atomically
|
|
@@ -346,6 +352,8 @@ class IBusTyper:
|
|
|
346
352
|
log.debug("Committing via IBus (%d chars)", len(text))
|
|
347
353
|
self._send(f"commit:{text}")
|
|
348
354
|
self._copy_to_clipboard(text)
|
|
355
|
+
if not self._engine_has_focus():
|
|
356
|
+
self._simulate_paste()
|
|
349
357
|
|
|
350
358
|
def clear_preedit(self) -> None:
|
|
351
359
|
self._send("clear")
|
|
@@ -363,6 +371,38 @@ class IBusTyper:
|
|
|
363
371
|
self._sock.close()
|
|
364
372
|
self._sock = None
|
|
365
373
|
|
|
374
|
+
def _engine_has_focus(self) -> bool:
|
|
375
|
+
"""Ask the IBus engine whether it has input focus in the active window."""
|
|
376
|
+
try:
|
|
377
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
|
378
|
+
sock.settimeout(0.3)
|
|
379
|
+
sock.bind("") # autobind for receiving reply
|
|
380
|
+
sock.sendto(b"focus?", str(SOCKET_PATH))
|
|
381
|
+
data, _ = sock.recvfrom(64)
|
|
382
|
+
sock.close()
|
|
383
|
+
return data == b"focused"
|
|
384
|
+
except (OSError, socket.timeout):
|
|
385
|
+
return True # assume focused on error (safe default: no extra paste)
|
|
386
|
+
|
|
387
|
+
def _simulate_paste(self) -> None:
|
|
388
|
+
"""Simulate Ctrl+V to paste clipboard into non-IBus apps (terminals)."""
|
|
389
|
+
try:
|
|
390
|
+
if self._ydotool:
|
|
391
|
+
# ydotool key scancodes: 29=LCtrl, 47=V
|
|
392
|
+
subprocess.run(
|
|
393
|
+
[self._ydotool, "key", "29:1", "47:1", "47:0", "29:0"],
|
|
394
|
+
capture_output=True, timeout=3,
|
|
395
|
+
)
|
|
396
|
+
elif self._wtype:
|
|
397
|
+
subprocess.run(
|
|
398
|
+
[self._wtype, "-M", "ctrl", "-k", "v", "-m", "ctrl"],
|
|
399
|
+
capture_output=True, timeout=3,
|
|
400
|
+
)
|
|
401
|
+
else:
|
|
402
|
+
log.debug("No paste tool available (ydotool/wtype)")
|
|
403
|
+
except (subprocess.TimeoutExpired, OSError) as e:
|
|
404
|
+
log.debug("Paste simulation failed: %s", e)
|
|
405
|
+
|
|
366
406
|
def _copy_to_clipboard(self, text: str) -> None:
|
|
367
407
|
"""Copy text to clipboard as backup for non-IBus apps (terminals).
|
|
368
408
|
|