feat: VoiceCoach v2 — livekit-agents 1.5 personal voice assistant

Replaces pipecat 0.0.48 (6 manual patches, DuplicateIdentity reconnect storms)
with livekit-agents 1.5 SDK. New features:
- SvelteKit UI with live transcript, VAD ring, mode bar, vocal meter, history view
- SQLite session store with WAL mode (cross-session memory)
- Async SpeechBrain vocal/emotion analysis injected per turn
- Homelab tools: SSH/docker, Prometheus alerts, Home Assistant, SearXNG search
- OpenClaw LLM gateway preserved (qwen2.5:14b via Ollama)
- 4 modes: coaching, vent, practice, briefing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 18:05:30 -05:00
parent 55f05a88d0
commit b3bef75981
24 changed files with 1660 additions and 0 deletions
@@ -0,0 +1,11 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json .
RUN npm install --legacy-peer-deps
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
@@ -0,0 +1,16 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Proxy API calls to the agent's FastAPI server
location /api/ {
proxy_pass http://voicecoach-agent:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
try_files $uri $uri/ /index.html;
}
}
@@ -0,0 +1,21 @@
{
"name": "voicecoach-v2-web",
"version": "2.0.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"type": "module",
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"svelte": "^5.0.0",
"vite": "^5.0.0"
},
"dependencies": {
"livekit-client": "^2.5.0"
}
}
@@ -0,0 +1,40 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #22263a;
--accent: #6c63ff;
--accent2: #4ecdc4;
--text: #e8eaf0;
--muted: #7b8094;
--danger: #ff5252;
--success: #4caf50;
--warn: #ffc107;
--radius: 12px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--font); }
button {
cursor: pointer;
border: none;
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
border-radius: var(--radius);
padding: 0.5rem 1.2rem;
transition: opacity 0.15s, transform 0.1s;
}
button:active { transform: scale(0.97); }
button:disabled { opacity: 0.4; cursor: default; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover:not(:disabled) { opacity: 0.85; }
.btn-danger { background: var(--danger); color: #fff; }
.btn-ghost { background: var(--surface2); color: var(--text); }
.btn-ghost:hover:not(:disabled) { background: #2d3147; }
a { color: var(--accent2); text-decoration: none; }
a:hover { text-decoration: underline; }
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>VoiceCoach</title>
%sveltekit.head%
</head>
<body>
<div>%sveltekit.body%</div>
</body>
</html>
@@ -0,0 +1,66 @@
import { Room, RoomEvent, Track } from 'livekit-client';
const LIVEKIT_URL = 'wss://voice.hobbs.farm';
const API_BASE = '/api';
export let room = null;
export async function connect(identity, onEvent) {
const res = await fetch(`${API_BASE}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity: identity || `jeremy-${Date.now()}` }),
});
if (!res.ok) throw new Error(`Token fetch failed: ${res.status}`);
const { token, url } = await res.json();
room = new Room({
audioCaptureDefaults: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
room.on(RoomEvent.Connected, () => onEvent({ type: 'connected' }));
room.on(RoomEvent.Disconnected, (reason) => onEvent({ type: 'disconnected', reason }));
room.on(RoomEvent.ParticipantConnected, (p) => onEvent({ type: 'participant_joined', identity: p.identity }));
room.on(RoomEvent.ParticipantDisconnected, (p) => onEvent({ type: 'participant_left', identity: p.identity }));
room.on(RoomEvent.TrackSubscribed, (track, _pub, participant) => {
if (track.kind === Track.Kind.Audio) {
const el = track.attach();
el.autoplay = true;
document.body.appendChild(el);
onEvent({ type: 'audio_attached', identity: participant.identity });
}
});
room.on(RoomEvent.TrackUnsubscribed, (track) => {
track.detach().forEach(el => el.remove());
});
room.on(RoomEvent.DataReceived, (payload, participant) => {
try {
const msg = JSON.parse(new TextDecoder().decode(payload));
onEvent(msg);
} catch (_) {}
});
await room.connect(url || LIVEKIT_URL, token);
await room.localParticipant.setMicrophoneEnabled(true);
return room;
}
export async function disconnect() {
if (room) {
await room.disconnect();
room = null;
}
}
export function sendData(payload) {
if (!room?.localParticipant) return;
const data = new TextEncoder().encode(JSON.stringify(payload));
room.localParticipant.publishData(data, { reliable: true });
}
@@ -0,0 +1,2 @@
export const prerender = false;
export const ssr = false;
@@ -0,0 +1,6 @@
<script>
import '../app.css';
let { children } = $props();
</script>
{@render children()}
@@ -0,0 +1,328 @@
<script>
import { connect, disconnect, sendData } from '$lib/livekit.js';
import { onDestroy } from 'svelte';
let status = 'disconnected'; // disconnected | connecting | connected
let mode = 'coaching';
let transcript = []; // {role, text, partial, id}
let tools = []; // {action, detail, result, done}
let vocal = null; // {energy, emotion, pitch_hz}
let error = '';
let nextId = 0;
const MODES = ['coaching', 'vent', 'practice', 'briefing'];
function handleEvent(ev) {
switch (ev.type) {
case 'connected':
status = 'connected';
error = '';
break;
case 'disconnected':
status = 'disconnected';
break;
case 'partial_transcript':
if (ev.speaker === 'user') {
const last = transcript[transcript.length - 1];
if (last && last.partial && last.role === 'user') {
transcript[transcript.length - 1] = { ...last, text: ev.text };
} else {
transcript = [...transcript, { role: 'user', text: ev.text, partial: true, id: nextId++ }];
}
}
break;
case 'final_transcript':
if (ev.speaker === 'user') {
// Replace last partial or add new
const lastIdx = transcript.findLastIndex(t => t.partial && t.role === 'user');
if (lastIdx >= 0) {
transcript = transcript.map((t, i) =>
i === lastIdx ? { ...t, text: ev.text, partial: false } : t
);
} else {
transcript = [...transcript, { role: 'user', text: ev.text, partial: false, id: nextId++ }];
}
}
break;
case 'agent_response':
transcript = [...transcript, { role: 'assistant', text: ev.text, partial: false, id: nextId++ }];
break;
case 'tool_start':
tools = [...tools, { action: ev.action, detail: ev.detail || '', result: '', done: false, id: nextId++ }];
break;
case 'tool_complete':
tools = tools.map(t =>
!t.done && t.action === ev.action ? { ...t, result: ev.result, done: true } : t
);
break;
case 'vocal_state':
vocal = { energy: ev.energy, emotion: ev.emotion, pitch_hz: ev.pitch_hz };
break;
case 'mode_ack':
mode = ev.mode;
break;
}
}
async function startSession() {
status = 'connecting';
error = '';
transcript = [];
tools = [];
try {
await connect(null, handleEvent);
} catch (e) {
status = 'disconnected';
error = e.message;
}
}
async function endSession() {
await disconnect();
status = 'disconnected';
}
function setMode(m) {
mode = m;
sendData({ type: 'mode_change', mode: m });
}
onDestroy(() => disconnect());
$: energyPct = vocal ? Math.round(vocal.energy * 100) : 0;
$: vadRing = status === 'connected' && vocal && vocal.energy > 0.1;
</script>
<main>
<header>
<div class="logo">
<div class="logo-dot" class:connected={status === 'connected'} class:connecting={status === 'connecting'}></div>
<span>VoiceCoach</span>
</div>
<nav>
<a href="/history">History</a>
</nav>
</header>
<div class="content">
<!-- Mode bar -->
<div class="mode-bar" class:disabled={status !== 'connected'}>
{#each MODES as m}
<button
class="mode-chip"
class:active={mode === m}
disabled={status !== 'connected'}
on:click={() => setMode(m)}
>{m}</button>
{/each}
</div>
<!-- Transcript -->
<div class="transcript" id="transcript-scroll">
{#if transcript.length === 0 && status === 'disconnected'}
<p class="hint">Press Start to connect your voice.</p>
{:else if transcript.length === 0 && status !== 'disconnected'}
<p class="hint">Listening…</p>
{/if}
{#each transcript as msg (msg.id)}
<div class="msg msg-{msg.role}" class:partial={msg.partial}>
<span class="msg-label">{msg.role === 'user' ? 'You' : 'Assistant'}</span>
<span class="msg-text">{msg.text}</span>
</div>
{/each}
</div>
<!-- Tool activity feed -->
{#if tools.length > 0}
<div class="tool-feed">
{#each tools.slice(-5) as t (t.id)}
<div class="tool-item" class:done={t.done}>
<span class="tool-icon">{t.done ? '✓' : '⚙'}</span>
<span class="tool-text">{t.action}{t.detail ? ` — ${t.detail}` : ''}</span>
</div>
{/each}
</div>
{/if}
<!-- Vocal meter -->
{#if status === 'connected'}
<div class="vocal-row">
<div class="energy-bar">
<div class="energy-fill" style="width: {energyPct}%"></div>
</div>
{#if vocal && vocal.emotion && vocal.emotion !== 'neutral'}
<span class="emotion-tag">{vocal.emotion}</span>
{/if}
</div>
{/if}
<!-- Error -->
{#if error}
<p class="error">{error}</p>
{/if}
<!-- Controls -->
<div class="controls">
{#if status === 'disconnected'}
<button class="btn-primary big-btn" on:click={startSession}>Start Session</button>
{:else if status === 'connecting'}
<button class="btn-ghost big-btn" disabled>Connecting…</button>
{:else}
<button class="btn-danger big-btn" on:click={endSession}>End Session</button>
{/if}
</div>
</div>
<!-- VAD ring -->
{#if vadRing}
<div class="vad-ring" style="opacity: {Math.min(vocal.energy * 3, 1)}"></div>
{/if}
</main>
<style>
main {
min-height: 100dvh;
display: flex;
flex-direction: column;
max-width: 680px;
margin: 0 auto;
padding: 0 1rem;
position: relative;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 0;
border-bottom: 1px solid #2a2d3e;
}
.logo { display: flex; align-items: center; gap: 0.5rem; font-weight: 600; font-size: 1.1rem; }
.logo-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--muted);
transition: background 0.3s;
}
.logo-dot.connecting { background: var(--warn); animation: pulse 1s infinite; }
.logo-dot.connected { background: var(--success); }
nav a { font-size: 0.875rem; color: var(--muted); }
.content { flex: 1; display: flex; flex-direction: column; padding: 1rem 0; gap: 1rem; }
.mode-bar {
display: flex; gap: 0.5rem; flex-wrap: wrap;
}
.mode-bar.disabled { opacity: 0.5; }
.mode-chip {
padding: 0.35rem 0.9rem;
border-radius: 999px;
background: var(--surface2);
color: var(--muted);
font-size: 0.8rem;
text-transform: capitalize;
border: 1px solid transparent;
}
.mode-chip.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.transcript {
flex: 1;
min-height: 300px;
background: var(--surface);
border-radius: var(--radius);
padding: 1rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.hint { color: var(--muted); font-size: 0.875rem; text-align: center; margin: auto; }
.msg { display: flex; flex-direction: column; gap: 0.15rem; }
.msg-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.msg-user .msg-label { color: var(--accent2); }
.msg-assistant .msg-label { color: var(--accent); }
.msg-text { font-size: 0.95rem; line-height: 1.5; }
.msg.partial .msg-text { opacity: 0.6; font-style: italic; }
.tool-feed {
background: var(--surface);
border-radius: var(--radius);
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.tool-item {
display: flex; align-items: center; gap: 0.5rem;
font-size: 0.8rem; color: var(--muted);
}
.tool-item.done { color: var(--success); }
.tool-icon { font-size: 0.9rem; }
.vocal-row {
display: flex; align-items: center; gap: 0.75rem;
}
.energy-bar {
flex: 1; height: 6px; border-radius: 3px;
background: var(--surface2); overflow: hidden;
}
.energy-fill {
height: 100%; border-radius: 3px;
background: linear-gradient(90deg, var(--accent2), var(--accent));
transition: width 0.2s;
}
.emotion-tag {
font-size: 0.75rem; color: var(--muted);
background: var(--surface2);
padding: 0.2rem 0.5rem;
border-radius: 999px;
text-transform: capitalize;
}
.error { color: var(--danger); font-size: 0.85rem; }
.controls { display: flex; justify-content: center; padding-bottom: 1.5rem; }
.big-btn {
width: 100%; max-width: 320px; padding: 0.85rem;
font-size: 1rem; border-radius: var(--radius);
}
.vad-ring {
position: fixed; inset: 0; pointer-events: none;
border: 3px solid var(--accent2);
border-radius: inherit;
transition: opacity 0.1s;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@media (max-width: 480px) {
.big-btn { max-width: 100%; }
}
</style>
@@ -0,0 +1,164 @@
<script>
import { onMount } from 'svelte';
let sessions = [];
let expanded = null;
let messages = {};
let loading = true;
onMount(async () => {
try {
const res = await fetch('/api/sessions?limit=30');
sessions = await res.json();
} catch (e) {
console.error(e);
} finally {
loading = false;
}
});
async function toggleSession(id) {
if (expanded === id) { expanded = null; return; }
expanded = id;
if (!messages[id]) {
const res = await fetch(`/api/sessions/${id}/messages`);
messages[id] = await res.json();
}
}
function fmt(ts) {
if (!ts) return '';
return new Date(ts * 1000).toLocaleString();
}
function duration(s) {
if (!s.ended_at || !s.started_at) return '';
const mins = Math.round((s.ended_at - s.started_at) / 60);
return mins < 1 ? '<1m' : `${mins}m`;
}
</script>
<main>
<header>
<a href="/" class="back">← Back</a>
<h1>Session History</h1>
</header>
{#if loading}
<p class="hint">Loading…</p>
{:else if sessions.length === 0}
<p class="hint">No sessions yet.</p>
{:else}
<div class="session-list">
{#each sessions as s}
<div class="session-card" class:open={expanded === s.id}>
<button class="session-header" on:click={() => toggleSession(s.id)}>
<div class="session-meta">
<span class="session-date">{fmt(s.started_at)}</span>
<span class="session-chips">
<span class="chip mode-chip">{s.mode || 'coaching'}</span>
{#if duration(s)}
<span class="chip">{duration(s)}</span>
{/if}
</span>
</div>
{#if s.summary}
<p class="session-summary">{s.summary}</p>
{/if}
<span class="toggle-icon">{expanded === s.id ? '▲' : '▼'}</span>
</button>
{#if expanded === s.id && messages[s.id]}
<div class="transcript">
{#each messages[s.id] as msg}
<div class="msg msg-{msg.role}">
<span class="msg-label">
{msg.role === 'user' ? 'You' : 'Assistant'}
{#if msg.vocal_emotion && msg.vocal_emotion !== 'neutral'}
<span class="vocal-badge">{msg.vocal_emotion}</span>
{/if}
</span>
<span class="msg-text">{msg.content}</span>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</main>
<style>
main {
max-width: 680px; margin: 0 auto; padding: 1rem;
min-height: 100dvh;
}
header {
display: flex; align-items: center; gap: 1rem;
padding: 1rem 0; border-bottom: 1px solid #2a2d3e; margin-bottom: 1rem;
}
header h1 { font-size: 1.1rem; font-weight: 600; }
.back { font-size: 0.875rem; color: var(--muted); }
.hint { color: var(--muted); text-align: center; padding: 2rem; }
.session-list { display: flex; flex-direction: column; gap: 0.75rem; }
.session-card {
background: var(--surface);
border-radius: var(--radius);
overflow: hidden;
border: 1px solid transparent;
transition: border-color 0.2s;
}
.session-card.open { border-color: var(--accent); }
.session-header {
width: 100%; text-align: left;
background: none; padding: 1rem;
display: flex; flex-direction: column; gap: 0.4rem;
border-radius: 0; color: var(--text);
position: relative;
}
.session-meta { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.session-date { font-size: 0.8rem; color: var(--muted); }
.session-chips { display: flex; gap: 0.35rem; }
.chip {
font-size: 0.7rem; padding: 0.15rem 0.5rem;
border-radius: 999px; background: var(--surface2); color: var(--muted);
}
.mode-chip { color: var(--accent2); }
.session-summary { font-size: 0.85rem; color: var(--text); line-height: 1.4; }
.toggle-icon {
position: absolute; right: 1rem; top: 1rem;
font-size: 0.7rem; color: var(--muted);
}
.transcript {
padding: 1rem; border-top: 1px solid #2a2d3e;
display: flex; flex-direction: column; gap: 0.75rem;
}
.msg { display: flex; flex-direction: column; gap: 0.1rem; }
.msg-label {
font-size: 0.7rem; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--muted);
display: flex; align-items: center; gap: 0.4rem;
}
.msg-user .msg-label { color: var(--accent2); }
.msg-assistant .msg-label { color: var(--accent); }
.msg-text { font-size: 0.9rem; line-height: 1.5; }
.vocal-badge {
font-size: 0.65rem; background: var(--surface2);
padding: 0.1rem 0.4rem; border-radius: 999px;
text-transform: capitalize; color: var(--muted);
font-weight: 400; letter-spacing: 0;
}
</style>
@@ -0,0 +1,7 @@
import adapter from '@sveltejs/adapter-static';
export default {
kit: {
adapter: adapter({ fallback: 'index.html' }),
},
};
@@ -0,0 +1,8 @@
import { sveltekit } from '@sveltejs/kit/vite'
/** @type {import('vite').UserConfig} */
const config = {
plugins: [sveltekit()]
}
export default config