Adds affect-modulated voice to the web console: the completed assistant
response is spoken on SSE `done`, emotion-modulated by the live PAD the persona
pane already shows (DEC-7 — voice as affect OBSERVABILITY, not chat-app TTS).
- src/ratatoskr/tts.py (new): Zonos-gateway client + PAD→emotion-dial mapping.
tts_synthesize POSTs {input, voice, response_format:"wav", **dials}; wav-only
(DEC-3 — mp3/opus silently return mislabeled PCM). pad_to_dials is total
(None/NaN/out-of-range → valid dials, never raises). TtsUnavailable on any
gateway failure; the single swap seam if we ever move off Zonos.
- web/server.py: POST /api/tts proxy (DEC-4/INV-TTS-1 — the gateway host never
reaches the browser). Per-character voice map (DEC-8: ratatoskr:donut→donut),
serialize lock (DEC-5 — shared 3090), 503 degrade (INV-TTS-4).
- web/static/index.html: 🔊 toggle (opt-in, localStorage, default off,
INV-TTS-2), speak-on-done, AbortController cancel-on-new-turn (INV-TTS-3),
hidden <audio> sink; PAD read off the pane's current snapshot.
- web/entrypoint.py: RATATOSKR_TTS_URL override (the swap seam).
TDD: 17 tts unit tests + 5 endpoint tests (516 green). Live-smoked end-to-end
against the Zonos gateway (:8890): Donut voice + affect dials → 44.1kHz wav,
missing-text→400, neutral→200, gateway-fail→503.
Per docs/contracts/donut_voiced_interview.contract.md (slice 2 of 3).