Instructions to use marcosremar2/MuseTalk1.5 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Diffusers
How to use marcosremar2/MuseTalk1.5 with Diffusers:
pip install -U diffusers transformers accelerate
import torch from diffusers import DiffusionPipeline # switch to "mps" for apple devices pipe = DiffusionPipeline.from_pretrained("marcosremar2/MuseTalk1.5", dtype=torch.bfloat16, device_map="cuda") prompt = "Astronaut in a jungle, cold color palette, muted colors, detailed, 8k" image = pipe(prompt).images[0] - Notebooks
- Google Colab
- Kaggle
| <html lang="pt-BR"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>MuseTalk - Streaming em Tempo Real</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%); | |
| min-height: 100vh; | |
| color: #fff; | |
| } | |
| .container { max-width: 600px; margin: 0 auto; padding: 20px; } | |
| header { | |
| text-align: center; | |
| padding: 15px 0; | |
| margin-bottom: 20px; | |
| } | |
| header h1 { | |
| font-size: 1.5rem; | |
| background: linear-gradient(90deg, #00ff88, #00d4ff, #7c3aed); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| margin-bottom: 5px; | |
| } | |
| .subtitle { | |
| color: #666; | |
| font-size: 0.85rem; | |
| } | |
| .live-badge { | |
| display: inline-block; | |
| background: #ff4444; | |
| color: white; | |
| padding: 2px 8px; | |
| border-radius: 4px; | |
| font-size: 0.7rem; | |
| font-weight: bold; | |
| margin-left: 10px; | |
| animation: pulse 1s infinite; | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.6; } | |
| } | |
| .video-section { | |
| background: rgba(255,255,255,0.03); | |
| border-radius: 24px; | |
| padding: 20px; | |
| margin-bottom: 20px; | |
| border: 1px solid rgba(255,255,255,0.08); | |
| } | |
| .canvas-container { | |
| position: relative; | |
| width: 100%; | |
| aspect-ratio: 1; | |
| max-width: 450px; | |
| margin: 0 auto 15px; | |
| border-radius: 20px; | |
| overflow: hidden; | |
| background: #000; | |
| box-shadow: 0 10px 40px rgba(0,0,0,0.5); | |
| } | |
| #videoCanvas { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| } | |
| .canvas-overlay { | |
| position: absolute; | |
| top: 0; left: 0; right: 0; bottom: 0; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| flex-direction: column; | |
| gap: 15px; | |
| background: rgba(0,0,0,0.7); | |
| opacity: 0; | |
| transition: opacity 0.3s; | |
| } | |
| .canvas-overlay.active { opacity: 1; } | |
| .stats-bar { | |
| position: absolute; | |
| bottom: 0; | |
| left: 0; right: 0; | |
| background: linear-gradient(transparent, rgba(0,0,0,0.8)); | |
| padding: 20px 15px 10px; | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 0.75rem; | |
| } | |
| .stat { | |
| display: flex; | |
| align-items: center; | |
| gap: 5px; | |
| color: #aaa; | |
| } | |
| .stat-value { | |
| color: #00ff88; | |
| font-weight: 600; | |
| } | |
| /* Listening ring */ | |
| .listening-ring { | |
| position: absolute; | |
| top: -3px; left: -3px; right: -3px; bottom: -3px; | |
| border: 3px solid #00ff88; | |
| border-radius: 23px; | |
| display: none; | |
| animation: ring-pulse 0.6s ease-in-out infinite; | |
| } | |
| .canvas-container.listening .listening-ring { display: block; } | |
| @keyframes ring-pulse { | |
| 0%, 100% { opacity: 0.4; transform: scale(1); } | |
| 50% { opacity: 1; transform: scale(1.005); } | |
| } | |
| .status-text { | |
| text-align: center; | |
| font-size: 1rem; | |
| color: #ccc; | |
| min-height: 50px; | |
| padding: 10px; | |
| line-height: 1.4; | |
| } | |
| .progress-bar { | |
| width: 100%; | |
| height: 4px; | |
| background: rgba(255,255,255,0.1); | |
| border-radius: 2px; | |
| overflow: hidden; | |
| margin: 10px 0; | |
| } | |
| .progress-fill { | |
| height: 100%; | |
| background: linear-gradient(90deg, #00ff88, #00d4ff); | |
| width: 0%; | |
| transition: width 0.1s; | |
| } | |
| .timing-info { | |
| display: flex; | |
| justify-content: center; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| font-size: 0.7rem; | |
| color: #666; | |
| } | |
| .timing-info span { | |
| padding: 4px 10px; | |
| background: rgba(255,255,255,0.05); | |
| border-radius: 20px; | |
| } | |
| .chat-section { | |
| background: rgba(255,255,255,0.03); | |
| border-radius: 20px; | |
| padding: 20px; | |
| border: 1px solid rgba(255,255,255,0.08); | |
| } | |
| .chat-history { | |
| max-height: 120px; | |
| overflow-y: auto; | |
| margin-bottom: 15px; | |
| } | |
| .message { | |
| margin-bottom: 8px; | |
| padding: 10px 14px; | |
| border-radius: 14px; | |
| max-width: 85%; | |
| font-size: 0.85rem; | |
| line-height: 1.4; | |
| } | |
| .message.user { | |
| background: linear-gradient(135deg, #7c3aed, #5b21b6); | |
| margin-left: auto; | |
| border-bottom-right-radius: 4px; | |
| } | |
| .message.assistant { | |
| background: rgba(255,255,255,0.08); | |
| border-bottom-left-radius: 4px; | |
| } | |
| .controls { | |
| display: flex; | |
| gap: 10px; | |
| } | |
| .record-btn { | |
| flex: 1; | |
| padding: 18px; | |
| 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; | |
| } | |
| .record-btn.idle { | |
| background: linear-gradient(135deg, #00ff88, #00cc6a); | |
| color: #000; | |
| } | |
| .record-btn.recording { | |
| background: linear-gradient(135deg, #ff4444, #cc0000); | |
| color: white; | |
| animation: rec-pulse 0.4s ease-in-out infinite; | |
| } | |
| @keyframes rec-pulse { | |
| 0%, 100% { transform: scale(1); } | |
| 50% { transform: scale(1.02); } | |
| } | |
| .record-btn:disabled { | |
| background: #333; | |
| color: #666; | |
| cursor: not-allowed; | |
| } | |
| .settings-btn { | |
| padding: 18px; | |
| border: none; | |
| border-radius: 16px; | |
| background: rgba(255,255,255,0.08); | |
| color: #fff; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .settings-btn:hover { | |
| background: rgba(255,255,255,0.12); | |
| } | |
| /* Settings Modal */ | |
| .modal { | |
| display: none; | |
| position: fixed; | |
| top: 0; left: 0; right: 0; bottom: 0; | |
| background: rgba(0,0,0,0.9); | |
| z-index: 100; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .modal.active { display: flex; } | |
| .modal-content { | |
| background: linear-gradient(135deg, #1a1a2e 0%, #252547 100%); | |
| padding: 30px; | |
| border-radius: 24px; | |
| border: 1px solid rgba(124,58,237,0.3); | |
| min-width: 320px; | |
| } | |
| .modal-content h3 { | |
| margin-bottom: 25px; | |
| font-size: 1.2rem; | |
| } | |
| .setting-group { | |
| margin-bottom: 20px; | |
| } | |
| .setting-group label { | |
| display: block; | |
| margin-bottom: 8px; | |
| color: #888; | |
| font-size: 0.85rem; | |
| } | |
| .setting-group select { | |
| width: 100%; | |
| padding: 12px; | |
| border-radius: 10px; | |
| border: 1px solid rgba(255,255,255,0.15); | |
| background: rgba(255,255,255,0.08); | |
| color: #fff; | |
| font-size: 1rem; | |
| } | |
| .setting-group select option { | |
| background: #1a1a2e; | |
| } | |
| .modal-close { | |
| width: 100%; | |
| padding: 14px; | |
| background: linear-gradient(135deg, #7c3aed, #5b21b6); | |
| border: none; | |
| border-radius: 12px; | |
| color: #fff; | |
| font-size: 1rem; | |
| font-weight: 600; | |
| cursor: pointer; | |
| } | |
| .connection-status { | |
| position: fixed; | |
| top: 10px; | |
| right: 10px; | |
| padding: 6px 12px; | |
| border-radius: 20px; | |
| font-size: 0.7rem; | |
| font-weight: 600; | |
| } | |
| .connection-status.connected { | |
| background: rgba(0,255,136,0.2); | |
| color: #00ff88; | |
| } | |
| .connection-status.disconnected { | |
| background: rgba(255,68,68,0.2); | |
| color: #ff4444; | |
| } | |
| /* Hidden audio element */ | |
| #audioPlayer { display: none; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="connection-status disconnected" id="connection-status">Desconectado</div> | |
| <div class="container"> | |
| <header> | |
| <h1>MuseTalk Streaming <span class="live-badge">LIVE</span></h1> | |
| <p class="subtitle">Video gerado frame-a-frame em tempo real via WebSocket</p> | |
| </header> | |
| <div class="video-section"> | |
| <div class="canvas-container" id="canvas-container"> | |
| <div class="listening-ring"></div> | |
| <canvas id="videoCanvas" width="512" height="512"></canvas> | |
| <div class="canvas-overlay" id="canvas-overlay"> | |
| <div style="font-size: 3rem;">🎙️</div> | |
| <div>Aguardando...</div> | |
| </div> | |
| <div class="stats-bar" id="stats-bar"> | |
| <div class="stat">Frame: <span class="stat-value" id="frame-count">0/0</span></div> | |
| <div class="stat">FPS: <span class="stat-value" id="fps-display">0</span></div> | |
| <div class="stat">Latencia: <span class="stat-value" id="latency-display">0ms</span></div> | |
| </div> | |
| </div> | |
| <div class="progress-bar"> | |
| <div class="progress-fill" id="progress-fill"></div> | |
| </div> | |
| <div class="status-text" id="status-text"> | |
| Pressione o botao e fale para iniciar o streaming em tempo real | |
| </div> | |
| <div class="timing-info" id="timing-info"></div> | |
| </div> | |
| <div class="chat-section"> | |
| <div class="chat-history" id="chat-history"></div> | |
| <div class="controls"> | |
| <button class="record-btn idle" id="record-btn"> | |
| <svg width="24" height="24" 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">Pressione para falar</span> | |
| </button> | |
| <button class="settings-btn" id="settings-btn"> | |
| <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <circle cx="12" cy="12" r="3"/> | |
| <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/> | |
| </svg> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Settings Modal --> | |
| <div class="modal" id="settings-modal"> | |
| <div class="modal-content"> | |
| <h3>Configuracoes de Streaming</h3> | |
| <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="4">4 - Menor latencia</option> | |
| <option value="8" selected>8 - Balanceado</option> | |
| <option value="16">16 - Mais rapido</option> | |
| </select> | |
| </div> | |
| <button class="modal-close" id="modal-close">Salvar</button> | |
| </div> | |
| </div> | |
| <audio id="audioPlayer"></audio> | |
| <script> | |
| // Elements | |
| const canvas = document.getElementById('videoCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const canvasContainer = document.getElementById('canvas-container'); | |
| const canvasOverlay = document.getElementById('canvas-overlay'); | |
| const statusText = document.getElementById('status-text'); | |
| const progressFill = document.getElementById('progress-fill'); | |
| const frameCount = document.getElementById('frame-count'); | |
| const fpsDisplay = document.getElementById('fps-display'); | |
| const latencyDisplay = document.getElementById('latency-display'); | |
| const timingInfo = document.getElementById('timing-info'); | |
| const chatHistory = document.getElementById('chat-history'); | |
| 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 modalClose = document.getElementById('modal-close'); | |
| const resolutionSelect = document.getElementById('resolution-select'); | |
| const batchSelect = document.getElementById('batch-select'); | |
| const connectionStatus = document.getElementById('connection-status'); | |
| const audioPlayer = document.getElementById('audioPlayer'); | |
| let ws = null; | |
| let mediaRecorder = null; | |
| let audioChunks = []; | |
| let isRecording = false; | |
| let frameStartTime = 0; | |
| let framesReceived = 0; | |
| let totalFrames = 0; | |
| let targetFps = 30; | |
| let audioStarted = false; | |
| // Load settings | |
| if (localStorage.getItem('stream_resolution')) { | |
| resolutionSelect.value = localStorage.getItem('stream_resolution'); | |
| } | |
| if (localStorage.getItem('stream_batch')) { | |
| batchSelect.value = localStorage.getItem('stream_batch'); | |
| } | |
| // Settings modal | |
| settingsBtn.onclick = () => settingsModal.classList.add('active'); | |
| modalClose.onclick = () => { | |
| settingsModal.classList.remove('active'); | |
| localStorage.setItem('stream_resolution', resolutionSelect.value); | |
| localStorage.setItem('stream_batch', batchSelect.value); | |
| }; | |
| // Connect WebSocket | |
| function connectWebSocket() { | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| ws = new WebSocket(`${protocol}//${window.location.host}/ws/stream`); | |
| ws.onopen = () => { | |
| console.log('WebSocket connected'); | |
| connectionStatus.textContent = 'Conectado'; | |
| connectionStatus.className = 'connection-status connected'; | |
| }; | |
| ws.onclose = () => { | |
| console.log('WebSocket disconnected'); | |
| connectionStatus.textContent = 'Desconectado'; | |
| connectionStatus.className = 'connection-status disconnected'; | |
| // Reconnect after 2 seconds | |
| setTimeout(connectWebSocket, 2000); | |
| }; | |
| ws.onerror = (error) => { | |
| console.error('WebSocket error:', error); | |
| }; | |
| ws.onmessage = (event) => { | |
| const data = JSON.parse(event.data); | |
| handleMessage(data); | |
| }; | |
| } | |
| function handleMessage(data) { | |
| switch (data.type) { | |
| case 'status': | |
| statusText.textContent = data.message; | |
| break; | |
| case 'transcription': | |
| addMessage('user', data.text); | |
| break; | |
| case 'response': | |
| addMessage('assistant', data.text); | |
| statusText.textContent = data.text; | |
| break; | |
| case 'audio': | |
| // Preload audio | |
| audioPlayer.src = data.url; | |
| audioPlayer.load(); | |
| break; | |
| case 'info': | |
| totalFrames = data.total_frames; | |
| targetFps = data.fps; | |
| frameCount.textContent = `0/${totalFrames}`; | |
| frameStartTime = Date.now(); | |
| framesReceived = 0; | |
| audioStarted = false; | |
| canvasOverlay.classList.remove('active'); | |
| break; | |
| case 'frame': | |
| renderFrame(data.frame, data.index); | |
| framesReceived++; | |
| // Start audio on first frame | |
| if (!audioStarted && framesReceived === 1) { | |
| audioPlayer.play().catch(e => console.log('Audio autoplay blocked')); | |
| audioStarted = true; | |
| } | |
| // Update stats | |
| frameCount.textContent = `${data.index + 1}/${totalFrames}`; | |
| const progress = ((data.index + 1) / totalFrames) * 100; | |
| progressFill.style.width = `${progress}%`; | |
| // Calculate FPS | |
| const elapsed = (Date.now() - frameStartTime) / 1000; | |
| const currentFps = framesReceived / elapsed; | |
| fpsDisplay.textContent = currentFps.toFixed(1); | |
| // Estimate latency (frames behind real-time) | |
| const expectedFrame = elapsed * targetFps; | |
| const latencyFrames = expectedFrame - framesReceived; | |
| const latencyMs = (latencyFrames / targetFps) * 1000; | |
| latencyDisplay.textContent = `${Math.max(0, latencyMs).toFixed(0)}ms`; | |
| break; | |
| case 'done': | |
| statusText.textContent = `Streaming completo! ${data.total_frames} frames`; | |
| progressFill.style.width = '100%'; | |
| recordBtn.disabled = false; | |
| timingInfo.innerHTML = `<span>Total: ${((Date.now() - frameStartTime) / 1000).toFixed(1)}s</span>`; | |
| break; | |
| case 'error': | |
| statusText.textContent = `Erro: ${data.message}`; | |
| canvasOverlay.classList.add('active'); | |
| recordBtn.disabled = false; | |
| break; | |
| } | |
| } | |
| function renderFrame(base64Data, index) { | |
| const img = new Image(); | |
| img.onload = () => { | |
| // Clear and draw | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| // Calculate scaling to fit canvas while maintaining aspect ratio | |
| const scale = Math.min(canvas.width / img.width, canvas.height / img.height); | |
| const x = (canvas.width - img.width * scale) / 2; | |
| const y = (canvas.height - img.height * scale) / 2; | |
| ctx.drawImage(img, x, y, img.width * scale, img.height * scale); | |
| }; | |
| img.src = 'data:image/jpeg;base64,' + base64Data; | |
| } | |
| function addMessage(role, text) { | |
| const div = document.createElement('div'); | |
| div.className = 'message ' + role; | |
| div.textContent = text; | |
| chatHistory.appendChild(div); | |
| chatHistory.scrollTop = chatHistory.scrollHeight; | |
| } | |
| 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 = async () => { | |
| const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); | |
| audioChunks = []; | |
| await sendAudio(audioBlob); | |
| }; | |
| statusText.textContent = 'Pronto! Pressione e fale para streaming em tempo real.'; | |
| } catch (err) { | |
| statusText.textContent = 'Erro: Permita acesso ao microfone'; | |
| recordBtn.disabled = true; | |
| } | |
| } | |
| async function sendAudio(audioBlob) { | |
| if (!ws || ws.readyState !== WebSocket.OPEN) { | |
| statusText.textContent = 'Erro: WebSocket nao conectado'; | |
| recordBtn.disabled = false; | |
| return; | |
| } | |
| // Convert blob to base64 | |
| const reader = new FileReader(); | |
| reader.onloadend = () => { | |
| const base64 = reader.result.split(',')[1]; | |
| // Send to WebSocket | |
| ws.send(JSON.stringify({ | |
| type: 'start', | |
| audio_base64: base64, | |
| resolution: parseInt(resolutionSelect.value), | |
| batch_size: parseInt(batchSelect.value) | |
| })); | |
| frameStartTime = Date.now(); | |
| }; | |
| reader.readAsDataURL(audioBlob); | |
| } | |
| function startRecording() { | |
| if (isRecording || !mediaRecorder || recordBtn.disabled) return; | |
| if (!ws || ws.readyState !== WebSocket.OPEN) { | |
| statusText.textContent = 'Aguarde conexao WebSocket...'; | |
| return; | |
| } | |
| isRecording = true; | |
| audioChunks = []; | |
| mediaRecorder.start(); | |
| recordBtn.classList.replace('idle', 'recording'); | |
| btnText.textContent = 'Gravando... solte para enviar'; | |
| canvasContainer.classList.add('listening'); | |
| canvasOverlay.innerHTML = '<div style="font-size: 3rem;">🎤</div><div>Ouvindo...</div>'; | |
| canvasOverlay.classList.add('active'); | |
| } | |
| function stopRecording() { | |
| if (!isRecording) return; | |
| isRecording = false; | |
| mediaRecorder.stop(); | |
| recordBtn.classList.replace('recording', 'idle'); | |
| btnText.textContent = 'Pressione para falar'; | |
| canvasContainer.classList.remove('listening'); | |
| recordBtn.disabled = true; | |
| canvasOverlay.innerHTML = '<div class="spinner" style="width:50px;height:50px;border:4px solid rgba(255,255,255,0.2);border-top-color:#00ff88;border-radius:50%;animation:spin 0.8s linear infinite;"></div><div>Processando...</div>'; | |
| statusText.textContent = 'Enviando audio...'; | |
| } | |
| // 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(); }); | |
| // Draw initial frame (placeholder) | |
| function drawPlaceholder() { | |
| ctx.fillStyle = '#1a1a2e'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.fillStyle = '#333'; | |
| ctx.font = '20px Arial'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('Aguardando streaming...', canvas.width/2, canvas.height/2); | |
| } | |
| // Initialize | |
| drawPlaceholder(); | |
| connectWebSocket(); | |
| initMicrophone(); | |
| </script> | |
| </body> | |
| </html> | |