2026-04-08 10:31:11 +02:00
|
|
|
"""Hotkey-Listener via pynput für Push-to-Talk (Windows / Fallback)."""
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import logging
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
from pynput.keyboard import Key, Listener
|
|
|
|
|
|
|
|
|
|
from whisper_local.hotkey import AsyncCallback
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _evdev_to_pynput_key(evdev_name: str) -> Key:
|
|
|
|
|
"""Übersetzt evdev Key-Namen (z.B. KEY_F12) ins pynput-Format."""
|
|
|
|
|
match = re.match(r"^KEY_(F\d+)$", evdev_name)
|
|
|
|
|
if match:
|
|
|
|
|
attr = match.group(1).lower()
|
|
|
|
|
key = getattr(Key, attr, None)
|
|
|
|
|
if key is not None:
|
|
|
|
|
return key
|
|
|
|
|
|
|
|
|
|
# Versuche direkte Zuordnung (KEY_SPACE -> space, etc.)
|
|
|
|
|
suffix = evdev_name.removeprefix("KEY_").lower()
|
|
|
|
|
key = getattr(Key, suffix, None)
|
|
|
|
|
if key is not None:
|
|
|
|
|
return key
|
|
|
|
|
|
|
|
|
|
raise ValueError(f"Unbekannter Key-Name: {evdev_name}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PynputHotkeyListener:
|
|
|
|
|
def __init__(self, key_name: str = "KEY_F12"):
|
|
|
|
|
self.key_name = key_name
|
|
|
|
|
self._target_key = _evdev_to_pynput_key(key_name)
|
|
|
|
|
self.on_press: AsyncCallback | None = None
|
|
|
|
|
self.on_release: AsyncCallback | None = None
|
|
|
|
|
self._loop: asyncio.AbstractEventLoop | None = None
|
2026-04-08 10:53:09 +02:00
|
|
|
self._pressed = False
|
2026-04-10 21:05:20 +02:00
|
|
|
self._stop_event: asyncio.Event | None = None
|
|
|
|
|
|
|
|
|
|
def stop(self) -> None:
|
|
|
|
|
"""Signalisiert dem listen()-Loop zu beenden."""
|
|
|
|
|
if self._loop is not None and self._stop_event is not None:
|
|
|
|
|
self._loop.call_soon_threadsafe(self._stop_event.set)
|
2026-04-08 10:31:11 +02:00
|
|
|
|
|
|
|
|
async def _handle_key_event(self, key_down: bool) -> None:
|
|
|
|
|
"""Ruft den passenden Callback auf."""
|
|
|
|
|
if key_down and self.on_press:
|
|
|
|
|
await self.on_press()
|
|
|
|
|
elif not key_down and self.on_release:
|
|
|
|
|
await self.on_release()
|
|
|
|
|
|
|
|
|
|
def _schedule_callback(self, key_down: bool) -> None:
|
|
|
|
|
"""Bridged den Thread-Callback zu asyncio."""
|
|
|
|
|
if self._loop is None:
|
|
|
|
|
return
|
|
|
|
|
self._loop.call_soon_threadsafe(
|
|
|
|
|
asyncio.ensure_future,
|
|
|
|
|
self._handle_key_event(key_down),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _on_press(self, key) -> None:
|
2026-04-08 10:53:09 +02:00
|
|
|
if key == self._target_key and not self._pressed:
|
|
|
|
|
self._pressed = True
|
2026-04-08 10:31:11 +02:00
|
|
|
logger.debug("%s gedrückt", self.key_name)
|
|
|
|
|
self._schedule_callback(key_down=True)
|
|
|
|
|
|
|
|
|
|
def _on_release(self, key) -> None:
|
2026-04-08 10:53:09 +02:00
|
|
|
if key == self._target_key and self._pressed:
|
|
|
|
|
self._pressed = False
|
2026-04-08 10:31:11 +02:00
|
|
|
logger.debug("%s losgelassen", self.key_name)
|
|
|
|
|
self._schedule_callback(key_down=False)
|
|
|
|
|
|
|
|
|
|
async def listen(self) -> None:
|
|
|
|
|
"""Startet den pynput Keyboard-Listener und blockiert async."""
|
|
|
|
|
self._loop = asyncio.get_running_loop()
|
2026-04-10 21:05:20 +02:00
|
|
|
self._stop_event = asyncio.Event()
|
2026-04-08 10:31:11 +02:00
|
|
|
|
|
|
|
|
listener = Listener(on_press=self._on_press, on_release=self._on_release)
|
|
|
|
|
listener.start()
|
|
|
|
|
logger.info("Lausche auf %s via pynput", self.key_name)
|
|
|
|
|
|
|
|
|
|
try:
|
2026-04-10 21:05:20 +02:00
|
|
|
await self._stop_event.wait()
|
2026-04-08 10:31:11 +02:00
|
|
|
finally:
|
|
|
|
|
listener.stop()
|