From 7ffd41260387a4007e64e87786f091dc86ef891d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 3 Apr 2026 12:54:05 -0400 Subject: [PATCH] feat(tts): add useStreamingTts composable for sentence-level streaming --- frontend/src/composables/useStreamingTts.ts | 143 ++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 frontend/src/composables/useStreamingTts.ts diff --git a/frontend/src/composables/useStreamingTts.ts b/frontend/src/composables/useStreamingTts.ts new file mode 100644 index 0000000..f813caa --- /dev/null +++ b/frontend/src/composables/useStreamingTts.ts @@ -0,0 +1,143 @@ +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 | ComputedRef + streaming: Ref | ComputedRef + enabled: Ref | ComputedRef +} + +export interface UseStreamingTtsReturn { + /** True while any synthesis request is in-flight or audio is playing. */ + speaking: ComputedRef + /** 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 = 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 { + 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 } +}