All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
-
VLLM_GPU_MEM_UTILdefault lowered from0.85→0.45, freeing ~4.2 GB of VRAM for co-resident GPU tenants (uttera-stt-hotcold, uttera-sentiment-vllm, comfyui, in-flight F5 / Kokoro smokes...) with zero throughput regression. The engine sizes the KV cache block pool asnum_kvcache_blocks = (total_gpu_memory × util - peak) / per_block_size— i.e. it consumes the WHOLE available budget regardless of whethermax_num_seqs × max_model_lenneeds that much. Empirical burst-64 / burst-256 sweep on sphinx (RTX 5090) with the canonicaluttera-tts-40wbenchmark corpus proved throughput is flat across 0.40 / 0.45 / 0.85 while VRAM scales linearly.Benchmark (2026-04-23, canonical uttera-tts-40w corpus, HOT-only inference paths, cache wiped before each run):
util VRAM burst-64 wall burst-64 rps burst-256 wall burst-256 rps 0.30 fail — (assertion) — — — 0.40 22.0 GB 20.1 s 3.19 60.9 s 4.20 0.45 23.6 GB 20.6 s 3.11 59.1 s 4.33 0.85 27.8 GB 20.8 s 3.08 (base) 64.4 s 3.98 (base) 0.45 is chosen over 0.40 because it leaves ~1 GB of headroom above the startup assertion floor (which can move up / down depending on model HF-cache state and other GPU tenants at boot time).
-
.env.examplerewritten with the measured table above, a per-GPU-class starting-point matrix, and explicit documentation ofengine/model_runner.py:230'savailable_budgetformula.
- Applied to
sphinx.espuny.net2026-04-23 via.envupdate +systemctl restart uttera-tts-vllm. Post-change: tts-vllm process 23.6 GB VRAM (down from 27.8 GB), burst-64 rps 3.11, burst-256 rps 4.33 (100 % ok in both), functional parity with v1.4.2.
- A
VLLM_ENFORCE_EAGERenv var was prototyped during investigation to disable CUDA-graph capture (whose buffers are allocated outside theavailable_budgetaccounting and expand into whatever free VRAM remains). Measured impact: ~4 GB VRAM savings at the cost of 2-3× throughput regression (rps 3.08 → 0.96 at util=0.35 + enforce_eager=true). Not a good trade for a production TTS service. The knob was removed before landing. Future 16-GB deployments should reducemax_num_seqsinstead (caps the number of CUDA graph sizes captured).
setup.shnow pinstorch/torchaudioto2.8.xand pre-installs the officialflash-attn 2.8.3release wheel from GitHub (matching the resolved torch minor, Python version and CXX11-ABI flag), before runningpip install --no-build-isolation -r requirements.txt.
- flash-attn has no pre-built wheels on PyPI and its source build
requires the host
nvcc's CUDA major to match whattorchwas compiled against. On Ubuntu 25.10 hosts (which only shipcuda-toolkit-13-0-local) paired withtorch-cu128, the build aborts withRuntimeError: The detected CUDA version (13.0) mismatches the version that was used to compile PyTorch (12.8). - Upstream flash-attn v2.8.3 does not publish a torch 2.9 wheel yet, so
the previous unconstrained
torch>=2.5.0,<2.10.0pin resolved to 2.9.x and fell back to the source build. Pinning to 2.8.x restores a path that has a matching pre-built wheel.
- The ABI and torch-minor are auto-detected at install time so the script keeps working across venv re-builds without hard-coding a specific wheel filename.
- If the release URL 404s (e.g. a future torch upgrade), the script prints a remediation hint and lets pip fall through to its normal behaviour — which will try the source build and fail loudly on CUDA mismatch, surfacing the underlying problem instead of hiding it.
setup.shnow preferspython3.11, thenpython3.12, then falls back to systempython3with a warning. Reason: the upstreamnano-vllm-voxcpmpackage on PyPI declaresRequires-Python >=3.10,<3.13, so fresh installs on py3.13+ systems (e.g. Ubuntu 25.10 which ships only py3.13) fail withERROR: No matching distribution found for nano-vllm-voxcpm>=2.0.0. The 3.13+ fallback now prints an explicit remediation hint pointing atpyenv/ the deadsnakes PPA. No runtime code changes — server behaviour is identical.
Prometheus /metrics endpoint. Additive only — all existing
endpoints unchanged.
GET /metrics— OpenMetrics-format scrape endpoint using the defaultprometheus_clientglobal registry. Scrape with Telegraf'sinputs.prometheusplugin, Prometheus itself, or any other OpenMetrics-compatible consumer.- HTTP-level metrics (bounded cardinality — unknown paths fall
into
"other"):uttera_tts_requests_total{endpoint, method, status}uttera_tts_request_duration_seconds{endpoint, method}— buckets 25 ms → 60 suttera_tts_inflight_requests— Gauge reflecting_in_flight
- TTS-specific metrics:
uttera_tts_synthesis_total{response_format, route, cache}— Counter broken down by the output format (mp3/wav/pcm/opus/flac), lane (HOT/CACHE/ADHOC) and cache decision (HIT/MISS/BYPASS/ADHOC/DISABLED). The labels match theX-Route/X-Cacheresponse headers exactly.uttera_tts_characters_synthesised_total{response_format}— Counter summinglen(req.input)for every successful synthesis. Billing / throughput proxy. Cache hits do NOT re-bill (the caller already paid when the entry was first populated).uttera_tts_inference_duration_seconds{op}— Histogram per model call kind:synthesis(nano-vllm-voxcpm generation) andffmpeg_encode(output-format transcoding). Separates GPU time from CPU-encoder time.uttera_tts_voices_loaded— Gauge of voice names resident in VRAM (latents precomputed), refreshed on every/metricsscrape.
- State gauges (refreshed on every
/metricsscrape so they're always current):uttera_tts_engine_ready— 1 once the engine has passed startup, 0 during load.
uttera_tts_errors_total{type}— Counter of errors by cause. Types:model(uncaught synthesis exception),encoding(ffmpeg transcode failure). Generic 4xx errors stay visible via thestatuslabel onrequests_total.uttera_tts_build_info{version, engine, model}— Gauge set to1with the runningSERVER_VERSION, engine (nano-vllm-voxcpm), and the actualVOXCPM_MODELas labels, so dashboards can show version + model in the field without a separate lookup.
- The streaming endpoint (
/v1/audio/speech/stream) incrementssynthesis_totalwithroute="HOT"+cache="DISABLED"and records the total stream duration under thesynthesisop (streaming bypasses the cache entirely and emits audio as the engine generates it). - Cache hits increment
synthesis_total{route="CACHE",cache="HIT"}but do NOT tickcharacters_synthesised_total.
- New runtime dep:
prometheus-client>=0.20.0. SERVER_VERSIONbumped to1.4.0.
/v1/audio/speech,/v1/audio/speech/stream,/v1/voices,/admin/reload-voices,/v1/models,/healthbehave identically to v1.3.0. The/healthbody still reportsin_flight/total_completed/total_errorsfor callers that have them hardcoded; Prometheus counters are the new canonical observability path.
-
Default port migrated from
5100→9004in lockstep with the siblinguttera-tts-hotcoldv2.3.0. Canonical Uttera-stack scheme: TTS services on9004, STT services on9005. The Gatekeeper and clients route by service family; swapping hotcold ↔ vllm is a backend change, not a port change.Why not keep
5100: pairing TTS=5100 with STT=9005 (STT had to move off 5000 due to macOS AirPlay / Docker Registry v2 collisions) was asymmetric. Both families now live in the9000-9099range (IANA "User Ports", no canonical assignment, no mainstream collisions).Artefacts updated:
PORTenv default inmain_tts.py,DockerfileEXPOSE/CMD,docker-compose.ymlport mapping + healthcheck,.env.example,README.md,API.md,.github/workflows/ci.yml, issue template health-probe URL.
Deployments with explicit PORT env var: no change required.
Deployments on the old default (:5100):
- Repoint your Gatekeeper / reverse proxy at
:9004. - Or set
PORT=5100in your env to preserve the old endpoint. - Docker users: update your
-pflag ordocker-compose.yml.
uttera-tts-hotcoldv2.3.0 adopts the same9004port.uttera-stt-hotcoldv2.3.0 anduttera-stt-vllmv1.3.0 adopt9005for the STT pair.
OpenAI-compatibility polish sweep. Driven by a full endpoint validation run against v1.1.0. Found one CRITICAL bug (adhoc voice cloning was silently broken) plus seven polish items, all fixed. Behaviour is backward-compatible except for adhoc cloning which now actually works — v1.1.0 clients that thought they were cloning a voice were in fact getting the default voice.
- [CRITICAL] Adhoc voice cloning was silently disabled. The
isinstance(spec, UploadFile)check importedfastapi.datastructures.UploadFilebut Starlette's form parser returnsstarlette.datastructures.UploadFile— in FastAPI 0.136+ / Starlette 1.0+ these are distinct classes (they were aliases in earlier versions). Theisinstancecheck always returned False, sospeaker_wavnever latched, the handler silently used the default voice, and the request hit the regular audio cache. Responses carriedX-Route: HOT/X-Cache: MISS|HITinstead of the documentedX-Route: ADHOC/X-Cache: ADHOC. Fixed by matching either class (plus a duck-type fallback for future-proofing) — see_is_upload_file(). Users who relied on v1.1.0 adhoc cloning: upgrade to v1.2.0 to actually get cloned voices. - Bogus
custom_voice_filebodies (non-audio, empty) were accepted and silently produced default-voice audio — same root cause as (1). Now rejected with HTTP 400 and a trimmed decode error (the nano-vllm-voxcpm traceback no longer leaks to clients). - JSON body without
inputraised apydantic.ValidationErrorthat bubbled up as HTTP 500 with no body. Now caught and converted to HTTP 422 with the pydantic error detail. speedrange validation. Values outside[0.25, 4.0](OpenAI spec) were silently accepted. Now → HTTP 422 with an explicit range message.speedis now actually applied. The engine doesn't support variable-rate synthesis natively, sospeed != 1.0was silently ignored up to v1.1.0._encode_audio()now routes through an ffmpegatempofilter chain (chained for values < 0.5 or > 2.0) for every output format —mp3,wav,pcm,opus,flac.cfg_valuerange validation. Values outside[0.5, 5.0](VoxCPM2 safe range) were accepted and could produce NaN / garbage from the diffusion solver. Now → HTTP 422.HEAD /healthreturned HTTP 405. Now accepts both GET and HEAD via@app.api_route(methods=["GET", "HEAD"]).- No CORS middleware. Added opt-in
CORSMiddlewaregated on theCORS_ALLOW_ORIGINSenv var (comma-separated list, or"*"). Disabled by default — API-first deployments don't need it, and enabling it unconditionally broadens the attack surface.
SERVER_VERSIONbumped to1.2.0.
- 128-concurrent regression burst: 128/128 OK, ~12 rps (same order of magnitude as v1.1.0; minor variance from cold cache in the run).
- Adhoc voice cloning via
custom_voice_fileandspeaker_wavalias both emitX-Route: ADHOCand generate audio in the uploaded voice. speed=2.0produces ~half-duration audio;speed=0.5produces ~double-duration audio (verified withffprobe).- CORS preflight + actual POST emit the expected headers when
CORS_ALLOW_ORIGINSis set.
- Clients that unknowingly relied on v1.1.0's silent fallback to
default voice will now receive real cloned audio from their
uploaded
custom_voice_file. That is the documented contract; if the upload fails decode, the server now returns HTTP 400 instead of quietly using the default voice.
- Canonical adhoc-cloning field renamed to
custom_voice_file, symmetric withuttera-tts-hotcoldv2.1.0. The same client code —curl -F custom_voice_file=@sample.wav ...— now works against either backend. The v1.0.0 namespeaker_wavis still accepted as an alias (this is a v1.x additive change, not a breaking rename); if both field names are present on the same request, the canonicalcustom_voice_filewins.
- The field is format-agnostic. Any libsndfile-readable file (wav,
flac, mp3, ogg, m4a) works — the old
speaker_wavname was a misleading Coqui carry-over.
First public stable release. uttera-tts-vllm graduates from pre-alpha
after end-to-end validation on NVIDIA RTX 5090 (Blackwell, 32 GB)
against the 40-prompt Spanish corpus in
uttera-benchmarks Run 6:
latency 20/20 p50 1.8 s / p95 2.5 s burst@8 8/8 p50 3.3 s burst@64 64/64 p50 11.7 s burst@256 256/256 p50 33.9 s burst@512 512/512 p50 64.2 s burst@1024 1024/1024 p50 123 s ← zero failures at every N sustained 600/600 p50 3.3 s / p95 4.0 s (2 rps × 5 min)
Throughput saturates near 4.3 rps from N = 256 upwards; sustained at 50 % of burst@64 capacity stays flat with no drift over the window.
POST /v1/audio/speech— OpenAI-compatible JSON body or multipart form. Supports adhoc voice cloning viaspeaker_wavfile field.POST /v1/audio/speech/stream— chunkedaudio/wavstreaming. Starts emitting PCM as soon as the engine produces it; no caching.GET /v1/voices,POST /admin/reload-voices,GET /v1/models,GET /health.- Audio cache keyed by MD5 of
(model, voice, speed, format, params, text). Client opt-out per-request via{"cache": false}body orCache-Control: no-cacheheader. Every response carriesX-Cache: HIT | MISS | BYPASS | ADHOC | DISABLED. X-Route: CACHE | HOT | ADHOCon non-cache responses.
VLLM_GPU_MEM_UTIL(default 0.85)VLLM_MAX_NUM_SEQS(default 64)VLLM_MAX_NUM_BATCHED_TOKENS,VLLM_MAX_MODEL_LEN,VOXCPM_INFERENCE_TIMESTEPS
- The endpoint paths, request/response schemas, env var names and
their defaults, and the
X-Cache/X-Routeheader values are frozen — any breaking change to these requires a v2.0.0. - Additive extensions (new optional body fields, new
X-Cache/X-Routevalues) are v1.x minor releases. - Bug fixes are v1.0.x patch releases.
- JSON-body cache opt-out:
{"cache": false}in the request body skips both the read and the write side of the audio cache for that single request. Symmetric with the existingCache-Control: no-cacheHTTP header support and withuttera-tts-hotcoldv2.0.3. Multipart/form submissions acceptcache=0/false/no/off.
- Per-request cache bypass via the standard
Cache-Control: no-cache(orno-store) request header. Response headerX-Cache: HIT | MISS | BYPASS | ADHOC | DISABLEDdocuments the cache decision on every/v1/audio/speechresponse. Symmetric with the feature added inuttera-tts-hotcoldv2.0.2 so clients can use the same bench/retry logic across backends.
setup.shpre-install list was still incomplete.flash-attn'ssetup.pyimportstorch,packaging,psutil, andninja; v0.1.1 covered only torch and packaging, so the build died onModuleNotFoundError: No module named 'psutil'. Addedpsutilandninjato the pre-install list.
setup.shfailed duringpip install -r requirements.txtbecauseflash-attn(a transitive dep ofnano-vllm-voxcpm) requirestorchat build time, but pip's default PEP 517 build-isolation sandbox does not have it.setup.shnow pre-installs torch and torchaudio, then runspip install --no-build-isolation -r requirements.txtso flash-attn picks up the torch in the venv.requirements.txtdrops the torch pins (they live in the pre-install step).
v0.1.0 never made it past the first install on a clean machine.
First scaffold release. Pre-alpha — active development. API surface may still change before v1.0.0.
- Single-process FastAPI server embedding nano-vllm-voxcpm's
AsyncVoxCPM2ServerPoolin-process. Concurrency handled entirely by the engine's internal continuous batching — no hot/cold worker pool. - OpenAI-compatible endpoints:
POST /v1/audio/speech— JSON body (OpenAI classic) ormultipart/form-data(enables adhoc voice cloning viaspeaker_wavfile upload). Response formats:mp3,wav,pcm,opus,flac. Cache-key identical to uttera-tts-hotcold: MD5 of(model, voice, speed, format, params, text). Cache is bypassed for adhoc requests.POST /v1/audio/speech/stream— chunkedaudio/wavstreaming. UsesAsyncVoxCPM2ServerPool.generate()directly; emits a 0xFFFFFFFF-length WAV header immediately, then raw PCM chunks as the engine produces them.GET /v1/voices— lists every voice whose latents are currently resident in memory, plus the configured default.POST /admin/reload-voices— re-readsvoices.jsonand recomputes latents for new entries without restarting the engine.GET /v1/models,GET /health.
- File-based voice registry (design Model A):
voices.jsonat the repo root mapsname → relative pathinsideassets/voices/{standard,elite}/. Latents are precomputed at startup and kept in memory. - Adhoc voice cloning (design Model C):
POST /v1/audio/speechwith aspeaker_wavmultipart file field clones the voice just for that request. No persistence. Cache is bypassed. - Audio cache with identical semantics to uttera-tts-hotcold:
AUDIO_CACHE_DIR+CACHE_TTL_MINUTES. Set TTL to 0 to disable. - Optional Redis self-registration (parity with every other Uttera
repo). When
REDIS_URLis set, publishes{load_score, accepts_requests, host, port, version, engine="nano-vllm-voxcpm", model, ts}totts:nodes:{NODE_ID}. - Engine tuning env vars:
VLLM_GPU_MEM_UTIL,VLLM_MAX_NUM_SEQS,VLLM_MAX_NUM_BATCHED_TOKENS,VLLM_MAX_MODEL_LEN,VOXCPM_INFERENCE_TIMESTEPS. Defaults tuned for RTX 5090 (32 GB). - Asset pre-provisioning:
setup_assets.shpulls the VoxCPM2 model (~1.7 GB) and the 6 standard OpenAI reference voices (alloy/echo/fable/onyx/nova/shimmer) before the first request. - OSS scaffolding shared with the rest of the Uttera stack:
LICENSE(Apache-2.0),NOTICE,AUTHORS.md,CODE_OF_CONDUCT.md,SECURITY.md,CONTRIBUTING.md,CODEOWNERS,.github/templates,docs/img/banner, Dockerfile +docker-compose.ymlwith NVIDIA GPU passthrough, systemd unit (uttera-tts-vllm.yml), CI workflow (lint + structure + optional GPU smoke).
tests/with a benchmark harness. Will be ported fromuttera-benchmarksin a later release.- Dynamic voice registry (design Model B:
POST /v1/voicesto upload + persist cloned voices,DELETE /v1/voices/{id}to remove). Documented in ROADMAP. - The GPU smoke CI job is defined but gated off (
if: false) because no self-hosted GPU runner is configured yet.