gitre / app.py
kjbytes's picture
feat: add thread lock to prevent concurrent model corruption
187d5d8
Raw
History Blame Contribute Delete
4.3 kB
import os
import re
import secrets
import threading
import uuid
from fastapi import Depends, FastAPI, HTTPException, Header, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
# strip(): pasted secrets often carry a trailing newline.
API_KEY = os.environ["API_KEY"].strip()
OUT_DIR = os.environ.get("OUT_DIR", "/tmp/audio")
os.makedirs(OUT_DIR, exist_ok=True)
app = FastAPI(title="Chatterbox TTS")
_model = None
# ponytail: global lock. chatterbox keeps per-generation state on the shared model
# (alignment_stream_analyzer), so concurrent generate() calls corrupt each other.
# Sync endpoints run in a threadpool, so "one worker" is not one caller.
# Upgrade path: a real request queue / model pool if throughput matters.
_gen_lock = threading.Lock()
def auth(x_api_key: str = Header(None)):
if not x_api_key or not secrets.compare_digest(x_api_key.strip(), API_KEY):
raise HTTPException(401, "bad api key")
def model():
# ponytail: lazy singleton so the Space boots fast and healthchecks pass.
# Worst case two requests both build it and one wins; generate() is serialized
# by _gen_lock anyway.
global _model
if _model is None:
import functools
import torch
# chatterbox's from_local() loads s3gen.pt without map_location, so the
# CUDA-saved tensors blow up on CPU. Default every torch.load to CPU.
torch.load = functools.partial(torch.load, map_location="cpu")
from chatterbox.mtl_tts import ChatterboxMultilingualTTS
_model = ChatterboxMultilingualTTS.from_pretrained(device="cpu")
return _model
class TTSRequest(BaseModel):
# NOTE: upstream generate() hardcodes max_new_tokens=1000 at S3_TOKEN_RATE=25, so
# audio past ~40s (roughly 600-700 chars) is silently truncated, not errored.
text: str = Field(min_length=1, max_length=1000)
lang: str = Field("en", min_length=2, max_length=5)
exaggeration: float = Field(0.5, ge=0.0, le=2.0)
cfg_weight: float = Field(0.5, ge=0.0, le=1.0)
@app.post("/tts", dependencies=[Depends(auth)])
def tts(req: TTSRequest, request: Request):
import torchaudio # ponytail: lazy so the module imports without torch installed
m = model()
lang = req.lang.lower()
if lang not in m.get_supported_languages():
raise HTTPException(400, f"unsupported lang: {lang}")
with _gen_lock:
wav = m.generate(
req.text,
language_id=lang,
exaggeration=req.exaggeration,
cfg_weight=req.cfg_weight,
)
stem = uuid.uuid4().hex
torchaudio.save(os.path.join(OUT_DIR, stem + ".wav"), wav, m.sr)
with open(os.path.join(OUT_DIR, stem + ".srt"), "w") as f:
f.write(srt(req.text, wav.shape[-1] / m.sr))
base = str(request.base_url)
return {
"url": base + f"audio/{stem}.wav",
"srt_url": base + f"audio/{stem}.srt",
"lang": lang,
}
def _ts(t):
ms = round(t * 1000)
return f"{ms // 3600000:02d}:{ms // 60000 % 60:02d}:{ms // 1000 % 60:02d},{ms % 1000:03d}"
def srt(text, duration):
# ponytail: chatterbox gives no word timings, so split on sentence enders and
# give each cue a slice of the duration proportional to its length. Swap in
# forced alignment (whisperx/aeneas) if the drift ever matters.
parts = [s.strip() for s in re.split(r"(?<=[.!?。!?])\s+", text) if s.strip()]
total = sum(len(p) for p in parts) or 1
out, t = [], 0.0
for i, p in enumerate(parts, 1):
end = t + duration * len(p) / total
out.append(f"{i}\n{_ts(t)} --> {_ts(end)}\n{p}\n")
t = end
return "\n".join(out)
@app.get("/audio/{name}")
def download(name: str):
# ponytail: uuid names only; reject anything else instead of path-joining it.
if len(name) != 36 or name[32:] not in (".wav", ".srt") or not name[:32].isalnum():
raise HTTPException(400, "bad name")
path = os.path.join(OUT_DIR, name)
if not os.path.isfile(path):
raise HTTPException(404, "unknown file")
media = "audio/wav" if name.endswith(".wav") else "application/x-subrip"
return FileResponse(path, media_type=media, filename=name)
@app.get("/")
def health():
return {"ok": True, "loaded": _model is not None}