OmniVoice_TTS_API / CLAUDE.md
aspirant312's picture
Add clone-once voice registry (POST /voices + voice_id reuse)
912d5e5
|
Raw
History Blame Contribute Delete
5.85 kB
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
A self-hosted FastAPI wrapper around the `k2-fsa/OmniVoice` TTS model, packaged as a HuggingFace Space (Docker SDK, T4 GPU). The model is loaded **in-process at startup** β€” there is no proxying to the public Gradio Space. The entire service is one file: [app.py](app.py).
Primary use case is Urdu / Punjabi synthesis (plus 600+ languages OmniVoice supports), consumed by a downstream voice-agent client that retries 502/503/504 with backoff to absorb cold starts.
## Commands
```bash
# Run locally (downloads ~2 GB model to HF_HOME on first request)
pip install -r requirements.txt # torch is NOT here β€” see below
uvicorn app:app --host 0.0.0.0 --port 7860
# torch must be installed separately to match upstream's CUDA build:
pip install torch==2.8.0 torchaudio==2.8.0 --extra-index-url https://download.pytorch.org/whl/cu128
# Build the Space image
docker build -t omnivoice-tts .
# Smoke-test endpoints
curl localhost:7860/health
curl -X POST localhost:7860/tts -H 'Content-Type: application/json' \
-d '{"text":"ؒپ کا شکریہ","language":"Urdu","gender":"Female"}' --output out.wav
```
There is no test suite, linter config, or build step beyond the Docker image.
## Architecture
Everything lives in [app.py](app.py). Request flow:
1. **Model load (module top-level)** β€” `OmniVoice.from_pretrained()` runs once at import time. `SAMPLE_RATE` is read from the loaded model. This means *any* import of `app.py` triggers the full model download/load; there is no lazy init.
2. **`TTSRequest` (pydantic)** β€” the shared request schema for `/tts`, `/tts/stream`, and `/ws/tts`. `/tts/clone` uses multipart form fields instead and constructs a `TTSRequest` internally.
3. **Voice-design attribute layer** β€” `gender`/`age`/`pitch`/`style`/`accent`/`dialect` are normalized then composed into a single OmniVoice `instruct` string:
- `_normalize_label()` maps friendly aliases (`"female"`, `"young"`, `"low"`) β†’ canonical bilingual labels (`"Female / ε₯³"`).
- `_attr_part()` then picks the **English half** of each label β€” *except dialects, where it picks the Chinese half* (`is_dialect=True`). This mirrors the upstream `omnivoice/cli/demo.py` convention; preserve it.
- A caller-supplied `instruct` string bypasses this whole layer (raw override).
4. **`_synthesize()`** β€” the single inference path for all endpoints. Returns int16 PCM (1-D ndarray). Voice cloning is the same path with `voice_clone_prompt` added: either a prebuilt prompt passed in (registry reuse) or one built inline via `model.create_voice_clone_prompt()` from a `ref_audio_path` (ad-hoc `/tts/clone`).
- **Voice registry (clone once, reuse many)**: `POST /voices` runs `create_voice_clone_prompt()` a single time and caches it in `_VOICE_CACHE` (a thread-safe `OrderedDict` LRU, lock = `_VOICE_LOCK`, cap = `OMNIVOICE_MAX_VOICES`, default 50) under a client-chosen name. Any `TTSRequest` with `voice_id` set then reuses the cached prompt across `/tts`, `/tts/stream`, and `/ws/tts` β€” skipping all reference re-encoding. Unknown `voice_id` β†’ 404. The cache is in-process/in-memory only (lost on restart). `_resolve_voice()` does the lookup-or-404 for HTTP endpoints; the WebSocket handler checks inline and replies with an error frame instead.
5. **WAV framing** β€” `_wav_header()` builds a 44-byte int16-mono header; `/tts` wraps the full buffer, `/tts/stream` and `/ws/tts` emit the header then chunk raw PCM (~0.2 s HTTP frames, ~0.5 s WS frames).
Inference is always wrapped in `asyncio.to_thread(_synthesize, ...)` so the event loop stays responsive β€” keep new synthesis calls off the main thread the same way.
## Conventions and gotchas
- **The attribute vocabulary sets** (`GENDERS`, `AGES`, `PITCHES`, `STYLES`, `ACCENTS`, `DIALECTS`) must stay in sync with the upstream Gradio demo's `"English / δΈ­ζ–‡"` labels. The `/` (GET root) descriptor advertises these to clients, so changes here are part of the public API.
- **Dependency pins are load-bearing.** `transformers==5.3` is required β€” older versions break the `higgs_audio_v2_tokenizer` that `omnivoice` imports. `torch==2.8.0` matches the upstream Space. The Dockerfile installs torch *before* `requirements.txt` so other deps don't drag in a different CUDA variant. Don't unpin these casually.
- **Config is env-var driven** (read at module load): `OMNIVOICE_MODEL`, `OMNIVOICE_DEVICE` (auto CPU fallback), `OMNIVOICE_LOAD_ASR` (`0` skips ~1 GB whisper download β€” but then cloning/registration **must** supply `ref_text`), `OMNIVOICE_MAX_VOICES` (registry LRU cap, default 50), `LOG_LEVEL`, `PORT` (7860, the HF Spaces standard).
- **HF Spaces persistence**: caches go to `/data/.cache/...` (set via `HF_HOME`/`TORCH_HOME` in the Dockerfile); `/data` is the writeable persistent mount.
- **Generation params** carry validated bounds: `nfe_steps` 4–64, `guidance` 0–4. Higher `nfe_steps` = better quality, slower. Lowering `nfe_steps` (e.g. 16) is the main lever for faster responses / better RTF.
- **`/tts/stream` and `/ws/tts` are pseudo-streaming.** The upstream `omnivoice` library has no incremental decoding β€” `generate()` returns the *complete* audio. Both endpoints call `_synthesize` fully (blocking until the whole utterance is generated) and *then* chunk the finished PCM buffer. So they reduce transmission framing latency but **do not** lower time-to-first-audio or improve RTF. Don't market them as real-time streaming.
## Deploying
This repo *is* the Space. Push to the HF Space remote (`huggingface.co/spaces/ebitlogix/omnivoice-tts`) and set hardware to `t4-small`+ in Settings β†’ Hardware. README front-matter (`sdk: docker`, `suggested_hardware: t4-small`) configures the Space.