From 81efa8da96d720315da3f91287997954f7386024 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Sun, 31 May 2026 13:59:10 -0700 Subject: [PATCH] feat(zonos): OpenAI-ish REST adapter for asset-engine routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream Zonos ships only Gradio + Python SDK — no REST surface — so asset-engine (which routes a clean JSON POST to /v1/audio/speech) can't target it directly. Add a thin FastAPI adapter (stacks/zonos/adapter/): POST /v1/audio/speech in front of the Zonos SDK, built FROM local/zonos to reuse torch/CUDA/SDK. Returns a JSON envelope {audio, audio_format, seed} — the seed rides back so asset-engine regenerate/fork can pin it (Zonos is the fleet's first genuinely seedable TTS). compose gains a zonos-api service on 8201; .env.example gains the port + voices dir. --- stacks/zonos/.env.example | 16 +- stacks/zonos/README.md | 38 +++- stacks/zonos/adapter/Dockerfile | 22 +++ .../__pycache__/server.cpython-311.pyc | Bin 0 -> 11165 bytes stacks/zonos/adapter/requirements.txt | 6 + stacks/zonos/adapter/server.py | 174 ++++++++++++++++++ stacks/zonos/compose.yaml | 37 ++++ 7 files changed, 283 insertions(+), 10 deletions(-) create mode 100644 stacks/zonos/adapter/Dockerfile create mode 100644 stacks/zonos/adapter/__pycache__/server.cpython-311.pyc create mode 100644 stacks/zonos/adapter/requirements.txt create mode 100644 stacks/zonos/adapter/server.py diff --git a/stacks/zonos/.env.example b/stacks/zonos/.env.example index ed02dd5..51903d2 100644 --- a/stacks/zonos/.env.example +++ b/stacks/zonos/.env.example @@ -16,9 +16,14 @@ ZONOS_TAG=v1 # Reserved on irv-ml1: 8188 ComfyUI, 8190 CosyVoice, 8191 Qwen3-TTS, # 8192 IndexTTS-2, 8193 Kokoro, 8194 VibeVoice, 8195 Fish-S2, # 8196 Chatterbox, 8197 Voxtral, 8198 Kyutai, 8200 Dia, 8765 Parakeet. -# 8199 picked here. +# 8199 picked here for the Gradio eval UI. ZONOS_PORT=8199 +# Host port for the OpenAI-ish REST adapter (zonos-api service) that +# asset-engine routes to. Container listens on 8000 internally. 8201 is +# the next free slot above the Gradio port. +ZONOS_API_PORT=8201 + # 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. ZONOS_BIND=0.0.0.0 @@ -42,3 +47,12 @@ ZONOS_GPU_DEVICES=0 # Bind-mounted so they survive container recreate. Excluded from restic # (regenerable from HF). ZONOS_CACHE_DIR=/worktank/zonos/cache + +# Reference voice clips for zero-shot cloning (mounted read-only into the +# adapter at /app/voices). Drop 10–30s clean WAVs here; the catalog's +# `voice` field selects one by filename. Empty/absent → default voice. +ZONOS_VOICES_DIR=/worktank/zonos/voices + +# Model the adapter loads at startup. Only the transformer variant is +# available in the current image (mamba-ssm absent → no hybrid). +ZONOS_MODEL=Zyphra/Zonos-v0.1-transformer diff --git a/stacks/zonos/README.md b/stacks/zonos/README.md index a370f1f..e748ca4 100644 --- a/stacks/zonos/README.md +++ b/stacks/zonos/README.md @@ -13,7 +13,7 @@ Models ([`Zonos.from_pretrained()`](https://github.com/Zyphra/Zonos)): | hybrid | [Zyphra/Zonos-v0.1-hybrid](https://huggingface.co/Zyphra/Zonos-v0.1-hybrid) | Mamba-SSM; needs Ampere+ GPU + extra build deps | **Server:** irv-ml1 (Irvine, WireGuard-only) -**Port:** 8199 (container Gradio listens on 7860) +**Ports:** 8199 Gradio eval UI (container 7860) · 8201 REST adapter (container 8000) **GPUs:** pins to device 0 (RTX 3090) by default; ~6 GB VRAM **Image:** `local/zonos:v1` — built locally from a pinned git SHA of the upstream repo via docker buildx's git URL context @@ -29,17 +29,37 @@ natural-language paralinguistic tags. Worth A/B-ing by ear against Chatterbox-Turbo (the research that prompted this stack explicitly said "benchmark Zonos against Chatterbox before choosing"). -## ⚠️ Audition surface, not skaldsong-pluggable (yet) +## Two surfaces: Gradio eval (8199) + REST adapter (8201) -The official repo ships a **Gradio WebUI + Python SDK only** — there is -**no OpenAI-compatible `/v1/audio/speech` endpoint**. So this stack is -for *auditioning quality*, not for wiring into skaldsong's engine -router as-is. To promote Zonos to a real engine slot we'd need either: +The official repo ships a **Gradio WebUI + Python SDK only** — no REST +endpoint. So this stack runs two services: -- the community FastAPI fork ([Zyphra/Zonos PR #73](https://github.com/Zyphra/Zonos/pull/73), adds REST + basic streaming), or -- a thin OpenAI-compat adapter in front of the Python SDK. +- **`zonos`** (8199) — upstream Gradio UI, for *auditioning quality by ear*. +- **`zonos-api`** (8201) — a thin OpenAI-ish adapter we built + (`adapter/server.py`) exposing `POST /v1/audio/speech` so **asset-engine** + can route to Zonos like every other TTS in the catalog. Returns a JSON + envelope `{audio: , audio_format, seed}` — the `seed` rides back + so regenerate/fork can pin it (Zonos is the fleet's first seedable TTS). -Both are a follow-up if Zonos earns a slot in the ear test. +The adapter is built `FROM local/zonos:` (reuses torch/CUDA/SDK) and +loads its own copy of the model (~6 GB on top of the Gradio service). Once +Zonos earns a permanent slot, drop the Gradio service and keep the adapter. + +The community FastAPI fork ([PR #73](https://github.com/Zyphra/Zonos/pull/73)) +was the alternative; we chose the self-owned adapter over pinning to an +unmerged fork. + +### Adapter request (example) + +```bash +curl -sS http://10.100.79.3:8201/v1/audio/speech \ + -H 'content-type: application/json' \ + -d '{"input":"Hello from Zonos.","language":"en-us","seed":420}' \ + | python3 -c 'import sys,json,base64; d=json.load(sys.stdin); open("out.wav","wb").write(base64.b64decode(d["audio"])); print("seed",d["seed"])' +``` + +Cloning: drop a 10–30s WAV in `/worktank/zonos/voices/` and pass its +filename as `"voice"`. `GET /v1/audio/voices` lists what's available. ## Deploy diff --git a/stacks/zonos/adapter/Dockerfile b/stacks/zonos/adapter/Dockerfile new file mode 100644 index 0000000..529f89e --- /dev/null +++ b/stacks/zonos/adapter/Dockerfile @@ -0,0 +1,22 @@ +# Thin OpenAI-ish REST adapter, layered on top of the already-built +# upstream Zonos image (local/zonos:) so we reuse its torch + CUDA + +# espeak-ng + Zonos SDK install rather than rebuilding the heavy base. +# +# Build arg ZONOS_BASE lets compose pin which base tag to layer on. +ARG ZONOS_BASE=local/zonos:v1 +FROM ${ZONOS_BASE} + +# libsndfile1 backs soundfile's wav/flac encode (the base image lacks it). +RUN apt-get update \ + && apt-get install -y --no-install-recommends libsndfile1 \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt + +WORKDIR /app +COPY server.py /app/server.py + +# Container listens on 8000 internally; compose maps it to the host port. +EXPOSE 8000 +CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/stacks/zonos/adapter/__pycache__/server.cpython-311.pyc b/stacks/zonos/adapter/__pycache__/server.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f400fb324a543d1383b158c72935a2bd66dc75e8 GIT binary patch literal 11165 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 literal 0 HcmV?d00001 diff --git a/stacks/zonos/adapter/requirements.txt b/stacks/zonos/adapter/requirements.txt new file mode 100644 index 0000000..0adc239 --- /dev/null +++ b/stacks/zonos/adapter/requirements.txt @@ -0,0 +1,6 @@ +# Adapter-only deps. torch / torchaudio / the zonos SDK already live in +# the base image (local/zonos) — do NOT reinstall them here or the CUDA +# build gets clobbered. +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +soundfile==0.12.1 diff --git a/stacks/zonos/adapter/server.py b/stacks/zonos/adapter/server.py new file mode 100644 index 0000000..91c59b5 --- /dev/null +++ b/stacks/zonos/adapter/server.py @@ -0,0 +1,174 @@ +"""OpenAI-ish /v1/audio/speech adapter in front of the Zonos Python SDK. + +Why this exists: the upstream Zyphra/Zonos repo ships only a Gradio WebUI ++ Python SDK — no REST endpoint. asset-engine routes generation over a +clean JSON POST (every other TTS in the catalog speaks /v1/audio/speech), +so this thin FastAPI layer adapts the SDK's generate path to that wire. + +Wire shape (request): POST /v1/audio/speech application/json +Wire shape (response): JSON envelope {audio: , audio_format: + "audio/wav", seed: } — mirrors the + kokoro-captioned envelope, plus `seed` so + asset-engine's regenerate/fork can pin it + (Zonos is the first genuinely seedable TTS in the + fleet: catalog reproducibility.seed_field=seed). + +The model is loaded once at startup. Generation is serialized behind an +asyncio lock — the SDK model and the speaker-embedding cache are not +concurrency-safe, and there is one GPU. +""" + +from __future__ import annotations + +import asyncio +import base64 +import io +import os +from typing import Optional + +import soundfile as sf +import torch +import torchaudio +from fastapi import FastAPI, HTTPException +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from zonos.conditioning import make_cond_dict, supported_language_codes +from zonos.model import Zonos +from zonos.utils import DEFAULT_DEVICE as DEVICE + +MODEL_ID = os.getenv("ZONOS_MODEL", "Zyphra/Zonos-v0.1-transformer") +VOICES_DIR = os.getenv("ZONOS_VOICES_DIR", "/app/voices") + +# Blessed defaults lifted verbatim from upstream gradio_interface.py — the +# model authors picked these for human-facing UX (CATALOG-CONTRACT source +# precedence: Gradio UI for blessed defaults/ranges). +SAMPLER = dict(top_p=0.0, top_k=0, min_p=0.0, linear=0.5, conf=0.40, quad=0.00) +MAX_NEW_TOKENS = 86 * 30 # ~30s ceiling, as upstream + +app = FastAPI(title="zonos-adapter", version="0.1.0") +_lock = asyncio.Lock() +_model: Optional[Zonos] = None +# (voice_path -> embedding) cache; recomputing the speaker embedding per +# request is the dominant avoidable cost for repeated clones. +_spk_cache: dict[str, torch.Tensor] = {} + + +class SpeechRequest(BaseModel): + input: str = Field(..., description="Text to synthesize") + model: str = Field(MODEL_ID, description="Only the transformer variant is loaded") + voice: Optional[str] = Field( + None, description="Reference clip name under VOICES_DIR (cloning); omit for default voice" + ) + language: str = Field("en-us", description="eSpeak language code") + response_format: str = Field("wav", description="wav | flac") + seed: Optional[int] = Field( + None, description="Omit for a random seed; the seed actually used is returned" + ) + # Conditioning knobs (ranges mirror the Gradio sliders). + speaking_rate: float = Field(15.0, ge=5.0, le=30.0) + pitch_std: float = Field(45.0, ge=0.0, le=300.0) + fmax: float = Field(24000.0, ge=0.0, le=24000.0) + cfg_scale: float = Field(2.0, ge=1.0, le=5.0) + # 8-float emotion vector [happy, sad, disgust, fear, surprise, anger, + # other, neutral]; omit to leave emotion unconditional (Gradio default). + emotion: Optional[list[float]] = Field(None, min_length=8, max_length=8) + + +def _get_model() -> Zonos: + global _model + if _model is None: + _model = Zonos.from_pretrained(MODEL_ID, device=DEVICE) + _model.requires_grad_(False).eval() + return _model + + +def _speaker_embedding(model: Zonos, voice: str) -> torch.Tensor: + path = voice if os.path.isabs(voice) else os.path.join(VOICES_DIR, voice) + if not os.path.isfile(path): + raise HTTPException(404, f"voice not found: {voice}") + if path not in _spk_cache: + wav, sr = torchaudio.load(path) + emb = model.make_speaker_embedding(wav, sr).to(DEVICE, dtype=torch.bfloat16) + _spk_cache[path] = emb + return _spk_cache[path] + + +_FORMATS = {"wav": "audio/wav", "flac": "audio/flac"} + + +@app.get("/health") +async def health() -> dict: + return {"status": "ok", "model": MODEL_ID, "loaded": _model is not None} + + +@app.get("/v1/audio/voices") +async def voices() -> dict: + try: + names = sorted(f for f in os.listdir(VOICES_DIR) if f.lower().endswith((".wav", ".flac", ".mp3"))) + except FileNotFoundError: + names = [] + return {"voices": names} + + +@app.post("/v1/audio/speech") +async def speech(req: SpeechRequest) -> JSONResponse: + if req.response_format not in _FORMATS: + raise HTTPException(400, f"unsupported response_format: {req.response_format}") + if req.language not in supported_language_codes: + raise HTTPException(400, f"unsupported language: {req.language}") + + async with _lock: + model = _get_model() + + # Resolve seed: explicit pins it; otherwise draw one and report it + # back so asset-engine can regenerate/fork deterministically. + seed = req.seed if req.seed is not None else int(torch.randint(0, 2**32 - 1, (1,)).item()) + torch.manual_seed(seed) + + unconditional_keys: list[str] = [] + speaker = None + if req.voice: + speaker = _speaker_embedding(model, req.voice) + else: + unconditional_keys.append("speaker") + if req.emotion is not None: + emotion = torch.tensor([float(x) for x in req.emotion], device=DEVICE) + else: + emotion = None + unconditional_keys.append("emotion") + + cond = make_cond_dict( + text=req.input, + language=req.language, + speaker=speaker, + emotion=emotion, + fmax=float(req.fmax), + pitch_std=float(req.pitch_std), + speaking_rate=float(req.speaking_rate), + device=DEVICE, + unconditional_keys=unconditional_keys, + ) + conditioning = model.prepare_conditioning(cond) + + with torch.inference_mode(): + codes = model.generate( + prefix_conditioning=conditioning, + max_new_tokens=MAX_NEW_TOKENS, + cfg_scale=float(req.cfg_scale), + batch_size=1, + sampling_params=SAMPLER, + ) + wav = model.autoencoder.decode(codes).cpu().detach() + sr = model.autoencoder.sampling_rate + if wav.dim() == 2 and wav.size(0) > 1: + wav = wav[0:1, :] + samples = wav.squeeze().to(torch.float32).numpy() + + buf = io.BytesIO() + sf.write(buf, samples, sr, format=req.response_format.upper()) + audio_b64 = base64.b64encode(buf.getvalue()).decode("ascii") + + return JSONResponse( + {"audio": audio_b64, "audio_format": _FORMATS[req.response_format], "seed": seed} + ) diff --git a/stacks/zonos/compose.yaml b/stacks/zonos/compose.yaml index 0cd42a5..5466e0a 100644 --- a/stacks/zonos/compose.yaml +++ b/stacks/zonos/compose.yaml @@ -63,3 +63,40 @@ services: - homepage.icon=mdi-waveform - homepage.description=Expressive multilingual TTS + cloning, 44kHz (Gradio eval, irv-ml1) - homepage.href=http://10.100.79.3:${ZONOS_PORT} + + # OpenAI-ish REST adapter (POST /v1/audio/speech) that asset-engine + # routes to — upstream Zonos has no REST surface, only Gradio + SDK. + # Built FROM the gradio image above (reuses torch/CUDA/SDK); loads its + # own copy of the model, so it adds ~6 GB VRAM on top of the gradio + # service. Drop the gradio service once Zonos earns a permanent slot. + zonos-api: + image: local/zonos-api:${ZONOS_TAG} + build: + context: ./adapter + dockerfile: Dockerfile + args: + ZONOS_BASE: local/zonos:${ZONOS_TAG} + container_name: zonos-api + restart: unless-stopped + runtime: nvidia + depends_on: + - zonos + ports: + - "${ZONOS_BIND:-0.0.0.0}:${ZONOS_API_PORT}:8000" + environment: + - NVIDIA_VISIBLE_DEVICES=${ZONOS_GPU_DEVICES:-0} + - NVIDIA_DRIVER_CAPABILITIES=compute,utility + - HF_HOME=/app/hf_cache + - ZONOS_MODEL=${ZONOS_MODEL:-Zyphra/Zonos-v0.1-transformer} + - ZONOS_VOICES_DIR=/app/voices + volumes: + - ${ZONOS_CACHE_DIR}:/app/hf_cache + # Reference clips for zero-shot cloning, selected by the `voice` + # request field (filename under this dir). + - ${ZONOS_VOICES_DIR}:/app/voices:ro + healthcheck: + test: ["CMD-SHELL", "python3 -c \"import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5); sys.exit(0)\""] + interval: 30s + timeout: 10s + retries: 3 + start_period: 600s