Lucy-TTS/F5: Skripte + Batches versionieren, schwere Assets ignoriert
- pocket_server.py (Produktions-TTS mit Stimmen-Waechter), text_norm, Bench-/Diag-Skripte - lucy-f5: f5_server/f5_test/bench_dml (DirectML-Experiment, Phase C/D offen) - .gitignore: venvs/Modelle/Audio/Logs der beiden Ordner + box_recon/gemma_swap-Scratch Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Lucy-Stimme: Pocket TTS (Kyutai) als lokaler CPU-Dienst. Klont Lucys Saber-Stimme, real-time, kein GPU."""
|
||||
import os, io, re, time, threading, logging
|
||||
import numpy as np, soundfile as sf, librosa
|
||||
from scipy.signal import butter, sosfilt
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import Response, JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from contextlib import asynccontextmanager
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from pocket_tts import TTSModel, export_model_state
|
||||
from text_norm import normalize_numbers, fix_acronyms # Zahlen-Normalisierung + Akronym-Fix für die Stimme
|
||||
|
||||
log = logging.getLogger("lucy-tts"); logging.basicConfig(level=logging.INFO)
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
REF_MP3 = os.path.join(BASE, "ref.mp3")
|
||||
LANG = os.environ.get("LUCY_LANG", "german_24l")
|
||||
# Vom User per Ohr getunt (2026-06-28): lsd 10 + noise_clamp 2.5 + ref18 (Sweep-Sieger: sauberes
|
||||
# 'Commander', Timbre + logische Betonung, wenig Rauschen). temp 0.9 (lebendig). Per Env umschaltbar.
|
||||
LSD = int(os.environ.get("LUCY_LSD", "16")) # 16 statt 10: glattere Prosodie/Komma-Übergänge (User-A/B 2026-07-01), RTF bleibt <1
|
||||
TEMP = float(os.environ.get("LUCY_TEMP", "0.9"))
|
||||
def _parse_nc(v): # noise_clamp: Zahl, oder None bei "none"/leer/0
|
||||
return None if v.strip().lower() in ("", "none", "0") else float(v)
|
||||
NOISE_CLAMP = _parse_nc(os.environ.get("LUCY_NOISE_CLAMP", "2.5")) # dämpft Artefakte+Rauschen+Kollaps; 3.0 = rauschärmer
|
||||
FEOS = int(os.environ.get("LUCY_FRAMES_AFTER_EOS", "4")) # kurze Sätze bekamen sonst 0ms Schwanz
|
||||
TARGET_RMS = float(os.environ.get("LUCY_TARGET_RMS", "0.09")) # User: 0.09 u. 0.06 beide gut -> 0.09 Default
|
||||
LEAD = os.environ.get("LUCY_LEAD", "Tja. ") # Wegwerf-Lead-Wort -> natürlicher Onset fürs echte 1. Wort
|
||||
QUANTIZE = os.environ.get("LUCY_QUANTIZE", "1") not in ("0", "false", "False") # int8: ~27% schneller, lt. Doku ohne Qualitätsverlust
|
||||
PAD_S = float(os.environ.get("LUCY_PAD_S", "0.025")) # Satz-Polster (klein = flüssigere Übergänge bei langen Antworten)
|
||||
TAIL_DB = int(os.environ.get("LUCY_TAIL_DB", "30")) # Tail-Trim (dB unter Peak): kleiner = mehr Nachlauf-Stille weg = kürzere Satz-Pausen (gemessen: 45 ließ 500-680ms stehen)
|
||||
MAX_GAP_S = float(os.environ.get("LUCY_MAX_GAP", "0.20")) # interne Sprech-Pausen deckeln: Pockets Komma-Pausen ziehen bis 580ms -> hart auf 200ms kappen (flüssiger)
|
||||
F0_FLOOR = float(os.environ.get("LUCY_F0_FLOOR", "160")) # Hz: drunter = männlicher Kollaps (Klon~220, male~100)
|
||||
FP_FLOOR = float(os.environ.get("LUCY_FP_FLOOR", "0.94")) # Fingerabdruck-Ähnlichkeit (ohne MFCC0): drunter = Stimm-Drift (gut 0.956-0.98, drift 0.928)
|
||||
MAX_TRIES = int(os.environ.get("LUCY_MAX_TRIES", "3")) # max. Versuche gegen Stimm-Anomalien
|
||||
FP_MIN_S = float(os.environ.get("LUCY_FP_MIN_S", "2.0")) # B3: FP-Drift-Gate erst ab so viel stimmhafter Dauer (kurze Audios = verrauschter Fingerabdruck = Fehlalarm). F0-Gate bleibt immer aktiv.
|
||||
# A1: Voice-State einmalig nach safetensors exportieren -> Start lädt kvcache statt neu zu klonen.
|
||||
VOICE_ST = os.environ.get("LUCY_VOICE_ST", os.path.join(BASE, "lucy_voice.safetensors"))
|
||||
FORCE_RECLONE = os.environ.get("LUCY_FORCE_RECLONE", "0") not in ("0", "false", "False")
|
||||
# A2: Referenz-Cleaning (pocket reproduziert die Sample-Qualität mit) — Highpass + Peak-Norm, abschaltbar.
|
||||
REF_CLEAN = os.environ.get("LUCY_REF_CLEAN", "1") not in ("0", "false", "False")
|
||||
REF_HPF_HZ = float(os.environ.get("LUCY_REF_HPF_HZ", "70")) # Rumpel/Netzbrumm raus
|
||||
REF_SECONDS = float(os.environ.get("LUCY_REF_SECONDS", "18")) # Klon-Referenzlänge
|
||||
# B2: parallele Satz-Worker. SWEEP-ERGEBNIS (9700X, german_24l, 30.06.): seriell hat die BESTE
|
||||
# TTFB (3.6s vs 6–8s Pool) UND RTF 0.74<1 (generiert schneller als Echtzeit -> Streaming spielt
|
||||
# lückenlos). Daher Default 0 = seriell für Lucys Live-Stimme. Der Pool (>=1) lohnt NUR für
|
||||
# Batch-/tts (ganze Datei am Stück): Durchsatz-Sweet-Spot 4w×2t (RTF 0.44) bzw. 3w×2t (RTF 0.50).
|
||||
# Threads pro Worker via LUCY_WORKER_THREADS.
|
||||
WORKERS = int(os.environ.get("LUCY_WORKERS", "0"))
|
||||
# TTFB-Opt „kurzer erster Chunk": erster Stream-Chunk bleibt kurz -> Lucy spricht früher.
|
||||
# MIT B3 (FP-Gate überspringt kurze Audios) GEMESSEN STARK: TTFB 1.31s statt 3.74s (30.06.),
|
||||
# Wall ~gleich, RTF<1 (lückenlos). Daher Default AN. (Ohne B3 wäre es schlechter -> beides gehört
|
||||
# zusammen.) Nur Stream-Pfad.
|
||||
FAST_FIRST = os.environ.get("LUCY_FAST_FIRST", "1") not in ("0", "false", "False")
|
||||
MIN_LEN = int(os.environ.get("LUCY_MIN_LEN", "55")) # Chunk-Bündelung: größer = weniger Übergänge = flüssiger
|
||||
# Laufzeit-Regler (POST /tune, OHNE Neustart) -> Prosodie live nach Ohr A/B-testen.
|
||||
# temp/lsd/noise_clamp sind Modell-Load-Params und NICHT hier drin (die brauchen einen Neustart).
|
||||
TUNE = {"min_len": MIN_LEN, "pad_s": PAD_S, "fast_first": FAST_FIRST}
|
||||
# Umlaut-Fix: LLM (Hermes/Qwen) gibt manchmal ASCII-Umlaute aus (ueber/schoen/maerz) -> pocket liest
|
||||
# „ue" wörtlich. Wir wandeln NUR bekannte Ganzwörter zurück (sicher: „neue/aktuell/Steuer" bleiben).
|
||||
UMLAUT_FIX = os.environ.get("LUCY_UMLAUT_FIX", "1") not in ("0", "false", "False")
|
||||
STATE, LOCK = {}, threading.Lock()
|
||||
|
||||
# Bekannte deutsche Umlaut-Wörter (korrekt geschrieben). Die ASCII-Schlüssel (ä->ae, ö->oe, ü->ue,
|
||||
# ß->ss) werden daraus AUTOMATISCH abgeleitet -> wenig Fehlerquelle. Erweiterbar via LUCY_UMLAUT_EXTRA.
|
||||
_UML_WORDS = (
|
||||
"über überall übrigens übernehmen überzeugt überprüfen für fürs fünf fünfzehn fünfzig müssen "
|
||||
"müsste müssten können könnt könnte könnten könig königin möchte möchten möchtest möglich "
|
||||
"möglichst möglichkeit würde würden würdest müde prüfen prüft prüfung zurück natürlich gemütlich "
|
||||
"grün grüne grüße grüßen drücken dürfen darüber gegenüber dafür wofür hierfür führen führt "
|
||||
"führung fühlen gefühl gefühle tür türen glück glücklich stück stücke brücke küche kühl "
|
||||
"kühlschrank früh früher frühstück frühling bücher büro bürger südlich süden schüler schützen "
|
||||
"wünschen wünsche übung übungen künstler künstlich rücken rückkehr brüder lücke müll gründe "
|
||||
"gründen begründung vergnügen "
|
||||
"schön schöne schöner schönste schönheit möbel höher höhe hören gehört gehören größe größer "
|
||||
"größte öffnen öffnet geöffnet öffentlich öl östlich löschen löffel lösung lösen börse dörfer "
|
||||
"wörter wörterbuch völlig völker störung stören zwölf böse höflich öfter vögel mögen "
|
||||
"ähnlich änderung ändern ärger ärgern ärztin äußern äußerst gespräch gespräche hängen länger "
|
||||
"länge mädchen männer märz nächste nächsten nähe näher qualität universität städte tätigkeit "
|
||||
"täglich träume träumen väter wäre wären während wärme zähne erklären erklärung verändern "
|
||||
"gefährlich geschäft geschäfte jährlich käse hände kälte plätze sätze wälder fähig fähigkeit"
|
||||
).split()
|
||||
|
||||
def _build_umlaut_map():
|
||||
words = list(_UML_WORDS)
|
||||
extra = os.environ.get("LUCY_UMLAUT_EXTRA", "")
|
||||
words += [w.strip() for w in extra.replace(",", " ").split() if w.strip()]
|
||||
# gängige Flexionsendungen mitnehmen -> deckt mögliche/möglichen/größeren/schönes... ab.
|
||||
# Bogus-Formen (z.B. „möchtee") sind harmlos: sie tauchen in echtem Text nie auf.
|
||||
endings = ("", "e", "en", "er", "es", "em", "n", "s")
|
||||
m = {}
|
||||
for w in words:
|
||||
base = w.lower()
|
||||
for suf in endings:
|
||||
wl = base + suf
|
||||
ascii_w = wl.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
|
||||
if ascii_w != wl: # nur echte Umlaut-Wörter
|
||||
m.setdefault(ascii_w, wl)
|
||||
return m
|
||||
|
||||
_UML_MAP = _build_umlaut_map()
|
||||
_UML_RX = re.compile(r"\b(" + "|".join(sorted((re.escape(k) for k in _UML_MAP), key=len, reverse=True))
|
||||
+ r")\b", re.IGNORECASE) if _UML_MAP else None
|
||||
|
||||
def _fix_umlauts(text: str) -> str:
|
||||
"""Wandelt NUR bekannte ASCII-Umlaut-Ganzwörter zurück (ueber->über), case-erhaltend."""
|
||||
if not UMLAUT_FIX or not _UML_RX:
|
||||
return text
|
||||
def _repl(mo):
|
||||
s = mo.group(0); u = _UML_MAP[s.lower()]
|
||||
return u[:1].upper() + u[1:] if s[:1].isupper() else u
|
||||
return _UML_RX.sub(_repl, text)
|
||||
|
||||
def _prep_ref():
|
||||
"""A2: Klon-Referenz aufbereiten. pocket-tts reproduziert die Sample-Qualität mit, daher optional
|
||||
Highpass (Rumpeln/Netzbrumm) + Peak-Normalisierung. Per LUCY_REF_CLEAN=0 abschaltbar (A/B per Ohr)."""
|
||||
ref = os.path.join(BASE, "ref.wav")
|
||||
y, _ = librosa.load(REF_MP3, sr=24000, mono=True)
|
||||
y, _ = librosa.effects.trim(y, top_db=30)
|
||||
if REF_CLEAN:
|
||||
sos = butter(4, REF_HPF_HZ, btype="highpass", fs=24000, output="sos")
|
||||
y = sosfilt(sos, y).astype(np.float32)
|
||||
# nach dem Highpass nochmal randstille trimmen, dann Peak-Norm gegen zu leise/zu laute Referenz
|
||||
y, _ = librosa.effects.trim(y, top_db=30)
|
||||
peak = float(np.max(np.abs(y))) or 1.0
|
||||
y = (y * (0.95 / peak)).astype(np.float32)
|
||||
sf.write(ref, y[: int(REF_SECONDS * 24000)], 24000)
|
||||
return ref
|
||||
|
||||
def _drop_tail_blip(a: np.ndarray, sr: int) -> np.ndarray:
|
||||
"""Entfernt das End-'taa': isolierter, sehr leiser + kurzer Schwanz-Blip (Modell-Halluzination,
|
||||
intermittierend bei temp 0.9). Verifiziert: trifft Blip (rms-ratio 0.15), schont echte Kurzwörter
|
||||
wie 'Fehler' (ratio 0.55). Bedingung ALLE: große Lücke + viel leiser als Sprache + kurz."""
|
||||
for _ in range(3): # bis zu 3 Blips hintereinander
|
||||
iv = librosa.effects.split(a, top_db=35)
|
||||
if len(iv) < 2:
|
||||
break
|
||||
speech_rms = float(np.median([np.sqrt(np.mean(a[s:e] ** 2)) for s, e in iv[:-1]]))
|
||||
s, e = iv[-1]
|
||||
last_dur = (e - s) / sr
|
||||
last_rms = float(np.sqrt(np.mean(a[s:e] ** 2)))
|
||||
gap = (s - iv[-2][1]) / sr
|
||||
if gap > 0.35 and last_rms < 0.30 * speech_rms and last_dur < 0.35:
|
||||
a = a[: iv[-2][1]]
|
||||
else:
|
||||
break
|
||||
return a
|
||||
|
||||
def _crop_lead(a: np.ndarray, sr: int) -> np.ndarray:
|
||||
"""Schneidet das Wegwerf-Lead-Wort weg -> echtes 1. Wort behält seinen vollen, natürlichen Onset
|
||||
(pocket-tts startet sonst mid-Phonem = 'vorne abgeschnitten'). Schnitt KURZ VOR dem echten Wort
|
||||
(nicht direkt hinter dem Lead), damit auch die variable, teils lange Pause nach 'Tja.' verschwindet."""
|
||||
iv = librosa.effects.split(a, top_db=35)
|
||||
if len(iv) >= 2:
|
||||
cut = max(iv[0][1], iv[1][0] - int(0.06 * sr)) # 60ms vor echtem Wort, aber hinter dem Lead
|
||||
a = a[cut:]
|
||||
return a
|
||||
|
||||
def _compress_gaps(a: np.ndarray, sr: int, max_gap: float = MAX_GAP_S, top_db: int = 30) -> np.ndarray:
|
||||
"""Deckelt INTERNE Sprech-Pausen (Pockets Komma-Pausen ziehen gemessen bis 580ms) auf max_gap.
|
||||
Findet stimmhafte Segmente, fügt sie mit gekappter Lücke wieder zusammen -> flüssiger, ohne die
|
||||
Sprache selbst anzutasten (nur Stille wird gekürzt). Onset-Vorlauf + Schwanz bleiben unberührt."""
|
||||
if a.size == 0 or max_gap <= 0:
|
||||
return a
|
||||
iv = librosa.effects.split(a, top_db=top_db)
|
||||
if len(iv) < 2:
|
||||
return a
|
||||
cap = int(max_gap * sr)
|
||||
out = [a[: iv[0][1]]] # Kopf inkl. natürlichem Onset-Vorlauf
|
||||
for k in range(1, len(iv)):
|
||||
g0 = iv[k - 1][1]
|
||||
keep = min(iv[k][0] - g0, cap)
|
||||
if keep > 0:
|
||||
out.append(a[g0 : g0 + keep]) # gekappte Pause (Wort-Ausklang bleibt erhalten)
|
||||
out.append(a[iv[k][0] : iv[k][1]]) # nächstes stimmhaftes Segment
|
||||
out.append(a[iv[-1][1] :]) # Schwanz (bereits getrimmt)
|
||||
return np.concatenate(out)
|
||||
|
||||
def cleanup(a: np.ndarray, sr: int) -> np.ndarray:
|
||||
"""v4 (2026-06-28): Onset-Fix + RMS-Lautstärke + Blip-Killer.
|
||||
- _drop_tail_blip gegen End-'taa'
|
||||
- _crop_lead entfernt Wegwerf-Lead -> voller Onset vorne (kein Abschneiden mehr)
|
||||
- nur HINTEN trimmen (vorne unangetastet), RMS-Normalisierung auf TARGET_RMS (statt lautem Peak 0.95)
|
||||
- 8ms-Fades + 80ms-Atempause vorne+hinten."""
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1)
|
||||
if a.size == 0: return a
|
||||
a = _drop_tail_blip(a, sr)
|
||||
a = _crop_lead(a, sr)
|
||||
# nur den Schwanz trimmen (vorderen Onset behalten)
|
||||
rev, _ = librosa.effects.trim(a[::-1], top_db=TAIL_DB)
|
||||
a = rev[::-1] if rev.size else a
|
||||
a = _compress_gaps(a, sr) # interne Komma-Pausen deckeln (größter Glättungs-Gewinn bei langen Sätzen)
|
||||
# RMS-Normalisierung auf Zielpegel (gegen 'zu laut') + Peak-Sicherheits-Clamp
|
||||
rms = float(np.sqrt(np.mean(a ** 2))) or 1e-9
|
||||
a = a * (TARGET_RMS / rms)
|
||||
peak = float(np.max(np.abs(a)))
|
||||
if peak > 0.9: a = a * (0.9 / peak)
|
||||
fi = min(int(0.008 * sr), a.size // 2) # 8ms Fade gegen Klicks
|
||||
if fi > 0:
|
||||
a[:fi] *= np.linspace(0.0, 1.0, fi, dtype=np.float32)
|
||||
a[-fi:] *= np.linspace(1.0, 0.0, fi, dtype=np.float32)
|
||||
pad = np.zeros(int(TUNE["pad_s"] * sr), dtype=np.float32) # Atempause/Anti-Klick (klein -> flüssiger Satz-Übergang)
|
||||
return np.concatenate([pad, a, pad])
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
t0 = time.time()
|
||||
ref = _prep_ref() # ref.wav immer erzeugen -> Worker + Fingerabdruck
|
||||
need_export = FORCE_RECLONE or not os.path.exists(VOICE_ST) or \
|
||||
os.path.getmtime(VOICE_ST) < os.path.getmtime(REF_MP3)
|
||||
if WORKERS >= 1:
|
||||
# B2-Pool: safetensors einmalig erzeugen (kurz ein Modell), dann RAM freigeben; Worker
|
||||
# laden ihre eigene Instanz aus dem Cache. Hauptprozess hält danach KEIN Modell.
|
||||
log.info("Starte Worker-Pool (%d) — pocket-tts %s lsd=%d temp=%.2f nc=%s quantize=%s",
|
||||
WORKERS, LANG, LSD, TEMP, NOISE_CLAMP, QUANTIZE)
|
||||
if need_export:
|
||||
log.info("Erzeuge Voice-Cache %s ...", os.path.basename(VOICE_ST))
|
||||
mtmp = TTSModel.load_model(language=LANG, lsd_decode_steps=LSD, temp=TEMP,
|
||||
noise_clamp=NOISE_CLAMP, quantize=QUANTIZE)
|
||||
try:
|
||||
export_model_state(mtmp.get_state_for_audio_prompt(ref), VOICE_ST)
|
||||
except Exception as e:
|
||||
log.warning("Voice-Export fehlgeschlagen (Worker klonen selbst): %s", e)
|
||||
sr0 = mtmp.sample_rate
|
||||
del mtmp
|
||||
else:
|
||||
sr0 = 24000
|
||||
ref_audio, _ = librosa.load(ref, sr=sr0, mono=True)
|
||||
STATE.update(sr=sr0, ref_fp=_fingerprint(ref_audio, sr0))
|
||||
pool = ProcessPoolExecutor(max_workers=WORKERS, initializer=_worker_init)
|
||||
list(pool.map(_warmup, range(WORKERS))) # alle Worker vorab hochfahren
|
||||
STATE["pool"] = pool
|
||||
log.info("Worker-Pool bereit (%d Prozesse) in %.1fs", WORKERS, time.time() - t0)
|
||||
else:
|
||||
log.info("Seriell — pocket-tts %s lsd=%d temp=%.2f nc=%s quantize=%s",
|
||||
LANG, LSD, TEMP, NOISE_CLAMP, QUANTIZE)
|
||||
m = TTSModel.load_model(language=LANG, lsd_decode_steps=LSD, temp=TEMP,
|
||||
noise_clamp=NOISE_CLAMP, quantize=QUANTIZE)
|
||||
if need_export:
|
||||
log.info("Klone Stimme aus Referenz -> Export %s", os.path.basename(VOICE_ST))
|
||||
vs = m.get_state_for_audio_prompt(ref)
|
||||
try:
|
||||
export_model_state(vs, VOICE_ST); log.info("Voice-State exportiert")
|
||||
except Exception as e:
|
||||
log.warning("Voice-Export fehlgeschlagen (nutze Live-Klon): %s", e)
|
||||
else:
|
||||
log.info("Lade gecachten Voice-State <- %s", os.path.basename(VOICE_ST))
|
||||
try:
|
||||
vs = m.get_state_for_audio_prompt(VOICE_ST)
|
||||
except Exception as e:
|
||||
log.warning("Cache-Load fehlgeschlagen, klone neu: %s", e)
|
||||
vs = m.get_state_for_audio_prompt(ref)
|
||||
ref_audio, _ = librosa.load(ref, sr=m.sample_rate, mono=True)
|
||||
STATE.update(m=m, vs=vs, sr=m.sample_rate, ref_fp=_fingerprint(ref_audio, m.sample_rate))
|
||||
log.info("Lucy-Stimme bereit in %.1fs (sr=%d)", time.time() - t0, m.sample_rate)
|
||||
yield
|
||||
pool = STATE.get("pool")
|
||||
if pool is not None:
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
STATE.clear()
|
||||
|
||||
app = FastAPI(title="Lucy TTS (Pocket)", lifespan=lifespan)
|
||||
|
||||
class Req(BaseModel):
|
||||
text: str
|
||||
|
||||
class PerfIn(BaseModel):
|
||||
msg: str = ""
|
||||
|
||||
class TuneIn(BaseModel):
|
||||
min_len: int | None = None # Chunk-Bündelung (größer = flüssiger, langsamere TTFB später)
|
||||
pad_s: float | None = None # Pause zwischen Stücken (Sekunden)
|
||||
fast_first: bool | None = None # erster Satz kurz halten (schnelle TTFB) an/aus
|
||||
|
||||
def _ready() -> bool:
|
||||
return STATE.get("pool") is not None or "m" in STATE
|
||||
|
||||
def _gen_sentences_ordered(sentences):
|
||||
"""Liefert je Satz fertiges float32-Audio IN REIHENFOLGE. Mit Worker-Pool laufen alle Sätze
|
||||
parallel (bis WORKERS gleichzeitig), werden aber in Eingabereihenfolge ausgegeben -> niedrige
|
||||
TTFB + korrekte Reihenfolge. Ohne Pool: seriell im Hauptprozess (wie bisher, unter LOCK)."""
|
||||
pool = STATE.get("pool")
|
||||
sr = STATE["sr"]
|
||||
if pool is not None:
|
||||
for fut in [pool.submit(_worker_gen, s) for s in sentences]:
|
||||
yield fut.result()
|
||||
else:
|
||||
with LOCK:
|
||||
for s in sentences:
|
||||
yield cleanup(_gen_gated_core(STATE, s), sr)
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok" if _ready() else "loading", "engine": "pocket-tts", "lang": LANG,
|
||||
"sr": STATE.get("sr"), "workers": WORKERS, "device": "cpu"}
|
||||
|
||||
@app.post("/perf")
|
||||
def perf(body: PerfIn):
|
||||
"""Client-Perf-Zeilen in DIESES Terminal loggen (der Nutzer hat den TTS-Log eh offen) ->
|
||||
„Lucy denkt lange"-Diagnose ohne Browser-DevTools."""
|
||||
log.info("[lucy-perf] %s", body.msg)
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/tune")
|
||||
def tune_get():
|
||||
"""Aktuelle Laufzeit-Regler. temp/lsd/nc stehen separat (Modell-Load, Neustart nötig)."""
|
||||
return {**TUNE, "note": "temp/lsd/noise_clamp brauchen Neustart (Env LUCY_TEMP/LSD/NOISE_CLAMP)"}
|
||||
|
||||
@app.post("/tune")
|
||||
def tune_set(body: TuneIn):
|
||||
"""Prosodie live ändern OHNE Neustart -> nächste Äußerung nutzt die neuen Werte."""
|
||||
if body.min_len is not None: TUNE["min_len"] = max(1, int(body.min_len))
|
||||
if body.pad_s is not None: TUNE["pad_s"] = max(0.0, float(body.pad_s))
|
||||
if body.fast_first is not None: TUNE["fast_first"] = bool(body.fast_first)
|
||||
log.info("[tune] %s", TUNE)
|
||||
return TUNE
|
||||
|
||||
@app.post("/tts")
|
||||
def tts(req: Req):
|
||||
if not _ready():
|
||||
return JSONResponse({"error": "loading"}, status_code=503)
|
||||
t0 = time.time(); sr = STATE["sr"]
|
||||
parts = list(_gen_sentences_ordered(_split_sentences(fix_acronyms(normalize_numbers(_fix_umlauts(req.text))), min_len=TUNE["min_len"])))
|
||||
a = np.concatenate(parts) if parts else np.zeros(0, np.float32)
|
||||
buf = io.BytesIO(); sf.write(buf, a, sr, format="WAV", subtype="PCM_16"); buf.seek(0)
|
||||
dur = a.size / sr; gen = time.time() - t0
|
||||
log.info("/tts %dZ audio=%.1fs gen=%.1fs rtf=%.2f", len(req.text), dur, gen, gen / max(dur, 0.01))
|
||||
return Response(buf.read(), media_type="audio/wav",
|
||||
headers={"X-Audio-Seconds": f"{dur:.2f}", "X-Gen-Seconds": f"{gen:.2f}"})
|
||||
|
||||
def _voiced_f0(a: np.ndarray, sr: int) -> float:
|
||||
"""Schnelle Grundfrequenz-Schätzung auf dem längsten stimmhaften Segment (max 0.6s) via yin.
|
||||
Für das Stimm-Kollaps-Gate: Klon ~220Hz, männlicher Default ~100Hz."""
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1)
|
||||
iv = librosa.effects.split(a, top_db=35)
|
||||
if not len(iv):
|
||||
return float("nan")
|
||||
s, e = max(iv, key=lambda x: x[1] - x[0])
|
||||
seg = a[s:min(e, s + int(0.6 * sr))]
|
||||
if seg.size < int(0.1 * sr):
|
||||
return float("nan")
|
||||
fv = librosa.yin(seg, fmin=80, fmax=400, sr=sr, frame_length=1024)
|
||||
return float(np.median(fv)) if fv.size else float("nan")
|
||||
|
||||
def _fingerprint(a: np.ndarray, sr: int) -> np.ndarray:
|
||||
"""Stimm-Fingerabdruck (MFCC mean+std über stimmhafte Frames, normiert). Für den Drift-Wächter:
|
||||
erkennt JEDEN Stimm-Wechsel (auch weiblich->andere weiblich, das der F0-Wächter durchließ)."""
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1)
|
||||
iv = librosa.effects.split(a, top_db=30)
|
||||
if len(iv):
|
||||
a = np.concatenate([a[s:e] for s, e in iv])
|
||||
if a.size < int(0.2 * sr):
|
||||
return None
|
||||
m = librosa.feature.mfcc(y=a, sr=sr, n_mfcc=20)[1:] # MFCC0 (Energie) weglassen -> amplituden-invariant
|
||||
v = np.concatenate([m.mean(1), m.std(1)])
|
||||
return (v / (np.linalg.norm(v) + 1e-9)).astype(np.float32)
|
||||
|
||||
def _gen_gated_core(st: dict, text: str) -> np.ndarray:
|
||||
"""Einen Satz erzeugen mit DOPPEL-Wächter (best-of-N): männlicher Kollaps (F0<Floor) UND Stimm-Drift
|
||||
(Fingerabdruck-Ähnlichkeit zur Referenz < Floor) -> neu generieren; am Ende den ref-ähnlichsten,
|
||||
nicht-männlichen Kandidaten behalten. `st` = Zustand (STATE im Hauptprozess, _W im Worker)."""
|
||||
best_a, best_score = None, -2.0
|
||||
m, vs, sr, ref_fp = st["m"], st["vs"], st["sr"], st.get("ref_fp")
|
||||
for attempt in range(MAX_TRIES):
|
||||
audio = m.generate_audio(vs, LEAD + text, frames_after_eos=FEOS)
|
||||
a = audio.numpy() if hasattr(audio, "numpy") else np.asarray(audio)
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1)
|
||||
f0 = _voiced_f0(a, sr)
|
||||
male = f0 == f0 and f0 < F0_FLOOR
|
||||
cropped = _crop_lead(a, sr) # ohne Lead -> vergleichbar mit Referenz
|
||||
# B3: FP-Drift nur bei genug stimmhafter Dauer prüfen (kurze Audios -> Fingerabdruck unzuverlässig -> Fehlalarm)
|
||||
iv = librosa.effects.split(cropped, top_db=30)
|
||||
voiced_s = (sum(int(e - s) for s, e in iv) / sr) if len(iv) else 0.0
|
||||
fp = _fingerprint(cropped, sr) if voiced_s >= FP_MIN_S else None
|
||||
sim = float(np.dot(ref_fp, fp)) if (ref_fp is not None and fp is not None) else 1.0
|
||||
score = sim - (1.0 if male else 0.0) # männlich hart abstrafen
|
||||
if score > best_score:
|
||||
best_score, best_a = score, a
|
||||
if not male and sim >= FP_FLOOR: # gut genug -> stop
|
||||
break
|
||||
log.warning("Stimm-Anomalie (F0=%.0f male=%s sim=%.3f<%.2f) -> regeneriere (try %d)",
|
||||
f0, male, sim, FP_FLOOR, attempt + 1)
|
||||
return best_a
|
||||
|
||||
def _gen_gated(text: str) -> np.ndarray:
|
||||
"""Serieller Hauptprozess-Pfad (nutzt STATE). Wird auch von make_samples.py verwendet."""
|
||||
return _gen_gated_core(STATE, text)
|
||||
|
||||
# --- B2: persistenter Worker-Pool (je Prozess eigene Modellinstanz + Voice-State aus A1-Cache) -----
|
||||
_W: dict = {} # Per-Prozess-Zustand des Workers
|
||||
|
||||
def _worker_init():
|
||||
"""Einmal pro Worker-Prozess: Torch-Threads pinnen (gegen Oversubscription), Modell laden,
|
||||
Voice-State aus dem safetensors-Cache (A1) ziehen (Fallback: live klonen)."""
|
||||
try:
|
||||
import torch
|
||||
torch.set_num_threads(int(os.environ.get("LUCY_WORKER_THREADS", "2")))
|
||||
except Exception:
|
||||
pass
|
||||
m = TTSModel.load_model(language=LANG, lsd_decode_steps=LSD, temp=TEMP,
|
||||
noise_clamp=NOISE_CLAMP, quantize=QUANTIZE)
|
||||
try:
|
||||
vs = m.get_state_for_audio_prompt(VOICE_ST)
|
||||
except Exception:
|
||||
vs = m.get_state_for_audio_prompt(_prep_ref())
|
||||
ref_audio, _ = librosa.load(os.path.join(BASE, "ref.wav"), sr=m.sample_rate, mono=True)
|
||||
_W.update(m=m, vs=vs, sr=m.sample_rate, ref_fp=_fingerprint(ref_audio, m.sample_rate))
|
||||
|
||||
def _worker_gen(text: str) -> np.ndarray:
|
||||
"""Im Worker: Satz mit Doppel-Wächter erzeugen + cleanup. Rückgabe = fertiges float32-PCM (picklebar)."""
|
||||
return np.asarray(cleanup(_gen_gated_core(_W, text), _W["sr"]), dtype=np.float32)
|
||||
|
||||
def _warmup(_):
|
||||
if "m" not in _W:
|
||||
_worker_init()
|
||||
return _W["sr"]
|
||||
|
||||
_SENT_RX = re.compile(r".+?(?:[.!?…]+(?:\s|$)|$)", re.S)
|
||||
|
||||
def _split_sentences(text: str, min_len: int = 55, keep_first_short: bool = False) -> list[str]:
|
||||
"""Text in Sätze zerlegen und sehr kurze Teile bündeln. Für satzweise Generierung +
|
||||
F0-Gate pro Satz -> ein Kollaps in der Mitte langer Antworten erreicht den Nutzer NIE.
|
||||
keep_first_short=True: der ERSTE Teil bleibt eigenständig (auch wenn kurz) -> schnellste TTFB
|
||||
im Stream (Lucy fängt früher an zu sprechen), Rest wird normal gebündelt."""
|
||||
parts = [m.group(0).strip() for m in _SENT_RX.finditer(text.strip())]
|
||||
out: list[str] = []
|
||||
for p in parts:
|
||||
if not p:
|
||||
continue
|
||||
if not out:
|
||||
out.append(p) # erster Teil
|
||||
elif keep_first_short and len(out) == 1:
|
||||
out.append(p) # zweiter Teil startet frisch -> Index 0 bleibt kurz
|
||||
elif len(out[-1]) < min_len:
|
||||
out[-1] = f"{out[-1]} {p}"
|
||||
else:
|
||||
out.append(p)
|
||||
return out or [text.strip()]
|
||||
|
||||
def _to_pcm16(a: np.ndarray, gain: float) -> bytes:
|
||||
"""float -> 16-bit-PCM (LE), mit Gain + Sicherheits-Clip."""
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1) * gain
|
||||
np.clip(a, -0.95, 0.95, out=a)
|
||||
return (a * 32767.0).astype("<i2").tobytes()
|
||||
|
||||
@app.post("/tts/stream")
|
||||
def tts_stream(req: Req):
|
||||
"""Streamt rohes PCM16-mono (sr via Header X-Sample-Rate) für niedrige Time-to-first-audio.
|
||||
SATZWEISE Generierung mit F0-Gate PRO Satz: jeder Satz wird voll erzeugt, auf Stimm-Kollaps
|
||||
(männlich/F0<Floor) geprüft und bei Bedarf neu generiert, BEVOR er emittiert wird. So erreicht
|
||||
kein kollabiertes Audio den Nutzer — auch nicht mitten in langen Antworten (war die 'gruselige'
|
||||
Schwäche, da pocket lange Texte intern chunkt und einzelne Chunks kippen können).
|
||||
Jeder Satz läuft durch die bewährte cleanup()-Pipeline (Lead-Crop, Tail-Blip, RMS-Norm, Pads).
|
||||
B2: Mit Worker-Pool laufen die Sätze PARALLEL, werden aber in Reihenfolge emittiert (TTFB =
|
||||
erster Satz, restliche rechnen schon nebenher)."""
|
||||
if not _ready():
|
||||
return JSONResponse({"error": "loading"}, status_code=503)
|
||||
sr = STATE["sr"]
|
||||
sentences = _split_sentences(normalize_numbers(_fix_umlauts(req.text)), min_len=TUNE["min_len"], keep_first_short=TUNE["fast_first"]) # +Umlaut +Zahlen, tunebar
|
||||
|
||||
def pcm_stream():
|
||||
t0 = time.time(); total = 0; first = True
|
||||
for a in _gen_sentences_ordered(sentences): # parallel (Pool) bzw. seriell, immer in Reihenfolge
|
||||
total += a.size
|
||||
if first:
|
||||
log.info("/tts/stream TTFB=%.2fs (%d Sätze, workers=%d)", time.time() - t0, len(sentences), WORKERS)
|
||||
first = False
|
||||
yield _to_pcm16(a, 1.0) # cleanup hat schon auf TARGET_RMS normalisiert
|
||||
log.info("/tts/stream %dZ audio=%.1fs gen=%.1fs", len(req.text), total / sr, time.time() - t0)
|
||||
|
||||
return StreamingResponse(pcm_stream(), media_type="application/octet-stream",
|
||||
headers={"X-Sample-Rate": str(sr)})
|
||||
Reference in New Issue
Block a user