docs: streaming TTS implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,594 @@
|
||||
# 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 ✅
|
||||
Reference in New Issue
Block a user