# 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:"<extract>"}]`. 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 `<Name>.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 `<Name>.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 <id> --end-user-id ratatoskr-tui`. Define:
`python -m ratatoskr.tier3 define --name <slug> --system-prompt "<prompt>" --role thoughtful-character`.
