python-voiceio 0.2.1__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.
- {python_voiceio-0.2.1/python_voiceio.egg-info → python_voiceio-0.2.3}/PKG-INFO +3 -5
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/README.md +1 -3
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/pyproject.toml +2 -2
- {python_voiceio-0.2.1 → python_voiceio-0.2.3/python_voiceio.egg-info}/PKG-INFO +3 -5
- python_voiceio-0.2.3/tests/test_app_wiring.py +139 -0
- python_voiceio-0.2.3/voiceio/__init__.py +1 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/app.py +17 -17
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/cli.py +5 -4
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/config.py +2 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/hotkeys/evdev.py +6 -2
- python_voiceio-0.2.3/voiceio/sounds/commit.wav +0 -0
- python_voiceio-0.2.3/voiceio/sounds/start.wav +0 -0
- python_voiceio-0.2.3/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/wizard.py +13 -12
- python_voiceio-0.2.1/tests/test_app_wiring.py +0 -135
- python_voiceio-0.2.1/voiceio/__init__.py +0 -1
- python_voiceio-0.2.1/voiceio/sounds/commit.wav +0 -0
- python_voiceio-0.2.1/voiceio/sounds/start.wav +0 -0
- python_voiceio-0.2.1/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/LICENSE +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/python_voiceio.egg-info/SOURCES.txt +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/python_voiceio.egg-info/entry_points.txt +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/setup.cfg +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/tests/test_backend_probes.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/tests/test_config.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/tests/test_fallback.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/tests/test_health.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/tests/test_ibus_typer.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/tests/test_platform.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/tests/test_prebuffer.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/tests/test_streaming.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/tests/test_transcriber.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/__main__.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/backends.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/feedback.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/health.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/ibus/__init__.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/ibus/engine.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/platform.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/recorder.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/service.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/streaming.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/transcriber.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/tray.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/typers/base.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/typers/chain.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/typers/clipboard.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/typers/ibus.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-0.2.1 → python_voiceio-0.2.3}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-0.2.1 → 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.
|
|
4
|
-
Summary:
|
|
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
|
|
@@ -39,9 +39,7 @@ Dynamic: license-file
|
|
|
39
39
|
[](https://pypi.org/project/python-voiceio/)
|
|
40
40
|
[](LICENSE)
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
100% local and open source. No API keys, no cloud, no telemetry. Use and modify at your will.
|
|
42
|
+
Speak → text, locally, instantly.
|
|
45
43
|
|
|
46
44
|
<!-- demo video -->
|
|
47
45
|
<p align="center">
|
|
@@ -5,9 +5,7 @@
|
|
|
5
5
|
[](https://pypi.org/project/python-voiceio/)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-
|
|
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">
|
|
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-voiceio"
|
|
7
|
-
version = "0.2.
|
|
8
|
-
description = "
|
|
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"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-voiceio
|
|
3
|
-
Version: 0.2.
|
|
4
|
-
Summary:
|
|
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
|
|
@@ -39,9 +39,7 @@ Dynamic: license-file
|
|
|
39
39
|
[](https://pypi.org/project/python-voiceio/)
|
|
40
40
|
[](LICENSE)
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
100% local and open source. No API keys, no cloud, no telemetry. Use and modify at your will.
|
|
42
|
+
Speak → text, locally, instantly.
|
|
45
43
|
|
|
46
44
|
<!-- demo video -->
|
|
47
45
|
<p align="center">
|
|
@@ -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
|
-
#
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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",
|
|
438
|
+
subprocess.run(["pipx", "uninstall", PYPI_NAME], timeout=30)
|
|
438
439
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
439
|
-
print("Failed. Run manually: pipx uninstall
|
|
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
|
|
448
|
+
print(f" pip uninstall {PYPI_NAME}")
|
|
448
449
|
else:
|
|
449
450
|
print("To fully remove the package:")
|
|
450
|
-
print(" pip uninstall
|
|
451
|
+
print(f" pip uninstall {PYPI_NAME}")
|
|
451
452
|
else:
|
|
452
453
|
print("\nvoiceio fully removed.")
|
|
453
454
|
|
|
@@ -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
|
-
|
|
117
|
+
since = now - last_trigger[0]
|
|
118
|
+
if since >= DEBOUNCE_SECS:
|
|
117
119
|
last_trigger[0] = now
|
|
118
|
-
|
|
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
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -844,22 +844,23 @@ def run_wizard() -> None:
|
|
|
844
844
|
_streaming_test(model=_get_or_load_model())
|
|
845
845
|
|
|
846
846
|
# ── Done ────────────────────────────────────────────────────────────
|
|
847
|
-
#
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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"
|
|
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.1"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|