update
This commit is contained in:
@@ -0,0 +1 @@
|
||||
Backup 20260613_182043
|
||||
@@ -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.",
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user