# Donut voiced-interview build — ACTIVE (auto-TTS via Zonos + KB-recall bridge + Princess Donut) `[2026-08-01]` Operator asked: "add auto-tts to the ratatoskr web gui" → grew into a voiced, corpus-grounded Tier-3 interview character. Contract: `docs/contracts/donut_voiced_interview.contract.md` (validated). Slice 1 DONE; slices 2-3 TO BUILD. Operator ruling: **build it today, but design the KB path to RETIRE** (it works around a Worldtree gap — Tier-3 agents are tool-less by design; KB search is integral, so the gap should close). ## Slice status - **SLICE 1 (persona) DONE** — `docs/characters/donut.md` authored (corpus-grounded from a Mimir pull + DCC canon: interview-register performer, "Carl looks intimidating while I talk," vulnerability under a good question, and the "pinned corpus = her own memory" framing). **Defined:** `ratatoskr:donut` (role `thoughtful-character`) live on personal :8081, in the console picker. Chattable now (from training until slice 3). NOT yet committed (uncommitted at snapshot). - **SLICE 2 (auto-TTS) TO BUILD** — new `src/ratatoskr/tts.py` (Zonos gateway client + `pad_to_dials`) + `/api/tts` proxy route in `web/server.py` + `index.html` (🔊 toggle, speak-on-`done` playback, serialize/cancel). TDD (respx-mock the gateway), live-smoke. - **SLICE 3 (KB-bridge) TO BUILD** — new `src/ratatoskr/kb_bridge.py` (RETIRE-READY, INV-KB-1) + the one server.py seam. Per interview turn: out-of-band Mimir consult → extract evidence → POST Donut's turn with `memory_context=[{kind:"corpus_reference", text:""}]`. She answers in-voice. TDD. ## Design — LOCKED (both external deps verified live 2026-08-01) **TTS (infra-ops Zonos specs, verified):** - Endpoint: `POST http://10.100.79.3:8890/v1/audio/speech` (the zonos GATEWAY at irv-ml1:8890 — NOT the engine's :1920, which has a rep-penalty bug). Also GET /v1/voices, /v1/dials, /health. WG-internal, **no auth**. Reachable from nh3-dev (the console box) over WireGuard. - Request: JSON, only `input` required. `response_format:"wav"` (16-bit RIFF; **mp3/opus silently fall back to mislabeled PCM — never use**). Verified: voice=donut → HTTP 200, RIFF/WAVE PCM 16-bit mono 44.1kHz. - **NOT streaming** — gateway buffers to a complete clip (~1.8s/sentence). "Speak on done" gives the whole clip after the synth delay. True streaming = a future gateway enhancement (infra-ops can raise). - **Voice: `voice:"donut"` is ALREADY REGISTERED** (infra-ops cloned Vuong's Princess Donut bundle; GET /v1/voices returns Donut; case-folded so "Donut.wav"→voice:"donut"). Custom-voice registration mechanism: drop a `.wav` (44.1kHz mono 16-bit, ~10-20s clean single-speaker) in the gateway voices dir + restart. Audition A/B: http://10.100.10.50:8090/b/donut-voice/ - **Affect-driven emotion (DEC-7, the standout):** map the turn's live PAD (from the `affect_update` SSE the console ALREADY consumes) → Zonos `emotion_valence`(pleasure)+`emotion_arousal`(arousal). Reframes the feature as voice OBSERVABILITY (hear the affect the persona pane shows), NOT chat-app creep — resolves the scope concern. Dials also: `preset`, `emotion_sliders{happy,sad,angry,surprised}`, `emotion_strength`, `speed`, `seed`. - **Coupling: direct :8890** (DEC-1) for the emotion dials; the swappable LiteLLM `ext-tts` alias (10.250.50.70:4000) loses the Zonos emotion params. `tts.py` is the single swap seam. - **Serialize** one synth per turn (DEC-5) — shared 3090 ~90% VRAM; cancel-on-new-turn. - **Proxy** (DEC-4): browser → `/api/tts` (nh3-dev) → gateway; never surface irv-ml1 to the client. **KB-recall (worldtree-dev ruling, wyrd-proven):** - Tier-3 is DELIBERATELY tool-less in v1 (`build_tier3_agent_context` hard-codes `tool_schemas=[]`); no search_kb grant, no call-Mimir-as-tool (#361 rejected it, recursion). So: **consumer-side retrieval + memory_context pinning** — ratatoskr consults Mimir out-of-band, extracts the cited evidence, pins `memory_context=[{kind:"corpus_reference", text}]` on Donut's turn. She frames it as her own memory (the persona prompt already instructs this). Our SDK's stream_turn already forwards `memory_context` verbatim. Latency: one retrieval round-trip/turn. - **RETIRE-READY (INV-KB-1):** kb_bridge behind ONE seam `pin_kb_context(question, agent_id) -> list|[]`; retiring = delete the module + one call-site. Delete when Worldtree #361 `reference_knowledge` extends to Tier-3 (operator flagged it an integral GAP; worldtree-dev surfacing the extension to Vuong — decision inbound to the operator). ## OWED / open loops (post-clear) - **P&P verification re-run OWED to infra-ops** — #382 deployed (b166: self-describing `_index.md` + the Mimir prompt rule "hits ARE library content, never discard/substitute"). Re-run the failing P&P-dismiss shape on :8081, report to infra-ops. **DCC's backfill FAILED** (job predates state-tracking, no `_index.md` yet — flagged to worldtree-dev) — but the prompt rule alone should stop the discard, so the KB-bridge's DCC Mimir consults should ground fine. Verify this when building slice 3. - **worldtree-dev #361-Tier-3 extension** — an inbound surface decision for Vuong (would let Donut search in-voice + delete the bridge). - **infra-ops custom-voice** — mechanism proven; hand them a `.wav` + name for any future character. ## Discipline Operator said "do both slices" (skip /heid-contract-review, straight to TDD). Run heid code-review + bug-hunt AFTER each code slice. Release-only versioning cadence (no per-commit tags; `feat:`/`fix:` conventional commits between releases). CLI to drive Mimir/Donut turns: `ratatoskr --send "..." --new --agent --end-user-id ratatoskr-tui`. Define: `python -m ratatoskr.tier3 define --name --system-prompt "" --role thoughtful-character`.