Merge branch 'master' of https://code.vitaligraf.de/info/whisper-local
This commit is contained in:
@@ -9,11 +9,11 @@ from whisper_local.tray._tray import AppState, NoOpTray
|
||||
def create_tray(
|
||||
on_settings: Callable[[], None],
|
||||
on_quit: Callable[[], None],
|
||||
) -> "Win32TrayApp | NoOpTray":
|
||||
) -> "PystrayApp | NoOpTray":
|
||||
"""Gibt den plattformspezifischen Tray zurück."""
|
||||
if sys.platform == "win32":
|
||||
from whisper_local.tray._tray import Win32TrayApp
|
||||
return Win32TrayApp(on_settings=on_settings, on_quit=on_quit)
|
||||
if sys.platform in ("win32", "linux"):
|
||||
from whisper_local.tray._tray import PystrayApp
|
||||
return PystrayApp(on_settings=on_settings, on_quit=on_quit)
|
||||
return NoOpTray()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Hotkey-Aufzeichnung via evdev (Linux)."""
|
||||
|
||||
import selectors
|
||||
import threading
|
||||
from typing import Callable
|
||||
|
||||
import evdev
|
||||
from evdev import InputDevice, ecodes
|
||||
|
||||
|
||||
def find_all_keyboards() -> list[InputDevice]:
|
||||
"""Gibt alle Input-Devices zurück, die EV_KEY-Events liefern können."""
|
||||
keyboards: list[InputDevice] = []
|
||||
try:
|
||||
for path in evdev.list_devices():
|
||||
try:
|
||||
device = InputDevice(path)
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
try:
|
||||
capabilities = device.capabilities()
|
||||
except OSError:
|
||||
device.close()
|
||||
continue
|
||||
if ecodes.EV_KEY in capabilities:
|
||||
keyboards.append(device)
|
||||
else:
|
||||
device.close()
|
||||
except BaseException:
|
||||
for dev in keyboards:
|
||||
dev.close()
|
||||
raise
|
||||
return keyboards
|
||||
|
||||
|
||||
def _keycode_to_name(code: int) -> str:
|
||||
"""Übersetzt evdev-Keycode zu Key-Namen. Gibt '' bei unbekanntem Code."""
|
||||
name = ecodes.KEY.get(code)
|
||||
if isinstance(name, list):
|
||||
return name[0]
|
||||
if isinstance(name, str):
|
||||
return name
|
||||
return ""
|
||||
|
||||
|
||||
def record_hotkey(
|
||||
on_result: Callable[[str, bool], None],
|
||||
cancel_event: threading.Event,
|
||||
) -> None:
|
||||
"""Blockiert bis zum ersten Keydown oder bis cancel_event gesetzt wird.
|
||||
|
||||
Ruft on_result(evdev_key_name, has_conflict) auf. has_conflict ist auf
|
||||
Linux immer False — es gibt kein Äquivalent zum Win32-RegisterHotKey-Check.
|
||||
"""
|
||||
devices = find_all_keyboards()
|
||||
if not devices:
|
||||
return
|
||||
|
||||
selector = selectors.DefaultSelector()
|
||||
try:
|
||||
for dev in devices:
|
||||
selector.register(dev.fd, selectors.EVENT_READ, dev)
|
||||
|
||||
captured: str | None = None
|
||||
while captured is None and not cancel_event.is_set():
|
||||
for key, _mask in selector.select(timeout=0.1):
|
||||
dev: InputDevice = key.data
|
||||
for event in dev.read():
|
||||
if event.type == ecodes.EV_KEY and event.value == 1:
|
||||
name = _keycode_to_name(event.code)
|
||||
if name:
|
||||
captured = name
|
||||
break
|
||||
if captured:
|
||||
break
|
||||
|
||||
if captured and not cancel_event.is_set():
|
||||
on_result(captured, False)
|
||||
finally:
|
||||
selector.close()
|
||||
for dev in devices:
|
||||
dev.close()
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Hotkey-Aufzeichnung und Konflikt-Erkennung für Windows (pynput + Win32)."""
|
||||
|
||||
import ctypes
|
||||
import threading
|
||||
from typing import Callable
|
||||
|
||||
|
||||
def pynput_to_evdev_key(key) -> str:
|
||||
"""Konvertiert pynput-Key zu evdev-Key-Namen (z.B. Key.f12 → 'KEY_F12')."""
|
||||
from pynput.keyboard import Key, KeyCode
|
||||
|
||||
if isinstance(key, Key):
|
||||
return f"KEY_{key.name.upper()}"
|
||||
if isinstance(key, KeyCode) and key.char:
|
||||
return f"KEY_{key.char.upper()}"
|
||||
return ""
|
||||
|
||||
|
||||
def check_hotkey_conflict(evdev_name: str) -> bool:
|
||||
"""Gibt True zurück wenn die Taste per Win32-RegisterHotKey belegt ist."""
|
||||
from whisper_local.hotkey._pynput import _evdev_to_pynput_key
|
||||
from pynput.keyboard import Key
|
||||
|
||||
try:
|
||||
pynput_key = _evdev_to_pynput_key(evdev_name)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
if not isinstance(pynput_key, Key):
|
||||
return False
|
||||
|
||||
vk = getattr(pynput_key.value, "vk", None)
|
||||
if vk is None:
|
||||
return False
|
||||
|
||||
HOTKEY_ID = 0x7FFF
|
||||
user32 = ctypes.windll.user32
|
||||
if user32.RegisterHotKey(None, HOTKEY_ID, 0, vk):
|
||||
user32.UnregisterHotKey(None, HOTKEY_ID)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def record_hotkey(
|
||||
on_result: Callable[[str, bool], None],
|
||||
cancel_event: threading.Event,
|
||||
) -> None:
|
||||
"""Blockiert bis der nächste Keydown kommt oder cancel_event gesetzt wird.
|
||||
|
||||
Ruft on_result(evdev_key_name, has_conflict) auf.
|
||||
"""
|
||||
from pynput.keyboard import Listener
|
||||
|
||||
captured: list[str] = []
|
||||
|
||||
def on_press(key):
|
||||
evdev = pynput_to_evdev_key(key)
|
||||
if evdev:
|
||||
captured.append(evdev)
|
||||
return False # Listener stoppen
|
||||
|
||||
with Listener(on_press=on_press) as lst:
|
||||
while lst.running and not cancel_event.is_set():
|
||||
lst.join(timeout=0.1)
|
||||
if lst.running:
|
||||
lst.stop()
|
||||
|
||||
if captured and not cancel_event.is_set():
|
||||
evdev = captured[0]
|
||||
on_result(evdev, check_hotkey_conflict(evdev))
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Einstellungs-Dialog für whisper-local (Windows)."""
|
||||
"""Einstellungs-Dialog für whisper-local (cross-platform)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import sys
|
||||
import threading
|
||||
from typing import Callable
|
||||
|
||||
@@ -12,42 +12,6 @@ from whisper_local.config import Config, save_config
|
||||
from whisper_local.tray._theme import apply_system_theme
|
||||
|
||||
|
||||
def pynput_to_evdev_key(key) -> str:
|
||||
"""Konvertiert pynput-Key zu evdev-Key-Namen (z.B. Key.f12 → 'KEY_F12')."""
|
||||
from pynput.keyboard import Key, KeyCode
|
||||
|
||||
if isinstance(key, Key):
|
||||
return f"KEY_{key.name.upper()}"
|
||||
if isinstance(key, KeyCode) and key.char:
|
||||
return f"KEY_{key.char.upper()}"
|
||||
return ""
|
||||
|
||||
|
||||
def check_hotkey_conflict(evdev_name: str) -> bool:
|
||||
"""Gibt True zurück wenn die Taste per Win32-RegisterHotKey belegt ist."""
|
||||
from whisper_local.hotkey._pynput import _evdev_to_pynput_key
|
||||
from pynput.keyboard import Key
|
||||
|
||||
try:
|
||||
pynput_key = _evdev_to_pynput_key(evdev_name)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
if not isinstance(pynput_key, Key):
|
||||
return False
|
||||
|
||||
vk = getattr(pynput_key.value, "vk", None)
|
||||
if vk is None:
|
||||
return False
|
||||
|
||||
HOTKEY_ID = 0x7FFF
|
||||
user32 = ctypes.windll.user32
|
||||
if user32.RegisterHotKey(None, HOTKEY_ID, 0, vk):
|
||||
user32.UnregisterHotKey(None, HOTKEY_ID)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def list_microphones() -> list[tuple[str, int]]:
|
||||
"""Gibt Liste aller Eingabegeräte als (name, index) zurück."""
|
||||
devices = sd.query_devices()
|
||||
@@ -58,12 +22,22 @@ def list_microphones() -> list[tuple[str, int]]:
|
||||
]
|
||||
|
||||
|
||||
def _get_record_hotkey():
|
||||
"""Wählt das plattformspezifische record_hotkey-Callable."""
|
||||
if sys.platform == "win32":
|
||||
from whisper_local.tray._hotkey_record_pynput import record_hotkey
|
||||
return record_hotkey
|
||||
from whisper_local.tray._hotkey_record_evdev import record_hotkey
|
||||
return record_hotkey
|
||||
|
||||
|
||||
class SettingsDialog:
|
||||
"""Einstellungs-Dialog (läuft in eigenem Thread)."""
|
||||
|
||||
def __init__(self, config: Config, on_save: Callable[[Config], None]):
|
||||
self._config = config
|
||||
self._on_save = on_save
|
||||
self._cancel_event = threading.Event()
|
||||
|
||||
def open(self) -> None:
|
||||
"""Öffnet den Dialog in einem Daemon-Thread."""
|
||||
@@ -94,36 +68,28 @@ class SettingsDialog:
|
||||
row=1, column=0, columnspan=3, sticky=tk.W
|
||||
)
|
||||
|
||||
def record_hotkey():
|
||||
record_hotkey = _get_record_hotkey()
|
||||
|
||||
def do_record():
|
||||
hotkey_var.set("...")
|
||||
conflict_var.set("")
|
||||
|
||||
captured: list[str] = []
|
||||
def on_result(evdev_key: str, has_conflict: bool):
|
||||
root.after(0, lambda: hotkey_var.set(evdev_key))
|
||||
if has_conflict:
|
||||
root.after(
|
||||
0,
|
||||
lambda: conflict_var.set(
|
||||
"⚠ Taste ist von einer anderen App belegt (Win32-Hotkeys)"
|
||||
),
|
||||
)
|
||||
|
||||
def on_press(key):
|
||||
evdev = pynput_to_evdev_key(key)
|
||||
if evdev:
|
||||
captured.append(evdev)
|
||||
return False # Listener stoppen
|
||||
def worker():
|
||||
record_hotkey(on_result, self._cancel_event)
|
||||
|
||||
def listen():
|
||||
from pynput.keyboard import Listener
|
||||
with Listener(on_press=on_press) as lst:
|
||||
lst.join()
|
||||
if captured:
|
||||
evdev = captured[0]
|
||||
root.after(0, lambda: hotkey_var.set(evdev))
|
||||
if check_hotkey_conflict(evdev):
|
||||
root.after(
|
||||
0,
|
||||
lambda: conflict_var.set(
|
||||
"⚠ Taste ist von einer anderen App belegt (Win32-Hotkeys)"
|
||||
),
|
||||
)
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
threading.Thread(target=listen, daemon=True).start()
|
||||
|
||||
ttk.Button(frame, text="Aufzeichnen", command=record_hotkey).grid(
|
||||
ttk.Button(frame, text="Aufzeichnen", command=do_record).grid(
|
||||
row=0, column=2, padx=4
|
||||
)
|
||||
|
||||
@@ -154,9 +120,15 @@ class SettingsDialog:
|
||||
)
|
||||
save_config(new_config)
|
||||
self._on_save(new_config)
|
||||
self._cancel_event.set()
|
||||
root.destroy()
|
||||
|
||||
def cancel():
|
||||
self._cancel_event.set()
|
||||
root.destroy()
|
||||
|
||||
ttk.Button(btn_frame, text="Speichern", command=save).pack(side=tk.RIGHT, padx=4)
|
||||
ttk.Button(btn_frame, text="Abbrechen", command=root.destroy).pack(side=tk.RIGHT)
|
||||
ttk.Button(btn_frame, text="Abbrechen", command=cancel).pack(side=tk.RIGHT)
|
||||
|
||||
root.protocol("WM_DELETE_WINDOW", cancel)
|
||||
root.mainloop()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tray-App und App-Zustände für whisper-local (Windows)."""
|
||||
"""Tray-App und App-Zustände für whisper-local."""
|
||||
|
||||
import enum
|
||||
import threading
|
||||
@@ -11,8 +11,8 @@ class AppState(enum.Enum):
|
||||
TRANSCRIBING = "transcribing"
|
||||
|
||||
|
||||
class Win32TrayApp:
|
||||
"""Tray-Icon via pystray für Windows."""
|
||||
class PystrayApp:
|
||||
"""Tray-Icon via pystray — cross-platform (Windows + Linux)."""
|
||||
|
||||
def __init__(self, on_settings: Callable[[], None], on_quit: Callable[[], None]):
|
||||
self._on_settings = on_settings
|
||||
|
||||
Reference in New Issue
Block a user