feat(tts): add useStreamingTts composable for sentence-level streaming
This commit is contained in:
@@ -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<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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user