feat: initial commit — VoiceCoach real-time voice coaching pipeline

Services: Pipecat agent (LiveKit + Claude + Whisper STT + Piper TTS),
SpeechBrain analysis service, Piper TTS service, web client (nginx).
Includes LiveKit server config and docker-compose orchestration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-01 01:15:24 -05:00
commit 59856bd4fc
23 changed files with 1209 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
FROM nginx:alpine
COPY index.html /usr/share/nginx/html/
COPY app.js /usr/share/nginx/html/
COPY style.css /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+77
View File
@@ -0,0 +1,77 @@
const LIVEKIT_URL = "wss://voice.hobbs.farm";
let room = null;
async function connect() {
setStatus("connecting", "Connecting...");
try {
// Request a token from the token service
const response = await fetch("/api/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: "jeremy-" + Date.now() }),
});
if (!response.ok) throw new Error("Failed to get token");
const { token } = await response.json();
room = new LivekitClient.Room({
audioCaptureDefaults: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
room.on(LivekitClient.RoomEvent.Connected, () => {
setStatus("connected", "Connected");
document.getElementById("connectBtn").style.display = "none";
document.getElementById("disconnectBtn").style.display = "block";
});
room.on(LivekitClient.RoomEvent.Disconnected, () => {
setStatus("disconnected", "Disconnected");
document.getElementById("connectBtn").style.display = "block";
document.getElementById("disconnectBtn").style.display = "none";
});
room.on(LivekitClient.RoomEvent.TrackSubscribed, (track) => {
if (track.kind === LivekitClient.Track.Kind.Audio) {
const element = track.attach();
document.body.appendChild(element);
element.play();
}
});
await room.connect(LIVEKIT_URL, token);
await room.localParticipant.setMicrophoneEnabled(true);
} catch (err) {
setStatus("disconnected", "Error: " + err.message);
console.error(err);
}
}
async function disconnect() {
if (room) {
await room.disconnect();
room = null;
}
}
function setMode(mode) {
document.getElementById("modeDisplay").textContent = "Mode: " + mode;
if (room && room.localParticipant) {
const encoder = new TextEncoder();
room.localParticipant.publishData(
encoder.encode(JSON.stringify({ type: "mode_change", mode })),
{ reliable: true }
);
}
}
function setStatus(state, text) {
const el = document.getElementById("status");
el.className = "status " + state;
el.textContent = text;
}
document.getElementById("connectBtn").addEventListener("click", connect);
document.getElementById("disconnectBtn").addEventListener("click", disconnect);
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>VoiceCoach</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<div class="header">
<h1>VoiceCoach</h1>
<div id="status" class="status disconnected">Disconnected</div>
</div>
<div class="controls">
<button id="connectBtn" class="btn btn-primary">Start Session</button>
<button id="disconnectBtn" class="btn btn-danger" style="display:none">End Session</button>
</div>
<div id="modeDisplay" class="mode-display">Mode: coaching</div>
<div id="transcript" class="transcript">
<p class="hint">Press "Start Session" to connect your voice.</p>
</div>
<div class="mode-buttons">
<button class="mode-btn" onclick="setMode('coaching mode')">Coaching</button>
<button class="mode-btn" onclick="setMode('vent mode')">Vent</button>
<button class="mode-btn" onclick="setMode('practice mode')">Practice</button>
<button class="mode-btn" onclick="setMode('briefing mode')">Briefing</button>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script>
<script src="app.js"></script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://pipecat-agent:8080/;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+88
View File
@@ -0,0 +1,88 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #eee;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.container {
max-width: 420px;
width: 100%;
padding: 24px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
h1 { font-size: 24px; font-weight: 600; }
.status {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.status.disconnected { background: #444; color: #aaa; }
.status.connecting { background: #553; color: #ffa; }
.status.connected { background: #253; color: #5f5; }
.controls { margin-bottom: 20px; }
.btn {
width: 100%;
padding: 14px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.btn:hover { opacity: 0.85; }
.btn-primary { background: #4a6cf7; color: #fff; }
.btn-danger { background: #f74a4a; color: #fff; }
.mode-display {
text-align: center;
font-size: 13px;
color: #888;
margin-bottom: 16px;
}
.transcript {
background: #16213e;
border-radius: 8px;
padding: 16px;
min-height: 200px;
max-height: 400px;
overflow-y: auto;
margin-bottom: 16px;
}
.transcript .hint { color: #666; font-style: italic; }
.mode-buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.mode-btn {
padding: 10px;
background: #16213e;
border: 1px solid #333;
border-radius: 6px;
color: #ccc;
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
}
.mode-btn:hover { background: #1a2744; border-color: #4a6cf7; }