feat: VoiceCoach v2.1 - GPU local, Ollama LLM, settings panel, token server separation
- pipeline.py: switched LLM from OpenClaw/Claude to local Ollama, added settings loading, sentence aggregator, async SpeechBrain, tuned VAD - main.py: simplified (no more token server thread, just pipeline) - token_server.py: NEW - standalone token + settings API server - docker-compose.yml: all services on GPU machine, added token-server service, CUDA speaches, Ollama LLM - web-client/index.html: added settings panel with sliders - web-client/app.js: settings management, localStorage persistence, push to server - web-client/style.css: settings panel styles - web-client/nginx.conf: proxy to token-server instead of pipecat-agent - patch_livekit.py: updated with proper indentation-safe patching
This commit is contained in:
+141
-19
@@ -1,17 +1,119 @@
|
||||
const LIVEKIT_URL = "wss://voice.hobbs.farm";
|
||||
let room = null;
|
||||
|
||||
// --- Settings ---
|
||||
const DEFAULTS = {
|
||||
confidence: 0.7, min_volume: 0.3, start_secs: 0.4,
|
||||
stop_secs: 0.5, max_tokens: 150, tts_voice: "af_nova"
|
||||
};
|
||||
|
||||
function getSettings() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem("vc_settings"));
|
||||
return saved ? { ...DEFAULTS, ...saved } : { ...DEFAULTS };
|
||||
} catch (e) { return { ...DEFAULTS }; }
|
||||
}
|
||||
|
||||
function loadSettingsUI() {
|
||||
const s = getSettings();
|
||||
for (const [key, val] of Object.entries(s)) {
|
||||
const input = document.getElementById("set-" + key);
|
||||
const display = document.getElementById("val-" + key);
|
||||
if (input) input.value = val;
|
||||
if (display) display.textContent = val;
|
||||
}
|
||||
// Bind live value display
|
||||
document.querySelectorAll(".settings-body input[type=range]").forEach(function(el) {
|
||||
el.addEventListener("input", function() {
|
||||
const key = el.id.replace("set-", "");
|
||||
document.getElementById("val-" + key).textContent = el.value;
|
||||
});
|
||||
});
|
||||
var sel = document.getElementById("set-tts_voice");
|
||||
if (sel) sel.addEventListener("change", function() {
|
||||
document.getElementById("val-tts_voice").textContent = sel.value;
|
||||
});
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
var s = {};
|
||||
for (var key of Object.keys(DEFAULTS)) {
|
||||
var el = document.getElementById("set-" + key);
|
||||
if (el) s[key] = el.tagName === "SELECT" ? el.value : parseFloat(el.value);
|
||||
}
|
||||
localStorage.setItem("vc_settings", JSON.stringify(s));
|
||||
// Push to server
|
||||
fetch("/api/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(s),
|
||||
}).then(function(r) {
|
||||
if (r.ok) log("Settings saved and applied");
|
||||
else log("Settings saved locally but server update failed", "warn");
|
||||
}).catch(function() {
|
||||
log("Settings saved locally (server unreachable)", "warn");
|
||||
});
|
||||
}
|
||||
|
||||
function resetSettings() {
|
||||
localStorage.removeItem("vc_settings");
|
||||
loadSettingsUI();
|
||||
saveSettings();
|
||||
log("Settings reset to defaults");
|
||||
}
|
||||
|
||||
function toggleSettings() {
|
||||
var body = document.getElementById("settingsBody");
|
||||
var toggle = document.getElementById("settingsToggle");
|
||||
if (body.style.display === "none") {
|
||||
body.style.display = "block";
|
||||
toggle.textContent = "-";
|
||||
} else {
|
||||
body.style.display = "none";
|
||||
toggle.textContent = "+";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Logging ---
|
||||
function log(msg, level) {
|
||||
var el = document.getElementById("logOutput");
|
||||
var time = new Date().toLocaleTimeString();
|
||||
var cls = level === "error" ? "log-error" : level === "warn" ? "log-warn" : "log-info";
|
||||
el.innerHTML += '<div class="' + cls + '"><span class="log-time">' + time + '</span> ' + msg + '</div>';
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
document.getElementById("logOutput").innerHTML = "";
|
||||
}
|
||||
|
||||
// --- Connection ---
|
||||
async function connect() {
|
||||
setStatus("connecting", "Connecting...");
|
||||
log("Requesting token...");
|
||||
|
||||
// Push current settings before connecting
|
||||
var settings = getSettings();
|
||||
try {
|
||||
// Request a token from the token service
|
||||
const response = await fetch("/api/token", {
|
||||
await fetch("/api/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
log("Settings pushed: confidence=" + settings.confidence + " vol=" + settings.min_volume);
|
||||
} catch (e) {
|
||||
log("Could not push settings", "warn");
|
||||
}
|
||||
|
||||
try {
|
||||
var 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();
|
||||
if (!response.ok) throw new Error("Token " + response.status);
|
||||
var data = await response.json();
|
||||
log("Token received");
|
||||
|
||||
room = new LivekitClient.Room({
|
||||
audioCaptureDefaults: {
|
||||
@@ -21,57 +123,77 @@ async function connect() {
|
||||
},
|
||||
});
|
||||
|
||||
room.on(LivekitClient.RoomEvent.Connected, () => {
|
||||
room.on(LivekitClient.RoomEvent.Connected, function() {
|
||||
setStatus("connected", "Connected");
|
||||
document.getElementById("connectBtn").style.display = "none";
|
||||
document.getElementById("disconnectBtn").style.display = "block";
|
||||
log("Connected to LiveKit");
|
||||
});
|
||||
|
||||
room.on(LivekitClient.RoomEvent.Disconnected, () => {
|
||||
room.on(LivekitClient.RoomEvent.Disconnected, function(reason) {
|
||||
setStatus("disconnected", "Disconnected");
|
||||
document.getElementById("connectBtn").style.display = "block";
|
||||
document.getElementById("disconnectBtn").style.display = "none";
|
||||
log("Disconnected" + (reason ? ": " + reason : ""), "warn");
|
||||
});
|
||||
|
||||
room.on(LivekitClient.RoomEvent.TrackSubscribed, (track) => {
|
||||
room.on(LivekitClient.RoomEvent.ParticipantConnected, function(p) {
|
||||
log("Agent joined: " + p.identity);
|
||||
});
|
||||
|
||||
room.on(LivekitClient.RoomEvent.TrackSubscribed, function(track, pub, p) {
|
||||
if (track.kind === LivekitClient.Track.Kind.Audio) {
|
||||
const element = track.attach();
|
||||
document.body.appendChild(element);
|
||||
element.play();
|
||||
var el = track.attach();
|
||||
document.body.appendChild(el);
|
||||
el.play().catch(function(e) { log("Audio play error: " + e.message, "error"); });
|
||||
}
|
||||
});
|
||||
|
||||
await room.connect(LIVEKIT_URL, token);
|
||||
room.on(LivekitClient.RoomEvent.MediaDevicesError, function(err) {
|
||||
log("Mic error: " + err.message, "error");
|
||||
});
|
||||
|
||||
await room.connect(data.url || LIVEKIT_URL, data.token);
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
log("Mic enabled. Speak now.");
|
||||
|
||||
} catch (err) {
|
||||
setStatus("disconnected", "Error: " + err.message);
|
||||
console.error(err);
|
||||
log("Error: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
if (room) {
|
||||
await room.disconnect();
|
||||
room = null;
|
||||
}
|
||||
if (room) { await room.disconnect(); room = null; }
|
||||
}
|
||||
|
||||
function setMode(mode) {
|
||||
document.getElementById("modeDisplay").textContent = "Mode: " + mode;
|
||||
log("Mode: " + mode);
|
||||
if (room && room.localParticipant) {
|
||||
const encoder = new TextEncoder();
|
||||
room.localParticipant.publishData(
|
||||
encoder.encode(JSON.stringify({ type: "mode_change", mode })),
|
||||
new TextEncoder().encode(JSON.stringify({ type: "mode_change", mode })),
|
||||
{ reliable: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(state, text) {
|
||||
const el = document.getElementById("status");
|
||||
var el = document.getElementById("status");
|
||||
el.className = "status " + state;
|
||||
el.textContent = text;
|
||||
}
|
||||
|
||||
document.getElementById("connectBtn").addEventListener("click", connect);
|
||||
document.getElementById("disconnectBtn").addEventListener("click", disconnect);
|
||||
|
||||
// Init
|
||||
loadSettingsUI();
|
||||
// Push saved settings on load
|
||||
fetch("/api/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(getSettings()),
|
||||
}).catch(function() {});
|
||||
|
||||
log("VoiceCoach loaded. Ready.");
|
||||
|
||||
@@ -30,6 +30,60 @@
|
||||
<button class="mode-btn" onclick="setMode('practice mode')">Practice</button>
|
||||
<button class="mode-btn" onclick="setMode('briefing mode')">Briefing</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-panel">
|
||||
<div class="settings-header" onclick="toggleSettings()">
|
||||
<span>Settings</span>
|
||||
<span id="settingsToggle">+</span>
|
||||
</div>
|
||||
<div id="settingsBody" class="settings-body" style="display:none">
|
||||
<div class="setting-group">
|
||||
<label>VAD Confidence <span class="setting-val" id="val-confidence">0.7</span></label>
|
||||
<input type="range" id="set-confidence" min="0.3" max="0.95" step="0.05" value="0.7">
|
||||
<div class="setting-hint">Higher = less false triggers from noise</div>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<label>Min Volume <span class="setting-val" id="val-min_volume">0.3</span></label>
|
||||
<input type="range" id="set-min_volume" min="0.05" max="0.6" step="0.05" value="0.3">
|
||||
<div class="setting-hint">Higher = ignores quieter sounds</div>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<label>Start Delay <span class="setting-val" id="val-start_secs">0.4</span>s</label>
|
||||
<input type="range" id="set-start_secs" min="0.1" max="0.8" step="0.1" value="0.4">
|
||||
<div class="setting-hint">Sustained speech needed before trigger</div>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<label>Stop Delay <span class="setting-val" id="val-stop_secs">0.5</span>s</label>
|
||||
<input type="range" id="set-stop_secs" min="0.2" max="1.5" step="0.1" value="0.5">
|
||||
<div class="setting-hint">Silence before turn ends (higher = less cutoff)</div>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<label>LLM Max Tokens <span class="setting-val" id="val-max_tokens">150</span></label>
|
||||
<input type="range" id="set-max_tokens" min="50" max="500" step="25" value="150">
|
||||
<div class="setting-hint">Longer = more detailed responses, slower</div>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<label>TTS Voice <span class="setting-val" id="val-tts_voice">af_nova</span></label>
|
||||
<select id="set-tts_voice">
|
||||
<option value="af_nova">Nova (Female, American)</option>
|
||||
<option value="af_sky">Sky (Female, American)</option>
|
||||
<option value="am_adam">Adam (Male, American)</option>
|
||||
<option value="bf_emma">Emma (Female, British)</option>
|
||||
<option value="bm_george">George (Male, British)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-small" onclick="saveSettings()">Apply Settings</button>
|
||||
<button class="btn btn-small btn-reset" onclick="resetSettings()">Reset Defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log-panel">
|
||||
<div class="log-header">
|
||||
<span>Log</span>
|
||||
<button class="log-clear" onclick="clearLog()">Clear</button>
|
||||
</div>
|
||||
<div id="logOutput" class="log-output"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script>
|
||||
|
||||
@@ -4,7 +4,7 @@ server {
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://pipecat-agent:8080/;
|
||||
proxy_pass http://token-server:8080/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
@@ -86,3 +86,125 @@ h1 { font-size: 24px; font-weight: 600; }
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.mode-btn:hover { background: #1a2744; border-color: #4a6cf7; }
|
||||
|
||||
.settings-panel {
|
||||
margin-top: 16px;
|
||||
background: #0d1117;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: #161b22;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.settings-header:hover { color: #ccc; }
|
||||
.settings-body { padding: 12px; }
|
||||
.setting-group { margin-bottom: 14px; }
|
||||
.setting-group label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: #ccc;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.setting-val {
|
||||
color: #4a6cf7;
|
||||
font-weight: 600;
|
||||
font-family: 'SF Mono', monospace;
|
||||
}
|
||||
.setting-group input[type=range] {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
-webkit-appearance: none;
|
||||
background: #333;
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
}
|
||||
.setting-group input[type=range]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #4a6cf7;
|
||||
cursor: pointer;
|
||||
}
|
||||
.setting-group select {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: #16213e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
color: #ccc;
|
||||
font-size: 13px;
|
||||
}
|
||||
.setting-hint {
|
||||
font-size: 11px;
|
||||
color: #555;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.btn-small {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
width: auto;
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.btn-reset {
|
||||
background: #333;
|
||||
color: #aaa;
|
||||
}
|
||||
.btn-reset:hover { background: #444; }
|
||||
|
||||
.log-panel {
|
||||
margin-top: 16px;
|
||||
background: #0d1117;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.log-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
background: #161b22;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.log-clear {
|
||||
background: none;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.log-clear:hover { border-color: #666; color: #ccc; }
|
||||
.log-output {
|
||||
padding: 8px 12px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.log-output div { word-break: break-all; }
|
||||
.log-time { color: #555; margin-right: 6px; }
|
||||
.log-info { color: #8b949e; }
|
||||
.log-warn { color: #d29922; }
|
||||
.log-error { color: #f85149; }
|
||||
|
||||
Reference in New Issue
Block a user