← 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
M public/index.htmlM public/voice-chat.jsM server.js
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 →