284 lines
9.3 KiB
HTML
284 lines
9.3 KiB
HTML
<!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>
|