fix(f5tts): Referenz-WAV auf 10s clippen + txt neu transkribieren
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) <noreply@anthropic.com>
This commit is contained in:
parent
dc20570f6d
commit
8b52f4c92b
|
|
@ -73,6 +73,12 @@ VOICES_DIR = Path(os.getenv("VOICES_DIR", "/voices"))
|
||||||
|
|
||||||
PCM_CHUNK_BYTES = 8192 # ~170ms @ 24kHz mono s16
|
PCM_CHUNK_BYTES = 8192 # ~170ms @ 24kHz mono s16
|
||||||
TARGET_SR = 24000 # F5-TTS native
|
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,
|
# Wird in einer Uebergangsphase als "ungueltige Referenz" erkannt (alte voices,
|
||||||
# die hochgeladen wurden bevor die whisper-bridge online war). Bei Erkennung
|
# 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"
|
return VOICES_DIR / f"{safe}.wav", VOICES_DIR / f"{safe}.txt"
|
||||||
|
|
||||||
|
|
||||||
def ensure_24k_mono_wav(src_wav: Path) -> Path:
|
def normalize_ref_wav(src_wav: Path, max_seconds: float = REF_MAX_SECONDS) -> tuple[Path, bool]:
|
||||||
"""F5-TTS moechte 24kHz mono als Referenz — ffmpeg konvertiert inplace.
|
"""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
|
Returns:
|
||||||
reingeschrieben (Original wird ueberschrieben).
|
(path, was_modified) — was_modified=True wenn die Datei wirklich
|
||||||
|
geaendert wurde (Caller sollte dann den passenden .txt invalidieren).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
info = sf.info(str(src_wav))
|
info = sf.info(str(src_wav))
|
||||||
if info.samplerate == TARGET_SR and info.channels == 1:
|
# Schon gut? Sample-Rate, Kanaele und Dauer passen?
|
||||||
return src_wav
|
if (info.samplerate == TARGET_SR and info.channels == 1
|
||||||
|
and info.duration <= max_seconds + 0.1):
|
||||||
|
return src_wav, False
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
info = None
|
||||||
|
|
||||||
tmp_out = src_wav.with_suffix(".conv.wav")
|
tmp_out = src_wav.with_suffix(".conv.wav")
|
||||||
cmd = ["ffmpeg", "-y", "-i", str(src_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)
|
r = subprocess.run(cmd, capture_output=True, timeout=30)
|
||||||
if r.returncode != 0:
|
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])
|
src_wav, r.stderr.decode(errors="replace")[:200])
|
||||||
try:
|
try:
|
||||||
tmp_out.unlink()
|
tmp_out.unlink()
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
return src_wav
|
return src_wav, False
|
||||||
os.replace(tmp_out, src_wav)
|
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:
|
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()
|
t0 = time.time()
|
||||||
ref_wav_path, ref_txt_path = voice_paths(voice) if voice else (None, None)
|
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
|
# Legacy-Platzhalter erkennen → behandeln als "kein txt" und neu transkribieren
|
||||||
if voice and ref_txt_path and ref_txt_path.exists():
|
if voice and ref_txt_path and ref_txt_path.exists():
|
||||||
try:
|
try:
|
||||||
|
|
@ -491,8 +522,9 @@ async def handle_voice_upload(ws, payload: dict) -> None:
|
||||||
size_kb = wav_path.stat().st_size / 1024
|
size_kb = wav_path.stat().st_size / 1024
|
||||||
logger.info("Voice WAV gespeichert: %s (%.0fKB)", wav_path, size_kb)
|
logger.info("Voice WAV gespeichert: %s (%.0fKB)", wav_path, size_kb)
|
||||||
|
|
||||||
# Auf 24kHz mono normalisieren (falls App in anderem Format liefert)
|
# Auf 24kHz mono clippen auf 10s (F5-TTS Hard-Limit ist 12s,
|
||||||
ensure_24k_mono_wav(wav_path)
|
# kuerzer = schnellerer Warmup + Text+Audio bleiben aligned)
|
||||||
|
normalize_ref_wav(wav_path)
|
||||||
|
|
||||||
# Transkription ueber whisper-bridge anfragen
|
# Transkription ueber whisper-bridge anfragen
|
||||||
logger.info("Transkribiere '%s' via whisper-bridge...", name)
|
logger.info("Transkribiere '%s' via whisper-bridge...", name)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue