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("", _on_content_configure) def _on_canvas_configure(event): canvas.itemconfigure(content_id, width=event.width) canvas.bind("", _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("", _on_mousewheel) canvas.bind_all("", _on_button4) canvas.bind_all("", _on_button5) def _cleanup_binds(): try: canvas.unbind_all("") canvas.unbind_all("") canvas.unbind_all("") except tk.TclError: pass win.bind("", 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("", lambda _: b.config(bg=c_in)) b.bind("", 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))