"""Einstellungs-Dialog für whisper-local (Windows).""" from __future__ import annotations import ctypes import threading from typing import Callable import sounddevice as sd 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() return [ (dev["name"], idx) for idx, dev in enumerate(devices) if dev["max_input_channels"] > 0 ] 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 def open(self) -> None: """Öffnet den Dialog in einem Daemon-Thread.""" thread = threading.Thread(target=self._run, daemon=True) thread.start() def _run(self) -> None: import tkinter as tk from tkinter import ttk root = tk.Tk() root.title("whisper-local – Einstellungen") root.resizable(False, False) apply_system_theme(root) frame = ttk.Frame(root, padding=16) frame.pack(fill=tk.BOTH, expand=True) # --- Hotkey --- ttk.Label(frame, text="Hotkey").grid(row=0, column=0, sticky=tk.W, pady=4) hotkey_var = tk.StringVar(value=self._config.hotkey) ttk.Label(frame, textvariable=hotkey_var, width=14, relief="sunken").grid( row=0, column=1, padx=8 ) conflict_var = tk.StringVar() ttk.Label(frame, textvariable=conflict_var, foreground="orange").grid( row=1, column=0, columnspan=3, sticky=tk.W ) def record_hotkey(): hotkey_var.set("...") conflict_var.set("") captured: list[str] = [] def on_press(key): evdev = pynput_to_evdev_key(key) if evdev: captured.append(evdev) return False # Listener stoppen 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=listen, daemon=True).start() ttk.Button(frame, text="Aufzeichnen", command=record_hotkey).grid( row=0, column=2, padx=4 ) # --- Mikrofon --- ttk.Label(frame, text="Mikrofon").grid(row=2, column=0, sticky=tk.W, pady=4) mics = list_microphones() mic_names = ["Standard"] + [name for name, _ in mics] current_mic = self._config.microphone or "Standard" mic_var = tk.StringVar(value=current_mic) ttk.Combobox( frame, textvariable=mic_var, values=mic_names, state="readonly", width=32 ).grid(row=2, column=1, columnspan=2, sticky=tk.W, padx=4) # --- Buttons --- btn_frame = ttk.Frame(frame) btn_frame.grid(row=3, column=0, columnspan=3, pady=12, sticky=tk.E) def save(): new_config = Config( hotkey=hotkey_var.get(), whisper_model=self._config.whisper_model, language=self._config.language, compute_type=self._config.compute_type, sample_rate=self._config.sample_rate, channels=self._config.channels, min_duration=self._config.min_duration, microphone="" if mic_var.get() == "Standard" else mic_var.get(), ) save_config(new_config) self._on_save(new_config) 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) root.mainloop()