Files
esh-pfi-infrastructure/stacks/index-tts/README.md
T
vh 54fef0e9d8 stacks/index-tts: add streaming WAV endpoint (wrapper 0.2.0)
IndexTTS-2's tts.infer(stream_return=True) is a generator that yields
audio chunks per text segment as they finish, plus inter-segment
silence. Expose this via the existing POST /v1/audio/speech with a new
"stream": true field on the request body.

Wire-up:
  - 44-byte WAV header emitted up front with placeholder data length
    (0xFFFFFFFF) so chunks can be written before total samples are
    known. Players that read until EOF (mpv, ffplay, aplay, sox,
    browsers via <audio>) handle this fine.
  - Each yielded chunk goes through _chunk_to_pcm_bytes(), which
    handles torch tensors / numpy arrays in either int16 or float
    (-1..1) form.
  - 22050 Hz mono int16 — IndexTTS-2's hardcoded output shape.

Time-to-first-audio drops from full-file latency to ~one-segment
latency. Single-sentence inputs barely benefit; long passages /
multi-paragraph reads benefit a lot. Strict metadata parsers may
balk at the placeholder size — request without stream for a
closed-length WAV in that case.

INDEX_TTS_TAG bumped to v2 to force a rebuild.
2026-04-25 14:50:29 -07:00

181 lines
6.7 KiB
Markdown

# 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 mono — IndexTTS-2's native rate).
`response_format` other than `wav` is rejected.
### Streaming (since 0.2.0)
Add `"stream": true` to any request to stream the WAV as it generates.
IndexTTS-2 emits one chunk per text segment (~120 tokens) as soon as it
finishes synthesizing it; long inputs start playing while the rest is
still being generated.
```bash
# Pipe straight into a player. Time-to-first-audio drops from
# whole-file latency to ~one-segment latency.
curl -fsS -X POST http://10.100.79.3:8192/v1/audio/speech \
-H 'Content-Type: application/json' \
-d '{"input":"long passage of text...","voice":"glados","stream":true}' \
| mpv --no-cache -
# Or save while playing (tee).
curl -fsS -X POST http://10.100.79.3:8192/v1/audio/speech \
-H 'Content-Type: application/json' \
-d '{"input":"...","voice":"glados","stream":true}' \
| tee out.wav | mpv -
```
The streaming WAV uses a placeholder data-length in the header
(`0xFFFFFFFF`) so chunks can be written before the total is known.
Players that read until EOF (mpv, ffplay, aplay, sox, browsers via
`<audio>`) handle this fine. Strict parsers (some metadata extractors,
foobar2000 default settings) may complain about the size. If that
matters, request without `stream` and you get a normal closed-length
WAV.
## 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.
- **Bundled example WAVs are LFS pointers, not audio.** Upstream stores
`examples/emo_*.wav` and `examples/voice_*.wav` as Git LFS objects.
The image clones the repo without `git lfs pull` (the index-tts org
has exhausted GitHub's LFS bandwidth budget repeatedly, so doing it
in the Dockerfile aborts the build). If you want the IndexTTS-2
example clips as starter material, fetch them once via the media
CDN — that's a separate code path that doesn't count against the
LFS API budget:
```bash
ssh irv-ml1 '
cd /worktank/index-tts/emotions
curl -fsSL -o hate.wav https://media.githubusercontent.com/media/index-tts/index-tts/main/examples/emo_hate.wav
curl -fsSL -o sad.wav https://media.githubusercontent.com/media/index-tts/index-tts/main/examples/emo_sad.wav
'
```
- **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.