From 9cea1c1bba1d910319bde9d4363e129bb5f26a02 Mon Sep 17 00:00:00 2001 From: Ian Keane Date: Mon, 31 Aug 2026 11:47:29 -0400 Subject: [PATCH] Initial commit --- .env | 28 +++ .env.example | 27 +++ Dockerfile | 17 ++ Dockerfile.agent | 14 ++ README.md | 90 ++++++++++ __pycache__/agent.cpython-312.pyc | Bin 0 -> 9464 bytes agent.py | 148 ++++++++++++++++ docker-compose.gpu.yml | 30 ++++ docker-compose.yml | 106 +++++++++++ requirements.txt | 11 ++ start.sh | 12 ++ static/index.html | 284 ++++++++++++++++++++++++++++++ token_server.py | 81 +++++++++ 13 files changed, 848 insertions(+) create mode 100644 .env create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 Dockerfile.agent create mode 100644 README.md create mode 100644 __pycache__/agent.cpython-312.pyc create mode 100644 agent.py create mode 100644 docker-compose.gpu.yml create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100755 start.sh create mode 100644 static/index.html create mode 100644 token_server.py 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 0000000000000000000000000000000000000000..d22da1e28a7b3a49c50182524fc09aad36819701 GIT binary patch literal 9464 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 literal 0 HcmV?d00001 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)