e4c812a603
- Route now logs every synthesis request (char count, voice, speed) - Route logs char count + text preview when the 8000-char limit is hit - Route logs empty audio with preview (helps spot no-chunk-produced edge case) - Route logs success with byte count and duration - Kokoro synthesise() logs per-call: samples produced, elapsed, chars/s - Kokoro synthesise() logs warning when zero audio chunks returned with preview - Kokoro synthesise() catches and logs pipeline-internal errors with preview - Frontend: console.warn now includes char count + 80-char preview on failure and retry Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
169 lines
5.1 KiB
TypeScript
169 lines
5.1 KiB
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
|
|
/** 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<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) {
|
|
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 }
|
|
}
|