[object Object]

← back to Voice Chat

pivot to free stack: browser STT+TTS + Mac1 ollama qwen3:8b

6475eea36c330bfc6b932341a87638a964bd4ef1 · 2026-05-10 23:07:14 -0700 · Steve Abrams

Files touched

Diff

commit 6475eea36c330bfc6b932341a87638a964bd4ef1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 10 23:07:14 2026 -0700

    pivot to free stack: browser STT+TTS + Mac1 ollama qwen3:8b
---
 public/index.html    | 224 ++++++++++++++++++++++++-------
 public/voice-chat.js | 365 +++++++++++++++++++++++++++++++--------------------
 server.js            | 192 ++++++++++++++++-----------
 3 files changed, 506 insertions(+), 275 deletions(-)

diff --git a/public/index.html b/public/index.html
index d3f6573..13af434 100644
--- a/public/index.html
+++ b/public/index.html
@@ -3,15 +3,17 @@
 <head>
   <meta charset="utf-8">
   <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
-  <title>voice-chat</title>
+  <title>voice-chat · SA</title>
   <style>
     :root {
-      --bg: #0a0a0c;
-      --fg: #f4f4f5;
-      --muted: #9ca3af;
-      --accent: #ef4444;
-      --green: #22c55e;
-      --border: #1f2937;
+      --bg:        #0a0a0c;
+      --panel:     #11131a;
+      --fg:        #f4f4f5;
+      --muted:     #9ca3af;
+      --accent:    #ef4444;
+      --green:     #22c55e;
+      --gold:      #C9A14B;
+      --border:    #1f2937;
     }
     *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
     html, body {
@@ -21,75 +23,195 @@
       height: 100vh;
       overflow: hidden;
     }
+    /* layout — three rows */
     body {
+      display: grid;
+      grid-template-rows: auto 1fr auto;
+      gap: 16px;
+      padding: 20px;
+      max-width: 920px;
+      margin: 0 auto;
+    }
+
+    /* ── top bar ─────────────────────────────────────────────── */
+    .topbar {
       display: flex;
-      flex-direction: column;
       align-items: center;
-      justify-content: center;
-      gap: 32px;
+      gap: 14px;
+      padding: 12px 16px;
+      background: var(--panel);
+      border: 1px solid var(--border);
+      border-radius: 14px;
+    }
+    .avatar {
+      width: 44px; height: 44px;
+      border-radius: 50%;
+      background: linear-gradient(135deg, var(--gold), #8b6914);
+      display: flex; align-items: center; justify-content: center;
+      font-weight: 700; font-size: 18px; color: #0a0a0c;
+      flex-shrink: 0;
+      box-shadow: 0 0 0 2px rgba(201, 161, 75, 0.2);
+    }
+    .who { flex: 1; }
+    .who .name { font-size: 14px; font-weight: 600; }
+    .who .sub  { font-size: 11px; color: var(--muted); letter-spacing: 0.4px; text-transform: uppercase; }
+    .backend {
+      font-size: 11px;
+      color: var(--muted);
+      padding: 4px 10px;
+      border: 1px solid var(--border);
+      border-radius: 999px;
+      font-variant-numeric: tabular-nums;
+    }
+    .backend.up   { color: var(--green); border-color: rgba(34, 197, 94, 0.35); }
+    .backend.down { color: var(--accent); border-color: rgba(239, 68, 68, 0.35); }
+
+    /* ── transcript ─────────────────────────────────────────── */
+    .transcript {
+      overflow-y: auto;
+      padding: 12px 16px;
+      background: var(--panel);
+      border: 1px solid var(--border);
+      border-radius: 14px;
+      display: flex;
+      flex-direction: column;
+      gap: 10px;
+    }
+    .bubble {
+      max-width: 80%;
+      padding: 10px 14px;
+      border-radius: 14px;
+      font-size: 14px;
+      line-height: 1.5;
+      word-wrap: break-word;
+    }
+    .bubble.me {
+      align-self: flex-end;
+      background: var(--gold);
+      color: #0a0a0c;
+      border-bottom-right-radius: 4px;
+    }
+    .bubble.ai {
+      align-self: flex-start;
+      background: #1f2937;
+      color: var(--fg);
+      border-bottom-left-radius: 4px;
+    }
+    .bubble.interim {
+      opacity: 0.5;
+      font-style: italic;
+    }
+    .empty {
+      color: var(--muted);
+      font-size: 13px;
+      text-align: center;
       padding: 24px;
+      font-style: italic;
+    }
+
+    /* ── controls ───────────────────────────────────────────── */
+    .controls {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      gap: 14px;
+      padding: 12px;
+      background: var(--panel);
+      border: 1px solid var(--border);
+      border-radius: 14px;
     }
     #mic {
-      width: 220px;
-      height: 220px;
+      width: 84px; height: 84px;
       border-radius: 50%;
       background: var(--accent);
       border: none;
       color: white;
-      font-size: 18px;
-      font-weight: 600;
-      letter-spacing: 0.5px;
+      font-size: 12px;
+      font-weight: 700;
+      letter-spacing: 1px;
       cursor: pointer;
-      box-shadow: 0 12px 48px rgba(239, 68, 68, 0.4);
-      transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.2s;
-      user-select: none;
+      box-shadow: 0 8px 24px rgba(239, 68, 68, 0.4);
+      transition: transform 0.15s ease, background 0.2s;
+      flex-shrink: 0;
     }
-    #mic:hover { transform: scale(1.03); }
-    #mic:active { transform: scale(0.97); }
-    #mic.connected {
+    #mic:hover  { transform: scale(1.04); }
+    #mic:active { transform: scale(0.96); }
+    #mic.listening {
       background: var(--green);
-      box-shadow: 0 12px 48px rgba(34, 197, 94, 0.35);
+      box-shadow: 0 8px 24px rgba(34, 197, 94, 0.35);
       animation: pulse 1.8s ease-in-out infinite;
     }
-    #mic.error { background: #6b7280; }
     @keyframes pulse {
-      0%, 100% { box-shadow: 0 12px 48px rgba(34, 197, 94, 0.35); }
-      50%      { box-shadow: 0 12px 64px rgba(34, 197, 94, 0.7); }
+      0%, 100% { box-shadow: 0 8px 24px rgba(34, 197, 94, 0.35); }
+      50%      { box-shadow: 0 8px 32px rgba(34, 197, 94, 0.7); }
     }
-    #status {
-      color: var(--muted);
-      font-size: 14px;
-      letter-spacing: 0.5px;
-      text-align: center;
-      min-height: 18px;
+    .meta {
+      flex: 1;
+      display: flex;
+      flex-direction: column;
+      gap: 4px;
     }
-    #log {
-      width: min(720px, 95vw);
-      max-height: 240px;
-      overflow-y: auto;
-      border: 1px solid var(--border);
-      border-radius: 12px;
-      padding: 16px;
+    .status {
+      color: var(--muted);
       font-size: 13px;
-      line-height: 1.55;
-      background: rgba(255,255,255,0.02);
-    }
-    #log .me   { color: #facc15; }
-    #log .ai   { color: #e0e7ff; }
-    #log .sys  { color: var(--muted); font-style: italic; }
-    #log .row  { margin-bottom: 4px; }
-    #latency {
+      min-height: 16px;
+    }
+    .latency {
       color: var(--muted);
-      font-size: 12px;
+      font-size: 11px;
       font-variant-numeric: tabular-nums;
+      letter-spacing: 0.4px;
     }
+    .options {
+      display: flex;
+      gap: 8px;
+      flex-wrap: wrap;
+    }
+    select {
+      background: #1f2937;
+      color: var(--fg);
+      border: 1px solid var(--border);
+      border-radius: 8px;
+      padding: 6px 10px;
+      font-size: 12px;
+      font-family: inherit;
+    }
+    .toggle {
+      font-size: 11px;
+      color: var(--muted);
+      display: flex;
+      align-items: center;
+      gap: 4px;
+      cursor: pointer;
+    }
+    .toggle input { accent-color: var(--gold); }
   </style>
 </head>
 <body>
-  <button id="mic">START</button>
-  <div id="status">tap to begin</div>
-  <div id="log"></div>
-  <div id="latency"></div>
+  <header class="topbar">
+    <div class="avatar">SA</div>
+    <div class="who">
+      <div class="name">Steve · voice-chat</div>
+      <div class="sub">free stack · browser STT + ollama + browser TTS</div>
+    </div>
+    <div id="backend" class="backend">checking…</div>
+  </header>
+
+  <div id="transcript" class="transcript">
+    <div class="empty">tap the mic and start talking</div>
+  </div>
+
+  <footer class="controls">
+    <button id="mic">START</button>
+    <div class="meta">
+      <div id="status" class="status">idle</div>
+      <div id="latency" class="latency"></div>
+      <div class="options">
+        <select id="voicePicker" title="system voice"></select>
+        <label class="toggle"><input id="autoSpeak" type="checkbox" checked>read aloud</label>
+      </div>
+    </div>
+  </footer>
 
   <script type="module" src="voice-chat.js"></script>
 </body>
diff --git a/public/voice-chat.js b/public/voice-chat.js
index f961fea..2e68635 100644
--- a/public/voice-chat.js
+++ b/public/voice-chat.js
@@ -1,163 +1,240 @@
-// voice-chat client — opens a WebRTC peer connection directly to OpenAI
-// using an ephemeral key minted by our server. No audio passes through us.
-
-const $mic     = document.getElementById("mic");
-const $status  = document.getElementById("status");
-const $log     = document.getElementById("log");
-const $latency = document.getElementById("latency");
-
-let pc = null;       // RTCPeerConnection
-let dc = null;       // RTCDataChannel for control messages
-let audioEl = null;  // remote audio playback
-let micStream = null;
-let lastUserSpokeAt = 0;
-
-function log(role, text) {
-  const row = document.createElement("div");
-  row.className = "row " + role;
-  const prefix = role === "me" ? "you · " : role === "ai" ? "ai · " : "· ";
-  row.textContent = prefix + text;
-  $log.appendChild(row);
-  $log.scrollTop = $log.scrollHeight;
+// 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
+
+function setStatus(t) { $status.textContent = t; }
+
+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;
 }
 
-function setStatus(msg) { $status.textContent = msg; }
+// ── 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;
+}
 
-async function start() {
-  $mic.disabled = true;
-  setStatus("requesting mic…");
+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 {
-    micStream = await navigator.mediaDevices.getUserMedia({
-      audio: {
-        echoCancellation: true,
-        noiseSuppression: true,
-        autoGainControl: true,
-        channelCount: 1,
-        sampleRate: 24000,
-      },
-    });
+    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) {
-    setStatus("mic denied — check browser permissions");
-    $mic.classList.add("error");
-    $mic.textContent = "MIC DENIED";
-    $mic.disabled = false;
-    return;
+    $backend.textContent = "backend unreachable";
+    $backend.className = "backend down";
   }
-
-  setStatus("minting session…");
-  let session;
-  try {
-    const r = await fetch("/api/session", { method: "POST" });
-    if (!r.ok) throw new Error("session " + r.status);
-    session = await r.json();
-  } catch (e) {
-    setStatus("session error: " + e.message);
-    $mic.classList.add("error");
-    $mic.disabled = false;
-    return;
+}
+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 EPHEMERAL_KEY = session.client_secret.value;
-  const MODEL = session.model;
-
-  setStatus("connecting to openai…");
-
-  // RTCPeerConnection
-  pc = new RTCPeerConnection();
-
-  // Remote audio: AI voice
-  audioEl = document.createElement("audio");
-  audioEl.autoplay = true;
-  pc.ontrack = (e) => {
-    audioEl.srcObject = e.streams[0];
+  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;
   };
-
-  // Local mic → peer
-  for (const track of micStream.getTracks()) {
-    pc.addTrack(track, micStream);
-  }
-
-  // Data channel for control + transcripts
-  dc = pc.createDataChannel("oai-events");
-  dc.onopen = () => {
-    setStatus("connected — speak naturally");
-    $mic.classList.add("connected");
-    $mic.textContent = "LISTENING";
-    log("sys", "session open");
+  r.onend = () => {
+    $mic.classList.remove("listening");
+    $mic.textContent = "START";
+    listening = false;
+    if (!stopFlag) {
+      // Auto-restart on accidental end (Chrome cuts off ~60s)
+      setTimeout(() => { if (!stopFlag) try { r.start(); } catch {} }, 100);
+    } else {
+      setStatus("stopped");
+    }
   };
-  dc.onmessage = (e) => {
-    let m;
-    try { m = JSON.parse(e.data); } catch { return; }
-    switch (m.type) {
-      case "response.audio_transcript.done":
-        log("ai", m.transcript);
-        if (lastUserSpokeAt) {
-          $latency.textContent = `last turn: ${Date.now() - lastUserSpokeAt} ms (mouth-to-mouth)`;
-        }
-        break;
-      case "conversation.item.input_audio_transcription.completed":
-        log("me", m.transcript);
-        lastUserSpokeAt = Date.now();
-        break;
-      case "input_audio_buffer.speech_started":
-        setStatus("you're speaking…");
-        break;
-      case "input_audio_buffer.speech_stopped":
-        setStatus("thinking…");
-        lastUserSpokeAt = Date.now();
-        break;
-      case "response.done":
-        setStatus("connected — speak naturally");
-        break;
-      case "error":
-        log("sys", "ERROR: " + (m.error?.message || JSON.stringify(m.error)));
-        break;
+  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) {
+      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();
     }
   };
-
-  // SDP offer/answer
-  const offer = await pc.createOffer();
-  await pc.setLocalDescription(offer);
-
-  const sdpResp = await fetch(`https://api.openai.com/v1/realtime?model=${MODEL}`, {
-    method: "POST",
-    body: offer.sdp,
-    headers: {
-      Authorization: `Bearer ${EPHEMERAL_KEY}`,
-      "Content-Type": "application/sdp",
-    },
-  });
-  if (!sdpResp.ok) {
-    const err = await sdpResp.text();
-    setStatus("openai webrtc error: " + sdpResp.status);
-    log("sys", err.slice(0, 300));
-    return;
-  }
-  const answer = { type: "answer", sdp: await sdpResp.text() };
-  await pc.setRemoteDescription(answer);
-
-  $mic.disabled = false;
-  $mic.textContent = "STOP";
+  return r;
 }
 
-async function stop() {
-  setStatus("disconnecting…");
-  try { dc && dc.close(); } catch {}
-  try { pc && pc.close(); } catch {}
-  if (micStream) micStream.getTracks().forEach(t => t.stop());
-  pc = null; dc = null;
-  $mic.classList.remove("connected", "error");
-  $mic.textContent = "START";
-  setStatus("disconnected");
+// ── 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);
+  }
 }
 
-let running = false;
-$mic.addEventListener("click", async () => {
-  if (running) {
-    running = false;
-    await stop();
+// ── 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 {
-    running = true;
-    await start();
+    stopFlag = false;
+    try { rec.start(); } catch (e) {
+      if (e.name !== "InvalidStateError") setStatus("can't start: " + e.message);
+    }
   }
 });
diff --git a/server.js b/server.js
index c966c9c..79379e2 100644
--- a/server.js
+++ b/server.js
@@ -1,30 +1,23 @@
 #!/usr/bin/env node
 /**
- * voice-chat — minimal voice-native chat server.
+ * voice-chat — fully free local stack.
  *
- * Architecture:
- *   Browser ──(WebRTC + ephemeral key)──> OpenAI Realtime API
- *      │
- *      │ (1) GET /          → serves public/index.html
- *      │ (2) POST /api/session → server mints ephemeral session via OpenAI,
- *      │                        returns client_secret to browser. Browser
- *      │                        uses that token to open a direct WebRTC
- *      │                        connection to OpenAI — no audio passes
- *      │                        through this server. Latency ≈ 300-500ms.
- *      └─→ Express
+ *   Browser SpeechRecognition (free)
+ *     → POST /api/chat  (NDJSON streaming)
+ *       → Ollama on Mac1 (192.168.1.133:11434, free)
+ *     ← NDJSON tokens streamed back
+ *   ← Browser SpeechSynthesis (free, native system voices)
  *
- * Why ephemeral keys: the long-lived OPENAI_API_KEY never leaves this server.
- * Each /api/session call mints a short-lived token (~1 min TTL) the browser
- * can use for one direct WebRTC connection. OpenAI's recommended pattern
- * for browser clients.
+ * No API keys leave this box. Audio never touches this server (browser handles
+ * both STT and TTS natively). We only proxy the LLM call so the browser doesn't
+ * have to talk cross-origin to Mac1.
  *
- * Env vars (in .env):
- *   OPENAI_API_KEY        — required, must have Realtime API access
- *   VOICE                 — optional, default "alloy" (alloy|echo|fable|onyx|nova|shimmer|ash|ballad|coral|sage|verse)
- *   MODEL                 — optional, default "gpt-4o-realtime-preview-2024-12-17"
- *   PORT                  — optional, default 9870
- *   SYSTEM_INSTRUCTIONS   — optional, default short Steve-flavored prompt
- *   BASIC_AUTH            — optional, "user:pw" for nginx-style protection
+ * Env (in .env or shell):
+ *   OLLAMA_URL    — default http://192.168.1.133:11434  (Mac1; flip to localhost to use Mac2 GPU)
+ *   MODEL         — default "qwen3:8b"
+ *   PORT          — default 9893
+ *   SYSTEM_PROMPT — default short Steve-flavored prompt
+ *   BASIC_AUTH    — optional "user:pw"
  */
 
 import express from "express";
@@ -35,23 +28,19 @@ import { fileURLToPath } from "node:url";
 
 const __dirname = dirname(fileURLToPath(import.meta.url));
 
-// ─── env loading (read .env file if present, no dotenv dep) ──────────────
 async function loadEnvFile() {
   for (const path of [
     join(__dirname, ".env"),
-    join(homedir(), "Projects/secrets-manager/.env"),
   ]) {
     try {
       const txt = await fs.readFile(path, "utf-8");
       for (const line of txt.split("\n")) {
         const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
         if (!m) continue;
-        const k = m[1];
-        if (process.env[k]) continue;          // existing env wins
+        if (process.env[m[1]]) continue;
         let v = m[2];
         if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
-        if (v.startsWith("'") && v.endsWith("'")) v = v.slice(1, -1);
-        process.env[k] = v;
+        process.env[m[1]] = v;
       }
     } catch {}
   }
@@ -59,31 +48,21 @@ async function loadEnvFile() {
 await loadEnvFile();
 
 const PORT = Number(process.env.PORT || 9893);
-const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
-const MODEL = process.env.MODEL || "gpt-4o-realtime-preview-2024-12-17";
-const VOICE = process.env.VOICE || "alloy";
+const OLLAMA_URL = (process.env.OLLAMA_URL || "http://192.168.1.133:11434").replace(/\/$/, "");
+const MODEL = process.env.MODEL || "qwen3:8b";
 const BASIC_AUTH = process.env.BASIC_AUTH || "";
-const SYSTEM_INSTRUCTIONS =
-  process.env.SYSTEM_INSTRUCTIONS ||
-  "You are a brisk, candid assistant for Steve. Keep replies short — one or two sentences unless asked to elaborate. Skip filler ('great question', 'sure'). If unsure, say so. Talk like a colleague, not a brochure.";
-
-if (!OPENAI_API_KEY) {
-  console.error("voice-chat: OPENAI_API_KEY missing — set it in ~/Projects/voice-chat/.env or via /secrets.");
-  process.exit(1);
-}
-
-// ─── express ─────────────────────────────────────────────────────────────
+const SYSTEM_PROMPT =
+  process.env.SYSTEM_PROMPT ||
+  "You are a brisk, candid voice assistant for Steve. Reply in 1-2 short sentences — this is a spoken conversation, not a written essay. Skip filler ('great question', 'sure'). No bullet lists, no markdown. If unsure, say so. Talk like a colleague.";
 
 const app = express();
-app.use(express.json());
+app.use(express.json({ limit: "1mb" }));
 
-// Optional basic-auth gate
 if (BASIC_AUTH) {
   const expected = "Basic " + Buffer.from(BASIC_AUTH).toString("base64");
   app.use((req, res, next) => {
     if (req.path === "/api/health") return next();
-    const got = req.headers.authorization || "";
-    if (got !== expected) {
+    if ((req.headers.authorization || "") !== expected) {
       res.set("WWW-Authenticate", 'Basic realm="voice-chat"');
       return res.status(401).send("auth required");
     }
@@ -91,55 +70,108 @@ if (BASIC_AUTH) {
   });
 }
 
-// Health
 app.get("/api/health", (req, res) => {
-  res.json({ ok: true, model: MODEL, voice: VOICE });
+  res.json({ ok: true, model: MODEL, backend: OLLAMA_URL });
 });
 
-// Mint ephemeral session
-app.post("/api/session", async (req, res) => {
+// Quick Ollama liveness check (used by the UI to show backend status)
+app.get("/api/backend", async (req, res) => {
   try {
-    const body = {
-      model: MODEL,
-      voice: VOICE,
-      instructions: SYSTEM_INSTRUCTIONS,
-      modalities: ["audio", "text"],
-      turn_detection: {
-        type: "server_vad",
-        threshold: 0.5,
-        silence_duration_ms: 500,
-      },
-    };
-    const r = await fetch("https://api.openai.com/v1/realtime/sessions", {
+    const r = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(3000) });
+    const d = await r.json();
+    const models = (d.models || []).map(m => m.name);
+    res.json({ ok: true, url: OLLAMA_URL, model: MODEL, modelAvailable: models.includes(MODEL), modelsCount: models.length });
+  } catch (e) {
+    res.status(503).json({ ok: false, url: OLLAMA_URL, error: e.message });
+  }
+});
+
+/**
+ * POST /api/chat
+ * body: { messages: [{role:"user"|"assistant"|"system", content:"..."}] }
+ * response: text/event-stream — each event is a JSON line:
+ *   {"delta":"some token"}      while streaming
+ *   {"done":true,"elapsedMs":...}  at end
+ */
+app.post("/api/chat", async (req, res) => {
+  const { messages = [] } = req.body || {};
+  if (!Array.isArray(messages) || messages.length === 0) {
+    return res.status(400).json({ error: "messages[] required" });
+  }
+  const full = [{ role: "system", content: SYSTEM_PROMPT }, ...messages];
+  const t0 = Date.now();
+
+  res.setHeader("Content-Type", "text/event-stream");
+  res.setHeader("Cache-Control", "no-cache, no-transform");
+  res.setHeader("Connection", "keep-alive");
+  res.flushHeaders?.();
+
+  let ollamaRes;
+  try {
+    ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
       method: "POST",
-      headers: {
-        Authorization: `Bearer ${OPENAI_API_KEY}`,
-        "Content-Type": "application/json",
-        "OpenAI-Beta": "realtime=v1",
-      },
-      body: JSON.stringify(body),
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        model: MODEL,
+        stream: true,
+        messages: full,
+        options: {
+          temperature: 0.7,
+          num_predict: 200,         // keep replies short for conversation
+        },
+      }),
     });
-    const data = await r.json();
-    if (!r.ok) {
-      console.error("OpenAI session mint failed:", r.status, data);
-      return res.status(r.status).json({ error: data });
+  } catch (e) {
+    res.write(`data: ${JSON.stringify({ error: "ollama unreachable: " + e.message })}\n\n`);
+    return res.end();
+  }
+  if (!ollamaRes.ok) {
+    const t = await ollamaRes.text();
+    res.write(`data: ${JSON.stringify({ error: `ollama ${ollamaRes.status}: ${t.slice(0,200)}` })}\n\n`);
+    return res.end();
+  }
+
+  // Ollama streams NDJSON. Convert to SSE.
+  const reader = ollamaRes.body.getReader();
+  const decoder = new TextDecoder();
+  let buf = "";
+  let firstTokenAt = null;
+
+  try {
+    while (true) {
+      const { value, done } = await reader.read();
+      if (done) break;
+      buf += decoder.decode(value, { stream: true });
+      const lines = buf.split("\n");
+      buf = lines.pop();
+      for (const line of lines) {
+        if (!line.trim()) continue;
+        let obj;
+        try { obj = JSON.parse(line); } catch { continue; }
+        const piece = obj.message?.content;
+        if (piece) {
+          if (firstTokenAt === null) firstTokenAt = Date.now();
+          res.write(`data: ${JSON.stringify({ delta: piece })}\n\n`);
+        }
+        if (obj.done) {
+          res.write(`data: ${JSON.stringify({
+            done: true,
+            elapsedMs: Date.now() - t0,
+            ttftMs: firstTokenAt ? firstTokenAt - t0 : null,
+          })}\n\n`);
+        }
+      }
     }
-    // Return only what the browser needs.
-    res.json({
-      client_secret: data.client_secret,
-      model: MODEL,
-      voice: VOICE,
-    });
   } catch (e) {
-    console.error("session error:", e);
-    res.status(500).json({ error: e.message });
+    res.write(`data: ${JSON.stringify({ error: e.message })}\n\n`);
   }
+  res.end();
 });
 
 app.use(express.static(join(__dirname, "public")));
 
 app.listen(PORT, () => {
   console.log(`voice-chat ready on http://localhost:${PORT}`);
-  console.log(`  model=${MODEL} voice=${VOICE}`);
+  console.log(`  backend=${OLLAMA_URL} model=${MODEL}`);
   if (BASIC_AUTH) console.log("  basic-auth ON");
 });

← 182cab1 port: 9870 collided with Shop by Color → 9893  ·  back to Voice Chat  ·  auto-shutoff: 30s idle countdown with visible timer + resets 326417d →