Initial commit

This commit is contained in:
Ian Keane 2026-08-31 11:47:29 -04:00
commit 9cea1c1bba
13 changed files with 848 additions and 0 deletions

284
static/index.html Normal file
View file

@ -0,0 +1,284 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Local LiveKit Voice Chat</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: #0d0d0d;
color: #e2e2e2;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 2rem;
}
h1 { font-size: 1.6rem; font-weight: 600; }
.card {
background: #1a1a1a;
border: 1px solid #2e2e2e;
border-radius: 12px;
padding: 1.5rem 2rem;
width: 100%;
max-width: 480px;
display: flex;
flex-direction: column;
gap: 1rem;
}
label { font-size: .85rem; color: #888; margin-bottom: .25rem; display: block; }
select, input {
width: 100%;
padding: .5rem .75rem;
border-radius: 8px;
border: 1px solid #333;
background: #0d0d0d;
color: #e2e2e2;
font-size: .95rem;
}
button {
padding: .65rem 1.2rem;
border-radius: 8px;
border: none;
cursor: pointer;
font-size: .95rem;
font-weight: 600;
transition: opacity .15s;
}
button:disabled { opacity: .4; cursor: not-allowed; }
#connectBtn { background: #3b82f6; color: #fff; }
#connectBtn:hover:not(:disabled) { opacity: .85; }
#disconnectBtn { background: #ef4444; color: #fff; display: none; }
#disconnectBtn:hover:not(:disabled) { opacity: .85; }
#status {
font-size: .85rem;
color: #888;
min-height: 1.2em;
text-align: center;
}
#status.ok { color: #22c55e; }
#status.err { color: #ef4444; }
#transcript {
background: #0d0d0d;
border: 1px solid #2e2e2e;
border-radius: 8px;
padding: 1rem;
min-height: 120px;
max-height: 260px;
overflow-y: auto;
font-size: .88rem;
line-height: 1.6;
white-space: pre-wrap;
}
.msg-user { color: #93c5fd; }
.msg-agent { color: #86efac; }
.vol-bar-wrap {
display: flex;
gap: .3rem;
align-items: flex-end;
height: 28px;
}
.vol-bar {
flex: 1;
background: #3b82f6;
border-radius: 3px;
transition: height .05s;
min-height: 3px;
}
</style>
</head>
<body>
<h1>🎙️ Local AI Voice Chat</h1>
<div class="card">
<div>
<label for="modelSel">Model</label>
<select id="modelSel">
<option value="">Loading models…</option>
</select>
</div>
<div>
<label for="roomName">Room name</label>
<input id="roomName" value="my-room" />
</div>
<div>
<label for="systemPrompt">System prompt <span style="opacity:.5;font-size:.8em">(optional override)</span></label>
<textarea id="systemPrompt" rows="3" style="width:100%;resize:vertical;font-family:inherit;font-size:.9rem;padding:.5rem;background:#1a1a1a;color:#e2e2e2;border:1px solid #333;border-radius:6px" placeholder="Leave blank to use server default"></textarea>
</div>
<div style="display:flex;gap:.75rem">
<button id="connectBtn">Connect</button>
<button id="disconnectBtn">Disconnect</button>
</div>
<div id="status">Idle</div>
</div>
<div class="card">
<label>Volume</label>
<div class="vol-bar-wrap" id="volBars">
<!-- bars injected by JS -->
</div>
<label style="margin-top:.5rem">Transcript</label>
<div id="transcript"><span style="color:#555">Conversation will appear here…</span></div>
</div>
<!-- LiveKit browser SDK (CDN) -->
<script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script>
<script>
const { Room, RoomEvent, Track, TrackKind, RemoteTrack, ConnectionState } = LivekitClient;
const LIVEKIT_URL = "ws://localhost:7880";
// ── Volume bars ────────────────────────────────────────────────────────────
const volBarsEl = document.getElementById("volBars");
const BAR_COUNT = 12;
const bars = Array.from({length: BAR_COUNT}, () => {
const b = document.createElement("div");
b.className = "vol-bar";
b.style.height = "3px";
volBarsEl.appendChild(b);
return b;
});
let animFrame;
function animateBars(analyser) {
const data = new Uint8Array(analyser.frequencyBinCount);
function draw() {
analyser.getByteFrequencyData(data);
const step = Math.floor(data.length / BAR_COUNT);
bars.forEach((b, i) => {
const v = data[i * step] / 255;
b.style.height = Math.max(3, v * 28) + "px";
b.style.opacity = 0.4 + v * 0.6;
});
animFrame = requestAnimationFrame(draw);
}
draw();
}
// ── Load models ────────────────────────────────────────────────────────────
async function loadModels() {
const sel = document.getElementById("modelSel");
try {
const r = await fetch("/models");
const { models, error } = await r.json();
sel.innerHTML = "";
if (!models.length) {
sel.innerHTML = `<option value="">No models found (is Ollama running?)</option>`;
return;
}
models.forEach(m => {
const opt = document.createElement("option");
opt.value = m; opt.textContent = m;
sel.appendChild(opt);
});
} catch (e) {
sel.innerHTML = `<option value="">Error loading models</option>`;
}
}
loadModels();
// ── Main logic ─────────────────────────────────────────────────────────────
let room = null;
let audioCtx = null;
const connectBtn = document.getElementById("connectBtn");
const disconnectBtn = document.getElementById("disconnectBtn");
const statusEl = document.getElementById("status");
const transcriptEl = document.getElementById("transcript");
function setStatus(msg, cls="") {
statusEl.textContent = msg;
statusEl.className = cls;
}
function appendTranscript(role, text) {
if (transcriptEl.querySelector("span")) transcriptEl.innerHTML = "";
const line = document.createElement("div");
line.className = role === "user" ? "msg-user" : "msg-agent";
line.textContent = `[${role === "user" ? "You" : "Agent"}] ${text}`;
transcriptEl.appendChild(line);
transcriptEl.scrollTop = transcriptEl.scrollHeight;
}
connectBtn.addEventListener("click", async () => {
const model = document.getElementById("modelSel").value;
const roomName = document.getElementById("roomName").value.trim() || "my-room";
const prompt = document.getElementById("systemPrompt").value.trim();
if (!model) { setStatus("Select a model first", "err"); return; }
setStatus("Fetching token…");
connectBtn.disabled = true;
try {
let url = `/token?room=${encodeURIComponent(roomName)}&model=${encodeURIComponent(model)}`;
if (prompt) url += `&prompt=${encodeURIComponent(prompt)}`;
const r = await fetch(url);
const { token } = await r.json();
setStatus("Connecting to LiveKit…");
room = new Room({ adaptiveStream: true, dynacast: true });
room.on(RoomEvent.Connected, async () => {
setStatus("Connected ✓ (speak to the agent)", "ok");
disconnectBtn.style.display = "inline-block";
// Publish microphone
await room.localParticipant.setMicrophoneEnabled(true);
const micTrack = room.localParticipant.getTrackPublication(Track.Source.Microphone);
if (micTrack?.track?.mediaStreamTrack) {
audioCtx = new AudioContext();
const source = audioCtx.createMediaStreamSource(
new MediaStream([micTrack.track.mediaStreamTrack])
);
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
animateBars(analyser);
}
});
room.on(RoomEvent.Disconnected, () => {
setStatus("Disconnected", "err");
connectBtn.disabled = false;
disconnectBtn.style.display = "none";
cancelAnimationFrame(animFrame);
bars.forEach(b => b.style.height = "3px");
});
room.on(RoomEvent.TrackSubscribed, (track) => {
if (track.kind === Track.Kind.Audio) {
const el = track.attach();
document.body.appendChild(el);
}
});
room.on(RoomEvent.TrackUnsubscribed, (track) => track.detach());
// Data messages for transcript (agent sends via DataChannel)
room.on(RoomEvent.DataReceived, (data) => {
try {
const msg = JSON.parse(new TextDecoder().decode(data));
if (msg.type === "transcript") {
appendTranscript(msg.role, msg.text);
}
} catch {}
});
await room.connect(LIVEKIT_URL, token);
} catch (e) {
setStatus("Error: " + e.message, "err");
connectBtn.disabled = false;
}
});
disconnectBtn.addEventListener("click", async () => {
if (room) { await room.disconnect(); room = null; }
if (audioCtx) { audioCtx.close(); audioCtx = null; }
});
</script>
</body>
</html>