450 lines
19 KiB
Python
450 lines
19 KiB
Python
# -*- 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,
|
||
)
|
||
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)
|
||
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()
|
||
if not _win_clipboard_set(full):
|
||
try:
|
||
self.clipboard_clear()
|
||
self.clipboard_append(sanitize_markdown_for_plain_text(full))
|
||
except Exception:
|
||
pass
|
||
if full:
|
||
try:
|
||
save_to_ablage("Diktat", full)
|
||
except Exception:
|
||
pass
|
||
self.set_status("Diktat transkribiert und kopiert.")
|
||
status_var.set("Fertig. Text wurde ins Zwischenablage kopiert.")
|
||
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)
|