/ src / lib / tts-stream.ts
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  }