Spaces:
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.
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
# 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. Request flow:
- Model load (module top-level) β
OmniVoice.from_pretrained()runs once at import time.SAMPLE_RATEis read from the loaded model. This means any import ofapp.pytriggers the full model download/load; there is no lazy init. TTSRequest(pydantic) β the shared request schema for/tts,/tts/stream, and/ws/tts./tts/cloneuses multipart form fields instead and constructs aTTSRequestinternally.- Voice-design attribute layer β
gender/age/pitch/style/accent/dialectare normalized then composed into a single OmniVoiceinstructstring:_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 upstreamomnivoice/cli/demo.pyconvention; preserve it.- A caller-supplied
instructstring bypasses this whole layer (raw override).
_synthesize()β the single inference path for all endpoints. Returns int16 PCM (1-D ndarray). Voice cloning is the same path withvoice_clone_promptadded: either a prebuilt prompt passed in (registry reuse) or one built inline viamodel.create_voice_clone_prompt()from aref_audio_path(ad-hoc/tts/clone).- Voice registry (clone once, reuse many):
POST /voicesrunscreate_voice_clone_prompt()a single time and caches it in_VOICE_CACHE(a thread-safeOrderedDictLRU, lock =_VOICE_LOCK, cap =OMNIVOICE_MAX_VOICES, default 50) under a client-chosen name. AnyTTSRequestwithvoice_idset then reuses the cached prompt across/tts,/tts/stream, and/ws/ttsβ skipping all reference re-encoding. Unknownvoice_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.
- Voice registry (clone once, reuse many):
- WAV framing β
_wav_header()builds a 44-byte int16-mono header;/ttswraps the full buffer,/tts/streamand/ws/ttsemit 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.3is required β older versions break thehiggs_audio_v2_tokenizerthatomnivoiceimports.torch==2.8.0matches the upstream Space. The Dockerfile installs torch beforerequirements.txtso 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(0skips ~1 GB whisper download β but then cloning/registration must supplyref_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 viaHF_HOME/TORCH_HOMEin the Dockerfile);/datais the writeable persistent mount. - Generation params carry validated bounds:
nfe_steps4β64,guidance0β4. Highernfe_steps= better quality, slower. Loweringnfe_steps(e.g. 16) is the main lever for faster responses / better RTF. /tts/streamand/ws/ttsare pseudo-streaming. The upstreamomnivoicelibrary has no incremental decoding βgenerate()returns the complete audio. Both endpoints call_synthesizefully (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.