qwen3-tts: add stack + deploy playbook for irv-ml1
Alibaba's open-weight TTS (Apache 2.0, Jan 2026), deployed via groxaxo/Qwen3-TTS-Openai-Fastapi wrapper. Built locally from a pinned git SHA via docker buildx's git context — no source vendored. 1.7B flagship model by default; 0.6B available via QWEN3_TTS_MODEL env override. Why we need a second TTS stack: cosyvoice 3 emits Chinese phonemes for English content per upstream FunAudioLLM/CosyVoice#1790 (unfixed). Qwen3-TTS is from the same Alibaba team but with English first-class in the checkpoint — 10 languages, 97 ms streaming TTFB, instruction-driven emotion. Coexists with cosyvoice on irv-ml1 (port 8191; cosyvoice keeps 8190). Voice cloning shape DIFFERS from cosyvoice: profile-based, not voice-id. Profiles live under voice_library/profiles/<name>/ and are referenced as voice="clone:<name>". Path layout: /worktank/qwen3-tts/{cache,voices}/, with cache excluded from restic (regenerable from HF Hub) and voices included (cloned profiles need original reference audio to recreate). playbooks/deploy-qwen3-tts.yaml: 10 steps + 5 verify, idempotent; the wait step polls /health for up to ~10 min to absorb first-run model download. Stack only — restic profile update for /worktank/qwen3-tts/voices/ to follow when this is empirically validated against the GLaDOS voice (the "did Qwen inherit the Chinese-bias bug?" question).
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
# Deploy Qwen3-TTS to irv-ml1.
|
||||
#
|
||||
# Builds the image locally from groxaxo/Qwen3-TTS-Openai-Fastapi via
|
||||
# docker buildx's git URL context (no source vendored on the host),
|
||||
# stages compose + .env under /opt/docker/compose/qwen3-tts/, brings
|
||||
# up, waits for /health, and verifies the web surface + MCP-irrelevant
|
||||
# REST endpoints answer.
|
||||
#
|
||||
# First run is slow: ~3-5 min for the docker build (CUDA torch +
|
||||
# transformers wheels) plus ~3-5 min for the 1.7B model download
|
||||
# from HF on first inference / warmup. The healthz wait below has
|
||||
# a generous deadline.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/elway irv-ml1 --playbook playbooks/deploy-qwen3-tts.yaml
|
||||
#
|
||||
# Idempotent — every step is creates-/when-gated; rerun is safe.
|
||||
|
||||
vars:
|
||||
compose_dir: /opt/docker/compose/qwen3-tts
|
||||
cache_dir: /worktank/qwen3-tts/cache
|
||||
voices_dir: /worktank/qwen3-tts/voices
|
||||
host_port: "8191"
|
||||
|
||||
steps:
|
||||
# ── host-side dirs ──────────────────────────────────────────────────
|
||||
|
||||
- name: Ensure /worktank/qwen3-tts root exists (one-time, sudo)
|
||||
shell: mkdir -p /worktank/qwen3-tts
|
||||
sudo: true
|
||||
creates: /worktank/qwen3-tts
|
||||
|
||||
- name: Chown /worktank/qwen3-tts to lkraven
|
||||
shell: chown lkraven:lkraven /worktank/qwen3-tts
|
||||
sudo: true
|
||||
when: '[ "$(stat -c %U /worktank/qwen3-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 compose dir exists
|
||||
shell: mkdir -p {{ compose_dir }}
|
||||
creates: "{{ compose_dir }}"
|
||||
|
||||
# ── deploy compose files ────────────────────────────────────────────
|
||||
|
||||
- name: Upload compose.yaml
|
||||
upload:
|
||||
src: stacks/qwen3-tts/compose.yaml
|
||||
dest: "{{ compose_dir }}/compose.yaml"
|
||||
mode: "0644"
|
||||
|
||||
- name: Seed .env from template (only if absent)
|
||||
upload:
|
||||
src: stacks/qwen3-tts/.env.example
|
||||
dest: "{{ compose_dir }}/.env"
|
||||
mode: "0644"
|
||||
when: "[ ! -f {{ compose_dir }}/.env ]"
|
||||
|
||||
# ── build + bring up ────────────────────────────────────────────────
|
||||
|
||||
- name: docker compose build (~3-5 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 /health to respond
|
||||
# 1.7B model download on first boot can take a few minutes; allow
|
||||
# up to ~10 minutes for the wait, polling every 5s.
|
||||
shell: |
|
||||
for i in $(seq 1 120); do
|
||||
curl -sf -o /dev/null --max-time 3 http://localhost:{{ host_port }}/health && exit 0
|
||||
sleep 5
|
||||
done
|
||||
exit 1
|
||||
changed_when: "false"
|
||||
|
||||
verify:
|
||||
- name: /health returns 200
|
||||
shell: curl -sf -o /dev/null http://localhost:{{ host_port }}/health
|
||||
changed_when: "false"
|
||||
|
||||
- name: /v1/models lists at least one model
|
||||
shell: curl -sf http://localhost:{{ host_port }}/v1/models | grep -q '"data"\|Qwen3-TTS\|model'
|
||||
changed_when: "false"
|
||||
|
||||
- name: /v1/voices endpoint reachable
|
||||
shell: curl -sf -o /dev/null http://localhost:{{ host_port }}/v1/voices
|
||||
changed_when: "false"
|
||||
|
||||
- name: Web UI root serves HTML
|
||||
shell: '[ "$(curl -s -o /dev/null -w %{http_code} http://localhost:{{ host_port }}/)" -eq 200 ]'
|
||||
changed_when: "false"
|
||||
|
||||
- name: Container running + healthy or starting
|
||||
shell: docker inspect qwen3-tts --format '{{.State.Status}}' | grep -q running
|
||||
changed_when: "false"
|
||||
@@ -0,0 +1,52 @@
|
||||
# Qwen3-TTS stack tunables. Copy to `.env` on irv-ml1 before deploying.
|
||||
|
||||
# ── build pin ────────────────────────────────────────────────────────
|
||||
# SHA of groxaxo/Qwen3-TTS-Openai-Fastapi to build from. Bump + rebuild
|
||||
# when you want upstream wrapper updates.
|
||||
QWEN3_TTS_SHA=10323ce778c48a75dbda93d0a4891983fb371f58
|
||||
|
||||
# Local image tag — bump when you change build context to force a
|
||||
# fresh layer build.
|
||||
QWEN3_TTS_TAG=v1
|
||||
|
||||
# ── network ──────────────────────────────────────────────────────────
|
||||
# Host port (container listens on 8880 internally).
|
||||
QWEN3_TTS_PORT=8191
|
||||
|
||||
# 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.
|
||||
QWEN3_TTS_BIND=0.0.0.0
|
||||
|
||||
# ── runtime ──────────────────────────────────────────────────────────
|
||||
# Inference backend. `official` = default upstream; `optimized` =
|
||||
# faster but slightly less robust; `vllm_omni` = vLLM-backed (needs
|
||||
# more VRAM); `pytorch` = bare pytorch path.
|
||||
QWEN3_TTS_BACKEND=official
|
||||
|
||||
# Model variant. 1.7B = flagship, 6–8 GB VRAM with bfloat16, best
|
||||
# quality + control. 0.6B = lightweight, ~2–3 GB VRAM, faster, slightly
|
||||
# less expressive.
|
||||
QWEN3_TTS_MODEL=Qwen/Qwen3-TTS-12Hz-1.7B
|
||||
|
||||
# Warm the model on container start so the first synthesis request
|
||||
# doesn't pay the load latency. Adds ~30 s to startup. Recommended.
|
||||
QWEN3_TTS_WARMUP=true
|
||||
|
||||
# Concurrency cap on synthesis requests. Single GPU + 1.7B model →
|
||||
# leave at 1 unless you're load-testing.
|
||||
QWEN3_TTS_MAX_CONCURRENT=1
|
||||
|
||||
# Mount the gradio voice-studio UI at /voice-studio for browser-side
|
||||
# voice cloning. Set "false" to disable for headless deployments.
|
||||
QWEN3_TTS_VOICE_STUDIO=true
|
||||
|
||||
# ── persistent storage on the host ───────────────────────────────────
|
||||
# HuggingFace cache (model weights, ~5 GB after first run). Bind-mounted
|
||||
# so model state survives container recreate. Excluded from restic
|
||||
# (regenerable from HF Hub).
|
||||
QWEN3_TTS_CACHE_DIR=/worktank/qwen3-tts/cache
|
||||
|
||||
# Cloned voice profiles (meta.json + reference.wav per voice). Precious
|
||||
# — cloned voices need the original reference audio to recreate.
|
||||
# Included in restic.
|
||||
QWEN3_TTS_VOICES_DIR=/worktank/qwen3-tts/voices
|
||||
@@ -0,0 +1,103 @@
|
||||
# Qwen3-TTS
|
||||
|
||||
Alibaba's open-weight TTS (Apache 2.0, released Jan 2026), deployed
|
||||
via the [groxaxo/Qwen3-TTS-Openai-Fastapi](https://github.com/groxaxo/Qwen3-TTS-Openai-Fastapi)
|
||||
OpenAI-compatible wrapper.
|
||||
|
||||
**Server:** irv-ml1 (Irvine, WireGuard-only)
|
||||
**Port:** 8191 (container 8880)
|
||||
**GPUs:** both exposed (`NVIDIA_VISIBLE_DEVICES=all`); 1.7B model
|
||||
fits on either the RTX 3090 (24 GB) or A6000 (48 GB) with headroom
|
||||
**Image:** `local/qwen3-tts:v1` — built locally from a pinned git SHA
|
||||
of the wrapper repo via docker buildx's git URL context
|
||||
|
||||
## Why this stack alongside cosyvoice
|
||||
|
||||
CosyVoice 3 (the other stack on this host) emits Chinese-flavored
|
||||
phonemes when given English content. Confirmed against upstream
|
||||
issue [FunAudioLLM/CosyVoice#1790](https://github.com/FunAudioLLM/CosyVoice/issues/1790)
|
||||
— closed without a fix as of 2026-04-14.
|
||||
|
||||
Qwen3-TTS is from the same Alibaba team but built English-first into
|
||||
the checkpoint: 10 languages, 97 ms streaming TTFB, instruction-driven
|
||||
emotional expression, voice cloning. It's the better choice for any
|
||||
English-narration use; keep CosyVoice 3 around for Chinese / dialect
|
||||
work where it shines.
|
||||
|
||||
## API endpoints
|
||||
|
||||
| Method + path | Purpose |
|
||||
|---|---|
|
||||
| `POST /v1/audio/speech` | OpenAI-compatible TTS (drop-in for OpenAI clients) |
|
||||
| `GET /v1/voices` | List cloned voice profiles |
|
||||
| `GET /v1/models` | List available model checkpoints |
|
||||
| `GET /health` | Healthcheck |
|
||||
| `GET /` | Web UI |
|
||||
| `GET /voice-studio` | Gradio voice-cloning UI (when `ENABLE_VOICE_STUDIO=true`) |
|
||||
|
||||
## Voice cloning — different shape from cosyvoice
|
||||
|
||||
Profile-based, not voice-id-based:
|
||||
|
||||
1. Clone a voice via the `/voice-studio` web UI (uploads reference
|
||||
audio + transcript, names the profile, e.g. `glados`).
|
||||
2. The wrapper writes
|
||||
`/root/qwen3-tts/voice_library/profiles/<name>/{meta.json,reference.wav}`
|
||||
inside the container (bind-mounted to
|
||||
`/worktank/qwen3-tts/voices/profiles/<name>/` on the host).
|
||||
3. Reference the profile in synthesis requests as
|
||||
`voice="clone:<name>"`.
|
||||
|
||||
```bash
|
||||
# OpenAI-shape, English with emotion via instruction
|
||||
curl -X POST http://10.100.79.3:8191/v1/audio/speech \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "Qwen/Qwen3-TTS-12Hz-1.7B",
|
||||
"voice": "clone:glados",
|
||||
"input": "You should leave now. Visitors are not welcome.",
|
||||
"instructions": "speak with cold contempt",
|
||||
"response_format": "wav"
|
||||
}' \
|
||||
-o glados_en_angry.wav
|
||||
```
|
||||
|
||||
(Note `instructions` field name; OpenAI standard. Whether English
|
||||
instructions actually drive emotion correctly here is the open
|
||||
question this stack exists to test — see the deploy playbook's verify
|
||||
section.)
|
||||
|
||||
## Path layout
|
||||
|
||||
| Host path | Container path | Purpose | Restic? |
|
||||
|---|---|---|---|
|
||||
| `/worktank/qwen3-tts/cache/` | `/root/.cache/huggingface` | Model cache (~5 GB after first run) | excluded |
|
||||
| `/worktank/qwen3-tts/voices/` | `/root/qwen3-tts/voice_library` | Cloned profiles | **included** (precious) |
|
||||
|
||||
## Deploy
|
||||
|
||||
Via elway — see `playbooks/deploy-qwen3-tts.yaml` in the
|
||||
eshpfi-management root. The playbook builds the image, ensures the
|
||||
host dirs, installs compose + .env, brings up. First boot downloads
|
||||
the 1.7B model from HF (~6 GB), which is the slowest step.
|
||||
|
||||
```bash
|
||||
scripts/elway irv-ml1 --playbook playbooks/deploy-qwen3-tts.yaml
|
||||
```
|
||||
|
||||
To pin a different upstream wrapper SHA, set `--var sha=<new-sha>` or
|
||||
edit `.env` on the server and rebuild.
|
||||
|
||||
## Switching to the smaller model
|
||||
|
||||
If 1.7B is too heavy or you need to share GPUs more aggressively:
|
||||
|
||||
```bash
|
||||
ssh irv-ml1 '
|
||||
cd /opt/docker/compose/qwen3-tts && \
|
||||
sed -i "s|^QWEN3_TTS_MODEL=.*|QWEN3_TTS_MODEL=Qwen/Qwen3-TTS-12Hz-0.6B|" .env && \
|
||||
docker compose up -d
|
||||
'
|
||||
```
|
||||
|
||||
The new model auto-downloads on next start (~2–3 GB).
|
||||
@@ -0,0 +1,63 @@
|
||||
# Qwen3-TTS — Alibaba's open-weight TTS, deployed via the
|
||||
# groxaxo/Qwen3-TTS-Openai-Fastapi wrapper.
|
||||
#
|
||||
# Why this stack exists alongside cosyvoice: CosyVoice 3 emits
|
||||
# Chinese phonemes for non-Chinese inputs (upstream issue
|
||||
# FunAudioLLM/CosyVoice#1790, no fix). Qwen3-TTS is from the same
|
||||
# Alibaba team but with English first-class — 10 languages, 97 ms
|
||||
# streaming TTFB, voice cloning, instruction-driven emotional
|
||||
# expression. Released Jan 2026, Apache 2.0.
|
||||
#
|
||||
# Build: no prebuilt image; pinned to a SHA via docker buildx's git
|
||||
# context URL so subsequent rebuilds are reproducible. ~5–10 min on
|
||||
# first build (CUDA torch + transformers).
|
||||
#
|
||||
# Model: 1.7B flagship (~6–8 GB VRAM with bfloat16) by default; the
|
||||
# host has plenty of VRAM. Switch to the 0.6B in .env if you ever
|
||||
# need more headroom.
|
||||
#
|
||||
# Voice cloning shape DIFFERS from cosyvoice: profile-based, not
|
||||
# voice-id. Profiles live under voice_library/profiles/<name>/ with
|
||||
# meta.json + reference.wav, and are referenced as
|
||||
# `voice="clone:<name>"` in /v1/audio/speech requests.
|
||||
#
|
||||
# All tunables live in .env — edit that, not this file.
|
||||
|
||||
services:
|
||||
qwen3-tts:
|
||||
image: local/qwen3-tts:${QWEN3_TTS_TAG}
|
||||
build:
|
||||
context: https://github.com/groxaxo/Qwen3-TTS-Openai-Fastapi.git#${QWEN3_TTS_SHA}
|
||||
dockerfile: Dockerfile
|
||||
container_name: qwen3-tts
|
||||
restart: unless-stopped
|
||||
runtime: nvidia
|
||||
ports:
|
||||
- "${QWEN3_TTS_BIND:-0.0.0.0}:${QWEN3_TTS_PORT}:8880"
|
||||
environment:
|
||||
- NVIDIA_VISIBLE_DEVICES=all
|
||||
- PORT=8880
|
||||
- TTS_BACKEND=${QWEN3_TTS_BACKEND:-official}
|
||||
- TTS_MODEL_NAME=${QWEN3_TTS_MODEL:-Qwen/Qwen3-TTS-12Hz-1.7B}
|
||||
- TTS_WARMUP_ON_START=${QWEN3_TTS_WARMUP:-true}
|
||||
- TTS_MAX_CONCURRENT=${QWEN3_TTS_MAX_CONCURRENT:-1}
|
||||
- ENABLE_VOICE_STUDIO=${QWEN3_TTS_VOICE_STUDIO:-true}
|
||||
- VOICE_LIBRARY_DIR=/root/qwen3-tts/voice_library
|
||||
- HF_HOME=/root/.cache/huggingface
|
||||
volumes:
|
||||
- ${QWEN3_TTS_CACHE_DIR}:/root/.cache/huggingface
|
||||
- ${QWEN3_TTS_VOICES_DIR}:/root/qwen3-tts/voice_library
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8880/health >/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
# First boot pulls torch + Qwen3-TTS-12Hz-1.7B (~6 GB) and
|
||||
# optionally warms the model — give it a generous budget.
|
||||
start_period: 600s
|
||||
labels:
|
||||
- homepage.group=AI Systems
|
||||
- homepage.name=Qwen3-TTS
|
||||
- homepage.icon=mdi-account-voice
|
||||
- homepage.description=Multilingual TTS with English-first emotion (irv-ml1)
|
||||
- homepage.href=http://10.100.79.3:${QWEN3_TTS_PORT}
|
||||
Reference in New Issue
Block a user