python-voiceio 0.2.0__tar.gz → 0.2.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.
Files changed (64) hide show
  1. {python_voiceio-0.2.0/python_voiceio.egg-info → python_voiceio-0.2.3}/PKG-INFO +10 -13
  2. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/README.md +7 -9
  3. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/pyproject.toml +3 -3
  4. {python_voiceio-0.2.0 → python_voiceio-0.2.3/python_voiceio.egg-info}/PKG-INFO +10 -13
  5. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/python_voiceio.egg-info/requires.txt +3 -3
  6. python_voiceio-0.2.3/tests/test_app_wiring.py +139 -0
  7. python_voiceio-0.2.3/voiceio/__init__.py +1 -0
  8. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/app.py +17 -17
  9. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/cli.py +5 -4
  10. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/config.py +2 -0
  11. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/hotkeys/evdev.py +6 -2
  12. python_voiceio-0.2.3/voiceio/sounds/commit.wav +0 -0
  13. python_voiceio-0.2.3/voiceio/sounds/start.wav +0 -0
  14. python_voiceio-0.2.3/voiceio/sounds/stop.wav +0 -0
  15. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/wizard.py +13 -12
  16. python_voiceio-0.2.0/tests/test_app_wiring.py +0 -135
  17. python_voiceio-0.2.0/voiceio/__init__.py +0 -1
  18. python_voiceio-0.2.0/voiceio/sounds/commit.wav +0 -0
  19. python_voiceio-0.2.0/voiceio/sounds/start.wav +0 -0
  20. python_voiceio-0.2.0/voiceio/sounds/stop.wav +0 -0
  21. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/LICENSE +0 -0
  22. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/python_voiceio.egg-info/SOURCES.txt +0 -0
  23. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/python_voiceio.egg-info/dependency_links.txt +0 -0
  24. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/python_voiceio.egg-info/entry_points.txt +0 -0
  25. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/python_voiceio.egg-info/top_level.txt +0 -0
  26. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/setup.cfg +0 -0
  27. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/tests/test_backend_probes.py +0 -0
  28. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/tests/test_config.py +0 -0
  29. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/tests/test_fallback.py +0 -0
  30. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/tests/test_health.py +0 -0
  31. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/tests/test_ibus_typer.py +0 -0
  32. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/tests/test_platform.py +0 -0
  33. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/tests/test_prebuffer.py +0 -0
  34. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/tests/test_recorder_integration.py +0 -0
  35. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/tests/test_streaming.py +0 -0
  36. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/tests/test_transcriber.py +0 -0
  37. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/__main__.py +0 -0
  38. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/backends.py +0 -0
  39. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/feedback.py +0 -0
  40. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/health.py +0 -0
  41. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/hotkeys/__init__.py +0 -0
  42. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/hotkeys/base.py +0 -0
  43. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/hotkeys/chain.py +0 -0
  44. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/hotkeys/pynput_backend.py +0 -0
  45. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/hotkeys/socket_backend.py +0 -0
  46. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/ibus/__init__.py +0 -0
  47. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/ibus/engine.py +0 -0
  48. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/platform.py +0 -0
  49. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/recorder.py +0 -0
  50. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/service.py +0 -0
  51. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/sounds/__init__.py +0 -0
  52. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/streaming.py +0 -0
  53. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/transcriber.py +0 -0
  54. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/tray.py +0 -0
  55. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/typers/__init__.py +0 -0
  56. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/typers/base.py +0 -0
  57. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/typers/chain.py +0 -0
  58. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/typers/clipboard.py +0 -0
  59. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/typers/ibus.py +0 -0
  60. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/typers/pynput_type.py +0 -0
  61. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/typers/wtype.py +0 -0
  62. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/typers/xdotool.py +0 -0
  63. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/typers/ydotool.py +0 -0
  64. {python_voiceio-0.2.0 → python_voiceio-0.2.3}/voiceio/worker.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.2.0
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.3
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
@@ -19,8 +19,7 @@ License-File: LICENSE
19
19
  Requires-Dist: faster-whisper>=1.0.0
20
20
  Requires-Dist: sounddevice>=0.4.6
21
21
  Requires-Dist: numpy>=1.24.0
22
- Provides-Extra: linux
23
- Requires-Dist: evdev>=1.6.0; extra == "linux"
22
+ Requires-Dist: evdev>=1.6.0; sys_platform == "linux"
24
23
  Provides-Extra: x11
25
24
  Requires-Dist: pynput>=1.7.6; extra == "x11"
26
25
  Provides-Extra: mac
@@ -36,13 +35,11 @@ Dynamic: license-file
36
35
  # voiceio
37
36
 
38
37
  [![CI](https://github.com/Hugo0/voiceio/actions/workflows/ci.yml/badge.svg)](https://github.com/Hugo0/voiceio/actions/workflows/ci.yml)
39
- [![PyPI](https://img.shields.io/pypi/v/voiceio)](https://pypi.org/project/voiceio/)
40
- [![Python](https://img.shields.io/pypi/pyversions/voiceio)](https://pypi.org/project/voiceio/)
38
+ [![PyPI](https://img.shields.io/pypi/v/python-voiceio)](https://pypi.org/project/python-voiceio/)
39
+ [![Python](https://img.shields.io/pypi/pyversions/python-voiceio)](https://pypi.org/project/python-voiceio/)
41
40
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
42
41
 
43
- Push-to-talk voice-to-text for Linux and macOS, on any app. Press a hotkey, speak, press again - text appears at your cursor.
44
-
45
- 100% local and open source. No API keys, no cloud, no telemetry. Use and modify at your will.
42
+ Speak text, locally, instantly.
46
43
 
47
44
  <!-- demo video -->
48
45
  <p align="center">
@@ -60,7 +57,7 @@ Push-to-talk voice-to-text for Linux and macOS, on any app. Press a hotkey, spea
60
57
  sudo apt install pipx ibus gir1.2-ibus-1.0 python3-gi portaudio19-dev
61
58
 
62
59
  # 2. Install voiceio
63
- pipx install voiceio
60
+ pipx install python-voiceio
64
61
 
65
62
  # 3. Run the setup wizard
66
63
  voiceio setup
@@ -73,7 +70,7 @@ That's it. Press **Ctrl+Alt+V** (or your chosen hotkey) to start dictating.
73
70
 
74
71
  ```bash
75
72
  sudo dnf install pipx ibus python3-gobject portaudio-devel
76
- pipx install voiceio
73
+ pipx install python-voiceio
77
74
  voiceio setup
78
75
  ```
79
76
  </details>
@@ -83,7 +80,7 @@ voiceio setup
83
80
 
84
81
  ```bash
85
82
  sudo pacman -S python-pipx ibus python-gobject portaudio
86
- pipx install voiceio
83
+ pipx install python-voiceio
87
84
  voiceio setup
88
85
  ```
89
86
  </details>
@@ -194,7 +191,7 @@ voiceio auto-detects your platform and picks the best available backends. Run `v
194
191
 
195
192
  ```bash
196
193
  voiceio uninstall # removes service, IBus, shortcuts, symlinks
197
- pipx uninstall voiceio # removes the package
194
+ pipx uninstall python-voiceio # removes the package
198
195
  ```
199
196
 
200
197
  ## TODO
@@ -1,13 +1,11 @@
1
1
  # voiceio
2
2
 
3
3
  [![CI](https://github.com/Hugo0/voiceio/actions/workflows/ci.yml/badge.svg)](https://github.com/Hugo0/voiceio/actions/workflows/ci.yml)
4
- [![PyPI](https://img.shields.io/pypi/v/voiceio)](https://pypi.org/project/voiceio/)
5
- [![Python](https://img.shields.io/pypi/pyversions/voiceio)](https://pypi.org/project/voiceio/)
4
+ [![PyPI](https://img.shields.io/pypi/v/python-voiceio)](https://pypi.org/project/python-voiceio/)
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.
8
+ Speak text, locally, instantly.
11
9
 
12
10
  <!-- demo video -->
13
11
  <p align="center">
@@ -25,7 +23,7 @@ Push-to-talk voice-to-text for Linux and macOS, on any app. Press a hotkey, spea
25
23
  sudo apt install pipx ibus gir1.2-ibus-1.0 python3-gi portaudio19-dev
26
24
 
27
25
  # 2. Install voiceio
28
- pipx install voiceio
26
+ pipx install python-voiceio
29
27
 
30
28
  # 3. Run the setup wizard
31
29
  voiceio setup
@@ -38,7 +36,7 @@ That's it. Press **Ctrl+Alt+V** (or your chosen hotkey) to start dictating.
38
36
 
39
37
  ```bash
40
38
  sudo dnf install pipx ibus python3-gobject portaudio-devel
41
- pipx install voiceio
39
+ pipx install python-voiceio
42
40
  voiceio setup
43
41
  ```
44
42
  </details>
@@ -48,7 +46,7 @@ voiceio setup
48
46
 
49
47
  ```bash
50
48
  sudo pacman -S python-pipx ibus python-gobject portaudio
51
- pipx install voiceio
49
+ pipx install python-voiceio
52
50
  voiceio setup
53
51
  ```
54
52
  </details>
@@ -159,7 +157,7 @@ voiceio auto-detects your platform and picks the best available backends. Run `v
159
157
 
160
158
  ```bash
161
159
  voiceio uninstall # removes service, IBus, shortcuts, symlinks
162
- pipx uninstall voiceio # removes the package
160
+ pipx uninstall python-voiceio # removes the package
163
161
  ```
164
162
 
165
163
  ## TODO
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-voiceio"
7
- version = "0.2.0"
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.3"
8
+ description = "Speak text, locally, instantly."
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  requires-python = ">=3.11"
@@ -23,10 +23,10 @@ dependencies = [
23
23
  "faster-whisper>=1.0.0",
24
24
  "sounddevice>=0.4.6",
25
25
  "numpy>=1.24.0",
26
+ "evdev>=1.6.0; sys_platform == 'linux'",
26
27
  ]
27
28
 
28
29
  [project.optional-dependencies]
29
- linux = ["evdev>=1.6.0"]
30
30
  x11 = ["pynput>=1.7.6"]
31
31
  mac = ["pynput>=1.7.6"]
32
32
  tray = ["pystray>=0.19", "Pillow>=10.0"]
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.2.0
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.3
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
@@ -19,8 +19,7 @@ License-File: LICENSE
19
19
  Requires-Dist: faster-whisper>=1.0.0
20
20
  Requires-Dist: sounddevice>=0.4.6
21
21
  Requires-Dist: numpy>=1.24.0
22
- Provides-Extra: linux
23
- Requires-Dist: evdev>=1.6.0; extra == "linux"
22
+ Requires-Dist: evdev>=1.6.0; sys_platform == "linux"
24
23
  Provides-Extra: x11
25
24
  Requires-Dist: pynput>=1.7.6; extra == "x11"
26
25
  Provides-Extra: mac
@@ -36,13 +35,11 @@ Dynamic: license-file
36
35
  # voiceio
37
36
 
38
37
  [![CI](https://github.com/Hugo0/voiceio/actions/workflows/ci.yml/badge.svg)](https://github.com/Hugo0/voiceio/actions/workflows/ci.yml)
39
- [![PyPI](https://img.shields.io/pypi/v/voiceio)](https://pypi.org/project/voiceio/)
40
- [![Python](https://img.shields.io/pypi/pyversions/voiceio)](https://pypi.org/project/voiceio/)
38
+ [![PyPI](https://img.shields.io/pypi/v/python-voiceio)](https://pypi.org/project/python-voiceio/)
39
+ [![Python](https://img.shields.io/pypi/pyversions/python-voiceio)](https://pypi.org/project/python-voiceio/)
41
40
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
42
41
 
43
- Push-to-talk voice-to-text for Linux and macOS, on any app. Press a hotkey, speak, press again - text appears at your cursor.
44
-
45
- 100% local and open source. No API keys, no cloud, no telemetry. Use and modify at your will.
42
+ Speak text, locally, instantly.
46
43
 
47
44
  <!-- demo video -->
48
45
  <p align="center">
@@ -60,7 +57,7 @@ Push-to-talk voice-to-text for Linux and macOS, on any app. Press a hotkey, spea
60
57
  sudo apt install pipx ibus gir1.2-ibus-1.0 python3-gi portaudio19-dev
61
58
 
62
59
  # 2. Install voiceio
63
- pipx install voiceio
60
+ pipx install python-voiceio
64
61
 
65
62
  # 3. Run the setup wizard
66
63
  voiceio setup
@@ -73,7 +70,7 @@ That's it. Press **Ctrl+Alt+V** (or your chosen hotkey) to start dictating.
73
70
 
74
71
  ```bash
75
72
  sudo dnf install pipx ibus python3-gobject portaudio-devel
76
- pipx install voiceio
73
+ pipx install python-voiceio
77
74
  voiceio setup
78
75
  ```
79
76
  </details>
@@ -83,7 +80,7 @@ voiceio setup
83
80
 
84
81
  ```bash
85
82
  sudo pacman -S python-pipx ibus python-gobject portaudio
86
- pipx install voiceio
83
+ pipx install python-voiceio
87
84
  voiceio setup
88
85
  ```
89
86
  </details>
@@ -194,7 +191,7 @@ voiceio auto-detects your platform and picks the best available backends. Run `v
194
191
 
195
192
  ```bash
196
193
  voiceio uninstall # removes service, IBus, shortcuts, symlinks
197
- pipx uninstall voiceio # removes the package
194
+ pipx uninstall python-voiceio # removes the package
198
195
  ```
199
196
 
200
197
  ## TODO
@@ -2,13 +2,13 @@ faster-whisper>=1.0.0
2
2
  sounddevice>=0.4.6
3
3
  numpy>=1.24.0
4
4
 
5
+ [:sys_platform == "linux"]
6
+ evdev>=1.6.0
7
+
5
8
  [dev]
6
9
  pytest>=7.0
7
10
  pytest-mock
8
11
 
9
- [linux]
10
- evdev>=1.6.0
11
-
12
12
  [mac]
13
13
  pynput>=1.7.6
14
14
 
@@ -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.3"
@@ -33,7 +33,7 @@ class VoiceIO:
33
33
  self._typer = typer_chain.select(self.platform, cfg.output.method)
34
34
  self._auto_fallback = cfg.health.auto_fallback
35
35
 
36
- # Always start socket backend alongside native hotkey
36
+ # Socket backend runs alongside native hotkey for extra robustness
37
37
  self._socket: SocketHotkey | None = None
38
38
  if self._hotkey.name != "socket":
39
39
  self._socket = SocketHotkey()
@@ -47,6 +47,8 @@ class VoiceIO:
47
47
  self._session: StreamingSession | None = None
48
48
  self._processing = False
49
49
  self._record_start: float = 0
50
+ self._hotkey_lock = threading.Lock()
51
+ self._last_hotkey: float = 0
50
52
  self._prev_ibus_engine: str | None = None
51
53
  self._engine_proc: subprocess.Popen | None = None
52
54
  self._shutdown = threading.Event()
@@ -56,23 +58,21 @@ class VoiceIO:
56
58
  self._shutdown.set()
57
59
 
58
60
  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)
61
+ with self._hotkey_lock:
62
+ now = time.monotonic()
63
+ # Deduplicate: multiple backends (evdev + socket) may fire
64
+ # for the same physical keypress
65
+ if now - self._last_hotkey < 0.3:
74
66
  return
67
+ self._last_hotkey = now
68
+ self._on_hotkey_inner()
69
+ # Update timestamp after completion so threads that waited
70
+ # behind the lock see a fresh timestamp and get debounced
71
+ self._last_hotkey = time.monotonic()
75
72
 
73
+ def _on_hotkey_inner(self) -> None:
74
+ if self.recorder.is_recording:
75
+ elapsed = time.monotonic() - self._record_start
76
76
  self._play_record_cue(start=False)
77
77
  if self._streaming and self._session is not None:
78
78
  final_text = self._session.stop()
@@ -82,11 +82,11 @@ class VoiceIO:
82
82
  self._play_feedback(final_text)
83
83
  log.info("Streaming done (%.1fs): '%s'", elapsed, final_text)
84
84
  else:
85
+ self._play_record_cue(start=False)
85
86
  audio = self.recorder.stop()
86
87
  log.info("Stopped recording (%.1fs)", elapsed)
87
88
  if audio is not None and not self._processing:
88
89
  threading.Thread(target=self._process, args=(audio,), daemon=True).start()
89
- # Deactivate IBus engine, return keyboard to normal
90
90
  self._deactivate_ibus()
91
91
  elif not self._processing:
92
92
  # Activate IBus engine so preedit/commit can reach the focused app
@@ -429,14 +429,15 @@ def _cmd_uninstall() -> None:
429
429
  print("\nNothing to remove. voiceio was not installed on this system.")
430
430
 
431
431
  # Offer to uninstall the Python package itself
432
+ from voiceio.config import PYPI_NAME
432
433
  is_pipx = "pipx" in sys.prefix
433
434
  if is_pipx:
434
435
  answer = input("\nAlso uninstall the voiceio Python package (pipx uninstall)? [Y/n] ").strip().lower()
435
436
  if answer in ("y", "yes", ""):
436
437
  try:
437
- subprocess.run(["pipx", "uninstall", "voiceio"], timeout=30)
438
+ subprocess.run(["pipx", "uninstall", PYPI_NAME], timeout=30)
438
439
  except (FileNotFoundError, subprocess.TimeoutExpired):
439
- print("Failed. Run manually: pipx uninstall voiceio")
440
+ print(f"Failed. Run manually: pipx uninstall {PYPI_NAME}")
440
441
  else:
441
442
  # Dev install or pip install: check if voiceio is still reachable
442
443
  voiceio_bin = shutil.which("voiceio")
@@ -444,10 +445,10 @@ def _cmd_uninstall() -> None:
444
445
  print(f"\nNote: 'voiceio' is still available at {voiceio_bin}")
445
446
  if ".venv" in str(voiceio_bin) or "site-packages" in str(voiceio_bin):
446
447
  print("This is a development install. To fully remove:")
447
- print(" pip uninstall voiceio")
448
+ print(f" pip uninstall {PYPI_NAME}")
448
449
  else:
449
450
  print("To fully remove the package:")
450
- print(" pip uninstall voiceio")
451
+ print(f" pip uninstall {PYPI_NAME}")
451
452
  else:
452
453
  print("\nvoiceio fully removed.")
453
454
 
@@ -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
+ f" 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.0"
File without changes
File without changes