From 8b52f4c92b96e93418a9ca4d815097865ac4dc70 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Fri, 24 Apr 2026 18:42:33 +0200 Subject: [PATCH] fix(f5tts): Referenz-WAV auf 10s clippen + txt neu transkribieren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F5-TTS hat ein Hard-Limit von 12s fuer das Referenz-Audio — laengere WAVs werden intern abgeschnitten, aber unser ref_text war das komplette Transkript. Text und Audio wurden dadurch unaligned, Render-Qualitaet leidet und der initial Warmup-Render dauerte 57s statt 5s. Fix: - normalize_ref_wav(max_seconds=10): ffmpeg schneidet auf 10s + 24kHz mono, gibt was_modified zurueck damit Caller den txt invalidieren kann - handle_voice_upload: clippt VOR der Transkription, Whisper sieht also nur die 10s → txt passt garantiert zum Audio - _do_tts: checkt vor jedem Render die WAV-Dauer. WAVs > 10.5s werden geclippt, .txt geloescht → on-the-fly Neu-Transkription beim Render Bestehende kaputte Voices (wie MAIA mit 600+ Worten txt zu einem 20s Audio) werden beim naechsten Render automatisch gefixt. Co-Authored-By: Claude Opus 4.7 (1M context) --- xtts/f5tts/bridge.py | 58 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/xtts/f5tts/bridge.py b/xtts/f5tts/bridge.py index 1f71a3e..f1e9603 100644 --- a/xtts/f5tts/bridge.py +++ b/xtts/f5tts/bridge.py @@ -73,6 +73,12 @@ VOICES_DIR = Path(os.getenv("VOICES_DIR", "/voices")) PCM_CHUNK_BYTES = 8192 # ~170ms @ 24kHz mono s16 TARGET_SR = 24000 # F5-TTS native +# F5-TTS hat ein 12s Hard-Limit fuer Referenz-Audio. Laengere WAVs werden +# vom Modell stumm abgeschnitten — aber unser ref_text bleibt lang und passt +# dann nicht mehr zum gekuerzten Audio (Quali leidet, warmup-Render ist +# unnoetig lange). Wir clippen explizit auf 10s + re-transkribieren den Text +# damit beide synchron bleiben. +REF_MAX_SECONDS = 10.0 # Wird in einer Uebergangsphase als "ungueltige Referenz" erkannt (alte voices, # die hochgeladen wurden bevor die whisper-bridge online war). Bei Erkennung @@ -248,32 +254,42 @@ def voice_paths(name: str) -> tuple[Path, Path]: return VOICES_DIR / f"{safe}.wav", VOICES_DIR / f"{safe}.txt" -def ensure_24k_mono_wav(src_wav: Path) -> Path: - """F5-TTS moechte 24kHz mono als Referenz — ffmpeg konvertiert inplace. +def normalize_ref_wav(src_wav: Path, max_seconds: float = REF_MAX_SECONDS) -> tuple[Path, bool]: + """Bringt die Referenz-WAV in F5-TTS-freundliche Form: + 24kHz mono + max max_seconds Dauer. Original wird ueberschrieben wenn + Aenderungen noetig waren. - Wenn das File schon passt, wird nichts geaendert. Sonst wird es - reingeschrieben (Original wird ueberschrieben). + Returns: + (path, was_modified) — was_modified=True wenn die Datei wirklich + geaendert wurde (Caller sollte dann den passenden .txt invalidieren). """ try: info = sf.info(str(src_wav)) - if info.samplerate == TARGET_SR and info.channels == 1: - return src_wav + # Schon gut? Sample-Rate, Kanaele und Dauer passen? + if (info.samplerate == TARGET_SR and info.channels == 1 + and info.duration <= max_seconds + 0.1): + return src_wav, False except Exception: - pass + info = None + tmp_out = src_wav.with_suffix(".conv.wav") cmd = ["ffmpeg", "-y", "-i", str(src_wav), - "-ar", str(TARGET_SR), "-ac", "1", "-f", "wav", str(tmp_out)] + "-ar", str(TARGET_SR), "-ac", "1", + "-t", str(max_seconds), + "-f", "wav", str(tmp_out)] r = subprocess.run(cmd, capture_output=True, timeout=30) if r.returncode != 0: - logger.warning("ffmpeg-Konvertierung von %s fehlgeschlagen: %s", + logger.warning("ffmpeg-Normalisierung von %s fehlgeschlagen: %s", src_wav, r.stderr.decode(errors="replace")[:200]) try: tmp_out.unlink() except OSError: pass - return src_wav + return src_wav, False os.replace(tmp_out, src_wav) - return src_wav + logger.info("Referenz-WAV normalisiert: %s (24kHz mono, max %.1fs)", + src_wav.name, max_seconds) + return src_wav, True async def _send(ws, mtype: str, payload: dict) -> None: @@ -349,6 +365,21 @@ async def _do_tts(ws, runner: F5Runner, text: str, voice: str, t0 = time.time() ref_wav_path, ref_txt_path = voice_paths(voice) if voice else (None, None) + # WAV zu lang? F5-TTS limitiert intern auf 12s, dann passt der txt nicht + # mehr zum Audio. Wir clippen explizit auf 10s und invalidieren den txt, + # damit er on-the-fly passend zum gekuerzten Audio neu transkribiert wird. + if voice and ref_wav_path and ref_wav_path.exists(): + try: + info = sf.info(str(ref_wav_path)) + if info.duration > REF_MAX_SECONDS + 0.5: + logger.info("Voice '%s' WAV ist %.1fs (>%.0fs) → clippen + txt neu", + voice, info.duration, REF_MAX_SECONDS) + _, modified = normalize_ref_wav(ref_wav_path) + if modified and ref_txt_path and ref_txt_path.exists(): + ref_txt_path.unlink() + except Exception as e: + logger.warning("Konnte WAV-Dauer nicht pruefen: %s", e) + # Legacy-Platzhalter erkennen → behandeln als "kein txt" und neu transkribieren if voice and ref_txt_path and ref_txt_path.exists(): try: @@ -491,8 +522,9 @@ async def handle_voice_upload(ws, payload: dict) -> None: size_kb = wav_path.stat().st_size / 1024 logger.info("Voice WAV gespeichert: %s (%.0fKB)", wav_path, size_kb) - # Auf 24kHz mono normalisieren (falls App in anderem Format liefert) - ensure_24k_mono_wav(wav_path) + # Auf 24kHz mono clippen auf 10s (F5-TTS Hard-Limit ist 12s, + # kuerzer = schnellerer Warmup + Text+Audio bleiben aligned) + normalize_ref_wav(wav_path) # Transkription ueber whisper-bridge anfragen logger.info("Transkribiere '%s' via whisper-bridge...", name)