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 /** Speak a complete text through the same sentence-chunking pipeline. */ speak: (text: string) => 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) { const errMsg = e instanceof Error ? e.message : String(e) console.warn('[StreamingTTS] Synthesis failed, retrying', { chars: stripped.length, preview: stripped.slice(0, 80), error: errMsg, }) try { blob = await synthesiseSpeech(stripped) } catch (e2) { const errMsg2 = e2 instanceof Error ? e2.message : String(e2) console.warn('[StreamingTTS] Retry failed, sentence dropped', { chars: stripped.length, preview: stripped.slice(0, 80), error: errMsg2, }) } } 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 } }) function speak(text: string): void { stop() if (!text.trim()) return const myAbortId = abortId const { sentences, remainder } = extractSentences(text) for (const sentence of sentences) { enqueueSentence(sentence, myAbortId) } if (remainder.trim().length >= MIN_CHARS) { enqueueSentence(remainder.trim(), myAbortId) } } return { speaking, stop, speak } }