Voice assignment moves from the hardcoded server map to ~/.config/ratatoskr/ voices.json (per-agent voice + optional narration_voice). An agent with a narration_voice gets a two-voice split: quoted speech in `voice`, narration in `narration_voice`, synthesized per-span and stitched under one WAV header. - new src/ratatoskr/voices.py: load_voice_config (degrade-not-crash), segment_dialogue (quote-based, straight + curly), resolve_voice_spans - tts.py: tts_stream_stitched replaces tts_stream — serial per-span synth, span 0 verbatim, spans 1..N header-stripped -> one gapless 48kHz stream; a single-span list is a byte-identical passthrough (no single-voice regression) - server.py: _tts_endpoint resolves spans from app.state.voice_config; the hardcoded _TTS_VOICE_MAP is retired; create_app gains a voice_config param - entrypoint.py: loads voices.json at startup - contract DEC-11 + INV-TTS-5/6/7; initial config donut->donut, sindra->miranda (dialogue) / emmie (narration) Live-verified on :8765: Sindra mixed turn -> 2 dots calls (emmie+miranda) stitched into one 48kHz WAV with a single RIFF header; Donut single-voice unchanged. 545 tests green (incl. new test_voices.py).
462 lines
35 KiB
Markdown
462 lines
35 KiB
Markdown
---
|
||
contract_version: "2.1"
|
||
module: "ratatoskr.web.tts_kb"
|
||
purpose: >
|
||
A voiced, corpus-grounded Tier-3 interview character in the ratatoskr web
|
||
console. Two capabilities plus one character: (a) auto-TTS via the
|
||
dots-tts gateway, spoken on SSE `done` (migrated Zonos→chatterbox-fast
|
||
2026-08-07, then chatterbox-fast→dots-tts 2026-08-10; no affect modulation —
|
||
dots 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.
|
||
scope: >
|
||
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).
|
||
touches:
|
||
- src/ratatoskr/web/server.py # /api/tts route + the retrieval-pinning seam on the turn path
|
||
- src/ratatoskr/web/static/index.html # speak-on-done playback (SR 48000), 🔊 toggle, <audio> sink; turn POST carries agent_id
|
||
- src/ratatoskr/web/entrypoint.py # RATATOSKR_TTS_URL override (the tts swap seam)
|
||
- src/ratatoskr/tts.py # dots-tts gateway client (Zonos→chatterbox 2026-08-07→dots 2026-08-10; OpenAI-shaped)
|
||
- src/ratatoskr/kb_bridge.py # NEW, RETIRE-READY — consumer-side retrieval + memory_context pinning
|
||
- src/ratatoskr/wt.py # stream_turn gains a memory_context passthrough (seam-review: the contract's original touch list undercounted this by one file; the param defaults None so the bridge's RETIREMENT stays inert — deleting kb_bridge.py + the one call-site leaves wt.stream_turn's SDK-parity param harmless)
|
||
- docs/characters/donut.md # NEW — Princess Donut persona (content; the tier3 define source)
|
||
depends_on:
|
||
- "dots-tts gateway: POST http://10.100.79.3:8198/v1/audio/speech (infra-ops; WG-internal, no auth; OpenAI-shaped schema {input,voice,response_format,stream}; streaming placeholder-header wav @ 48000 Hz mono s16le; dots streams a whole turn from one call; SERIALIZED single-consumer; zero-shot voice cloning, voices donut/glados/emmie/miranda; NO affect controls; verified 2026-08-10 against dots-studio/dots.tts-soar). chatterbox-fast :8197 kept up as rollback."
|
||
- "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"
|
||
used_by:
|
||
- "ratatoskr.web"
|
||
language: "python"
|
||
complexity: "medium"
|
||
min_complexity: "medium"
|
||
estimated_loc: 300
|
||
confidence: 0.8
|
||
---
|
||
|
||
# Contract: Donut voiced interview (auto-TTS + KB-recall bridge)
|
||
|
||
> **⚠ TTS MIGRATED chatterbox-fast → dots-tts 2026-08-10 (operator-directed, after an
|
||
> A/B win).** The synthesis backend moved from chatterbox-fast (:8197 bespoke `/tts`)
|
||
> to dots-tts (rednote-hilab `dots.tts-soar`, :8198 OpenAI-shaped `/v1/audio/speech`),
|
||
> verified live. Four deltas; everything else (the streaming placeholder-header WAV
|
||
> shape, the browser Web-Audio PCM decode path, POST `/api/tts`, the serialize lock,
|
||
> INV-TTS-1..4) is UNCHANGED:
|
||
> - **Gateway body OpenAI-shaped.** `{input, voice, response_format:"wav", stream:true}`
|
||
> — `input` (not chatterbox's `text`), `response_format` (not `format`). Closer to the
|
||
> Zonos-era client. `tts.py` stays the single swap seam (DEC-1), now translating the
|
||
> OpenAI schema; `DOTS_TTS_URL` replaces `CHATTERBOX_TTS_URL`.
|
||
> - **Sample rate 24000 → 48000 Hz.** The browser Web Audio decode MUST use 48000 or the
|
||
> voice plays ~2× too fast (`index.html` `SR = 48000`).
|
||
> - **Default voice `glados_25s` → `glados`.** dots voices are donut/glados/emmie/miranda
|
||
> (GET /v1/voices); `donut` carries over. Non-interview agents fall to `glados`.
|
||
> - **Serialized single-consumer.** dots renders one generation at a time — satisfied by
|
||
> the existing DEC-5 lock (no code change). If concurrent streams are ever needed,
|
||
> infra-ops escalates the backend behind the same API (client unchanged).
|
||
> Affect stays dropped (DEC-7): dots has no emotion knob, same as chatterbox — NOT a fresh
|
||
> regression. chatterbox-fast :8197 is kept up as the rollback until dots is confirmed
|
||
> solid. The 2026-08-07 chatterbox banner + DEC-7/9/9a/10 below are retained as historical
|
||
> record.
|
||
|
||
> **⚠ 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-10 for dots).**
|
||
dots clones a voice server-side from a reference clip + transcript; the client just
|
||
passes a voice NAME (GET /v1/voices lists them: donut/glados/emmie/miranda). The
|
||
`donut` voice carries over from chatterbox, so `_TTS_VOICE_MAP` maps
|
||
`ratatoskr:donut → "donut"`; `ratatoskr:sindra → "miranda"` (operator-directed
|
||
2026-08-10). NOTE the case: lowercase `"donut"` (Zonos used
|
||
`"Donut"`). Non-interview agents fall to the dots default `"glados"` (was chatterbox
|
||
`"glados_25s"` / Zonos `"Cora"`, neither of which exists on dots). New voices are a
|
||
one-line request to infra-ops (derived from the canonical voice corpus).
|
||
- **DEC-9 — hold English: RESOLVED SERVER-SIDE 2026-08-07 (client sends full text, default
|
||
sampling).** The Zonos `language:"en-us"` pin is dropped — chatterbox has no `language` field.
|
||
The long-turn garble ("swaps to German halfway through") went through two WRONG hypotheses
|
||
before the real cause surfaced under the operator's clean-diagnosis push (thread 01KZEDMJ…):
|
||
- WRONG-1: "English-only, nothing to drift" (infra-ops Q8) — falsified by the operator report.
|
||
- WRONG-2: "multilingual leak, curb with tighter sampling" — I shipped `top_k 1000→80` /
|
||
`top_p 0.95→0.85` / `temp 0.8→0.5`. This made it WORSE: tight sampling pulls the degradation
|
||
onset to a SHORTER length (garble by ~200 chars vs ~300 at default knobs).
|
||
- **REAL CAUSE (infra-ops, signal-measured): the Turbo model OVER-RUNS its generation TAIL** —
|
||
a long single generation degrades into garble/dead-air in its final ~2-3s (voiced-tail
|
||
zero-crossing rate 1.58x the middle; the lib itself filters OOV tokens + pads silence). The
|
||
scheduler's unbounded buffer-ratchet built 300-600 char mega-chunks landing squarely in that
|
||
zone, and streaming concatenated each bad tail. The "German" was that tail garble mis-heard
|
||
(compounded by shared-3090 OOM garbage, now gone — Zonos was moved off the 3090).
|
||
- **FIX (deployed server-side, image :v2): `max_chunk_chars=250`** on the gateway scheduler,
|
||
bounding each generation below the ~300-char onset → clean 3-4 sentence chunks with a full
|
||
prosodic arc (verified: voiced-tail ZCR 1.58x→0.64x; operator ear-confirmed clean audio AND
|
||
clean paragraph joins — chatterbox's low emotiveness keeps the seams smooth). Per-request
|
||
`max_chunk_chars` override available for per-call tuning.
|
||
- **CONSUMER SIDE (this is what the code does): send FULL text with the gateway's DEFAULT
|
||
sampling.** The WRONG-2 sampling curbs were REVERTED (they degrade audio earlier and fight
|
||
the :v2 cap). `gateway_body` is back to `{text, voice, format, stream}` — no client chunking
|
||
(DEC-10 stays retired; the server chunks at 250), no sampling overrides. The persona's
|
||
dialogue-only rewrite in `docs/characters/donut.md` still stands. (Original Zonos DEC-9 below.)
|
||
|
||
- **DEC-9a — empty-200 OOM guard: KEPT as hygiene (OOM itself resolved).** chatterbox-fast used
|
||
to share the RTX 3090 with Zonos2 (~1 GB headroom); a long single generation could OOM the card
|
||
and the gateway then returned HTTP **200 with a 0-byte body**. The OOM is resolved (Zonos moved
|
||
off the 3090; the :v2 250-char cap keeps generations small), but `tts_endpoint` KEEPS treating an
|
||
empty 200 body as a synthesis failure → 503 (INV-TTS-4 visible skip) — good hygiene regardless
|
||
(infra-ops-endorsed), never a silent empty audio/wav stream.
|
||
- **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).
|
||
|
||
- **DEC-11 — config-driven voices + two-voice dialogue/narration split (2026-08-11, operator-directed).**
|
||
Voice assignment moves from the hardcoded `_TTS_VOICE_MAP` (DEC-8) to a rata-side config file
|
||
`~/.config/ratatoskr/voices.json` (beside local_agents.json + provider.env; NOT folded into the
|
||
agent-index schema — isolated from its v1→v2 silent-drop foot-gun). Supersedes DEC-8's hardcoded map.
|
||
- **Schema:** `{"default": "<voice>", "agents": {"<agent_id>": {"voice": "<voice>", "narration_voice": "<voice>"?}}}`.
|
||
`voice` = the agent's dialogue/primary voice; an unmapped agent (or one with no `voice`) falls to
|
||
`default`. Voice names are GATEWAY-validated (GET /v1/voices), not client-asserted.
|
||
- **`narration_voice` is OPTIONAL and its PRESENCE is the two-voice switch** (no separate flag).
|
||
Absent → the whole turn is one span in `voice` (byte-identical to the prior single-call passthrough —
|
||
this is why dialogue-only Donut needs NO special-casing). Present → the turn is SEGMENTED into
|
||
dialogue vs narration spans; dialogue → `voice`, narration → `narration_voice`.
|
||
- **Segmentation (FN segment_dialogue):** QUOTED text (straight `"` OR curly `“ ”`) = dialogue;
|
||
text OUTSIDE quotes = narration. Order preserved; empty/whitespace spans dropped. An unbalanced
|
||
trailing open-quote → its run-to-end is dialogue (best-effort, never raises). dots' server-side
|
||
curly→ASCII fold is pronunciation-only and does NOT affect boundary detection (we match both styles).
|
||
- **Synthesis (FN tts_stream_stitched):** spans synth SERIALLY (dots single-consumer) into ONE
|
||
continuous stream — span 0 streamed as wav VERBATIM (header + PCM), spans 1..N streamed as wav with
|
||
the leading WAV header STRIPPED (accumulate-until-`data`, emit after `data`+8) so the browser decodes
|
||
one gapless 48kHz mono s16le stream after a single leading header. The single-span case is EXACTLY the
|
||
prior tts_stream passthrough (INV-TTS-6, no regression). The `yielded_any` degrade pivot spans the whole
|
||
sequence: a pre-first-byte failure on ANY span before span 0 has committed → TtsUnavailable → 503; a
|
||
failure after ≥1 byte committed → degrade (drop the tail, keep what played), never raise into the 200.
|
||
- **Config load (FN load_voice_config):** entrypoint reads voices.json at startup → `create_app(voice_config)`
|
||
→ `app.state.voice_config`; `_tts_endpoint` resolves spans per turn (FN resolve_voice_spans). An
|
||
absent/malformed file → the built-in DEFAULT_VOICE_CONFIG (donut→donut; sindra→miranda dialogue + emmie
|
||
narration; glados default), logged — degrade-not-crash (INV-TTS-5).
|
||
- **Initial voices.json:** `donut → {voice: donut}` (dialogue-only, single voice); `sindra →
|
||
{voice: miranda, narration_voice: emmie}`.
|
||
|
||
## Invariants
|
||
|
||
- **INV-TTS-1 [hard]** — the TTS gateway host/URL (dots-tts :8198) 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-TTS-5 [hard]** — voice config is degrade-not-crash: an absent, unreadable, or malformed
|
||
`voices.json` (bad JSON, wrong types, missing keys) falls back to the built-in DEFAULT_VOICE_CONFIG
|
||
and logs; it NEVER crashes `create_app`/the server. Per-agent malformed entries fall to `default`.
|
||
- **INV-TTS-6 [hard]** — the single-span path is a byte-identical passthrough: an agent with no
|
||
`narration_voice` (e.g. Donut), or any turn that segments to one span, produces the exact stream the
|
||
prior single `tts_stream` call did (one leading WAV header + PCM, verbatim). No regression for the
|
||
dialogue-only / single-voice case.
|
||
- **INV-TTS-7 [hard]** — a stitched multi-span stream carries EXACTLY ONE WAV header (span 0's); spans
|
||
1..N are header-stripped before their PCM is emitted, so the browser decodes one continuous s16le
|
||
stream (never a RIFF header buried mid-stream).
|
||
- **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-10 dots)
|
||
```
|
||
tts_stream(text, *, voice, client: httpx.AsyncClient, url=DOTS_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. dots streams a whole turn from this SINGLE call (DEC-10 RETIRED) — no client-side
|
||
# chunk-and-concatenate wrapper.
|
||
# gateway_body(text, voice) = {input, voice, response_format:"wav", stream:true} (OpenAI-shaped: `input`
|
||
# not `text`, `response_format` not `format`). Full text, DEFAULT sampling. NO dials, NO language,
|
||
# NO client sampling curbs.
|
||
precondition: text non-empty. Voice membership in GET /v1/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 mono s16le PCM @ 48000 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=48000) -> BufferSource.start(playAt) GAPLESSLY -> playAt += buf.duration
|
||
# SR = 48000 (dots; was 24000 for chatterbox — MUST match or the voice plays ~2x 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.
|