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:
Executable
+8
@@ -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
|
||||
Executable
+77
@@ -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);
|
||||
Executable
+38
@@ -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>
|
||||
Executable
+15
@@ -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;
|
||||
}
|
||||
}
|
||||
Executable
+88
@@ -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; }
|
||||
Reference in New Issue
Block a user