"""Global push-to-talk hotkey. The wake word is the primary trigger, but it misfires in a noisy room and won't fire at all if you're on a call — so there's a keyboard fallback that works even when the pet has no focus (it's a frameless Qt.Tool window with no taskbar entry, so an ordinary QShortcut would never see the key). Needs `pynput`, which needs an X11/Win32/macOS input hook: on most Wayland sessions it can't grab global keys, and on macOS it needs Accessibility permission. All of that is a soft failure — start() reports the reason and the wake word / tray keep working. """ from __future__ import annotations from typing import Callable, Optional # Aliases for the names people actually type in a .env file. _ALIASES = { "control": "ctrl", "ctl": "ctrl", "option": "alt", "opt": "alt", "win": "cmd", "windows": "cmd", "super": "cmd", "meta": "cmd", "command": "cmd", "return": "enter", "escape": "esc", "del": "delete", "ins": "insert", "pgup": "page_up", "pgdn": "page_down", } class HotkeyError(Exception): pass def to_pynput_spec(spec: str) -> str: """"ctrl+alt+space" -> "++" (pynput's GlobalHotKeys syntax: named keys in angle brackets, literal characters bare).""" tokens = [t.strip().lower() for t in (spec or "").split("+")] tokens = [t for t in tokens if t] if not tokens: raise HotkeyError("empty hotkey") parts = [] for token in tokens: token = _ALIASES.get(token, token) parts.append(token if len(token) == 1 else f"<{token}>") return "+".join(parts) class GlobalHotkey: """Fires *callback* whenever the hotkey is pressed, anywhere. Safe to construct unconditionally — nothing happens until start(), and start() reports failure instead of raising into the UI thread.""" def __init__(self, spec: str, callback: Callable[[], None]): self.spec = spec self._callback = callback self._listener = None @property def running(self) -> bool: return self._listener is not None def start(self) -> Optional[str]: """None on success, otherwise a human-readable reason it's off.""" if not (self.spec or "").strip(): return None # explicitly disabled — not an error worth reporting try: pynput_spec = to_pynput_spec(self.spec) except HotkeyError as exc: return f"push-to-talk hotkey {self.spec!r} is invalid: {exc}" try: from pynput import keyboard except Exception as exc: # ImportError, or a backend that won't load return f"push-to-talk needs pynput ({exc}) — wake word still works" try: listener = keyboard.GlobalHotKeys({pynput_spec: self._safe_callback}) listener.daemon = True listener.start() except Exception as exc: return f"push-to-talk unavailable on this session ({exc}) — wake word still works" self._listener = listener return None def _safe_callback(self) -> None: # This runs on pynput's listener thread; an exception there would # silently kill the listener for the rest of the session. try: self._callback() except Exception: pass def stop(self) -> None: if self._listener is not None: try: self._listener.stop() except Exception: pass self._listener = None