
twelve adapters in eight days
the voice and TTS provider arc that shipped March 1–8: how a unified adapter interface made adding the twelfth provider as cheap as adding the third.
the bet on a unified provider interface paid off in days, not quarters. the voice and TTS expansion arc that ran from March 1–8 2026 added twelve provider adapters across three repos. the last adapter took roughly the same engineering time as the third, which is the signal that the abstraction held.
the control-plane decision in khaos-tools
the starting point for the arc was a problem in khaos-tools’ voice catalog: it was embedded. a static list of voices compiled into the binary. when Vokda updated their catalog — new voices, deprecated ones, pricing tier changes — the only path to refresh was a khaos-tools release.
7e190150 introduced control-plane HTTP fetch for the Vokda catalog. the fetch runs at startup and on a configurable refresh interval; the embedded catalog shrinks to a 20-voice safety net that activates only when the fetch fails. 427dd7cf added the voice update CLI subcommand so operators could trigger a manual refresh. 8daaa429 and 032ba754 handled the edge cases: stale cache detection, partial fetch recovery, signature verification on the downloaded manifest. d7daa93e cut the embedded catalog from its prior size to the 20-voice floor.
the control-plane pattern is worth naming. it separates the binary’s release cadence from the catalog’s refresh cadence. the two cadences are fundamentally different: binary releases are slow, coordinated, signed, and notarized; catalog refreshes are fast, unilateral, and content-only. conflating them meant that a provider adding a new voice required a full khaos-tools release cycle. the separation makes the boundary explicit.
unified TTS provider architecture in khaos-wfl
khaos-wfl’s synthesis dispatch had been written with the Vokda provider as the primary path and a fallback stub. expanding to multiple providers required either duplicating the dispatch logic per provider or extracting an interface.
d3b84d8 extracted the interface. the TTSAdapter type defines three methods: Voices() ([]Voice, error), Synthesize(ctx context.Context, req SynthesisRequest) ([]byte, error), and Name() string. each adapter is a struct that satisfies the interface. the Reflex DAG’s synthesis node accepts a []TTSAdapter slice and iterates through it in priority order — the first adapter that returns a non-error response wins.
ba00862 added VokdaAdapter as the first concrete implementation. 3290bca followed with the local adapters: an MLXAdapter for on-device synthesis via the MLX framework and an EdgeTTSAdapter for Microsoft’s edge-tts runtime. both local adapters implement the same interface — no special casing in the synthesis node, no feature flags. 047f3be landed the OpenAICompatibleTTSAdapter, which handles any provider that speaks the OpenAI TTS wire protocol — Groq, OpenRouter’s TTS surface, and future OpenAI-compatible providers all route through the same adapter.
WAV normalization landed in the same batch. raw synthesis output from different providers arrives at different sample rates, bit depths, and channel counts. the composition assembler expects a consistent format. the normalization step runs after each adapter call and before the output is written to disk, so the synthesis node never reasons about format details.
twelve providers in khaos-manager’s registry
khaos-manager’s role is configuration and routing: it holds the registry of configured providers and routes synthesis requests to the appropriate adapter based on capability and availability. at the start of March 1, the registry held two entries. by March 8, it held twelve.
1b4b9fa built the registry structure. 7a384b9 added KittenTTS as a local service provider — a voice synthesis runtime that runs on the user’s machine without a cloud API key. fca4c4d wired KittenTTS into khaosd’s lifecycle: the daemon starts and stops the local service alongside itself, healthchecks it, and marks it available in the registry when it responds.
OAuth onboarding for cloud providers landed at c44eb27. the prior onboarding path required copy-pasting an API key from the provider’s dashboard. the OAuth path exchanges a browser redirect for a token, stores it in the keychain, and configures the provider without the user handling a raw credential. e7a6437 implemented per-capability routing: when multiple providers support the same capability (e.g., character voice synthesis), the routing rule prefers the provider marked primary in the registry, with the remainder as fallbacks.
14fa847, 5c21a67, 55893bd, 081029b, d5ebe66, and 627fc10 added the remaining providers to the registry, each following the same pattern: adapter implementation in khaos-wfl, registry entry in khaos-manager, capability declaration, and a matching onboarding flow in khaos-gui.
voice management in khaos-gui
the GUI arc started at 1118484 as a standalone voice management view — a page that displayed the configured voice registry and allowed CRUD operations on entries. 5b0b11c folded it into the projects view: voice configuration is now scoped to a project, not to the global daemon state.
f822256 added full CRUD. c4af689 added the OpenAI provider form, which has a different credential shape from the OAuth providers — an API key input rather than a browser redirect. 9608b96 implemented the search and filter picker: when a screenplay has 40 characters and the registry has 12 providers with dozens of voices each, the assignment UI requires filtering by provider, language, and gender. 5b5adf2 added audio preview — clicking a voice plays a short synthesis sample before the user commits to the assignment.
the story-player integration at eb7d070, 3aec59f, 36b59de, fff4bb0, and d37a862 closed the loop. composition events in KSPD carry audio URLs after synthesis; the story player resolves those URLs, renders character names alongside audio segments, and presents the composition as a playable timeline. the media asset API addition at 3aec59f provided the server-side surface for URL resolution. 36b59de added character name hydration — the composition’s element IDs are resolved against story.json at render time, so character names in the timeline stay in sync with the screenplay even when names change.
what the interface abstraction cost
the TTSAdapter interface has three methods. that is intentionally narrow. the synthesis node does not need to know whether the adapter is cloud or local, whether it requires a session token or a static key, or what format its raw output arrives in. it needs Voices, Synthesize, and Name.
the cost of the narrow interface is that adapter-specific capabilities — streaming synthesis, partial output, voice cloning — cannot be expressed through the base contract. the OpenAI-compatible adapter supports streaming; the base interface does not. the current resolution is capability negotiation at the registry level: providers declare their capabilities as a string set, and routing can filter by capability before selecting an adapter. this means the synthesis node still calls through the narrow interface, but the caller has already selected an adapter that supports the required capability. whether that holds as capabilities diverge further is an open question; the interface boundary has already paid for itself in adapters added with no core changes.