This commit is contained in:
2026-06-13 22:47:31 +02:00
parent add3da5177
commit d1446fc452
8032 changed files with 2650751 additions and 1551 deletions

View File

@@ -7,7 +7,7 @@ import os
import threading
import wave
import tkinter as tk
from tkinter import ttk, messagebox
from tkinter import messagebox
from tkinter.scrolledtext import ScrolledText
from aza_persistence import (
@@ -25,6 +25,8 @@ from aza_ui_helpers import (
add_resize_grip,
add_font_scale_control,
add_text_font_size_control,
add_tool_pin_button,
apply_tool_window_pin,
RoundedButton,
)
from aza_audio import AudioRecorder
@@ -54,7 +56,6 @@ class AzaDiktatMixin:
win.title("Diktat")
win.minsize(DIKTAT_MIN_W, DIKTAT_MIN_H)
win.configure(bg=_BG)
win.attributes("-topmost", True)
self._diktat_window = win
self._register_window(win)
@@ -90,6 +91,16 @@ class AzaDiktatMixin:
save_diktat_geometry(geom)
except Exception:
pass
try:
apply_tool_window_pin(win, False)
except Exception:
pass
try:
from aza_mini_diktat_window import close_mini_diktat_window
close_mini_diktat_window(self, restore_diktat=False)
except Exception:
pass
self._diktat_window = None
self._diktat_recording_active = False
self._diktat_recorder = None
@@ -159,7 +170,6 @@ class AzaDiktatMixin:
if _mini_status_lbl[0]:
_mini_status_lbl[0]._parent_bar.pack_forget()
body_outer.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))
@@ -175,7 +185,6 @@ class AzaDiktatMixin:
else:
_dik_geom_before[0] = win.geometry()
body_outer.pack_forget()
btn_minimize_dik.configure(text="")
_dik_minimized[0] = True
win.minsize(200, 74)
w_cur = win.winfo_width()
@@ -221,21 +230,37 @@ class AzaDiktatMixin:
win.bind("<Configure>", _on_dik_configure, add="+")
btn_minimize_dik = tk.Label(diktat_header, text="", font=("Segoe UI", 12, "bold"),
bg=_HDR_BG, fg="#A0C4D8", cursor="hand2", padx=6)
btn_minimize_dik.pack(side="right", padx=(0, 4))
btn_minimize_dik.bind("<Button-1>", lambda e: _toggle_minimize_diktat())
btn_minimize_dik.bind("<Enter>", lambda e: btn_minimize_dik.configure(fg="#FFFFFF"))
btn_minimize_dik.bind("<Leave>", lambda e: btn_minimize_dik.configure(fg="#A0C4D8"))
btn_close_hdr = tk.Label(diktat_header, text="Schliessen", font=("Segoe UI", 9),
bg=_HDR_BG, fg="#A0C4D8", cursor="hand2", padx=10)
btn_close_hdr.pack(side="right", padx=(0, 6))
btn_close_hdr.bind("<Button-1>", lambda e: on_diktat_close())
btn_close_hdr.bind("<Enter>", lambda e: btn_close_hdr.configure(fg="#FFFFFF"))
btn_close_hdr.bind("<Leave>", lambda e: btn_close_hdr.configure(fg="#A0C4D8"))
def _open_mini_diktat_mode(evt=None):
try:
from aza_mini_diktat_window import open_mini_diktat_window
ax = ay = None
if evt is not None:
ax, ay = evt.x_root, evt.y_root
open_mini_diktat_window(self, win, anchor_x=ax, anchor_y=ay)
except Exception:
pass
btn_mini_diktat = tk.Label(
diktat_header, text="Mini-Diktat", font=("Segoe UI", 9),
bg=_HDR_BG, fg="#A0C4D8", cursor="hand2", padx=6,
)
btn_mini_diktat.pack(side="right", padx=(0, 4))
btn_mini_diktat.bind("<Button-1>", _open_mini_diktat_mode)
btn_mini_diktat.bind("<Enter>", lambda e: btn_mini_diktat.configure(fg="#FFFFFF"))
btn_mini_diktat.bind("<Leave>", lambda e: btn_mini_diktat.configure(fg="#A0C4D8"))
win._aza_mini_diktat_btn = btn_mini_diktat
add_tool_pin_button(diktat_header, win, bg=_HDR_BG, side="right", padx=(0, 4))
if getattr(win, "_tool_pin_btn", None) is not None:
try:
win._tool_pin_btn.configure(fg="#E8F4FA", font=("Segoe UI Emoji", 12), padx=8)
except Exception:
pass
win._aza_minimize = _toggle_minimize_diktat
win._aza_is_minimized = lambda: _dik_minimized[0]
win._aza_restore_diktat_content = _restore_diktat_content
if hasattr(self, "_aza_windows"):
self._aza_windows.add(win)
@@ -315,16 +340,20 @@ class AzaDiktatMixin:
cb_row = tk.Frame(main_inner, bg=_CARD_BG)
cb_row.pack(fill="x", pady=(2, 0))
_diktat_autocopy_var = tk.BooleanVar(value=is_autocopy_after_diktat_enabled())
ttk.Checkbutton(
tk.Checkbutton(
cb_row, text="Autokopie nach Transkription",
variable=_diktat_autocopy_var,
command=lambda: save_autocopy_prefs(autocopy=_diktat_autocopy_var.get()),
bg=_CARD_BG, fg=_TEXT, activebackground=_CARD_BG,
selectcolor="#FFFFFF", font=("Segoe UI", 8),
).pack(side="left")
_diktat_rclick_var = tk.BooleanVar(value=is_global_right_click_paste_enabled())
ttk.Checkbutton(
tk.Checkbutton(
cb_row, text="Rechtsklick = Einfügen",
variable=_diktat_rclick_var,
command=lambda: save_autocopy_prefs(global_right_click=_diktat_rclick_var.get()),
bg=_CARD_BG, fg=_TEXT, activebackground=_CARD_BG,
selectcolor="#FFFFFF", font=("Segoe UI", 8),
).pack(side="left", padx=(12, 0))
btn_row = tk.Frame(main_inner, bg=_CARD_BG)
@@ -333,8 +362,45 @@ class AzaDiktatMixin:
btn_row2.pack(fill="x", pady=(4, 0))
diktat_recorder = [None]
is_recording = [False]
append_next_transcript = [False]
def _recorder_hardware_active(rec) -> bool:
if rec is None:
return False
if getattr(rec, "_recording", False):
return True
proc = getattr(rec, "_ffmpeg_proc", None)
if proc is not None and proc.poll() is None:
return True
stream = getattr(rec, "_stream", None)
if stream is not None:
try:
return bool(stream.active)
except Exception:
pass
return False
def _diktat_recorder_phase() -> str:
"""Zentrale Zustandsquelle: idle | recording | finalizing | error."""
if getattr(self, "_diktat_neu_busy", False):
return "finalizing"
if is_recording[0]:
return "recording"
if _recorder_hardware_active(diktat_recorder[0]):
return "recording"
return "idle"
def _sync_mini_diktat_ui():
try:
from aza_mini_diktat_window import sync_mini_diktat_window
sync_mini_diktat_window(self, diktat_win=win)
except Exception:
pass
def toggle_diktat():
if getattr(self, "_diktat_neu_busy", False):
return
if not diktat_recorder[0]:
diktat_recorder[0] = AudioRecorder()
rec = diktat_recorder[0]
@@ -344,18 +410,22 @@ class AzaDiktatMixin:
is_recording[0] = True
self._diktat_recording_active = True
self._diktat_recorder = rec
btn_diktat_record.configure(text="Aufnahme stoppen", bg="#C86B2A", active_bg="#A85520")
lbl_status.configure(fg=_STATUS_REC)
status_var.set("Aufnahme läuft…")
_refresh_diktat_controls()
except Exception as e:
messagebox.showerror("Aufnahme-Fehler", str(e))
status_var.set("Bereit.")
else:
is_recording[0] = False
self._diktat_recording_active = False
btn_diktat_record.configure(text="Diktat starten", bg=_BTN_PRI, active_bg=_BTN_PRI_H)
# Finalizing-Sperre: blockiert Doppelklick/zweiten Start/zweiten Stop
# (toggle_diktat, _diktat_weiterfahren, _diktat_neu_von_logo pruefen das Flag),
# bis die Transkription abgeschlossen ist. Wird IMMER (finally) zurueckgesetzt.
self._diktat_neu_busy = True
lbl_status.configure(fg=_TEXT_SUB)
status_var.set("Transkribiere")
status_var.set("Aufnahme wird abgeschlossen ")
_refresh_diktat_controls()
def worker():
def _safe_after(fn):
@@ -365,6 +435,13 @@ class AzaDiktatMixin:
except Exception:
pass
def _after_finalize():
self._diktat_neu_busy = False
try:
_refresh_diktat_controls()
except Exception:
pass
try:
wav_path = rec.stop_and_save_wav()
@@ -406,7 +483,7 @@ class AzaDiktatMixin:
_safe_after(lambda: status_var.set("Kein Text erkannt."))
return
transcript_text = self._diktat_apply_punctuation(transcript_text)
transcript_text = self._diktat_postprocess_transcript(transcript_text)
if not transcript_text or not transcript_text.strip():
diktat_recorder[0] = None
@@ -423,6 +500,9 @@ class AzaDiktatMixin:
except Exception:
pass
_safe_after(_show_err)
finally:
# Busy-Flag immer zuruecksetzen (Stop garantiert beendet Finalizing).
_safe_after(_after_finalize)
def _done(text):
diktat_recorder[0] = None
@@ -432,9 +512,22 @@ class AzaDiktatMixin:
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 append_next_transcript[0]:
append_next_transcript[0] = False
existing = txt.get("1.0", "end").strip()
merged = text or ""
if existing and merged:
sep = "\n\n" if existing.endswith("\n") else (
" " if not existing.endswith((" ", "-", ":", ";")) else ""
)
merged = existing + sep + merged
txt.delete("1.0", "end")
txt.insert("1.0", merged)
full = merged.strip()
else:
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):
@@ -455,12 +548,177 @@ class AzaDiktatMixin:
else:
self.set_status("Diktat transkribiert.")
status_var.set("Fertig.")
_refresh_diktat_controls()
except Exception:
pass
threading.Thread(target=worker, daemon=True).start()
def _refresh_diktat_controls():
phase = _diktat_recorder_phase()
try:
if phase == "finalizing":
btn_diktat_record.configure(
text="Diktat stoppen", bg="#C86B2A", active_bg="#A85520",
command=lambda: None,
)
elif phase == "recording":
btn_diktat_record.configure(
text="Diktat stoppen", bg="#C86B2A", active_bg="#A85520",
command=toggle_diktat,
)
else:
btn_diktat_record.configure(
text="Diktat starten", bg=_BTN_PRI, active_bg=_BTN_PRI_H,
command=toggle_diktat,
)
except Exception:
pass
_sync_mini_diktat_ui()
win._aza_diktat_toggle = toggle_diktat
win._aza_diktat_phase = _diktat_recorder_phase
win._aza_diktat_refresh = _refresh_diktat_controls
win._aza_diktat_is_recording = lambda: _diktat_recorder_phase() == "recording"
win._aza_diktat_status_var = status_var
win._aza_diktat_text = txt
def _preserve_diktat_text_if_any() -> bool:
"""Bestehenden Text sichern (Kopie/Ablage) bevor das Feld geleert wird."""
existing = txt.get("1.0", "end").strip()
if not existing:
return False
copied = False
if _diktat_autocopy_var.get():
if not _win_clipboard_set(existing):
try:
self.clipboard_clear()
self.clipboard_append(sanitize_markdown_for_plain_text(existing))
except Exception:
pass
copied = True
try:
save_to_ablage("Diktat", existing)
except Exception:
pass
if copied:
status_var.set("Vorheriges Diktat gesichert. Kopiert.")
else:
status_var.set("Vorheriges Diktat gesichert.")
return True
def _stop_recording_async_then(on_ready, busy_status):
"""Stoppt die laufende Aufnahme im Hintergrund (kein Tk-Mainthread-Block)
und ruft danach on_ready() im Mainthread auf.
Root-Cause-Fix: rec.stop_and_save_wav() macht ffmpeg_proc.wait(timeout=30)
und blockierte bisher den UI-Thread (Logo "Neues Diktat" -> Programm haengt).
self._diktat_neu_busy schuetzt zugleich gegen Doppelklick/Doppelstart
(toggle_diktat, _diktat_weiterfahren und do_neu pruefen dieses Flag)."""
rec = diktat_recorder[0]
is_recording[0] = False
self._diktat_recording_active = False
self._diktat_neu_busy = True
try:
lbl_status.configure(fg=_TEXT_SUB)
except Exception:
pass
status_var.set(busy_status)
_refresh_diktat_controls()
diktat_recorder[0] = None
self._diktat_recorder = None
def _bg():
ok = True
try:
if rec is not None:
wav_path = rec.stop_and_save_wav()
try:
if wav_path and os.path.exists(wav_path):
os.remove(wav_path)
except Exception:
pass
except Exception:
ok = False
def _fin():
self._diktat_neu_busy = False
if ok:
try:
on_ready()
except Exception:
pass
else:
status_var.set("Aufnahme-Fehler. Bestehender Text erhalten.")
_refresh_diktat_controls()
try:
if self.winfo_exists():
self.after(0, _fin)
else:
self._diktat_neu_busy = False
except Exception:
self._diktat_neu_busy = False
threading.Thread(target=_bg, daemon=True).start()
def _diktat_weiterfahren():
if getattr(self, "_diktat_neu_busy", False):
return
if _diktat_recorder_phase() == "recording":
toggle_diktat()
return
append_next_transcript[0] = True
try:
txt.focus_set()
txt.mark_set(tk.INSERT, tk.END)
except Exception:
pass
if not is_recording[0]:
toggle_diktat()
def _diktat_neu_von_logo():
if getattr(self, "_diktat_neu_busy", False):
return
if is_recording[0]:
if not messagebox.askyesno(
"Aufnahme läuft",
"Es läuft gerade eine Aufnahme.\n"
"Möchtest du die aktuelle Aufnahme verwerfen und ein neues Diktat starten?",
parent=win,
):
return
def _ready_logo():
append_next_transcript[0] = False
_preserve_diktat_text_if_any()
try:
txt.configure(state="normal")
txt.delete("1.0", "end")
except Exception:
pass
status_var.set("Aufnahme läuft …")
toggle_diktat()
_stop_recording_async_then(_ready_logo, "Aktuelle Aufnahme wird abgeschlossen …")
return
append_next_transcript[0] = False
_preserve_diktat_text_if_any()
txt.configure(state="normal")
txt.delete("1.0", "end")
status_var.set("Aufnahme läuft …")
toggle_diktat()
def _diktat_korrigieren():
_diktat_weiterfahren()
win._aza_diktat_weiterfahren = _diktat_weiterfahren
win._aza_diktat_neu_von_logo = _diktat_neu_von_logo
win._aza_diktat_korrigieren = _diktat_korrigieren
def do_neu():
if getattr(self, "_diktat_neu_busy", False):
return
if is_recording[0]:
if not messagebox.askyesno(
"Aufnahme läuft",
@@ -469,19 +727,18 @@ class AzaDiktatMixin:
parent=win
):
return
rec = diktat_recorder[0]
is_recording[0] = False
self._diktat_recording_active = False
btn_diktat_record.configure(text="Diktat starten", bg=_BTN_PRI, active_bg=_BTN_PRI_H)
lbl_status.configure(fg=_TEXT_SUB)
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
self._diktat_recorder = None
def _ready_neu():
try:
txt.configure(state="normal")
txt.delete("1.0", "end")
except Exception:
pass
status_var.set("Bereit.")
toggle_diktat()
_stop_recording_async_then(_ready_neu, "Aktuelle Aufnahme wird abgeschlossen …")
return
txt.configure(state="normal")
txt.delete("1.0", "end")
status_var.set("Bereit.")
@@ -524,5 +781,4 @@ class AzaDiktatMixin:
width=80, height=30, canvas_bg=_CARD_BG,
bg=_BTN_SEC, fg=_BTN_PRI, active_bg="#E8F4FA",
).pack(side="left", padx=(8, 0))
if self._autotext_data.get("diktat_auto_start", True):
win.after(350, toggle_diktat)
_refresh_diktat_controls()