09a1c98514
- 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>
472 lines
26 KiB
Python
472 lines
26 KiB
Python
# -*- 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)})
|