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).
5.5 KiB
[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 byexcept ValueError— routed through the hardenedPadState.from_obj); non-stragent_id(unhashabledict.getTypeError —isinstance(str)guard); lone surrogate in text (rides JSON as\udXXX,UnicodeEncodeErroron httpx's utf-8 encode of the gateway body — scrubbed viaencode("utf-8","ignore")); whitespace-only text (empty 200 → 400 viastrip()). - Lock+client leak on peek escape (was a PERMANENT deadlock — a browser abort/CancelledError or
httpx.InvalidURLbefore byte 1 escaped the two narrowexceptarms; thefinallyonly existed oncepiped()ran):except BaseException: await _release(); raisearound the peek. - A2 (net-new): a mid-stream drop AFTER the 200 committed raised into the response (httpx wraps
aiter_bytesinexcept RequestError, so a mid-stream drop arrives asTtsUnavailable, and the oldif i==0: raisefired post-commit). Fixed with ayielded_anypivot: 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_headerscan (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 aValueErrorsubclass, already caught — Regin confirmed); Regin self-retracted a false_hard_wrapfinding; accepted the char→token proxy (documented + infra-ops-blessed). Deferred (unchanged sibling handlers):_create_session/_submit_turn500 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).