Compare commits

...

8 Commits

Author SHA1 Message Date
duffyduck b373f915b5 feat(f5tts): HF-URL Support fuer Custom Checkpoints (aihpi/F5-TTS-German)
_resolve_hf_path wandelt hf://user/repo/path → lokaler Download via
huggingface_hub.hf_hub_download. So kann man in Diagnostic einfach die
HF-Pfade fuer custom Modelle reinschreiben, ohne erst manuell zu
downloaden + zu mounten.

Format: hf://aihpi/F5-TTS-German/F5TTS_Base/model_365000.safetensors
        hf://aihpi/F5-TTS-German/vocab.txt

Diagnostic UI: Placeholders + Labels angepasst mit Beispiel-HF-Pfaden
und Hinweis dass fuer Fine-Tunes "F5TTS_Base" statt "F5TTS_v1_Base"
als Architektur-Name gesetzt werden muss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:16:44 +02:00
duffyduck 7748834a0f fix(f5tts): Ref-WAV Preprocessing — Loudness + Silence-Trim
F5-TTS reagiert empfindlich auf leise / verrauschte / zerhackte
Referenzen — wir haben bisher nur auf 24kHz mono + 10s geclipped.
Jetzt zusaetzlich:
  - silenceremove am Anfang (bis Speech einsetzt, <-50dB)
  - silenceremove am Ende (0.5s Stille nach letzter Speech = Cutoff)
  - loudnorm -16 LUFS (EBU R128) fuer konsistente Amplitude

Damit sieht das Modell saubere, konstant laute Referenz-Audios statt
kaputter Clips mit Ausklang oder leiser Aufnahme. Besonders bei Deutsch
(wo F5TTS_v1_Base schwach ist) hilft jede Input-Konsistenz der Quali.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:07:58 +02:00
duffyduck 8b52f4c92b 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>
2026-04-24 18:42:33 +02:00
duffyduck dc20570f6d debug: Initial-Handshake Logs damit man sieht was passiert
Beim user kommt nach 'RVS verbunden' nichts mehr — Modell-Download
startet nicht, banner aktualisiert sich nicht. Vermutung: alter Code
laeuft noch (kein neu gebauter Container) ODER der Initial-Handshake
crashed silent (asyncio.create_task ohne await schluckt Exceptions).

- whisper + f5tts: Initial-Handshake mit logger.info Zeilen, damit
  man sieht ob er ueberhaupt ausgefuehrt wird
- f5tts: zusaetzlich exception-Catch + fehler-broadcast falls der
  Modell-Load crashed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:54:12 +02:00
duffyduck 744a27cfd1 fix: HF-Cache zurueck + Banner-Bug + config_request Pattern
Vier Bugs in einem Aufwasch:

1. HF-Cache als Bind-Mount zurueck
   xtts/hf-cache:/root/.cache/huggingface fuer beide Bridges. War vorher
   raus, dadurch jedes Container-Restart = ~3GB Whisper-Download +
   ~1GB F5-TTS-Download. User dachte 5min ist einmalig — ist aber bei
   jedem Restart. Jetzt: einmal pro Maschine geladen, fertig.

2. Banner zeigte stale "ready"
   whisper-bridge sendete beim Connect nur dann Status wenn Modell schon
   geladen war. Sonst blieb der App/Diagnostic Banner auf dem alten
   "ready" State von vor dem Restart haengen — User sah "bereit" obwohl
   gerade gar nichts geladen war. Jetzt wird IMMER ein Status broadcast:
   ready oder loading.

3. config_request Pattern
   aria-bridge wusste nicht wann Gamebox-Bridges sich (re)connecten.
   Wenn die nach aria-bridge kamen, verpassten sie den Config-Broadcast
   und blieben mit Hard-Defaults stehen.
   Jetzt: whisper- und f5tts-bridge senden beim Connect ein
   config_request, aria-bridge antwortet mit der persistierten Config
   (whisperModel, xttsVoice, f5tts*-Felder).

4. RVS ALLOWED_TYPES um config_request erweitert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:46:47 +02:00
duffyduck 37c5f6c368 fix: dynamischer STT-Timeout — whisper Modell-Download nicht abkappen
aria-bridge horcht jetzt auf service_status fuer den Service 'whisper'.
Solange whisper-bridge im 'loading' steckt (Erst-Download large-v3 kann
1-2 Min dauern), gilt fuer stt_request ein Timeout von 300s statt 45s.
Sobald 'ready', zurueck auf 45s — reicht selbst fuer lange Audios.

Symptom vorher: Beim ersten Sprechen nach Container-Restart hat aria-
bridge nach 45s aufgegeben und lokal gefallback waehrend whisper-bridge
noch fleissig den Download laufen hatte. Damit wurde der Sinn der
Auslagerung kaputt gemacht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:06:04 +02:00
duffyduck a361015ff4 fix: WebSocket max_size hochgedreht — voice_upload sprengte Default 1MB
Symptom: aria-whisper-bridge bekam beim ersten internen stt_request
(via voice_upload mit WAV als base64, ~2.4MB) den Frame zu Gesicht,
default ws-max ist 1MB → mit Close-Code 1009 abgewiesen → Verbindung
tot → naechster stt_request lief in Timeout → lokales Fallback.

Fixes:
- whisper-bridge: max_size=50*1024*1024 in websockets.connect()
  (gleicher Wert wie f5tts-bridge schon hat)
- RVS-Server: maxPayload=50*1024*1024 in WebSocketServer-Optionen,
  damit der Server die Frames nicht selbst auf 1MB cappt bevor er
  sie an die Bridge weiterleitet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:54:36 +02:00
duffyduck d83b555209 fix(whisper): kein eager preload mehr — wartet auf config-Broadcast
Vorher: Container-Start lud erst 'small' (env default), dann nochmal
das in Diagnostic konfigurierte Modell (z.B. large-v3) wenn die
config-Broadcast vom aria-bridge ankam. Doppelter Download, doppelte
Wartezeit, doppelter VRAM-Peak.

Jetzt:
- Initial wird NICHTS geladen
- aria-bridge sendet die persistierte voice_config.json kurz nach
  RVS-Connect → whisper-bridge sieht den richtigen Modellnamen
- config-Handler erkennt: noch nichts geladen ODER Wechsel
  → loading-Broadcast → ensure_loaded → ready-Broadcast
- stt_request-Handler: gleicher Status-Broadcast falls Race-Condition
  (Spracheingabe in den ersten 1-2s nach Container-Start)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:50:46 +02:00
7 changed files with 218 additions and 70 deletions
+41 -4
View File
@@ -544,6 +544,10 @@ class ARIABridge:
# STT-Requests die aktuell auf Antwort von der whisper-bridge (Gamebox) warten. # STT-Requests die aktuell auf Antwort von der whisper-bridge (Gamebox) warten.
# requestId → Future mit dem Text (oder None bei Fehler). # requestId → Future mit dem Text (oder None bei Fehler).
self._pending_stt: dict[str, asyncio.Future] = {} self._pending_stt: dict[str, asyncio.Future] = {}
# whisper-bridge service_status: True wenn ready, False/None wenn loading/unbekannt.
# Beeinflusst das Timeout fuer stt_request — bei "loading" warten wir laenger,
# weil das Modell beim ersten Request noch ~1-2 Min runtergeladen werden kann.
self._remote_stt_ready: bool = False
def initialize(self) -> None: def initialize(self) -> None:
"""Initialisiert alle Komponenten. """Initialisiert alle Komponenten.
@@ -1442,13 +1446,41 @@ class ARIABridge:
future.set_result(text) future.set_result(text)
return return
elif msg_type == "service_status":
# Gamebox-Bridges (whisper / f5tts) melden ihren Lade-Status.
# Wir nutzen das fuer den dynamischen STT-Timeout: solange whisper
# im 'loading' steckt, geben wir der Bridge mehr Zeit (Modell-Download
# kann 1-2 Min dauern), statt nach 45s lokal zu fallbacken.
svc = payload.get("service", "")
state = payload.get("state", "")
if svc == "whisper":
was_ready = self._remote_stt_ready
self._remote_stt_ready = (state == "ready")
if self._remote_stt_ready != was_ready:
logger.info("[rvs] whisper-bridge -> %s", state)
return
elif msg_type == "config_request":
# Eine andere Bridge (whisper/f5tts) bittet um die aktuelle Voice-
# Config — passiert wenn sie sich connected, weil sie sonst die
# Diagnostic-Settings nicht kennt. Wir broadcasten die persistierte
# Config (auch beim normalen Connect von aria-bridge selber, aber
# da war eventuell die andere Bridge noch nicht connected).
requester = payload.get("service", "?")
logger.info("[rvs] config_request von %s — broadcaste Voice-Config", requester)
asyncio.create_task(self._broadcast_persisted_config())
return
else: else:
logger.debug("[rvs] Unbekannter Typ: %s", msg_type) logger.debug("[rvs] Unbekannter Typ: %s", msg_type)
# STT-Orchestrierung: zuerst Remote (Gamebox), Fallback lokal. # STT-Orchestrierung: zuerst Remote (Gamebox), Fallback lokal.
# Timeout grosszuegig gewaehlt, damit auch ein erstmaliger Modell-Load # Zwei Timeouts:
# auf der Gamebox (bis ~30s bei large-v3) durchgeht. # ready=True → 45s reicht selbst fuer lange Audios
_STT_REMOTE_TIMEOUT_S = 45.0 # ready=False → 300s, weil das Modell evtl. noch heruntergeladen wird
# (large-v3 ~3GB, kann auf der Gamebox 1-2 Min dauern).
_STT_REMOTE_TIMEOUT_READY_S = 45.0
_STT_REMOTE_TIMEOUT_LOADING_S = 300.0
async def _process_app_audio(self, audio_b64: str, mime_type: str) -> None: async def _process_app_audio(self, audio_b64: str, mime_type: str) -> None:
"""App-Audio → STT → aria-core. Primaer via whisper-bridge (RVS), Fallback lokal.""" """App-Audio → STT → aria-core. Primaer via whisper-bridge (RVS), Fallback lokal."""
@@ -1514,7 +1546,12 @@ class ARIABridge:
if not ok: if not ok:
logger.warning("[rvs] stt_request konnte nicht gesendet werden — skip Remote") logger.warning("[rvs] stt_request konnte nicht gesendet werden — skip Remote")
return None return None
return await asyncio.wait_for(future, timeout=self._STT_REMOTE_TIMEOUT_S) timeout_s = (self._STT_REMOTE_TIMEOUT_READY_S
if self._remote_stt_ready
else self._STT_REMOTE_TIMEOUT_LOADING_S)
logger.info("[rvs] STT-Timeout %ds (whisper-bridge %s)",
int(timeout_s), "ready" if self._remote_stt_ready else "loading")
return await asyncio.wait_for(future, timeout=timeout_s)
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.warning("[rvs] Remote-STT Timeout (%.0fs)", self._STT_REMOTE_TIMEOUT_S) logger.warning("[rvs] Remote-STT Timeout (%.0fs)", self._STT_REMOTE_TIMEOUT_S)
return None return None
+7 -5
View File
@@ -469,23 +469,25 @@
Hardcoded Defaults: F5TTS_v1_Base, cfg_strength=2.5, nfe_step=32. Hardcoded Defaults: F5TTS_v1_Base, cfg_strength=2.5, nfe_step=32.
</div> </div>
<label style="color:#8888AA;font-size:12px;">Modell-ID:</label> <label style="color:#8888AA;font-size:12px;">
Modell-Architektur (F5TTS_v1_Base = Default multilingual, F5TTS_Base = fuer die meisten Fine-Tunes):
</label>
<input type="text" id="diag-f5tts-model" <input type="text" id="diag-f5tts-model"
placeholder="F5TTS_v1_Base" placeholder="F5TTS_v1_Base"
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;"> style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;">
<label style="color:#8888AA;font-size:12px;"> <label style="color:#8888AA;font-size:12px;">
Custom Checkpoint (HF-Repo "user/repo" oder Container-Pfad, leer = Default): Custom Checkpoint HF-Pfad (hf://user/repo/file) oder lokaler Container-Pfad. Leer = Default.
</label> </label>
<input type="text" id="diag-f5tts-ckpt" <input type="text" id="diag-f5tts-ckpt"
placeholder="z.B. aoxo/F5-TTS-German" placeholder="z.B. hf://aihpi/F5-TTS-German/F5TTS_Base/model_365000.safetensors"
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;"> style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;">
<label style="color:#8888AA;font-size:12px;"> <label style="color:#8888AA;font-size:12px;">
Custom Vocab (passend zum Checkpoint, optional): Custom Vocab — muss zum Checkpoint passen. Leer = Default.
</label> </label>
<input type="text" id="diag-f5tts-vocab" <input type="text" id="diag-f5tts-vocab"
placeholder="leer = Default" placeholder="z.B. hf://aihpi/F5-TTS-German/vocab.txt"
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;"> style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:6px 10px;font-size:13px;">
<div style="display:flex;gap:12px;"> <div style="display:flex;gap:12px;">
+5 -1
View File
@@ -22,6 +22,7 @@ const ALLOWED_TYPES = new Set([
"voice_preload", "voice_ready", "voice_preload", "voice_ready",
"stt_request", "stt_response", "stt_request", "stt_response",
"service_status", "service_status",
"config_request",
]); ]);
// Token-Raum: token -> { clients: Set<ws> } // Token-Raum: token -> { clients: Set<ws> }
@@ -54,7 +55,10 @@ function cleanupRooms() {
// ── WebSocket-Server starten ──────────────────────────────────────── // ── WebSocket-Server starten ────────────────────────────────────────
const wss = new WebSocketServer({ port: PORT }); // maxPayload 50MB: TTS-Streaming + Voice-Upload (WAV als base64) +
// audio_pcm Chunks koennen die ws-Library Default 1MB ueberschreiten.
// Default-Limit war der Killer fuer die voice_upload Pipeline.
const wss = new WebSocketServer({ port: PORT, maxPayload: 50 * 1024 * 1024 });
wss.on("listening", () => { wss.on("listening", () => {
log(`RVS läuft auf Port ${PORT} | Max Sessions: ${MAX_SESSIONS}`); log(`RVS läuft auf Port ${PORT} | Max Sessions: ${MAX_SESSIONS}`);
+4
View File
@@ -1,3 +1,7 @@
# HuggingFace Model-Cache (Whisper + F5-TTS, geteilt zwischen den
# beiden Bridges via Bind-Mount, kann mehrere GB werden)
hf-cache/
# Voice-Samples (lokal, gehoert nicht ins Repo) # Voice-Samples (lokal, gehoert nicht ins Repo)
voices/ voices/
+10 -7
View File
@@ -31,11 +31,11 @@ services:
capabilities: [gpu] capabilities: [gpu]
volumes: volumes:
- ./voices:/voices # WAV + TXT Referenz - ./voices:/voices # WAV + TXT Referenz
# KEIN HF-Cache-Mount mehr — - ./hf-cache:/root/.cache/huggingface # HF-Cache als Bind-Mount.
# Modell wird beim Start neu # Direkt sichtbar im xtts/hf-cache/,
# gezogen. Diagnostic zeigt # einfach manuell zu loeschen, kein
# "TTS laedt..." Banner bis # Docker-Desktop .vhdx Bloat.
# service_status: ready kommt. # Wird mit whisper-bridge geteilt.
environment: environment:
# Bootstrap-only — alle anderen F5-TTS-Settings (Modell, cfg_strength, # Bootstrap-only — alle anderen F5-TTS-Settings (Modell, cfg_strength,
# nfe_step, Custom-Checkpoint) kommen ueber Diagnostic via RVS-config. # nfe_step, Custom-Checkpoint) kommen ueber Diagnostic via RVS-config.
@@ -77,6 +77,9 @@ services:
- WHISPER_DEVICE=${WHISPER_DEVICE:-cuda} - WHISPER_DEVICE=${WHISPER_DEVICE:-cuda}
- WHISPER_COMPUTE_TYPE=${WHISPER_COMPUTE_TYPE:-float16} - WHISPER_COMPUTE_TYPE=${WHISPER_COMPUTE_TYPE:-float16}
- WHISPER_LANGUAGE=${WHISPER_LANGUAGE:-de} - WHISPER_LANGUAGE=${WHISPER_LANGUAGE:-de}
# KEIN HF-Cache-Mount — Whisper-Modell wird beim Start neu gezogen. volumes:
# Wechsel via Diagnostic triggert ebenso Re-Download. - ./hf-cache:/root/.cache/huggingface # gleicher Cache wie f5tts-bridge —
# ein Modell muss nur einmal pro
# Maschine geladen werden, kein
# Re-Download bei Container-Restart.
restart: unless-stopped restart: unless-stopped
+113 -35
View File
@@ -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
@@ -93,6 +99,33 @@ def _get_f5tts_cls():
return _F5TTS_cls return _F5TTS_cls
def _resolve_hf_path(p: str) -> str:
"""Wenn p mit 'hf://' anfaengt → aus HuggingFace Hub runterladen,
lokalen Pfad zurueckgeben. Sonst unveraendert.
Format: hf://user/repo/path/to/file.ext
Beispiel: hf://aihpi/F5-TTS-German/F5TTS_Base/model_365000.safetensors
"""
if not p or not p.startswith("hf://"):
return p
try:
from huggingface_hub import hf_hub_download
rest = p[5:]
parts = rest.split("/", 2)
if len(parts) < 3:
logger.warning("Ungueltiges hf:// Format: %s (erwarte hf://user/repo/path)", p)
return p
repo_id = f"{parts[0]}/{parts[1]}"
filename = parts[2]
logger.info("HF-Download: %s aus %s", filename, repo_id)
local = hf_hub_download(repo_id=repo_id, filename=filename)
logger.info("HF-Download fertig: %s", local)
return local
except Exception as e:
logger.exception("HF-Download fehlgeschlagen fuer %s: %s", p, e)
return p
class F5Runner: class F5Runner:
"""Haelt das F5-TTS-Modell. Synthese laeuft im Executor (blocking). """Haelt das F5-TTS-Modell. Synthese laeuft im Executor (blocking).
@@ -116,14 +149,16 @@ class F5Runner:
def _load_blocking(self) -> None: def _load_blocking(self) -> None:
cls = _get_f5tts_cls() cls = _get_f5tts_cls()
ckpt_resolved = _resolve_hf_path(self.ckpt_file) if self.ckpt_file else ""
vocab_resolved = _resolve_hf_path(self.vocab_file) if self.vocab_file else ""
logger.info("Lade F5-TTS '%s' (device=%s, ckpt=%s)...", logger.info("Lade F5-TTS '%s' (device=%s, ckpt=%s)...",
self.model_id, F5TTS_DEVICE, self.ckpt_file or "default") self.model_id, F5TTS_DEVICE, ckpt_resolved or "default")
self._load_started_at = time.time() self._load_started_at = time.time()
kwargs = {"model": self.model_id, "device": F5TTS_DEVICE} kwargs = {"model": self.model_id, "device": F5TTS_DEVICE}
if self.ckpt_file: if ckpt_resolved:
kwargs["ckpt_file"] = self.ckpt_file kwargs["ckpt_file"] = ckpt_resolved
if self.vocab_file: if vocab_resolved:
kwargs["vocab_file"] = self.vocab_file kwargs["vocab_file"] = vocab_resolved
self.model = cls(**kwargs) self.model = cls(**kwargs)
elapsed = time.time() - self._load_started_at elapsed = time.time() - self._load_started_at
logger.info("F5-TTS geladen in %.1fs (cfg_strength=%.1f, nfe=%d)", logger.info("F5-TTS geladen in %.1fs (cfg_strength=%.1f, nfe=%d)",
@@ -248,32 +283,51 @@ 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:
Wenn das File schon passt, wird nichts geaendert. Sonst wird es * 24kHz mono
reingeschrieben (Original wird ueberschrieben). * max max_seconds Dauer
* Stille am Anfang + Ende abgeschnitten (silenceremove-Filter)
* Lautheit auf -16 LUFS normalisiert (loudnorm-Filter) damit
das Modell konsistente Amplituden sieht
F5-TTS reagiert empfindlich auf leise / verrauschte / zerhackte
Referenzen. Konsistente, saubere Input-Lautheit hilft der Quali.
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
except Exception:
pass
tmp_out = src_wav.with_suffix(".conv.wav") tmp_out = src_wav.with_suffix(".conv.wav")
# silenceremove am Anfang: bis -50dB gesprochen wird
# silenceremove am Ende: ueber -50dB rein, dann 0.5s stille als Cutoff
# loudnorm: EBU R128, Ziel -16 LUFS
af = ("silenceremove=start_periods=1:start_duration=0.05:start_threshold=-50dB,"
"silenceremove=stop_periods=1:stop_duration=0.5:stop_threshold=-50dB,"
"loudnorm=I=-16:TP=-1.5:LRA=11")
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)] "-af", af,
"-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")[:300])
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 try:
info = sf.info(str(src_wav))
logger.info("Referenz-WAV normalisiert: %s (%.1fs, %dHz mono, -16 LUFS, silence getrimmt)",
src_wav.name, info.duration, info.samplerate)
except Exception:
logger.info("Referenz-WAV normalisiert: %s", src_wav.name)
return src_wav, True
async def _send(ws, mtype: str, payload: dict) -> None: async def _send(ws, mtype: str, payload: dict) -> None:
@@ -349,6 +403,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 +560,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)
@@ -613,22 +683,30 @@ async def run_loop(runner: F5Runner) -> None:
tls_fallback_tried = False tls_fallback_tried = False
# Status-Broadcast: erst loading, dann ready nach erfolgreichem Load. # Status-Broadcast: erst loading, dann ready nach erfolgreichem Load.
# Modell wird hier (nicht ausserhalb der Schleife) gestartet damit # Plus: config_request damit wir die persistierte Diagnostic-Config
# der Loading-Status auch wirklich uebertragen werden kann. # bekommen, falls aria-bridge ihre nicht von alleine sendet.
async def _load_with_status(): async def _load_with_status():
if runner.model is not None:
await _broadcast_status(ws, "ready",
model=runner.model_id,
loadSeconds=runner.last_load_seconds)
return
await _broadcast_status(ws, "loading", model=runner.model_id)
try: try:
await runner.ensure_loaded() if runner.model is not None:
await _broadcast_status(ws, "ready", logger.info("Initial: broadcaste ready (Modell schon im RAM: %s)", runner.model_id)
model=runner.model_id, await _broadcast_status(ws, "ready",
loadSeconds=runner.last_load_seconds) model=runner.model_id,
loadSeconds=runner.last_load_seconds)
else:
logger.info("Initial: broadcaste loading + lade Modell '%s'", runner.model_id)
await _broadcast_status(ws, "loading", model=runner.model_id)
await runner.ensure_loaded()
await _broadcast_status(ws, "ready",
model=runner.model_id,
loadSeconds=runner.last_load_seconds)
logger.info("Initial: sende config_request an aria-bridge")
await _send(ws, "config_request", {"service": "f5tts"})
except Exception as e: except Exception as e:
await _broadcast_status(ws, "error", error=str(e)[:200]) logger.exception("Initial-Load crashed: %s", e)
try:
await _broadcast_status(ws, "error", error=str(e)[:200])
except Exception:
pass
asyncio.create_task(_load_with_status()) asyncio.create_task(_load_with_status())
# TTS-Worker fuer diese Verbindung starten # TTS-Worker fuer diese Verbindung starten
+38 -18
View File
@@ -152,8 +152,17 @@ async def handle_stt_request(ws, payload: dict, runner: WhisperRunner) -> None:
try: try:
t_load = time.time() t_load = time.time()
# Falls Modell noch nicht geladen (Race-Condition: stt_request vor config)
# → Status-Broadcast loading→ready damit der App-Banner aufpoppt
needs_load = runner.model is None or runner.model_size != model
if needs_load:
await _broadcast_status(ws, "loading", model=model)
await runner.ensure_loaded(model) await runner.ensure_loaded(model)
load_ms = int((time.time() - t_load) * 1000) load_ms = int((time.time() - t_load) * 1000)
if needs_load:
await _broadcast_status(ws, "ready",
model=runner.model_size,
loadSeconds=load_ms / 1000.0)
audio = ffmpeg_to_float32(audio_b64, mime_type) audio = ffmpeg_to_float32(audio_b64, mime_type)
if audio.size == 0: if audio.size == 0:
@@ -203,27 +212,34 @@ async def run_loop(runner: WhisperRunner) -> None:
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
try: try:
logger.info("Verbinde zu RVS: %s", masked) logger.info("Verbinde zu RVS: %s", masked)
async with websockets.connect(url, ping_interval=20, ping_timeout=10) as ws: # max_size 50MB damit grosse stt_request (Voice-Cloning-WAVs als
# base64 koennen mehrere MB werden) nicht das Frame-Limit sprengen
# und die Verbindung mit 1009 'message too big' killen.
async with websockets.connect(url, ping_interval=20, ping_timeout=10, max_size=50 * 1024 * 1024) as ws:
logger.info("RVS verbunden") logger.info("RVS verbunden")
retry_s = 2 retry_s = 2
tls_fallback_tried = False tls_fallback_tried = False
# Modell laden, dabei loading→ready broadcasten # Initialer Status-Broadcast — uebertont alten "ready"-State
async def _load_with_status(): # im App/Diagnostic Banner (sonst denkt der User noch alles ist
if runner.model is not None: # gut von vorher). Wenn Modell schon geladen → ready, sonst
await _broadcast_status(ws, "ready", model=runner.model_size) # loading mit aktuellem (Default-)Namen.
return # Plus: config_request an aria-bridge — wir wissen nicht ob
await _broadcast_status(ws, "loading", model=WHISPER_MODEL) # sie auch grad reconnected hat oder schon laenger online ist.
async def _initial_handshake():
try: try:
t0 = time.time() if runner.model is not None:
await runner.ensure_loaded(WHISPER_MODEL) logger.info("Initial: broadcaste ready (Modell schon im RAM: %s)", runner.model_size)
elapsed = time.time() - t0 await _broadcast_status(ws, "ready", model=runner.model_size)
await _broadcast_status(ws, "ready", else:
model=runner.model_size, init_model = runner.model_size or WHISPER_MODEL
loadSeconds=elapsed) logger.info("Initial: broadcaste loading (model=%s)", init_model)
await _broadcast_status(ws, "loading", model=init_model)
logger.info("Initial: sende config_request an aria-bridge")
await _send(ws, "config_request", {"service": "whisper"})
except Exception as e: except Exception as e:
await _broadcast_status(ws, "error", error=str(e)[:200]) logger.exception("Initial-Handshake crashed: %s", e)
asyncio.create_task(_load_with_status()) asyncio.create_task(_initial_handshake())
async for raw in ws: async for raw in ws:
try: try:
@@ -240,9 +256,13 @@ async def run_loop(runner: WhisperRunner) -> None:
req_id[:8] if req_id != "?" else "?", audio_len // 1365) req_id[:8] if req_id != "?" else "?", audio_len // 1365)
asyncio.create_task(handle_stt_request(ws, payload, runner)) asyncio.create_task(handle_stt_request(ws, payload, runner))
elif mtype == "config": elif mtype == "config":
new_model = payload.get("whisperModel") new_model = payload.get("whisperModel") or WHISPER_MODEL
if new_model and new_model != runner.model_size: # Laden wenn (a) noch nix geladen, oder (b) Modell wechselt
logger.info("Config-Broadcast: Whisper-Modell -> %s", new_model) needs_load = (runner.model is None) or (new_model != runner.model_size)
if needs_load:
logger.info("Config-Broadcast: Whisper-Modell -> %s%s",
new_model,
" (initial)" if runner.model is None else " (Wechsel)")
async def _swap_with_status(target): async def _swap_with_status(target):
await _broadcast_status(ws, "loading", model=target) await _broadcast_status(ws, "loading", model=target)
try: try: