Files
ratatoskr/persistent-memory.d/2026-08-02-donut-tts-chunking-english-gates.md
vh 006daf31ac memory: /snapshot — Donut long-form TTS + English pin + dialogue-only shipped (d59f907)
reference_knowledge empty-recall root-caused to a Worldtree fiction/main wing-misfile;
worldtree-dev ruled DCC re-ingest into fiction, execution queued next session (post-fix
verify offer standing). worldtree-sdk 1.2.0 repin deferred. Filed #21 (sibling JSON-500).
2026-08-02 14:22:23 -07:00

5.5 KiB
Raw Permalink Blame History

[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).