python-voiceio 0.2.1__tar.gz → 0.2.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 (64) hide show
  1. {python_voiceio-0.2.1/python_voiceio.egg-info → python_voiceio-0.2.4}/PKG-INFO +12 -33
  2. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/README.md +8 -31
  3. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/pyproject.toml +4 -2
  4. {python_voiceio-0.2.1 → python_voiceio-0.2.4/python_voiceio.egg-info}/PKG-INFO +12 -33
  5. python_voiceio-0.2.4/tests/test_app_wiring.py +139 -0
  6. python_voiceio-0.2.4/voiceio/__init__.py +1 -0
  7. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/app.py +17 -18
  8. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/cli.py +41 -4
  9. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/config.py +2 -0
  10. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/hotkeys/evdev.py +6 -2
  11. python_voiceio-0.2.4/voiceio/sounds/commit.wav +0 -0
  12. python_voiceio-0.2.4/voiceio/sounds/start.wav +0 -0
  13. python_voiceio-0.2.4/voiceio/sounds/stop.wav +0 -0
  14. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/wizard.py +13 -12
  15. python_voiceio-0.2.1/tests/test_app_wiring.py +0 -135
  16. python_voiceio-0.2.1/voiceio/__init__.py +0 -1
  17. python_voiceio-0.2.1/voiceio/sounds/commit.wav +0 -0
  18. python_voiceio-0.2.1/voiceio/sounds/start.wav +0 -0
  19. python_voiceio-0.2.1/voiceio/sounds/stop.wav +0 -0
  20. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/LICENSE +0 -0
  21. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/python_voiceio.egg-info/SOURCES.txt +0 -0
  22. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/python_voiceio.egg-info/dependency_links.txt +0 -0
  23. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/python_voiceio.egg-info/entry_points.txt +0 -0
  24. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/python_voiceio.egg-info/requires.txt +0 -0
  25. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/python_voiceio.egg-info/top_level.txt +0 -0
  26. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/setup.cfg +0 -0
  27. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/tests/test_backend_probes.py +0 -0
  28. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/tests/test_config.py +0 -0
  29. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/tests/test_fallback.py +0 -0
  30. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/tests/test_health.py +0 -0
  31. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/tests/test_ibus_typer.py +0 -0
  32. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/tests/test_platform.py +0 -0
  33. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/tests/test_prebuffer.py +0 -0
  34. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/tests/test_recorder_integration.py +0 -0
  35. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/tests/test_streaming.py +0 -0
  36. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/tests/test_transcriber.py +0 -0
  37. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/__main__.py +0 -0
  38. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/backends.py +0 -0
  39. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/feedback.py +0 -0
  40. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/health.py +0 -0
  41. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/hotkeys/__init__.py +0 -0
  42. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/hotkeys/base.py +0 -0
  43. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/hotkeys/chain.py +0 -0
  44. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/hotkeys/pynput_backend.py +0 -0
  45. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/hotkeys/socket_backend.py +0 -0
  46. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/ibus/__init__.py +0 -0
  47. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/ibus/engine.py +0 -0
  48. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/platform.py +0 -0
  49. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/recorder.py +0 -0
  50. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/service.py +0 -0
  51. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/sounds/__init__.py +0 -0
  52. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/streaming.py +0 -0
  53. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/transcriber.py +0 -0
  54. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/tray.py +0 -0
  55. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/typers/__init__.py +0 -0
  56. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/typers/base.py +0 -0
  57. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/typers/chain.py +0 -0
  58. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/typers/clipboard.py +0 -0
  59. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/typers/ibus.py +0 -0
  60. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/typers/pynput_type.py +0 -0
  61. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/typers/wtype.py +0 -0
  62. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/typers/xdotool.py +0 -0
  63. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/typers/ydotool.py +0 -0
  64. {python_voiceio-0.2.1 → python_voiceio-0.2.4}/voiceio/worker.py +0 -0
@@ -1,11 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.2.1
4
- Summary: Push-to-talk voice-to-text for Linux. Press a hotkey, speak, press again - text appears at your cursor.
3
+ Version: 0.2.4
4
+ Summary: Speak text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/Hugo0/voiceio
8
+ Project-URL: Repository, https://github.com/Hugo0/voiceio
8
9
  Project-URL: Issues, https://github.com/Hugo0/voiceio/issues
10
+ Project-URL: Changelog, https://github.com/Hugo0/voiceio/releases
9
11
  Keywords: voice,speech-to-text,whisper,linux,dictation,wayland,ibus
10
12
  Classifier: Development Status :: 4 - Beta
11
13
  Classifier: Environment :: X11 Applications
@@ -39,18 +41,7 @@ Dynamic: license-file
39
41
  [![Python](https://img.shields.io/pypi/pyversions/python-voiceio)](https://pypi.org/project/python-voiceio/)
40
42
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
41
43
 
42
- Push-to-talk voice-to-text for Linux and macOS, on any app. Press a hotkey, speak, press again - text appears at your cursor.
43
-
44
- 100% local and open source. No API keys, no cloud, no telemetry. Use and modify at your will.
45
-
46
- <!-- demo video -->
47
- <p align="center">
48
- <a href="https://www.tella.tv/video/YOUR_VIDEO_ID">
49
- <img src="https://github.com/Hugo0/voiceio/raw/main/assets/demo-thumbnail.png" alt="voiceio demo" width="600">
50
- </a>
51
- <br>
52
- <em>Click to watch the demo</em>
53
- </p>
44
+ Speak text, locally, instantly.
54
45
 
55
46
  ## Quick start
56
47
 
@@ -100,7 +91,7 @@ voiceio setup
100
91
  ```
101
92
  </details>
102
93
 
103
- > You can also install with `uv tool install voiceio` or `pip install voiceio`.
94
+ > You can also install with `uv tool install python-voiceio` or `pip install python-voiceio`.
104
95
 
105
96
  ## How it works
106
97
 
@@ -145,6 +136,7 @@ voiceio setup Interactive setup wizard
145
136
  voiceio doctor Health check (--fix to auto-repair)
146
137
  voiceio test Test microphone + live transcription
147
138
  voiceio toggle Toggle recording on a running daemon
139
+ voiceio update Update to latest version
148
140
  voiceio service install Autostart on login via systemd
149
141
  voiceio logs View recent logs
150
142
  voiceio uninstall Remove all system integrations
@@ -196,28 +188,15 @@ voiceio uninstall # removes service, IBus, shortcuts, symlinks
196
188
  pipx uninstall python-voiceio # removes the package
197
189
  ```
198
190
 
199
- ## TODO
200
-
201
- **Launch**
202
- - [ ] Publish to PyPI
203
- - [ ] Record demo video + thumbnail
204
- - [ ] Test clean install on a fresh VM/container
205
- - [ ] GitHub repo: description, topics, social preview image
206
- - [ ] Bump version to 0.2.0
207
-
208
- **Code quality**
209
- - [ ] IBus activation on non-GNOME desktops (KDE, Sway, Hyprland), currently GNOME-only via gsettings
210
- - [ ] `voiceio doctor --json` for machine-readable output
211
- - [ ] Shell completions (`voiceio completion bash/zsh/fish`)
212
- - [ ] Refactor wizard.py (882 lines) into smaller, testable modules
213
- - [ ] Socket protocol versioning (e.g. `v1:preedit:text`)
214
- - [ ] Configurable log file path
215
-
216
191
  ## Wishlist
217
192
 
218
- Contributions welcome! Open an issue to discuss before starting.
193
+ Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md). Open an issue to discuss before starting.
219
194
 
220
195
  **High impact**
196
+ - [ ] **macOS support**: test and polish pynput hotkey + typer backends
197
+ - [ ] **Silence filtering**: VAD-based trimming to prevent Whisper hallucinations on silence
198
+ - [ ] **distil-whisper models**: better speed/accuracy tradeoffs
199
+ - [ ] **IBus on non-GNOME desktops**: KDE, Sway, Hyprland activation (currently GNOME-only via gsettings)
221
200
  - [ ] **Text-to-speech (voice output)**: select text, press a hotkey, hear it spoken aloud. Completes the "io" in voiceio. Use a local TTS engine (Piper, Coqui, espeak-ng), same philosophy: no cloud, no API keys
222
201
  - [ ] **Wake word**: "Hey voiceio" hands-free activation (no hotkey needed). Use a small always-on keyword model (e.g. openWakeWord, Porcupine)
223
202
  - [ ] **Custom vocabulary / hot words**: user-defined word list for names, jargon, technical terms that Whisper gets wrong. Boost via `initial_prompt` or fine-tuned logit bias
@@ -5,18 +5,7 @@
5
5
  [![Python](https://img.shields.io/pypi/pyversions/python-voiceio)](https://pypi.org/project/python-voiceio/)
6
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
7
 
8
- Push-to-talk voice-to-text for Linux and macOS, on any app. Press a hotkey, speak, press again - text appears at your cursor.
9
-
10
- 100% local and open source. No API keys, no cloud, no telemetry. Use and modify at your will.
11
-
12
- <!-- demo video -->
13
- <p align="center">
14
- <a href="https://www.tella.tv/video/YOUR_VIDEO_ID">
15
- <img src="https://github.com/Hugo0/voiceio/raw/main/assets/demo-thumbnail.png" alt="voiceio demo" width="600">
16
- </a>
17
- <br>
18
- <em>Click to watch the demo</em>
19
- </p>
8
+ Speak text, locally, instantly.
20
9
 
21
10
  ## Quick start
22
11
 
@@ -66,7 +55,7 @@ voiceio setup
66
55
  ```
67
56
  </details>
68
57
 
69
- > You can also install with `uv tool install voiceio` or `pip install voiceio`.
58
+ > You can also install with `uv tool install python-voiceio` or `pip install python-voiceio`.
70
59
 
71
60
  ## How it works
72
61
 
@@ -111,6 +100,7 @@ voiceio setup Interactive setup wizard
111
100
  voiceio doctor Health check (--fix to auto-repair)
112
101
  voiceio test Test microphone + live transcription
113
102
  voiceio toggle Toggle recording on a running daemon
103
+ voiceio update Update to latest version
114
104
  voiceio service install Autostart on login via systemd
115
105
  voiceio logs View recent logs
116
106
  voiceio uninstall Remove all system integrations
@@ -162,28 +152,15 @@ voiceio uninstall # removes service, IBus, shortcuts, symlinks
162
152
  pipx uninstall python-voiceio # removes the package
163
153
  ```
164
154
 
165
- ## TODO
166
-
167
- **Launch**
168
- - [ ] Publish to PyPI
169
- - [ ] Record demo video + thumbnail
170
- - [ ] Test clean install on a fresh VM/container
171
- - [ ] GitHub repo: description, topics, social preview image
172
- - [ ] Bump version to 0.2.0
173
-
174
- **Code quality**
175
- - [ ] IBus activation on non-GNOME desktops (KDE, Sway, Hyprland), currently GNOME-only via gsettings
176
- - [ ] `voiceio doctor --json` for machine-readable output
177
- - [ ] Shell completions (`voiceio completion bash/zsh/fish`)
178
- - [ ] Refactor wizard.py (882 lines) into smaller, testable modules
179
- - [ ] Socket protocol versioning (e.g. `v1:preedit:text`)
180
- - [ ] Configurable log file path
181
-
182
155
  ## Wishlist
183
156
 
184
- Contributions welcome! Open an issue to discuss before starting.
157
+ Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md). Open an issue to discuss before starting.
185
158
 
186
159
  **High impact**
160
+ - [ ] **macOS support**: test and polish pynput hotkey + typer backends
161
+ - [ ] **Silence filtering**: VAD-based trimming to prevent Whisper hallucinations on silence
162
+ - [ ] **distil-whisper models**: better speed/accuracy tradeoffs
163
+ - [ ] **IBus on non-GNOME desktops**: KDE, Sway, Hyprland activation (currently GNOME-only via gsettings)
187
164
  - [ ] **Text-to-speech (voice output)**: select text, press a hotkey, hear it spoken aloud. Completes the "io" in voiceio. Use a local TTS engine (Piper, Coqui, espeak-ng), same philosophy: no cloud, no API keys
188
165
  - [ ] **Wake word**: "Hey voiceio" hands-free activation (no hotkey needed). Use a small always-on keyword model (e.g. openWakeWord, Porcupine)
189
166
  - [ ] **Custom vocabulary / hot words**: user-defined word list for names, jargon, technical terms that Whisper gets wrong. Boost via `initial_prompt` or fine-tuned logit bias
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-voiceio"
7
- version = "0.2.1"
8
- description = "Push-to-talk voice-to-text for Linux. Press a hotkey, speak, press again - text appears at your cursor."
7
+ version = "0.2.4"
8
+ description = "Speak text, locally, instantly."
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  requires-python = ">=3.11"
@@ -34,7 +34,9 @@ dev = ["pytest>=7.0", "pytest-mock"]
34
34
 
35
35
  [project.urls]
36
36
  Homepage = "https://github.com/Hugo0/voiceio"
37
+ Repository = "https://github.com/Hugo0/voiceio"
37
38
  Issues = "https://github.com/Hugo0/voiceio/issues"
39
+ Changelog = "https://github.com/Hugo0/voiceio/releases"
38
40
 
39
41
  [project.scripts]
40
42
  voiceio = "voiceio.cli:main"
@@ -1,11 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.2.1
4
- Summary: Push-to-talk voice-to-text for Linux. Press a hotkey, speak, press again - text appears at your cursor.
3
+ Version: 0.2.4
4
+ Summary: Speak text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/Hugo0/voiceio
8
+ Project-URL: Repository, https://github.com/Hugo0/voiceio
8
9
  Project-URL: Issues, https://github.com/Hugo0/voiceio/issues
10
+ Project-URL: Changelog, https://github.com/Hugo0/voiceio/releases
9
11
  Keywords: voice,speech-to-text,whisper,linux,dictation,wayland,ibus
10
12
  Classifier: Development Status :: 4 - Beta
11
13
  Classifier: Environment :: X11 Applications
@@ -39,18 +41,7 @@ Dynamic: license-file
39
41
  [![Python](https://img.shields.io/pypi/pyversions/python-voiceio)](https://pypi.org/project/python-voiceio/)
40
42
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
41
43
 
42
- Push-to-talk voice-to-text for Linux and macOS, on any app. Press a hotkey, speak, press again - text appears at your cursor.
43
-
44
- 100% local and open source. No API keys, no cloud, no telemetry. Use and modify at your will.
45
-
46
- <!-- demo video -->
47
- <p align="center">
48
- <a href="https://www.tella.tv/video/YOUR_VIDEO_ID">
49
- <img src="https://github.com/Hugo0/voiceio/raw/main/assets/demo-thumbnail.png" alt="voiceio demo" width="600">
50
- </a>
51
- <br>
52
- <em>Click to watch the demo</em>
53
- </p>
44
+ Speak text, locally, instantly.
54
45
 
55
46
  ## Quick start
56
47
 
@@ -100,7 +91,7 @@ voiceio setup
100
91
  ```
101
92
  </details>
102
93
 
103
- > You can also install with `uv tool install voiceio` or `pip install voiceio`.
94
+ > You can also install with `uv tool install python-voiceio` or `pip install python-voiceio`.
104
95
 
105
96
  ## How it works
106
97
 
@@ -145,6 +136,7 @@ voiceio setup Interactive setup wizard
145
136
  voiceio doctor Health check (--fix to auto-repair)
146
137
  voiceio test Test microphone + live transcription
147
138
  voiceio toggle Toggle recording on a running daemon
139
+ voiceio update Update to latest version
148
140
  voiceio service install Autostart on login via systemd
149
141
  voiceio logs View recent logs
150
142
  voiceio uninstall Remove all system integrations
@@ -196,28 +188,15 @@ voiceio uninstall # removes service, IBus, shortcuts, symlinks
196
188
  pipx uninstall python-voiceio # removes the package
197
189
  ```
198
190
 
199
- ## TODO
200
-
201
- **Launch**
202
- - [ ] Publish to PyPI
203
- - [ ] Record demo video + thumbnail
204
- - [ ] Test clean install on a fresh VM/container
205
- - [ ] GitHub repo: description, topics, social preview image
206
- - [ ] Bump version to 0.2.0
207
-
208
- **Code quality**
209
- - [ ] IBus activation on non-GNOME desktops (KDE, Sway, Hyprland), currently GNOME-only via gsettings
210
- - [ ] `voiceio doctor --json` for machine-readable output
211
- - [ ] Shell completions (`voiceio completion bash/zsh/fish`)
212
- - [ ] Refactor wizard.py (882 lines) into smaller, testable modules
213
- - [ ] Socket protocol versioning (e.g. `v1:preedit:text`)
214
- - [ ] Configurable log file path
215
-
216
191
  ## Wishlist
217
192
 
218
- Contributions welcome! Open an issue to discuss before starting.
193
+ Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md). Open an issue to discuss before starting.
219
194
 
220
195
  **High impact**
196
+ - [ ] **macOS support**: test and polish pynput hotkey + typer backends
197
+ - [ ] **Silence filtering**: VAD-based trimming to prevent Whisper hallucinations on silence
198
+ - [ ] **distil-whisper models**: better speed/accuracy tradeoffs
199
+ - [ ] **IBus on non-GNOME desktops**: KDE, Sway, Hyprland activation (currently GNOME-only via gsettings)
221
200
  - [ ] **Text-to-speech (voice output)**: select text, press a hotkey, hear it spoken aloud. Completes the "io" in voiceio. Use a local TTS engine (Piper, Coqui, espeak-ng), same philosophy: no cloud, no API keys
222
201
  - [ ] **Wake word**: "Hey voiceio" hands-free activation (no hotkey needed). Use a small always-on keyword model (e.g. openWakeWord, Porcupine)
223
202
  - [ ] **Custom vocabulary / hot words**: user-defined word list for names, jargon, technical terms that Whisper gets wrong. Boost via `initial_prompt` or fine-tuned logit bias
@@ -0,0 +1,139 @@
1
+ """Test that VoiceIO app wires up correctly with mocked backends."""
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
+ import pytest
10
+
11
+ from voiceio.config import Config
12
+
13
+
14
+ def _make_vio(mock_transcriber=None):
15
+ """Create a VoiceIO instance with mocked backends."""
16
+ mock_hotkey = MagicMock()
17
+ mock_hotkey.name = "socket"
18
+ mock_typer = MagicMock()
19
+ mock_typer.name = "clipboard"
20
+ if mock_transcriber is None:
21
+ mock_transcriber = MagicMock()
22
+
23
+ with patch("voiceio.app.hotkey_chain.select", return_value=mock_hotkey), \
24
+ patch("voiceio.app.typer_chain.select", return_value=mock_typer), \
25
+ patch("voiceio.app.Transcriber", return_value=mock_transcriber), \
26
+ patch("voiceio.app.plat.detect") as mock_detect:
27
+ mock_detect.return_value = MagicMock(display_server="wayland", desktop="gnome")
28
+
29
+ from voiceio.app import VoiceIO
30
+ vio = VoiceIO(Config())
31
+ vio.recorder._stream = MagicMock() # skip real audio
32
+ return vio, mock_typer, mock_transcriber
33
+
34
+
35
+ class TestVoiceIOInit:
36
+ def test_init_with_mocked_backends(self):
37
+ vio, mock_typer, _ = _make_vio()
38
+ assert vio._typer is mock_typer
39
+
40
+ def test_on_hotkey_toggle_cycle(self):
41
+ mock_trans = MagicMock()
42
+ mock_trans.transcribe.return_value = "hello world"
43
+ vio, _, _ = _make_vio(mock_trans)
44
+
45
+ # Start recording
46
+ vio.on_hotkey()
47
+ assert vio.recorder.is_recording
48
+
49
+ # Feed some audio
50
+ for _ in range(20):
51
+ data = np.full((1024, 1), 0.3, dtype=np.float32)
52
+ vio.recorder._callback(data, 1024, None, None)
53
+
54
+ # Set record_start far back so debounce allows stop
55
+ vio._record_start = time.monotonic() - 5.0
56
+ vio._last_hotkey = time.monotonic() - 5.0
57
+
58
+ # Stop recording
59
+ vio.on_hotkey()
60
+ assert not vio.recorder.is_recording
61
+
62
+
63
+ class TestHotkeyDebounce:
64
+ """Verify that duplicate hotkey events are properly debounced."""
65
+
66
+ def test_rapid_duplicate_ignored(self):
67
+ """Two on_hotkey calls within 0.3s should only trigger once."""
68
+ vio, _, _ = _make_vio()
69
+
70
+ vio.on_hotkey()
71
+ assert vio.recorder.is_recording
72
+
73
+ # Simulate duplicate from socket backend ~50ms later
74
+ vio.on_hotkey()
75
+ # Should still be recording (duplicate was debounced, not treated as stop)
76
+ assert vio.recorder.is_recording
77
+
78
+ def test_stop_after_debounce_window(self):
79
+ """on_hotkey after debounce window should stop recording."""
80
+ vio, _, _ = _make_vio()
81
+
82
+ vio.on_hotkey()
83
+ assert vio.recorder.is_recording
84
+
85
+ # Feed audio
86
+ for _ in range(20):
87
+ data = np.full((1024, 1), 0.3, dtype=np.float32)
88
+ vio.recorder._callback(data, 1024, None, None)
89
+
90
+ # Move timestamps back so debounce allows through
91
+ vio._record_start = time.monotonic() - 2.0
92
+ vio._last_hotkey = time.monotonic() - 2.0
93
+
94
+ vio.on_hotkey()
95
+ assert not vio.recorder.is_recording
96
+
97
+ def test_concurrent_hotkey_no_phantom_recording(self):
98
+ """Socket event waiting behind lock must not start phantom recording.
99
+
100
+ This is the critical race: evdev stops recording (takes time),
101
+ socket event waits on lock, then must be debounced when lock releases.
102
+ """
103
+ vio, _, _ = _make_vio()
104
+
105
+ # Start recording
106
+ vio.on_hotkey()
107
+ assert vio.recorder.is_recording
108
+
109
+ # Feed audio
110
+ for _ in range(20):
111
+ data = np.full((1024, 1), 0.3, dtype=np.float32)
112
+ vio.recorder._callback(data, 1024, None, None)
113
+
114
+ # Allow stop
115
+ vio._record_start = time.monotonic() - 2.0
116
+ vio._last_hotkey = time.monotonic() - 2.0
117
+
118
+ # Simulate: evdev thread stops recording, socket thread waits then fires
119
+ results = []
120
+
121
+ def evdev_stop():
122
+ vio.on_hotkey()
123
+ results.append(("evdev", vio.recorder.is_recording))
124
+
125
+ def socket_delayed():
126
+ time.sleep(0.05) # socket arrives 50ms after evdev
127
+ vio.on_hotkey()
128
+ results.append(("socket", vio.recorder.is_recording))
129
+
130
+ t1 = threading.Thread(target=evdev_stop)
131
+ t2 = threading.Thread(target=socket_delayed)
132
+ t1.start()
133
+ t2.start()
134
+ t1.join(timeout=5)
135
+ t2.join(timeout=5)
136
+
137
+ # Recording must be stopped and NOT restarted by socket
138
+ assert not vio.recorder.is_recording, \
139
+ f"Phantom recording started! results={results}"
@@ -0,0 +1 @@
1
+ __version__ = "0.2.4"
@@ -18,7 +18,6 @@ from voiceio.recorder import AudioRecorder
18
18
  from voiceio.streaming import StreamingSession
19
19
  from voiceio.transcriber import Transcriber
20
20
  from voiceio.typers import chain as typer_chain
21
- from voiceio.typers.base import StreamingTyper
22
21
  log = logging.getLogger("voiceio")
23
22
 
24
23
 
@@ -33,7 +32,7 @@ class VoiceIO:
33
32
  self._typer = typer_chain.select(self.platform, cfg.output.method)
34
33
  self._auto_fallback = cfg.health.auto_fallback
35
34
 
36
- # Always start socket backend alongside native hotkey
35
+ # Socket backend runs alongside native hotkey for extra robustness
37
36
  self._socket: SocketHotkey | None = None
38
37
  if self._hotkey.name != "socket":
39
38
  self._socket = SocketHotkey()
@@ -47,6 +46,8 @@ class VoiceIO:
47
46
  self._session: StreamingSession | None = None
48
47
  self._processing = False
49
48
  self._record_start: float = 0
49
+ self._hotkey_lock = threading.Lock()
50
+ self._last_hotkey: float = 0
50
51
  self._prev_ibus_engine: str | None = None
51
52
  self._engine_proc: subprocess.Popen | None = None
52
53
  self._shutdown = threading.Event()
@@ -56,23 +57,21 @@ class VoiceIO:
56
57
  self._shutdown.set()
57
58
 
58
59
  def on_hotkey(self) -> None:
59
- if self.recorder.is_recording:
60
- elapsed = time.monotonic() - self._record_start
61
- if elapsed < self.cfg.output.cancel_window_secs:
62
- # Quick double-press = cancel recording without typing
63
- if self._streaming and self._session is not None:
64
- self._session.stop()
65
- self._session = None
66
- self.recorder.stop()
67
- if isinstance(self._typer, StreamingTyper):
68
- self._typer.clear_preedit()
69
- self._deactivate_ibus()
70
- log.info("Recording cancelled (double-press)")
71
- return
72
- if elapsed < self.cfg.output.min_recording_secs:
73
- log.debug("Ignoring stop, only %.1fs into recording (min %.1fs)", elapsed, self.cfg.output.min_recording_secs)
60
+ with self._hotkey_lock:
61
+ now = time.monotonic()
62
+ # Deduplicate: multiple backends (evdev + socket) may fire
63
+ # for the same physical keypress
64
+ if now - self._last_hotkey < 0.3:
74
65
  return
66
+ self._last_hotkey = now
67
+ self._on_hotkey_inner()
68
+ # Update timestamp after completion so threads that waited
69
+ # behind the lock see a fresh timestamp and get debounced
70
+ self._last_hotkey = time.monotonic()
75
71
 
72
+ def _on_hotkey_inner(self) -> None:
73
+ if self.recorder.is_recording:
74
+ elapsed = time.monotonic() - self._record_start
76
75
  self._play_record_cue(start=False)
77
76
  if self._streaming and self._session is not None:
78
77
  final_text = self._session.stop()
@@ -82,11 +81,11 @@ class VoiceIO:
82
81
  self._play_feedback(final_text)
83
82
  log.info("Streaming done (%.1fs): '%s'", elapsed, final_text)
84
83
  else:
84
+ self._play_record_cue(start=False)
85
85
  audio = self.recorder.stop()
86
86
  log.info("Stopped recording (%.1fs)", elapsed)
87
87
  if audio is not None and not self._processing:
88
88
  threading.Thread(target=self._process, args=(audio,), daemon=True).start()
89
- # Deactivate IBus engine, return keyboard to normal
90
89
  self._deactivate_ibus()
91
90
  elif not self._processing:
92
91
  # Activate IBus engine so preedit/commit can reach the focused app
@@ -59,6 +59,9 @@ def main() -> None:
59
59
  choices=["install", "uninstall", "start", "stop", "status"],
60
60
  help="Action to perform (default: status)")
61
61
 
62
+ # ── voiceio update ──────────────────────────────────────────────────
63
+ sub.add_parser("update", help="Update voiceio to the latest version")
64
+
62
65
  # ── voiceio uninstall ──────────────────────────────────────────────
63
66
  sub.add_parser("uninstall", help="Remove all voiceio system integrations")
64
67
 
@@ -77,6 +80,8 @@ def main() -> None:
77
80
  _cmd_test()
78
81
  elif args.command == "service":
79
82
  _cmd_service(args)
83
+ elif args.command == "update":
84
+ _cmd_update()
80
85
  elif args.command == "uninstall":
81
86
  _cmd_uninstall()
82
87
  elif args.command == "logs":
@@ -283,6 +288,37 @@ def _cmd_service(args: argparse.Namespace) -> None:
283
288
  sys.exit(1)
284
289
 
285
290
 
291
+ def _cmd_update() -> None:
292
+ """Update voiceio to the latest PyPI version."""
293
+ import subprocess
294
+ from voiceio import __version__
295
+ from voiceio.config import PYPI_NAME
296
+
297
+ is_pipx = "pipx" in sys.prefix
298
+ if is_pipx:
299
+ print(f"Current version: {__version__}")
300
+ print("Checking for updates...")
301
+ try:
302
+ result = subprocess.run(
303
+ ["pipx", "upgrade", PYPI_NAME],
304
+ capture_output=True, text=True, timeout=60,
305
+ )
306
+ print(result.stdout.strip())
307
+ if result.returncode != 0 and result.stderr.strip():
308
+ print(result.stderr.strip(), file=sys.stderr)
309
+ sys.exit(1)
310
+ except FileNotFoundError:
311
+ print("pipx not found. Update manually: pipx upgrade " + PYPI_NAME, file=sys.stderr)
312
+ sys.exit(1)
313
+ except subprocess.TimeoutExpired:
314
+ print("Update timed out.", file=sys.stderr)
315
+ sys.exit(1)
316
+ else:
317
+ print("Not a pipx install. Update manually:")
318
+ print(f" pip install --upgrade {PYPI_NAME}")
319
+ sys.exit(1)
320
+
321
+
286
322
  def _cmd_uninstall() -> None:
287
323
  """Remove all voiceio system integrations."""
288
324
  import os
@@ -429,14 +465,15 @@ def _cmd_uninstall() -> None:
429
465
  print("\nNothing to remove. voiceio was not installed on this system.")
430
466
 
431
467
  # Offer to uninstall the Python package itself
468
+ from voiceio.config import PYPI_NAME
432
469
  is_pipx = "pipx" in sys.prefix
433
470
  if is_pipx:
434
471
  answer = input("\nAlso uninstall the voiceio Python package (pipx uninstall)? [Y/n] ").strip().lower()
435
472
  if answer in ("y", "yes", ""):
436
473
  try:
437
- subprocess.run(["pipx", "uninstall", "voiceio"], timeout=30)
474
+ subprocess.run(["pipx", "uninstall", PYPI_NAME], timeout=30)
438
475
  except (FileNotFoundError, subprocess.TimeoutExpired):
439
- print("Failed. Run manually: pipx uninstall python-voiceio")
476
+ print(f"Failed. Run manually: pipx uninstall {PYPI_NAME}")
440
477
  else:
441
478
  # Dev install or pip install: check if voiceio is still reachable
442
479
  voiceio_bin = shutil.which("voiceio")
@@ -444,10 +481,10 @@ def _cmd_uninstall() -> None:
444
481
  print(f"\nNote: 'voiceio' is still available at {voiceio_bin}")
445
482
  if ".venv" in str(voiceio_bin) or "site-packages" in str(voiceio_bin):
446
483
  print("This is a development install. To fully remove:")
447
- print(" pip uninstall python-voiceio")
484
+ print(f" pip uninstall {PYPI_NAME}")
448
485
  else:
449
486
  print("To fully remove the package:")
450
- print(" pip uninstall python-voiceio")
487
+ print(f" pip uninstall {PYPI_NAME}")
451
488
  else:
452
489
  print("\nvoiceio fully removed.")
453
490
 
@@ -9,6 +9,8 @@ from pathlib import Path
9
9
 
10
10
  log = logging.getLogger(__name__)
11
11
 
12
+ PYPI_NAME = "python-voiceio"
13
+
12
14
  CONFIG_DIR = Path.home() / ".config" / "voiceio"
13
15
  CONFIG_PATH = CONFIG_DIR / "config.toml"
14
16
  LOG_DIR = Path.home() / ".local" / "state" / "voiceio"
@@ -108,16 +108,20 @@ class EvdevHotkey:
108
108
  if event.type != ecodes.EV_KEY:
109
109
  continue
110
110
  key_event = evdev.categorize(event)
111
+ should_trigger = False
111
112
  with pressed_lock:
112
113
  if key_event.keystate == evdev.KeyEvent.key_down:
113
114
  pressed.add(event.code)
114
115
  if event.code == key_code and check_mods():
115
116
  now = time.monotonic()
116
- if now - last_trigger[0] >= DEBOUNCE_SECS:
117
+ since = now - last_trigger[0]
118
+ if since >= DEBOUNCE_SECS:
117
119
  last_trigger[0] = now
118
- on_trigger()
120
+ should_trigger = True
119
121
  elif key_event.keystate == evdev.KeyEvent.key_up:
120
122
  pressed.discard(event.code)
123
+ if should_trigger:
124
+ on_trigger()
121
125
  except OSError:
122
126
  pass
123
127
 
@@ -844,22 +844,23 @@ def run_wizard() -> None:
844
844
  _streaming_test(model=_get_or_load_model())
845
845
 
846
846
  # ── Done ────────────────────────────────────────────────────────────
847
- # Restart service if it was already running (setup may have killed the
848
- # IBus engine via `ibus restart`)
849
- from voiceio.service import is_running
850
- if is_running():
851
- subprocess.run(
852
- ["systemctl", "--user", "restart", "voiceio.service"],
853
- capture_output=True, timeout=5,
854
- )
855
- print(f" {GREEN}✓{RESET} {DIM}Restarted voiceio service{RESET}")
847
+ # Start (or restart) the service so it's immediately usable
848
+ if autostart_idx == 0:
849
+ from voiceio.service import is_running
850
+ action = "restart" if is_running() else "start"
851
+ try:
852
+ subprocess.run(
853
+ ["systemctl", "--user", action, "voiceio.service"],
854
+ capture_output=True, timeout=5,
855
+ )
856
+ print(f" {GREEN}✓{RESET} {DIM}voiceio service started{RESET}")
857
+ except (FileNotFoundError, subprocess.TimeoutExpired):
858
+ pass
856
859
 
857
860
  from voiceio.config import LOG_PATH
858
861
  log_path = LOG_PATH
859
862
  start_hint = (
860
- f" It will start automatically on next login.\n"
861
- f" Or start now:\n"
862
- f" {CYAN}systemctl --user start voiceio{RESET}"
863
+ " voiceio is running and will start automatically on login."
863
864
  if autostart_idx == 0
864
865
  else f" Start voiceio:\n {CYAN}voiceio{RESET}"
865
866
  )
@@ -1,135 +0,0 @@
1
- """Test that VoiceIO app wires up correctly with mocked backends."""
2
- from __future__ import annotations
3
-
4
- from unittest.mock import MagicMock, patch
5
-
6
- import numpy as np
7
- import pytest
8
-
9
- from voiceio.config import Config
10
-
11
-
12
- class TestVoiceIOInit:
13
- """Verify VoiceIO can be constructed without real hardware."""
14
-
15
- def test_init_with_mocked_backends(self):
16
- from voiceio.backends import ProbeResult
17
-
18
- mock_hotkey = MagicMock()
19
- mock_hotkey.name = "socket"
20
- mock_hotkey.probe.return_value = ProbeResult(ok=True)
21
-
22
- mock_typer = MagicMock()
23
- mock_typer.name = "clipboard"
24
- mock_typer.probe.return_value = ProbeResult(ok=True)
25
-
26
- mock_transcriber = MagicMock()
27
-
28
- with patch("voiceio.app.hotkey_chain.select", return_value=mock_hotkey), \
29
- patch("voiceio.app.typer_chain.select", return_value=mock_typer), \
30
- patch("voiceio.app.Transcriber", return_value=mock_transcriber), \
31
- patch("voiceio.app.plat.detect") as mock_detect:
32
- mock_detect.return_value = MagicMock(display_server="wayland", desktop="gnome")
33
-
34
- from voiceio.app import VoiceIO
35
- vio = VoiceIO(Config())
36
-
37
- assert vio._hotkey is mock_hotkey
38
- assert vio._typer is mock_typer
39
-
40
- def test_on_hotkey_toggle_cycle(self):
41
- """Test on_hotkey start/stop without real audio."""
42
- from voiceio.backends import ProbeResult
43
-
44
- mock_hotkey = MagicMock()
45
- mock_hotkey.name = "socket"
46
-
47
- mock_typer = MagicMock()
48
- mock_typer.name = "clipboard"
49
-
50
- mock_transcriber = MagicMock()
51
- mock_transcriber.transcribe.return_value = "hello world"
52
-
53
- with patch("voiceio.app.hotkey_chain.select", return_value=mock_hotkey), \
54
- patch("voiceio.app.typer_chain.select", return_value=mock_typer), \
55
- patch("voiceio.app.Transcriber", return_value=mock_transcriber), \
56
- patch("voiceio.app.plat.detect") as mock_detect:
57
- mock_detect.return_value = MagicMock(display_server="wayland", desktop="gnome")
58
-
59
- from voiceio.app import VoiceIO
60
- vio = VoiceIO(Config())
61
- vio.recorder._stream = MagicMock() # skip real audio
62
-
63
- # First toggle: start recording
64
- vio.on_hotkey()
65
- assert vio.recorder.is_recording
66
-
67
- # Simulate some audio coming in
68
- for _ in range(20):
69
- data = np.full((1024, 1), 0.3, dtype=np.float32)
70
- vio.recorder._callback(data, 1024, None, None)
71
-
72
- # Hack: set _record_start far enough back
73
- import time
74
- vio._record_start = time.monotonic() - 5.0
75
-
76
- # Second toggle: stop recording
77
- vio.on_hotkey()
78
- assert not vio.recorder.is_recording
79
-
80
- def test_double_press_cancels_recording(self):
81
- """Rapid double-press (< 0.5s) cancels without typing."""
82
- mock_hotkey = MagicMock()
83
- mock_hotkey.name = "socket"
84
- mock_typer = MagicMock()
85
- mock_typer.name = "clipboard"
86
-
87
- with patch("voiceio.app.hotkey_chain.select", return_value=mock_hotkey), \
88
- patch("voiceio.app.typer_chain.select", return_value=mock_typer), \
89
- patch("voiceio.app.Transcriber") as mock_trans_cls, \
90
- patch("voiceio.app.plat.detect") as mock_detect:
91
- mock_detect.return_value = MagicMock(display_server="wayland", desktop="gnome")
92
-
93
- from voiceio.app import VoiceIO
94
- vio = VoiceIO(Config())
95
- vio.recorder._stream = MagicMock()
96
-
97
- # Start recording
98
- vio.on_hotkey()
99
- assert vio.recorder.is_recording
100
-
101
- # Immediately double-press (< 0.5s) - cancels
102
- vio.on_hotkey()
103
- assert not vio.recorder.is_recording
104
- mock_typer.type_text.assert_not_called()
105
-
106
- def test_min_recording_duration_enforced(self):
107
- """Press between cancel_window and min_recording should be ignored."""
108
- import time
109
- mock_hotkey = MagicMock()
110
- mock_hotkey.name = "socket"
111
- mock_typer = MagicMock()
112
- mock_typer.name = "clipboard"
113
-
114
- cfg = Config()
115
-
116
- with patch("voiceio.app.hotkey_chain.select", return_value=mock_hotkey), \
117
- patch("voiceio.app.typer_chain.select", return_value=mock_typer), \
118
- patch("voiceio.app.Transcriber") as mock_trans_cls, \
119
- patch("voiceio.app.plat.detect") as mock_detect:
120
- mock_detect.return_value = MagicMock(display_server="wayland", desktop="gnome")
121
-
122
- from voiceio.app import VoiceIO
123
- vio = VoiceIO(cfg)
124
- vio.recorder._stream = MagicMock()
125
-
126
- # Start recording
127
- vio.on_hotkey()
128
- assert vio.recorder.is_recording
129
-
130
- # Set record_start past cancel window but before min duration
131
- vio._record_start = time.monotonic() - (cfg.output.cancel_window_secs + 0.1)
132
-
133
- # Press again - should be ignored
134
- vio.on_hotkey()
135
- assert vio.recorder.is_recording # still recording
@@ -1 +0,0 @@
1
- __version__ = "0.2.1"
File without changes
File without changes