Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
4.9 KiB
Streaming TTS Design
Date: 2026-04-03
Status: Approved
Goal
Start playing TTS audio during LLM generation rather than waiting for the full response to finish. When listen mode is on, the first sentence plays as soon as Kokoro finishes synthesizing it — while the LLM is still streaming the rest of the response.
Approach
Client-side sentence queuing composable. The frontend accumulates streaming tokens, detects sentence boundaries, fires per-sentence synthesis requests concurrently, and plays audio in strict insertion order. The existing /api/voice/synthesise backend endpoint is unchanged.
Architecture
useStreamingTts composable
File: frontend/src/composables/useStreamingTts.ts
Inputs:
streamingContent: Ref<string>— the growing accumulated response text (e.g.store.streamingContent)streaming: Ref<boolean>— whether the LLM is currently generatingenabled: Ref<boolean>—truewhen listen mode is on AND TTS is available
Exports:
speaking: Ref<boolean>—truewhile any synthesis is in-flight or audio is playingstop()— cancels all pending synthesis/playback and clears the queue
Internal state:
sentenceBuffer: string— accumulates characters since the last dispatched sentencelastSeenLength: number— tracks how far intostreamingContentwe've processedabortId: number— incremented onstop(); each queued promise checks the current id and bails if staleplayQueue: Promise<void>— a chained promise that serializes audio playback in insertion order
Sentence detection:
- Regex:
/[.!?]+(?=\s|$)/— handles...,?!, multi-punctuation - Triggered on every
streamingContentchange and onstreamingflippingfalse(flush) - Fragments < 3 characters after markdown stripping are skipped
Per-sentence pipeline:
- Strip markdown (same logic as current
speakLastAssistantMessage) - Fire
synthesiseSpeech(sentence)immediately — runs concurrently with other sentences - On failure: one immediate retry. If retry also fails, skip silently and advance the queue
- Resolved blob is inserted into the playback queue at its original position
- Playback queue plays blobs strictly in insertion order via
useVoiceAudio
Stream-end flush:
- When
streamingflipsfalse, any remainingsentenceBuffercontent (fragment without terminal punctuation) is dispatched as a final sentence — covers responses that end without a period
Automatic reset:
- When
streamingflipstrue(new message starting),stop()is called automatically to cancel any in-flight audio from the previous response before starting fresh
Views updated
| View | Change |
|---|---|
ChatView.vue |
Replace speakLastAssistantMessage() + watch(streaming) + synthesising ref with useStreamingTts |
BriefingView.vue |
Replace speakText() + watch(streaming) + synthesising ref with useStreamingTts |
WorkspaceView.vue |
Add listen mode toggle button (same UI pattern as ChatView) + useStreamingTts wired to workspace chat stream |
In all three views: the speaking export from useStreamingTts replaces the old synthesising || audio.playing.value checks for button busy state.
Backend
No changes. /api/voice/synthesise accepts shorter sentence-length strings without issue.
Error Handling
| Scenario | Behavior |
|---|---|
| Synthesis fails for a sentence | One immediate retry; if retry fails, sentence is skipped, queue advances, and a console.warn is emitted with the sentence index and error |
stop() called mid-queue |
abortId incremented; all in-flight promises check id and discard their result |
| New message starts while audio playing | watch(streaming, true → ...) calls stop() before starting new queue |
| TTS unavailable or listen mode off | Composable is inert — watchers do nothing, no requests fired |
| Fragment < 3 chars after stripping | Skipped without a TTS request |
| Response ends without terminal punctuation | Remaining buffer flushed as final sentence on stream-end |
Data Flow
LLM SSE chunks → store.streamingContent (grows)
↓
useStreamingTts watcher
↓
sentenceBuffer accumulation
↓
sentence boundary detected? → synthesiseSpeech(sentence) [concurrent]
↓ ↓ (fail → 1 retry → skip)
playQueue.then(play blob) resolved blob
↓
useVoiceAudio.play() [sequential]
↓
audio output
Files Changed
- New:
frontend/src/composables/useStreamingTts.ts - Modified:
frontend/src/views/ChatView.vue— swap TTS logic for composable - Modified:
frontend/src/views/BriefingView.vue— swap TTS logic for composable - Modified:
frontend/src/views/WorkspaceView.vue— add listen mode + composable