Files
esh-pfi-infrastructure/stacks/omnivoice/app.py
T
vh 06eb487a26 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.
2026-06-18 23:03:20 -07:00

112 lines
4.1 KiB
Python

"""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")