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

@@ -0,0 +1 @@
Backup 20260613_182043

View File

@@ -0,0 +1,545 @@
# -*- coding: utf-8 -*-
"""
AudioRecorder Aufnahme direkt als M4A (AAC via ffmpeg-Pipe).
Kein WAV-Zwischenschritt. Fallback auf WAV nur wenn ffmpeg fehlt.
"""
import os
import shutil
import subprocess
import tempfile
import time
import wave
from datetime import datetime
from typing import List, Optional
import numpy as np
try:
import sounddevice as sd
except Exception:
sd = None
CHUNK_MAX_SECONDS = 600
_AUDIO_BACKUP_SUBDIR = "Audio_Backup"
def get_audio_backup_dir() -> str:
"""Gibt den sicheren Backup-Ordner für Audio zurück und erstellt ihn bei Bedarf."""
docs = os.path.join(os.path.expanduser("~"), "Documents")
if not os.path.isdir(docs):
docs = os.path.expanduser("~")
backup_dir = os.path.join(docs, "KG_Diktat_Ablage", _AUDIO_BACKUP_SUBDIR)
os.makedirs(backup_dir, exist_ok=True)
return backup_dir
def persist_audio_safe(temp_path: str) -> str:
"""Kopiert Audio in den sicheren Backup-Ordner. Gibt neuen Pfad zurück."""
backup_dir = get_audio_backup_dir()
ext = os.path.splitext(temp_path)[1] or ".m4a"
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_name = f"aufnahme_{ts}{ext}"
safe_path = os.path.join(backup_dir, safe_name)
shutil.copy2(temp_path, safe_path)
return safe_path
def wait_for_audio_file_stable(
path: str,
*,
min_size: int = 1,
timeout: float = 3.0,
interval: float = 0.15,
) -> bool:
"""Wartet bis Audiodatei existiert, lesbar ist und die Groesse stabil bleibt."""
deadline = time.time() + max(0.1, float(timeout))
last_size = -1
stable_reads = 0
while time.time() < deadline:
try:
if not os.path.isfile(path):
stable_reads = 0
time.sleep(interval)
continue
size = os.path.getsize(path)
if size < min_size:
stable_reads = 0
last_size = size
time.sleep(interval)
continue
if size == last_size:
stable_reads += 1
else:
stable_reads = 0
last_size = size
time.sleep(interval)
continue
if stable_reads >= 2:
with open(path, "rb") as fh:
fh.read(1)
return True
except Exception:
stable_reads = 0
time.sleep(interval)
return False
def cleanup_old_audio_backups(max_age_days: int = 30):
"""Löscht Audio-Backups älter als max_age_days (nur erfolgreich transkribierte)."""
backup_dir = get_audio_backup_dir()
cutoff = datetime.now().timestamp() - max_age_days * 86400
try:
for f in os.listdir(backup_dir):
fp = os.path.join(backup_dir, f)
if os.path.isfile(fp) and os.path.getmtime(fp) < cutoff:
try:
os.remove(fp)
except Exception:
pass
except Exception:
pass
_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
_WINDOWS_SOUND_SETTINGS = "Einstellungen > System > Sound > Eingabe"
_mic_check_cache: dict = {}
def _fail(msg: str, dev_name=None, dev_index=None) -> dict:
return {"ok": False, "device_name": dev_name, "device_index": dev_index, "message": msg}
def check_microphone(force: bool = False) -> dict:
"""Prüft ob ein brauchbares Mikrofon verfügbar ist.
Returns dict:
ok (bool), device_name (str|None), device_index (int|None),
message (str deutsch, benutzerfreundlich)
"""
if not force and _mic_check_cache.get("result"):
return _mic_check_cache["result"]
def _cache(r):
_mic_check_cache["result"] = r
return r
if sd is None:
return _cache(_fail(
"Audio-Modul nicht verfügbar.\n\n"
"Das Paket 'sounddevice' konnte nicht geladen werden.\n"
"Aufnahme und Diktat sind nicht möglich."
))
# --- Schritt 1: Default-Input-Device abfragen ---
dev_index = None
dev_name = None
try:
info = sd.query_devices(kind="input")
dev_name = info["name"]
dev_index = sd.default.device[0]
except Exception:
pass
# --- Schritt 2: Fallback alle Geräte durchsuchen ---
if dev_name is None:
try:
all_devs = sd.query_devices()
for i, d in enumerate(all_devs):
try:
if d["max_input_channels"] > 0:
dev_name = d["name"]
dev_index = i
break
except (KeyError, TypeError, IndexError):
continue
except Exception:
pass
if dev_name is None:
return _cache(_fail(
"Kein Mikrofon gefunden.\n\n"
"Bitte schliessen Sie ein Mikrofon an oder\n"
"aktivieren Sie es in den Windows-Einstellungen:\n\n"
f" {_WINDOWS_SOUND_SETTINGS}"
))
# --- Schritt 3: Kanäle prüfen ---
try:
info = sd.query_devices(dev_index) if dev_index is not None else sd.query_devices(kind="input")
max_ch = info["max_input_channels"]
except Exception:
max_ch = 0
if max_ch < 1:
return _cache(_fail(
f"Gerät '{dev_name}' hat keine Eingangskanäle.\n\n"
"Bitte ein anderes Mikrofon auswählen:\n\n"
f" {_WINDOWS_SOUND_SETTINGS}",
dev_name, dev_index,
))
# --- Schritt 4: Kurzer Öffnungstest ---
try:
test_stream = sd.InputStream(
device=dev_index,
samplerate=16000,
channels=1,
dtype="float32",
blocksize=1024,
)
test_stream.close()
except Exception as e:
err = str(e)
return _cache(_fail(
f"Mikrofon '{dev_name}' konnte nicht geöffnet werden.\n\n"
"Mögliche Ursachen:\n"
" - Mikrofon ist von einer anderen App belegt\n"
" - Zugriff in Windows-Datenschutz blockiert\n"
" - Gerät ist deaktiviert oder getrennt\n\n"
f"Windows-Einstellungen:\n {_WINDOWS_SOUND_SETTINGS}\n\n"
f"(Technisch: {err[:120]})",
dev_name, dev_index,
))
result = {
"ok": True,
"device_name": dev_name,
"device_index": dev_index,
"message": f"Mikrofon bereit: {dev_name}",
}
return _cache(result)
def invalidate_mic_cache():
"""Setzt den Mikrofon-Cache zurück (z.B. nach Gerätewechsel)."""
_mic_check_cache.clear()
def _find_ffmpeg() -> Optional[str]:
path = shutil.which("ffmpeg")
if path:
return path
script_dir = os.path.dirname(os.path.abspath(__file__))
for candidate in (
os.path.join(script_dir, "ffmpeg.exe"),
os.path.join(script_dir, "_internal", "ffmpeg.exe"),
):
if os.path.isfile(candidate):
return candidate
return None
class AudioRecorder:
"""Nimmt Audio auf und streamt es direkt in ffmpeg (M4A/AAC).
Wenn ffmpeg verfuegbar: Audio wird waehrend der Aufnahme in Echtzeit
als M4A kodiert kein WAV-Zwischenschritt, sofort kleine Datei.
Wenn ffmpeg fehlt: Fallback auf WAV (16kHz mono 16-bit PCM).
"""
def __init__(self, samplerate=16000, channels=1):
self.samplerate = samplerate
self.channels = channels
self._stream = None
self._ffmpeg_proc: Optional[subprocess.Popen] = None
self._output_path: Optional[str] = None
self._recording = False
self._wav_fallback = False
self._frames: list = []
def start(self):
mic = check_microphone()
if not mic["ok"]:
raise RuntimeError(mic["message"])
self._recording = True
self._wav_fallback = False
self._frames = []
self._ffmpeg_proc = None
self._device_index = mic.get("device_index")
ffmpeg = _find_ffmpeg()
if ffmpeg:
fd, self._output_path = tempfile.mkstemp(suffix=".m4a", prefix="kg_rec_")
os.close(fd)
try:
self._ffmpeg_proc = subprocess.Popen(
[ffmpeg, "-y",
"-f", "s16le", "-ar", str(self.samplerate),
"-ac", str(self.channels), "-i", "pipe:0",
"-c:a", "aac", "-b:a", "64k",
"-movflags", "+faststart",
self._output_path],
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=_NO_WINDOW,
)
except Exception:
self._ffmpeg_proc = None
self._wav_fallback = True
self._output_path = None
else:
self._wav_fallback = True
def callback(indata, frames, time_info, status):
if not self._recording:
return
pcm = (np.clip(indata, -1.0, 1.0) * 32767.0).astype(np.int16)
if self._ffmpeg_proc and self._ffmpeg_proc.stdin:
try:
self._ffmpeg_proc.stdin.write(pcm.tobytes())
except Exception:
pass
else:
self._frames.append(indata.copy())
try:
self._stream = sd.InputStream(
device=self._device_index,
samplerate=self.samplerate,
channels=self.channels,
callback=callback,
dtype="float32",
blocksize=0,
)
self._stream.start()
except Exception as e:
invalidate_mic_cache()
err = str(e)
if "device" in err.lower() or "portaudio" in err.lower() or "-1" in err:
raise RuntimeError(
"Mikrofon konnte nicht geöffnet werden.\n\n"
"Bitte prüfen Sie:\n"
" - Ist ein Mikrofon angeschlossen?\n"
" - Ist es in Windows aktiviert?\n\n"
f"Windows: {_WINDOWS_SOUND_SETTINGS}\n\n"
f"(Technisch: {err[:120]})"
) from None
raise
def stop_and_save(self) -> str:
"""Stoppt Aufnahme, gibt Pfad zur fertigen Audiodatei zurueck."""
if not self._stream:
raise RuntimeError("Recorder wurde nicht gestartet.")
self._recording = False
self._stream.stop()
self._stream.close()
self._stream = None
if self._ffmpeg_proc and self._ffmpeg_proc.stdin:
try:
self._ffmpeg_proc.stdin.close()
except Exception:
pass
try:
self._ffmpeg_proc.wait(timeout=30)
except Exception:
try:
self._ffmpeg_proc.kill()
except Exception:
pass
if (self._output_path
and os.path.isfile(self._output_path)
and os.path.getsize(self._output_path) > 0):
self._ffmpeg_proc = None
return self._output_path
self._ffmpeg_proc = None
self._wav_fallback = True
if self._wav_fallback or not self._output_path:
return self._save_wav_fallback()
return self._output_path
def stop_and_save_wav(self) -> str:
"""Legacy-Alias."""
return self.stop_and_save()
def _save_wav_fallback(self) -> str:
if not self._frames:
raise RuntimeError("Keine Audio-Daten aufgenommen (leer).")
audio = np.concatenate(self._frames, axis=0)
audio = np.clip(audio, -1.0, 1.0)
pcm16 = (audio * 32767.0).astype(np.int16)
fd, path = tempfile.mkstemp(suffix=".wav", prefix="kg_rec_")
os.close(fd)
with wave.open(path, "wb") as wf:
wf.setnchannels(self.channels)
wf.setsampwidth(2)
wf.setframerate(self.samplerate)
wf.writeframes(pcm16.tobytes())
return path
# ── Chunking ──────────────────────────────────────────────────────────
def split_audio_into_chunks(audio_path: str, max_seconds: int = CHUNK_MAX_SECONDS) -> List[str]:
ext = os.path.splitext(audio_path)[1].lower()
if ext == ".m4a":
return _split_m4a(audio_path, max_seconds)
return _split_wav(audio_path, max_seconds)
def _split_m4a(m4a_path: str, max_seconds: int) -> List[str]:
ffmpeg = _find_ffmpeg()
if not ffmpeg:
return [m4a_path]
try:
probe = subprocess.run(
[ffmpeg, "-i", m4a_path, "-f", "null", "-"],
capture_output=True, timeout=30, creationflags=_NO_WINDOW,
)
duration_s = None
for line in (probe.stderr or b"").decode("utf-8", errors="replace").splitlines():
if "Duration:" in line:
parts = line.split("Duration:")[1].split(",")[0].strip()
h, m, s = parts.split(":")
duration_s = int(h) * 3600 + int(m) * 60 + float(s)
break
if duration_s is None or duration_s <= max_seconds:
return [m4a_path]
except Exception:
return [m4a_path]
chunks: List[str] = []
offset = 0.0
idx = 0
while offset < duration_s:
fd, chunk_path = tempfile.mkstemp(suffix=f"_chunk{idx}.m4a", prefix="kg_rec_")
os.close(fd)
result = subprocess.run(
[ffmpeg, "-y", "-ss", str(offset), "-i", m4a_path,
"-t", str(max_seconds), "-c", "copy", chunk_path],
capture_output=True, timeout=120, creationflags=_NO_WINDOW,
)
if result.returncode == 0 and os.path.isfile(chunk_path) and os.path.getsize(chunk_path) > 0:
chunks.append(chunk_path)
else:
try:
os.remove(chunk_path)
except Exception:
pass
break
offset += max_seconds
idx += 1
return chunks if chunks else [m4a_path]
def _split_wav(wav_path: str, max_seconds: int) -> List[str]:
with wave.open(wav_path, "rb") as wf:
n_channels = wf.getnchannels()
sampwidth = wf.getsampwidth()
framerate = wf.getframerate()
n_frames = wf.getnframes()
duration_s = n_frames / framerate
if duration_s <= max_seconds:
return [wav_path]
chunk_frames = int(max_seconds * framerate)
chunks: List[str] = []
with wave.open(wav_path, "rb") as wf:
frames_remaining = n_frames
idx = 0
while frames_remaining > 0:
read_count = min(chunk_frames, frames_remaining)
data = wf.readframes(read_count)
fd, chunk_path = tempfile.mkstemp(suffix=f"_chunk{idx}.wav", prefix="kg_rec_")
os.close(fd)
with wave.open(chunk_path, "wb") as cf:
cf.setnchannels(n_channels)
cf.setsampwidth(sampwidth)
cf.setframerate(framerate)
cf.writeframes(data)
chunks.append(chunk_path)
frames_remaining -= read_count
idx += 1
return chunks
split_wav_into_chunks = split_audio_into_chunks
def test_audio_device(duration_sec: float = 1.5) -> dict:
"""Quick microphone test: records briefly and checks for signal.
Returns dict with keys:
ok (bool), device (str|None), message (str)
"""
if sd is None:
return {
"ok": False,
"device": None,
"message": "Python-Paket 'sounddevice' ist nicht verfügbar.\n"
"Audio-Aufnahme nicht möglich.",
}
try:
dev_info = sd.query_devices(kind="input")
device_name = dev_info.get("name", "Unbekanntes Gerät")
except Exception:
return {
"ok": False,
"device": None,
"message": "Kein Eingabegerät (Mikrofon) gefunden.\n"
"Bitte Mikrofon anschliessen und erneut versuchen.",
}
try:
audio = sd.rec(
int(duration_sec * 16000),
samplerate=16000,
channels=1,
dtype="float32",
blocking=True,
)
except Exception as exc:
return {
"ok": False,
"device": device_name,
"message": f"Aufnahmetest fehlgeschlagen:\n{exc}",
}
if audio is None or len(audio) == 0:
return {
"ok": False,
"device": device_name,
"message": "Keine Audio-Daten empfangen.\n"
"Bitte Mikrofon-Zugriff in den Windows-Einstellungen prüfen.",
}
peak = float(np.max(np.abs(audio)))
rms = float(np.sqrt(np.mean(audio ** 2)))
if peak < 0.001:
return {
"ok": False,
"device": device_name,
"message": f"Gerät: {device_name}\n\n"
f"Kein Signal erkannt (Peak={peak:.4f}).\n"
"Mikrofon ist möglicherweise stummgeschaltet oder defekt.",
}
level_pct = min(100, int(rms * 1000))
return {
"ok": True,
"device": device_name,
"message": f"Gerät: {device_name}\n\n"
f"Audio-Signal erkannt.\n"
f"Pegel: {level_pct}% (Peak={peak:.3f}, RMS={rms:.4f})\n\n"
"Mikrofon funktioniert.",
}

View File

@@ -0,0 +1,735 @@
# -*- coding: utf-8 -*-
"""
AzaDiktatMixin Diktat-Fenster (nur Transkription, keine KG).
"""
import os
import threading
import wave
import tkinter as tk
from tkinter import 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,
add_tool_pin_button,
apply_tool_window_pin,
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
_BG = "#E8F4FA"
_HDR_BG = "#1A4D6D"
_HDR_FG = "#FFFFFF"
_CARD_BG = "#FFFFFF"
_CARD_BD = "#C8D8E8"
_TEXT = "#1A3D55"
_TEXT_SUB = "#607890"
_BTN_PRI = "#1A4D6D"
_BTN_PRI_H = "#4A7A9E"
_BTN_SEC = "#FFFFFF"
_BTN_SEC_BD = "#C8D8E8"
_STATUS_REC = "#C86B2A"
win = tk.Toplevel(self)
win.title("Diktat")
win.minsize(DIKTAT_MIN_W, DIKTAT_MIN_H)
win.configure(bg=_BG)
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:
if is_recording[0]:
rec = diktat_recorder[0]
is_recording[0] = False
self._diktat_recording_active = False
try:
if rec is not None and hasattr(rec, "stop_and_save_wav"):
wav_path = rec.stop_and_save_wav()
if wav_path and os.path.exists(wav_path):
os.remove(wav_path)
except Exception:
pass
diktat_recorder[0] = None
self._diktat_recorder = None
except Exception:
pass
try:
geom = win.geometry()
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
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 (AzA Add-on Beta Stil) ───
diktat_header = tk.Frame(win, bg=_HDR_BG)
diktat_header.pack(fill="x")
tk.Label(diktat_header, text="Diktat", font=("Segoe UI", 12, "bold"),
bg=_HDR_BG, fg=_HDR_FG).pack(side="left", padx=(14, 6), pady=10)
_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()
body_outer.pack(fill="both", expand=True)
_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()
body_outer.pack_forget()
_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=_HDR_BG, 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="#A8D4F0"))
_mini_rec_btn[0].bind("<Leave>", lambda e: _mini_rec_btn[0].configure(
fg="#D04040" if is_recording[0] else "#A0C4D8"))
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=_HDR_BG, fg="#A0C4D8", 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="#FFFFFF"))
_mini_neu_btn[0].bind("<Leave>", lambda e: _mini_neu_btn[0].configure(fg="#A0C4D8"))
_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=_BG, height=22, padx=10, pady=2)
_mini_status_lbl[0] = tk.Label(
_mini_status_bar, textvariable=status_var,
fg=_STATUS_REC, bg=_BG,
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="+")
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)
body_outer = tk.Frame(win, bg=_BG, padx=12, pady=10)
body_outer.pack(fill="both", expand=True)
main_f = tk.Frame(body_outer, bg=_CARD_BG,
highlightbackground=_CARD_BD, highlightthickness=1)
main_f.pack(fill="both", expand=True, padx=0, pady=0)
main_inner = tk.Frame(main_f, bg=_CARD_BG, padx=12, pady=10)
main_inner.pack(fill="both", expand=True)
label_frame = tk.Frame(main_inner, bg=_CARD_BG)
label_frame.pack(fill="x", anchor="w")
tk.Label(label_frame, text="Transkript", font=("Segoe UI", 9, "bold"),
bg=_CARD_BG, fg=_TEXT).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=_CARD_BG, fg=_TEXT,
activebackground=_CARD_BG, selectcolor="#E8F4FA",
font=("Segoe UI", 8),
).pack(side="left", padx=(10, 4))
tk.Checkbutton(
label_frame, text="Allgemein", variable=self._transcribe_general_var,
command=_on_gen_toggle, bg=_CARD_BG, fg=_TEXT,
activebackground=_CARD_BG, selectcolor="#E8F4FA",
font=("Segoe UI", 8),
).pack(side="left")
diktat_font = ("Segoe UI", 8)
txt = ScrolledText(main_inner, wrap="word", font=diktat_font, bg="#FAFCFE",
height=8, relief="flat", bd=1,
highlightbackground=_CARD_BD, highlightthickness=1)
txt.pack(fill="both", expand=True, pady=(6, 6))
add_text_font_size_control(label_frame, txt, initial_size=8, bg_color=_CARD_BG, save_key="diktat_window")
self._bind_textblock_pending(txt)
status_var = tk.StringVar(value="Bereit.")
lbl_status = tk.Label(
main_inner, textvariable=status_var, fg=_TEXT_SUB, bg=_CARD_BG,
font=("Segoe UI", 8), anchor="w",
)
lbl_status.pack(fill="x", pady=(0, 4))
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())
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())
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)
btn_row.pack(fill="x", pady=(6, 0))
btn_row2 = tk.Frame(main_inner, bg=_CARD_BG)
btn_row2.pack(fill="x", pady=(4, 0))
diktat_recorder = [None]
is_recording = [False]
append_next_transcript = [False]
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]
if not is_recording[0]:
try:
rec.start()
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…")
_sync_mini_diktat_ui()
except Exception as e:
messagebox.showerror("Aufnahme-Fehler", str(e))
status_var.set("Bereit.")
else:
is_recording[0] = False
self._diktat_recording_active = False
# 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
btn_diktat_record.configure(text="Diktat starten", bg=_BTN_PRI, active_bg=_BTN_PRI_H)
lbl_status.configure(fg=_TEXT_SUB)
status_var.set("Aufnahme wird abgeschlossen …")
_sync_mini_diktat_ui()
def worker():
def _safe_after(fn):
try:
if self.winfo_exists():
self.after(0, fn)
except Exception:
pass
def _after_finalize():
self._diktat_neu_busy = False
try:
_sync_mini_diktat_ui()
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_postprocess_transcript(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)
finally:
# Busy-Flag immer zuruecksetzen (Stop garantiert beendet Finalizing).
_safe_after(_after_finalize)
def _done(text):
diktat_recorder[0] = None
self._diktat_recorder = None
self._diktat_recording_active = False
try:
if not win.winfo_exists():
return
txt.configure(state="normal")
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):
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.")
_sync_mini_diktat_ui()
except Exception:
pass
threading.Thread(target=worker, daemon=True).start()
win._aza_diktat_toggle = toggle_diktat
win._aza_diktat_is_recording = lambda: is_recording[0]
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:
btn_diktat_record.configure(text="Diktat starten", bg=_BTN_PRI, active_bg=_BTN_PRI_H)
lbl_status.configure(fg=_TEXT_SUB)
except Exception:
pass
status_var.set(busy_status)
_sync_mini_diktat_ui()
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.")
_sync_mini_diktat_ui()
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
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",
"Es läuft gerade eine Aufnahme.\n"
"Möchtest du die aktuelle Aufnahme wirklich verwerfen und neu starten?",
parent=win
):
return
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.")
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, "Diktat starten", command=toggle_diktat,
width=148, height=30, canvas_bg=_CARD_BG,
bg=_BTN_PRI, fg="#FFFFFF", active_bg=_BTN_PRI_H,
)
btn_diktat_record.pack(side="left")
RoundedButton(
btn_row, "Neu", command=do_neu,
width=72, height=30, canvas_bg=_CARD_BG,
bg=_BTN_SEC, fg=_BTN_PRI, active_bg="#E8F4FA",
).pack(side="left", padx=(8, 0))
RoundedButton(
btn_row2, "Kopieren", command=do_kopieren,
width=100, height=30, canvas_bg=_CARD_BG,
bg=_BTN_SEC, fg=_BTN_PRI, active_bg="#E8F4FA",
).pack(side="left")
RoundedButton(
btn_row2, "Leeren", command=do_clear_text,
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)

View File

@@ -0,0 +1,540 @@
# -*- coding: utf-8 -*-
"""AzA Mini-Diktat — gleicher Aufbau wie AzA Mini (Logo3, Weiterfahren, Status)."""
from __future__ import annotations
import os
import sys
import tkinter as tk
from typing import Any
from aza_mini_record_window import (
_BG_ACTIVE,
_BG_IDLE,
_BTN_BLUE,
_CHROME_BG,
_FF,
_MINI_WIN_H,
_MINI_WIN_MIN_H,
_MINI_WIN_MIN_W,
_MINI_WIN_W,
_bind_window_drag,
_place_mini_window,
)
_MAIN_LOGO_PX = 82
_MINI_LOGO_PX = int(_MAIN_LOGO_PX * 1.3)
_LOGO_CANDIDATES = ("Logo3.png", "logo3.png")
# Eigene Breite: Weiterfahren + Titel + sichtbares X (236px Mini-Aufnahme reicht nicht).
_MINI_DICTAT_WIN_W = 268
_MINI_DICTAT_MIN_W = 248
def _mini_win(app: Any) -> tk.Toplevel | None:
win = getattr(app, "_mini_diktat_win", None)
if win is None:
return None
try:
return win if win.winfo_exists() else None
except Exception:
return None
def is_mini_diktat_window_open(app: Any) -> bool:
return _mini_win(app) is not None
def _diktat_target(app: Any) -> tk.Misc | None:
win = getattr(app, "_mini_diktat_target_win", None)
if win is None:
return None
try:
return win if win.winfo_exists() else None
except Exception:
return None
def _app_base_dirs() -> list[str]:
dirs: list[str] = []
try:
if getattr(sys, "frozen", False):
dirs.append(getattr(sys, "_MEIPASS", "") or "")
except Exception:
pass
dirs.append(os.path.dirname(os.path.abspath(__file__)))
return [d for d in dirs if d]
def _resolve_asset_path(candidates: tuple[str, ...]) -> str | None:
for base in _app_base_dirs():
assets_dir = os.path.join(base, "assets")
if not os.path.isdir(assets_dir):
continue
try:
names = {n.lower(): n for n in os.listdir(assets_dir)}
except Exception:
names = {}
for cand in candidates:
key = cand.lower()
if key in names:
return os.path.join(assets_dir, names[key])
for cand in candidates:
path = os.path.join(assets_dir, cand)
if os.path.isfile(path):
return path
return None
def _load_logo_photo(path: str | None) -> Any:
if not path or not os.path.isfile(path):
return None
try:
from PIL import Image, ImageTk
img = Image.open(path).convert("RGBA")
img = img.resize((_MINI_LOGO_PX, _MINI_LOGO_PX), Image.Resampling.LANCZOS)
return ImageTk.PhotoImage(img)
except Exception:
return None
def _ensure_mini_logo(app: Any) -> Any | None:
photo = getattr(app, "_mini_diktat_photo", None)
if photo is not None:
return photo
logo_path = _resolve_asset_path(_LOGO_CANDIDATES)
photo = _load_logo_photo(logo_path)
app._mini_diktat_photo = photo
app._mini_diktat_logo_path = logo_path
return photo
def _is_diktat_recording(diktat_win: tk.Misc | None) -> bool:
if diktat_win is None:
return False
fn = getattr(diktat_win, "_aza_diktat_is_recording", None)
if callable(fn):
try:
return bool(fn())
except Exception:
pass
return False
def _diktat_status_text(diktat_win: tk.Misc | None) -> str:
if diktat_win is None:
return "Bereit"
status_var = getattr(diktat_win, "_aza_diktat_status_var", None)
if status_var is not None:
try:
return str(status_var.get() or "Bereit").strip() or "Bereit"
except Exception:
pass
return "Aufnahme läuft …" if _is_diktat_recording(diktat_win) else "Bereit"
def _apply_mini_bg(app: Any, recording: bool) -> None:
bg = _BG_ACTIVE if recording else _BG_IDLE
win = _mini_win(app)
if win is None:
return
try:
win.configure(bg=bg)
except Exception:
pass
for attr in ("_mini_diktat_chrome_hdr", "_mini_diktat_logo_bg", "_mini_diktat_status_lbl"):
w = getattr(app, attr, None)
if w is not None:
try:
w.configure(bg=bg if attr != "_mini_diktat_chrome_hdr" else _CHROME_BG)
except Exception:
pass
logo_lbl = getattr(app, "_mini_diktat_logo_lbl", None)
if logo_lbl is not None:
try:
logo_lbl.configure(bg=bg)
except Exception:
pass
def sync_mini_diktat_window(app: Any, *, diktat_win: tk.Misc | None = None) -> None:
"""Aktualisiert Mini-Diktat-Anzeige (Logo, Hintergrund, Status)."""
win = _mini_win(app)
if win is None:
return
target = diktat_win or _diktat_target(app)
try:
recording = _is_diktat_recording(target)
_apply_mini_bg(app, recording)
photo = _ensure_mini_logo(app)
logo_lbl = getattr(app, "_mini_diktat_logo_lbl", None)
if logo_lbl is not None and photo is not None:
try:
logo_lbl.configure(image=photo)
except Exception:
pass
status_var = getattr(app, "_mini_diktat_status_var", None)
if status_var is not None:
status_var.set(_diktat_status_text(target))
# Weiterfahren-Knopf spiegelt den echten Zustand: Stoppen waehrend Aufnahme,
# waehrend Finalisierung gesperrt, sonst Weiterfahren.
btn = getattr(app, "_mini_diktat_weiter_btn", None)
if btn is not None:
busy = bool(getattr(app, "_diktat_neu_busy", False))
try:
if busy:
btn.configure(text="Abschluss…", state="disabled")
elif recording:
btn.configure(text="Stoppen", state="normal")
else:
btn.configure(text="Weiterfahren", state="normal")
except Exception:
pass
except Exception:
pass
def _paste_into_diktat_text(diktat_win: tk.Misc) -> None:
txt = getattr(diktat_win, "_aza_diktat_text", None)
if txt is None:
return
try:
clip = str(diktat_win.clipboard_get() or "")
except tk.TclError:
return
if not str(clip).strip():
return
try:
txt.configure(state="normal")
try:
if txt.tag_ranges(tk.SEL):
txt.delete(tk.SEL_FIRST, tk.SEL_LAST)
except Exception:
pass
try:
idx = txt.index(tk.INSERT)
except Exception:
idx = tk.END
txt.insert(idx, clip)
except Exception:
try:
txt.configure(state="normal")
txt.insert(tk.END, clip)
except Exception:
pass
def _bind_mini_diktat_context_paste(app: Any, mini_win: tk.Toplevel, diktat_win: tk.Misc, *widgets: tk.Misc) -> None:
menu = tk.Menu(mini_win, tearoff=0)
def _do_paste() -> None:
_paste_into_diktat_text(diktat_win)
sync_mini_diktat_window(app, diktat_win=diktat_win)
menu.add_command(label="Einfügen", command=_do_paste)
def _popup(evt) -> None:
try:
menu.tk_popup(evt.x_root, evt.y_root)
finally:
try:
menu.grab_release()
except Exception:
pass
for w in widgets:
if w is not None:
w.bind("<Button-3>", _popup)
def _raise_mini_window_topmost(win: tk.Toplevel) -> None:
try:
win.deiconify()
win.attributes("-topmost", True)
win.lift()
win.update_idletasks()
win.focus_force()
except Exception:
pass
def _clear_mini_window_topmost(win: tk.Toplevel) -> None:
try:
if win.winfo_exists():
win.attributes("-topmost", False)
except Exception:
pass
def _save_diktat_restore_state(diktat_win: tk.Misc) -> None:
try:
diktat_win.update_idletasks()
diktat_win._mini_restore_geometry = diktat_win.geometry()
except Exception:
diktat_win._mini_restore_geometry = getattr(diktat_win, "_mini_restore_geometry", "") or ""
def _restore_diktat_window(
app: Any,
cursor_x: int | None = None,
cursor_y: int | None = None,
) -> None:
diktat_win = _diktat_target(app)
if diktat_win is None:
return
try:
if cursor_x is not None and cursor_y is not None:
from aza_ui_helpers import restore_tool_window_at_cursor
restore_tool_window_at_cursor(
diktat_win,
int(cursor_x),
int(cursor_y),
anchor_widget=getattr(diktat_win, "_aza_mini_diktat_btn", None),
)
return
except Exception:
pass
try:
diktat_win.deiconify()
if bool(getattr(diktat_win, "_tool_pinned", False)):
from aza_ui_helpers import apply_tool_window_pin
apply_tool_window_pin(diktat_win, True)
diktat_win.lift()
diktat_win.focus_force()
except Exception:
try:
diktat_win.deiconify()
except Exception:
pass
def close_mini_diktat_window(
app: Any,
*,
restore_diktat: bool = True,
cursor_x: int | None = None,
cursor_y: int | None = None,
) -> None:
"""Schliesst Mini-Diktat und stellt das normale Diktatfenster wieder her."""
win = _mini_win(app)
target = _diktat_target(app)
if target is not None and _is_diktat_recording(target):
toggle_fn = getattr(target, "_aza_diktat_toggle", None)
if callable(toggle_fn):
try:
toggle_fn()
except Exception:
pass
if win is None:
if restore_diktat:
_restore_diktat_window(app, cursor_x, cursor_y)
return
if restore_diktat:
cx, cy = cursor_x, cursor_y
if cx is None or cy is None:
try:
cx = int(win.winfo_pointerx())
cy = int(win.winfo_pointery())
except Exception:
cx = cy = None
_restore_diktat_window(app, cx, cy)
for attr in (
"_mini_diktat_win", "_mini_diktat_logo_lbl", "_mini_diktat_logo_bg",
"_mini_diktat_chrome_hdr", "_mini_diktat_status_lbl", "_mini_diktat_photo",
"_mini_diktat_status_var", "_mini_diktat_target_win",
):
try:
setattr(app, attr, None)
except Exception:
pass
try:
_clear_mini_window_topmost(win)
win.destroy()
except Exception:
pass
def open_mini_diktat_window(
app: Any,
diktat_win: tk.Misc,
*,
anchor_x: int | None = None,
anchor_y: int | None = None,
) -> None:
"""Öffnet Mini-Diktat (Singleton) und verbirgt das normale Diktatfenster."""
if diktat_win is None:
return
try:
if not diktat_win.winfo_exists():
return
except Exception:
return
restore_fn = getattr(diktat_win, "_aza_restore_diktat_content", None)
is_min = getattr(diktat_win, "_aza_is_minimized", None)
if callable(is_min) and is_min() and callable(restore_fn):
try:
restore_fn()
except Exception:
pass
app._mini_diktat_target_win = diktat_win
existing = _mini_win(app)
if existing is not None:
if anchor_x is not None and anchor_y is not None:
try:
sh = existing.winfo_screenheight()
h = min(_MINI_WIN_H, max(_MINI_WIN_MIN_H, sh - 80))
except Exception:
h = _MINI_WIN_H
_place_mini_window(existing, _MINI_DICTAT_WIN_W, h, anchor_x=anchor_x, anchor_y=anchor_y)
_raise_mini_window_topmost(existing)
try:
app.after(80, lambda w=existing: _raise_mini_window_topmost(w))
except Exception:
pass
sync_mini_diktat_window(app, diktat_win=diktat_win)
return
_save_diktat_restore_state(diktat_win)
try:
diktat_win.withdraw()
except Exception:
pass
win = tk.Toplevel(app)
app._mini_diktat_win = win
win.title("Mini-Diktat")
win.configure(bg=_BG_IDLE)
win.resizable(False, False)
try:
win.overrideredirect(True)
except Exception:
pass
try:
sh = win.winfo_screenheight()
h = min(_MINI_WIN_H, max(_MINI_WIN_MIN_H, sh - 80))
except Exception:
h = _MINI_WIN_H
win.minsize(_MINI_DICTAT_MIN_W, _MINI_WIN_MIN_H)
_place_mini_window(win, _MINI_DICTAT_WIN_W, h, anchor_x=anchor_x, anchor_y=anchor_y)
_raise_mini_window_topmost(win)
try:
app.after(80, lambda w=win: _raise_mini_window_topmost(w))
except Exception:
pass
if hasattr(app, "_register_window"):
try:
app._register_window(win)
except Exception:
pass
chrome_hdr = tk.Frame(
win, bg=_CHROME_BG, highlightbackground="#C8DCE8", highlightthickness=1,
)
chrome_hdr.pack(side="top", fill="x")
app._mini_diktat_chrome_hdr = chrome_hdr
_bind_window_drag(chrome_hdr, win)
close_btn = tk.Button(
chrome_hdr, text="X", command=lambda: close_mini_diktat_window(app),
font=(_FF, 9, "bold"), width=2, bg=_CHROME_BG, fg="#8B3A3A",
activebackground="#E8D0D0", activeforeground="#6B1A1A",
relief="flat", bd=0, cursor="hand2", padx=2, pady=0,
)
close_btn.pack(side="right", padx=(2, 6), pady=3)
app._mini_diktat_close_btn = close_btn
try:
from aza_ui_helpers import ToolTip
ToolTip(close_btn, "Mini-Diktat schliessen")
except Exception:
pass
def _on_weiterfahren():
# Waehrend Finalisierung gesperrt (kein Doppelklick/zweiter Stop).
if getattr(app, "_diktat_neu_busy", False):
return
if _is_diktat_recording(diktat_win):
# Aktiver Knopf wirkt als STOPPEN -> bestehender sicherer Stop/Finalize.
stop_fn = getattr(diktat_win, "_aza_diktat_toggle", None)
if callable(stop_fn):
stop_fn()
else:
fn = getattr(diktat_win, "_aza_diktat_weiterfahren", None) or getattr(
diktat_win, "_aza_diktat_korrigieren", None
)
if callable(fn):
fn()
sync_mini_diktat_window(app, diktat_win=diktat_win)
btn_weiter = tk.Button(
chrome_hdr, text="Weiterfahren", command=_on_weiterfahren,
font=(_FF, 8, "bold"), bg=_BTN_BLUE, fg="#FFFFFF",
relief="flat", bd=0, padx=8, pady=3, cursor="hand2",
)
btn_weiter.pack(side="left", padx=(8, 4), pady=5)
app._mini_diktat_weiter_btn = btn_weiter
try:
from aza_ui_helpers import ToolTip
ToolTip(btn_weiter, "Aufnahme anhängen / während Aufnahme: Stoppen")
except Exception:
pass
drag_hint = tk.Label(
chrome_hdr, text="Mini-Diktat", font=(_FF, 8),
bg=_CHROME_BG, fg="#5A7A8F", cursor="fleur",
)
drag_hint.pack(side="left", expand=True, fill="x")
_bind_window_drag(drag_hint, win)
logo_bg = tk.Frame(win, bg=_BG_IDLE)
logo_bg.pack(side="top", fill="both", expand=True, padx=10, pady=6)
app._mini_diktat_logo_bg = logo_bg
idle_photo = _ensure_mini_logo(app)
def _on_logo_click(_evt=None):
fn = getattr(diktat_win, "_aza_diktat_neu_von_logo", None)
if not callable(fn):
fn = getattr(diktat_win, "_aza_diktat_toggle", None)
if callable(fn):
fn()
sync_mini_diktat_window(app, diktat_win=diktat_win)
if idle_photo is not None:
logo_lbl = tk.Label(logo_bg, image=idle_photo, bg=_BG_IDLE, cursor="hand2")
logo_lbl.pack(expand=True)
logo_lbl.bind("<Button-1>", _on_logo_click)
app._mini_diktat_logo_lbl = logo_lbl
try:
from aza_ui_helpers import ToolTip
ToolTip(logo_lbl, "Neues Diktat starten")
except Exception:
pass
else:
logo_lbl = tk.Label(
logo_bg, text="Diktat", font=(_FF, 22, "bold"),
bg=_BG_IDLE, fg="#2E6F8F", cursor="hand2",
)
logo_lbl.pack(expand=True)
logo_lbl.bind("<Button-1>", _on_logo_click)
app._mini_diktat_logo_lbl = logo_lbl
status_var = tk.StringVar(value=_diktat_status_text(diktat_win))
app._mini_diktat_status_var = status_var
status_lbl = tk.Label(
win, textvariable=status_var, font=(_FF, 8),
bg=_BG_IDLE, fg="#4A6070", pady=4,
)
status_lbl.pack(side="bottom", fill="x")
app._mini_diktat_status_lbl = status_lbl
_bind_mini_diktat_context_paste(app, win, diktat_win, logo_bg, status_lbl, win)
sync_mini_diktat_window(app, diktat_win=diktat_win)

View File

@@ -0,0 +1,54 @@
# Konsistenter lokaler Office-Release-Candidate v8 (kein Installer, kein Release)
# Ein gemeinsamer Build-Stamp fuer alle drei EXEs. Keine EXEs aus V7 mischen.
$ErrorActionPreference = "Stop"
$projectRoot = $PSScriptRoot
Set-Location $projectRoot
$outDir = Join-Path $projectRoot "dist\test_final_release_candidate_v8"
Write-Host "Konsistenter Office-Release-Candidate v8 -> $outDir"
Write-Host "Build-Stamp (einmal fuer alle drei EXEs)..."
python (Join-Path $projectRoot "aza_build_stamp.py")
if ($LASTEXITCODE -ne 0) {
Write-Error "Build-Stamp fehlgeschlagen"
exit 1
}
$specs = @(
@{ Name = "aza_desktop"; Spec = "aza_desktop.spec"; OutFolder = "dist\aza_desktop" },
@{ Name = "AZA_EmpfangShell"; Spec = "AZA_EmpfangShell.spec"; OutFile = "dist\AZA_EmpfangShell.exe" },
@{ Name = "AZA_KontaktPanel"; Spec = "AZA_KontaktPanel.spec"; OutFile = "dist\AZA_KontaktPanel.exe" }
)
foreach ($s in $specs) {
Write-Host "PyInstaller: $($s.Name)..."
& pyinstaller --noconfirm (Join-Path $projectRoot $s.Spec)
if ($LASTEXITCODE -ne 0) {
Write-Error "Build fehlgeschlagen: $($s.Spec)"
exit 1
}
}
$desktopBuilt = Join-Path $projectRoot "dist\aza_desktop"
$desktopExe = Join-Path $desktopBuilt "aza_desktop.exe"
$shellExe = Join-Path $projectRoot "dist\AZA_EmpfangShell.exe"
$panelExe = Join-Path $projectRoot "dist\AZA_KontaktPanel.exe"
foreach ($p in @($desktopExe, $shellExe, $panelExe)) {
if (-not (Test-Path $p)) {
Write-Error "EXE fehlt nach Build: $p"
exit 1
}
}
if (Test-Path $outDir) {
Remove-Item $outDir -Recurse -Force
}
Copy-Item $desktopBuilt $outDir -Recurse -Force
Copy-Item $shellExe (Join-Path $outDir "AZA_EmpfangShell.exe") -Force
Copy-Item $panelExe (Join-Path $outDir "AZA_KontaktPanel.exe") -Force
Write-Host ""
Write-Host "Testbuild fertig: $outDir"
Write-Host "Start: .\start_doku_prompt_test.ps1"

View File

@@ -0,0 +1,212 @@
# Lokaler Dev-Start: Konsistenter Office-Release-Candidate v5 (Stable 1.3.12 unveraendert)
# Startet zuerst den lokalen HTML-Proxy als persistenten Prozess, wartet auf HTTP 200, dann Desktop.
$ErrorActionPreference = "Stop"
function Write-TestLog {
param([string]$Message)
Write-Host ("[{0}] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"), $Message)
}
$env:AZA_DOKU_PROMPT_TEST = "1"
$ProjectRoot = $PSScriptRoot
Set-Location $ProjectRoot
Write-Host "Starte AzA Testbuild test_final_release_candidate_v8 (Stable 1.3.12 unveraendert)..."
$testExe = Join-Path $ProjectRoot "dist\test_final_release_candidate_v8\aza_desktop.exe"
if (-not (Test-Path $testExe)) {
Write-Error "Testbuild nicht gefunden: $testExe`nBitte zuerst build_test_final_release_candidate_v8.ps1 ausfuehren."
exit 1
}
$shellExe = Join-Path (Split-Path $testExe) "AZA_EmpfangShell.exe"
$panelExe = Join-Path (Split-Path $testExe) "AZA_KontaktPanel.exe"
if (-not (Test-Path $shellExe)) {
Write-Warning "AZA_EmpfangShell.exe fehlt neben dem Testbuild: $shellExe"
}
if (-not (Test-Path $panelExe)) {
Write-Warning "AZA_KontaktPanel.exe fehlt neben dem Testbuild: $panelExe"
}
# Upstream-API (backend_url.txt)
$upstream = "https://api.aza-medwork.ch"
$backendFile = Join-Path $ProjectRoot "backend_url.txt"
if (Test-Path $backendFile) {
$line = Get-Content $backendFile -ErrorAction SilentlyContinue | Where-Object { $_ -and -not $_.StartsWith("#") } | Select-Object -First 1
if ($line) { $upstream = $line.Trim().TrimEnd("/") }
}
$env:AZA_EMPFANG_TEST_UPSTREAM = $upstream
# Python + Proxy-Skript (absolute Pfade)
$pythonExe = (Get-Command python -ErrorAction Stop).Source
$proxyScript = Join-Path $ProjectRoot "aza_empfang_test_html_proxy.py"
if (-not (Test-Path $proxyScript)) {
Write-Error "Proxy-Skript fehlt: $proxyScript"
exit 1
}
$logDir = Join-Path $ProjectRoot ".aza_test_proxy_logs"
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
$ts = Get-Date -Format "yyyyMMdd_HHmmss"
$stdoutLog = Join-Path $logDir "proxy_stdout_$ts.log"
$stderrLog = Join-Path $logDir "proxy_stderr_$ts.log"
# Veraltete Test-Proxy-Prozesse beenden: sonst bedient ein frueher gestarteter
# Proxy (mit altem Code im Speicher) denselben Port und bricht SSO/Redirect.
try {
$staleProxies = Get-CimInstance Win32_Process -Filter "Name='python.exe'" -ErrorAction SilentlyContinue |
Where-Object { $_.CommandLine -and ($_.CommandLine -match 'aza_empfang_test_html_proxy\.py') }
foreach ($sp in $staleProxies) {
Write-Host ("Beende veralteten Test-Proxy PID {0}" -f $sp.ProcessId)
Stop-Process -Id $sp.ProcessId -Force -ErrorAction SilentlyContinue
}
if ($staleProxies) { Start-Sleep -Milliseconds 500 }
} catch {
Write-Warning ("Stale-Proxy-Cleanup uebersprungen: {0}" -f $_.Exception.Message)
}
Write-Host "Starte Test-HTML-Proxy (persistenter Prozess)..."
Write-Host " Python: $pythonExe"
Write-Host " Skript: $proxyScript"
Write-Host " Upstream: $upstream"
Write-Host " Log stdout: $stdoutLog"
Write-Host " Log stderr: $stderrLog"
$proxyProc = Start-Process `
-FilePath $pythonExe `
-ArgumentList @("aza_empfang_test_html_proxy.py", "--serve") `
-WorkingDirectory $ProjectRoot `
-PassThru `
-WindowStyle Hidden `
-RedirectStandardOutput $stdoutLog `
-RedirectStandardError $stderrLog
$proxyPort = $null
$deadline = (Get-Date).AddSeconds(15)
while ((Get-Date) -lt $deadline) {
if ($proxyProc.HasExited) {
$errTail = ""
if (Test-Path $stderrLog) {
$errTail = (Get-Content $stderrLog -Raw -ErrorAction SilentlyContinue)
}
$outTail = ""
if (Test-Path $stdoutLog) {
$outTail = (Get-Content $stdoutLog -Raw -ErrorAction SilentlyContinue)
}
Write-Error @"
Test-HTML-Proxy-Prozess beendet (Exit $($proxyProc.ExitCode)) bevor READY.
stdout: $stdoutLog
stderr: $stderrLog
--- stdout ---
$outTail
--- stderr ---
$errTail
"@
exit 1
}
if (Test-Path $stdoutLog) {
$out = Get-Content $stdoutLog -Raw -ErrorAction SilentlyContinue
if ($out -match 'READY port=(\d+)') {
$proxyPort = [int]$Matches[1]
break
}
}
Start-Sleep -Milliseconds 300
}
if (-not $proxyPort) {
if (-not $proxyProc.HasExited) {
Stop-Process -Id $proxyProc.Id -Force -ErrorAction SilentlyContinue
}
Write-Error "Test-HTML-Proxy: READY-Timeout nach 15s. Logs: $stdoutLog / $stderrLog"
exit 1
}
$proxyBase = "http://127.0.0.1:$proxyPort"
$healthUrl = "$proxyBase/health"
$empfangUrl = "$proxyBase/empfang/"
Write-TestLog ("Proxy READY auf Port {0} (PID {1})" -f $proxyPort, $proxyProc.Id)
# HTTP-Healthcheck (zusaetzlich zur READY-Zeile)
try {
$healthResp = Invoke-WebRequest -Uri $healthUrl -UseBasicParsing -TimeoutSec 5
if ($healthResp.StatusCode -ne 200) {
throw "Health Status $($healthResp.StatusCode)"
}
Write-Host ("Healthcheck OK: {0}" -f $healthUrl)
} catch {
Stop-Process -Id $proxyProc.Id -Force -ErrorAction SilentlyContinue
Write-Error ("Healthcheck fehlgeschlagen: {0} - {1}`nstderr: {2}" -f $healthUrl, $_.Exception.Message, $stderrLog)
exit 1
}
try {
$htmlResp = Invoke-WebRequest -Uri $empfangUrl -UseBasicParsing -TimeoutSec 8
if ($htmlResp.StatusCode -ne 200) {
throw "Empfang HTML Status $($htmlResp.StatusCode)"
}
if ($htmlResp.Headers["Cache-Control"] -notmatch "no-store") {
Write-Warning "Empfang HTML ohne Cache-Control no-store (weiterhin OK)"
}
Write-Host ("Empfang HTML OK: {0} ({1} Bytes)" -f $empfangUrl, $htmlResp.RawContentLength)
} catch {
Stop-Process -Id $proxyProc.Id -Force -ErrorAction SilentlyContinue
Write-Error ("Empfang-HTML-Check fehlgeschlagen: {0} - {1}`nstderr: {2}" -f $empfangUrl, $_.Exception.Message, $stderrLog)
exit 1
}
$env:AZA_EMPFANG_TEST_PROXY_PORT = [string]$proxyPort
$env:AZA_EMPFANG_WEB_BASE = $proxyBase
$env:AZA_EMPFANG_CHAT_SHELL_URL = "$proxyBase/empfang/"
Write-Host "Umgebung gesetzt:"
Write-Host " AZA_EMPFANG_WEB_BASE=$($env:AZA_EMPFANG_WEB_BASE)"
Write-Host " AZA_EMPFANG_CHAT_SHELL_URL=$($env:AZA_EMPFANG_CHAT_SHELL_URL)"
Write-TestLog "Starte Desktop-Testbuild..."
try {
$desktopProc = Start-Process `
-FilePath $testExe `
-WorkingDirectory (Split-Path $testExe) `
-PassThru
Write-TestLog ("Desktop-Prozess gestartet (PID {0})" -f $desktopProc.Id)
Start-Sleep -Seconds 2
if ($proxyProc.HasExited) {
Write-Error ("Proxy beendet unerwartet nach Desktopstart. stderr: {0}" -f $stderrLog)
exit 1
}
$postHealth = Invoke-WebRequest -Uri $healthUrl -UseBasicParsing -TimeoutSec 5
if ($postHealth.StatusCode -ne 200) {
throw ("Post-Desktop Health Status {0}" -f $postHealth.StatusCode)
}
Write-TestLog ("Proxy nach Desktopstart erreichbar: health {0}" -f $postHealth.StatusCode)
$postHtml = Invoke-WebRequest -Uri $empfangUrl -UseBasicParsing -TimeoutSec 8
if ($postHtml.StatusCode -ne 200) {
throw ("Post-Desktop Empfang Status {0}" -f $postHtml.StatusCode)
}
Write-TestLog ("Proxy Empfang nach Desktopstart: {0} ({1} Bytes)" -f $postHtml.StatusCode, $postHtml.RawContentLength)
Write-Host ""
Write-Host "V5-Test laeuft."
Write-Host "Proxy bleibt aktiv."
Write-Host "Nach Abschluss aller AzA-Testfenster hier ENTER druecken, um den Proxy zu beenden."
Write-Host ""
Read-Host | Out-Null
} finally {
Write-TestLog ("Beende Test-HTML-Proxy (PID {0})..." -f $proxyProc.Id)
if (-not $proxyProc.HasExited) {
Stop-Process -Id $proxyProc.Id -Force -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 200
}
if (-not $proxyProc.HasExited) {
Write-Warning ("Proxy-Prozess {0} laeuft noch - bitte manuell pruefen." -f $proxyProc.Id)
} else {
Write-Host "Proxy beendet."
}
}