b92c6b1487
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
595 lines
18 KiB
Markdown
595 lines
18 KiB
Markdown
# Streaming TTS Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Start playing TTS audio during LLM generation by splitting responses into sentences and synthesizing each sentence as it completes, rather than waiting for the full response.
|
||
|
||
**Architecture:** A new `useStreamingTts` composable watches `streamingContent` for sentence boundaries, fires per-sentence `synthesiseSpeech` requests concurrently, and plays audio in strict insertion order using `useVoiceAudio`. ChatView, BriefingView, and WorkspaceView all use this composable, replacing their current post-stream speak logic.
|
||
|
||
**Tech Stack:** Vue 3 Composition API, TypeScript, `useVoiceAudio` (existing), `synthesiseSpeech` from `api/client.ts` (existing), no backend changes.
|
||
|
||
---
|
||
|
||
## File Map
|
||
|
||
| Action | File | Responsibility |
|
||
|--------|------|----------------|
|
||
| **Create** | `frontend/src/composables/useStreamingTts.ts` | All streaming TTS logic: sentence splitting, TTS queuing, ordered playback |
|
||
| **Modify** | `frontend/src/views/ChatView.vue` | Replace `speakLastAssistantMessage` + old watch with `useStreamingTts` |
|
||
| **Modify** | `frontend/src/views/BriefingView.vue` | Replace `speakText` + `listenToLatest` + old watch with `useStreamingTts` |
|
||
| **Modify** | `frontend/src/views/WorkspaceView.vue` | Add listen mode toggle button + `useStreamingTts` |
|
||
|
||
---
|
||
|
||
## Task 1: Create `useStreamingTts` composable
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/composables/useStreamingTts.ts`
|
||
|
||
- [ ] **Step 1: Create the composable**
|
||
|
||
Create `frontend/src/composables/useStreamingTts.ts` with the full implementation:
|
||
|
||
```typescript
|
||
import { ref, watch, computed } from 'vue'
|
||
import type { Ref, ComputedRef } from 'vue'
|
||
import { synthesiseSpeech } from '@/api/client'
|
||
import { useVoiceAudio } from '@/composables/useVoiceAudio'
|
||
|
||
/** Minimum stripped character count to bother synthesizing. */
|
||
const MIN_CHARS = 3
|
||
|
||
/** Matches sentence-terminal punctuation followed by whitespace or end-of-string. */
|
||
const SENTENCE_BOUNDARY = /[.!?]+(?=\s|$)/
|
||
|
||
function stripMarkdown(text: string): string {
|
||
return text
|
||
.replace(/```[\s\S]*?```/g, '')
|
||
.replace(/`[^`]+`/g, (m) => m.slice(1, -1))
|
||
.replace(/#{1,6}\s+/g, '')
|
||
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||
.replace(/\*([^*]+)\*/g, '$1')
|
||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||
.replace(/^\s*[-*+]\s+/gm, '')
|
||
.replace(/\n{2,}/g, ' ')
|
||
.trim()
|
||
}
|
||
|
||
/**
|
||
* Extract completed sentences from `text` using SENTENCE_BOUNDARY.
|
||
* Returns the sentences found and the unconsumed remainder.
|
||
*/
|
||
function extractSentences(text: string): { sentences: string[]; remainder: string } {
|
||
const sentences: string[] = []
|
||
let remaining = text
|
||
let match: RegExpExecArray | null
|
||
|
||
while ((match = SENTENCE_BOUNDARY.exec(remaining)) !== null) {
|
||
const boundary = match.index + match[0].length
|
||
const sentence = remaining.slice(0, boundary).trim()
|
||
if (sentence) sentences.push(sentence)
|
||
remaining = remaining.slice(boundary)
|
||
}
|
||
|
||
return { sentences, remainder: remaining }
|
||
}
|
||
|
||
export interface UseStreamingTtsOptions {
|
||
streamingContent: Ref<string> | ComputedRef<string>
|
||
streaming: Ref<boolean> | ComputedRef<boolean>
|
||
enabled: Ref<boolean> | ComputedRef<boolean>
|
||
}
|
||
|
||
export interface UseStreamingTtsReturn {
|
||
/** True while any synthesis request is in-flight or audio is playing. */
|
||
speaking: ComputedRef<boolean>
|
||
/** Cancel all in-flight synthesis/playback and clear the queue. */
|
||
stop: () => void
|
||
}
|
||
|
||
export function useStreamingTts(options: UseStreamingTtsOptions): UseStreamingTtsReturn {
|
||
const { streamingContent, streaming, enabled } = options
|
||
const audio = useVoiceAudio()
|
||
|
||
let sentenceBuffer = ''
|
||
let lastSeenLength = 0
|
||
let abortId = 0
|
||
let playQueue: Promise<void> = Promise.resolve()
|
||
const pendingCount = ref(0)
|
||
|
||
const speaking = computed(() => pendingCount.value > 0 || audio.playing.value)
|
||
|
||
function stop(): void {
|
||
abortId++
|
||
sentenceBuffer = ''
|
||
lastSeenLength = 0
|
||
playQueue = Promise.resolve()
|
||
audio.stop()
|
||
pendingCount.value = 0
|
||
}
|
||
|
||
async function enqueueSentence(sentence: string, myAbortId: number): Promise<void> {
|
||
const stripped = stripMarkdown(sentence)
|
||
if (stripped.length < MIN_CHARS) return
|
||
|
||
pendingCount.value++
|
||
let blob: Blob | null = null
|
||
try {
|
||
blob = await synthesiseSpeech(stripped)
|
||
} catch (e) {
|
||
console.warn('[StreamingTTS] Synthesis failed, retrying sentence', { sentence: stripped, error: e })
|
||
try {
|
||
blob = await synthesiseSpeech(stripped)
|
||
} catch (e2) {
|
||
console.warn('[StreamingTTS] Retry also failed, skipping sentence', { sentence: stripped, error: e2 })
|
||
}
|
||
} finally {
|
||
pendingCount.value--
|
||
}
|
||
|
||
if (!blob) return
|
||
|
||
// Capture blob for the closure — TS can't narrow after async gap
|
||
const resolvedBlob = blob
|
||
playQueue = playQueue.then(async () => {
|
||
if (abortId !== myAbortId) return
|
||
await audio.play(resolvedBlob)
|
||
})
|
||
}
|
||
|
||
function dispatchBuffer(flush: boolean): void {
|
||
if (!enabled.value) return
|
||
const myAbortId = abortId
|
||
const { sentences, remainder } = extractSentences(sentenceBuffer)
|
||
sentenceBuffer = flush ? '' : remainder
|
||
for (const sentence of sentences) {
|
||
enqueueSentence(sentence, myAbortId)
|
||
}
|
||
if (flush && remainder.trim().length >= MIN_CHARS) {
|
||
enqueueSentence(remainder.trim(), myAbortId)
|
||
}
|
||
}
|
||
|
||
// Watch accumulating content — extract new characters since last check
|
||
watch(streamingContent, (newContent) => {
|
||
if (!enabled.value) return
|
||
const delta = newContent.slice(lastSeenLength)
|
||
lastSeenLength = newContent.length
|
||
sentenceBuffer += delta
|
||
dispatchBuffer(false)
|
||
})
|
||
|
||
// Watch streaming flag — stop on new message start, flush on end
|
||
watch(streaming, (isStreaming) => {
|
||
if (!enabled.value) return
|
||
if (isStreaming) {
|
||
// New message starting — cancel previous response's audio
|
||
stop()
|
||
} else {
|
||
// Stream ended — flush any remaining fragment
|
||
dispatchBuffer(true)
|
||
lastSeenLength = 0
|
||
}
|
||
})
|
||
|
||
return { speaking, stop }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: TypeScript check**
|
||
|
||
```bash
|
||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
|
||
npx vue-tsc --noEmit 2>&1 | head -40
|
||
```
|
||
|
||
Expected: no errors mentioning `useStreamingTts.ts`.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/composables/useStreamingTts.ts
|
||
git commit -m "feat(tts): add useStreamingTts composable for sentence-level streaming"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Update ChatView
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/views/ChatView.vue`
|
||
|
||
Current TTS code to remove (lines ~35–66):
|
||
|
||
```typescript
|
||
// REMOVE these:
|
||
const synthesising = ref(false);
|
||
|
||
async function speakLastAssistantMessage() { ... } // entire function
|
||
|
||
watch(() => store.streaming, async (streaming) => {
|
||
if (!streaming && listenMode.value && voiceTtsEnabled.value) {
|
||
await new Promise((r) => setTimeout(r, 200));
|
||
await speakLastAssistantMessage();
|
||
}
|
||
});
|
||
```
|
||
|
||
Also remove the `synthesiseSpeech` import from `@/api/client` (it is no longer called directly in this file).
|
||
|
||
- [ ] **Step 1: Add import and replace TTS logic**
|
||
|
||
In `frontend/src/views/ChatView.vue`:
|
||
|
||
1. Add to imports at the top of `<script setup>`:
|
||
```typescript
|
||
import { useStreamingTts } from "@/composables/useStreamingTts";
|
||
```
|
||
|
||
2. Remove `synthesiseSpeech` from the `@/api/client` import line (keep other imports like `apiGet`, `transcribeAudio`).
|
||
|
||
3. Remove `const synthesising = ref(false);` (line ~35).
|
||
|
||
4. Remove the entire `speakLastAssistantMessage` function (lines ~38–59).
|
||
|
||
5. Remove the `watch(() => store.streaming, ...)` block that called `speakLastAssistantMessage` (lines ~61–66).
|
||
|
||
6. Add after `const listenMode = useListenMode();`:
|
||
```typescript
|
||
const tts = useStreamingTts({
|
||
streamingContent: computed(() => store.streamingContent),
|
||
streaming: computed(() => store.streaming),
|
||
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Update template references**
|
||
|
||
In the ChatView template, replace every occurrence of `synthesising` with `tts.speaking.value`:
|
||
|
||
Find (line ~919):
|
||
```html
|
||
:class="{ 'btn-listen--active': listenMode, 'btn-listen--busy': synthesising || audio.playing.value }"
|
||
```
|
||
Replace with:
|
||
```html
|
||
:class="{ 'btn-listen--active': listenMode, 'btn-listen--busy': tts.speaking.value }"
|
||
```
|
||
|
||
Find (line ~920):
|
||
```html
|
||
@click="listenMode = !listenMode; if (listenMode) speakLastAssistantMessage()"
|
||
```
|
||
Replace with:
|
||
```html
|
||
@click="listenMode = !listenMode; if (!listenMode) tts.stop()"
|
||
```
|
||
|
||
Find (line ~924):
|
||
```html
|
||
<svg v-if="!synthesising && !audio.playing.value" ...>
|
||
```
|
||
Replace with:
|
||
```html
|
||
<svg v-if="!tts.speaking.value" ...>
|
||
```
|
||
|
||
Note: the `audio` variable (`useVoiceAudio()`) is still used for the volume slider and PTT stop — do NOT remove it.
|
||
|
||
- [ ] **Step 3: TypeScript check**
|
||
|
||
```bash
|
||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
|
||
npx vue-tsc --noEmit 2>&1 | head -40
|
||
```
|
||
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/views/ChatView.vue
|
||
git commit -m "feat(tts): wire useStreamingTts into ChatView"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Update BriefingView
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/views/BriefingView.vue`
|
||
|
||
Current TTS code to remove:
|
||
|
||
```typescript
|
||
// REMOVE:
|
||
const synthesising = ref(false)
|
||
|
||
async function speakText(text: string) { ... } // entire function
|
||
async function listenToLatest() { ... } // entire function
|
||
|
||
// REMOVE this watch block (the TTS one — keep the other streaming watch):
|
||
watch(() => chatStore.streaming, async (streaming) => {
|
||
if (!streaming && listenMode.value && voiceTtsEnabled.value) {
|
||
await new Promise((r) => setTimeout(r, 200))
|
||
await listenToLatest()
|
||
}
|
||
})
|
||
```
|
||
|
||
Note: BriefingView has **two** `watch(() => chatStore.streaming, ...)` blocks. Keep the first one (lines ~152–156, which refreshes messages). Remove only the TTS one (lines ~327–332).
|
||
|
||
Also remove the `synthesiseSpeech` import from `@/api/client`.
|
||
|
||
- [ ] **Step 1: Add import and replace TTS logic**
|
||
|
||
In `frontend/src/views/BriefingView.vue`:
|
||
|
||
1. Add to imports:
|
||
```typescript
|
||
import { useStreamingTts } from '@/composables/useStreamingTts'
|
||
```
|
||
|
||
2. Remove `synthesiseSpeech` from the `@/api/client` import line.
|
||
|
||
3. Remove `const synthesising = ref(false)`.
|
||
|
||
4. Remove the entire `speakText` function.
|
||
|
||
5. Remove the entire `listenToLatest` function.
|
||
|
||
6. Remove the TTS `watch(() => chatStore.streaming, ...)` block (the one that calls `listenToLatest`).
|
||
|
||
7. Add after `const listenMode = useListenMode()`:
|
||
```typescript
|
||
const tts = useStreamingTts({
|
||
streamingContent: computed(() => chatStore.streamingContent),
|
||
streaming: computed(() => chatStore.streaming),
|
||
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Update template references**
|
||
|
||
Find the listen toggle button in the template. Replace `synthesising` references:
|
||
|
||
```html
|
||
<!-- Before -->
|
||
:class="{ 'btn-icon-active': listenMode, 'btn-icon-busy': synthesising || audio.playing.value }"
|
||
@click="listenMode ? (listenMode = false) : (listenMode = true, listenToLatest())"
|
||
|
||
<!-- After -->
|
||
:class="{ 'btn-icon-active': listenMode, 'btn-icon-busy': tts.speaking.value }"
|
||
@click="listenMode = !listenMode; if (!listenMode) tts.stop()"
|
||
```
|
||
|
||
Find the stop button:
|
||
```html
|
||
<!-- Before -->
|
||
v-if="voiceTtsEnabled && (synthesising || audio.playing.value)"
|
||
@click="audio.stop(); synthesising = false"
|
||
|
||
<!-- After -->
|
||
v-if="voiceTtsEnabled && tts.speaking.value"
|
||
@click="tts.stop()"
|
||
```
|
||
|
||
Find the spinner SVG condition:
|
||
```html
|
||
<!-- Before -->
|
||
<svg v-if="!synthesising && !audio.playing.value" ...>
|
||
|
||
<!-- After -->
|
||
<svg v-if="!tts.speaking.value" ...>
|
||
```
|
||
|
||
- [ ] **Step 3: TypeScript check**
|
||
|
||
```bash
|
||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
|
||
npx vue-tsc --noEmit 2>&1 | head -40
|
||
```
|
||
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/views/BriefingView.vue
|
||
git commit -m "feat(tts): wire useStreamingTts into BriefingView"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Add streaming TTS to WorkspaceView
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/views/WorkspaceView.vue`
|
||
|
||
WorkspaceView has no TTS today. We add: listen mode toggle, `useStreamingTts`, and the listen button in the chat input toolbar.
|
||
|
||
- [ ] **Step 1: Add imports and composable**
|
||
|
||
In `frontend/src/views/WorkspaceView.vue`, add to the import block at the top of `<script setup>`:
|
||
|
||
```typescript
|
||
import { useListenMode } from '@/composables/useListenMode'
|
||
import { useStreamingTts } from '@/composables/useStreamingTts'
|
||
import { useVoiceAudio } from '@/composables/useVoiceAudio'
|
||
```
|
||
|
||
After the existing store setup code (after `const settingsStore = useSettingsStore()`), add:
|
||
|
||
```typescript
|
||
const listenMode = useListenMode()
|
||
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady)
|
||
const audio = useVoiceAudio()
|
||
const tts = useStreamingTts({
|
||
streamingContent: computed(() => chatStore.streamingContent),
|
||
streaming: computed(() => chatStore.streaming),
|
||
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Add listen mode button to template**
|
||
|
||
In the `<div class="chat-input-area">` section (around line 365), add the listen button before the abort/send button:
|
||
|
||
```html
|
||
<div class="chat-input-area">
|
||
<textarea
|
||
ref="inputEl"
|
||
v-model="messageInput"
|
||
class="chat-input"
|
||
:placeholder="chatStore.streaming ? 'Type to queue next message… (Enter to queue)' : 'Message the agent… (Enter to send)'"
|
||
rows="1"
|
||
@keydown="onInputKeydown"
|
||
@input="autoResize"
|
||
></textarea>
|
||
<!-- Listen mode toggle (TTS) -->
|
||
<button
|
||
v-if="voiceTtsEnabled"
|
||
class="btn-listen-ws"
|
||
:class="{ 'btn-listen-ws--active': listenMode, 'btn-listen-ws--busy': tts.speaking.value }"
|
||
:title="listenMode ? 'Stop auto-read' : 'Read responses aloud'"
|
||
aria-label="Toggle listen mode"
|
||
@click="listenMode = !listenMode; if (!listenMode) tts.stop()"
|
||
>
|
||
<svg v-if="!tts.speaking.value" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
||
</svg>
|
||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||
<path d="M18 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C21.8 14.82 22 13.43 22 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
|
||
</svg>
|
||
</button>
|
||
<button
|
||
v-if="chatStore.streaming"
|
||
class="btn-abort"
|
||
title="Stop generation"
|
||
@click="chatStore.cancelGeneration()"
|
||
>
|
||
■ Stop
|
||
</button>
|
||
<button
|
||
v-else
|
||
class="btn-send"
|
||
:disabled="!messageInput.trim()"
|
||
@click="sendMessage"
|
||
>
|
||
Send
|
||
</button>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 3: Add CSS for the listen button**
|
||
|
||
In the `<style>` block, add:
|
||
|
||
```css
|
||
.btn-listen-ws {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 2rem;
|
||
height: 2rem;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-md);
|
||
background: transparent;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||
}
|
||
.btn-listen-ws:hover {
|
||
color: var(--color-text);
|
||
border-color: var(--color-primary);
|
||
}
|
||
.btn-listen-ws--active {
|
||
color: var(--color-primary);
|
||
border-color: var(--color-primary);
|
||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||
}
|
||
.btn-listen-ws--busy {
|
||
color: var(--color-primary);
|
||
animation: pulse 1.2s ease-in-out infinite;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: TypeScript check**
|
||
|
||
```bash
|
||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
|
||
npx vue-tsc --noEmit 2>&1 | head -40
|
||
```
|
||
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/views/WorkspaceView.vue
|
||
git commit -m "feat(tts): add streaming TTS listen mode to WorkspaceView"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Final integration check and push
|
||
|
||
- [ ] **Step 1: Full TypeScript check**
|
||
|
||
```bash
|
||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
|
||
npx vue-tsc --noEmit 2>&1
|
||
```
|
||
|
||
Expected: zero errors.
|
||
|
||
- [ ] **Step 2: Verify no dead imports remain**
|
||
|
||
```bash
|
||
grep -n "synthesiseSpeech\|speakLastAssistantMessage\|speakText\|listenToLatest\|synthesising" \
|
||
frontend/src/views/ChatView.vue \
|
||
frontend/src/views/BriefingView.vue \
|
||
frontend/src/views/WorkspaceView.vue
|
||
```
|
||
|
||
Expected: no matches (all replaced).
|
||
|
||
- [ ] **Step 3: Manual smoke test**
|
||
|
||
1. Enable voice in Admin → Config
|
||
2. Open Chat, enable listen mode (speaker icon)
|
||
3. Send a message and watch: audio should begin playing the first sentence while the LLM is still streaming the response
|
||
4. Send another message mid-playback — previous audio should stop immediately
|
||
5. Toggle listen mode off mid-response — audio stops, `tts.stop()` called
|
||
6. Repeat in `/briefing` and `/workspace/:id`
|
||
|
||
- [ ] **Step 4: Push**
|
||
|
||
```bash
|
||
git push origin dev
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
**Spec coverage check:**
|
||
- ✅ Starts playing during generation (sentence-level queue, fires on each boundary)
|
||
- ✅ Automatic when listen mode on (enabled computed = listenMode && voiceTtsEnabled)
|
||
- ✅ ChatView updated
|
||
- ✅ BriefingView updated
|
||
- ✅ WorkspaceView added
|
||
- ✅ One retry before skipping on failure
|
||
- ✅ Failures logged via `console.warn` with sentence text and error
|
||
- ✅ `stop()` on new message start (watch streaming → true)
|
||
- ✅ Flush remaining buffer on stream end (watch streaming → false)
|
||
- ✅ Fragments < 3 chars skipped
|
||
- ✅ `abortId` prevents stale playback after stop
|
||
|
||
**Type consistency:**
|
||
- `tts.speaking` is `ComputedRef<boolean>` — accessed as `tts.speaking.value` in templates ✅
|
||
- `tts.stop()` called consistently across all three views ✅
|
||
- `useStreamingTts` options match usage in all three call sites ✅
|
||
- `audio` variable kept in ChatView (used by volume slider) — not removed ✅
|