From 06eb487a2644755a51a0170c38499c4e5112f2fa Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Thu, 18 Jun 2026 23:03:20 -0700 Subject: [PATCH] feat(omnivoice): wire to asset-engine via FastAPI wrapper + reuse chatterbox voices - app.py: thin FastAPI wrapper exposing OpenAI /v1/audio/speech (+ /v1/audio/voices, /healthz) around OmniVoice's Python API; precomputes a voice-clone prompt per voice at startup (loaded Whisper auto-transcribes each reference). Replaces the Gradio demo. - Dockerfile/compose: run the uvicorn wrapper, /healthz healthcheck, project name pinned to "omnivoice" so the asset-engine liveness probe matches. - deploy-omnivoice.yaml: stage chatterbox /refs/*.wav as clone voices (skip _* artifacts) + verify the API surface. - services.yaml: catalog entry (id omnivoice, :8199/v1/audio/speech, voice list sourced live from /v1/audio/voices) + reproducibility_audit row. Verified live on irv-ml1: /healthz ok, 33 voices loaded, test synth -> 24kHz PCM_16 WAV. --- docs/asset-engine/services.yaml | 65 +++++++++++++++++++ playbooks/deploy-omnivoice.yaml | 39 +++++++---- stacks/omnivoice/Dockerfile | 21 +++--- stacks/omnivoice/README.md | 33 ++++++++-- stacks/omnivoice/app.py | 111 ++++++++++++++++++++++++++++++++ stacks/omnivoice/compose.yaml | 23 ++++--- 6 files changed, 255 insertions(+), 37 deletions(-) create mode 100644 stacks/omnivoice/app.py diff --git a/docs/asset-engine/services.yaml b/docs/asset-engine/services.yaml index 6c1803a..e3a9537 100644 --- a/docs/asset-engine/services.yaml +++ b/docs/asset-engine/services.yaml @@ -565,6 +565,66 @@ services: Three-way mutual-exclusion among emotion_voice / emotion_vector / emotion_text; precedence as above. UI should expose this as a single picker. + - id: omnivoice + name: OmniVoice + description: > + k2-fsa zero-shot, massively-multilingual (600+ language) voice-cloning TTS + (diffusion-LM, RTF ~0.025). Apache-2.0. Behind our own FastAPI wrapper + (stacks/omnivoice/app.py); voices are the reused chatterbox reference clips. + category: tts + version: 1 + status: ready + host: irv-ml1 + lifecycle: + stack: omnivoice + vram_gb: 6 + gpu_device_id: 0 + endpoint: http://10.100.79.3:8199/v1/audio/speech + method: POST + content_type: application/json + model: + id: k2-fsa/OmniVoice + revision: null + image: local/omnivoice:latest + fields: + - name: input + type: textarea + label: Text + required: true + max_length: 5000 + - name: voice + type: select + label: Speaker Voice + source_url: http://10.100.79.3:8199/v1/audio/voices + source_jsonpath: $.voices[*] + description: > + Zero-shot clone target — a reference clip staged in + /worktank/omnivoice/voices/ (reused chatterbox voices; 33 at deploy). + Live list at /v1/audio/voices. + - name: response_format + type: select + options: [wav] + default: wav + description: 24000 Hz PCM_16 mono only; no negotiation. + response: + type: audio + mime: audio/wav + reproducibility: + seedable: false + deterministic: false + notes: > + Diffusion-LM, temperature/denoise sampled — not byte-exact, no seed exposed. + Output 24000 Hz PCM_16 mono. Voice = a cloned reference clip (clone prompt + precomputed per voice at startup; Whisper auto-transcribes the reference). + estimated_latency: + cold_start_s: 600 + warm_per_unit: "full-utterance (no streaming)" + license: "Apache-2.0" + notes: | + v1 is clone-only (voice = a staged reference clip); OmniVoice's voice-DESIGN + and language/instruct controls are not yet exposed in the wrapper. No streaming. + Voices reused from chatterbox /refs (staged into /worktank/omnivoice/voices/). + - id: qwen3-tts name: Qwen3-TTS 1.7B description: > @@ -2207,6 +2267,11 @@ reproducibility_audit: model_deterministic: true image_tag_mutable: false notes: "22050 Hz hardcoded — caller must resample." + - service: omnivoice + seedable: false + model_deterministic: false + image_tag_mutable: true + notes: "Diffusion-LM, temperature/denoise sampled — not byte-exact, no seed exposed. 24000 Hz PCM_16 mono. image local/omnivoice:latest is mutable — pin a digest for true repro. Voices = reused chatterbox /refs clones (clone prompt precomputed per voice at startup)." - service: qwen3-tts seedable: false model_deterministic: true diff --git a/playbooks/deploy-omnivoice.yaml b/playbooks/deploy-omnivoice.yaml index eb4cbb8..8e1aa38 100644 --- a/playbooks/deploy-omnivoice.yaml +++ b/playbooks/deploy-omnivoice.yaml @@ -61,6 +61,25 @@ steps: dest: "{{ compose_dir }}/Dockerfile" mode: "0644" + - name: Upload app.py (asset-engine FastAPI wrapper) + upload: + src: stacks/omnivoice/app.py + dest: "{{ compose_dir }}/app.py" + mode: "0644" + + - name: Stage chatterbox reference voices for cloning (skip _*.wav artifacts) + shell: | + set -e + mkdir -p {{ voices_dir }} + docker exec chatterbox-fast sh -c 'ls /refs/*.wav' | while read -r f; do + b=$(basename "$f") + case "$b" in _*) continue;; esac + docker cp "chatterbox-fast:$f" "{{ voices_dir }}/$b" + done + echo "staged:"; ls {{ voices_dir }} + # Skip if already staged (Emily.wav is a proxy for "voices present"). + when: "[ ! -f {{ voices_dir }}/Emily.wav ]" + - name: Upload entrypoint.sh upload: src: stacks/omnivoice/entrypoint.sh @@ -85,26 +104,24 @@ steps: - name: docker compose up -d shell: cd {{ compose_dir }} && docker compose up -d - - name: Wait for the Gradio UI to respond (allow ~20 min for weight pre-warm) + - name: Wait for /healthz (allow ~25 min for weight + Whisper pre-warm + voice cloning) shell: | - for i in $(seq 1 240); do - curl -sf -o /dev/null --max-time 3 http://localhost:{{ host_port }}/ && exit 0 + for i in $(seq 1 300); 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: Gradio UI returns 200 - shell: curl -sf -o /dev/null http://localhost:{{ host_port }}/ + - name: /healthz returns 200 + shell: curl -sf -o /dev/null http://localhost:{{ host_port }}/healthz + changed_when: "false" + + - name: /v1/audio/voices lists the reused chatterbox voices + shell: curl -sf http://localhost:{{ host_port }}/v1/audio/voices | grep -q '"voices"' changed_when: "false" - name: Container is running shell: docker inspect omnivoice --format '{{.State.Status}}' | grep -q running changed_when: "false" - - - name: Container is healthy (or still starting weights) - shell: | - s=$(docker inspect omnivoice --format '{{.State.Health.Status}}' 2>/dev/null) - echo "health: $s"; [ "$s" = healthy ] || [ "$s" = starting ] - changed_when: "false" diff --git a/stacks/omnivoice/Dockerfile b/stacks/omnivoice/Dockerfile index a8082fe..5e9a7b7 100644 --- a/stacks/omnivoice/Dockerfile +++ b/stacks/omnivoice/Dockerfile @@ -33,22 +33,23 @@ RUN pip install --no-cache-dir \ ARG OMNIVOICE_VERSION= RUN pip install --no-cache-dir "omnivoice${OMNIVOICE_VERSION:+==${OMNIVOICE_VERSION}}" huggingface_hub -# Fail the build loudly if the console script name isn't what we expect, -# rather than crash-loop at runtime. Logs the actual omni* entrypoints. -RUN echo "omni console scripts:" && (ls /opt/venv/bin | grep -i omni || true) \ - && command -v omnivoice-demo >/dev/null \ - || { echo "ERROR: omnivoice-demo CLI not found after install"; exit 1; } +# Our asset-engine wrapper deps (FastAPI stack + soundfile for WAV encoding). +RUN pip install --no-cache-dir fastapi 'uvicorn[standard]' soundfile python-multipart + +# Fail the build loudly if the runtime imports aren't satisfiable. +RUN python -c "import omnivoice, fastapi, soundfile, uvicorn; print('omnivoice wrapper deps OK')" 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 8001 -# Gradio serves HTML at / — 200 once the UI is up (weights load lazily on -# first synth; the entrypoint pre-warms them). Generous start period. -HEALTHCHECK --interval=30s --timeout=10s --start-period=900s --retries=3 \ - CMD wget -q -O /dev/null http://127.0.0.1:8001/ || exit 1 +# Our wrapper exposes /healthz (200 once model + >=1 voice are loaded). Generous +# start period: first boot pre-warms OmniVoice + Whisper ASR + clones every voice. +HEALTHCHECK --interval=30s --timeout=10s --start-period=1200s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1:8001/healthz || exit 1 ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] -CMD ["omnivoice-demo", "--ip", "0.0.0.0", "--port", "8001"] +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8001"] diff --git a/stacks/omnivoice/README.md b/stacks/omnivoice/README.md index 436e6d5..7a7b5ac 100644 --- a/stacks/omnivoice/README.md +++ b/stacks/omnivoice/README.md @@ -17,14 +17,33 @@ RTF as low as ~0.025 (≈40× real-time). **Apache-2.0** — commercially clean ## How it's served -Upstream ships **its own Gradio demo** (`omnivoice-demo`), so this stack -just runs that — no custom wrapper. That means the surface is the **Gradio -UI + Gradio API**, *not* an OpenAI-compatible `/v1/audio/speech` endpoint. +Behind our own thin **FastAPI wrapper** ([`app.py`](app.py)) — upstream ships +only a Gradio demo, which we replaced (2026-06-19) so the **asset-engine** can +consume it. Endpoints on `http://10.100.79.3:8199`: -- UI: `http://10.100.79.3:8199/` -- Programmatic: the Gradio API under `/gradio_api/` (or `/config` to - introspect). If you later want OpenAI-compat for asset-engine, add a thin - FastAPI wrapper like [`stacks/index-tts/app.py`](../index-tts/app.py). +| Endpoint | Purpose | +|---|---| +| `POST /v1/audio/speech` | OpenAI-style `{input, voice, response_format=wav}` → 24 kHz PCM_16 mono | +| `GET /v1/audio/voices` | `{"voices": [...]}` — the staged clone targets | +| `GET /healthz` | readiness (200 once model + ≥1 voice loaded) | + +The wrapper loads OmniVoice + a Whisper ASR and **precomputes a voice-clone +prompt per staged reference WAV at startup** (Whisper auto-transcribes each +reference), so per-request latency is just generation. v1 is **clone-only** — +OmniVoice's voice-*design* / language / instruct controls aren't exposed yet. + +### Voices — reused from chatterbox + +The clone references are chatterbox-fast's `/refs/*.wav`, staged into +`/worktank/omnivoice/voices/` by the deploy playbook (33 named voices at deploy; +`_*.wav` test artifacts skipped). Add more by dropping WAVs there and restarting. + +### asset-engine + +Catalogued in [`docs/asset-engine/services.yaml`](../../docs/asset-engine/services.yaml) +(`id: omnivoice`, `lifecycle.stack: omnivoice`, `voice` field sourced live from +`/v1/audio/voices`). The compose **project name is pinned to `omnivoice`** so the +liveness probe (docker-ps project-name match) sees it online. ## Placement diff --git a/stacks/omnivoice/app.py b/stacks/omnivoice/app.py new file mode 100644 index 0000000..65b255e --- /dev/null +++ b/stacks/omnivoice/app.py @@ -0,0 +1,111 @@ +"""Thin FastAPI wrapper exposing OmniVoice (k2-fsa/OmniVoice) as an OpenAI-style +TTS for the asset-engine. + +Upstream ships only a Gradio demo; we own this wrapper (same pattern as +stacks/index-tts/app.py). Voices are reference WAVs staged in +${OMNIVOICE_VOICES_DIR} (the reused chatterbox /refs/*.wav). A voice-clone prompt +is precomputed once per voice at startup (the loaded Whisper ASR auto-transcribes +each reference) and cached, so per-request latency is just generation. + +Endpoints: + GET /healthz -> readiness (200 once model + >=1 voice are loaded) + GET /v1/audio/voices -> {"voices": [, ...]} + POST /v1/audio/speech -> {input, voice, response_format=wav} -> audio/wav +""" +import glob +import io +import logging +import os +from pathlib import Path +from typing import Optional + +import numpy as np +import soundfile as sf +import torch +from fastapi import FastAPI, HTTPException +from fastapi.responses import Response +from pydantic import BaseModel + +from omnivoice import OmniVoice, OmniVoiceGenerationConfig + +logging.basicConfig(level=os.environ.get("OMNIVOICE_LOG_LEVEL", "INFO")) +log = logging.getLogger("omnivoice-api") + +CKPT = os.environ.get("OMNIVOICE_CKPT", "k2-fsa/OmniVoice") +VOICES_DIR = os.environ.get("OMNIVOICE_VOICES_DIR", "/app/voices") +ASR_MODEL = os.environ.get("OMNIVOICE_ASR_MODEL", "openai/whisper-large-v3-turbo") +NUM_STEP = int(os.environ.get("OMNIVOICE_NUM_STEP", "32")) +GUIDANCE = float(os.environ.get("OMNIVOICE_GUIDANCE_SCALE", "2.0")) + +app = FastAPI(title="OmniVoice TTS (asset-engine wrapper)") + +MODEL: Optional[OmniVoice] = None +PROMPTS: dict = {} # voice name -> VoiceClonePrompt +SR: int = 24000 + + +class SpeechRequest(BaseModel): + input: str + voice: str + response_format: str = "wav" + model: Optional[str] = None # ignored (single model); OpenAI-compat field + + +@app.on_event("startup") +def _load() -> None: + global MODEL, SR + device = "cuda" if torch.cuda.is_available() else "cpu" + log.info("loading OmniVoice %s on %s (asr=%s)", CKPT, device, ASR_MODEL) + MODEL = OmniVoice.from_pretrained( + CKPT, device_map=device, load_asr=True, asr_model_name=ASR_MODEL + ) + SR = int(getattr(MODEL, "sampling_rate", 24000)) + + for wav in sorted(glob.glob(os.path.join(VOICES_DIR, "*.wav"))): + name = Path(wav).stem + if name.startswith("_"): + continue # skip _*.wav (chatterbox test/deploy artifacts) + try: + PROMPTS[name] = MODEL.create_voice_clone_prompt(ref_audio=wav) + log.info("voice ready: %s", name) + except Exception as exc: # noqa: BLE001 + log.warning("voice %s failed to load: %s", name, exc) + log.info("%d voices loaded: %s", len(PROMPTS), sorted(PROMPTS)) + + +@app.get("/healthz") +def healthz(): + if MODEL is None or not PROMPTS: + raise HTTPException(status_code=503, detail="not ready") + return {"status": "ok", "voices": len(PROMPTS), "sampling_rate": SR} + + +@app.get("/v1/audio/voices") +def voices(): + return {"voices": sorted(PROMPTS.keys())} + + +@app.post("/v1/audio/speech") +def speech(req: SpeechRequest): + if MODEL is None: + raise HTTPException(status_code=503, detail="model not loaded") + if not req.input or not req.input.strip(): + raise HTTPException(status_code=400, detail="input is empty") + if req.response_format not in ("wav", "", None): + raise HTTPException(status_code=400, detail="only response_format=wav is supported") + + prompt = PROMPTS.get(req.voice) + if prompt is None: + raise HTTPException( + status_code=404, + detail=f"unknown voice '{req.voice}'; have {sorted(PROMPTS)}", + ) + + gen = OmniVoiceGenerationConfig(num_step=NUM_STEP, guidance_scale=GUIDANCE, denoise=True) + out = MODEL.generate(text=req.input.strip(), voice_clone_prompt=prompt, generation_config=gen) + audio = out[0] if isinstance(out, (list, tuple)) else out + audio = np.asarray(audio, dtype=np.float32) + + 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/omnivoice/compose.yaml b/stacks/omnivoice/compose.yaml index 5f01803..1a4fe84 100644 --- a/stacks/omnivoice/compose.yaml +++ b/stacks/omnivoice/compose.yaml @@ -1,9 +1,10 @@ # OmniVoice (k2-fsa/OmniVoice) — zero-shot, massively-multilingual (600+ # language) voice-cloning + voice-design TTS, diffusion-LM, Apache-2.0. -# Served via upstream's own Gradio demo. NOTE: this exposes the Gradio UI -# + Gradio API, NOT an OpenAI-compatible /v1/audio/speech endpoint — wrap -# it later (à la stacks/index-tts/app.py) if asset-engine integration is -# wanted. For now it's a "stand it up and try it" UI. +# Served behind our OWN thin FastAPI wrapper (stacks/omnivoice/app.py) exposing +# OpenAI-compatible /v1/audio/speech (+ /v1/audio/voices, /healthz) so the +# asset-engine can consume it. Upstream ships only a Gradio demo; the wrapper +# replaced it (2026-06-19). Voices are reference WAVs in ${OMNIVOICE_VOICES_DIR} +# (the reused chatterbox /refs/*.wav); clone prompts are precomputed at startup. # # Build: local image from the Dockerfile in this dir. Weights download # from HF (k2-fsa/OmniVoice) on first boot into ${OMNIVOICE_CACHE_DIR}. @@ -14,6 +15,10 @@ # # All tunables live in .env — edit that, not this file. +# Compose project name MUST equal the catalog lifecycle.stack ("omnivoice") or the +# asset-engine liveness probe (docker ps project-name match) shows it OFFLINE. +name: omnivoice + services: omnivoice: image: local/omnivoice:${OMNIVOICE_TAG:-latest} @@ -34,15 +39,15 @@ services: - ${OMNIVOICE_CACHE_DIR}:/app/hf_cache - ${OMNIVOICE_VOICES_DIR}:/app/voices healthcheck: - test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:8001/ || exit 1"] + test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:8001/healthz || exit 1"] interval: 30s timeout: 10s retries: 3 - # First boot: weight pre-warm download (entrypoint) + CUDA warmup. - start_period: 900s + # First boot: OmniVoice + Whisper ASR pre-warm + cloning every staged voice. + start_period: 1200s labels: - homepage.group=AI Systems - homepage.name=OmniVoice - homepage.icon=mdi-account-voice - - homepage.description=Zero-shot multilingual voice-cloning TTS (irv-ml1, 3090) - - homepage.href=http://10.100.79.3:${OMNIVOICE_PORT} + - homepage.description=Zero-shot multilingual voice-cloning TTS, OpenAI /v1/audio/speech (irv-ml1, 3090) + - homepage.href=http://10.100.79.3:${OMNIVOICE_PORT}/docs