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:
@@ -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")
|
||||
Reference in New Issue
Block a user