engines: import f5-tts + kokoro + tortoise sidecars into the tree

The python FastAPI sidecars have lived ad-hoc at /srv/appdata/
<engine>/build/ on the host without version control. Bringing them into
the skald repo so the engine code travels with the cross-engine
routing it depends on.

This commit lands the VANILLA version of each engine on main:

  engines/f5-tts/    SWivid F5-TTS (CC-BY-NC weights flagged)
  engines/kokoro/    hexgrad Kokoro-82M (Apache 2.0 top to bottom)
  engines/tortoise/  neonbjb Tortoise-TTS (Apache 2.0 top to bottom)

Engine-specific kludges (question doubling, GPU coordination,
pause-duration tuning) get layered on engine/* branches per the
README. Main stays the safe-to-read baseline.
This commit is contained in:
Sulkta 2026-05-14 09:40:01 -07:00
parent d2ec84af21
commit 01ec9ffd0e
10 changed files with 1115 additions and 0 deletions

41
engines/f5-tts/Dockerfile Normal file
View file

@ -0,0 +1,41 @@
# Sulkta build of F5-TTS — upstream ghcr.io/swivid/f5-tts:main was
# shipped with torch 2.11/torchaudio 2.4 ABI mismatch on 2026-05-13,
# breaking import torchaudio at boot. We rebuild on a known-good
# pytorch base + pip install f5-tts.
#
# Image tag in localhost-registry: registry.example.local:5000/f5-tts:<ver>
#
# License: Apache 2.0 (code) / CC-BY-NC (Emilia-trained weights).
# Personal use OK; redistribution gray-area — flagged.
FROM pytorch/pytorch:2.6.0-cuda12.4-cudnn9-runtime
ENV DEBIAN_FRONTEND=noninteractive \
PYTHONUNBUFFERED=1 \
HF_HOME=/cache/hf \
HF_HUB_DISABLE_TELEMETRY=1
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
git \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
# Base torch 2.6.0 + torchaudio 2.6.0; f5-tts pulls a recent
# transformers (5.x) which needs torch >=2.5's modern
# torch.library.custom_op type signatures.
RUN pip install --no-cache-dir 'f5-tts>=1.0.0'
# Pre-warm the HF cache directory.
RUN mkdir -p /cache/hf /audio /voices
COPY f5_server.py /app/f5_server.py
WORKDIR /app
EXPOSE 7860
# Skald talks to our purpose-built FastAPI server, not Gradio.
# Models load at startup (first request would otherwise pay the
# cold-start cost). uvicorn on :7860 to keep the port stable.
CMD ["uvicorn", "f5_server:app", "--host", "0.0.0.0", "--port", "7860"]

View file

@ -0,0 +1,43 @@
# F5-TTS standalone stack on the host.
#
# License posture (acknowledged 2026-05-13): code is Apache 2.0, but
# the pretrained model weights are CC-BY-NC (Emilia training data).
# Personal listen is fine; public sharing is a flagged gray area.
# Sulkta's call: ship anyway.
#
# Runtime: 8GB GPU is plenty (F5 inference ~4-6GB peak).
#
# First-run cost: ~2GB model download from HuggingFace into hf-cache,
# happens on first inference request. Subsequent runs are warm.
name: f5-tts
services:
f5-tts:
image: registry.example.local:5000/f5-tts:0.3
container_name: f5-tts
restart: unless-stopped
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
ports:
- "127.0.0.1:7792:7860"
- "127.0.0.1:7792:7860"
volumes:
# HF model weights cache — persists ~2GB after first download.
- /srv/appdata/f5-tts/hf-cache:/cache/hf
# Reference voice clips (lj_speech.wav, etc).
- /srv/appdata/f5-tts/voices:/voices:ro
# Rendered audio output — skald writes story narrations here.
- /srv/appdata/f5-tts/audio:/audio
environment:
HF_HOME: /cache/hf
HF_HUB_DISABLE_TELEMETRY: "1"
labels:
org.sulkta.domain: "sulkta"
org.sulkta.owner: "Sulkta"
org.sulkta.managed-by: "compose"
org.sulkta.role: "f5-tts"

184
engines/f5-tts/server.py Normal file
View file

@ -0,0 +1,184 @@
"""Thin FastAPI server inside the F5-TTS container.
Loads model + vocoder ONCE at startup (heavy: ~5s, ~5GB VRAM).
POST /synthesize runs inference and writes the WAV to a shared
volume; the response is JSON with the output path and metadata
not the WAV bytes, since chapter-length renders are 20-30MB and
both skald and the f5 container share /audio anyway.
Why not Gradio's API: Gradio's /gradio_api/call/* shape is event-
stream + polling; this is a single POST + immediate response.
Right for skald's "render one chapter, then move on" loop.
"""
import logging
import time
import uuid
from pathlib import Path
import soundfile as sf
import torch
from fastapi import FastAPI, HTTPException
from omegaconf import OmegaConf
from pydantic import BaseModel, Field
from cached_path import cached_path
from importlib.resources import files
from hydra.utils import get_class
from f5_tts.infer.utils_infer import (
infer_process,
load_model,
load_vocoder,
preprocess_ref_audio_text,
)
log = logging.getLogger("f5-server")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
# ─── model state ─────────────────────────────────────────────────
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
MODEL_NAME = "F5TTS_v1_Base"
VOCODER_NAME = "vocos"
AUDIO_ROOT = Path("/audio")
VOICES_ROOT = Path("/voices")
_model = None
_vocoder = None
def _load_models() -> None:
"""One-time model + vocoder load. ~5-8s wall-clock on first call."""
global _model, _vocoder
if _model is not None:
return
log.info("loading vocoder=%s device=%s", VOCODER_NAME, DEVICE)
_vocoder = load_vocoder(vocoder_name=VOCODER_NAME, device=DEVICE)
cfg_path = str(files("f5_tts").joinpath(f"configs/{MODEL_NAME}.yaml"))
model_cfg = OmegaConf.load(cfg_path)
model_cls = get_class(f"f5_tts.model.{model_cfg.model.backbone}")
model_arc = model_cfg.model.arch
# F5TTS_v1_Base ships as a HuggingFace artifact; cached_path
# handles the resolution + downloads to HF_HOME.
ckpt_file = str(
cached_path("hf://SWivid/F5-TTS/F5TTS_v1_Base/model_1250000.safetensors")
)
vocab_file = str(files("f5_tts").joinpath("infer/examples/vocab.txt"))
log.info("loading model=%s ckpt=%s", MODEL_NAME, ckpt_file)
_model = load_model(
model_cls, model_arc, ckpt_file,
mel_spec_type=VOCODER_NAME, vocab_file=vocab_file, device=DEVICE,
)
log.info("model + vocoder loaded; ready")
# ─── FastAPI app ─────────────────────────────────────────────────
class SynthesizeRequest(BaseModel):
# The text we want to synthesize. Long-form OK — F5-TTS chunks
# internally via infer_process.
gen_text: str = Field(min_length=1)
# Reference audio path (inside the f5-tts container). Defaults
# to the staged lj_speech clip.
ref_audio_path: str = "/voices/lj_speech.wav"
# Reference transcript. Defaults to the bundled lj_speech.txt.
ref_text: str | None = None
# Output filename, relative to /audio (the shared output dir).
# If omitted, a UUID-based name is assigned.
output_filename: str | None = None
# Speech speed (0.5-2.0). Default 1.0 = natural pace.
speed: float = Field(default=1.0, ge=0.3, le=2.0)
# Cross-fade between chunks; F5 default is 0.15s. Bigger smooths
# chunk boundaries on long-form prose at the cost of pacing.
cross_fade_duration: float = Field(default=0.15, ge=0.0, le=1.0)
class SynthesizeResponse(BaseModel):
ok: bool
output_path: str
sample_rate_hz: int
duration_seconds: float
elapsed_ms: int
chars_in: int
app = FastAPI(title="f5-tts-server", version="0.1.0")
@app.on_event("startup")
def _startup() -> None:
_load_models()
@app.get("/healthz")
def healthz() -> dict:
return {
"ok": True,
"device": DEVICE,
"model": MODEL_NAME,
"vocoder": VOCODER_NAME,
"loaded": _model is not None,
}
@app.post("/synthesize", response_model=SynthesizeResponse)
def synthesize(req: SynthesizeRequest) -> SynthesizeResponse:
if _model is None:
raise HTTPException(503, "model not loaded yet — retry shortly")
ref_audio_path = Path(req.ref_audio_path)
if not ref_audio_path.is_file():
raise HTTPException(400, f"ref_audio_path not found: {ref_audio_path}")
# If no explicit ref_text, try sidecar .txt then fall back to ""
# (which triggers F5's auto-ASR).
ref_text = req.ref_text
if ref_text is None:
sidecar = ref_audio_path.with_suffix(".txt")
if sidecar.is_file():
ref_text = sidecar.read_text().strip()
else:
ref_text = ""
output_filename = req.output_filename or f"{uuid.uuid4().hex}.wav"
if "/" in output_filename or ".." in output_filename:
raise HTTPException(400, "output_filename must be a bare name, no path parts")
output_path = AUDIO_ROOT / output_filename
output_path.parent.mkdir(parents=True, exist_ok=True)
started = time.monotonic()
ref_audio_processed, ref_text_processed = preprocess_ref_audio_text(
str(ref_audio_path), ref_text
)
audio_segment, final_sample_rate, _ = infer_process(
ref_audio_processed,
ref_text_processed,
req.gen_text,
_model,
_vocoder,
mel_spec_type=VOCODER_NAME,
speed=req.speed,
cross_fade_duration=req.cross_fade_duration,
device=DEVICE,
)
elapsed_ms = int((time.monotonic() - started) * 1000)
sf.write(str(output_path), audio_segment, final_sample_rate, subtype="PCM_16")
duration_s = float(len(audio_segment)) / float(final_sample_rate)
log.info(
"synthesized chars=%d -> %s (sr=%d, dur=%.2fs, elapsed=%dms)",
len(req.gen_text), output_path, final_sample_rate, duration_s, elapsed_ms,
)
return SynthesizeResponse(
ok=True,
output_path=str(output_path),
sample_rate_hz=final_sample_rate,
duration_seconds=duration_s,
elapsed_ms=elapsed_ms,
chars_in=len(req.gen_text),
)