Initial commit
This commit is contained in:
commit
9cea1c1bba
13 changed files with 848 additions and 0 deletions
28
.env
Normal file
28
.env
Normal 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
27
.env.example
Normal 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
17
Dockerfile
Normal 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
14
Dockerfile.agent
Normal 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
90
README.md
Normal 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
|
||||
BIN
__pycache__/agent.cpython-312.pyc
Normal file
BIN
__pycache__/agent.cpython-312.pyc
Normal file
Binary file not shown.
148
agent.py
Normal file
148
agent.py
Normal 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
30
docker-compose.gpu.yml
Normal 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
106
docker-compose.yml
Normal 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
11
requirements.txt
Normal 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
12
start.sh
Executable 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
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>
|
||||
81
token_server.py
Normal file
81
token_server.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue