mirror of
https://github.com/Bijit-Mondal/VoiceAgent.git
synced 2026-03-02 18:36:39 +00:00
feat(example): video streaming
This commit is contained in:
BIN
example/frames/frame_00000_2026-02-19T13-10-00-344Z.webp
Normal file
BIN
example/frames/frame_00000_2026-02-19T13-10-00-344Z.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
example/frames/frame_00001_2026-02-19T13-10-04-584Z.webp
Normal file
BIN
example/frames/frame_00001_2026-02-19T13-10-04-584Z.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
example/frames/frame_00002_2026-02-19T13-10-06-446Z.webp
Normal file
BIN
example/frames/frame_00002_2026-02-19T13-10-06-446Z.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
example/frames/frame_00003_2026-02-19T13-10-14-543Z.webp
Normal file
BIN
example/frames/frame_00003_2026-02-19T13-10-14-543Z.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
example/frames/frame_00004_2026-02-19T13-10-24-540Z.webp
Normal file
BIN
example/frames/frame_00004_2026-02-19T13-10-24-540Z.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -2,19 +2,19 @@ const http = require('http');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const PORT = 3000;
|
const PORT = 3102;
|
||||||
|
|
||||||
// Create a simple HTTP server to serve the voice client HTML
|
// Create a simple HTTP server to serve the voice client HTML
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
if (req.url === '/' || req.url === '/index.html') {
|
if (req.url === '/' || req.url === '/index.html') {
|
||||||
const htmlPath = path.join(__dirname, 'voice-client.html');
|
const htmlPath = path.join(__dirname, 'video-client.html');
|
||||||
fs.readFile(htmlPath, (err, data) => {
|
fs.readFile(htmlPath, (err, data) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
res.writeHead(500);
|
res.writeHead(500);
|
||||||
res.end('Error loading voice-client.html');
|
res.end('Error loading voice-client.html');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.writeHead(200, {'Content-Type': 'text/html'});
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
res.end(data);
|
res.end(data);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
998
example/video-client.html
Normal file
998
example/video-client.html
Normal file
@@ -0,0 +1,998 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Video + Voice Agent Client</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: #f9fafb;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
video {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #000;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: #2563eb;
|
||||||
|
color: white;
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: #dc2626;
|
||||||
|
color: white;
|
||||||
|
border-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 8px 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.disconnected {
|
||||||
|
background: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.connected {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.listening {
|
||||||
|
background: #f59e0b;
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.speaking {
|
||||||
|
background: #3b82f6;
|
||||||
|
animation: pulse 1.2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#transcript,
|
||||||
|
#assistant,
|
||||||
|
#reasoning,
|
||||||
|
#tools {
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-left: 4px solid #9ca3af;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#transcript {
|
||||||
|
border-left-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
#assistant {
|
||||||
|
border-left-color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
#reasoning {
|
||||||
|
border-left-color: #f59e0b;
|
||||||
|
font-style: italic;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tools {
|
||||||
|
border-left-color: #8b5cf6;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#log {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family: 'SF Mono', monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mic selector & level meter ── */
|
||||||
|
#micRow {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#micSelect {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
#refreshMicsBtn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-wrap label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#levelMeter {
|
||||||
|
flex: 1;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#levelBar {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #22c55e;
|
||||||
|
transition: width 60ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
#levelBar.hot {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
#rmsValue {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
width: 56px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Push-to-talk ── */
|
||||||
|
#pttBtn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2px solid #2563eb;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pttBtn:active,
|
||||||
|
#pttBtn.active {
|
||||||
|
background: #dc2626;
|
||||||
|
color: white;
|
||||||
|
border-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pttBtn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>📹 Video + Voice Agent</h1>
|
||||||
|
<p class="subtitle">Webcam + microphone → multimodal AI (vision + speech)</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<video id="localVideo" autoplay playsinline muted></video>
|
||||||
|
<canvas id="frameCanvas" style="display:none"></canvas>
|
||||||
|
|
||||||
|
<div class="row" style="margin-top:16px">
|
||||||
|
<input type="text" id="wsEndpoint" value="ws://localhost:8081" style="flex:1; min-width:260px" />
|
||||||
|
<button id="connectBtn" class="primary">Connect</button>
|
||||||
|
<button id="disconnectBtn" disabled>Disconnect</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Mic selector ── -->
|
||||||
|
<div class="row" id="micRow">
|
||||||
|
<label>Microphone:</label>
|
||||||
|
<select id="micSelect">
|
||||||
|
<option value="">-- click Refresh --</option>
|
||||||
|
</select>
|
||||||
|
<button id="refreshMicsBtn">🔄 Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Live level meter ── -->
|
||||||
|
<div class="meter-wrap">
|
||||||
|
<label>Mic level:</label>
|
||||||
|
<div id="levelMeter">
|
||||||
|
<div id="levelBar"></div>
|
||||||
|
</div>
|
||||||
|
<span id="rmsValue">0.000</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label>Input mode:</label>
|
||||||
|
<select id="inputMode">
|
||||||
|
<option value="browser-stt">Browser STT</option>
|
||||||
|
<option value="server-whisper">Server Whisper (VAD)</option>
|
||||||
|
<option value="push-to-talk" selected>Push-to-Talk</option>
|
||||||
|
</select>
|
||||||
|
<label>Frames:</label>
|
||||||
|
<select id="frameInterval">
|
||||||
|
<option value="3000">every 3s</option>
|
||||||
|
<option value="5000" selected>every 5s</option>
|
||||||
|
<option value="10000">every 10s</option>
|
||||||
|
<option value="0">manual only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<button id="startMediaBtn" disabled>📹🎤 Start Camera + Mic</button>
|
||||||
|
<button id="stopMediaBtn" disabled>⏹ Stop</button>
|
||||||
|
<button id="captureBtn" disabled>Capture Frame Now</button>
|
||||||
|
<button id="pttBtn" disabled>🎙 Hold to Talk</button>
|
||||||
|
<button id="interruptBtn" class="danger" disabled>✋ Interrupt</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status" id="status">
|
||||||
|
<span class="dot disconnected"></span>Disconnected
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>👤 You said</h3>
|
||||||
|
<div id="transcript">—</div>
|
||||||
|
|
||||||
|
<h3>🤖 Assistant</h3>
|
||||||
|
<div id="assistant"></div>
|
||||||
|
|
||||||
|
<div id="reasoningSection" class="hidden">
|
||||||
|
<h3>💭 Reasoning</h3>
|
||||||
|
<div id="reasoning"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toolsSection" class="hidden">
|
||||||
|
<h3>🛠️ Tools</h3>
|
||||||
|
<div id="tools"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>📜 Log</h3>
|
||||||
|
<div id="log"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// State & Elements
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const els = {
|
||||||
|
wsEndpoint: document.getElementById('wsEndpoint'),
|
||||||
|
connectBtn: document.getElementById('connectBtn'),
|
||||||
|
disconnectBtn: document.getElementById('disconnectBtn'),
|
||||||
|
inputMode: document.getElementById('inputMode'),
|
||||||
|
frameInterval: document.getElementById('frameInterval'),
|
||||||
|
startMediaBtn: document.getElementById('startMediaBtn'),
|
||||||
|
stopMediaBtn: document.getElementById('stopMediaBtn'),
|
||||||
|
captureBtn: document.getElementById('captureBtn'),
|
||||||
|
pttBtn: document.getElementById('pttBtn'),
|
||||||
|
interruptBtn: document.getElementById('interruptBtn'),
|
||||||
|
status: document.getElementById('status'),
|
||||||
|
transcript: document.getElementById('transcript'),
|
||||||
|
assistant: document.getElementById('assistant'),
|
||||||
|
reasoningSec: document.getElementById('reasoningSection'),
|
||||||
|
reasoning: document.getElementById('reasoning'),
|
||||||
|
toolsSec: document.getElementById('toolsSection'),
|
||||||
|
tools: document.getElementById('tools'),
|
||||||
|
log: document.getElementById('log'),
|
||||||
|
video: document.getElementById('localVideo'),
|
||||||
|
canvas: document.getElementById('frameCanvas'),
|
||||||
|
micSelect: document.getElementById('micSelect'),
|
||||||
|
refreshMicsBtn: document.getElementById('refreshMicsBtn'),
|
||||||
|
levelBar: document.getElementById('levelBar'),
|
||||||
|
rmsValue: document.getElementById('rmsValue'),
|
||||||
|
};
|
||||||
|
|
||||||
|
let ws = null;
|
||||||
|
let localStream = null;
|
||||||
|
let audioOnlyStream = null; // ← ADD THIS
|
||||||
|
let mediaRecorder = null;
|
||||||
|
let audioChunks = [];
|
||||||
|
let frameTimer = null;
|
||||||
|
let audioQueue = [];
|
||||||
|
let isPlaying = false;
|
||||||
|
let currentSource = null;
|
||||||
|
|
||||||
|
// Level-meter / VAD audio nodes (use browser-native sample rate)
|
||||||
|
let meterCtx = null; // AudioContext for the meter (always running when media is on)
|
||||||
|
let meterAnalyser = null;
|
||||||
|
let meterSource = null;
|
||||||
|
let meterRafId = null;
|
||||||
|
|
||||||
|
// VAD-specific
|
||||||
|
let silenceStart = null;
|
||||||
|
let recordingStartTime = null;
|
||||||
|
const SPEECH_THRESHOLD = 0.015;
|
||||||
|
const SILENCE_THRESHOLD = 0.008;
|
||||||
|
const SILENCE_DURATION = 1400; // ms
|
||||||
|
const MIN_RECORDING_TIME = 600; // ms
|
||||||
|
|
||||||
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
let recognition = null;
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function log(...args) {
|
||||||
|
const time = new Date().toLocaleTimeString([], { hour12: false });
|
||||||
|
const line = `[${time}] ${args.join(' ')}\n`;
|
||||||
|
els.log.textContent += line;
|
||||||
|
els.log.scrollTop = els.log.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text, state = 'disconnected') {
|
||||||
|
els.status.innerHTML = `<span class="dot ${state}"></span>${text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enable(...btns) {
|
||||||
|
btns.forEach(b => { if (els[b]) els[b].disabled = false; });
|
||||||
|
}
|
||||||
|
function disable(...btns) {
|
||||||
|
btns.forEach(b => { if (els[b]) els[b].disabled = true; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetUI() {
|
||||||
|
els.assistant.textContent = '';
|
||||||
|
els.reasoning.textContent = '';
|
||||||
|
els.tools.textContent = '';
|
||||||
|
els.reasoningSec.classList.add('hidden');
|
||||||
|
els.toolsSec.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// Mic enumeration
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function refreshMics() {
|
||||||
|
try {
|
||||||
|
// Need a temporary stream to get labelled device list
|
||||||
|
const tmp = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
tmp.getTracks().forEach(t => t.stop());
|
||||||
|
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
const mics = devices.filter(d => d.kind === 'audioinput');
|
||||||
|
els.micSelect.innerHTML = '';
|
||||||
|
mics.forEach((m, i) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = m.deviceId;
|
||||||
|
opt.textContent = m.label || `Microphone ${i + 1}`;
|
||||||
|
els.micSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
log(`Found ${mics.length} microphone(s)`);
|
||||||
|
} catch (err) {
|
||||||
|
log('Mic enumeration failed:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
els.refreshMicsBtn.onclick = refreshMics;
|
||||||
|
// Auto-populate on page load
|
||||||
|
refreshMics();
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// Live audio level meter (always-on when media is active)
|
||||||
|
// Uses AnalyserNode + rAF – no ScriptProcessorNode needed.
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function startLevelMeter(stream) {
|
||||||
|
// Use the browser's native sample rate (NO custom sampleRate!)
|
||||||
|
meterCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
meterSource = meterCtx.createMediaStreamSource(stream);
|
||||||
|
meterAnalyser = meterCtx.createAnalyser();
|
||||||
|
meterAnalyser.fftSize = 1024;
|
||||||
|
meterSource.connect(meterAnalyser);
|
||||||
|
// Do NOT connect to destination – we don't want to hear ourselves
|
||||||
|
|
||||||
|
const buf = new Float32Array(meterAnalyser.fftSize);
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
meterAnalyser.getFloatTimeDomainData(buf);
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i];
|
||||||
|
const rms = Math.sqrt(sum / buf.length);
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
const pct = Math.min(rms / 0.15, 1) * 100; // 0.15 is "loud"
|
||||||
|
els.levelBar.style.width = pct + '%';
|
||||||
|
els.levelBar.classList.toggle('hot', rms > SPEECH_THRESHOLD);
|
||||||
|
els.rmsValue.textContent = rms.toFixed(4);
|
||||||
|
|
||||||
|
// If VAD mode is active, drive it from here
|
||||||
|
if (els.inputMode.value === 'server-whisper') {
|
||||||
|
vadTick(rms);
|
||||||
|
}
|
||||||
|
|
||||||
|
meterRafId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
log(`Level meter started (sampleRate=${meterCtx.sampleRate})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopLevelMeter() {
|
||||||
|
if (meterRafId) { cancelAnimationFrame(meterRafId); meterRafId = null; }
|
||||||
|
if (meterSource) { meterSource.disconnect(); meterSource = null; }
|
||||||
|
if (meterAnalyser) { meterAnalyser.disconnect(); meterAnalyser = null; }
|
||||||
|
if (meterCtx) { meterCtx.close(); meterCtx = null; }
|
||||||
|
els.levelBar.style.width = '0%';
|
||||||
|
els.rmsValue.textContent = '0.000';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// Frame capture & send
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function captureFrame(reason = 'timer') {
|
||||||
|
if (!els.video.videoWidth) return;
|
||||||
|
|
||||||
|
const ctx = els.canvas.getContext('2d');
|
||||||
|
els.canvas.width = els.video.videoWidth;
|
||||||
|
els.canvas.height = els.video.videoHeight;
|
||||||
|
ctx.drawImage(els.video, 0, 0);
|
||||||
|
|
||||||
|
const dataUrl = els.canvas.toDataURL('image/webp', 0.78);
|
||||||
|
const base64 = dataUrl.split(',')[1];
|
||||||
|
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'video_frame',
|
||||||
|
sessionId: 'client-main',
|
||||||
|
sequence: Date.now(),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
triggerReason: reason,
|
||||||
|
image: {
|
||||||
|
data: base64,
|
||||||
|
format: 'webp',
|
||||||
|
width: els.canvas.width,
|
||||||
|
height: els.canvas.height
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
log(`Frame sent (${(base64.length / 1000).toFixed(1)} kB) — ${reason}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// Audio playback queue
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function playNext() {
|
||||||
|
if (isPlaying || audioQueue.length === 0) return;
|
||||||
|
isPlaying = true;
|
||||||
|
|
||||||
|
const { bytes, format } = audioQueue.shift();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const buffer = await ctx.decodeAudioData(
|
||||||
|
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length)
|
||||||
|
);
|
||||||
|
const source = ctx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.connect(ctx.destination);
|
||||||
|
currentSource = source;
|
||||||
|
source.onended = () => {
|
||||||
|
currentSource = null;
|
||||||
|
isPlaying = false;
|
||||||
|
ctx.close();
|
||||||
|
playNext();
|
||||||
|
};
|
||||||
|
source.start(0);
|
||||||
|
log(`Playing audio chunk (${bytes.length} bytes, ${format})`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Audio decode/play error:', err);
|
||||||
|
isPlaying = false;
|
||||||
|
playNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// WebSocket
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
const url = els.wsEndpoint.value.trim();
|
||||||
|
if (!url) return log('No endpoint');
|
||||||
|
|
||||||
|
setStatus('Connecting...', 'disconnected');
|
||||||
|
ws = new WebSocket(url);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setStatus('Connected', 'connected');
|
||||||
|
enable('startMediaBtn', 'interruptBtn', 'captureBtn');
|
||||||
|
disable('connectBtn');
|
||||||
|
enable('disconnectBtn');
|
||||||
|
log(`Connected to ${url}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setStatus('Disconnected', 'disconnected');
|
||||||
|
disable('startMediaBtn', 'stopMediaBtn', 'captureBtn', 'interruptBtn', 'pttBtn');
|
||||||
|
enable('connectBtn');
|
||||||
|
disable('disconnectBtn');
|
||||||
|
stopAllMedia();
|
||||||
|
log('Disconnected');
|
||||||
|
ws = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (e) => {
|
||||||
|
log('WebSocket error', e);
|
||||||
|
setStatus('Error', 'disconnected');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
handleMessage(msg);
|
||||||
|
} catch (err) {
|
||||||
|
log('Parse error:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
if (ws) ws.close();
|
||||||
|
stopAllMedia();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// Media (camera + mic)
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function startMedia() {
|
||||||
|
try {
|
||||||
|
const audioConstraint = els.micSelect.value
|
||||||
|
? { deviceId: { exact: els.micSelect.value } }
|
||||||
|
: true;
|
||||||
|
|
||||||
|
localStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { width: { ideal: 640 }, height: { ideal: 480 } },
|
||||||
|
audio: audioConstraint,
|
||||||
|
});
|
||||||
|
|
||||||
|
audioOnlyStream = new MediaStream(localStream.getAudioTracks()); // ← ADD THIS
|
||||||
|
|
||||||
|
|
||||||
|
// Log which mic was actually selected
|
||||||
|
const audioTrack = localStream.getAudioTracks()[0];
|
||||||
|
log(`Mic active: "${audioTrack?.label || 'unknown'}"`);
|
||||||
|
|
||||||
|
els.video.srcObject = localStream;
|
||||||
|
await els.video.play();
|
||||||
|
|
||||||
|
enable('stopMediaBtn', 'pttBtn');
|
||||||
|
disable('startMediaBtn');
|
||||||
|
|
||||||
|
// Start the always-on level meter
|
||||||
|
startLevelMeter(localStream);
|
||||||
|
|
||||||
|
// Periodic frames
|
||||||
|
const intervalMs = Number(els.frameInterval.value);
|
||||||
|
if (intervalMs > 0) {
|
||||||
|
frameTimer = setInterval(() => captureFrame('timer'), intervalMs);
|
||||||
|
log(`Frame capture every ${intervalMs / 1000}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the selected input mode
|
||||||
|
const mode = els.inputMode.value;
|
||||||
|
if (mode === 'browser-stt') {
|
||||||
|
startBrowserSTT();
|
||||||
|
}
|
||||||
|
// VAD and push-to-talk don't need extra init – they're driven by
|
||||||
|
// the level-meter tick and button events respectively.
|
||||||
|
|
||||||
|
setStatus('Listening...', 'listening');
|
||||||
|
log(`Camera + Mic started, input mode: ${mode}`);
|
||||||
|
} catch (err) {
|
||||||
|
log('getUserMedia failed:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAllMedia() {
|
||||||
|
if (frameTimer) { clearInterval(frameTimer); frameTimer = null; }
|
||||||
|
|
||||||
|
stopLevelMeter();
|
||||||
|
|
||||||
|
if (localStream) {
|
||||||
|
localStream.getTracks().forEach(t => t.stop());
|
||||||
|
audioOnlyStream = null;
|
||||||
|
localStream = null;
|
||||||
|
}
|
||||||
|
els.video.srcObject = null;
|
||||||
|
|
||||||
|
if (mediaRecorder?.state === 'recording') mediaRecorder.stop();
|
||||||
|
mediaRecorder = null;
|
||||||
|
|
||||||
|
if (recognition) recognition.stop();
|
||||||
|
recognition = null;
|
||||||
|
|
||||||
|
silenceStart = null;
|
||||||
|
recordingStartTime = null;
|
||||||
|
audioChunks = [];
|
||||||
|
|
||||||
|
disable('stopMediaBtn', 'pttBtn');
|
||||||
|
enable('startMediaBtn');
|
||||||
|
setStatus('Connected', 'connected');
|
||||||
|
log('Media stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// Shared: record a segment from localStream and send it
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function chosenMimeType() {
|
||||||
|
for (const mt of [
|
||||||
|
'audio/webm;codecs=opus',
|
||||||
|
'audio/webm',
|
||||||
|
'audio/ogg;codecs=opus',
|
||||||
|
'audio/mp4',
|
||||||
|
]) {
|
||||||
|
if (MediaRecorder.isTypeSupported(mt)) return mt;
|
||||||
|
}
|
||||||
|
return ''; // let browser pick default
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRecording() {
|
||||||
|
if (mediaRecorder?.state === 'recording') return;
|
||||||
|
if (!audioOnlyStream) { log('No audio stream!'); return; }
|
||||||
|
|
||||||
|
audioChunks = [];
|
||||||
|
recordingStartTime = Date.now();
|
||||||
|
silenceStart = null;
|
||||||
|
|
||||||
|
const mimeType = chosenMimeType();
|
||||||
|
const opts = mimeType ? { mimeType } : undefined;
|
||||||
|
mediaRecorder = new MediaRecorder(audioOnlyStream, opts);
|
||||||
|
|
||||||
|
mediaRecorder.ondataavailable = e => {
|
||||||
|
if (e.data.size > 0) audioChunks.push(e.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.onstop = async () => {
|
||||||
|
const usedMime = mediaRecorder?.mimeType || mimeType || 'audio/webm';
|
||||||
|
if (audioChunks.length === 0) {
|
||||||
|
log('No audio chunks recorded');
|
||||||
|
setStatus('Listening...', 'listening');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob(audioChunks, { type: usedMime });
|
||||||
|
if (blob.size < 800) {
|
||||||
|
log(`Audio too short (${blob.size} bytes), skipping`);
|
||||||
|
setStatus('Listening...', 'listening');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
|
const base64 = btoa(
|
||||||
|
new Uint8Array(arrayBuffer).reduce((d, b) => d + String.fromCharCode(b), '')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'audio', data: base64, format: usedMime }));
|
||||||
|
log(`Sent audio (${(base64.length / 1000).toFixed(1)} kB, ${usedMime})`);
|
||||||
|
els.transcript.textContent = 'Transcribing...';
|
||||||
|
} else {
|
||||||
|
log('WS not connected, audio dropped');
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('Listening...', 'listening');
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.start(100); // timeslice 100ms
|
||||||
|
setStatus('🔴 Recording...', 'speaking');
|
||||||
|
log('Recording started');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
if (mediaRecorder?.state === 'recording') {
|
||||||
|
mediaRecorder.stop();
|
||||||
|
silenceStart = null;
|
||||||
|
recordingStartTime = null;
|
||||||
|
setStatus('Processing...', 'connected');
|
||||||
|
log('Recording stopped, sending...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// VAD (driven from the level-meter rAF loop)
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function vadTick(rms) {
|
||||||
|
if (rms > SPEECH_THRESHOLD) {
|
||||||
|
silenceStart = null;
|
||||||
|
if (!mediaRecorder || mediaRecorder.state !== 'recording') {
|
||||||
|
startRecording();
|
||||||
|
}
|
||||||
|
} else if (rms < SILENCE_THRESHOLD && mediaRecorder?.state === 'recording') {
|
||||||
|
if (!silenceStart) {
|
||||||
|
silenceStart = Date.now();
|
||||||
|
} else if (Date.now() - silenceStart > SILENCE_DURATION) {
|
||||||
|
if (recordingStartTime && (Date.now() - recordingStartTime) > MIN_RECORDING_TIME) {
|
||||||
|
log('Silence → stopping');
|
||||||
|
stopRecording();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// Push-to-Talk
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function pttDown() {
|
||||||
|
if (!localStream) return;
|
||||||
|
els.pttBtn.classList.add('active');
|
||||||
|
startRecording();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pttUp() {
|
||||||
|
els.pttBtn.classList.remove('active');
|
||||||
|
stopRecording();
|
||||||
|
}
|
||||||
|
|
||||||
|
els.pttBtn.addEventListener('mousedown', pttDown);
|
||||||
|
els.pttBtn.addEventListener('mouseup', pttUp);
|
||||||
|
els.pttBtn.addEventListener('mouseleave', pttUp);
|
||||||
|
els.pttBtn.addEventListener('touchstart', e => { e.preventDefault(); pttDown(); });
|
||||||
|
els.pttBtn.addEventListener('touchend', e => { e.preventDefault(); pttUp(); });
|
||||||
|
|
||||||
|
// Spacebar push-to-talk (only when mode is push-to-talk)
|
||||||
|
let spaceHeld = false;
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.code === 'Space' && !spaceHeld && els.inputMode.value === 'push-to-talk'
|
||||||
|
&& localStream && !e.target.matches('input, textarea, select')) {
|
||||||
|
e.preventDefault();
|
||||||
|
spaceHeld = true;
|
||||||
|
pttDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('keyup', e => {
|
||||||
|
if (e.code === 'Space' && spaceHeld) {
|
||||||
|
e.preventDefault();
|
||||||
|
spaceHeld = false;
|
||||||
|
pttUp();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// Browser STT
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function startBrowserSTT() {
|
||||||
|
if (!SpeechRecognition) { log('Web Speech API not supported'); return; }
|
||||||
|
recognition = new SpeechRecognition();
|
||||||
|
recognition.continuous = true;
|
||||||
|
recognition.interimResults = true;
|
||||||
|
recognition.lang = 'en-US';
|
||||||
|
|
||||||
|
recognition.onresult = e => {
|
||||||
|
const transcript = Array.from(e.results).map(r => r[0].transcript).join('');
|
||||||
|
els.transcript.textContent = transcript;
|
||||||
|
if (e.results[0].isFinal) sendTranscript(transcript);
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onerror = e => log('STT error:', e.error);
|
||||||
|
recognition.start();
|
||||||
|
log('Browser STT started');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// Sending transcript / interrupt
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function sendTranscript(text) {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
ws.send(JSON.stringify({ type: 'transcript', text }));
|
||||||
|
log(`Sent transcript: ${text}`);
|
||||||
|
resetUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
function interrupt() {
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'interrupt', reason: 'user_button' }));
|
||||||
|
log('Interrupt sent');
|
||||||
|
}
|
||||||
|
audioQueue = [];
|
||||||
|
if (currentSource) { currentSource.stop(); currentSource = null; }
|
||||||
|
isPlaying = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// Server → Client messages
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleMessage(msg) {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'transcription_result':
|
||||||
|
els.transcript.textContent = msg.text || '(empty)';
|
||||||
|
log(`Transcription: ${msg.text}`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'text_delta':
|
||||||
|
els.assistant.textContent += msg.text || '';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'reasoning_delta':
|
||||||
|
els.reasoningSec.classList.remove('hidden');
|
||||||
|
els.reasoning.textContent += msg.text || '';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tool_call':
|
||||||
|
case 'tool_result':
|
||||||
|
els.toolsSec.classList.remove('hidden');
|
||||||
|
els.tools.innerHTML += `<div>${msg.type}: ${msg.toolName || '?'} → ${JSON.stringify(msg.result || msg.input || {})}</div>`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'audio_chunk':
|
||||||
|
case 'audio':
|
||||||
|
const bytes = Uint8Array.from(atob(msg.data), c => c.charCodeAt(0));
|
||||||
|
audioQueue.push({ bytes, format: msg.format || 'mp3' });
|
||||||
|
playNext();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'speech_interrupted':
|
||||||
|
audioQueue = [];
|
||||||
|
if (currentSource) currentSource.stop();
|
||||||
|
isPlaying = false;
|
||||||
|
log(`Speech interrupted: ${msg.reason || '?'}`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'response_complete':
|
||||||
|
log('Response complete');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'capture_frame':
|
||||||
|
log(`Server requested frame: ${msg.reason}`);
|
||||||
|
captureFrame(msg.reason || 'server_request');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'frame_ack':
|
||||||
|
break; // silent
|
||||||
|
|
||||||
|
case 'session_init':
|
||||||
|
log(`Session: ${msg.sessionId}`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'stream_start':
|
||||||
|
resetUI();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'stream_finish':
|
||||||
|
log(`Stream finished: ${msg.finishReason}`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'speech_stream_start':
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'speech_stream_end':
|
||||||
|
log('Speech done');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
log(`ERROR: ${msg.error}`);
|
||||||
|
console.error('Server error:', msg.error);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'transcription_error':
|
||||||
|
log(`Transcription error: ${msg.error}`);
|
||||||
|
els.transcript.textContent = `Error: ${msg.error}`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
if (msg.type?.includes('stream') || msg.type?.includes('step')) {
|
||||||
|
// verbose stream events – log quietly
|
||||||
|
} else {
|
||||||
|
log(`[${msg.type}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// Event listeners
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
els.connectBtn.onclick = connect;
|
||||||
|
els.disconnectBtn.onclick = disconnect;
|
||||||
|
els.startMediaBtn.onclick = startMedia;
|
||||||
|
els.stopMediaBtn.onclick = stopAllMedia;
|
||||||
|
els.captureBtn.onclick = () => captureFrame('manual');
|
||||||
|
els.interruptBtn.onclick = interrupt;
|
||||||
|
|
||||||
|
els.frameInterval.onchange = () => {
|
||||||
|
if (frameTimer) {
|
||||||
|
clearInterval(frameTimer);
|
||||||
|
const ms = Number(els.frameInterval.value);
|
||||||
|
if (ms > 0) frameTimer = setInterval(() => captureFrame('timer'), ms);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('wsEndpoint').addEventListener('keypress', e => {
|
||||||
|
if (e.key === 'Enter') connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
161
example/ws-server-video.ts
Normal file
161
example/ws-server-video.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
// ws-server-video.ts
|
||||||
|
import "dotenv/config";
|
||||||
|
import { WebSocketServer } from "ws";
|
||||||
|
import { VideoAgent } from "../src/VideoAgent"; // adjust path
|
||||||
|
import { tool } from "ai";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { openai } from "@ai-sdk/openai";
|
||||||
|
import { mkdirSync, writeFileSync } from "fs";
|
||||||
|
import { join, dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
// ── Frame saving ────────────────────────────────────────────────────────
|
||||||
|
const __dirname = typeof import.meta.dirname === "string"
|
||||||
|
? import.meta.dirname
|
||||||
|
: dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const FRAMES_DIR = join(__dirname, "frames");
|
||||||
|
mkdirSync(FRAMES_DIR, { recursive: true });
|
||||||
|
console.log(`[video-ws] Saving received frames to ${FRAMES_DIR}/`);
|
||||||
|
|
||||||
|
let frameCounter = 0;
|
||||||
|
|
||||||
|
function saveFrame(msg: {
|
||||||
|
sequence?: number;
|
||||||
|
timestamp?: number;
|
||||||
|
triggerReason?: string;
|
||||||
|
image: { data: string; format?: string; width?: number; height?: number };
|
||||||
|
}) {
|
||||||
|
const idx = frameCounter++;
|
||||||
|
const ext = msg.image.format === "jpeg" ? "jpg" : (msg.image.format || "webp");
|
||||||
|
const ts = new Date(msg.timestamp ?? Date.now())
|
||||||
|
.toISOString()
|
||||||
|
.replace(/[:.]/g, "-");
|
||||||
|
const filename = `frame_${String(idx).padStart(5, "0")}_${ts}.${ext}`;
|
||||||
|
const filepath = join(FRAMES_DIR, filename);
|
||||||
|
|
||||||
|
const buf = Buffer.from(msg.image.data, "base64");
|
||||||
|
writeFileSync(filepath, buf);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[frames] Saved ${filename} (${(buf.length / 1024).toFixed(1)} kB` +
|
||||||
|
`${msg.image.width ? `, ${msg.image.width}×${msg.image.height}` : ""}` +
|
||||||
|
`, ${msg.triggerReason ?? "unknown"})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = process.env.VIDEO_WS_ENDPOINT || "ws://localhost:8081";
|
||||||
|
const url = new URL(endpoint);
|
||||||
|
const port = Number(url.port || 8081);
|
||||||
|
const host = url.hostname || "localhost";
|
||||||
|
|
||||||
|
|
||||||
|
// ── Tools (same as demo.ts) ────────────────────────────────────────────
|
||||||
|
const weatherTool = tool({
|
||||||
|
description: "Get the weather in a location",
|
||||||
|
inputSchema: z.object({
|
||||||
|
location: z.string().describe("The location to get the weather for"),
|
||||||
|
}),
|
||||||
|
execute: async ({ location }) => ({
|
||||||
|
location,
|
||||||
|
temperature: 72 + Math.floor(Math.random() * 21) - 10,
|
||||||
|
conditions: ["sunny", "cloudy", "rainy", "partly cloudy"][
|
||||||
|
Math.floor(Math.random() * 4)
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeTool = tool({
|
||||||
|
description: "Get the current time",
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
execute: async () => ({
|
||||||
|
time: new Date().toLocaleTimeString(),
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const wss = new WebSocketServer({ port, host });
|
||||||
|
|
||||||
|
wss.on("listening", () => {
|
||||||
|
console.log(`[video-ws] listening on ${endpoint}`);
|
||||||
|
console.log(`[video-ws] Open video-client.html and connect → ${endpoint}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
wss.on("connection", (socket) => {
|
||||||
|
console.log("[video-ws] ✓ client connected");
|
||||||
|
|
||||||
|
const agent = new VideoAgent({
|
||||||
|
model: openai("gpt-4o"), // or gpt-4o-mini, claude-3.5-sonnet, gemini-1.5-flash…
|
||||||
|
transcriptionModel: openai.transcription("whisper-1"),
|
||||||
|
speechModel: openai.speech("gpt-4o-mini-tts"),
|
||||||
|
instructions: `You are a helpful video+voice assistant.
|
||||||
|
You can SEE what the user is showing via webcam.
|
||||||
|
Describe what you see when it helps answer the question.
|
||||||
|
Keep spoken answers concise and natural.`,
|
||||||
|
voice: "alloy",
|
||||||
|
streamingSpeech: {
|
||||||
|
minChunkSize: 25,
|
||||||
|
maxChunkSize: 140,
|
||||||
|
parallelGeneration: true,
|
||||||
|
maxParallelRequests: 3,
|
||||||
|
},
|
||||||
|
tools: { getWeather: weatherTool, getTime: timeTool },
|
||||||
|
// Tune these depending on your budget & latency goals
|
||||||
|
maxContextFrames: 6, // very important — each frame ≈ 100–400 tokens
|
||||||
|
maxFrameInputSize: 2_500_000, // ~2.5 MB
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reuse most of the same event logging you have in ws-server.ts
|
||||||
|
agent.on("text", (data: { role: string; text: string }) => {
|
||||||
|
console.log(`[video] Text (${data.role}): ${data.text?.substring(0, 100)}...`);
|
||||||
|
});
|
||||||
|
agent.on("chunk:text_delta", (data: { id: string; text: string }) => {
|
||||||
|
process.stdout.write(data.text || "");
|
||||||
|
});
|
||||||
|
agent.on("frame_received", ({ sequence, size, dimensions, triggerReason }) => {
|
||||||
|
console.log(`[video] Frame #${sequence} (${triggerReason}) ${size / 1024 | 0} kB ${dimensions.width}×${dimensions.height}`);
|
||||||
|
});
|
||||||
|
agent.on("frame_requested", ({ reason }) => console.log(`[video] Requested frame: ${reason}`));
|
||||||
|
|
||||||
|
// Audio and transcription events
|
||||||
|
agent.on("audio_received", ({ size, format }) => {
|
||||||
|
console.log(`[video] Audio received: ${size} bytes, format: ${format}`);
|
||||||
|
});
|
||||||
|
agent.on("transcription", ({ text, language }) => {
|
||||||
|
console.log(`[video] Transcription: "${text}" (${language || "unknown"})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Speech events
|
||||||
|
agent.on("speech_start", () => console.log(`[video] Speech started`));
|
||||||
|
agent.on("speech_complete", () => console.log(`[video] Speech complete`));
|
||||||
|
agent.on("audio_chunk", ({ chunkId, text }) => {
|
||||||
|
console.log(`[video] Audio chunk #${chunkId}: "${text?.substring(0, 50)}..."`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
agent.on("error", (error: Error) => {
|
||||||
|
console.error(`[video] ERROR:`, error);
|
||||||
|
});
|
||||||
|
agent.on("warning", (warning: string) => {
|
||||||
|
console.warn(`[video] WARNING:`, warning);
|
||||||
|
});
|
||||||
|
|
||||||
|
agent.on("disconnected", () => {
|
||||||
|
agent.destroy();
|
||||||
|
console.log("[video-ws] ✗ client disconnected (agent destroyed)");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Intercept raw messages to save frames to disk ────────────────────
|
||||||
|
socket.on("message", (raw) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(raw.toString());
|
||||||
|
if (msg.type === "video_frame" && msg.image?.data) {
|
||||||
|
saveFrame(msg);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// not JSON — ignore, agent will handle binary etc.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The crucial line — same as VoiceAgent
|
||||||
|
agent.handleSocket(socket);
|
||||||
|
});
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"demo": "tsx example/demo.ts",
|
"demo": "tsx example/demo.ts",
|
||||||
"ws:server": "tsx example/ws-server.ts",
|
"ws:server": "tsx example/ws-server.ts",
|
||||||
"client": "node example/serve-client.js",
|
"client": "node example/serve-client.js",
|
||||||
|
"ws:video": "tsx example/ws-server-video.ts",
|
||||||
"prepublishOnly": "pnpm build"
|
"prepublishOnly": "pnpm build"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ const DEFAULT_VIDEO_AGENT_CONFIG: VideoAgentConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface VideoAgentOptions {
|
export interface VideoAgentOptions {
|
||||||
|
/**
|
||||||
|
* AI SDK Model for chat. Must be a vision-enabled model (e.g., openai('gpt-4o'),
|
||||||
|
* anthropic('claude-3.5-sonnet'), google('gemini-1.5-pro')) to process video frames.
|
||||||
|
*/
|
||||||
model: LanguageModel; // AI SDK Model for chat (e.g., openai('gpt-4o'))
|
model: LanguageModel; // AI SDK Model for chat (e.g., openai('gpt-4o'))
|
||||||
transcriptionModel?: TranscriptionModel; // AI SDK Transcription Model (e.g., openai.transcription('whisper-1'))
|
transcriptionModel?: TranscriptionModel; // AI SDK Transcription Model (e.g., openai.transcription('whisper-1'))
|
||||||
speechModel?: SpeechModel; // AI SDK Speech Model (e.g., openai.speech('gpt-4o-mini-tts'))
|
speechModel?: SpeechModel; // AI SDK Speech Model (e.g., openai.speech('gpt-4o-mini-tts'))
|
||||||
@@ -285,6 +289,7 @@ Use tools when needed to provide accurate information.`;
|
|||||||
// Handle raw audio data that needs transcription
|
// Handle raw audio data that needs transcription
|
||||||
case "audio":
|
case "audio":
|
||||||
if (typeof message.data !== "string" || !message.data) {
|
if (typeof message.data !== "string" || !message.data) {
|
||||||
|
console.warn("Received empty or invalid audio message");
|
||||||
this.emit("warning", "Received empty or invalid audio message");
|
this.emit("warning", "Received empty or invalid audio message");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -293,9 +298,15 @@ Use tools when needed to provide accurate information.`;
|
|||||||
// Force capture current frame when user speaks
|
// Force capture current frame when user speaks
|
||||||
this.requestFrameCapture("user_request");
|
this.requestFrameCapture("user_request");
|
||||||
console.log(
|
console.log(
|
||||||
`Received audio data (${message.data.length / 1000}KB) for processing, format: ${message.format || "unknown"}`
|
`[audio handler] Received audio data (${(message.data.length / 1000).toFixed(1)}KB) for processing, format: ${message.format || "unknown"}`
|
||||||
);
|
);
|
||||||
await this.processAudioInput(message);
|
try {
|
||||||
|
await this.processAudioInput(message);
|
||||||
|
console.log(`[audio handler] processAudioInput completed`);
|
||||||
|
} catch (audioError) {
|
||||||
|
console.error(`[audio handler] Error in processAudioInput:`, audioError);
|
||||||
|
this.emit("error", audioError);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// Handle video frame from client
|
// Handle video frame from client
|
||||||
@@ -850,13 +861,20 @@ Use tools when needed to provide accurate information.`;
|
|||||||
/**
|
/**
|
||||||
* Process incoming audio data: transcribe and generate response
|
* Process incoming audio data: transcribe and generate response
|
||||||
*/
|
*/
|
||||||
private async processAudioInput(audioMessage: AudioData): Promise<void> {
|
private async processAudioInput(audioMessage: AudioData | { type: string; data: string; format?: string; sessionId?: string }): Promise<void> {
|
||||||
if (!this.transcriptionModel) {
|
if (!this.transcriptionModel) {
|
||||||
this.emit("error", new Error("Transcription model not configured for audio input"));
|
const error = new Error("Transcription model not configured for audio input");
|
||||||
|
console.error(error.message);
|
||||||
|
this.emit("error", error);
|
||||||
|
this.sendWebSocketMessage({
|
||||||
|
type: "error",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log(`[processAudioInput] Starting audio processing, data length: ${audioMessage.data?.length || 0}`);
|
||||||
const audioBuffer = Buffer.from(audioMessage.data, "base64");
|
const audioBuffer = Buffer.from(audioMessage.data, "base64");
|
||||||
|
|
||||||
if (audioBuffer.length > this.maxAudioInputSize) {
|
if (audioBuffer.length > this.maxAudioInputSize) {
|
||||||
@@ -877,19 +895,23 @@ Use tools when needed to provide accurate information.`;
|
|||||||
this.emit("audio_received", {
|
this.emit("audio_received", {
|
||||||
size: audioBuffer.length,
|
size: audioBuffer.length,
|
||||||
format: audioMessage.format,
|
format: audioMessage.format,
|
||||||
sessionId: audioMessage.sessionId,
|
sessionId: audioMessage.sessionId || this.sessionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Processing audio input: ${audioBuffer.length} bytes, format: ${audioMessage.format || "unknown"}`
|
`[processAudioInput] Processing audio: ${audioBuffer.length} bytes, format: ${audioMessage.format || "unknown"}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log(`[processAudioInput] Calling transcribeAudio...`);
|
||||||
const transcribedText = await this.transcribeAudio(audioBuffer);
|
const transcribedText = await this.transcribeAudio(audioBuffer);
|
||||||
console.log(`Transcribed text: "${transcribedText}"`);
|
console.log(`[processAudioInput] Transcribed text: "${transcribedText}"`);
|
||||||
|
|
||||||
if (transcribedText.trim()) {
|
if (transcribedText.trim()) {
|
||||||
|
console.log(`[processAudioInput] Enqueueing text input: "${transcribedText}"`);
|
||||||
await this.enqueueTextInput(transcribedText);
|
await this.enqueueTextInput(transcribedText);
|
||||||
|
console.log(`[processAudioInput] Text input processing complete`);
|
||||||
} else {
|
} else {
|
||||||
|
console.warn(`[processAudioInput] Transcription returned empty text`);
|
||||||
this.emit("warning", "Transcription returned empty text");
|
this.emit("warning", "Transcription returned empty text");
|
||||||
this.sendWebSocketMessage({
|
this.sendWebSocketMessage({
|
||||||
type: "transcription_error",
|
type: "transcription_error",
|
||||||
@@ -897,7 +919,7 @@ Use tools when needed to provide accurate information.`;
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to process audio input:", error);
|
console.error("[processAudioInput] Failed to process audio input:", error);
|
||||||
this.emit("error", error);
|
this.emit("error", error);
|
||||||
this.sendWebSocketMessage({
|
this.sendWebSocketMessage({
|
||||||
type: "transcription_error",
|
type: "transcription_error",
|
||||||
@@ -1049,28 +1071,38 @@ Use tools when needed to provide accurate information.`;
|
|||||||
* Drain the input queue, processing one request at a time
|
* Drain the input queue, processing one request at a time
|
||||||
*/
|
*/
|
||||||
private async drainInputQueue(): Promise<void> {
|
private async drainInputQueue(): Promise<void> {
|
||||||
if (this.processingQueue) return;
|
if (this.processingQueue) {
|
||||||
|
console.log(`[drainInputQueue] Already processing, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.processingQueue = true;
|
this.processingQueue = true;
|
||||||
|
console.log(`[drainInputQueue] Starting to drain queue, ${this.inputQueue.length} items`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (this.inputQueue.length > 0) {
|
while (this.inputQueue.length > 0) {
|
||||||
const item = this.inputQueue.shift()!;
|
const item = this.inputQueue.shift()!;
|
||||||
|
console.log(`[drainInputQueue] Processing item: text="${item.text?.substring(0, 50)}...", hasFrame=${!!item.frame}`);
|
||||||
try {
|
try {
|
||||||
let result: string;
|
let result: string;
|
||||||
if (item.frame && item.text) {
|
if (item.frame && item.text) {
|
||||||
|
console.log(`[drainInputQueue] Calling processMultimodalInput`);
|
||||||
result = await this.processMultimodalInput(item.text, item.frame);
|
result = await this.processMultimodalInput(item.text, item.frame);
|
||||||
} else if (item.text) {
|
} else if (item.text) {
|
||||||
|
console.log(`[drainInputQueue] Calling processUserInput`);
|
||||||
result = await this.processUserInput(item.text);
|
result = await this.processUserInput(item.text);
|
||||||
} else {
|
} else {
|
||||||
result = "";
|
result = "";
|
||||||
}
|
}
|
||||||
|
console.log(`[drainInputQueue] Got result: "${result?.substring(0, 100)}..."`);
|
||||||
item.resolve(result);
|
item.resolve(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(`[drainInputQueue] Error processing item:`, error);
|
||||||
item.reject(error);
|
item.reject(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.processingQueue = false;
|
this.processingQueue = false;
|
||||||
|
console.log(`[drainInputQueue] Done draining queue`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1173,6 +1205,7 @@ Use tools when needed to provide accurate information.`;
|
|||||||
* Process user input with streaming text generation
|
* Process user input with streaming text generation
|
||||||
*/
|
*/
|
||||||
private async processUserInput(text: string): Promise<string> {
|
private async processUserInput(text: string): Promise<string> {
|
||||||
|
console.log(`[processUserInput] Starting with text: "${text}"`);
|
||||||
this.isProcessing = true;
|
this.isProcessing = true;
|
||||||
this.currentStreamAbortController = new AbortController();
|
this.currentStreamAbortController = new AbortController();
|
||||||
const streamAbortSignal = this.currentStreamAbortController.signal;
|
const streamAbortSignal = this.currentStreamAbortController.signal;
|
||||||
@@ -1182,6 +1215,7 @@ Use tools when needed to provide accurate information.`;
|
|||||||
|
|
||||||
// Check if we have current frame data - if so, include it
|
// Check if we have current frame data - if so, include it
|
||||||
const hasVisualContext = !!this.currentFrameData;
|
const hasVisualContext = !!this.currentFrameData;
|
||||||
|
console.log(`[processUserInput] hasVisualContext: ${hasVisualContext}`);
|
||||||
|
|
||||||
let messages: ModelMessage[];
|
let messages: ModelMessage[];
|
||||||
|
|
||||||
@@ -1207,6 +1241,10 @@ Use tools when needed to provide accurate information.`;
|
|||||||
|
|
||||||
this.trimHistory();
|
this.trimHistory();
|
||||||
|
|
||||||
|
console.log(`[processUserInput] Calling streamText with ${messages.length} messages`);
|
||||||
|
console.log(`[processUserInput] Model:`, this.model);
|
||||||
|
console.log(`[processUserInput] Tools:`, Object.keys(this.tools));
|
||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: this.model,
|
model: this.model,
|
||||||
system: this.instructions,
|
system: this.instructions,
|
||||||
@@ -1218,6 +1256,7 @@ Use tools when needed to provide accurate information.`;
|
|||||||
this.handleStreamChunk(chunk);
|
this.handleStreamChunk(chunk);
|
||||||
},
|
},
|
||||||
onFinish: async (event) => {
|
onFinish: async (event) => {
|
||||||
|
console.log(`[processUserInput] onFinish called`);
|
||||||
for (const step of event.steps) {
|
for (const step of event.steps) {
|
||||||
for (const toolResult of step.toolResults) {
|
for (const toolResult of step.toolResults) {
|
||||||
this.emit("tool_result", {
|
this.emit("tool_result", {
|
||||||
@@ -1229,11 +1268,12 @@ Use tools when needed to provide accurate information.`;
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: ({ error }) => {
|
onError: ({ error }) => {
|
||||||
console.error("Stream error:", error);
|
console.error("[processUserInput] Stream error:", error);
|
||||||
this.emit("error", error);
|
this.emit("error", error);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(`[processUserInput] Calling processStreamResult`);
|
||||||
return await this.processStreamResult(result);
|
return await this.processStreamResult(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.pendingTextBuffer = "";
|
this.pendingTextBuffer = "";
|
||||||
|
|||||||
Reference in New Issue
Block a user