2026-04-10 21:13:00 +02:00
|
|
|
|
"""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
|
2026-04-10 21:15:34 +02:00
|
|
|
|
from whisper_local.tray._theme import apply_system_theme
|
2026-04-10 21:13:00 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
]
|
2026-04-10 21:15:34 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|