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.
This commit is contained in:
2026-06-18 23:03:20 -07:00
parent 984b72757f
commit 06eb487a26
6 changed files with 255 additions and 37 deletions
+65
View File
@@ -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
+28 -11
View File
@@ -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"
+11 -10
View File
@@ -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"]
+26 -7
View File
@@ -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
+111
View File
@@ -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": [<name>, ...]}
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")
+14 -9
View File
@@ -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