fix: Watcher griff Aufnahmen mitten im Schreiben (Race Condition)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PuP3zfgYqhnrwyEne2ejG7
This commit is contained in:
Jeuner 2026-08-23 10:25:22 +02:00
parent 878e49d372
commit 3749d60aa4
2 changed files with 29 additions and 4 deletions

View file

@ -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,

View file

@ -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):