Files
aza/AzA march 2026 - Kopie (6)/aza_diktat_mixin.py
2026-04-16 13:32:32 +02:00

475 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
AzaDiktatMixin Diktat-Fenster (nur Transkription, keine KG).
"""
import os
import threading
import wave
import tkinter as tk
from tkinter import ttk, messagebox
from tkinter.scrolledtext import ScrolledText
from aza_persistence import (
load_diktat_geometry,
save_diktat_geometry,
save_to_ablage,
_win_clipboard_set,
sanitize_markdown_for_plain_text,
is_autocopy_after_diktat_enabled,
is_global_right_click_paste_enabled,
save_autocopy_prefs,
)
from aza_ui_helpers import (
center_window,
add_resize_grip,
add_font_scale_control,
add_text_font_size_control,
RoundedButton,
)
from aza_audio import AudioRecorder
class AzaDiktatMixin:
"""Mixin für das Diktat-Fenster (nur Aufnahme + Transkription)."""
def open_diktat_window(self):
"""Unabhängiges Fenster: nur Diktat aufnehmen und transkribieren (keine KG). Text wird automatisch kopiert."""
if not self.ensure_ready():
return
DIKTAT_MIN_W, DIKTAT_MIN_H = 420, 380
win = tk.Toplevel(self)
win.title("Diktat nur Transkription")
win.minsize(DIKTAT_MIN_W, DIKTAT_MIN_H)
win.configure(bg="#B9ECFA")
win.attributes("-topmost", True)
self._diktat_window = win
self._register_window(win)
# Fensterposition: gespeichert laden oder zentrieren
saved_geom = load_diktat_geometry()
if saved_geom:
# Gespeicherte Position verwenden (Position wird beibehalten!)
win.geometry(saved_geom)
else:
# Keine gespeicherte Position → zentrieren
win.geometry("300x290")
center_window(win, 300, 290)
def on_diktat_close():
try:
geom = win.geometry()
save_diktat_geometry(geom)
except Exception:
pass
self._diktat_window = None
if hasattr(self, "_aza_windows"):
self._aza_windows.discard(win)
win.destroy()
if getattr(self, "_main_hidden", False):
try:
self.destroy()
except Exception:
pass
win.protocol("WM_DELETE_WINDOW", on_diktat_close)
# Speichere Position auch während Verschieben/Resize
_diktat_geom_after_id = [None]
def on_diktat_configure(e):
if e.widget is win and _diktat_geom_after_id[0]:
win.after_cancel(_diktat_geom_after_id[0])
if e.widget is win:
_diktat_geom_after_id[0] = win.after(400, lambda: save_diktat_geometry(win.geometry()))
win.bind("<Configure>", on_diktat_configure)
add_resize_grip(win, DIKTAT_MIN_W, DIKTAT_MIN_H)
add_font_scale_control(win)
# ─── Header mit Minimierungs-Button ───
diktat_header = tk.Frame(win, bg="#B9ECFA")
diktat_header.pack(fill="x")
tk.Label(diktat_header, text="🎙 Diktat", font=("Segoe UI", 12, "bold"),
bg="#B9ECFA", fg="#1a4d6d").pack(side="left", padx=10, pady=6)
_dik_minimized = [False]
_dik_geom_before = [None]
_dik_restoring = [False]
# Mini buttons + status (shown when minimized)
_mini_rec_btn = [None]
_mini_neu_btn = [None]
_mini_status_lbl = [None]
def _mini_toggle_record():
"""Start/Stop Aufnahme im minimierten Zustand."""
toggle_diktat()
if _mini_rec_btn[0]:
if is_recording[0]:
_mini_rec_btn[0].configure(text="", fg="#D04040")
else:
_mini_rec_btn[0].configure(text="", fg="#5A90B0")
def _mini_new_diktat():
"""Neu: Text leeren, laufende Aufnahme verwerfen, sofort neue Aufnahme starten."""
do_neu()
if _mini_rec_btn[0]:
if is_recording[0]:
_mini_rec_btn[0].configure(text="", fg="#D04040")
else:
_mini_rec_btn[0].configure(text="", fg="#5A90B0")
def _restore_diktat_content():
if not _dik_minimized[0]:
return
_dik_restoring[0] = True
if _mini_rec_btn[0]:
_mini_rec_btn[0].pack_forget()
if _mini_neu_btn[0]:
_mini_neu_btn[0].pack_forget()
if _mini_status_lbl[0]:
_mini_status_lbl[0]._parent_bar.pack_forget()
main_f.pack(fill="both", expand=True)
btn_minimize_dik.configure(text="")
_dik_minimized[0] = False
win.minsize(DIKTAT_MIN_W, DIKTAT_MIN_H)
win.after(200, lambda: _dik_restoring.__setitem__(0, False))
def _toggle_minimize_diktat():
if _dik_minimized[0]:
_restore_diktat_content()
if _dik_geom_before[0]:
try:
win.geometry(_dik_geom_before[0])
except Exception:
pass
else:
_dik_geom_before[0] = win.geometry()
main_f.pack_forget()
btn_minimize_dik.configure(text="")
_dik_minimized[0] = True
win.minsize(200, 74)
w_cur = win.winfo_width()
win.geometry(f"{w_cur}x74")
# Show mini record button
rec_text = "" if is_recording[0] else ""
rec_fg = "#D04040" if is_recording[0] else "#5A90B0"
if not _mini_rec_btn[0]:
_mini_rec_btn[0] = tk.Label(diktat_header, text=rec_text, font=("Segoe UI", 14, "bold"),
bg="#B9ECFA", fg=rec_fg, cursor="hand2", padx=4)
_mini_rec_btn[0].bind("<Button-1>", lambda e: _mini_toggle_record())
_mini_rec_btn[0].bind("<Enter>", lambda e: _mini_rec_btn[0].configure(fg="#1a4d6d"))
_mini_rec_btn[0].bind("<Leave>", lambda e: _mini_rec_btn[0].configure(
fg="#D04040" if is_recording[0] else "#5A90B0"))
else:
_mini_rec_btn[0].configure(text=rec_text, fg=rec_fg)
_mini_rec_btn[0].pack(side="left", padx=(0, 4))
if not _mini_neu_btn[0]:
_mini_neu_btn[0] = tk.Label(diktat_header, text="Neu", font=("Segoe UI", 9),
bg="#B9ECFA", fg="#5A90B0", cursor="hand2", padx=4)
_mini_neu_btn[0].bind("<Button-1>", lambda e: _mini_new_diktat())
_mini_neu_btn[0].bind("<Enter>", lambda e: _mini_neu_btn[0].configure(fg="#1a4d6d"))
_mini_neu_btn[0].bind("<Leave>", lambda e: _mini_neu_btn[0].configure(fg="#5A90B0"))
_mini_neu_btn[0].pack(side="left", padx=(0, 4))
# Show mini status bar below header
if not _mini_status_lbl[0]:
_mini_status_bar = tk.Frame(win, bg="#FFE4CC", height=22, padx=6, pady=2)
_mini_status_lbl[0] = tk.Label(
_mini_status_bar, textvariable=status_var,
fg="#BD4500", bg="#FFE4CC",
font=("Segoe UI", 8), anchor="w",
)
_mini_status_lbl[0].pack(side="left", fill="x", expand=True)
_mini_status_lbl[0]._parent_bar = _mini_status_bar
_mini_status_lbl[0]._parent_bar.pack(fill="x")
_mini_status_lbl[0].pack(side="left", fill="x", expand=True)
def _on_dik_configure(e):
if e.widget is not win:
return
if _dik_minimized[0] and not _dik_restoring[0] and e.height > 95:
_restore_diktat_content()
win.bind("<Configure>", _on_dik_configure, add="+")
btn_minimize_dik = tk.Label(diktat_header, text="", font=("Segoe UI", 12, "bold"),
bg="#B9ECFA", fg="#5A90B0", cursor="hand2", padx=6)
btn_minimize_dik.pack(side="right", padx=(0, 8))
btn_minimize_dik.bind("<Button-1>", lambda e: _toggle_minimize_diktat())
btn_minimize_dik.bind("<Enter>", lambda e: btn_minimize_dik.configure(fg="#1a4d6d"))
btn_minimize_dik.bind("<Leave>", lambda e: btn_minimize_dik.configure(fg="#5A90B0"))
win._aza_minimize = _toggle_minimize_diktat
win._aza_is_minimized = lambda: _dik_minimized[0]
if hasattr(self, "_aza_windows"):
self._aza_windows.add(win)
main_f = ttk.Frame(win, padding=12)
main_f.pack(fill="both", expand=True)
# Label + Schriftgrößen-Steuerung
label_frame = ttk.Frame(main_f)
label_frame.pack(fill="x", anchor="w")
ttk.Label(label_frame, text="Diktat (nur Transkription):").pack(side="left")
# Modus auch direkt im Diktat-Fenster: Medizin vs. Allgemein (exklusiv)
if not hasattr(self, "_transcribe_medical_var"):
self._transcribe_medical_var = tk.BooleanVar(value=True)
if not hasattr(self, "_transcribe_general_var"):
self._transcribe_general_var = tk.BooleanVar(value=False)
if not hasattr(self, "_transcribe_toggle_guard"):
self._transcribe_toggle_guard = False
def _set_mode(domain_value: str):
if hasattr(self, "_set_transcribe_domain"):
self._set_transcribe_domain(domain_value)
return
# Fallback ohne Hauptmethode
if domain_value == "general":
self._transcribe_medical_var.set(False)
self._transcribe_general_var.set(True)
else:
self._transcribe_medical_var.set(True)
self._transcribe_general_var.set(False)
def _on_med_toggle():
if self._transcribe_medical_var.get():
_set_mode("medical")
elif not self._transcribe_general_var.get():
_set_mode("medical")
def _on_gen_toggle():
if self._transcribe_general_var.get():
_set_mode("general")
elif not self._transcribe_medical_var.get():
_set_mode("medical")
tk.Checkbutton(
label_frame, text="Medizin", variable=self._transcribe_medical_var,
command=_on_med_toggle, bg="#B9ECFA", fg="#1a4d6d",
activebackground="#B9ECFA", selectcolor="#E8F4FA",
).pack(side="left", padx=(10, 4))
tk.Checkbutton(
label_frame, text="Allgemein", variable=self._transcribe_general_var,
command=_on_gen_toggle, bg="#B9ECFA", fg="#1a4d6d",
activebackground="#B9ECFA", selectcolor="#E8F4FA",
).pack(side="left")
# Kleinere Schrift für bessere Lesbarkeit (8pt statt 10pt)
diktat_font = ("Segoe UI", 8)
txt = ScrolledText(main_f, wrap="word", font=diktat_font, bg="#F5FCFF", height=8)
txt.pack(fill="both", expand=True, pady=(4, 4))
# Schriftgrößen-Spinbox in Fensterhintergrundfarbe
add_text_font_size_control(label_frame, txt, initial_size=8, bg_color="#B9ECFA", save_key="diktat_window")
self._bind_textblock_pending(txt)
status_var = tk.StringVar(value="Bereit.")
status_bar = tk.Frame(main_f, bg="#FFE4CC", height=24, padx=8, pady=4)
status_bar.pack(fill="x", pady=(4, 0))
status_bar.pack_propagate(False)
lbl_status = tk.Label(
status_bar, textvariable=status_var, fg="#BD4500", bg="#FFE4CC",
font=("Segoe UI", 8), anchor="w",
)
lbl_status.pack(side="left", fill="x", expand=True)
cb_row = tk.Frame(main_f, bg="#B9ECFA")
cb_row.pack(fill="x", pady=(2, 0))
_diktat_autocopy_var = tk.BooleanVar(value=is_autocopy_after_diktat_enabled())
ttk.Checkbutton(
cb_row, text="Autokopie nach Transkription",
variable=_diktat_autocopy_var,
command=lambda: save_autocopy_prefs(autocopy=_diktat_autocopy_var.get()),
).pack(side="left")
_diktat_rclick_var = tk.BooleanVar(value=is_global_right_click_paste_enabled())
ttk.Checkbutton(
cb_row, text="Rechtsklick = Einfügen",
variable=_diktat_rclick_var,
command=lambda: save_autocopy_prefs(global_right_click=_diktat_rclick_var.get()),
).pack(side="left", padx=(12, 0))
btn_row = ttk.Frame(main_f, padding=(0, 4, 0, 0))
btn_row.pack(fill="x")
btn_row2 = ttk.Frame(main_f, padding=(0, 2, 0, 0))
btn_row2.pack(fill="x")
diktat_recorder = [None]
is_recording = [False]
def toggle_diktat():
if not diktat_recorder[0]:
diktat_recorder[0] = AudioRecorder()
rec = diktat_recorder[0]
if not is_recording[0]:
try:
rec.start()
is_recording[0] = True
btn_diktat_record.configure(text="⏹ Aufnahme stoppen")
status_var.set("Aufnahme läuft…")
except Exception as e:
messagebox.showerror("Aufnahme-Fehler", str(e))
status_var.set("Bereit.")
else:
is_recording[0] = False
btn_diktat_record.configure(text="⏺ Aufnahme starten")
status_var.set("Transkribiere…")
def worker():
def _safe_after(fn):
try:
if self.winfo_exists():
self.after(0, fn)
except Exception:
pass
try:
wav_path = rec.stop_and_save_wav()
try:
with wave.open(wav_path, 'rb') as wf:
frames = wf.getnframes()
framerate = wf.getframerate()
duration = frames / float(framerate)
if duration < 0.3:
try:
if os.path.exists(wav_path):
os.remove(wav_path)
except Exception:
pass
diktat_recorder[0] = None
_safe_after(lambda: status_var.set("Kein Audio erkannt."))
return
except Exception:
pass
transcript_result = self.transcribe_wav(wav_path)
if hasattr(transcript_result, 'text'):
transcript_text = transcript_result.text
elif isinstance(transcript_result, str):
transcript_text = transcript_result
else:
transcript_text = ""
try:
if os.path.exists(wav_path):
os.remove(wav_path)
except Exception:
pass
if not transcript_text or not transcript_text.strip():
diktat_recorder[0] = None
_safe_after(lambda: status_var.set("Kein Text erkannt."))
return
transcript_text = self._diktat_apply_punctuation(transcript_text)
if not transcript_text or not transcript_text.strip():
diktat_recorder[0] = None
_safe_after(lambda: status_var.set("Kein Text erkannt."))
return
_safe_after(lambda: _done(transcript_text))
except Exception as e:
def _show_err(err=e):
try:
if win.winfo_exists():
messagebox.showerror("Fehler", str(err), parent=win)
status_var.set("Fehler.")
except Exception:
pass
_safe_after(_show_err)
def _done(text):
diktat_recorder[0] = None
try:
if not win.winfo_exists():
return
txt.configure(state="normal")
idx = txt.index(tk.INSERT)
txt.insert(idx, text)
full = txt.get("1.0", "end").strip()
copied = False
if _diktat_autocopy_var.get() and full:
if not _win_clipboard_set(full):
try:
self.clipboard_clear()
self.clipboard_append(sanitize_markdown_for_plain_text(full))
except Exception:
pass
copied = True
if full:
try:
save_to_ablage("Diktat", full)
except Exception:
pass
if copied:
self.set_status("Diktat transkribiert und kopiert.")
status_var.set("Fertig. Kopiert.")
else:
self.set_status("Diktat transkribiert.")
status_var.set("Fertig.")
except Exception:
pass
threading.Thread(target=worker, daemon=True).start()
def do_neu():
if is_recording[0]:
if not messagebox.askyesno(
"Aufnahme läuft",
"Es läuft gerade eine Aufnahme.\n"
"Möchtest du die aktuelle Aufnahme wirklich verwerfen und neu starten?",
parent=win
):
return
rec = diktat_recorder[0]
is_recording[0] = False
btn_diktat_record.configure(text="⏺ Aufnahme starten")
try:
wav_path = rec.stop_and_save_wav()
if os.path.exists(wav_path):
os.remove(wav_path)
except Exception:
pass
diktat_recorder[0] = None
txt.configure(state="normal")
txt.delete("1.0", "end")
status_var.set("Bereit.")
toggle_diktat()
def do_kopieren():
t = txt.get("1.0", "end").strip()
if t:
if not _win_clipboard_set(t):
self.clipboard_clear()
self.clipboard_append(sanitize_markdown_for_plain_text(t))
self.set_status("Diktat kopiert.")
else:
self.set_status("Nichts zum Kopieren.")
def do_clear_text():
txt.configure(state="normal")
txt.delete("1.0", "end")
status_var.set("Diktatfeld geleert.")
self.set_status("Diktatfeld geleert.")
btn_diktat_record = RoundedButton(
btn_row, "⏺ Aufnahme starten", command=toggle_diktat,
width=160, height=26, canvas_bg="#B9ECFA",
)
btn_diktat_record.pack(side="left")
RoundedButton(
btn_row, "Neu", command=do_neu,
width=70, height=26, canvas_bg="#B9ECFA",
).pack(side="left", padx=(6, 0))
RoundedButton(
btn_row2, "Kopieren", command=do_kopieren,
width=100, height=26, canvas_bg="#B9ECFA",
).pack(side="left")
RoundedButton(
btn_row2, "X", command=do_clear_text,
width=34, height=26, canvas_bg="#B9ECFA",
).pack(side="left", padx=(6, 0))
if self._autotext_data.get("diktat_auto_start", True):
win.after(350, toggle_diktat)