dumont-talker / web /index_video.html
marcosremar2
Initial commit: dumont-talker speech-to-speech avatar
71e110b
Raw
History Blame
45.7 kB
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<title>AI Avatar Assistant</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
background: #0a0a0f;
min-height: 100vh;
color: #fff;
display: flex;
justify-content: center;
align-items: center;
}
.app-container {
width: 100%;
max-width: 420px;
padding: 20px;
}
/* Avatar Section */
.avatar-section {
position: relative;
width: 100%;
aspect-ratio: 9/16;
max-height: 75vh;
border-radius: 32px;
overflow: hidden;
background: linear-gradient(145deg, #1a1a2e, #0f0f1a);
box-shadow:
0 30px 60px rgba(0,0,0,0.5),
0 0 0 1px rgba(255,255,255,0.05),
inset 0 1px 0 rgba(255,255,255,0.1);
}
.avatar-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 32px;
padding: 2px;
background: linear-gradient(145deg, rgba(255,255,255,0.1), transparent);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
z-index: 100;
}
.avatar-section video,
.avatar-section canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 32px;
}
#idle-video {
z-index: 5;
opacity: 1;
transition: opacity 0.1s ease-out;
}
#idle-video.hidden {
opacity: 0;
pointer-events: none;
}
#videoCanvas {
z-index: 10;
opacity: 0;
transition: opacity 0.1s ease-out;
}
#videoCanvas.active {
opacity: 1;
}
#avatar-video {
z-index: 15;
display: none;
}
#avatar-video.active {
display: block;
}
/* Status indicator */
.status-indicator {
position: absolute;
top: 16px;
left: 16px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(10px);
border-radius: 20px;
font-size: 0.75rem;
font-weight: 500;
z-index: 50;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #22c55e;
}
.status-dot.recording {
background: #ef4444;
animation: pulse 0.5s ease-in-out infinite;
}
.status-dot.processing {
background: #f59e0b;
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.2); }
}
/* Response text overlay */
.response-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 60px 20px 20px;
background: linear-gradient(transparent, rgba(0,0,0,0.9));
z-index: 40;
}
.response-text {
font-size: 1rem;
line-height: 1.5;
color: #fff;
text-align: center;
min-height: 48px;
}
/* Loading overlay - hidden, not used */
.loading-overlay {
display: none !important;
}
/* Controls */
.controls {
display: flex;
gap: 12px;
margin-top: 20px;
}
.record-btn {
flex: 1;
padding: 18px 24px;
border: none;
border-radius: 16px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
background: #fff;
color: #000;
}
.record-btn:hover {
transform: scale(1.02);
box-shadow: 0 8px 30px rgba(255,255,255,0.2);
}
.record-btn:active {
transform: scale(0.98);
}
.record-btn.recording {
background: #ef4444;
color: #fff;
}
.record-btn:disabled {
background: #333;
color: #666;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.record-btn.vad-mode {
background: linear-gradient(145deg, #22c55e, #16a34a);
color: #fff;
animation: vad-pulse 2s ease-in-out infinite;
}
.record-btn.vad-mode.recording {
background: linear-gradient(145deg, #ef4444, #dc2626);
animation: none;
}
@keyframes vad-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); }
50% { box-shadow: 0 0 0 15px rgba(34, 197, 94, 0); }
}
.record-btn svg {
width: 20px;
height: 20px;
}
.settings-btn {
padding: 18px;
border: none;
border-radius: 16px;
background: rgba(255,255,255,0.1);
color: #fff;
cursor: pointer;
transition: all 0.2s;
}
.settings-btn:hover {
background: rgba(255,255,255,0.15);
}
/* Stats bar */
.stats-bar {
display: flex;
justify-content: center;
gap: 20px;
margin-top: 16px;
font-size: 0.75rem;
color: rgba(255,255,255,0.5);
}
.stat {
display: flex;
align-items: center;
gap: 4px;
}
.stat-value {
color: #fff;
font-weight: 600;
}
/* Settings Modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.9);
backdrop-filter: blur(10px);
z-index: 100;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: #1a1a1f;
padding: 28px;
border-radius: 24px;
width: 90%;
max-width: 360px;
border: 1px solid rgba(255,255,255,0.1);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.modal-header h3 {
font-size: 1.2rem;
font-weight: 600;
}
.modal-close-x {
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 4px;
}
.setting-group {
margin-bottom: 20px;
}
.setting-group label {
display: block;
margin-bottom: 8px;
color: rgba(255,255,255,0.6);
font-size: 0.85rem;
}
.setting-group select {
width: 100%;
padding: 14px 16px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.1);
background: rgba(255,255,255,0.05);
color: #fff;
font-size: 1rem;
appearance: none;
cursor: pointer;
}
.setting-group select option {
background: #1a1a1f;
}
.modal-save {
width: 100%;
padding: 16px;
background: #fff;
color: #000;
border: none;
border-radius: 12px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
margin-top: 8px;
}
.modal-save:hover {
background: #f0f0f0;
}
/* Hidden elements */
#audioPlayer { display: none; }
</style>
</head>
<body>
<div class="app-container">
<div class="avatar-section" id="avatar-section">
<!-- Idle video plays continuously when not talking -->
<video id="idle-video" autoplay loop muted playsinline preload="auto" poster="static/avatar_thumb.jpg" src="avatar_videos/idle.mp4"></video>
<!-- Canvas for streaming frames -->
<canvas id="videoCanvas" width="1080" height="1920"></canvas>
<!-- Video for complete video playback -->
<video id="avatar-video" playsinline></video>
<!-- Status indicator -->
<div class="status-indicator">
<div class="status-dot" id="status-dot"></div>
<span id="status-text">Pronto</span>
</div>
<!-- Loading overlay -->
<div class="loading-overlay" id="loading-overlay">
<div class="spinner"></div>
<div class="loading-text" id="loading-text">Processando...</div>
</div>
<!-- Response text -->
<div class="response-overlay">
<div class="response-text" id="response-text">Pressione o botao e fale comigo</div>
</div>
</div>
<div class="controls">
<button class="record-btn" id="record-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v4M8 23h8"/>
</svg>
<span id="btn-text">Falar</span>
</button>
<button class="settings-btn" id="settings-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
</svg>
</button>
</div>
<div class="stats-bar" id="stats-bar">
<div class="stat">Frames: <span class="stat-value" id="frame-count">0</span></div>
<div class="stat">FPS: <span class="stat-value" id="fps-display">0</span></div>
<div class="stat" onclick="toggleMetricsPanel()" style="cursor:pointer">📊</div>
</div>
<div id="metrics-panel" style="display:none; position:fixed; bottom:60px; left:10px; right:10px; background:rgba(0,0,0,0.9); padding:10px; border-radius:8px; font-size:11px; max-height:200px; overflow:auto; z-index:1000;">
<div style="display:flex; justify-content:space-between; margin-bottom:5px;">
<b>Metricas</b>
<span onclick="toggleMetricsPanel()" style="cursor:pointer"></span>
</div>
<div id="metrics-content">Aguardando...</div>
</div>
</div>
<!-- Settings Modal -->
<div class="modal" id="settings-modal">
<div class="modal-content">
<div class="modal-header">
<h3>Configuracoes</h3>
<button class="modal-close-x" id="modal-close-x">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="setting-group">
<label>Resolucao</label>
<select id="resolution-select">
<option value="128">128px - Ultra rapido</option>
<option value="192">192px - Rapido</option>
<option value="256" selected>256px - Padrao</option>
</select>
</div>
<div class="setting-group">
<label>Batch Size</label>
<select id="batch-select">
<option value="8">8 - Menor latencia</option>
<option value="16" selected>16 - Balanceado</option>
<option value="32">32 - Mais rapido</option>
</select>
</div>
<div class="setting-group">
<label>Modo de Entrada</label>
<select id="input-mode-select">
<option value="push" selected>Push-to-Talk (Segurar botao)</option>
<option value="vad">VAD - Deteccao automatica de voz</option>
</select>
</div>
<button class="modal-save" id="modal-save">Salvar</button>
</div>
</div>
<audio id="audioPlayer" preload="none"></audio>
<script>
// Elements
const avatarSection = document.getElementById('avatar-section');
const idleVideo = document.getElementById('idle-video');
const videoCanvas = document.getElementById('videoCanvas');
const ctx = videoCanvas.getContext('2d');
const avatarVideo = document.getElementById('avatar-video');
const statusDot = document.getElementById('status-dot');
const statusText = document.getElementById('status-text');
const loadingOverlay = document.getElementById('loading-overlay');
const loadingText = document.getElementById('loading-text');
const responseText = document.getElementById('response-text');
const recordBtn = document.getElementById('record-btn');
const btnText = document.getElementById('btn-text');
const settingsBtn = document.getElementById('settings-btn');
const settingsModal = document.getElementById('settings-modal');
const modalCloseX = document.getElementById('modal-close-x');
const modalSave = document.getElementById('modal-save');
const resolutionSelect = document.getElementById('resolution-select');
const batchSelect = document.getElementById('batch-select');
const inputModeSelect = document.getElementById('input-mode-select');
const frameCountEl = document.getElementById('frame-count');
const fpsDisplay = document.getElementById('fps-display');
const audioPlayer = document.getElementById('audioPlayer');
// State
let ws = null;
let mediaRecorder = null;
let audioChunks = [];
let isRecording = false;
let isPlaying = false;
let inputMode = 'push'; // 'push' or 'vad'
// VAD state
let audioContext = null;
let analyser = null;
let vadStream = null;
let vadActive = false;
let silenceTimer = null;
const VAD_THRESHOLD = 15; // Volume threshold to detect speech
const SILENCE_DELAY = 1500; // ms of silence before stopping
// Streaming state
let frameBuffer = []; // Raw base64 data
let decodedFrames = []; // Pre-decoded ImageBitmap objects
let framesReceived = 0;
let totalFrames = 0;
let targetFps = 30;
let frameStartTime = 0;
let audioReady = false;
let audioLoaded = false; // Audio data fully loaded
let lastRenderedFrame = -1;
let idleTimeoutId = null;
const MIN_BUFFER_FRAMES = 5; // Minimum frames before starting playback
let firstFrameRendered = false;
// Client Metrics
class ClientMetrics {
constructor() { this.reset(); }
reset() {
this.startTime = Date.now();
this.events = {};
this.frameReceiveTimes = [];
this.frameRenderTimes = [];
this.drops = 0;
}
mark(event) {
this.events[event] = Date.now() - this.startTime;
console.log(`[METRICS] ${event}: ${this.events[event]}ms`);
}
frameReceived(index) { this.frameReceiveTimes.push({i: index, t: Date.now() - this.startTime}); }
frameRendered(index) { this.frameRenderTimes.push({i: index, t: Date.now() - this.startTime}); }
frameDrop() { this.drops++; }
getSummary() {
const avgGap = this.frameReceiveTimes.length > 1 ? (this.frameReceiveTimes[this.frameReceiveTimes.length-1].t - this.frameReceiveTimes[0].t) / (this.frameReceiveTimes.length - 1) : 0;
return { events: this.events, received: this.frameReceiveTimes.length, rendered: this.frameRenderTimes.length, drops: this.drops, avgGapMs: Math.round(avgGap), totalMs: Date.now() - this.startTime };
}
getFullData() {
return {
events: this.events,
frameReceiveTimes: this.frameReceiveTimes,
frameRenderTimes: this.frameRenderTimes,
drops: this.drops,
totalMs: Date.now() - this.startTime
};
}
}
const clientMetrics = new ClientMetrics();
// Metrics panel
let metricsVisible = false;
let sessionLogs = [];
function toggleMetricsPanel() {
metricsVisible = !metricsVisible;
document.getElementById("metrics-panel").style.display = metricsVisible ? "block" : "none";
}
function updateMetricsPanel(serverMetrics, clientSummary) {
const el = document.getElementById("metrics-content");
if (!el) return;
let html = "<b>Server (ms):</b><br>";
if (serverMetrics && serverMetrics.timings) {
for (const [k, v] of Object.entries(serverMetrics.timings)) {
html += `${k}: ${v}ms<br>`;
}
}
html += `<br><b>Client (ms):</b><br>`;
if (clientSummary.events) {
for (const [k, v] of Object.entries(clientSummary.events)) {
html += `${k}: ${v}ms<br>`;
}
}
html += `<br><b>Stats:</b><br>`;
html += `Frames: ${clientSummary.received} recv<br>`;
html += `Drops: ${clientSummary.drops}<br>`;
html += `Avg gap: ${clientSummary.avgGapMs}ms<br>`;
html += `Total: ${clientSummary.totalMs}ms`;
el.innerHTML = html;
if (sessionLogs.length > 0) {
html += "<br><button onclick=\"exportLogs()\" style=\"margin-top:5px; padding:3px 8px;\">Exportar Logs</button>";
el.innerHTML = html;
}
}
function exportLogs() {
const blob = new Blob([JSON.stringify(sessionLogs, null, 2)], {type: "application/json"});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "avatar_metrics_" + Date.now() + ".json";
a.click();
}
// Settings
function loadSettings() {
if (localStorage.getItem('resolution')) resolutionSelect.value = localStorage.getItem('resolution');
if (localStorage.getItem('batch')) batchSelect.value = localStorage.getItem('batch');
if (localStorage.getItem('inputMode')) {
inputModeSelect.value = localStorage.getItem('inputMode');
inputMode = localStorage.getItem('inputMode');
}
}
function saveSettings() {
localStorage.setItem('resolution', resolutionSelect.value);
localStorage.setItem('batch', batchSelect.value);
localStorage.setItem('inputMode', inputModeSelect.value);
// Update input mode
const newMode = inputModeSelect.value;
if (newMode !== inputMode) {
inputMode = newMode;
updateInputMode();
}
settingsModal.classList.remove('active');
}
// VAD Functions
function updateInputMode() {
if (inputMode === 'vad') {
btnText.textContent = 'Escutando...';
recordBtn.classList.add('vad-mode');
startVAD();
} else {
btnText.textContent = 'Falar';
recordBtn.classList.remove('vad-mode');
stopVAD();
}
}
async function startVAD() {
if (vadActive) return;
try {
vadStream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioContext = new (window.AudioContext || window.webkitAudioContext)();
analyser = audioContext.createAnalyser();
analyser.fftSize = 512;
const source = audioContext.createMediaStreamSource(vadStream);
source.connect(analyser);
vadActive = true;
monitorVAD();
console.log('[VAD] Started');
} catch (err) {
console.error('[VAD] Error:', err);
inputMode = 'push';
inputModeSelect.value = 'push';
btnText.textContent = 'Falar';
}
}
function stopVAD() {
vadActive = false;
if (silenceTimer) {
clearTimeout(silenceTimer);
silenceTimer = null;
}
if (vadStream) {
vadStream.getTracks().forEach(track => track.stop());
vadStream = null;
}
if (audioContext) {
audioContext.close();
audioContext = null;
}
console.log('[VAD] Stopped');
}
function monitorVAD() {
if (!vadActive || !analyser) return;
const dataArray = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(dataArray);
// Calculate average volume
const average = dataArray.reduce((a, b) => a + b) / dataArray.length;
// Detect speech
if (average > VAD_THRESHOLD) {
// Voice detected
if (!isRecording && !isPlaying && recordBtn.disabled === false) {
console.log('[VAD] Voice detected, starting recording');
startRecordingVAD();
}
// Reset silence timer
if (silenceTimer) {
clearTimeout(silenceTimer);
silenceTimer = null;
}
} else if (isRecording && !silenceTimer) {
// Silence detected while recording - start timer
silenceTimer = setTimeout(() => {
if (isRecording) {
console.log('[VAD] Silence detected, stopping recording');
stopRecordingVAD();
}
silenceTimer = null;
}, SILENCE_DELAY);
}
// Continue monitoring
if (vadActive) {
requestAnimationFrame(monitorVAD);
}
}
function startRecordingVAD() {
if (isRecording || isPlaying || recordBtn.disabled) return;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
isRecording = true;
audioChunks = [];
// Create new MediaRecorder for this recording
navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => {
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = (e) => audioChunks.push(e.data);
mediaRecorder.onstop = () => {
stream.getTracks().forEach(track => track.stop());
processAudio();
};
mediaRecorder.start();
recordBtn.classList.add('recording');
btnText.textContent = 'Ouvindo...';
setStatus('recording', 'Ouvindo...');
});
}
function stopRecordingVAD() {
if (!isRecording || !mediaRecorder) return;
isRecording = false;
if (mediaRecorder.state === 'recording') {
mediaRecorder.stop();
}
recordBtn.classList.remove('recording');
btnText.textContent = 'Escutando...';
}
// Start idle video
function startIdleVideo() {
if (isPlaying) return; // Only skip if actively playing response
console.log('[IDLE] Starting idle video');
idleVideo.classList.remove('hidden');
videoCanvas.classList.remove('active');
avatarVideo.classList.remove('active');
idleVideo.play().catch(e => console.log('[IDLE] Autoplay blocked:', e));
}
function stopIdleVideo() {
console.log('[IDLE] Hiding idle video (crossfade)');
idleVideo.classList.add('hidden');
// Don't pause - let it continue playing behind for smoother transition back
}
// Status updates
function setStatus(status, text) {
statusDot.className = 'status-dot ' + status;
statusText.textContent = text;
}
// WebSocket connection
function connectWebSocket() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return;
}
if (ws) {
ws.close();
ws = null;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const basePath = window.location.pathname.replace(/\/$/, '');
console.log('[WS] Connecting to:', `${protocol}//${window.location.host}${basePath}/ws/stream`);
ws = new WebSocket(`${protocol}//${window.location.host}${basePath}/ws/stream`);
ws.onopen = () => { setInterval(() => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({type: "ping"})); }, 5000);
console.log('[WS] Connected');
setStatus('', 'Pronto');
};
ws.onclose = (e) => {
console.log('[WS] Disconnected, code:', e.code, 'reason:', e.reason, 'wasClean:', e.wasClean);
setTimeout(connectWebSocket, 2000);
};
ws.onerror = (e) => {
console.error('[WS] Error:', e);
};
ws.onmessage = async (event) => { if (event.data instanceof Blob) { const text = await event.data.text(); try { handleWSMessage(JSON.parse(text)); } catch(e) { console.log("[WS] Binary data received"); } } else { handleWSMessage(JSON.parse(event.data)); } };
}
function handleWSMessage(data) {
switch (data.type) {
case 'status':
loadingText.textContent = data.message;
break;
case 'transcription':
clientMetrics.mark('transcription_received');
responseText.textContent = '"' + data.text + '"';
break;
case 'response':
clientMetrics.mark('response_received');
responseText.textContent = data.text;
break;
case 'audio':
clientMetrics.mark('audio_url_received');
console.log('[AUDIO] Received audio URL, framesReceived:', framesReceived);
audioReady = true;
audioLoaded = false;
const basePath = window.location.pathname.replace(/\/$/, ''); audioPlayer.src = basePath + data.url;
audioPlayer.load(); // Start preloading
// Wait for audio to be ready
audioPlayer.oncanplaythrough = () => {
if (!audioLoaded) {
audioLoaded = true;
clientMetrics.mark('audio_loaded');
console.log('[AUDIO] Audio loaded and ready');
tryStartPlayback();
}
};
// Fallback if oncanplaythrough doesn't fire
audioPlayer.onloadeddata = () => {
if (!audioLoaded) {
audioLoaded = true;
clientMetrics.mark('audio_loaded');
console.log('[AUDIO] Audio data loaded');
tryStartPlayback();
}
};
break;
case 'info':
clientMetrics.mark('info_received');
console.log('[STREAM] Info received:', data.total_frames, 'frames, audioLoaded:', audioLoaded);
// Cancel any pending idle operations
if (idleTimeoutId) {
clearTimeout(idleTimeoutId);
idleTimeoutId = null;
}
// Set stream parameters (state already reset in processAudio)
totalFrames = data.total_frames;
targetFps = data.fps;
frameStartTime = Date.now();
// Keep idle video visible while loading
setStatus('processing', 'Gerando...');
break;
case 'frame':
frameBuffer[data.index] = data.frame;
clientMetrics.frameReceived(data.index);
framesReceived++;
frameCountEl.textContent = `${framesReceived}/${totalFrames}`;
const elapsed = (Date.now() - frameStartTime) / 1000;
fpsDisplay.textContent = (framesReceived / elapsed).toFixed(1);
// Pre-decode frame in background for faster rendering
decodeFrame(data.index, data.frame);
// Render first frame to canvas immediately (hidden) for smooth transition
if (framesReceived === 1) {
console.log('[FRAME] First frame received, pre-rendering to canvas');
clientMetrics.mark('first_frame_received');
renderFrameSync(data.frame);
firstFrameRendered = true;
}
// Try to start playback when we have enough buffered frames
if (framesReceived === MIN_BUFFER_FRAMES) {
console.log('[FRAME] Buffer threshold reached:', MIN_BUFFER_FRAMES, 'frames');
tryStartPlayback();
}
break;
case 'done':
console.log('[METRICS] Server:', data.metrics);
console.log('[METRICS] Client:', clientMetrics.getSummary());
updateMetricsPanel(data.metrics, clientMetrics.getSummary());
sessionLogs.push({time: new Date().toISOString(), server: data.metrics, client: clientMetrics.getFullData()});
console.log('[STREAM] All frames received:', framesReceived, 'audioLoaded:', audioLoaded, 'isPlaying:', isPlaying);
// Final fallback - try to start if not already playing
if (!isPlaying && !playbackLock) {
tryStartPlayback();
}
break;
case 'error':
responseText.textContent = 'Erro: ' + data.message;
recordBtn.disabled = false;
setStatus('', 'Pronto');
startIdleVideo();
break;
}
}
let playbackLock = false; // Prevent multiple playback starts
// Pre-decode frame to ImageBitmap for faster rendering
async function decodeFrame(index, base64Data) {
try {
const response = await fetch('data:image/jpeg;base64,' + base64Data);
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
decodedFrames[index] = bitmap; console.log("[DECODE] Frame", index, "decoded");
} catch (e) {
// Fallback - keep base64 in frameBuffer
console.log('[DECODE] Failed for frame', index);
}
}
// Render frame synchronously (for first frame pre-render)
function renderFrameSync(base64Data) {
const img = new Image();
img.onload = () => {
ctx.clearRect(0, 0, videoCanvas.width, videoCanvas.height);
const scale = Math.max(videoCanvas.width / img.width, videoCanvas.height / img.height);
const x = (videoCanvas.width - img.width * scale) / 2;
const y = (videoCanvas.height - img.height * scale) / 2;
ctx.drawImage(img, x, y, img.width * scale, img.height * scale);
};
img.src = 'data:image/jpeg;base64,' + base64Data;
}
// Check if conditions are met to start playback
function tryStartPlayback() {
if (isPlaying || playbackLock) {
console.log('[TRY] Blocked - already playing or locked');
return;
}
// Need audio loaded and minimum frames buffered
if (!audioLoaded) {
console.log('[TRY] Waiting for audio to load...');
return;
}
if (framesReceived < MIN_BUFFER_FRAMES && framesReceived < totalFrames) {
console.log('[TRY] Waiting for more frames:', framesReceived, '/', MIN_BUFFER_FRAMES);
return;
}
if (framesReceived === 0) {
console.log('[TRY] No frames received yet');
return;
}
console.log('[TRY] All conditions met, starting playback');
startSyncPlayback();
}
function startSyncPlayback() {
// Double-check to prevent any duplicate calls
if (isPlaying || playbackLock) {
console.log('[PLAYBACK] Blocked - already playing');
return;
}
playbackLock = true; // Lock immediately
isPlaying = true;
console.log('[PLAYBACK] Starting');
clientMetrics.mark('playback_start');
// Cancel any pending idle timeout
if (idleTimeoutId) {
clearTimeout(idleTimeoutId);
idleTimeoutId = null;
}
// Hide idle, show canvas
stopIdleVideo();
videoCanvas.classList.add('active');
setStatus('', 'Falando...');
// Start audio
audioPlayer.currentTime = 0;
audioPlayer.play().catch(e => console.log('[AUDIO] Play error:', e));
// Start frame sync loop
syncPlaybackLoop();
}
function syncPlaybackLoop() {
if (!isPlaying) return;
// Check if audio ended
if (audioPlayer.ended) {
console.log('[PLAYBACK] Audio ended');
stopSyncPlayback();
return;
}
const audioTime = audioPlayer.currentTime;
let frameIndex = Math.floor(audioTime * targetFps);
frameIndex = Math.max(0, Math.min(frameIndex, totalFrames - 1));
// Try to find the best available frame
let targetIndex = frameIndex;
// First try pre-decoded frame (fastest)
let decodedFrame = decodedFrames[frameIndex];
if (!decodedFrame) {
// Fallback to nearest available decoded frame
for (let i = frameIndex - 1; i >= 0; i--) {
if (decodedFrames[i]) {
decodedFrame = decodedFrames[i];
targetIndex = i;
break;
}
}
}
if (decodedFrame && targetIndex !== lastRenderedFrame) {
// Use pre-decoded ImageBitmap (fast path)
console.log("[RENDER] Rendering decoded frame", targetIndex); renderDecodedFrame(decodedFrame); clientMetrics.frameRendered(targetIndex);
lastRenderedFrame = targetIndex;
} else if (!decodedFrame && frameBuffer[frameIndex] && frameIndex !== lastRenderedFrame) {
// Fallback to base64 rendering
console.log("[RENDER] Rendering base64 frame", frameIndex); renderFrameSync(frameBuffer[frameIndex]); clientMetrics.frameRendered(frameIndex);
lastRenderedFrame = frameIndex;
}
frameCountEl.textContent = `${frameIndex + 1}/${totalFrames}`;
requestAnimationFrame(syncPlaybackLoop);
}
function stopSyncPlayback() {
console.log('[PLAYBACK] Stopping');
isPlaying = false;
// NOTE: Do NOT reset playbackLock here - it prevents duplicate playback
// playbackLock is only reset in processAudio() when a NEW request starts
audioPlayer.pause();
videoCanvas.classList.remove('active');
recordBtn.disabled = false;
// Update status based on input mode
if (inputMode === 'vad') {
setStatus('', 'Escutando...');
btnText.textContent = 'Escutando...';
} else {
setStatus('', 'Pronto');
}
// Start idle after short delay
if (idleTimeoutId) clearTimeout(idleTimeoutId);
idleTimeoutId = setTimeout(() => {
if (!isPlaying && !isRecording) {
startIdleVideo();
}
}, 300);
}
// Render pre-decoded ImageBitmap (fastest)
function renderDecodedFrame(bitmap) {
ctx.clearRect(0, 0, videoCanvas.width, videoCanvas.height);
const scale = Math.max(videoCanvas.width / bitmap.width, videoCanvas.height / bitmap.height);
const x = (videoCanvas.width - bitmap.width * scale) / 2;
const y = (videoCanvas.height - bitmap.height * scale) / 2;
ctx.drawImage(bitmap, x, y, bitmap.width * scale, bitmap.height * scale);
}
// Recording
async function initMicrophone() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = (e) => audioChunks.push(e.data);
mediaRecorder.onstop = () => processAudio();
} catch (err) {
responseText.textContent = 'Erro: Permita o microfone';
recordBtn.disabled = true;
}
}
function startRecording() {
// Only for push-to-talk mode
if (inputMode !== 'push') return;
clientMetrics.reset();
if (isRecording || !mediaRecorder || recordBtn.disabled) return;
if (!ws || ws.readyState !== WebSocket.OPEN) {
responseText.textContent = 'Conectando...';
connectWebSocket();
return;
}
isRecording = true;
audioChunks = [];
mediaRecorder.start();
// Keep idle video playing while recording (avatar listening)
startIdleVideo();
recordBtn.classList.add('recording');
btnText.textContent = 'Solte para enviar';
setStatus('recording', 'Ouvindo...');
videoCanvas.classList.remove('active');
avatarVideo.classList.remove('active');
}
function stopRecording() {
// Only for push-to-talk mode
if (inputMode !== 'push') return;
clientMetrics.reset();
if (!isRecording) return;
isRecording = false;
mediaRecorder.stop();
recordBtn.classList.remove('recording');
btnText.textContent = 'Falar';
}
async function processAudio() {
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
audioChunks = [];
setStatus('processing', 'Processando...');
recordBtn.disabled = true;
// Reset ALL state for new request BEFORE sending
audioReady = false;
audioLoaded = false;
framesReceived = 0;
frameBuffer = [];
decodedFrames = [];
firstFrameRendered = false;
lastRenderedFrame = -1;
isPlaying = false;
playbackLock = false;
// Clear audio player event handlers
audioPlayer.oncanplaythrough = null;
audioPlayer.onloadeddata = null;
console.log('[PROCESS] Starting new request, state reset');
// Keep idle video visible while processing
startIdleVideo();
if (!ws || ws.readyState !== WebSocket.OPEN) {
responseText.textContent = 'Erro: Sem conexao';
recordBtn.disabled = false;
return;
}
const reader = new FileReader();
reader.onloadend = () => {
ws.send(JSON.stringify({
type: 'start',
audio_base64: reader.result.split(',')[1],
resolution: parseInt(resolutionSelect.value),
batch_size: parseInt(batchSelect.value)
}));
};
reader.readAsDataURL(audioBlob);
}
// Event listeners
recordBtn.addEventListener('mousedown', startRecording);
recordBtn.addEventListener('mouseup', stopRecording);
recordBtn.addEventListener('mouseleave', () => { if (isRecording) stopRecording(); });
recordBtn.addEventListener('touchstart', (e) => { e.preventDefault(); startRecording(); });
recordBtn.addEventListener('touchend', (e) => { e.preventDefault(); stopRecording(); });
settingsBtn.onclick = () => settingsModal.classList.add('active');
modalCloseX.onclick = () => settingsModal.classList.remove('active');
modalSave.onclick = saveSettings;
settingsModal.onclick = (e) => { if (e.target === settingsModal) settingsModal.classList.remove('active'); };
// Handle idle video click to play (for browsers that block autoplay)
idleVideo.onclick = () => {
idleVideo.play().catch(e => console.log('[IDLE] Click play error:', e));
};
// Close WebSocket on page unload
window.addEventListener('beforeunload', () => {
if (ws) {
ws.close();
ws = null;
}
});
// Initialize
loadSettings();
initMicrophone();
connectWebSocket();
updateInputMode(); // Apply input mode (push-to-talk or VAD)
// Ensure idle video plays when loaded
idleVideo.style.display = 'block';
// When video data is loaded, try to play
idleVideo.oncanplaythrough = () => { console.log('[IDLE] Video fully buffered'); }; idleVideo.onloadeddata = () => {
console.log('[IDLE] Video data loaded, attempting play');
idleVideo.play().catch(e => {
console.log('[IDLE] Autoplay blocked:', e);
responseText.textContent = 'Toque para ativar';
});
};
// Try to play immediately too
idleVideo.play().then(() => {
console.log('[IDLE] Video playing');
}).catch(e => {
console.log('[IDLE] Initial autoplay blocked');
});
// Play on any user interaction if paused
['click', 'touchstart'].forEach(event => {
document.addEventListener(event, () => {
if (idleVideo.paused && !isPlaying) {
idleVideo.play().then(() => {
responseText.textContent = 'Pressione o botao e fale comigo';
}).catch(() => {});
}
}, { once: true });
});
</script>
</body>
</html>