Case Study
TTS Wrapper
A unified web UI and API proxy that puts three local text-to-speech engines behind a single interface — no cloud, no API keys, no per-character fees.
The Problem
Local TTS is fragmented. Every engine has its own API, its own Python package, its own quirks. Want to try Supertonic's neural voices alongside Kyutai's multilingual models? That's two installs, two ports, two different HTTP interfaces to learn. KittenTTS is another Python API entirely. There's no single UI that lets you audition all three side by side.
Cloud TTS (ElevenLabs, OpenAI, Google) solves the uniformity problem but introduces latency, per-character costs, data privacy concerns, and an internet dependency. For offline-first workflows — accessibility tools, language learning apps, game dialogue generation — cloud isn't an option.
The Solution
TTS Wrapper wraps three local TTS engines behind one Node.js server with a dark-themed web UI and a consistent REST API. Pick a provider, choose a voice, type text, get speech — all running locally with zero cloud dependencies.
Three Engines, One UI
Supertonic neural TTS, Kyutai pocket-tts, and KittenTTS in a single dropdown selector. Switch providers and voices without restarting anything.
34+ Voices
English, Italian, Spanish, German, Portuguese, and French across Kyutai and KittenTTS, plus Supertonic's full voice roster.
API Proxy
Each provider runs as a background process. The Node server proxies /api/tts requests to the right engine and returns a unified WAV response.
Chunk Cache
Long scripts are split into chunks. Unchanged chunks reuse cached audio. The final WAV is stitched in order — edit a paragraph without regenerating the whole script.
Auto-Install
Missing Python packages install on first run. node server.js is the only command — it creates a venv, installs dependencies, and starts all three engines.
Download & Retry
Generated audio can be played in-browser, downloaded as WAV, or regenerated with a different voice or provider.
Architecture
The design is intentionally simple — a thin Node.js proxy that delegates to three independent TTS backends:
Node.js Proxy
server.js spawns Supertonic and Kyutai as subprocesses, calls KittenTTS through its Python API, and serves the web UI as a single HTML page with inline CSS and JS. No build step, no bundler, no framework.
Process Isolation
Each engine runs in its own process. If Kyutai crashes, Supertonic and KittenTTS stay up. The server health-checks each provider and reports availability in the UI.
Deterministic Chunking
Text is split into chunks by sentence boundaries before synthesis. Each chunk is hashed by provider, voice, and text — identical input always produces a cache hit.
Cache Outside the Repo
WAV files are cached under ~/.cache/tts-wrapper/ — not in the project directory. The cache is non-destructive: old chunks are never overwritten or deleted.
Challenges
Engine Incompatibility
All three engines have different Python version requirements. Supertonic needs Python 3.12, Kyutai works with 3.10-3.12, and KittenTTS requires specific PyTorch builds. The auto-venv setup handles this by pinning Python 3.12 and installing compatible wheel versions.
Subprocess Lifecycle
Each engine starts as a background HTTP server. Managing startup time, health checks, and graceful shutdown required careful subprocess orchestration. A startup timeout kills orphan processes and reports clear errors instead of hanging.
Cache Invalidation
Caching by chunk hash is easy. Knowing when to invalidate is hard. The solution: never invalidate. Each request generates a new cache key. If you change a single sentence, only that sentence's chunk is regenerated. The cache grows monotonically — clear it manually if disk matters.
Multi-Engine Latency
Different engines have different cold-start times. Supertonic loads models lazily; Kyutai takes 10+ seconds on first inference. The UI shows a spinner per provider and the cache mitigates repeat requests.
Outcome
- Single-command setup —
node server.jsinstalls everything and starts all three engines - Zero cloud dependencies — all inference runs locally, no API keys, no data leaves the machine
- 34+ voices across 6 languages from one unified interface
- Chunk-level caching makes iterative script editing fast — only changed sentences regenerate audio
- Consistent API format across providers means swapping engines requires changing one field in the request body
What I Learned
- Wrapping three similar-but-not-identical APIs reveals the interface that matters: text in, audio out. Everything else (voice selection, provider choice, caching) is orthogonal and composable.
- A Node.js subprocess orchestrator is simpler than a Python supervisor. Node's async model maps naturally to managing multiple child processes with health checks and restarts.
- Deterministic chunking with content-addressable caching is a general solution that applies to any media generation task — not just TTS.
- Local AI inference is production-viable today. The models are good enough, the hardware requirements keep dropping, and the privacy/offline benefits are irreplaceable.
Interested in this kind of work?
I build tools that bridge the gap between powerful local models and practical user-facing interfaces.