From 3749d60aa42282f825a1482820d519d014ad37be Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:25:22 +0200 Subject: [PATCH] fix: Watcher griff Aufnahmen mitten im Schreiben (Race Condition) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Fehler "[Errno 2] No such file or directory: '...stt_out_*.json'" sah wie ein Transkriptions-Bug aus, war aber ein Timing-Problem: _fertig() prüfte nur, ob die Dateigröße 2 Sekunden (RUHE_S) unveraendert blieb - eine normale Sprechpause reicht dafuer schon aus, waehrend FreeSWITCH den Anruf noch aktiv aufnimmt. Belegt im Verlauf-Log: die Aufnahme wurde um 10:15:56 als "fertig" gegriffen, der Anrufer legte laut Log aber erst um 10:16:01 auf. ffmpeg/whisper-cli lasen dabei einen mitten im Schreiben befindlichen RIFF-Header, produzierten keine Ausgabe, und process_call brach mit einem FileNotFoundError auf die erwartete JSON-Datei ab. Die Original-Audiodatei selbst blieb dabei unbeschaedigt (FreeSWITCH schrieb einfach in die schon nach telefon/fehler/ verschobene Datei weiter) - deshalb sah eine manuelle Nachpruefung der "kaputten" Datei immer intakt aus und liess sich nicht reproduzieren. _fertig() prueft jetzt zusaetzlich per `lsof -t`, ob noch ein Prozess die Datei offen haelt - nur wenn weder die Groesse waechst noch ein Handle offen ist, gilt die Aufnahme als abgeschlossen. Nebenbei: tempfile.mkstemp() in transcribe.py gab einen offenen File- Descriptor zurueck, der nie geschlossen wurde (fd-Leak in beiden Tempdatei-Erzeugungen) - behoben. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PuP3zfgYqhnrwyEne2ejG7 --- pipe/transcribe.py | 9 +++++++-- pipe/watch.py | 24 ++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/pipe/transcribe.py b/pipe/transcribe.py index a40b0a5..a168406 100644 --- a/pipe/transcribe.py +++ b/pipe/transcribe.py @@ -11,6 +11,7 @@ Beliebige Eingabeformate/Abtastraten werden vor der Erkennung per ffmpeg auf from __future__ import annotations import json +import os import subprocess import tempfile from dataclasses import dataclass, field @@ -37,7 +38,9 @@ def _letzte_zeile(ausgabe: str | None) -> str: def _nach_16k_mono(audio: Path) -> Path: """Normalisiert beliebiges Audio auf 16 kHz mono WAV (Tempdatei).""" - ziel = Path(tempfile.mkstemp(suffix=".wav", prefix="stt_")[1]) + fd, pfad = tempfile.mkstemp(suffix=".wav", prefix="stt_") + os.close(fd) + ziel = Path(pfad) try: subprocess.run( ["ffmpeg", "-y", "-i", str(audio), "-ar", "16000", "-ac", "1", str(ziel)], @@ -69,7 +72,9 @@ def transkribiere(audio: str | Path) -> Transkript: def _mit_whispercpp(audio: Path) -> Transkript: wav = _nach_16k_mono(audio) - ausgabe_basis = Path(tempfile.mkstemp(suffix="", prefix="stt_out_")[1]) + fd, pfad = tempfile.mkstemp(suffix="", prefix="stt_out_") + os.close(fd) + ausgabe_basis = Path(pfad) try: cmd = [ config.WHISPERCPP_BIN, diff --git a/pipe/watch.py b/pipe/watch.py index 2f67e27..95151c2 100644 --- a/pipe/watch.py +++ b/pipe/watch.py @@ -19,6 +19,7 @@ from __future__ import annotations import argparse import re import shutil +import subprocess import sys import time from datetime import datetime @@ -53,8 +54,25 @@ def _nummer_und_zeit(datei: Path) -> tuple[str | None, datetime | None]: return nummer, zeit +def _noch_offen(datei: Path) -> bool: + """Wahr, wenn ein Prozess (FreeSWITCH) die Datei noch zum Schreiben offen + haelt. Reine Groessenstabilitaet reicht nicht: eine Sprechpause laesst die + Datei fuer RUHE_S Sekunden still stehen, obwohl der Anruf noch laeuft - der + Watcher hat dann eine Aufnahme mitten im Schreiben gegriffen, ffmpeg/ + whisper-cli lasen einen unfertigen RIFF-Header und scheiterten wortlos + (kein Absturz, nur keine Ausgabedatei - sah wie ein Transkriptions-Bug + aus, war aber ein Race gegen die laufende Aufnahme).""" + try: + r = subprocess.run(["lsof", "-t", str(datei)], + capture_output=True, text=True, timeout=5) + return bool(r.stdout.strip()) + except Exception: + return False # lsof fehlt/schlaegt fehl: nicht blockieren + + def _fertig(datei: Path) -> bool: - """Wahr, wenn die Datei nicht mehr wächst - die Aufnahme also steht.""" + """Wahr, wenn die Datei nicht mehr waechst UND kein Prozess sie noch offen + haelt - die Aufnahme also wirklich abgeschlossen ist.""" try: groesse = datei.stat().st_size except FileNotFoundError: @@ -63,9 +81,11 @@ def _fertig(datei: Path) -> bool: return False time.sleep(RUHE_S) try: - return datei.stat().st_size == groesse + if datei.stat().st_size != groesse: + return False except FileNotFoundError: return False + return not _noch_offen(datei) def _neue_dateien(eingang: Path):