| /** | |
| * streamTts.ts β pluggable streaming text-to-speech surface. | |
| * | |
| * One interface, three planned implementations. This file ships the | |
| * first (browser Web Speech); Piper-WASM and remote vendor streamers | |
| * are follow-ups that just swap the concrete constructor in | |
| * :func:`createStreamingTts`. | |
| * | |
| * Contract (Β§ 5.3 of docs/analysis/voice-call-streaming-design.md): | |
| * | |
| * appendDelta(delta) push a new text chunk. Implementations | |
| * flush to the audio engine at sentence | |
| * boundaries; callers hand over raw deltas | |
| * without worrying about clause shaping. | |
| * flush() signal end-of-turn. Any residual buffer | |
| * is spoken; the engine transitions back | |
| * to idle once the audio drains. | |
| * stop() barge-in. MUST silence any audio playing | |
| * or scheduled to play within β€ 50 ms on | |
| * Chromium. Cheap to call redundantly. | |
| * isSpeaking read-only; true while audio is playing or | |
| * scheduled. Drives the VAD-tap enable gate. | |
| */ | |
| // ββ Sentence-boundary helper ββββββββββββββββββββββββββββββββββββββββββ | |
| // | |
| // Flush on: . ! ? ; or a newline β but only when they're followed | |
| // by whitespace or end-of-buffer (so "e.g." / "Dr." don't mis-split). | |
| // A tiny regex, kept readable rather than clever; the failure mode on | |
| // a miss is "one extra utterance per turn," not a correctness bug. | |
| const SENTENCE_END = /([.!?;\n])\s+/g; | |
| function splitIntoSentences(buffer) { | |
| const sentences = []; | |
| let lastIndex = 0; | |
| let m; | |
| SENTENCE_END.lastIndex = 0; | |
| while ((m = SENTENCE_END.exec(buffer)) !== null) { | |
| const end = m.index + m[0].length; | |
| const piece = buffer.slice(lastIndex, end).trim(); | |
| if (piece) | |
| sentences.push(piece); | |
| lastIndex = end; | |
| } | |
| const remainder = buffer.slice(lastIndex); | |
| return { sentences, remainder }; | |
| } | |
| // ββ Web Speech implementation (ships first) ββββββββββββββββββββββββββ | |
| // | |
| // Uses ``window.speechSynthesis`` directly. Each sentence becomes | |
| // exactly one SpeechSynthesisUtterance so barge-in (via ``cancel()``) | |
| // drops the current sentence + the queue atomically. | |
| // | |
| // Why one utterance per sentence instead of one per delta: | |
| // | |
| // 1. SpeechSynthesis queues utterances, not characters. Passing every | |
| // delta through would create 40+ queued utterances per turn on | |
| // Chromium, each with its own intonation reset β sounds robotic. | |
| // 2. Cancel semantics are per-utterance, so sentence-granularity | |
| // means barge-in can interrupt mid-sentence without stranding | |
| // half-spoken buffers. | |
| class WebSpeechStreamTts { | |
| constructor() { | |
| this.buffer = ''; | |
| this.speakingCount = 0; | |
| this.stopped = false; | |
| this.syn = | |
| typeof window !== 'undefined' && 'speechSynthesis' in window | |
| ? window.speechSynthesis | |
| : null; | |
| } | |
| get isSpeaking() { | |
| return this.speakingCount > 0; | |
| } | |
| appendDelta(delta) { | |
| if (this.stopped || !delta) | |
| return; | |
| this.buffer += delta; | |
| const { sentences, remainder } = splitIntoSentences(this.buffer); | |
| this.buffer = remainder; | |
| for (const s of sentences) | |
| this._speak(s); | |
| } | |
| flush() { | |
| if (this.stopped) | |
| return; | |
| if (this.buffer.trim()) { | |
| this._speak(this.buffer.trim()); | |
| this.buffer = ''; | |
| } | |
| } | |
| stop() { | |
| // Idempotent β safe to call repeatedly from the barge-in path. | |
| this.stopped = true; | |
| this.buffer = ''; | |
| this.speakingCount = 0; | |
| try { | |
| this.syn?.cancel(); | |
| } | |
| catch { | |
| /* cancel is best-effort */ | |
| } | |
| } | |
| _speak(text) { | |
| if (!this.syn) | |
| return; | |
| let utter; | |
| try { | |
| utter = new SpeechSynthesisUtterance(text); | |
| } | |
| catch { | |
| return; | |
| } | |
| // Track speaking state so the VAD-tap enable gate stays tight | |
| // even when the queue reorders utterances (Safari / old Chromium). | |
| this.speakingCount += 1; | |
| const done = () => { | |
| this.speakingCount = Math.max(0, this.speakingCount - 1); | |
| }; | |
| utter.onend = done; | |
| utter.onerror = done; | |
| try { | |
| this.syn.speak(utter); | |
| } | |
| catch { | |
| done(); | |
| } | |
| } | |
| } | |
| // ββ Null implementation β used in tests + as a safety fallback βββββββ | |
| class NullStreamTts { | |
| constructor() { | |
| this.isSpeaking = false; | |
| } | |
| appendDelta(_delta) { } | |
| flush() { } | |
| stop() { } | |
| } | |
| // ββ Factory βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // | |
| // Returns the best implementation available at runtime. Swapping | |
| // in Piper-WASM or a remote vendor streamer is a one-line change | |
| // in this function β call sites don't need to know. | |
| export function createStreamingTts() { | |
| if (typeof window === 'undefined') | |
| return new NullStreamTts(); | |
| if (!('speechSynthesis' in window)) | |
| return new NullStreamTts(); | |
| // Probe for the existence of an app-level SpeechService shim; if | |
| // present we could route through it, but for now the direct Web | |
| // Speech path is both simpler and lower-latency. | |
| return new WebSpeechStreamTts(); | |
| } | |
| // Exported for tests + rare callers that want an explicit unplugged | |
| // implementation (e.g. when a user opts out of TTS entirely). | |
| export const streamTtsInternals = { | |
| splitIntoSentences, | |
| WebSpeechStreamTts, | |
| NullStreamTts, | |
| }; | |