Files
ratatoskr/docs/contracts/donut_voiced_interview.contract.md
T
vh 19b499ab50 feat(tts): migrate off Zonos to chatterbox-fast; drop affect, hold English
Repoint the TTS client from the Zonos gateway (:8890 /v1/audio/speech) to
chatterbox-fast (:8197 /tts — bespoke non-OpenAI {text,voice,format,stream}
schema, no auth, 24kHz, infra-ops-verified). tts.py stays the single swap seam.

Dropped, no backward-compat (pre-v1):
- Affect (DEC-7): the Turbo checkpoint has no emotion knob, so PadState,
  EmotionDials, pad_to_dials, the /api/tts p/a fields, and the browser pad
  argument are deleted. Voice is now flat.
- Client-side chunking (DEC-10): chatterbox has no per-synth cap and chunks
  internally, so chunk_text/tts_stream_long/_pcm_after_header are deleted; a
  single tts_stream call voices a whole turn, the mid-stream yielded_any degrade
  folded into it.
- Language pin (DEC-9): no language field; re-purposed to sampling curbs (below).

Fixed / added:
- Browser Web Audio sample rate 44100 -> 24000 (the chatterbox rate).
- Default voice Cora -> glados_25s; donut registered lowercase at /refs/donut.wav.
- English-drift curb: Turbo is multilingual-leaky and wanders off English on a
  long generation (the gateway scheduler ratchets chunk size unbounded). Tighten
  sampling in gateway_body: top_k 1000->80, top_p 0.95->0.85, temperature
  0.8->0.5. These reduce drift probability; the guaranteed fix is a server-side
  max-chunk cap (infra-ops, greenlit).
- OOM guard (DEC-9a): a long generation can OOM the shared 3090, returning 200
  with a 0-byte body; /api/tts surfaces an empty 200 as 503 rather than
  committing silent audio.

Contract donut_voiced_interview.contract.md amended: migration banner, DEC-1/3/8
amended, DEC-7/9/10 retired with historical notes, DEC-9a added.

Tests rewritten to the new wire; 520 green. Live-smoked against the gateway
(24kHz synth + endpoint proxy + web console). persistent-memory.md committed
alongside (commit-along).
2026-08-07 10:23:13 -07:00

29 KiB
Raw Blame History

contract_version, module, purpose, scope, touches, depends_on, used_by, language, complexity, min_complexity, estimated_loc, confidence
contract_version module purpose scope touches depends_on used_by language complexity min_complexity estimated_loc confidence
2.1 ratatoskr.web.tts_kb A voiced, corpus-grounded Tier-3 interview character in the ratatoskr web console. Two capabilities plus one character: (a) auto-TTS via the chatterbox-fast gateway, spoken on SSE `done` (migrated off Zonos 2026-08-07; no affect modulation — chatterbox Turbo has no emotion knob); (b) a consumer-side KB-retrieval + `memory_context` pinning BRIDGE that grounds the character's recall in the ingested corpus while she stays in-voice; (c) Princess Donut (Dungeon Crawler Carl) as the first instance. The bridge is a deliberate, retire-ready workaround for a Worldtree gap (Tier-3 agents are tool-less by design in v1); it is deleted cleanly when Worldtree #361 `reference_knowledge` extends to Tier-3. Web console only (src/ratatoskr/web) + two new small modules + one persona content file. The Bifrost provider planes and the ratatoskr.wt SDK-adapter core are UNTOUCHED. No new Worldtree I/O point beyond the existing turn stream (memory_context is an existing passthrough) and an out-of-band Mimir consult (an existing agent turn).
src/ratatoskr/web/server.py
src/ratatoskr/web/static/index.html
src/ratatoskr/web/entrypoint.py
src/ratatoskr/tts.py
src/ratatoskr/kb_bridge.py
src/ratatoskr/wt.py
docs/characters/donut.md
chatterbox-fast gateway: POST http://10.100.79.3:8197/tts (infra-ops; WG-internal, no auth; bespoke non-OpenAI schema {text,voice,format,stream}; streaming placeholder-header wav @ 24000 Hz; no per-synth cap; NO affect controls; verified 2026-08-07 against image local/chatterbox-fast:v1)
Worldtree turn stream: memory_context[] passthrough (SDK stream_turn already forwards it verbatim)
Worldtree agents.define (Tier-3) for Donut; Mimir (search_kb) for the out-of-band retrieval consult
ratatoskr.web
python medium medium 300 0.8

Contract: Donut voiced interview (auto-TTS + KB-recall bridge)

⚠ TTS MIGRATED OFF ZONOS → chatterbox-fast 2026-08-07 (operator-directed). Slice 2's synthesis backend moved from the Zonos gateway (:8890 /v1/audio/speech) to chatterbox-fast (:8197 /tts). Three architecture deltas, all infra-ops-verified against image local/chatterbox-fast:v1:

  • Affect dropped (DEC-7 RETIRED). chatterbox serves the Chatterbox TURBO checkpoint, which has NO valence/arousal/emotion knob (exaggeration is exposed but inert on Turbo). The whole PAD→emotion-dial path — PadState, EmotionDials, pad_to_dials, and the browser p/a body fields — is deleted. Voice is now flat (high-quality but unmodulated). Operator's call: if live affect ever becomes load-bearing again, Zonos (:8890) remains the only fleet TTS with real emotion steering.
  • Client-side chunking dropped (DEC-10 RETIRED). chatterbox has no per-synth token/duration cap (Zonos capped at 6144 tok / 71.2s) and chunks arbitrary-length text internally, so chunk_text + tts_stream_long + _pcm_after_header are deleted; a single tts_stream call voices a whole turn. The mid-stream degrade policy (yielded_any) folds INTO tts_stream.
  • Sample rate 44100 → 24000 Hz. The browser Web Audio decode MUST use 24000 or the voice plays ~1.8× too fast. /api/tts stays POST; the streaming placeholder-header WAV shape (DEC-2/DEC-3) and the browser Web-Audio PCM decode path (DEC-2) are UNCHANGED except the sample rate. The tts.py client remains the single swap seam (DEC-1). DEC-7/9/10 below are retained as historical record of the Zonos build.

⚠ SLICE 3 (KB-recall bridge) RETIRED 2026-08-02. The kb_bridge.py module + its single web/server.py call-site were deleted per INV-KB-1 when Worldtree #383 shipped native Tier-3 reference_knowledge (v1.0.0b167, live on :8081 + demo). Donut now searches the fiction wing (DCC corpus) natively, in-turn, with evidence packets (note_id + path provenance, confidence bucket) and a server-side grounding rule — strictly better than the consumer-side memory_context pinning it replaced (no separate consult round-trip, not gated by our out-of-band prompt). Retirement live-verified: Donut called reference_knowledge and grounded in DCC in-voice before deletion. wt.stream_turn's memory_context param was KEPT (inert SDK parity). The DEC-6 / INV-KB-* / FN pin_kb_context sections below are retained as historical record of what was built and why it retired. Slices 1 (persona) + 2 (auto-TTS) remain LIVE.

Migration-style contract: three separable slices (persona / TTS / KB-bridge), each independently shippable. Slice order is chosen for fastest visible result.

Decisions (DEC)

  • DEC-1 — direct :8197 coupling (amended 2026-08-07). Proxy straight to the chatterbox-fast gateway. Original Zonos rationale was the emotion dials (which ext-tts dropped); that rationale is retired with affect (DEC-7). The coupling STANDS regardless: chatterbox-fast is a bespoke, non-OpenAI /tts schema ({text,voice,format,stream}, verified with infra-ops), NOT reachable through the OpenAI-shaped ext-tts LiteLLM alias — so tts.py remains the single swap seam, now translating that bespoke schema. (Considered routing via the generic alias once affect was dropped; the non-OpenAI wire ruled it out.)

  • DEC-2 — STREAMING, play-as-it-arrives (amended 2026-08-02, operator-directed). The gateway ALREADY streams: POST /v1/audio/speech relays a chunked int16 WAV (transfer-encoding: chunked, placeholder 0xFFFFFFFF RIFF/data sizes) as it synthesizes — TTFB ~0.44s vs ~7s total (infra-ops verified). So ratatoskr PROXIES THE CHUNKS STRAIGHT THROUGH (tts_stream, GET /api/tts) and the browser plays a progressive <audio src>; NEVER buffer, NEVER rewrite the placeholder header (a rewrite needs the whole clip and defeats streaming — the bug the original buffered tts_synthesize + _finalize_wav_header hit). The placeholder-size WAV is DESIGNED for <audio src> progressive playback (validated in Chromium: plays, currentTime advances, no MSE needed). Supersedes the original "full-synth latency accepted / no streaming." (Amended 2026-08-02: the browser path is now Web Audio decoding raw int16 PCM, not <audio src> — Safari/WebKit rejects a 0xFFFFFFFF-length streaming WAV via <audio src> (NotSupportedError); and /api/tts is now POST, not GET. See DEC-10.)

  • DEC-3 — wav streaming (amended 2026-08-07). format:"wav", stream:true → a streaming int16 RIFF/WAVE with placeholder (0xFFFFFFFF) RIFF/data sizes, one header, then s16le PCM to EOF — the shape the browser's one-header-strip decoder expects. chatterbox also offers format:"pcm" (headerless raw s16le, leaner); kept on wav so the streaming shape stays byte-identical to the Zonos path and the endpoint's RIFF-sniff (non-WAV-200 → 503) and the browser header-strip both stand unchanged. (pcm is a deferred lean-up — it would drop the header-strip + the RIFF-sniff, net a few lines.)

  • DEC-4 — server-side proxy. Browser → /api/tts (nh3-dev) → gateway. The irv-ml1 host/URL never reaches the client (INV-TTS-1). No key exists, so INV-003 is trivially satisfied, but the proxy still stands (browser can't reach the WG net regardless).

  • DEC-5 — serialize one synth per turn. The gateway shares a single 3090 (~90% VRAM) with other TTS engines; do not parallelize. A new turn cancels any in-flight synth + playback.

  • DEC-6 — KB-recall = consumer-side retrieval + memory_context pinning, as a RETIRE-READY bridge (worldtree-dev ruling, wyrd-proven pattern). Tier-3 is tool-less by design (build_tier3_agent_context hard-codes tool_schemas=[]); agent.call:mimir is rejected (recursion). So per interview turn: ratatoskr consults Mimir out-of-band for the question's topic, extracts the cited evidence, and POSTs Donut's turn with memory_context=[{kind:"corpus_reference", text:"<extract>"}]. She answers in-voice, framing it as her own memory. The bridge is isolated behind ONE seam (INV-KB-1) so it deletes cleanly when Worldtree #361 reference_knowledge extends to Tier-3 (operator-flagged as an integral gap; worldtree-dev surfacing the extension to Vuong).

    • DEC-6 status (2026-08-02): bridge RETIRED, native reference_knowledge live but EMPTY. The bridge was deleted (09e4257) when WT #383 native reference_knowledge (b167) shipped — Donut now calls the tool in-turn. But the tool returns zero hits for every query. Root-caused 2026-08-02: Mimir's search_library DOES find the DCC corpus (main wing, score ~0.03), so the store is NOT empty and this is NOT a ratatoskr gap — WT's native reference_knowledge isn't surfacing content Mimir retrieves fine (likely a wing-scope mismatch — tool scoped to the failed fiction wing — or a confidence threshold rejecting the weak ~0.03 hits). Escalated to worldtree-dev. Until fixed, Donut recalls from her own character knowledge (degrades in-voice per the persona).
  • DEC-7 — affect-driven emotion. RETIRED 2026-08-07 (chatterbox migration). chatterbox Turbo has no valence/arousal/emotion control (infra-ops-verified: exaggeration is exposed but inert; cfg_weight/min_p not exposed; only generic sampling knobs move output, and those change timbre/variance not emotion). There is no coupling point for a live-PAD driver, so the entire path is deleted — PadState, EmotionDials, pad_to_dials, the /api/tts p/a body fields, and the browser's pad argument. Voice is now flat. Retained below as historical record of the Zonos build. (Original: map live PAD from the affect_update SSE → Zonos emotion_valence/emotion_arousal, reframing the feature as voice OBSERVABILITY. The observability framing dies with the knob.)

  • DEC-8 — voice: custom "donut" is REGISTERED (amended 2026-08-07 for chatterbox). chatterbox voices are *.wav reference clips in /refs (GET /voices lists the stems). infra-ops registered /refs/donut.wav (the same reference clip behind the Zonos Donut voice) at operator direction, so _TTS_VOICE_MAP maps ratatoskr:donut → "donut" directly. NOTE the case: chatterbox wants lowercase "donut" (Zonos used "Donut"). Non-interview agents fall to the chatterbox default "glados_25s" (was Zonos "Cora", which does not exist on chatterbox).

  • DEC-9 — hold English (amended 2026-08-07; the "nothing to drift" call was WRONG). The Zonos language:"en-us" pin is dropped — chatterbox has no language field. BUT the initial "English-only, nothing to drift" rationale was FALSIFIED by an operator report the same day: the Turbo checkpoint drifts into German partway through a long turn. infra-ops's authoritative root-cause (source-read, thread 01KZEDMJ…): Turbo has latent multilingual capacity that leaks, and the drift is length-driven — the gateway's adaptive scheduler ratchets chunk size upward with NO cap, so a long turn collapses into essentially ONE long generation after the first 2-3 sentences, and the sampler wanders off English on that single long decode (generation state DOES reset per chunk — cross-chunk carry is not the mechanism; it's the unbounded per-chunk length). Two-layer response:

    • Sampling curbs (shipped, no redeploy) — REDUCE drift probability, do NOT guarantee it. gateway_body tightens below the gateway defaults: _TTS_TOP_K = 80 (from 1000 — the highest-leverage knob; the huge default admits off-language tokens), _TTS_TOP_P = 0.85 (from 0.95), _TTS_TEMPERATURE = 0.5 (from 0.8). Escalation if still drifting: temp 0.3-0.4, rep_penalty 1.2→1.3.
    • Length-bounding (the ROBUST fix) — GUARANTEES English by keeping each generation short. Two paths, operator's call: (a) return to short CLIENT-side chunking (~1-2 sentences per /tts call, each a fresh re-anchored generation; partially reverses DEC-10; works today, no redeploy; loses the gateway's seamless internal streaming); (b) infra-ops adds a SERVER-side max-chunk cap to the scheduler (keeps seamless streaming AND holds English; needs a chatterbox-fast redeploy + operator greenlight on the card-shared fleet service — then the consumer reverts to sending full text). The persona's dialogue-only rewrite in docs/characters/donut.md still stands (removes the asterisk-RP-voiced-verbatim vector regardless of engine). (Original Zonos DEC-9 below.)
  • DEC-9a — OOM on long single generations → empty 200 (infra-ops 2026-08-07). chatterbox-fast shares the RTX 3090 with Zonos2 (~1 GB headroom). A long single generation can OOM the card; the gateway then returns HTTP 200 with a 0-byte body (not a 5xx). tts_endpoint treats an empty 200 body as a synthesis failure → 503 (INV-TTS-4 visible skip), never a silent empty audio/wav stream. Length-bounding (DEC-9 above) fixes the OOM too — small chunks don't OOM.

  • DEC-10 — long-form chunk-and-concatenate. RETIRED 2026-08-07 (chatterbox migration). chatterbox-fast has NO per-synth token/duration cap (Zonos capped at 6144 tok / 71.2s) and chunks arbitrary-length text INTERNALLY via its adaptive scheduler, streaming seamlessly — so client-side chunk-and-concatenate is deleted: chunk_text, tts_stream_long, _pcm_after_header, and the _TTS_CHUNK_CHAR_BUDGET constants are gone; a single tts_stream call voices a whole turn. /api/tts STAYS POST (DEC-10a) — a long turn still rides the body, not a length-capped URL — and the 8000-char outer cap stays as a shared-GPU hold ceiling (no longer a chunk-count bound). The yielded_any mid-stream degrade that lived in tts_stream_long folds into tts_stream. Retained below as historical record of the Zonos build. (Original:) The Zonos model hard-caps ONE synthesis at max_tokens=6144 = 71.2s of audio (6144 / 86.3 Hz codec frame rate; the gateway 400s above 6144 — an architectural sequence limit, unraisable). So a turn longer than ~71s truncated mid-stream. Fix: split the text client-side and concatenate the PCM (chunk_text + tts_stream_long). Chunking is paragraph-first + greedy (operator call: fewer, fuller chunks for prosody), targeting ~75% of the cap per chunk (~747 chars @ ~14 c/s); the 25% headroom covers char→audio-seconds variance (the cap is on TOKENS — 6144 = 71.2s ALWAYS, a codec-frame constant regardless of delivery, infra-ops — while the budget is in CHARS, a proxy that stretches under slow/expressive delivery) so no chunk clips; oversized paragraphs fall back to sentence packing, oversized sentences to clause/word sub-splitting. Concatenation emits chunk 1's WAV verbatim then chunks 2..N header- stripped → ONE continuous int16-PCM stream (never bury a RIFF header mid-stream — infra-ops). Identical voice+dials+language per chunk for uniform delivery. /api/tts becomes POST (DEC-10a) so the full text rides the body, not a length-capped URL; the outer text cap rises 2000→8000 (a shared-3090 hold bound, not a URL bound).

Invariants

  • INV-TTS-1 [hard] — the TTS gateway host/URL (chatterbox-fast :8197) never reaches the browser; all synthesis goes through /api/tts.
  • INV-TTS-2 [hard] — TTS is opt-in: a 🔊 toggle (default OFF), persisted to localStorage (mirrors the theme/cot-toggle pattern). No speech without it.
  • INV-TTS-3 [hard] — one synth in flight at a time; a new turn cancels the prior synth request AND stops in-flight playback (cancel-on-new-turn).
  • INV-TTS-4 [hard] — TTS failure is non-blocking: a gateway error, non-wav body, or playback failure skips audio; the turn/transcript is unaffected. Logging is scoped to GENUINE failure: a committed-200 mid-stream/later-chunk degrade writes a tts_degrade stderr line (server) or a no WAV header ticker (browser); a browser-side ABORT/cancel (INV-TTS-3 new-turn) is deliberately SILENT — cancellation is not a failure.
  • INV-KB-1 [hard] — the KB bridge is import-isolated behind a single seam: server.py's turn path calls exactly one function pin_kb_context(question, agent_id) -> list[memory_context] | []. Retiring the bridge = delete kb_bridge.py + that one call-site; no other file changes. kb_bridge imports nothing from the TTS or SDK-adapter core.
  • INV-KB-2 [hard] — the retrieval channel is never persisted: memory_context is per-turn, kind="corpus_reference" (a free label), and rides the turn POST body only.
  • INV-KB-3 [hard] — KB-retrieval failure is non-blocking: a failed/empty Mimir consult pins nothing ([]) and Donut answers without the extract; never block or fail her turn on retrieval.
  • INV-KB-4 — the bridge is scoped to characters that opt in (a per-agent allowlist / config), not applied to every turn — only interview characters that need corpus recall pay the retrieval round-trip.

FN blocks

FN tts_stream (the sole synthesis primitive — DEC-2 streaming; amended 2026-08-07)

tts_stream(text, *, voice, client: httpx.AsyncClient, url=CHATTERBOX_TTS_URL) -> AsyncIterator[bytes]
  # Open the gateway's CHUNKED stream (client.stream("POST", url, json=gateway_body(text, voice))) and
  # YIELD wav chunks as they synthesize. Pass through verbatim — never buffer, never rewrite the placeholder
  # header. chatterbox chunks arbitrary-length text INTERNALLY (no per-synth cap, DEC-10 RETIRED), so this
  # SINGLE call voices a whole turn — no client-side chunk-and-concatenate wrapper.
  # gateway_body(text, voice) = {text, voice, format:"wav", stream:true, temperature:_TTS_TEMPERATURE}.
  #   temperature < gateway-default 0.8 holds English across a long turn (DEC-9). NO dials, NO language (RETIRED).
  precondition: text non-empty. Voice membership in GET /voices is GATEWAY-enforced, not client-asserted.
  postcondition: yields the gateway's chunked int16 streaming WAV bytes unmodified (0xFFFFFFFF placeholder
                 sizes intact), one leading header then s16le PCM @ 24000 Hz to EOF.
  error (the yielded_any pivot, folded in from the retired tts_stream_long):
    - a non-200 OPEN or a connect/transport failure BEFORE the first byte -> TtsUnavailable (so the endpoint
      peek can still return 503; nothing committed yet).
    - a transport drop AFTER >= 1 byte has streamed (the 200 is committed) -> DEGRADE: write a `tts_degrade`
      stderr line, END the generator, keep what played. NEVER raise into the committed StreamingResponse.
  invariant: format is ALWAYS "wav" (DEC-3); never mp3/opus/pcm from this seam.

FN chunk_text (DEC-10 long-form splitting; pure) — RETIRED 2026-08-07 (chatterbox chunks internally; deleted). Historical:

chunk_text(text, budget=_TTS_CHUNK_CHAR_BUDGET) -> list[str]
  # Split into synthesis chunks each <= budget chars. Paragraph-first (seams on blank lines), greedy pack.
  steps:
    - strip; empty/whitespace -> [].
    - whole paragraphs (each <= budget) greedily pack together, joined "\n\n".
    - a paragraph > budget flushes the pending run, then sentence-packs (split (?<=[.!?])\s+, join " ");
      a sentence > budget sub-splits on clause (, ; :) then space, hard-cut mid-word only as last resort.
  postcondition: every chunk non-empty and <= budget; word order preserved; no split mid-word unless the
                 input has no boundary at all. budget = 71.2s * 0.75 * 14 c/s ≈ 747 (75% of cap for prosody).

FN tts_stream_long (DEC-10 orchestrator) — RETIRED 2026-08-07 (no per-synth cap; deleted, its yielded_any degrade folded into tts_stream). Historical:

tts_stream_long(text, *, voice, dials, client, url=ZONOS_TTS_URL, budget=_TTS_CHUNK_CHAR_BUDGET) -> AsyncIterator[bytes]
  # chunk_text(text) then synth each chunk with tts_stream (identical voice+dials+language). Emit chunk 1
  # VERBATIM (WAV header + PCM); chunks 2..N via _pcm_after_header (strip up to+incl the `data` id+size) so
  # the browser decodes ONE continuous int16-PCM stream after a single leading header (infra-ops: one header).
  error: the pivot is `yielded_any`, NOT the chunk index. A failure BEFORE the first byte (an OPEN failure)
         propagates as TtsUnavailable -> endpoint peek -> 503 (nothing committed yet). A failure AFTER bytes
         have streamed — a MID-STREAM drop on chunk 0 OR a later chunk, past the committed 200 — degrades:
         drop the tail, keep what played, write a `tts_degrade` stderr line, RETURN (never raise into the
         committed StreamingResponse). httpx wraps aiter_bytes in `except RequestError`, so a mid-stream
         drop arrives as TtsUnavailable, not a clean generator end — the yielded_any gate is what keeps a
         chunk-0 mid-stream drop from raising into the 200.

FN pad_to_dials — RETIRED 2026-08-07 (DEC-7 affect dropped; PadState/EmotionDials/pad_to_dials all deleted). Historical:

pad_to_dials(pad: PadState | None) -> EmotionDials
  # Map live PAD -> Zonos emotion dials (DEC-7).
  steps:
    - none/malformed pad -> neutral dials (emotion_enabled=False) [degrade-not-crash].
    - emotion_valence = clamp(pad.pleasure, -1, 1); emotion_arousal = clamp(pad.arousal, -1, 1).
    - emotion_enabled = True; emotion_strength from a fixed default (tunable).
  invariant: total over any PAD the DECLARED surface produces (a PadState with float axes /
             None / out-of-range / NaN / inf / a non-PadState object) -> valid dials, never raises.
             (A PadState carrying NON-float axes is a type violation no call site constructs — the
             endpoint coerces via PadState.from_obj; not defended inside pad_to_dials.)

FN tts_endpoint (server.py, POST /api/tts — DEC-10a; amended 2026-08-07)

POST /api/tts  {text, agent_id?}  -> audio/wav (chunked StreamingResponse)
  # POST (not GET) so an arbitrarily long turn rides the body, not a length-capped URL. The gateway chunks
  # arbitrary-length text internally (DEC-10 RETIRED — no client concat); a single tts_stream call proxies it.
  # text capped 8000 chars, word-boundary truncated (a shared-GPU hold bound; the transcript still shows the
  # full text). text/agent_id are untrusted open-world body fields — each degrades, never 500s (INV-TTS-4).
  # (The `p`/`a` PAD body fields are GONE — DEC-7 affect retired.)
  steps:
    - bad JSON / non-str text -> 400. Scrub lone surrogates from text (else httpx's utf-8 encode of the gateway
      body 500s); if the scrubbed text is blank after strip -> 400. word-boundary truncate to 8000 (with a
      mid-word HARD-CUT fallback when the last space sits at index <= limit//2).
    - resolve voice: per-character map -> "donut", default "glados_25s"; a NON-str agent_id (unhashable) -> default.
    - acquire the serialize lock (DEC-5, one stream at a time on the shared GPU); open tts_stream and PEEK the
      first byte: a bad gateway OPEN -> 503; an EMPTY 200 body (no bytes — an OOM synth, DEC-9a) -> 503; and a
      200 whose first bytes are NOT a RIFF header -> 503 too (a mislabeled non-WAV body would decode as
      garbage) — all BEFORE committing a 200 (INV-TTS-4). Any OTHER escape during the peek (CancelledError,
      httpx.InvalidURL) releases the lock+client, then propagates.
    - return StreamingResponse piping tts_stream; the generator's finally releases the lock + closes the client
      (incl. the browser-abort path: a new turn's fetch() drops the POST) and, on a committed mid-stream drop,
      tts_stream degrades internally (ends the generator, no raise). httpx.Timeout is connect=10 / read=120 /
      write=10 / pool=10 (read=120 per infra-ops: a long synth can render slowly).

FN pin_kb_context (kb_bridge.py — RETIRE-READY, INV-KB-1)

pin_kb_context(question: str, agent_id: str | None, *, client) -> list[dict]   # memory_context items, or []
  # The bridge. Consumer-side retrieval + pinning (DEC-6).
  steps:
    - gate on the interview-character allowlist (INV-KB-4); not listed / blank question -> [].
    - out-of-band Mimir consult (a throwaway session + one turn), HARD-bounded by a timeout.
    - extract the answer text (prefer DoneEvent.response; fall back to text deltas), bounded length.
    - return [{"kind":"corpus_reference","text":<extract>}].
  error/empty/timeout: any failure or no hits -> [] (INV-KB-3; never raises to the turn path).
                       CancelledError (browser disconnect) is NOT caught — it propagates.
  no-hit sentinel (heid-code-review F7): the consult prompt asks Mimir to emit exactly NO_CORPUS_MATCH
                       when the search finds nothing relevant; pin_kb_context drops any extract containing
                       it -> [], so a non-empty "no results found" answer is never pinned as the character's
                       own memory. The token is artificial (no genuine passage contains it).
  session hygiene (heid-bug-hunt): the throwaway Mimir consult session is deleted (SDK sessions.delete) on
                       success/error/timeout via a caller-owned holder, so consults don't accumulate upstream.
  CONSULT PROMPT (foot-gun mitigation, tuned live 2026-08-02): force "search_library EXACTLY ONCE,
    no read_note" — converges Mimir in ~3-15s. The softer "do one search" phrasing let Mimir loop
    read_note<->search past a 25s ceiling on conversational (non-keyword) questions (live-observed).
  KNOWN LIMIT (surfaced by the live smoke, NOT a bridge defect): the bridge's GROUNDING VALUE is gated
    by Mimir's retrieval quality on the target corpus. DCC's fiction-wing index is currently weak
    (scores ~0.02, failed backfill — a standing worldtree-dev item), so hits are noisy/partial; the
    model's own DCC training knowledge already grounds Donut well, so the bridge is opportunistic here.
    Its real payoff is a corpus the model does NOT know AND that indexes cleanly.
  RETIREMENT: when Worldtree #361 reference_knowledge reaches Tier-3, delete this module + the single
              server.py call-site (wt.stream_turn's memory_context param stays, inert); Donut then
              searches in-voice natively.

FN client: speakOnDone (index.html — Web Audio STREAMING, DEC-2; amended 2026-08-07)

on SSE `done`:
  if !ttsEnabled(): return                                  # INV-TTS-2
  cancelTts()                                               # INV-TTS-3: abort fetch + stop scheduled nodes
  POST /api/tts {text (sliced to the 8000 cap), agent_id?} -> reader   # DEC-10a: POST body. NO p/a (DEC-7 retired).
  loop: read chunk -> skip ONE WAV header up to the data chunk (bounded 64KiB) -> int16 LE PCM -> Float32 ->
        AudioBuffer(sampleRate=24000) -> BufferSource.start(playAt) GAPLESSLY -> playAt += buf.duration
        # SR = 24000 (chatterbox; was 44100 for Zonos — MUST match or the voice plays ~1.8x too fast). TTFA ~0.5s.
  first scheduled node -> "▶ voiced". HARD failure (non-OK HTTP, or 64KiB with no WAV header) -> ticker + skip;
        ABORT/cancel (INV-TTS-3 new-turn) + bare network error -> SILENT skip (INV-TTS-4, cancel is not a failure)

WHY Web Audio, not <audio src>: Safari/WebKit REFUSES a streaming 0xFFFFFFFF-length WAV via <audio src>
(NotSupportedError — it can't compute duration/seek), which was the operator's live failure. Decoding the raw
int16 PCM ourselves and scheduling it into an AudioContext sidesteps every WAV-container quirk and works in all
engines. Validated in Chromium: 43 nodes scheduled, 5.1s decoded, no error.

AUTOPLAY UNLOCK: an AudioContext starts "suspended"; Safari + Chrome require resume() to originate from a user
gesture (then it stays running). _unlockTtsAudio() resumes it on the FIRST interaction anywhere (document
pointerdown/keydown) + toggle-on + each submit, so it's running before the ~15s-delayed speak-on-done. Validated:
ctx is "running" 6.5s after the gesture (past the transient-activation window). Page served no-store so a stale
cache can't hide these updates.

Slice plan

  1. Persona — author docs/characters/donut.md (corpus-grounded, interview frame, markdown-RP register) + tier3 define Princess Donut. Visible: she's in the picker, chattable (answers from training until slice 3). No code.
  2. Auto-TTS — tts.py (gateway client + pad_to_dials) + /api/tts proxy + the index.html 🔊 toggle + speak-on-done playback + serialize/cancel. Visible: she's voiced, affect-modulated. TDD (respx-mock the gateway; live smoke on the console).
  3. KB-bridge — kb_bridge.py + the one server.py seam. Visible: she recalls the DCC corpus in-voice. TDD. RETIRE-READY per INV-KB-1.

Out of scope: true audio streaming (DEC-2), custom voice registration (DEC-8, infra-ops follow-up), extending the bridge to non-interview agents.