// Plays announcements generated by the server's local Piper voice. Any failure - Piper not // installed, generation too slow, network hiccup - falls back to the browser's own voice, // so an announcement is never lost and never holds up timing. const PIPER_TIMEOUT_MS = 2500; let current = null; export function stopPiperSpeech() { if (!current) return; current.cancelled = true; current.controller.abort(); if (current.audio) current.audio.pause(); if (current.url) URL.revokeObjectURL(current.url); current = null; } export function isPiperSpeaking() { return Boolean(current); } // Returns true when the line was accepted (it will play via Piper or the fallback). export function speakWithPiper(text, { language, voice = "", rate, volume = 1, interrupt, onEnd, onError, fallback }) { if (current && !interrupt) return false; stopPiperSpeech(); const job = { cancelled: false, controller: new AbortController(), audio: null, url: "" }; current = job; const finish = (callback) => { if (job.url) URL.revokeObjectURL(job.url); if (current === job) current = null; callback?.(); }; const timer = setTimeout(() => job.controller.abort(), PIPER_TIMEOUT_MS); fetch("/api/tts/speech", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text, language, voice, rate }), signal: job.controller.signal, }) .then((response) => { if (!response.ok) throw new Error(`tts ${response.status}`); return response.blob(); }) .then((blob) => { clearTimeout(timer); if (job.cancelled) return; job.url = URL.createObjectURL(blob); job.audio = new Audio(job.url); job.audio.volume = Math.max(0, Math.min(1, Number(volume))); job.audio.onended = () => finish(onEnd); job.audio.onerror = () => finish(onError); return job.audio.play(); }) .catch(() => { clearTimeout(timer); if (job.cancelled) return; finish(() => { if (!fallback?.()) onError?.(); }); }); return true; }