Initial commit
This commit is contained in:
commit
9cea1c1bba
13 changed files with 848 additions and 0 deletions
284
static/index.html
Normal file
284
static/index.html
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue