tts-stream.ts
1 /** 2 * Streaming TTS utilities: sentence accumulation and ordered audio playback. 3 */ 4 5 // --------------------------------------------------------------------------- 6 // SentenceAccumulator — buffers text deltas, emits on sentence boundaries 7 // --------------------------------------------------------------------------- 8 9 export class SentenceAccumulator { 10 private buffer = '' 11 private onSentence: (sentence: string) => void 12 13 constructor(onSentence: (sentence: string) => void) { 14 this.onSentence = onSentence 15 } 16 17 push(delta: string) { 18 this.buffer += delta 19 // Emit on sentence-ending punctuation followed by space or newline 20 const sentenceEnd = /([.!?])\s+/g 21 let match: RegExpExecArray | null 22 let lastIndex = 0 23 while ((match = sentenceEnd.exec(this.buffer)) !== null) { 24 const sentence = this.buffer.slice(lastIndex, match.index + 1).trim() 25 if (sentence) this.onSentence(sentence) 26 lastIndex = match.index + match[0].length 27 } 28 // Also emit on double newlines 29 const doubleNewline = this.buffer.indexOf('\n\n', lastIndex) 30 if (doubleNewline !== -1) { 31 const sentence = this.buffer.slice(lastIndex, doubleNewline).trim() 32 if (sentence) this.onSentence(sentence) 33 lastIndex = doubleNewline + 2 34 } 35 // Flush if buffer exceeds 200 chars without a break 36 if (this.buffer.length - lastIndex > 200) { 37 const sentence = this.buffer.slice(lastIndex).trim() 38 if (sentence) this.onSentence(sentence) 39 lastIndex = this.buffer.length 40 } 41 this.buffer = this.buffer.slice(lastIndex) 42 } 43 44 flush() { 45 const remaining = this.buffer.trim() 46 if (remaining) this.onSentence(remaining) 47 this.buffer = '' 48 } 49 } 50 51 // --------------------------------------------------------------------------- 52 // AudioChunkQueue — ordered sequential playback of audio chunks 53 // --------------------------------------------------------------------------- 54 55 export class AudioChunkQueue { 56 private queue: Promise<ArrayBuffer>[] = [] 57 private playing = false 58 private audioCtx: AudioContext | null = null 59 private currentSource: AudioBufferSourceNode | null = null 60 private stopped = false 61 onComplete?: () => void 62 63 enqueue(fetchPromise: Promise<ArrayBuffer>) { 64 this.queue.push(fetchPromise) 65 if (!this.playing) this.playNext() 66 } 67 68 private async playNext() { 69 if (this.stopped) return 70 if (this.queue.length === 0) { 71 this.playing = false 72 this.onComplete?.() 73 return 74 } 75 76 this.playing = true 77 const bufferPromise = this.queue.shift()! 78 try { 79 if (!this.audioCtx) this.audioCtx = new AudioContext() 80 if (this.audioCtx.state === 'suspended') await this.audioCtx.resume() 81 82 const arrayBuffer = await bufferPromise 83 if (this.stopped) return 84 const audioBuffer = await this.audioCtx.decodeAudioData(arrayBuffer) 85 if (this.stopped) return 86 87 const source = this.audioCtx.createBufferSource() 88 source.buffer = audioBuffer 89 source.connect(this.audioCtx.destination) 90 this.currentSource = source 91 92 await new Promise<void>((resolve) => { 93 source.onended = () => { 94 this.currentSource = null 95 resolve() 96 } 97 source.start() 98 }) 99 } catch { 100 // Skip failed chunks 101 } 102 103 if (!this.stopped) this.playNext() 104 } 105 106 stop() { 107 this.stopped = true 108 this.queue = [] 109 if (this.currentSource) { 110 try { this.currentSource.stop() } catch { /* noop */ } 111 this.currentSource = null 112 } 113 this.playing = false 114 } 115 } 116 117 // --------------------------------------------------------------------------- 118 // Helper to fetch streaming TTS audio 119 // --------------------------------------------------------------------------- 120 121 export function fetchStreamTts(text: string): Promise<ArrayBuffer> { 122 return fetch('/api/tts/stream', { 123 method: 'POST', 124 headers: { 'Content-Type': 'application/json' }, 125 body: JSON.stringify({ text }), 126 }).then((res) => { 127 if (!res.ok) throw new Error(`TTS error: ${res.status}`) 128 return res.arrayBuffer() 129 }) 130 }