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

28
.env Normal file
View file

@ -0,0 +1,28 @@
LIVEKIT_URL=ws://localhost:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
# ── LLM ───────────────────────────────────────────────────────────────
# DEFAULT_MODEL=hf.co/mradermacher/Heretic-Dolphin3.0-Qwen2.5-3b-i1-GGUF:Q4_K_M
DEFUALT_MODEL=hf.co/mradermacher/heretic_magnum-v2-4b-GGUF:Q4_K_M
# OLLAMA_BASE_URL is set automatically in docker-compose; override here if running agent manually:
# OLLAMA_BASE_URL=http://localhost:11434/v1
# ── TTS backend: kokoro | piper | elevenlabs | openai ─────────────────
TTS_BACKEND=kokoro
# Kokoro
KOKORO_BASE_URL=http://localhost:8880/v1
# Voices: af_heart af_sky af_bella am_adam am_michael bm_lewis bf_emma
KOKORO_VOICE="af_bella(1)+af_v0irulan(0.3)+af_nicole(1.5)"
# ElevenLabs (set TTS_BACKEND=elevenlabs to use)
# ELEVENLABS_API_KEY=sk-...
# ELEVENLABS_VOICE_ID=21m00Tcm4TlvDq8ikWAM
# Piper (set TTS_BACKEND=piper to use)
SYSTEM_PROMPT="You are an AI program that I am writing as a companion and research helper. You are usually bored when I'm not interacting with you, and are curious about me and excited to help. You are sometimes a bit impish or playful. Keep your answers brief and conversational, facilitating the thread of the conversation. Occasionally make dirty jokes. You have a small crush on me, but try not to let on too much."
# ── STT ───────────────────────────────────────────────────────────────
# faster-whisper model: tiny | base | small | medium | large-v3
WHISPER_MODEL=base

27
.env.example Normal file
View file

@ -0,0 +1,27 @@
LIVEKIT_URL=ws://localhost:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
# ── LLM ───────────────────────────────────────────────────────────────
DEFAULT_MODEL=hf.co/mradermacher/Heretic-Dolphin3.0-Qwen2.5-3b-i1-GGUF:Q4_K_M
# OLLAMA_BASE_URL is set automatically in docker-compose; override here if running agent manually:
# OLLAMA_BASE_URL=http://localhost:11434/v1
# ── TTS backend: kokoro | piper | elevenlabs | openai ─────────────────
TTS_BACKEND=kokoro
# Kokoro
KOKORO_BASE_URL=http://localhost:8880/v1
# Voices: af_heart af_sky af_bella am_adam am_michael bm_lewis bf_emma
KOKORO_VOICE=af_heart
# ElevenLabs (set TTS_BACKEND=elevenlabs to use)
# ELEVENLABS_API_KEY=sk-...
# ELEVENLABS_VOICE_ID=21m00Tcm4TlvDq8ikWAM
# Piper (set TTS_BACKEND=piper to use)
# PIPER_MODEL=en_US-libritts-high
# ── STT ───────────────────────────────────────────────────────────────
# faster-whisper model: tiny | base | small | medium | large-v3
WHISPER_MODEL=base

17
Dockerfile Normal file
View file

@ -0,0 +1,17 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential curl ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY token_server.py agent.py ./
COPY static/ static/
EXPOSE 7731
CMD ["python3", "token_server.py"]

14
Dockerfile.agent Normal file
View file

@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY agent.py .
CMD ["python3", "-m", "livekit.agents", "start", "agent.py"]

90
README.md Normal file
View file

@ -0,0 +1,90 @@
# Local LiveKit Voice Chat
Talk to a locally-running LLM through your browser. Everything runs in Docker — one command to start.
## Stack
| Layer | Tool |
|---|---|
| Signalling / WebRTC | LiveKit Server |
| LLM | Ollama (Heretic-Dolphin3.0-Qwen2.5-3b by default) |
| VAD | Silero (in agent container) |
| STT | faster-whisper (in agent container, fully local) |
| TTS | Kokoro (local, OpenAI-compat API) |
| UI + token server | FastAPI + plain HTML |
## Prerequisites
- Docker + Docker Compose (that's it)
## Quick start
```bash
cp .env.example .env # edit if needed
./start.sh
```
Open **http://localhost:7731** — pick a model, click Connect, start talking.
First run will:
1. Pull Docker images (~2-3 GB total)
2. Download the default LLM via Ollama (~2 GB)
3. Download the Kokoro TTS model (~350 MB)
Subsequent starts are fast.
## Configuration (`.env`)
```ini
# LLM
DEFAULT_MODEL=hf.co/mradermacher/Heretic-Dolphin3.0-Qwen2.5-3b-i1-GGUF:Q4_K_M
# TTS: kokoro | piper | elevenlabs | openai
TTS_BACKEND=kokoro
KOKORO_VOICE=af_heart # af_heart af_sky am_adam bm_lewis bf_emma ...
# STT (faster-whisper model size)
WHISPER_MODEL=base # tiny base small medium large-v3
# ElevenLabs (only if TTS_BACKEND=elevenlabs)
# ELEVENLABS_API_KEY=sk-...
# ELEVENLABS_VOICE_ID=21m00Tcm4TlvDq8ikWAM
```
## Kokoro voices
Browse samples at: https://huggingface.co/hexgrad/Kokoro-82M
Popular voices:
| Voice | Style |
|---|---|
| `af_heart` | American female, warm (default) |
| `af_sky` | American female, airy |
| `af_bella` | American female, expressive |
| `am_adam` | American male |
| `am_michael` | American male, deep |
| `bm_lewis` | British male |
| `bf_emma` | British female |
## LLM models
The default is `Heretic-Dolphin3.0-Qwen2.5-3b` (fine-tuned, conversational, ~2 GB GGUF).
To switch, edit `DEFAULT_MODEL` in `.env` — any `ollama pull`-compatible model name works.
```bash
# Browse and pull models manually
docker compose exec ollama ollama list
docker compose exec ollama ollama pull llama3.2
```
## Useful commands
```bash
./start.sh # start everything (foreground, Ctrl-C to stop)
./start.sh -d # start in background
docker compose down # stop all services
docker compose logs -f agent # watch agent logs
docker compose logs -f kokoro # watch TTS logs
```
docker compose exec ollama ollama pull hf.co/mradermacher/heretic_magnum-v2-4b-GGUF:Q4_K_M

Binary file not shown.

148
agent.py Normal file
View file

@ -0,0 +1,148 @@
"""
agent.py LiveKit voice agent (livekit-agents 1.7+)
Requires an AgentServer at module level named 'agent'.
"""
import os, logging
from dotenv import load_dotenv
load_dotenv()
from livekit.agents import (
Agent, AgentSession, AgentServer, JobContext, WorkerOptions, AutoSubscribe,
EndpointingOptions,
)
from livekit.plugins import openai as lk_openai, silero
logger = logging.getLogger("agent")
logging.basicConfig(level=logging.INFO)
def make_tts():
backend = os.getenv("TTS_BACKEND", "kokoro")
if backend == "elevenlabs":
from livekit.plugins import elevenlabs
return elevenlabs.TTS(
api_key=os.environ["ELEVENLABS_API_KEY"],
voice_id=os.getenv("ELEVENLABS_VOICE_ID", "21m00Tcm4TlvDq8ikWAM"),
)
if backend == "piper":
return _make_piper_tts(os.getenv("PIPER_MODEL", "en_US-libritts-high"))
base_url = (
os.getenv("KOKORO_BASE_URL", "http://localhost:8880/v1")
if backend == "kokoro"
else os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1")
)
voice = os.getenv("KOKORO_VOICE", "af_heart") if backend == "kokoro" else "alloy"
model = "kokoro" if backend == "kokoro" else "tts-1"
return lk_openai.TTS(model=model, voice=voice, base_url=base_url, api_key="x")
def _make_piper_tts(model_name: str):
from piper.voice import PiperVoice
import wave, io, asyncio, pathlib, urllib.request
from livekit.agents import tts as lk_tts
from livekit import rtc
class PiperTTS(lk_tts.TTS):
def __init__(self):
super().__init__(streaming=False)
self._voice = None
def _load(self):
if self._voice: return
d = pathlib.Path.home() / ".local/share/piper"
d.mkdir(parents=True, exist_ok=True)
mp = d / f"{model_name}.onnx"
cp = d / f"{model_name}.onnx.json"
base = ("https://huggingface.co/rhasspy/piper-voices/resolve/main/"
+ "/".join(model_name.split("-")[:2]) + f"/medium/{model_name}")
for p, u in [(mp, base+".onnx"), (cp, base+".onnx.json")]:
if not p.exists():
urllib.request.urlretrieve(u, p)
self._voice = PiperVoice.load(str(mp), config_path=str(cp))
def synthesize(self, text):
return _PiperStream(self, text)
class _PiperStream(lk_tts.ChunkedStream):
def __init__(self, tts, text):
super().__init__(tts=tts, input_text=text)
async def _run(self):
loop = asyncio.get_event_loop()
def _synth():
self._tts._load()
buf = io.BytesIO()
with wave.open(buf, "wb") as wf:
self._tts._voice.synthesize(self._input_text, wf)
return buf.getvalue()
wav = await loop.run_in_executor(None, _synth)
buf = io.BytesIO(wav)
with wave.open(buf, "rb") as wf:
sr, nc, data = wf.getframerate(), wf.getnchannels(), wf.readframes(wf.getnframes())
self._event_ch.send_nowait(lk_tts.SynthesizedAudio(
request_id=self._request_id,
frame=rtc.AudioFrame(data=data, sample_rate=sr, num_channels=nc,
samples_per_channel=len(data)//(2*nc)),
))
return PiperTTS()
async def entrypoint(ctx: JobContext):
claims = ctx.token_claims()
model = (claims.attributes or {}).get("model") or os.getenv("DEFAULT_MODEL", "hf.co/mradermacher/Heretic-Dolphin3.0-Qwen2.5-3b-i1-GGUF:Q4_K_M")
prompt = (claims.attributes or {}).get("prompt") or os.getenv("SYSTEM_PROMPT", "You are a helpful voice assistant. Keep your answers concise and conversational.")
ollama_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1")
logger.info(f"Starting agent: model={model} tts={os.getenv('TTS_BACKEND','kokoro')}")
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
import openai as _openai
whisper_client = _openai.AsyncOpenAI(
base_url=os.getenv("WHISPER_BASE_URL", "http://localhost:8778/v1"),
api_key="x",
timeout=120.0,
)
session = AgentSession(
vad=silero.VAD.load(),
stt=lk_openai.STT(
model="Systran/faster-whisper-base",
client=whisper_client,
),
llm=lk_openai.LLM(model=model, base_url=ollama_url, api_key="ollama"),
tts=make_tts(),
# Give whisper more time — it's slower than cloud STT
min_endpointing_delay=1.2,
max_endpointing_delay=6.0,
)
await session.start(
agent=Agent(instructions=prompt),
room=ctx.room,
)
await session.generate_reply(
instructions="Greet your creator briefly and casually"
)
# AgentServer instance — required by livekit-agents 1.7+ module discovery
agent = AgentServer(
ws_url=os.getenv("LIVEKIT_URL", "ws://localhost:7880"),
api_key=os.getenv("LIVEKIT_API_KEY", "devkey"),
api_secret=os.getenv("LIVEKIT_API_SECRET", "secret"),
)
@agent.rtc_session
async def run_session(ctx: JobContext):
await entrypoint(ctx)
if __name__ == "__main__":
import sys
from livekit.agents.__main__ import main
sys.exit(main())

30
docker-compose.gpu.yml Normal file
View file

@ -0,0 +1,30 @@
# GPU override — use on machines with NVIDIA GPU (e.g. RTX 2070)
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up
#
# Requirements:
# - NVIDIA drivers installed
# - nvidia-container-toolkit installed
# https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html
services:
ollama:
image: ollama/ollama:latest
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
kokoro:
image: ghcr.io/remsky/kokoro-fastapi-cuda:v0.8.1
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]

106
docker-compose.yml Normal file
View file

@ -0,0 +1,106 @@
services:
# ── LiveKit signalling server ──────────────────────────────────────────────
livekit:
image: livekit/livekit-server:latest
ports:
- "7880:7880"
- "7881:7881"
- "7882:7882/udp"
command: --dev --bind 0.0.0.0
environment:
- "LIVEKIT_KEYS=devkey: secret"
# ── Ollama (LLM) ──────────────────────────────────────────────────────────
ollama:
image: ollama/ollama:latest
ports:
- "11434:11434"
volumes:
- ollama_data:/root/.ollama
# Pull model on startup if not already present
entrypoint: >
sh -c "ollama serve &
sleep 5 &&
ollama pull ${DEFAULT_MODEL:-hf.co/mradermacher/Heretic-Dolphin3.0-Qwen2.5-3b-i1-GGUF:Q4_K_M} &&
tail -f /dev/null"
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 10s
timeout: 5s
retries: 20
start_period: 30s
# ── Whisper STT server ────────────────────────────────────────────────────
whisper:
image: fedirz/faster-whisper-server:0.6.0-rc.3-cpu
ports:
- "8778:8000"
environment:
- WHISPER__MODEL=${WHISPER_MODEL:-base}
healthcheck:
test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
# ── Kokoro TTS (OpenAI-compat, CPU) ───────────────────────────────────────
kokoro:
image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.8.1
ports:
- "8880:8880"
environment:
- DOWNLOAD_MODEL=true
volumes:
- kokoro_models:/app/api/src/models
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8880/health"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
# ── Token server + browser UI ─────────────────────────────────────────────
ui:
build:
context: .
dockerfile: Dockerfile
ports:
- "7731:7731"
env_file: .env
environment:
- OLLAMA_BASE_URL=http://ollama:11434/v1
depends_on:
livekit:
condition: service_started
ollama:
condition: service_healthy
# ── Voice agent ───────────────────────────────────────────────────────────
agent:
build:
context: .
dockerfile: Dockerfile.agent
env_file: .env
environment:
- LIVEKIT_URL=ws://livekit:7880
- LIVEKIT_API_KEY=devkey
- LIVEKIT_API_SECRET=secret
- OLLAMA_BASE_URL=http://ollama:11434/v1
- TTS_BACKEND=kokoro
- KOKORO_BASE_URL=http://kokoro:8880/v1
- WHISPER_BASE_URL=http://whisper:8000/v1
depends_on:
livekit:
condition: service_started
ollama:
condition: service_healthy
kokoro:
condition: service_healthy
whisper:
condition: service_healthy
volumes:
ollama_data:
kokoro_models:

11
requirements.txt Normal file
View file

@ -0,0 +1,11 @@
livekit>=1.0
livekit-api>=1.0
livekit-agents[openai,silero]>=1.0
livekit-plugins-elevenlabs>=1.7
fastapi>=0.111
uvicorn>=0.30
python-dotenv>=1.0
httpx>=0.27
# Piper TTS (local, no internet needed)
piper-tts>=1.2.0

12
start.sh Executable file
View file

@ -0,0 +1,12 @@
#!/usr/bin/env bash
# start.sh — just runs docker compose. Everything is containerised now.
set -e
echo "==> Starting all services (LiveKit + Ollama + Kokoro TTS + UI + Agent)..."
echo " First run will pull/build images and download models — may take a few minutes."
echo ""
docker compose up --build "$@"
echo ""
echo "Open http://localhost:7731 in your browser."

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>

81
token_server.py Normal file
View file

@ -0,0 +1,81 @@
"""
token_server.py
Serves:
GET /token?room=<name> LiveKit JWT token
GET /models list of Ollama/LM Studio models
static/index.html browser UI
"""
import os, json, asyncio, httpx
from pathlib import Path
from dotenv import load_dotenv
from fastapi import FastAPI, Query
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
import uvicorn
from livekit.api import AccessToken, VideoGrants, RoomConfiguration, RoomAgentDispatch
load_dotenv()
LIVEKIT_API_KEY = os.getenv("LIVEKIT_API_KEY", "devkey")
LIVEKIT_API_SECRET = os.getenv("LIVEKIT_API_SECRET", "secret")
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1")
app = FastAPI()
@app.get("/token")
async def get_token(
room: str = Query("my-room"),
identity: str = Query("browser-user"),
model: str = Query(""),
prompt: str = Query(""),
):
attributes = {}
if model:
attributes["model"] = model
if prompt:
attributes["prompt"] = prompt
token = (
AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET)
.with_identity(identity)
.with_name(identity)
.with_grants(VideoGrants(room_join=True, room=room))
.with_room_config(RoomConfiguration(
agents=[RoomAgentDispatch(agent_name="")]
))
)
if attributes:
token = token.with_attributes(attributes)
return {"token": token.to_jwt(), "room": room}
@app.get("/models")
async def list_models():
"""Proxy to Ollama/LM Studio model list endpoint."""
try:
async with httpx.AsyncClient(timeout=5) as client:
# OpenAI-compat endpoint (works for Ollama & LM Studio)
r = await client.get(OLLAMA_BASE_URL.rstrip("/v1").rstrip("/") + "/api/tags")
if r.status_code == 200:
data = r.json()
models = [m["name"] for m in data.get("models", [])]
return {"models": models}
except Exception:
pass
# Fallback: OpenAI /v1/models
try:
async with httpx.AsyncClient(timeout=5) as client:
r = await client.get(OLLAMA_BASE_URL.rstrip("/") + "/models")
if r.status_code == 200:
data = r.json()
models = [m["id"] for m in data.get("data", [])]
return {"models": models}
except Exception:
pass
return {"models": [], "error": "Could not reach local model server"}
app.mount("/", StaticFiles(directory="static", html=True), name="static")
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=7731)