commit 9cea1c1bba1d910319bde9d4363e129bb5f26a02 Author: Ian Keane Date: Mon Aug 31 11:47:29 2026 -0400 Initial commit diff --git a/.env b/.env new file mode 100644 index 0000000..ff49208 --- /dev/null +++ b/.env @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..384d8f4 --- /dev/null +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9660b3a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Dockerfile.agent b/Dockerfile.agent new file mode 100644 index 0000000..b5662e1 --- /dev/null +++ b/Dockerfile.agent @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..1739b13 --- /dev/null +++ b/README.md @@ -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 diff --git a/__pycache__/agent.cpython-312.pyc b/__pycache__/agent.cpython-312.pyc new file mode 100644 index 0000000..d22da1e Binary files /dev/null and b/__pycache__/agent.cpython-312.pyc differ diff --git a/agent.py b/agent.py new file mode 100644 index 0000000..a10d9ce --- /dev/null +++ b/agent.py @@ -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()) diff --git a/docker-compose.gpu.yml b/docker-compose.gpu.yml new file mode 100644 index 0000000..ae4dc71 --- /dev/null +++ b/docker-compose.gpu.yml @@ -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] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9e8aa7b --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..615628d --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..d656e47 --- /dev/null +++ b/start.sh @@ -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." diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..c777b23 --- /dev/null +++ b/static/index.html @@ -0,0 +1,284 @@ + + + + + Local LiveKit Voice Chat + + + +

🎙️ Local AI Voice Chat

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
Idle
+
+ +
+ +
+ +
+ +
Conversation will appear here…
+
+ + + + + + diff --git a/token_server.py b/token_server.py new file mode 100644 index 0000000..24f5c3e --- /dev/null +++ b/token_server.py @@ -0,0 +1,81 @@ +""" +token_server.py +Serves: + GET /token?room= → 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)