`[2026-08-02]` **Donut TTS: English pin + long-form chunking + POST + dialogue-only persona + both heid gates — shipped `d59f907` (pushed).**

Operator reported two symptoms: Donut's voice "occasionally goes to gibberish" and "truncates on long streams." Both root-caused and fixed, then a mid-work persona directive folded in, then the full House Code Discipline (both heid gates) run.

## Gibberish → DEC-9 English pin
The Zonos gateway (`/v1/audio/speech`, irv-ml1 :8890) is multilingual and takes a `language` field we were never sending → it drifted into other-language phonemes on names/numerics/long spans. Fix: `gateway_body` pins `language:"en-us"` on every call (`_TTS_LANGUAGE`). Verified the gateway accepts it live. The persona rewrite (below) killed the OTHER gibberish vector — the old prompt MANDATED asterisk RP action beats (`*she flicks her tail*`), which were being fed to the engine verbatim.

## Truncation → DEC-10 client-side chunk-and-concatenate
Empirically characterized + confirmed by infra-ops (recipe thread `01KZ1FKX…`): the Zonos model hard-caps ONE synthesis at `max_tokens=6144` = **71.2s of audio** (6144 / 86.3Hz codec frame rate). CRITICAL physics (infra-ops correction): 86.3Hz is a **delivery-independent constant** — 6144 tokens is ALWAYS 71.2s regardless of emotion/rate; emotion changes how many WORDS land in 71.2s, not seconds-per-token. `max_tokens>6144` → HTTP 400 (architectural sequence limit, unraisable). Our old 2000-char cap was LARGER than one call could voice (~750-900 chars), so the gateway truncated first, mid-stream, at ~58-71s.
Fix (`tts.py`): `chunk_text(text, budget=747)` — paragraph-first greedy pack (seams on blank lines), sentence fallback for oversized paragraphs, clause(`, ; :`)/word sub-split for oversized sentences; budget = 71.2s×0.75×14c/s (operator: "greedy to 75% of cap for prosody"; the 25% headroom absorbs char→audio-seconds variance). `tts_stream_long` synthesizes each chunk (identical voice+dials+language) and concatenates: chunk 1 verbatim (WAV header + PCM), chunks 2..N header-stripped (`_pcm_after_header`) → ONE continuous int16-PCM stream (infra-ops: never bury a RIFF header mid-stream). `/api/tts` became **POST** (DEC-10a) so the full long text rides the body, not a length-capped GET URL; outer cap 2000→8000 (a shared-3090 hold bound now, not a URL bound). Live-proven: 1978 chars → 3 balanced chunks → 106.6s, one header, clean concatenation — ceiling broken.

## Persona → dialogue-only + always-consult-the-tool
Operator directive mid-work. Rewrote `docs/characters/donut.md` and live-patched `ratatoskr:donut` (via `python -m ratatoskr.tier3 patch`): (a) ONLY spoken dialogue — no stage directions / asterisks / emoji / markdown (preserves her theatrical VOICE, strips narrated ACTION — the TTS-gibberish source); (b) ALWAYS call `reference_knowledge` before answering any question. Note: the tool-call-first is PROMPT-LEVEL only — the define surface (`{agent_name, role, system_prompt}`) exposes no tool_choice, so a hard guarantee would need a WT-side forced-tool capability. Fixed stale drift: the doc still described the retired `kb_bridge`/`[MEMORY:DATA]` pinning. Live-verified: her first event was `reference_knowledge{query}`, output was 0-asterisk dialogue.

## Both heid gates (4-arm panels: Gróa/Hulda/Regin/Kimi)
Dispatched in parallel (code-review thread `01KZ21MC…`, bug-hunt `01KZ21RN…`). The GET→POST switch had re-opened untrusted-type paths that string-only query params masked — all four arms converged. Fixed:
- **Untrusted /api/tts body fields degrade, never 500:** huge-int PAD (`float()` OverflowError, not caught by `except ValueError` — routed through the hardened `PadState.from_obj`); non-str `agent_id` (unhashable `dict.get` TypeError — `isinstance(str)` guard); lone surrogate in text (rides JSON as `\udXXX`, `UnicodeEncodeError` on httpx's utf-8 encode of the gateway body — scrubbed via `encode("utf-8","ignore")`); whitespace-only text (empty 200 → 400 via `strip()`).
- **Lock+client leak on peek escape** (was a PERMANENT deadlock — a browser abort/CancelledError or `httpx.InvalidURL` before byte 1 escaped the two narrow `except` arms; the `finally` only existed once `piped()` ran): `except BaseException: await _release(); raise` around the peek.
- **A2 (net-new):** a mid-stream drop AFTER the 200 committed raised into the response (httpx wraps `aiter_bytes` in `except RequestError`, so a mid-stream drop arrives as `TtsUnavailable`, and the old `if i==0: raise` fired post-commit). Fixed with a `yielded_any` pivot: pre-first-byte failure → raise (→503), post-first-byte failure (any chunk) → degrade+log.
- **A3:** non-WAV 200 body forwarded as audio/wav (browser decodes markup as PCM): RIFF-sniff the peeked first bytes → 503; bounded `_pcm_after_header` scan (1KiB) + browser 64KiB acc bound.
- Contract brought canonical: DEC-9/10, FN chunk_text/tts_stream_long/tts_endpoint (POST), INV-TTS-4 logging scope, FN pad_to_dials domain, FN client:speakOnDone (POST+8000).
Triaged OUT: refuted Hulda's `UnicodeDecodeError`-escapes (it IS a `ValueError` subclass, already caught — Regin confirmed); Regin self-retracted a false `_hard_wrap` finding; accepted the char→token proxy (documented + infra-ops-blessed). Deferred (unchanged sibling handlers): `_create_session`/`_submit_turn` 500 on malformed JSON → filed as issue **#21**.

+14 regression tests across the two gates; 546 green; committed + pushed `d59f907`.

Related: [[2026-08-01-donut-voiced-interview-build]] (the prior slices).
