From b75f020cc92b8c40a31a864d9e098c3bc4799fcb Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Sat, 25 Apr 2026 10:38:14 -0700 Subject: [PATCH] stacks/index-tts: own FastAPI wrapper for IndexTTS-2 + deploy playbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 .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. --- playbooks/deploy-index-tts.yaml | 117 ++++++++++++++++++++++++ stacks/index-tts/.env.example | 53 +++++++++++ stacks/index-tts/Dockerfile | 57 ++++++++++++ stacks/index-tts/README.md | 134 +++++++++++++++++++++++++++ stacks/index-tts/app.py | 154 ++++++++++++++++++++++++++++++++ stacks/index-tts/compose.yaml | 63 +++++++++++++ stacks/index-tts/entrypoint.sh | 26 ++++++ 7 files changed, 604 insertions(+) create mode 100644 playbooks/deploy-index-tts.yaml create mode 100644 stacks/index-tts/.env.example create mode 100644 stacks/index-tts/Dockerfile create mode 100644 stacks/index-tts/README.md create mode 100644 stacks/index-tts/app.py create mode 100644 stacks/index-tts/compose.yaml create mode 100644 stacks/index-tts/entrypoint.sh diff --git a/playbooks/deploy-index-tts.yaml b/playbooks/deploy-index-tts.yaml new file mode 100644 index 0000000..459eea6 --- /dev/null +++ b/playbooks/deploy-index-tts.yaml @@ -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" diff --git a/stacks/index-tts/.env.example b/stacks/index-tts/.env.example new file mode 100644 index 0000000..ac7b338 --- /dev/null +++ b/stacks/index-tts/.env.example @@ -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 .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 .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 diff --git a/stacks/index-tts/Dockerfile b/stacks/index-tts/Dockerfile new file mode 100644 index 0000000..00c5a22 --- /dev/null +++ b/stacks/index-tts/Dockerfile @@ -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"] diff --git a/stacks/index-tts/README.md b/stacks/index-tts/README.md new file mode 100644 index 0000000..83ddd03 --- /dev/null +++ b/stacks/index-tts/README.md @@ -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/.wav # timbre references +/worktank/index-tts/emotions/.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. diff --git a/stacks/index-tts/app.py b/stacks/index-tts/app.py new file mode 100644 index 0000000..b5ec6e2 --- /dev/null +++ b/stacks/index-tts/app.py @@ -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=".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=".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") diff --git a/stacks/index-tts/compose.yaml b/stacks/index-tts/compose.yaml new file mode 100644 index 0000000..39e2812 --- /dev/null +++ b/stacks/index-tts/compose.yaml @@ -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} diff --git a/stacks/index-tts/entrypoint.sh b/stacks/index-tts/entrypoint.sh new file mode 100644 index 0000000..98ed139 --- /dev/null +++ b/stacks/index-tts/entrypoint.sh @@ -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 - <