Files
mission-control-v2/client/lucy-tts/pocket_server.py
T
Hitonabi 09a1c98514 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>
2026-07-02 10:29:33 +02:00

472 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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 68s 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)})