Spaces:
Sleeping
Sleeping
| # 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. | |