stacks/index-tts: own FastAPI wrapper for IndexTTS-2 + deploy playbook
Adds a third TTS to the irv-ml1 fleet. IndexTTS-2 is Bilibili's
emotion-controllable zero-shot TTS (paper 2506.21619). Distinguishing
capability vs the existing two: timbre and emotion are disentangled —
clone a voice's timbre from one reference and the emotion from a
different reference, OR set emotion via 8-vector, OR derive it from a
text description. Neither CosyVoice 3 nor Qwen3-TTS-1.7B-Base does
this cleanly in English.
Wrapper is owned end-to-end (~150 lines in app.py) — the only existing
FastAPI fork (csllpr/index-tts-fastapi) targets v1 and is a dormant
single-commit repo. Upstream IndexTTS-2 ships only a Gradio webui.
Layout follows the qwen3-tts pattern:
stacks/index-tts/
Dockerfile — CUDA 12.8 base, IndexTTS pinned to a SHA
app.py — FastAPI: POST /v1/audio/speech + /v1/voices
entrypoint.sh — one-time HF snapshot_download of the weights
compose.yaml — env-driven, GPU pinning support, bind mounts
.env.example — port 8192, fp16, paths
README.md — API examples + comparison vs the other TTS
playbooks/deploy-index-tts.yaml — elway playbook for irv-ml1
Voice and emotion libraries are flat host dirs of WAVs, bind-mounted.
Drop a new <name>.wav and /v1/voices picks it up immediately.
License caveat: IndexTTS-2 weights ship under a custom Bilibili
license (free at our scale, not OSI-open). README documents it.
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
# Deploy IndexTTS-2 (https://github.com/index-tts/index-tts) to irv-ml1
|
||||
# behind our own FastAPI wrapper (stacks/index-tts/app.py).
|
||||
#
|
||||
# Builds the image locally from the Dockerfile in stacks/index-tts/
|
||||
# (which clones the upstream IndexTTS-2 repo at a pinned SHA inside
|
||||
# the build), stages app.py + entrypoint.sh + compose + .env under
|
||||
# /opt/docker/compose/index-tts/, brings it up, waits for /healthz,
|
||||
# and verifies the API surface.
|
||||
#
|
||||
# First run is slow: ~5-10 min for the docker build (CUDA torch + the
|
||||
# IndexTTS pinned-deps tail) plus ~5-7 GB model download from HF on
|
||||
# first container start (entrypoint.sh handles that). The healthz wait
|
||||
# below allows up to 15 min total.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/elway irv-ml1 --playbook playbooks/deploy-index-tts.yaml
|
||||
#
|
||||
# Idempotent — every step is creates-/when-gated; rerun is safe.
|
||||
|
||||
vars:
|
||||
compose_dir: /opt/docker/compose/index-tts
|
||||
cache_dir: /worktank/index-tts/cache
|
||||
voices_dir: /worktank/index-tts/voices
|
||||
emotions_dir: /worktank/index-tts/emotions
|
||||
host_port: "8192"
|
||||
|
||||
steps:
|
||||
# ── host-side dirs ──────────────────────────────────────────────────
|
||||
|
||||
- name: Ensure /worktank/index-tts root exists (one-time, sudo)
|
||||
shell: mkdir -p /worktank/index-tts
|
||||
sudo: true
|
||||
creates: /worktank/index-tts
|
||||
|
||||
- name: Chown /worktank/index-tts to lkraven
|
||||
shell: chown lkraven:lkraven /worktank/index-tts
|
||||
sudo: true
|
||||
when: '[ "$(stat -c %U /worktank/index-tts)" != lkraven ]'
|
||||
|
||||
- name: Ensure cache dir exists
|
||||
shell: mkdir -p {{ cache_dir }}
|
||||
creates: "{{ cache_dir }}"
|
||||
|
||||
- name: Ensure voices dir exists
|
||||
shell: mkdir -p {{ voices_dir }}
|
||||
creates: "{{ voices_dir }}"
|
||||
|
||||
- name: Ensure emotions dir exists
|
||||
shell: mkdir -p {{ emotions_dir }}
|
||||
creates: "{{ emotions_dir }}"
|
||||
|
||||
- name: Ensure compose dir exists
|
||||
shell: mkdir -p {{ compose_dir }}
|
||||
creates: "{{ compose_dir }}"
|
||||
|
||||
# ── deploy build context (compose, env, dockerfile, app, entrypoint) ──
|
||||
|
||||
- name: Upload compose.yaml
|
||||
upload:
|
||||
src: stacks/index-tts/compose.yaml
|
||||
dest: "{{ compose_dir }}/compose.yaml"
|
||||
mode: "0644"
|
||||
|
||||
- name: Upload Dockerfile
|
||||
upload:
|
||||
src: stacks/index-tts/Dockerfile
|
||||
dest: "{{ compose_dir }}/Dockerfile"
|
||||
mode: "0644"
|
||||
|
||||
- name: Upload app.py
|
||||
upload:
|
||||
src: stacks/index-tts/app.py
|
||||
dest: "{{ compose_dir }}/app.py"
|
||||
mode: "0644"
|
||||
|
||||
- name: Upload entrypoint.sh
|
||||
upload:
|
||||
src: stacks/index-tts/entrypoint.sh
|
||||
dest: "{{ compose_dir }}/entrypoint.sh"
|
||||
mode: "0755"
|
||||
|
||||
- name: Seed .env from template (only if absent)
|
||||
upload:
|
||||
src: stacks/index-tts/.env.example
|
||||
dest: "{{ compose_dir }}/.env"
|
||||
mode: "0644"
|
||||
when: "[ ! -f {{ compose_dir }}/.env ]"
|
||||
|
||||
# ── build + bring up ────────────────────────────────────────────────
|
||||
|
||||
- name: docker compose build (~5-10 min first time; cached after)
|
||||
shell: cd {{ compose_dir }} && docker compose build
|
||||
|
||||
- name: docker compose up -d
|
||||
shell: cd {{ compose_dir }} && docker compose up -d
|
||||
|
||||
- name: Wait for /healthz to respond (allow ~15 min for model download)
|
||||
shell: |
|
||||
for i in $(seq 1 180); do
|
||||
curl -sf -o /dev/null --max-time 3 http://localhost:{{ host_port }}/healthz && exit 0
|
||||
sleep 5
|
||||
done
|
||||
exit 1
|
||||
changed_when: "false"
|
||||
|
||||
verify:
|
||||
- name: /healthz returns 200
|
||||
shell: curl -sf -o /dev/null http://localhost:{{ host_port }}/healthz
|
||||
changed_when: "false"
|
||||
|
||||
- name: /v1/voices returns a JSON object with 'voices' and 'emotions' keys
|
||||
shell: curl -sf http://localhost:{{ host_port }}/v1/voices | grep -q '"voices"'
|
||||
changed_when: "false"
|
||||
|
||||
- name: Container is running
|
||||
shell: docker inspect index-tts --format '{{.State.Status}}' | grep -q running
|
||||
changed_when: "false"
|
||||
@@ -0,0 +1,53 @@
|
||||
# IndexTTS-2 stack tunables. Copy to `.env` on irv-ml1 before deploying.
|
||||
|
||||
# ── build pin ────────────────────────────────────────────────────────
|
||||
# SHA of index-tts/index-tts to build from. Bump + rebuild when you want
|
||||
# upstream wrapper updates.
|
||||
INDEX_TTS_SHA=830f6f8f94a51fea23ab1d639027a86200075a4e
|
||||
|
||||
# Local image tag — bump when you change build context (Dockerfile,
|
||||
# app.py, entrypoint.sh) to force a fresh layer build.
|
||||
INDEX_TTS_TAG=v1
|
||||
|
||||
# ── network ──────────────────────────────────────────────────────────
|
||||
# Host port. Container listens on 8000 internally.
|
||||
# Reserved on irv-ml1: 8188 ComfyUI, 8190 CosyVoice, 8191 Qwen3-TTS,
|
||||
# 8765 Parakeet. 8192 is open.
|
||||
INDEX_TTS_PORT=8192
|
||||
|
||||
# Bind address. 0.0.0.0 exposes on all interfaces (incl. WG tunnel
|
||||
# interface 10.100.79.3); 127.0.0.1 restricts to local-only.
|
||||
INDEX_TTS_BIND=0.0.0.0
|
||||
|
||||
# ── runtime / GPU ────────────────────────────────────────────────────
|
||||
# bf16/fp16 vs fp32. Empty = fp32. "1" = fp16. IndexTTS-2 README
|
||||
# recommends fp16; ~6-10 GB VRAM at fp16, double at fp32.
|
||||
INDEX_TTS_FP16=1
|
||||
|
||||
# GPU pinning. Empty = let IndexTTS auto-pick (cuda:0). Set to "cuda:1"
|
||||
# to pin to the second GPU (irv-ml1's RTX 3090 vs RTX A6000).
|
||||
INDEX_TTS_DEVICE=
|
||||
|
||||
# Devices visible inside the container. Either "all" (both GPUs) or a
|
||||
# comma-separated list of indices (e.g. "1" to expose only the second
|
||||
# card). The DEVICE setting above further restricts within those.
|
||||
INDEX_TTS_GPU_DEVICES=all
|
||||
|
||||
# Logging level for the wrapper itself (IndexTTS internals are noisier
|
||||
# regardless).
|
||||
INDEX_TTS_LOG_LEVEL=INFO
|
||||
|
||||
# ── persistent storage on the host ───────────────────────────────────
|
||||
# Model weights (~5-7 GB after first run). Bind-mounted so model state
|
||||
# survives container recreate. Excluded from restic (regenerable from HF).
|
||||
INDEX_TTS_CACHE_DIR=/worktank/index-tts/cache
|
||||
|
||||
# Voice library — flat dir of <name>.wav files (timbre references).
|
||||
# Cloned voices need the original reference audio to recreate; included
|
||||
# in restic.
|
||||
INDEX_TTS_VOICES_DIR=/worktank/index-tts/voices
|
||||
|
||||
# Emotion library — flat dir of <name>.wav files (emotion references,
|
||||
# typically short clips with strong affect). Optional — without any
|
||||
# entries here you can still use emotion_vector or emotion_text.
|
||||
INDEX_TTS_EMOTIONS_DIR=/worktank/index-tts/emotions
|
||||
@@ -0,0 +1,57 @@
|
||||
# syntax=docker/dockerfile:1.6
|
||||
#
|
||||
# IndexTTS-2 served behind our own thin FastAPI wrapper (~150 lines in
|
||||
# app.py). Upstream ships only a Gradio webui; the existing csllpr
|
||||
# FastAPI fork is for v1 and dormant. We own the wrapper end-to-end.
|
||||
|
||||
ARG CUDA_BASE=nvidia/cuda:12.8.0-cudnn-runtime-ubuntu22.04
|
||||
FROM ${CUDA_BASE}
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
PIP_ROOT_USER_ACTION=ignore \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PATH="/opt/venv/bin:${PATH}"
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
python3.10 python3.10-venv python3-pip \
|
||||
git ffmpeg libsndfile1 \
|
||||
ca-certificates wget \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& python3.10 -m venv /opt/venv
|
||||
|
||||
# Pinned IndexTTS-2 SHA — bump in stack .env (INDEX_TTS_SHA build arg)
|
||||
# when you want upstream updates.
|
||||
ARG INDEX_TTS_SHA=830f6f8f94a51fea23ab1d639027a86200075a4e
|
||||
RUN git clone https://github.com/index-tts/index-tts.git /opt/index-tts \
|
||||
&& cd /opt/index-tts && git checkout ${INDEX_TTS_SHA}
|
||||
|
||||
# CUDA 12.8 torch wheels (per IndexTTS pyproject.toml's tool.uv.index
|
||||
# pin — same versions, same source).
|
||||
RUN pip install --no-cache-dir \
|
||||
torch==2.8.0 torchaudio==2.8.0 \
|
||||
--index-url https://download.pytorch.org/whl/cu128
|
||||
|
||||
# IndexTTS package + its long pinned-deps tail (numpy 1.26.2, transformers
|
||||
# 4.52.1, sentencepiece, librosa, descript-audiotools, jieba/g2p-en/cn2an,
|
||||
# WeTextProcessing, modelscope, accelerate, safetensors, etc.).
|
||||
# BigVGAN is vendored under indextts/ so it resolves without a pip dep.
|
||||
RUN cd /opt/index-tts && pip install --no-cache-dir -e .
|
||||
|
||||
# Wrapper deps (FastAPI stack + soundfile for in-memory WAV encoding).
|
||||
RUN pip install --no-cache-dir \
|
||||
fastapi 'uvicorn[standard]' python-multipart soundfile
|
||||
|
||||
WORKDIR /app
|
||||
COPY app.py /app/app.py
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# wget is in the base image (added above); curl is not.
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=600s --retries=3 \
|
||||
CMD wget -q -O /dev/null http://127.0.0.1:8000/healthz || exit 1
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,134 @@
|
||||
# IndexTTS-2
|
||||
|
||||
Bilibili's emotion-controllable zero-shot TTS
|
||||
([paper](https://arxiv.org/abs/2506.21619),
|
||||
[code](https://github.com/index-tts/index-tts),
|
||||
[weights](https://huggingface.co/IndexTeam/IndexTTS-2)) served behind
|
||||
our own thin FastAPI wrapper.
|
||||
|
||||
## Why this stack exists alongside the other two TTS
|
||||
|
||||
| | CosyVoice 3 | Qwen3-TTS-1.7B-Base | **IndexTTS-2** |
|
||||
|---|---|---|---|
|
||||
| Voice cloning | ✅ | ✅ (`-Base` variant only) | ✅ |
|
||||
| English quality | medium (Chinese-leaning) | high (English-first) | medium (better than CosyVoice) |
|
||||
| Emotion control | `instruct` mode is Chinese-only | inline tags | **explicit: audio / 8-vector / text** |
|
||||
| Duration control | implicit | implicit | **explicit token-count mode** |
|
||||
| License | Apache 2.0 | Apache 2.0 | custom (Bilibili — free at our scale) |
|
||||
| Wrapper | bare CosyVoice CLI | groxaxo upstream FastAPI | ours, in this dir |
|
||||
|
||||
The differentiator is **disentangled emotion**. IndexTTS-2 lets you
|
||||
clone a voice's timbre from one reference and the emotion from a
|
||||
different reference — or skip emotion-audio entirely and supply an
|
||||
8-vector or a text description. Neither of the other two does this
|
||||
cleanly in English.
|
||||
|
||||
## API
|
||||
|
||||
OpenAI-compat-ish:
|
||||
|
||||
```bash
|
||||
# List available voices + emotions
|
||||
curl http://10.100.79.3:8192/v1/voices
|
||||
|
||||
# Basic synthesis (uses speaker WAV's natural emotion)
|
||||
curl -X POST http://10.100.79.3:8192/v1/audio/speech \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"input": "I have all the time in the world.",
|
||||
"voice": "glados"
|
||||
}' > glados.wav
|
||||
|
||||
# Same speaker, emotion taken from a separate reference WAV
|
||||
curl -X POST http://10.100.79.3:8192/v1/audio/speech \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"input": "I have all the time in the world.",
|
||||
"voice": "glados",
|
||||
"emotion_voice": "menacing",
|
||||
"emotion_alpha": 0.9
|
||||
}' > glados-menacing.wav
|
||||
|
||||
# Same speaker, emotion as 8-vector
|
||||
# Order: happy, angry, sad, afraid, disgusted, melancholic, surprised, calm
|
||||
curl -X POST http://10.100.79.3:8192/v1/audio/speech \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"input": "I have all the time in the world.",
|
||||
"voice": "glados",
|
||||
"emotion_vector": [0, 0.7, 0, 0, 0.2, 0, 0, 0]
|
||||
}' > glados-angry.wav
|
||||
|
||||
# Same speaker, emotion derived from text by bundled QwenEmotion model
|
||||
curl -X POST http://10.100.79.3:8192/v1/audio/speech \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"input": "I have all the time in the world.",
|
||||
"voice": "glados",
|
||||
"emotion_text": "she said with quiet menace"
|
||||
}' > glados-menacing.wav
|
||||
|
||||
# Health
|
||||
curl http://10.100.79.3:8192/healthz
|
||||
```
|
||||
|
||||
Output is always WAV (PCM_16, 22050 Hz — IndexTTS-2's native rate).
|
||||
`response_format` other than `wav` is rejected.
|
||||
|
||||
## Voice library
|
||||
|
||||
Flat dirs on the host (bind-mounted; survives container recreates):
|
||||
|
||||
```
|
||||
/worktank/index-tts/voices/<name>.wav # timbre references
|
||||
/worktank/index-tts/emotions/<name>.wav # emotion references
|
||||
```
|
||||
|
||||
Drop a new WAV in either dir and `/v1/voices` picks it up immediately —
|
||||
no restart. Use clean reference clips, 5-30 s each, single speaker.
|
||||
Cloned voices live under restic; cache (model weights) is excluded.
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
scripts/elway irv-ml1 --playbook playbooks/deploy-index-tts.yaml
|
||||
```
|
||||
|
||||
First build: ~5-10 min for the docker image (CUDA torch + IndexTTS
|
||||
deps), plus ~5-7 GB model download on first container start. Subsequent
|
||||
starts: ~30 s warmup.
|
||||
|
||||
## Switching GPUs
|
||||
|
||||
irv-ml1 has an RTX 3090 (cuda:0) + RTX A6000 (cuda:1). Default is
|
||||
auto-pick (cuda:0). To pin to the A6000 alongside Qwen3-TTS-on-3090:
|
||||
|
||||
```bash
|
||||
ssh irv-ml1 '
|
||||
cd /opt/docker/compose/index-tts
|
||||
sed -i "s|^INDEX_TTS_DEVICE=.*|INDEX_TTS_DEVICE=cuda:1|" .env
|
||||
docker compose up -d
|
||||
'
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **License** — IndexTeam/IndexTTS-2 ships under a custom Bilibili
|
||||
license, not Apache/MIT. Free at our scale (the commercial tier kicks
|
||||
in at 100M MAU / RMB 1B revenue). Restricts using outputs to train
|
||||
other AI models. Read `INDEX_MODEL_LICENSE` in the HF repo before
|
||||
using outputs anywhere external.
|
||||
- **Sample rate** — 22050 Hz is hardcoded upstream. If you need 24 kHz
|
||||
or 48 kHz, resample in the caller.
|
||||
- **Emotion-source precedence** — if multiple emotion controls are
|
||||
specified in one request, the first non-empty one wins in this order:
|
||||
`emotion_voice` > `emotion_vector` > `emotion_text`. The others are
|
||||
silently ignored.
|
||||
- **Model download** — happens in the entrypoint on first start; the
|
||||
config.yaml file in the cache dir is the gate. To force a re-download,
|
||||
delete that file and recreate the container.
|
||||
- **HF cache pinning** — `infer_v2.py` pins `HF_HUB_CACHE` at import
|
||||
time to `./checkpoints/hf_cache`. The wrapper sets this env var
|
||||
before importing, so auxiliary HF assets (MaskGCT, campplus, BigVGAN,
|
||||
w2v-bert) land alongside the IndexTTS-2 weights and are excluded
|
||||
from restic together.
|
||||
@@ -0,0 +1,154 @@
|
||||
"""IndexTTS-2 — minimal FastAPI wrapper.
|
||||
|
||||
Upstream (https://github.com/index-tts/index-tts) ships only a Gradio
|
||||
webui; the only existing FastAPI fork (csllpr/index-tts-fastapi) targets
|
||||
v1 and is dormant. We own this wrapper end-to-end.
|
||||
|
||||
API surface:
|
||||
POST /v1/audio/speech OpenAI-compat-ish, see SpeechRequest
|
||||
GET /v1/voices list speakers + emotion references
|
||||
GET /healthz liveness for compose healthcheck
|
||||
|
||||
Voice library is a flat directory of WAVs (one file per voice). Emotion
|
||||
references live in a parallel directory. Both are bind-mounted from the
|
||||
host so cloned voices survive container recreates.
|
||||
|
||||
Emotion control is opt-in and mutually exclusive (audio > vector > text):
|
||||
* emotion_voice — name of a WAV in the emotions dir, used as a SECOND
|
||||
reference whose timbre is ignored but emotion is
|
||||
transferred onto the speaker.
|
||||
* emotion_vector — 8-float [happy, angry, sad, afraid, disgusted,
|
||||
melancholic, surprised, calm].
|
||||
* emotion_text — free text; bundled QwenEmotion model derives the
|
||||
vector ("she said excitedly" → joy spike).
|
||||
|
||||
Without any of these the speaker WAV's natural emotion is reused.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
# IndexTTS pins HF_HUB_CACHE at import time (./checkpoints/hf_cache by
|
||||
# default, see infer_v2.py:4). Override BEFORE the indextts import or
|
||||
# downloads land in the wrong tree.
|
||||
os.environ.setdefault(
|
||||
"HF_HUB_CACHE",
|
||||
os.environ.get("INDEX_TTS_HF_CACHE", "/app/checkpoints/hf_cache"),
|
||||
)
|
||||
|
||||
import soundfile as sf # noqa: E402
|
||||
from fastapi import FastAPI, HTTPException # noqa: E402
|
||||
from fastapi.responses import Response # noqa: E402
|
||||
from pydantic import BaseModel, Field # noqa: E402
|
||||
|
||||
from indextts.infer_v2 import IndexTTS2 # noqa: E402
|
||||
|
||||
# ── config from env ──────────────────────────────────────────────────
|
||||
MODEL_DIR = os.environ.get("INDEX_TTS_MODEL_DIR", "/app/checkpoints")
|
||||
CFG_PATH = os.environ.get("INDEX_TTS_CFG", f"{MODEL_DIR}/config.yaml")
|
||||
VOICES_DIR = Path(os.environ.get("INDEX_TTS_VOICES_DIR", "/app/voices"))
|
||||
EMOTIONS_DIR = Path(os.environ.get("INDEX_TTS_EMOTIONS_DIR", "/app/emotions"))
|
||||
USE_FP16 = os.environ.get("INDEX_TTS_FP16", "1") == "1"
|
||||
DEVICE = os.environ.get("INDEX_TTS_DEVICE") or None # "cuda:0", "cuda:1", or None=auto
|
||||
|
||||
VOICES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
EMOTIONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logging.basicConfig(level=os.environ.get("INDEX_TTS_LOG_LEVEL", "INFO"))
|
||||
log = logging.getLogger("index-tts")
|
||||
|
||||
log.info(
|
||||
"Loading IndexTTS2 from %s (device=%s, fp16=%s)",
|
||||
MODEL_DIR, DEVICE or "auto", USE_FP16,
|
||||
)
|
||||
tts = IndexTTS2(
|
||||
cfg_path=CFG_PATH,
|
||||
model_dir=MODEL_DIR,
|
||||
use_fp16=USE_FP16,
|
||||
device=DEVICE,
|
||||
)
|
||||
log.info("IndexTTS2 ready")
|
||||
|
||||
app = FastAPI(title="index-tts", version="0.1.0")
|
||||
|
||||
|
||||
class SpeechRequest(BaseModel):
|
||||
model: Optional[str] = "index-tts-2" # accepted but ignored
|
||||
input: str = Field(..., description="Text to synthesize")
|
||||
voice: str = Field(..., description="<name>.wav must exist in voices dir")
|
||||
response_format: str = Field("wav", description="wav (only)")
|
||||
# ── emotion (all optional, mutually exclusive) ──
|
||||
emotion_voice: Optional[str] = Field(
|
||||
None, description="<name>.wav in emotions dir, used as emotion ref"
|
||||
)
|
||||
emotion_vector: Optional[List[float]] = Field(
|
||||
None,
|
||||
description="8 floats: happy, angry, sad, afraid, disgusted, melancholic, surprised, calm",
|
||||
)
|
||||
emotion_text: Optional[str] = Field(
|
||||
None, description="Text describing emotion; QwenEmotion derives vector"
|
||||
)
|
||||
emotion_alpha: float = Field(1.0, ge=0.0, le=1.0)
|
||||
|
||||
|
||||
def _resolve(name: str, root: Path) -> Path:
|
||||
p = root / f"{name}.wav"
|
||||
if not p.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"not found: {root.name}/{name}.wav")
|
||||
return p
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/v1/voices")
|
||||
def list_voices() -> dict:
|
||||
return {
|
||||
"voices": sorted(p.stem for p in VOICES_DIR.glob("*.wav")),
|
||||
"emotions": sorted(p.stem for p in EMOTIONS_DIR.glob("*.wav")),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/v1/audio/speech")
|
||||
def synthesize(req: SpeechRequest) -> Response:
|
||||
if req.response_format != "wav":
|
||||
raise HTTPException(status_code=400, detail="only response_format=wav is supported")
|
||||
|
||||
spk = str(_resolve(req.voice, VOICES_DIR))
|
||||
|
||||
# First emotion source set wins.
|
||||
emo_path = None
|
||||
emo_vector = None
|
||||
use_emo_text = False
|
||||
emo_text = None
|
||||
if req.emotion_voice:
|
||||
emo_path = str(_resolve(req.emotion_voice, EMOTIONS_DIR))
|
||||
elif req.emotion_vector is not None:
|
||||
if len(req.emotion_vector) != 8:
|
||||
raise HTTPException(status_code=400, detail="emotion_vector must have 8 elements")
|
||||
emo_vector = list(req.emotion_vector)
|
||||
elif req.emotion_text:
|
||||
use_emo_text = True
|
||||
emo_text = req.emotion_text
|
||||
|
||||
sr, audio = tts.infer(
|
||||
spk_audio_prompt=spk,
|
||||
text=req.input,
|
||||
output_path=None, # in-memory return: (sr, np_int16)
|
||||
emo_audio_prompt=emo_path,
|
||||
emo_alpha=req.emotion_alpha,
|
||||
emo_vector=emo_vector,
|
||||
use_emo_text=use_emo_text,
|
||||
emo_text=emo_text,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
buf = io.BytesIO()
|
||||
sf.write(buf, audio, sr, format="WAV", subtype="PCM_16")
|
||||
return Response(content=buf.getvalue(), media_type="audio/wav")
|
||||
@@ -0,0 +1,63 @@
|
||||
# IndexTTS-2 — Bilibili's emotion-controllable zero-shot TTS, served
|
||||
# behind our own thin FastAPI wrapper (stacks/index-tts/app.py).
|
||||
#
|
||||
# Why this stack exists alongside qwen3-tts and cosyvoice:
|
||||
# IndexTTS-2 disentangles timbre from emotion — emotion can be cloned
|
||||
# from a separate audio reference, set via 8-vector, or derived from
|
||||
# free text. Neither qwen3-tts nor cosyvoice expose this cleanly in
|
||||
# English. See stacks/index-tts/README.md for the full rationale.
|
||||
#
|
||||
# Build: image is local, built from the Dockerfile in this dir. Pinned
|
||||
# upstream SHA lives in .env as INDEX_TTS_SHA so rebuilds are
|
||||
# reproducible.
|
||||
#
|
||||
# Model: ~5-7 GB IndexTTS-2 weights download on first start via the
|
||||
# entrypoint, persisted under ${INDEX_TTS_CACHE_DIR}.
|
||||
#
|
||||
# License note: weights carry a custom Bilibili license (free at our
|
||||
# scale, but not OSI-open). The wrapper code is ours, MIT-by-default.
|
||||
#
|
||||
# All tunables live in .env — edit that, not this file.
|
||||
|
||||
services:
|
||||
index-tts:
|
||||
image: local/index-tts:${INDEX_TTS_TAG}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
INDEX_TTS_SHA: ${INDEX_TTS_SHA}
|
||||
container_name: index-tts
|
||||
restart: unless-stopped
|
||||
runtime: nvidia
|
||||
ports:
|
||||
- "${INDEX_TTS_BIND:-0.0.0.0}:${INDEX_TTS_PORT}:8000"
|
||||
environment:
|
||||
- NVIDIA_VISIBLE_DEVICES=${INDEX_TTS_GPU_DEVICES:-all}
|
||||
- INDEX_TTS_MODEL_DIR=/app/checkpoints
|
||||
- INDEX_TTS_VOICES_DIR=/app/voices
|
||||
- INDEX_TTS_EMOTIONS_DIR=/app/emotions
|
||||
- INDEX_TTS_FP16=${INDEX_TTS_FP16:-1}
|
||||
- INDEX_TTS_DEVICE=${INDEX_TTS_DEVICE:-}
|
||||
- INDEX_TTS_LOG_LEVEL=${INDEX_TTS_LOG_LEVEL:-INFO}
|
||||
volumes:
|
||||
- ${INDEX_TTS_CACHE_DIR}:/app/checkpoints
|
||||
- ${INDEX_TTS_VOICES_DIR}:/app/voices
|
||||
- ${INDEX_TTS_EMOTIONS_DIR}:/app/emotions
|
||||
healthcheck:
|
||||
# Match Dockerfile's healthcheck. Compose-level entry overrides the
|
||||
# image-level one if anything ever needs tweaking per-deploy.
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:8000/healthz || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
# First boot: ~5-7 GB HF download + IndexTTS-2 import (touches several
|
||||
# auxiliary HF repos for MaskGCT, campplus, BigVGAN, w2v-bert) + initial
|
||||
# CUDA warmup. Generous deadline to ride that out.
|
||||
start_period: 600s
|
||||
labels:
|
||||
- homepage.group=AI Systems
|
||||
- homepage.name=IndexTTS-2
|
||||
- homepage.icon=mdi-account-music
|
||||
- homepage.description=Emotion-controllable TTS w/ voice cloning (irv-ml1)
|
||||
- homepage.href=http://10.100.79.3:${INDEX_TTS_PORT}
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# entrypoint.sh — fetch IndexTTS-2 weights on first run, then exec server.
|
||||
#
|
||||
# IndexTTS2() expects checkpoint files pre-staged under model_dir; if
|
||||
# they're missing, the constructor sys.exit(1)s. Pull them via
|
||||
# huggingface_hub.snapshot_download on first start. ~5-7 GB; subsequent
|
||||
# starts skip the download because config.yaml is the gate.
|
||||
|
||||
set -e
|
||||
|
||||
MODEL_DIR="${INDEX_TTS_MODEL_DIR:-/app/checkpoints}"
|
||||
mkdir -p "${MODEL_DIR}"
|
||||
|
||||
if [ ! -f "${MODEL_DIR}/config.yaml" ]; then
|
||||
echo "[index-tts] downloading IndexTTS-2 weights to ${MODEL_DIR} (~5-7 GB, one-time)"
|
||||
python3 - <<EOF
|
||||
from huggingface_hub import snapshot_download
|
||||
snapshot_download(
|
||||
repo_id="IndexTeam/IndexTTS-2",
|
||||
local_dir="${MODEL_DIR}",
|
||||
)
|
||||
EOF
|
||||
echo "[index-tts] download complete"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
Reference in New Issue
Block a user