392 lines
17 KiB
Python
392 lines
17 KiB
Python
import os
|
|
import threading
|
|
import tkinter as tk
|
|
from tkinter import filedialog
|
|
|
|
from whisper_app import config as cfg
|
|
|
|
|
|
def open(root: tk.Tk, on_reload) -> None:
|
|
"""Open the settings window as a Toplevel of *root*."""
|
|
_open_main(root, on_reload)
|
|
|
|
|
|
def _open_main(root: tk.Tk, on_reload) -> None:
|
|
# ── Palette: "Precision Audio" ──────────────────────────────────────────
|
|
BG = "#18181f"
|
|
BG2 = "#22222c"
|
|
BG3 = "#2c2c38"
|
|
BORDER = "#38384a"
|
|
FG = "#e8e8f0"
|
|
FG2 = "#7878a0"
|
|
AMBER = "#f5a623"
|
|
AMBER2 = "#c8831a"
|
|
GREEN = "#4ade80"
|
|
_mono = "Consolas" if os.name == "nt" else "monospace"
|
|
_sans = "Segoe UI" if os.name == "nt" else "sans-serif"
|
|
FONT = (_mono, 11)
|
|
FONT_UI = (_sans, 11)
|
|
FONT_B = (_sans, 11, "bold")
|
|
FONT_S = (_sans, 9)
|
|
FONT_H = (_sans, 16, "bold")
|
|
|
|
win = tk.Toplevel(root)
|
|
win.title("Whisper Dictation")
|
|
win.configure(bg=BG)
|
|
win.attributes("-topmost", True)
|
|
win.resizable(True, True)
|
|
win.minsize(700, 500)
|
|
|
|
# Global option for OptionMenu dropdowns (dark listbox)
|
|
win.option_add("*Menu.background", BG3)
|
|
win.option_add("*Menu.foreground", FG)
|
|
win.option_add("*Menu.activeBackground", AMBER)
|
|
win.option_add("*Menu.activeForeground", BG)
|
|
win.option_add("*Menu.font", FONT_UI)
|
|
|
|
# ── Header ──
|
|
hdr = tk.Frame(win, bg=BG2, pady=20)
|
|
hdr.pack(fill="x")
|
|
tk.Frame(hdr, bg=AMBER, height=3).pack(fill="x")
|
|
tk.Label(hdr, text="WHISPER DICTATION", font=FONT_H,
|
|
bg=BG2, fg=FG, pady=12).pack()
|
|
tk.Label(hdr, text="Lokale GPU-Transkription · offline · privat",
|
|
font=FONT_S, bg=BG2, fg=FG2).pack()
|
|
|
|
# ── Scrollable content ──
|
|
outer = tk.Frame(win, bg=BG)
|
|
outer.pack(fill="both", expand=True)
|
|
canvas = tk.Canvas(outer, bg=BG, highlightthickness=0, bd=0)
|
|
scrollbar = tk.Scrollbar(outer, orient="vertical", command=canvas.yview,
|
|
bg=BG3, troughcolor=BG, highlightthickness=0, bd=0)
|
|
canvas.configure(yscrollcommand=scrollbar.set)
|
|
scrollbar.pack(side="right", fill="y")
|
|
canvas.pack(side="left", fill="both", expand=True)
|
|
content = tk.Frame(canvas, bg=BG, padx=36, pady=16)
|
|
content_id = canvas.create_window((0, 0), window=content, anchor="nw")
|
|
|
|
def _on_content_configure(event):
|
|
canvas.configure(scrollregion=canvas.bbox("all"))
|
|
content.bind("<Configure>", _on_content_configure)
|
|
|
|
def _on_canvas_configure(event):
|
|
canvas.itemconfigure(content_id, width=event.width)
|
|
canvas.bind("<Configure>", _on_canvas_configure)
|
|
|
|
def _on_mousewheel(event):
|
|
canvas.yview_scroll(-1 if event.delta > 0 else 1, "units")
|
|
def _on_button4(event):
|
|
canvas.yview_scroll(-3, "units")
|
|
def _on_button5(event):
|
|
canvas.yview_scroll(3, "units")
|
|
canvas.bind_all("<MouseWheel>", _on_mousewheel)
|
|
canvas.bind_all("<Button-4>", _on_button4)
|
|
canvas.bind_all("<Button-5>", _on_button5)
|
|
def _cleanup_binds():
|
|
try:
|
|
canvas.unbind_all("<MouseWheel>")
|
|
canvas.unbind_all("<Button-4>")
|
|
canvas.unbind_all("<Button-5>")
|
|
except tk.TclError:
|
|
pass
|
|
win.bind("<Destroy>", lambda _: _cleanup_binds())
|
|
|
|
def section(label):
|
|
f = tk.Frame(content, bg=BG)
|
|
f.pack(fill="x", pady=(18, 6))
|
|
tk.Label(f, text=label, font=("Consolas", 9, "bold"),
|
|
bg=BG, fg=AMBER).pack(side="left")
|
|
tk.Frame(f, bg=BORDER, height=1).pack(side="left", fill="x", expand=True, padx=(10, 0), pady=6)
|
|
|
|
def dd(frame, var, values, width=14):
|
|
"""Create dark OptionMenu directly in frame as parent."""
|
|
m = tk.OptionMenu(frame, var, *values)
|
|
m.config(bg=BG3, fg=FG, activebackground=BG3, activeforeground=FG,
|
|
highlightbackground=BORDER, highlightthickness=1,
|
|
relief="flat", font=FONT_UI, anchor="w", bd=0, width=width)
|
|
m["menu"].config(bg=BG3, fg=FG, activebackground=AMBER,
|
|
activeforeground=BG, relief="flat", bd=0)
|
|
return m
|
|
|
|
def row(label, hint=None):
|
|
"""Returns frame — add controls to frame after calling."""
|
|
f = tk.Frame(content, bg=BG)
|
|
f.pack(fill="x", pady=5)
|
|
tk.Label(f, text=label, width=17, anchor="w", font=FONT_UI,
|
|
bg=BG, fg=FG2).pack(side="left")
|
|
if hint:
|
|
tk.Label(f, text=hint, font=FONT_S, bg=BG, fg=FG2).pack(side="right")
|
|
return f
|
|
|
|
# ── AUDIO ──
|
|
section("AUDIO")
|
|
from whisper_app.audio import get_input_devices, test_device
|
|
devices = get_input_devices()
|
|
dev_names = ["Standard"] + [name for _, name in devices]
|
|
dev_var = tk.StringVar()
|
|
cur_dev = cfg.config.get("audio_device")
|
|
dev_var.set(cur_dev if cur_dev and cur_dev in dev_names else "Standard")
|
|
f = row("Mikrofon")
|
|
dd(f, dev_var, dev_names, width=44).pack(side="left")
|
|
|
|
# ── Mic test ──
|
|
f_test = tk.Frame(content, bg=BG)
|
|
f_test.pack(fill="x", pady=(2, 8))
|
|
tk.Label(f_test, text="", width=17, bg=BG).pack(side="left") # spacer
|
|
|
|
level_canvas = tk.Canvas(f_test, width=200, height=14, bg=BG3,
|
|
highlightbackground=BORDER, highlightthickness=1, bd=0)
|
|
level_canvas.pack(side="left")
|
|
level_bar = level_canvas.create_rectangle(0, 0, 0, 14, fill=GREEN, width=0)
|
|
|
|
test_label = tk.Label(f_test, text="", font=FONT_S, bg=BG, fg=FG2)
|
|
test_label.pack(side="left", padx=(8, 0))
|
|
|
|
def run_mic_test():
|
|
test_btn.config(state="disabled", text="Aufnahme...")
|
|
test_label.config(text="Sprich jetzt...", fg=FG2)
|
|
level_canvas.coords(level_bar, 0, 0, 0, 14)
|
|
|
|
dev_name = dev_var.get()
|
|
device = None if dev_name == "Standard" else dev_name
|
|
|
|
def on_level(lvl):
|
|
win.after(0, lambda: level_canvas.coords(level_bar, 0, 0, int(lvl * 200), 14))
|
|
|
|
def on_done(ok):
|
|
def _update():
|
|
test_btn.config(state="normal", text="Test")
|
|
if ok:
|
|
test_label.config(text="Signal erkannt", fg=GREEN)
|
|
else:
|
|
test_label.config(text="Kein Signal!", fg="#f05050")
|
|
win.after(0, _update)
|
|
|
|
test_device(device, 2.0, on_level, on_done)
|
|
|
|
test_btn = tk.Button(f_test, text="Test", command=run_mic_test,
|
|
bg=BG3, fg=FG, font=FONT_S, relief="flat",
|
|
padx=10, pady=3, cursor="hand2", bd=0)
|
|
test_btn.pack(side="left", padx=(8, 0))
|
|
|
|
# ── MODELL ──
|
|
section("MODELL")
|
|
model_hints = {
|
|
"tiny": "~1 GB VRAM · sehr schnell",
|
|
"base": "~1 GB VRAM",
|
|
"small": "~2 GB VRAM",
|
|
"medium": "~5 GB VRAM · empfohlen ✓",
|
|
"large-v2": "~10 GB VRAM",
|
|
"large-v3": "~10 GB VRAM · bestes Ergebnis",
|
|
}
|
|
model_var = tk.StringVar(value=cfg.config["model"])
|
|
f_model = row("Modell")
|
|
dd(f_model, model_var, cfg.MODELS, 14).pack(side="left")
|
|
hint_lbl = tk.Label(f_model, text=model_hints.get(cfg.config["model"], ""),
|
|
font=FONT_S, bg=BG, fg=FG2)
|
|
hint_lbl.pack(side="left", padx=(14, 0))
|
|
model_var.trace_add("write", lambda *_: hint_lbl.config(text=model_hints.get(model_var.get(), "")))
|
|
|
|
lang_display = {v: k for k, v in cfg.LANGUAGES.items()}
|
|
lang_var = tk.StringVar(value=lang_display.get(cfg.config["language"], "Deutsch"))
|
|
f = row("Sprache")
|
|
dd(f, lang_var, list(cfg.LANGUAGES.keys()), 14).pack(side="left")
|
|
|
|
# ── TEXTVERARBEITUNG ──
|
|
section("TEXTVERARBEITUNG")
|
|
grammar_var = tk.BooleanVar(value=cfg.config.get("grammar_check", True))
|
|
f_gc = row("Grammatikkorrektur", hint="pip install language_tool_python")
|
|
tk.Checkbutton(f_gc, variable=grammar_var, text="Aktiviert",
|
|
bg=BG, fg=FG, selectcolor=BG3, activebackground=BG,
|
|
activeforeground=FG, font=FONT_UI,
|
|
highlightthickness=0, bd=0).pack(side="left")
|
|
|
|
paste_delay_var = tk.IntVar(value=cfg.config.get("paste_delay_ms", 300))
|
|
f_pd = row("Paste-Verzögerung", hint="ms — höher bei langsamen Apps (z.B. Teams)")
|
|
paste_delay_lbl = tk.Label(f_pd, text=f"{paste_delay_var.get()} ms", font=FONT,
|
|
bg=BG, fg=FG, width=7, anchor="w")
|
|
tk.Scale(f_pd, variable=paste_delay_var, from_=50, to=2000, orient="horizontal",
|
|
length=200, bg=BG, fg=FG, troughcolor=BG3, highlightthickness=0,
|
|
showvalue=False, bd=0, sliderrelief="flat",
|
|
command=lambda v: paste_delay_lbl.config(text=f"{int(float(v))} ms")
|
|
).pack(side="left")
|
|
paste_delay_lbl.pack(side="left", padx=(8, 0))
|
|
|
|
duck_var = tk.BooleanVar(value=cfg.config.get("media_duck", True))
|
|
f_dk = row("Medien leiser stellen", hint="bei Aufnahme via PulseAudio/PipeWire")
|
|
tk.Checkbutton(f_dk, variable=duck_var, text="Aktiviert",
|
|
bg=BG, fg=FG, selectcolor=BG3, activebackground=BG,
|
|
activeforeground=FG, font=FONT_UI,
|
|
highlightthickness=0, bd=0).pack(side="left")
|
|
|
|
duck_pct_var = tk.IntVar(value=cfg.config.get("duck_percent", 20))
|
|
f_dp = row("Ducking-Stärke", hint="% der Originallautstärke")
|
|
duck_pct_lbl = tk.Label(f_dp, text=f"{duck_pct_var.get()} %", font=FONT,
|
|
bg=BG, fg=FG, width=7, anchor="w")
|
|
tk.Scale(f_dp, variable=duck_pct_var, from_=0, to=100, orient="horizontal",
|
|
length=200, bg=BG, fg=FG, troughcolor=BG3, highlightthickness=0,
|
|
showvalue=False, bd=0, sliderrelief="flat",
|
|
command=lambda v: duck_pct_lbl.config(text=f"{int(float(v))} %")
|
|
).pack(side="left")
|
|
duck_pct_lbl.pack(side="left", padx=(8, 0))
|
|
|
|
# ── LEISTUNG ──
|
|
section("LEISTUNG")
|
|
device_var = tk.StringVar(value=cfg.config["device"])
|
|
f = row("Gerät (GPU/CPU)", hint="cuda = NVIDIA GPU empfohlen")
|
|
dd(f, device_var, cfg.DEVICES, 8).pack(side="left")
|
|
|
|
ct_display = {v: k for k, v in cfg.COMPUTE_TYPES.items()}
|
|
ct_var = tk.StringVar(value=ct_display.get(cfg.config["compute_type"], "float16 (GPU)"))
|
|
f = row("Compute Type")
|
|
dd(f, ct_var, list(cfg.COMPUTE_TYPES.keys()), 18).pack(side="left")
|
|
|
|
# ── STEUERUNG ──
|
|
section("STEUERUNG")
|
|
hotkey_var = tk.StringVar(value=cfg.config["hotkey"])
|
|
f_hk = row("Hotkey", hint="z.B. ctrl+shift+space")
|
|
tk.Entry(f_hk, textvariable=hotkey_var, font=FONT, width=24,
|
|
bg=BG3, fg=FG, insertbackground=AMBER,
|
|
relief="flat", bd=6,
|
|
highlightbackground=BORDER, highlightthickness=1).pack(side="left")
|
|
|
|
# ── PFADE ──
|
|
section("PFADE")
|
|
|
|
vocab_path_var = tk.StringVar(value=cfg.config.get("vocab_path", ""))
|
|
f_vp = row("Vocabulary-Datei", hint="leer = lokal im App-Ordner")
|
|
vp_entry = tk.Entry(f_vp, textvariable=vocab_path_var, font=FONT, width=30,
|
|
bg=BG3, fg=FG, insertbackground=AMBER,
|
|
relief="flat", bd=6,
|
|
highlightbackground=BORDER, highlightthickness=1)
|
|
vp_entry.pack(side="left")
|
|
|
|
def browse_vocab():
|
|
path = filedialog.askopenfilename(
|
|
parent=win, title="Vocabulary-Datei wählen",
|
|
filetypes=[("JSON", "*.json"), ("Alle", "*.*")])
|
|
if path:
|
|
vocab_path_var.set(path)
|
|
|
|
tk.Button(f_vp, text="...", command=browse_vocab,
|
|
bg=BG3, fg=FG, font=FONT_S, relief="flat",
|
|
padx=8, pady=3, cursor="hand2", bd=0).pack(side="left", padx=(6, 0))
|
|
|
|
model_dir_var = tk.StringVar(value=cfg.config.get("model_dir", ""))
|
|
f_md = row("Modell-Verzeichnis", hint="leer = Standard-Cache")
|
|
md_entry = tk.Entry(f_md, textvariable=model_dir_var, font=FONT, width=30,
|
|
bg=BG3, fg=FG, insertbackground=AMBER,
|
|
relief="flat", bd=6,
|
|
highlightbackground=BORDER, highlightthickness=1)
|
|
md_entry.pack(side="left")
|
|
|
|
def browse_model_dir():
|
|
path = filedialog.askdirectory(parent=win, title="Modell-Verzeichnis wählen")
|
|
if path:
|
|
model_dir_var.set(path)
|
|
|
|
tk.Button(f_md, text="...", command=browse_model_dir,
|
|
bg=BG3, fg=FG, font=FONT_S, relief="flat",
|
|
padx=8, pady=3, cursor="hand2", bd=0).pack(side="left", padx=(6, 0))
|
|
|
|
# ── Buttons ──
|
|
tk.Frame(win, bg=BORDER, height=1).pack(fill="x")
|
|
btn_bar = tk.Frame(win, bg=BG2, pady=16, padx=32)
|
|
btn_bar.pack(fill="x")
|
|
|
|
def save():
|
|
sel = dev_var.get()
|
|
cfg.config["audio_device"] = None if sel == "Standard" else sel
|
|
cfg.config["model"] = model_var.get()
|
|
cfg.config["language"] = cfg.LANGUAGES[lang_var.get()]
|
|
cfg.config["device"] = device_var.get()
|
|
cfg.config["compute_type"] = cfg.COMPUTE_TYPES[ct_var.get()]
|
|
cfg.config["hotkey"] = hotkey_var.get()
|
|
cfg.config["vocab_path"] = vocab_path_var.get()
|
|
cfg.config["model_dir"] = model_dir_var.get()
|
|
cfg.config["grammar_check"] = grammar_var.get()
|
|
cfg.config["paste_delay_ms"] = paste_delay_var.get()
|
|
cfg.config["media_duck"] = duck_var.get()
|
|
cfg.config["duck_percent"] = duck_pct_var.get()
|
|
cfg.save_config()
|
|
win.destroy()
|
|
threading.Thread(target=on_reload, daemon=True).start()
|
|
|
|
def btn_hover(b, c_in, c_out):
|
|
b.bind("<Enter>", lambda _: b.config(bg=c_in))
|
|
b.bind("<Leave>", lambda _: b.config(bg=c_out))
|
|
|
|
save_btn = tk.Button(btn_bar, text="Speichern & Neuladen", command=save,
|
|
bg=AMBER, fg=BG, font=FONT_B,
|
|
relief="flat", padx=20, pady=9, cursor="hand2", bd=0)
|
|
save_btn.pack(side="right")
|
|
btn_hover(save_btn, AMBER2, AMBER)
|
|
|
|
cancel_btn = tk.Button(btn_bar, text="Abbrechen", command=win.destroy,
|
|
bg=BG3, fg=FG2, font=FONT_UI,
|
|
relief="flat", padx=20, pady=9, cursor="hand2", bd=0)
|
|
cancel_btn.pack(side="right", padx=(0, 10))
|
|
btn_hover(cancel_btn, BORDER, BG3)
|
|
|
|
# ── Installation ──
|
|
_add_installation_section(win, content, section, row,
|
|
BG, BG3, BORDER, FG, FG2, AMBER, FONT_UI, FONT_S, FONT_B)
|
|
|
|
# Center on screen after layout
|
|
win.update_idletasks()
|
|
sw = win.winfo_screenwidth()
|
|
sh = win.winfo_screenheight()
|
|
w = max(win.winfo_reqwidth(), 700)
|
|
h = min(win.winfo_reqheight(), sh - 100)
|
|
win.geometry(f"{w}x{h}+{(sw-w)//2}+{(sh-h)//2}")
|
|
|
|
|
|
def _add_installation_section(win, content, section, row, BG, BG3, BORDER, FG, FG2, AMBER, FONT_UI, FONT_S, FONT_B) -> None:
|
|
import sys
|
|
from whisper_app import installer
|
|
|
|
is_frozen = getattr(sys, "frozen", False)
|
|
section("INSTALLATION")
|
|
|
|
features = [
|
|
("Autostart", installer.autostart_installed, installer.install_autostart, installer.remove_autostart),
|
|
("Startmenü-Eintrag", installer.startmenu_installed, installer.install_startmenu, installer.remove_startmenu),
|
|
("Desktop-Verknüpfung", installer.desktop_installed, installer.install_desktop, installer.remove_desktop),
|
|
]
|
|
|
|
for label, is_installed_fn, install_fn, remove_fn in features:
|
|
f = row(label)
|
|
status_var = tk.StringVar(value="eingerichtet" if is_installed_fn() else "nicht eingerichtet")
|
|
status_lbl = tk.Label(f, textvariable=status_var, font=FONT_S,
|
|
bg=BG, fg=AMBER if is_installed_fn() else FG2)
|
|
status_lbl.pack(side="left", padx=(0, 12))
|
|
|
|
def make_callbacks(install_f, remove_f, sv, lbl):
|
|
def do_install():
|
|
install_f()
|
|
sv.set("eingerichtet")
|
|
lbl.config(fg=AMBER)
|
|
def do_remove():
|
|
remove_f()
|
|
sv.set("nicht eingerichtet")
|
|
lbl.config(fg=FG2)
|
|
return do_install, do_remove
|
|
|
|
do_install, do_remove = make_callbacks(install_fn, remove_fn, status_var, status_lbl)
|
|
|
|
btn_install = tk.Button(f, text="Einrichten", command=do_install,
|
|
bg=BG3, fg=FG, font=FONT_S, relief="flat",
|
|
padx=8, pady=3, cursor="hand2" if is_frozen else "arrow", bd=0)
|
|
btn_install.pack(side="left", padx=(0, 4))
|
|
|
|
btn_remove = tk.Button(f, text="Entfernen", command=do_remove,
|
|
bg=BG3, fg=FG2, font=FONT_S, relief="flat",
|
|
padx=8, pady=3, cursor="hand2" if is_frozen else "arrow", bd=0)
|
|
btn_remove.pack(side="left")
|
|
|
|
if not is_frozen:
|
|
for btn in (btn_install, btn_remove):
|
|
btn.config(state="disabled")
|
|
tk.Label(f, text="Nur im gebauten Binary verfügbar",
|
|
font=FONT_S, bg=BG, fg=FG2).pack(side="left", padx=(8, 0))
|