From e802ab2b3c1df6e7ea9adc72180ce810fd3d4338 Mon Sep 17 00:00:00 2001 From: Christian Kauer Date: Sun, 22 Mar 2026 13:12:23 +0100 Subject: [PATCH] fix linux --- .claude/settings.local.json | 4 +- README.md | 10 ++ shared_data/vocabulary.json | 128 ++++++++------- whisper_app/hotkey.py | 318 +++++++++++++++++++++++++----------- 4 files changed, 306 insertions(+), 154 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2897294..62baf3b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -65,7 +65,9 @@ "Bash(lspci)", "Bash(pacman -Q)", "Bash(/run/media/chk/Ventoy/projects/chrka/whisper-dictation/.venv-linux/bin/pip install:*)", - "Bash(.venv-linux/bin/python build.py)" + "Bash(.venv-linux/bin/python build.py)", + "Bash(.venv-linux/bin/pip list:*)", + "Bash(.venv-linux/bin/python -c \":*)" ] } } diff --git a/README.md b/README.md index 99ca4c0..867b69b 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,16 @@ sudo apt install python3-tk libayatana-appindicator3-1 wl-clipboard xdotool | `wl-clipboard` | Text injection on Wayland (`wl-copy`) | | `xdotool` | Simulates Ctrl+V paste on Wayland, text typing on X11 | +**Low-latency hotkey (recommended):** + +For fast hotkey response via evdev (instead of the slower XWayland fallback), add your user to the `input` group: + +```bash +sudo usermod -aG input $USER +``` + +Log out and back in for the change to take effect. + **Optional (for GPU acceleration):** Arch/CachyOS: diff --git a/shared_data/vocabulary.json b/shared_data/vocabulary.json index c23e13d..7f20204 100644 --- a/shared_data/vocabulary.json +++ b/shared_data/vocabulary.json @@ -1,63 +1,67 @@ -{ - "words": [ - "test" - ], - "replacements": [ - { - "from": "KRA", - "to": "KRAH" - }, - { - "from": "Atos", - "to": "ATHOS" - }, - { - "from": "Resistec", - "to": "RESISTEC" - }, - { - "from": "Resistek", - "to": "RESISTEC" - }, - { - "from": "HES", - "to": "HEES" - }, - { - "from": "Ackerschot", - "to": "Ackerschott" - }, - { - "from": "Carrois", - "to": "Kauer" - }, - { - "from": "Jouer fixe", - "to": "Jour-Fixe" - }, - { - "from": "Docuware", - "to": "DocuWare" - }, - { - "from": "Nates", - "to": "Nejc" - }, - { - "from": "Bittzeit", - "to": "BitSight" - }, - { - "from": "Kalmikow", - "to": "Kalmykov" - }, - { - "from": "Leifert", - "to": "Leifer" - }, - { - "from": "Kiyosa", - "to": "Key-User" - } - ] +{ + "words": [ + "test" + ], + "replacements": [ + { + "from": "KRA", + "to": "KRAH" + }, + { + "from": "Atos", + "to": "ATHOS" + }, + { + "from": "Resistec", + "to": "RESISTEC" + }, + { + "from": "Resistek", + "to": "RESISTEC" + }, + { + "from": "HES", + "to": "HEES" + }, + { + "from": "Ackerschot", + "to": "Ackerschott" + }, + { + "from": "Carrois", + "to": "Kauer" + }, + { + "from": "Jouer fixe", + "to": "Jour-Fixe" + }, + { + "from": "Docuware", + "to": "DocuWare" + }, + { + "from": "Nates", + "to": "Nejc" + }, + { + "from": "Bittzeit", + "to": "BitSight" + }, + { + "from": "Kalmikow", + "to": "Kalmykov" + }, + { + "from": "Leifert", + "to": "Leifer" + }, + { + "from": "Kiyosa", + "to": "Key-User" + }, + { + "from": "Kashi", + "to": "Cachy" + } + ] } \ No newline at end of file diff --git a/whisper_app/hotkey.py b/whisper_app/hotkey.py index 2ed9ef5..c4c31f7 100644 --- a/whisper_app/hotkey.py +++ b/whisper_app/hotkey.py @@ -1,91 +1,227 @@ -_pynput_loaded = False -Key = KeyCode = KeyboardListener = None - -def _ensure_pynput(): - global _pynput_loaded, Key, KeyCode, KeyboardListener, _MODIFIER_MAP, _KEY_MAP - if _pynput_loaded: - return - from pynput.keyboard import Listener as _Listener, Key as _Key, KeyCode as _KeyCode - Key = _Key - KeyCode = _KeyCode - KeyboardListener = _Listener - _MODIFIER_MAP.update({ - "ctrl": {Key.ctrl_l, Key.ctrl_r}, - "ctrl_l": {Key.ctrl_l}, "ctrl_r": {Key.ctrl_r}, - "shift": {Key.shift_l, Key.shift_r}, - "shift_l": {Key.shift_l}, "shift_r": {Key.shift_r}, - "alt": {Key.alt_l, Key.alt_r}, - "alt_l": {Key.alt_l}, "alt_r": {Key.alt_r}, - }) - _KEY_MAP.update({ - "space": Key.space, "tab": Key.tab, "enter": Key.enter, - "esc": Key.esc, "escape": Key.esc, - "up": Key.up, "down": Key.down, "left": Key.left, "right": Key.right, - "home": Key.home, "end": Key.end, "page_up": Key.page_up, "page_down": Key.page_down, - "insert": Key.insert, "delete": Key.delete, "backspace": Key.backspace, - }) - for i in range(1, 13): - _KEY_MAP[f"f{i}"] = getattr(Key, f"f{i}") - _pynput_loaded = True - -_MODIFIER_MAP = {} -_KEY_MAP = {} - - -def _parse_hotkey(hotkey_str): - """Parse hotkey string into (modifier_sets, trigger_key). - Returns: (list of sets-of-pynput-keys for each modifier, pynput key for trigger) - """ - _ensure_pynput() - parts = [p.strip().lower() for p in hotkey_str.split("+")] - modifiers = [] - for p in parts[:-1]: - if p in _MODIFIER_MAP: - modifiers.append(_MODIFIER_MAP[p]) - elif p in _KEY_MAP: - modifiers.append({_KEY_MAP[p]}) - else: - modifiers.append({KeyCode.from_char(p)}) - trigger_part = parts[-1] - if trigger_part in _KEY_MAP: - trigger = _KEY_MAP[trigger_part] - elif trigger_part in _MODIFIER_MAP: - trigger = next(iter(_MODIFIER_MAP[trigger_part])) - else: - trigger = KeyCode.from_char(trigger_part) - return modifiers, trigger - - -class HotkeyListener: - """Hold-to-record hotkey using pynput. No root required on X11.""" - - def __init__(self, hotkey_str, on_press, on_release): - self._modifiers, self._trigger = _parse_hotkey(hotkey_str) - self._on_press = on_press - self._on_release = on_release - self._pressed = set() - self._active = False - self._listener = KeyboardListener(on_press=self._key_down, on_release=self._key_up) - self._listener.daemon = True - self._listener.start() - - def _matches_trigger(self, key): - return key == self._trigger - - def _modifiers_held(self): - return all(any(k in self._pressed for k in mod_set) for mod_set in self._modifiers) - - def _key_down(self, key): - self._pressed.add(key) - if not self._active and self._matches_trigger(key) and self._modifiers_held(): - self._active = True - self._on_press() - - def _key_up(self, key): - self._pressed.discard(key) - if self._active and self._matches_trigger(key): - self._active = False - self._on_release() - - def stop(self): - self._listener.stop() +import os +import threading + +# ── evdev key name → hotkey string mapping ─────────────────────────────────── +_EVDEV_MODIFIER_MAP = { + "ctrl": {"KEY_LEFTCTRL", "KEY_RIGHTCTRL"}, + "ctrl_l": {"KEY_LEFTCTRL"}, "ctrl_r": {"KEY_RIGHTCTRL"}, + "shift": {"KEY_LEFTSHIFT", "KEY_RIGHTSHIFT"}, + "shift_l": {"KEY_LEFTSHIFT"}, "shift_r": {"KEY_RIGHTSHIFT"}, + "alt": {"KEY_LEFTALT", "KEY_RIGHTALT"}, + "alt_l": {"KEY_LEFTALT"}, "alt_r": {"KEY_RIGHTALT"}, +} + +_EVDEV_KEY_MAP = { + "space": "KEY_SPACE", "tab": "KEY_TAB", "enter": "KEY_ENTER", + "esc": "KEY_ESC", "escape": "KEY_ESC", + "up": "KEY_UP", "down": "KEY_DOWN", "left": "KEY_LEFT", "right": "KEY_RIGHT", + "home": "KEY_HOME", "end": "KEY_END", "page_up": "KEY_PAGEUP", "page_down": "KEY_PAGEDOWN", + "insert": "KEY_INSERT", "delete": "KEY_DELETE", "backspace": "KEY_BACKSPACE", +} +for _i in range(1, 13): + _EVDEV_KEY_MAP[f"f{_i}"] = f"KEY_F{_i}" + + +def _parse_hotkey_evdev(hotkey_str): + """Parse hotkey string into evdev key code sets.""" + parts = [p.strip().lower() for p in hotkey_str.split("+")] + modifiers = [] + for p in parts[:-1]: + if p in _EVDEV_MODIFIER_MAP: + modifiers.append(_EVDEV_MODIFIER_MAP[p]) + elif p in _EVDEV_KEY_MAP: + modifiers.append({_EVDEV_KEY_MAP[p]}) + else: + modifiers.append({f"KEY_{p.upper()}"}) + trigger_part = parts[-1] + if trigger_part in _EVDEV_KEY_MAP: + trigger = _EVDEV_KEY_MAP[trigger_part] + elif trigger_part in _EVDEV_MODIFIER_MAP: + trigger = next(iter(_EVDEV_MODIFIER_MAP[trigger_part])) + else: + trigger = f"KEY_{trigger_part.upper()}" + return modifiers, trigger + + +def _can_use_evdev(): + """Check if evdev is available and we have access to input devices.""" + if os.name == "nt": + return False + try: + import evdev + devices = [evdev.InputDevice(p) for p in evdev.list_devices()] + keyboards = [d for d in devices if 1 in d.capabilities()] # EV_KEY + for d in devices: + d.close() + return len(keyboards) > 0 + except Exception: + return False + + +class _EvdevListener: + """Low-latency hotkey listener using evdev (Linux kernel input).""" + + def __init__(self, hotkey_str, on_press, on_release): + self._modifiers, self._trigger = _parse_hotkey_evdev(hotkey_str) + self._on_press = on_press + self._on_release = on_release + self._pressed = set() + self._active = False + self._stop_event = threading.Event() + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def _run(self): + import evdev + import select + + devices = [evdev.InputDevice(p) for p in evdev.list_devices()] + keyboards = [d for d in devices if 1 in d.capabilities()] + # close non-keyboard devices + for d in devices: + if d not in keyboards: + d.close() + + if not keyboards: + return + + while not self._stop_event.is_set(): + r, _, _ = select.select(keyboards, [], [], 0.5) + for dev in r: + try: + for event in dev.read(): + if event.type != 1: # EV_KEY + continue + code_name = evdev.ecodes.KEY.get(event.code) + if isinstance(code_name, list): + code_name = code_name[0] + if code_name is None: + continue + if event.value == 1: # key down + self._key_down(code_name) + elif event.value == 0: # key up + self._key_up(code_name) + except OSError: + pass + + for d in keyboards: + d.close() + + def _key_down(self, code_name): + self._pressed.add(code_name) + if not self._active and code_name == self._trigger and self._modifiers_held(): + self._active = True + self._on_press() + + def _key_up(self, code_name): + self._pressed.discard(code_name) + if self._active and code_name == self._trigger: + self._active = False + self._on_release() + + def _modifiers_held(self): + return all( + any(k in self._pressed for k in mod_set) + for mod_set in self._modifiers + ) + + def stop(self): + self._stop_event.set() + + +# ── pynput fallback (Windows, or Linux without evdev/input group) ──────────── + +_pynput_loaded = False +Key = KeyCode = KeyboardListener = None +_PYNPUT_MODIFIER_MAP = {} +_PYNPUT_KEY_MAP = {} + + +def _ensure_pynput(): + global _pynput_loaded, Key, KeyCode, KeyboardListener + if _pynput_loaded: + return + from pynput.keyboard import Listener as _Listener, Key as _Key, KeyCode as _KeyCode + Key = _Key + KeyCode = _KeyCode + KeyboardListener = _Listener + _PYNPUT_MODIFIER_MAP.update({ + "ctrl": {Key.ctrl_l, Key.ctrl_r}, + "ctrl_l": {Key.ctrl_l}, "ctrl_r": {Key.ctrl_r}, + "shift": {Key.shift_l, Key.shift_r}, + "shift_l": {Key.shift_l}, "shift_r": {Key.shift_r}, + "alt": {Key.alt_l, Key.alt_r}, + "alt_l": {Key.alt_l}, "alt_r": {Key.alt_r}, + }) + _PYNPUT_KEY_MAP.update({ + "space": Key.space, "tab": Key.tab, "enter": Key.enter, + "esc": Key.esc, "escape": Key.esc, + "up": Key.up, "down": Key.down, "left": Key.left, "right": Key.right, + "home": Key.home, "end": Key.end, "page_up": Key.page_up, "page_down": Key.page_down, + "insert": Key.insert, "delete": Key.delete, "backspace": Key.backspace, + }) + for i in range(1, 13): + _PYNPUT_KEY_MAP[f"f{i}"] = getattr(Key, f"f{i}") + _pynput_loaded = True + + +def _parse_hotkey_pynput(hotkey_str): + _ensure_pynput() + parts = [p.strip().lower() for p in hotkey_str.split("+")] + modifiers = [] + for p in parts[:-1]: + if p in _PYNPUT_MODIFIER_MAP: + modifiers.append(_PYNPUT_MODIFIER_MAP[p]) + elif p in _PYNPUT_KEY_MAP: + modifiers.append({_PYNPUT_KEY_MAP[p]}) + else: + modifiers.append({KeyCode.from_char(p)}) + trigger_part = parts[-1] + if trigger_part in _PYNPUT_KEY_MAP: + trigger = _PYNPUT_KEY_MAP[trigger_part] + elif trigger_part in _PYNPUT_MODIFIER_MAP: + trigger = next(iter(_PYNPUT_MODIFIER_MAP[trigger_part])) + else: + trigger = KeyCode.from_char(trigger_part) + return modifiers, trigger + + +class _PynputListener: + """Hotkey listener using pynput (Windows / fallback).""" + + def __init__(self, hotkey_str, on_press, on_release): + self._modifiers, self._trigger = _parse_hotkey_pynput(hotkey_str) + self._on_press = on_press + self._on_release = on_release + self._pressed = set() + self._active = False + self._listener = KeyboardListener(on_press=self._key_down, on_release=self._key_up) + self._listener.daemon = True + self._listener.start() + + def _key_down(self, key): + self._pressed.add(key) + if not self._active and key == self._trigger and self._modifiers_held(): + self._active = True + self._on_press() + + def _key_up(self, key): + self._pressed.discard(key) + if self._active and key == self._trigger: + self._active = False + self._on_release() + + def _modifiers_held(self): + return all(any(k in self._pressed for k in mod_set) for mod_set in self._modifiers) + + def stop(self): + self._listener.stop() + + +# ── Public API ─────────────────────────────────────────────────────────────── + +def HotkeyListener(hotkey_str, on_press, on_release): + """Create the best available hotkey listener for the current platform.""" + if _can_use_evdev(): + return _EvdevListener(hotkey_str, on_press, on_release) + return _PynputListener(hotkey_str, on_press, on_release)