← back to Voice Chat
public/voice-chat.js
288 lines
// voice-chat client — free stack
// • Browser SpeechRecognition for STT (instant, native)
// • Server proxies streaming chat to Ollama on Mac1
// • Browser SpeechSynthesis for TTS (instant, native system voices)
// No API keys, no cost.
const $mic = document.getElementById("mic");
const $status = document.getElementById("status");
const $transcript = document.getElementById("transcript");
const $latency = document.getElementById("latency");
const $voice = document.getElementById("voicePicker");
const $autoSpeak = document.getElementById("autoSpeak");
const $backend = document.getElementById("backend");
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
let rec = null;
let listening = false;
let interimEl = null;
let history = []; // [{role, content}]
let stopFlag = false;
let pendingSpeechAt = null; // ms — set when user-final committed, used to compute mouth-to-mouth
// ── auto-shutoff ────────────────────────────────────────────
// Mic stops itself after IDLE_TIMEOUT_MS of inactivity.
// Activity (user speech detected) resets the timer.
const IDLE_TIMEOUT_MS = 30_000;
let idleStartedAt = 0;
let countdownTimer = null;
function setStatus(t) { $status.textContent = t; }
// ── idle countdown ───────────────────────────────────────────
function startIdleCountdown() {
idleStartedAt = Date.now();
stopIdleCountdown();
countdownTimer = setInterval(() => {
const remaining = Math.max(0, IDLE_TIMEOUT_MS - (Date.now() - idleStartedAt));
const secs = Math.ceil(remaining / 1000);
renderCountdown(secs);
if (remaining <= 0) {
stopIdleCountdown();
stopFlag = true;
try { rec && rec.stop(); } catch {}
try { speechSynthesis.cancel(); } catch {}
setStatus("auto-stopped after 30s idle — tap START to resume");
}
}, 250);
}
function resetIdleCountdown() {
if (countdownTimer) idleStartedAt = Date.now();
}
function stopIdleCountdown() {
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
renderCountdown(null);
}
function renderCountdown(secs) {
const el = document.getElementById("countdown");
if (!el) return;
if (secs == null) {
el.textContent = "";
el.classList.remove("warn", "crit");
} else {
el.textContent = `auto-stop in ${secs}s`;
el.classList.toggle("warn", secs <= 10 && secs > 5);
el.classList.toggle("crit", secs <= 5);
}
}
function bubble(role, text, opts = {}) {
// Clear empty-placeholder once we have content
const empty = $transcript.querySelector(".empty");
if (empty) empty.remove();
const el = document.createElement("div");
el.className = "bubble " + role + (opts.interim ? " interim" : "");
el.textContent = text;
$transcript.appendChild(el);
$transcript.scrollTop = $transcript.scrollHeight;
return el;
}
// ── voice picker setup ───────────────────────────────────────
function loadVoices() {
if (!("speechSynthesis" in window)) return;
const voices = speechSynthesis.getVoices();
$voice.innerHTML = "";
// Prefer English, "Samantha"-style first
const sorted = voices.sort((a, b) => {
const pa = /samantha|alex|allison|ava|evan/i.test(a.name) ? 0 : 1;
const pb = /samantha|alex|allison|ava|evan/i.test(b.name) ? 0 : 1;
if (pa !== pb) return pa - pb;
return a.name.localeCompare(b.name);
});
for (const v of sorted) {
if (!v.lang.startsWith("en")) continue;
const o = document.createElement("option");
o.value = v.name;
o.textContent = `${v.name} (${v.lang})`;
$voice.appendChild(o);
}
}
if ("speechSynthesis" in window) {
loadVoices();
speechSynthesis.onvoiceschanged = loadVoices;
}
function speak(text) {
if (!$autoSpeak.checked) return;
if (!("speechSynthesis" in window)) return;
const u = new SpeechSynthesisUtterance(text);
const chosenName = $voice.value;
const v = speechSynthesis.getVoices().find(x => x.name === chosenName);
if (v) u.voice = v;
u.rate = 1.05;
u.pitch = 1.0;
speechSynthesis.speak(u);
}
// ── backend liveness ─────────────────────────────────────────
async function checkBackend() {
try {
const r = await fetch("/api/backend");
const d = await r.json();
if (d.ok && d.modelAvailable) {
$backend.textContent = `ollama · ${d.model}`;
$backend.className = "backend up";
} else if (d.ok) {
$backend.textContent = `model "${d.model}" missing`;
$backend.className = "backend down";
} else {
$backend.textContent = `backend down`;
$backend.className = "backend down";
}
} catch (e) {
$backend.textContent = "backend unreachable";
$backend.className = "backend down";
}
}
checkBackend();
setInterval(checkBackend, 30000);
// ── SpeechRecognition setup ──────────────────────────────────
function newRec() {
if (!SR) {
setStatus("this browser doesn't support SpeechRecognition (use Chrome or Safari)");
$mic.disabled = true;
return null;
}
const r = new SR();
r.continuous = true;
r.interimResults = true;
r.lang = "en-US";
r.onstart = () => {
setStatus("listening… speak naturally");
$mic.classList.add("listening");
$mic.textContent = "STOP";
listening = true;
startIdleCountdown();
};
r.onend = () => {
$mic.classList.remove("listening");
$mic.textContent = "START";
listening = false;
stopIdleCountdown();
if (!stopFlag) {
// Auto-restart on accidental end (Chrome cuts off ~60s)
setTimeout(() => { if (!stopFlag) try { r.start(); } catch {} }, 100);
} else {
setStatus("stopped");
}
};
r.onerror = (e) => {
if (e.error === "no-speech" || e.error === "audio-capture") return;
setStatus("recognition error: " + e.error);
};
r.onresult = (e) => {
let interim = "";
let final = "";
for (let i = e.resultIndex; i < e.results.length; i++) {
const piece = e.results[i][0].transcript;
if (e.results[i].isFinal) final += piece;
else interim += piece;
}
if (interim || final) resetIdleCountdown(); // any speech activity defers shutoff
if (interim) {
if (!interimEl) interimEl = bubble("me", "", { interim: true });
interimEl.textContent = interim;
}
if (final) {
if (interimEl) { interimEl.remove(); interimEl = null; }
final = final.trim();
if (!final) return;
bubble("me", final);
history.push({ role: "user", content: final });
pendingSpeechAt = Date.now();
sendToLLM();
}
};
return r;
}
// ── chat call to /api/chat with streaming ────────────────────
async function sendToLLM() {
setStatus("thinking…");
// While AI speaks, the recognition often picks up the synth voice — pause it
// and resume after speech finishes. We use a simple barge-in approach:
// keep recognition on, but only commit user messages when not currently speaking.
let aiEl = bubble("ai", "");
let acc = "";
let firstDeltaAt = null;
let sentencePtr = 0;
const t0 = Date.now();
try {
const r = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: history.slice(-12) }),
});
if (!r.ok || !r.body) {
aiEl.textContent = "(error: " + r.status + ")";
return;
}
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const events = buf.split("\n\n");
buf = events.pop();
for (const ev of events) {
const line = ev.replace(/^data:\s*/, "").trim();
if (!line) continue;
let m;
try { m = JSON.parse(line); } catch { continue; }
if (m.error) { aiEl.textContent += " (err: " + m.error + ")"; continue; }
if (m.delta) {
if (firstDeltaAt === null) firstDeltaAt = Date.now();
acc += m.delta;
aiEl.textContent = acc;
$transcript.scrollTop = $transcript.scrollHeight;
// Sentence-batched TTS — speak each new complete sentence as it arrives
while (true) {
const tail = acc.slice(sentencePtr);
const idx = tail.search(/[.!?]\s|[\n]/);
if (idx === -1) break;
const sentence = tail.slice(0, idx + 1).trim();
sentencePtr += idx + 1;
if (sentence) speak(sentence);
}
}
if (m.done) {
// Flush trailing partial sentence
const tail = acc.slice(sentencePtr).trim();
if (tail) speak(tail);
const turnMs = Date.now() - (pendingSpeechAt || t0);
const ttft = (firstDeltaAt && pendingSpeechAt) ? firstDeltaAt - pendingSpeechAt : null;
$latency.textContent =
`turn ${turnMs} ms · ttft ${ttft ?? "?"} ms · ollama ${m.elapsedMs} ms`;
history.push({ role: "assistant", content: acc });
setStatus("listening… speak naturally");
}
}
}
} catch (e) {
aiEl.textContent = "(network error: " + e.message + ")";
setStatus("error: " + e.message);
}
}
// ── mic button ───────────────────────────────────────────────
$mic.addEventListener("click", () => {
if (!rec) rec = newRec();
if (!rec) return;
if (listening) {
stopFlag = true;
try { rec.stop(); } catch {}
try { speechSynthesis.cancel(); } catch {}
} else {
stopFlag = false;
try { rec.start(); } catch (e) {
if (e.name !== "InvalidStateError") setStatus("can't start: " + e.message);
}
}
});