← 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);
    }
  }
});