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.
IndexTTS-2
Bilibili's emotion-controllable zero-shot TTS (paper, code, weights) 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:
# 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.
# 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
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:
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_LICENSEin 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_*.wavandexamples/voice_*.wavas Git LFS objects. The image clones the repo withoutgit 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: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.pypinsHF_HUB_CACHEat 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.