python-voiceio 0.2.0__py3-none-any.whl
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.0.dist-info/METADATA +260 -0
- python_voiceio-0.2.0.dist-info/RECORD +43 -0
- python_voiceio-0.2.0.dist-info/WHEEL +5 -0
- python_voiceio-0.2.0.dist-info/entry_points.txt +6 -0
- python_voiceio-0.2.0.dist-info/licenses/LICENSE +21 -0
- python_voiceio-0.2.0.dist-info/top_level.txt +1 -0
- voiceio/__init__.py +1 -0
- voiceio/__main__.py +3 -0
- voiceio/app.py +415 -0
- voiceio/backends.py +13 -0
- voiceio/cli.py +475 -0
- voiceio/config.py +136 -0
- voiceio/feedback.py +78 -0
- voiceio/health.py +194 -0
- voiceio/hotkeys/__init__.py +22 -0
- voiceio/hotkeys/base.py +27 -0
- voiceio/hotkeys/chain.py +83 -0
- voiceio/hotkeys/evdev.py +134 -0
- voiceio/hotkeys/pynput_backend.py +80 -0
- voiceio/hotkeys/socket_backend.py +77 -0
- voiceio/ibus/__init__.py +8 -0
- voiceio/ibus/engine.py +268 -0
- voiceio/platform.py +139 -0
- voiceio/recorder.py +208 -0
- voiceio/service.py +234 -0
- voiceio/sounds/__init__.py +0 -0
- voiceio/sounds/commit.wav +0 -0
- voiceio/sounds/start.wav +0 -0
- voiceio/sounds/stop.wav +0 -0
- voiceio/streaming.py +202 -0
- voiceio/transcriber.py +165 -0
- voiceio/tray.py +54 -0
- voiceio/typers/__init__.py +31 -0
- voiceio/typers/base.py +44 -0
- voiceio/typers/chain.py +79 -0
- voiceio/typers/clipboard.py +110 -0
- voiceio/typers/ibus.py +389 -0
- voiceio/typers/pynput_type.py +51 -0
- voiceio/typers/wtype.py +57 -0
- voiceio/typers/xdotool.py +45 -0
- voiceio/typers/ydotool.py +115 -0
- voiceio/wizard.py +882 -0
- voiceio/worker.py +39 -0
voiceio/health.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Health check / diagnostic report for all backends."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from voiceio import platform as plat
|
|
8
|
+
from voiceio.hotkeys import chain as hotkey_chain
|
|
9
|
+
from voiceio.typers import chain as typer_chain
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class BackendStatus:
|
|
16
|
+
name: str
|
|
17
|
+
ok: bool
|
|
18
|
+
reason: str = ""
|
|
19
|
+
fix_hint: str = ""
|
|
20
|
+
fix_cmd: list[str] = field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class IBusStatus:
|
|
25
|
+
component_installed: bool = False
|
|
26
|
+
daemon_running: bool = False
|
|
27
|
+
socket_reachable: bool = False
|
|
28
|
+
gnome_source_configured: bool = False
|
|
29
|
+
env_persisted: bool = False
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class HealthReport:
|
|
33
|
+
platform: plat.Platform
|
|
34
|
+
hotkey_backends: list[BackendStatus] = field(default_factory=list)
|
|
35
|
+
typer_backends: list[BackendStatus] = field(default_factory=list)
|
|
36
|
+
audio_ok: bool = False
|
|
37
|
+
audio_reason: str = ""
|
|
38
|
+
cli_in_path: bool = False
|
|
39
|
+
ibus_checks: IBusStatus | None = None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def all_ok(self) -> bool:
|
|
43
|
+
has_hotkey = any(b.ok for b in self.hotkey_backends)
|
|
44
|
+
has_typer = any(b.ok for b in self.typer_backends)
|
|
45
|
+
return has_hotkey and has_typer and self.audio_ok
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def check_health(p: plat.Platform | None = None) -> HealthReport:
|
|
49
|
+
"""Run all probes and return a health report."""
|
|
50
|
+
if p is None:
|
|
51
|
+
p = plat.detect()
|
|
52
|
+
|
|
53
|
+
report = HealthReport(platform=p)
|
|
54
|
+
|
|
55
|
+
# Probe hotkey backends
|
|
56
|
+
for name, backend, probe in hotkey_chain.resolve(p):
|
|
57
|
+
report.hotkey_backends.append(BackendStatus(
|
|
58
|
+
name=name, ok=probe.ok, reason=probe.reason,
|
|
59
|
+
fix_hint=probe.fix_hint, fix_cmd=probe.fix_cmd,
|
|
60
|
+
))
|
|
61
|
+
|
|
62
|
+
# Probe typer backends
|
|
63
|
+
for name, backend, probe in typer_chain.resolve(p):
|
|
64
|
+
report.typer_backends.append(BackendStatus(
|
|
65
|
+
name=name, ok=probe.ok, reason=probe.reason,
|
|
66
|
+
fix_hint=probe.fix_hint, fix_cmd=probe.fix_cmd,
|
|
67
|
+
))
|
|
68
|
+
|
|
69
|
+
# Check audio
|
|
70
|
+
try:
|
|
71
|
+
import sounddevice as _sd
|
|
72
|
+
_sd.query_devices(kind="input")
|
|
73
|
+
report.audio_ok = True
|
|
74
|
+
except Exception as e:
|
|
75
|
+
report.audio_reason = str(e)
|
|
76
|
+
|
|
77
|
+
# Check CLI in PATH
|
|
78
|
+
from voiceio.service import symlinks_installed
|
|
79
|
+
report.cli_in_path = symlinks_installed()
|
|
80
|
+
|
|
81
|
+
# IBus-specific checks (only if IBus is in the typer chain)
|
|
82
|
+
ibus_backends = [b for b in report.typer_backends if b.name == "ibus"]
|
|
83
|
+
if ibus_backends and ibus_backends[0].ok:
|
|
84
|
+
report.ibus_checks = _check_ibus()
|
|
85
|
+
|
|
86
|
+
return report
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _check_ibus() -> IBusStatus:
|
|
90
|
+
"""Run detailed IBus health checks."""
|
|
91
|
+
from pathlib import Path
|
|
92
|
+
from voiceio.ibus import SOCKET_PATH
|
|
93
|
+
from voiceio.typers.ibus import (
|
|
94
|
+
_ibus_daemon_running, _component_installed,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
status = IBusStatus()
|
|
98
|
+
status.component_installed = _component_installed()
|
|
99
|
+
status.daemon_running = _ibus_daemon_running()
|
|
100
|
+
status.socket_reachable = SOCKET_PATH.exists()
|
|
101
|
+
|
|
102
|
+
# Check GNOME input source
|
|
103
|
+
try:
|
|
104
|
+
from voiceio.platform import detect
|
|
105
|
+
if detect().is_gnome:
|
|
106
|
+
import subprocess
|
|
107
|
+
result = subprocess.run(
|
|
108
|
+
["gsettings", "get", "org.gnome.desktop.input-sources", "sources"],
|
|
109
|
+
capture_output=True, text=True, timeout=3,
|
|
110
|
+
)
|
|
111
|
+
status.gnome_source_configured = (
|
|
112
|
+
result.returncode == 0 and "('ibus', 'voiceio')" in result.stdout
|
|
113
|
+
)
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
# Check env persistence
|
|
118
|
+
env_file = Path.home() / ".config" / "environment.d" / "voiceio.conf"
|
|
119
|
+
status.env_persisted = env_file.exists()
|
|
120
|
+
|
|
121
|
+
return status
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _icon(ok: bool, warn: bool = False) -> str:
|
|
125
|
+
if ok:
|
|
126
|
+
return "\u2714" # ✔
|
|
127
|
+
if warn:
|
|
128
|
+
return "\u26A0" # ⚠
|
|
129
|
+
return "\u2718" # ✘
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _backend_line(b: BackendStatus, active: bool = False) -> list[str]:
|
|
133
|
+
lines = []
|
|
134
|
+
icon = _icon(b.ok)
|
|
135
|
+
suffix = " \u25C0 active" if active else ""
|
|
136
|
+
line = f" {icon} {b.name}{suffix}"
|
|
137
|
+
if not b.ok:
|
|
138
|
+
line += f" \u2014 {b.reason}"
|
|
139
|
+
lines.append(line)
|
|
140
|
+
if b.fix_hint and not b.ok:
|
|
141
|
+
lines.append(f" \u2192 {b.fix_hint}")
|
|
142
|
+
return lines
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def format_report(report: HealthReport) -> str:
|
|
146
|
+
"""Format a health report as a human-readable string."""
|
|
147
|
+
lines = []
|
|
148
|
+
lines.append(f"Platform: {report.platform.os} / {report.platform.display_server} / {report.platform.desktop}")
|
|
149
|
+
lines.append("")
|
|
150
|
+
|
|
151
|
+
# Find which backends are active (first OK in each list)
|
|
152
|
+
active_hotkey = next((b.name for b in report.hotkey_backends if b.ok), None)
|
|
153
|
+
active_typer = next((b.name for b in report.typer_backends if b.ok), None)
|
|
154
|
+
|
|
155
|
+
lines.append("Hotkey backends:")
|
|
156
|
+
for b in report.hotkey_backends:
|
|
157
|
+
lines.extend(_backend_line(b, active=(b.name == active_hotkey)))
|
|
158
|
+
|
|
159
|
+
lines.append("")
|
|
160
|
+
lines.append("Typer backends:")
|
|
161
|
+
for b in report.typer_backends:
|
|
162
|
+
lines.extend(_backend_line(b, active=(b.name == active_typer)))
|
|
163
|
+
|
|
164
|
+
lines.append("")
|
|
165
|
+
icon = _icon(report.audio_ok)
|
|
166
|
+
line = f"Audio: {icon}"
|
|
167
|
+
if not report.audio_ok:
|
|
168
|
+
line += f" \u2014 {report.audio_reason}"
|
|
169
|
+
lines.append(line)
|
|
170
|
+
|
|
171
|
+
icon = _icon(report.cli_in_path)
|
|
172
|
+
line = f"CLI in PATH: {icon}"
|
|
173
|
+
if not report.cli_in_path:
|
|
174
|
+
line += " \u2014 run 'voiceio setup' or 'voiceio doctor --fix'"
|
|
175
|
+
lines.append(line)
|
|
176
|
+
|
|
177
|
+
if report.ibus_checks is not None:
|
|
178
|
+
ibus = report.ibus_checks
|
|
179
|
+
lines.append("")
|
|
180
|
+
lines.append("IBus pipeline:")
|
|
181
|
+
for label, ok, hint in [
|
|
182
|
+
("Component installed", ibus.component_installed, "voiceio setup"),
|
|
183
|
+
("Daemon running", ibus.daemon_running, "ibus-daemon -drxR"),
|
|
184
|
+
("Engine socket", ibus.socket_reachable, "start voiceio to activate"),
|
|
185
|
+
("GNOME input source", ibus.gnome_source_configured, "voiceio setup"),
|
|
186
|
+
("Env persisted (reboot-safe)", ibus.env_persisted, "voiceio setup"),
|
|
187
|
+
]:
|
|
188
|
+
icon = _icon(ok, warn=True)
|
|
189
|
+
line = f" {icon} {label}"
|
|
190
|
+
if not ok:
|
|
191
|
+
line += f" \u2014 {hint}"
|
|
192
|
+
lines.append(line)
|
|
193
|
+
|
|
194
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Hotkey detection backends."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from voiceio.hotkeys.base import HotkeyBackend
|
|
8
|
+
from voiceio.platform import Platform
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_hotkey_backend(name: str, platform: Platform) -> HotkeyBackend:
|
|
12
|
+
"""Create a hotkey backend by name."""
|
|
13
|
+
if name == "evdev":
|
|
14
|
+
from voiceio.hotkeys.evdev import EvdevHotkey
|
|
15
|
+
return EvdevHotkey()
|
|
16
|
+
if name == "pynput":
|
|
17
|
+
from voiceio.hotkeys.pynput_backend import PynputHotkey
|
|
18
|
+
return PynputHotkey()
|
|
19
|
+
if name == "socket":
|
|
20
|
+
from voiceio.hotkeys.socket_backend import SocketHotkey
|
|
21
|
+
return SocketHotkey()
|
|
22
|
+
raise ValueError(f"Unknown hotkey backend: {name}")
|
voiceio/hotkeys/base.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Base protocol and types for hotkey backends."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Callable, Protocol, runtime_checkable
|
|
5
|
+
|
|
6
|
+
from voiceio.backends import ProbeResult
|
|
7
|
+
|
|
8
|
+
__all__ = ["ProbeResult", "HotkeyBackend"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@runtime_checkable
|
|
12
|
+
class HotkeyBackend(Protocol):
|
|
13
|
+
"""Protocol for hotkey detection backends."""
|
|
14
|
+
|
|
15
|
+
name: str
|
|
16
|
+
|
|
17
|
+
def probe(self) -> ProbeResult:
|
|
18
|
+
"""Check if this backend can work on the current system."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
def start(self, combo: str, on_trigger: Callable[[], None]) -> None:
|
|
22
|
+
"""Start listening for the hotkey combo. Non-blocking."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
def stop(self) -> None:
|
|
26
|
+
"""Stop listening. Idempotent."""
|
|
27
|
+
...
|
voiceio/hotkeys/chain.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Fallback chain for hotkey backends."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from voiceio.backends import ProbeResult
|
|
8
|
+
from voiceio.hotkeys.base import HotkeyBackend
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from voiceio.platform import Platform
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# Preference order by platform
|
|
16
|
+
_CHAINS: dict[tuple[str, str], list[str]] = {
|
|
17
|
+
# (display_server, desktop) -> backend list
|
|
18
|
+
("x11", "*"): ["pynput", "evdev", "socket"],
|
|
19
|
+
("wayland", "*"): ["evdev", "socket"],
|
|
20
|
+
("quartz", "*"): ["pynput"],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_chain(platform: Platform) -> list[str]:
|
|
25
|
+
"""Get the preference chain for this platform."""
|
|
26
|
+
# Try exact match first
|
|
27
|
+
key = (platform.display_server, platform.desktop)
|
|
28
|
+
if key in _CHAINS:
|
|
29
|
+
return _CHAINS[key]
|
|
30
|
+
# Try wildcard desktop
|
|
31
|
+
key = (platform.display_server, "*")
|
|
32
|
+
if key in _CHAINS:
|
|
33
|
+
return _CHAINS[key]
|
|
34
|
+
# Fallback
|
|
35
|
+
return ["socket"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve(platform: Platform, override: str | None = None) -> list[tuple[str, HotkeyBackend, ProbeResult]]:
|
|
39
|
+
"""Probe backends in preference order, return list of (name, backend, probe_result).
|
|
40
|
+
|
|
41
|
+
If override is set (not "auto"/None), only probe that one backend.
|
|
42
|
+
"""
|
|
43
|
+
from voiceio.hotkeys import create_hotkey_backend
|
|
44
|
+
|
|
45
|
+
if override and override != "auto":
|
|
46
|
+
backend = create_hotkey_backend(override, platform)
|
|
47
|
+
result = backend.probe()
|
|
48
|
+
return [(override, backend, result)]
|
|
49
|
+
|
|
50
|
+
chain = _get_chain(platform)
|
|
51
|
+
results = []
|
|
52
|
+
for name in chain:
|
|
53
|
+
try:
|
|
54
|
+
backend = create_hotkey_backend(name, platform)
|
|
55
|
+
result = backend.probe()
|
|
56
|
+
results.append((name, backend, result))
|
|
57
|
+
except Exception as e:
|
|
58
|
+
log.debug("Failed to create backend '%s': %s", name, e)
|
|
59
|
+
results.append((name, None, ProbeResult(ok=False, reason=str(e))))
|
|
60
|
+
|
|
61
|
+
return results
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def select(platform: Platform, override: str | None = None) -> HotkeyBackend:
|
|
65
|
+
"""Select the first working hotkey backend.
|
|
66
|
+
|
|
67
|
+
Raises RuntimeError if none work.
|
|
68
|
+
"""
|
|
69
|
+
results = resolve(platform, override)
|
|
70
|
+
|
|
71
|
+
for name, backend, probe in results:
|
|
72
|
+
if probe.ok and backend is not None:
|
|
73
|
+
log.info("Selected hotkey backend: %s", name)
|
|
74
|
+
return backend
|
|
75
|
+
log.debug("Hotkey backend '%s' unavailable: %s", name, probe.reason)
|
|
76
|
+
|
|
77
|
+
# Build error message
|
|
78
|
+
reasons = [f" {name}: {probe.reason}" for name, _, probe in results if not probe.ok]
|
|
79
|
+
hints = [probe.fix_hint for _, _, probe in results if probe.fix_hint]
|
|
80
|
+
msg = "No working hotkey backend found:\n" + "\n".join(reasons)
|
|
81
|
+
if hints:
|
|
82
|
+
msg += "\n\nTo fix:\n" + "\n".join(f" - {h}" for h in hints)
|
|
83
|
+
raise RuntimeError(msg)
|
voiceio/hotkeys/evdev.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Evdev hotkey backend: reads /dev/input directly."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
from voiceio.backends import ProbeResult
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
DEBOUNCE_SECS = 0.8
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EvdevHotkey:
|
|
18
|
+
"""Hotkey detection via Linux evdev (needs input group)."""
|
|
19
|
+
|
|
20
|
+
name = "evdev"
|
|
21
|
+
|
|
22
|
+
def probe(self) -> ProbeResult:
|
|
23
|
+
try:
|
|
24
|
+
import evdev # noqa: F401
|
|
25
|
+
except ImportError:
|
|
26
|
+
return ProbeResult(ok=False, reason="evdev package not installed",
|
|
27
|
+
fix_hint="pip install evdev")
|
|
28
|
+
|
|
29
|
+
# Check if we can open any keyboard device
|
|
30
|
+
for path in sorted(Path("/dev/input/").glob("event*")):
|
|
31
|
+
try:
|
|
32
|
+
with open(path, "rb"):
|
|
33
|
+
return ProbeResult(ok=True)
|
|
34
|
+
except PermissionError:
|
|
35
|
+
import getpass
|
|
36
|
+
return ProbeResult(
|
|
37
|
+
ok=False, reason="No permission to read /dev/input",
|
|
38
|
+
fix_hint="sudo usermod -aG input $USER && newgrp input",
|
|
39
|
+
fix_cmd=["sudo", "usermod", "-aG", "input", getpass.getuser()],
|
|
40
|
+
)
|
|
41
|
+
except OSError:
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
return ProbeResult(ok=False, reason="No input devices found")
|
|
45
|
+
|
|
46
|
+
def start(self, combo: str, on_trigger: Callable[[], None]) -> None:
|
|
47
|
+
import evdev
|
|
48
|
+
from evdev import ecodes
|
|
49
|
+
|
|
50
|
+
MODIFIER_MAP = {
|
|
51
|
+
"super": {ecodes.KEY_LEFTMETA, ecodes.KEY_RIGHTMETA},
|
|
52
|
+
"ctrl": {ecodes.KEY_LEFTCTRL, ecodes.KEY_RIGHTCTRL},
|
|
53
|
+
"alt": {ecodes.KEY_LEFTALT, ecodes.KEY_RIGHTALT},
|
|
54
|
+
"shift": {ecodes.KEY_LEFTSHIFT, ecodes.KEY_RIGHTSHIFT},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
KEY_MAP = {
|
|
58
|
+
**{chr(c): getattr(ecodes, f"KEY_{chr(c).upper()}") for c in range(ord("a"), ord("z") + 1)},
|
|
59
|
+
**{str(i): getattr(ecodes, f"KEY_{i}") for i in range(10)},
|
|
60
|
+
**{f"f{i}": getattr(ecodes, f"KEY_F{i}") for i in range(1, 13)},
|
|
61
|
+
"space": ecodes.KEY_SPACE,
|
|
62
|
+
"pause": ecodes.KEY_PAUSE,
|
|
63
|
+
"insert": ecodes.KEY_INSERT,
|
|
64
|
+
"scroll_lock": ecodes.KEY_SCROLLLOCK,
|
|
65
|
+
"print_screen": ecodes.KEY_SYSRQ,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
parts = [p.strip().lower() for p in combo.split("+")]
|
|
69
|
+
required_mods: set[int] = set()
|
|
70
|
+
for mod in parts[:-1]:
|
|
71
|
+
required_mods.update(MODIFIER_MAP.get(mod, set()))
|
|
72
|
+
key_code = KEY_MAP.get(parts[-1])
|
|
73
|
+
if key_code is None:
|
|
74
|
+
raise ValueError(f"Unknown key: {parts[-1]}")
|
|
75
|
+
|
|
76
|
+
keyboards = []
|
|
77
|
+
for path in sorted(Path("/dev/input/").glob("event*")):
|
|
78
|
+
try:
|
|
79
|
+
dev = evdev.InputDevice(str(path))
|
|
80
|
+
caps = dev.capabilities(verbose=False)
|
|
81
|
+
if ecodes.EV_KEY in caps:
|
|
82
|
+
keys = caps[ecodes.EV_KEY]
|
|
83
|
+
if ecodes.KEY_A in keys and ecodes.KEY_Z in keys:
|
|
84
|
+
keyboards.append(dev)
|
|
85
|
+
except (PermissionError, OSError):
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
if not keyboards:
|
|
89
|
+
raise RuntimeError("No keyboard devices accessible")
|
|
90
|
+
|
|
91
|
+
self._running = threading.Event()
|
|
92
|
+
self._running.set()
|
|
93
|
+
pressed: set[int] = set()
|
|
94
|
+
pressed_lock = threading.Lock()
|
|
95
|
+
last_trigger = [0.0]
|
|
96
|
+
|
|
97
|
+
def check_mods() -> bool:
|
|
98
|
+
for codes in MODIFIER_MAP.values():
|
|
99
|
+
if codes & required_mods and not (codes & pressed):
|
|
100
|
+
return False
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
def read_device(dev: evdev.InputDevice) -> None:
|
|
104
|
+
try:
|
|
105
|
+
for event in dev.read_loop():
|
|
106
|
+
if not self._running.is_set():
|
|
107
|
+
break
|
|
108
|
+
if event.type != ecodes.EV_KEY:
|
|
109
|
+
continue
|
|
110
|
+
key_event = evdev.categorize(event)
|
|
111
|
+
with pressed_lock:
|
|
112
|
+
if key_event.keystate == evdev.KeyEvent.key_down:
|
|
113
|
+
pressed.add(event.code)
|
|
114
|
+
if event.code == key_code and check_mods():
|
|
115
|
+
now = time.monotonic()
|
|
116
|
+
if now - last_trigger[0] >= DEBOUNCE_SECS:
|
|
117
|
+
last_trigger[0] = now
|
|
118
|
+
on_trigger()
|
|
119
|
+
elif key_event.keystate == evdev.KeyEvent.key_up:
|
|
120
|
+
pressed.discard(event.code)
|
|
121
|
+
except OSError:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
self._threads = []
|
|
125
|
+
for dev in keyboards:
|
|
126
|
+
t = threading.Thread(target=read_device, args=(dev,), daemon=True)
|
|
127
|
+
t.start()
|
|
128
|
+
self._threads.append(t)
|
|
129
|
+
|
|
130
|
+
log.info("Evdev hotkey listener started on %d keyboards", len(keyboards))
|
|
131
|
+
|
|
132
|
+
def stop(self) -> None:
|
|
133
|
+
if hasattr(self, "_running"):
|
|
134
|
+
self._running.clear()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Pynput hotkey backend for X11 and macOS."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from voiceio.backends import ProbeResult
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
DEBOUNCE_SECS = 0.8
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PynputHotkey:
|
|
16
|
+
"""Hotkey detection via pynput (X11 + macOS)."""
|
|
17
|
+
|
|
18
|
+
name = "pynput"
|
|
19
|
+
|
|
20
|
+
def probe(self) -> ProbeResult:
|
|
21
|
+
try:
|
|
22
|
+
import pynput # noqa: F401
|
|
23
|
+
except ImportError:
|
|
24
|
+
return ProbeResult(ok=False, reason="pynput package not installed",
|
|
25
|
+
fix_hint="pip install pynput")
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
session = os.environ.get("XDG_SESSION_TYPE", "")
|
|
29
|
+
if session == "wayland":
|
|
30
|
+
return ProbeResult(ok=False, reason="pynput does not work on Wayland",
|
|
31
|
+
fix_hint="Use evdev or socket backend instead.")
|
|
32
|
+
|
|
33
|
+
return ProbeResult(ok=True)
|
|
34
|
+
|
|
35
|
+
def start(self, combo: str, on_trigger: Callable[[], None]) -> None:
|
|
36
|
+
from pynput import keyboard
|
|
37
|
+
|
|
38
|
+
MOD_MAP = {
|
|
39
|
+
"super": {keyboard.Key.cmd, keyboard.Key.cmd_l, keyboard.Key.cmd_r},
|
|
40
|
+
"ctrl": {keyboard.Key.ctrl, keyboard.Key.ctrl_l, keyboard.Key.ctrl_r},
|
|
41
|
+
"alt": {keyboard.Key.alt, keyboard.Key.alt_l, keyboard.Key.alt_r},
|
|
42
|
+
"shift": {keyboard.Key.shift, keyboard.Key.shift_l, keyboard.Key.shift_r},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
parts = [p.strip().lower() for p in combo.split("+")]
|
|
46
|
+
required_mods = [parts[i] for i in range(len(parts) - 1)]
|
|
47
|
+
key_name = parts[-1]
|
|
48
|
+
if len(key_name) == 1:
|
|
49
|
+
target_key = keyboard.KeyCode.from_char(key_name)
|
|
50
|
+
else:
|
|
51
|
+
target_key = getattr(keyboard.Key, key_name)
|
|
52
|
+
|
|
53
|
+
pressed_mods: set = set()
|
|
54
|
+
last_trigger = [0.0]
|
|
55
|
+
|
|
56
|
+
def on_press(key):
|
|
57
|
+
for mod_keys in MOD_MAP.values():
|
|
58
|
+
if key in mod_keys:
|
|
59
|
+
pressed_mods.add(key)
|
|
60
|
+
if key == target_key:
|
|
61
|
+
for mod_name in required_mods:
|
|
62
|
+
if not (MOD_MAP[mod_name] & pressed_mods):
|
|
63
|
+
return
|
|
64
|
+
now = time.monotonic()
|
|
65
|
+
if now - last_trigger[0] >= DEBOUNCE_SECS:
|
|
66
|
+
last_trigger[0] = now
|
|
67
|
+
on_trigger()
|
|
68
|
+
|
|
69
|
+
def on_release(key):
|
|
70
|
+
for mod_keys in MOD_MAP.values():
|
|
71
|
+
if key in mod_keys:
|
|
72
|
+
pressed_mods.discard(key)
|
|
73
|
+
|
|
74
|
+
self._listener = keyboard.Listener(on_press=on_press, on_release=on_release)
|
|
75
|
+
self._listener.start()
|
|
76
|
+
log.info("Pynput hotkey listener started")
|
|
77
|
+
|
|
78
|
+
def stop(self) -> None:
|
|
79
|
+
if hasattr(self, "_listener"):
|
|
80
|
+
self._listener.stop()
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Socket-based hotkey backend: DE shortcut fires voiceio-toggle."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Callable
|
|
11
|
+
|
|
12
|
+
from voiceio.backends import ProbeResult
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
SOCKET_PATH = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "voiceio.sock"
|
|
17
|
+
DEBOUNCE_SECS = 0.8
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SocketHotkey:
|
|
21
|
+
"""Listens on a Unix DGRAM socket for 'toggle' commands."""
|
|
22
|
+
|
|
23
|
+
name = "socket"
|
|
24
|
+
|
|
25
|
+
def probe(self) -> ProbeResult:
|
|
26
|
+
runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
|
|
27
|
+
if not runtime_dir:
|
|
28
|
+
return ProbeResult(ok=False, reason="XDG_RUNTIME_DIR not set",
|
|
29
|
+
fix_hint="Running under a normal user session should set this.")
|
|
30
|
+
return ProbeResult(ok=True)
|
|
31
|
+
|
|
32
|
+
def start(self, combo: str, on_trigger: Callable[[], None]) -> None:
|
|
33
|
+
SOCKET_PATH.unlink(missing_ok=True)
|
|
34
|
+
|
|
35
|
+
self._on_trigger = on_trigger
|
|
36
|
+
self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
|
37
|
+
self._sock.bind(str(SOCKET_PATH))
|
|
38
|
+
self._sock.settimeout(1.0)
|
|
39
|
+
self._running = True
|
|
40
|
+
self._last_trigger: float = 0
|
|
41
|
+
self._thread = threading.Thread(target=self._loop, daemon=True)
|
|
42
|
+
self._thread.start()
|
|
43
|
+
log.debug("Socket listener started at %s", SOCKET_PATH)
|
|
44
|
+
|
|
45
|
+
def _loop(self) -> None:
|
|
46
|
+
while self._running:
|
|
47
|
+
try:
|
|
48
|
+
data = self._sock.recv(64)
|
|
49
|
+
if data != b"toggle":
|
|
50
|
+
continue
|
|
51
|
+
now = time.monotonic()
|
|
52
|
+
if now - self._last_trigger < DEBOUNCE_SECS:
|
|
53
|
+
continue
|
|
54
|
+
self._last_trigger = now
|
|
55
|
+
self._on_trigger()
|
|
56
|
+
except socket.timeout:
|
|
57
|
+
continue
|
|
58
|
+
except OSError:
|
|
59
|
+
break
|
|
60
|
+
|
|
61
|
+
def stop(self) -> None:
|
|
62
|
+
self._running = False
|
|
63
|
+
if hasattr(self, "_sock") and self._sock:
|
|
64
|
+
self._sock.close()
|
|
65
|
+
SOCKET_PATH.unlink(missing_ok=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def send_toggle() -> bool:
|
|
69
|
+
"""Send a toggle command to the running daemon."""
|
|
70
|
+
try:
|
|
71
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
|
72
|
+
sock.sendto(b"toggle", str(SOCKET_PATH))
|
|
73
|
+
sock.close()
|
|
74
|
+
return True
|
|
75
|
+
except (ConnectionRefusedError, FileNotFoundError, OSError) as e:
|
|
76
|
+
log.error("Could not reach voiceio daemon: %s", e)
|
|
77
|
+
return False
|
voiceio/ibus/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""IBus input method engine for VoiceIO."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
SOCKET_PATH = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "voiceio-ibus.sock"
|
|
8
|
+
READY_PATH = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "voiceio-ibus.ready"
|