// ------------------ WebSocket & State ------------------ let ws = null; let isConnected = false; let currentVolume = 0.8; let mutedState = false; let haConnected = false; let rmsTimeout = null; let currentRms = 0; const canvas = document.getElementById('audioCanvas'); const ctx = canvas.getContext('2d'); let canvasWidth = canvas.parentElement.clientWidth - 24; let canvasHeight = 80; canvas.width = canvasWidth; canvas.height = canvasHeight; // DOM elements const statusLed = document.getElementById('statusLed'); const statusLabel = document.getElementById('statusLabel'); const connectBtn = document.getElementById('connectBtn'); const orbCore = document.getElementById('orbCore'); const stateText = document.getElementById('stateText'); const volumeSlider = document.getElementById('volumeSlider'); const volumeValueSpan = document.getElementById('volumeValue'); const lastSttSpan = document.getElementById('lastStt'); const lastTtsSpan = document.getElementById('lastTts'); const mutedStatusSpan = document.getElementById('mutedStatus'); const haStatusSpan = document.getElementById('haStatus'); const liveVolSpan = document.getElementById('liveVol'); const eventFeedDiv = document.getElementById('eventFeed'); const rmsFill = document.getElementById('rmsFill'); const rmsValueSpan = document.getElementById('rmsValue'); function addEventToLog(message) { const time = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); const div = document.createElement('div'); div.className = 'event-item'; div.innerHTML = `${time} • ${message}`; eventFeedDiv.prepend(div); if (eventFeedDiv.children.length > 50) eventFeedDiv.removeChild(eventFeedDiv.lastChild); } function updateRmsMeter(level) { if (!rmsFill || !rmsValueSpan) return; const percent = Math.min(100, Math.max(0, level * 100)); rmsFill.style.width = percent + '%'; rmsValueSpan.textContent = level.toFixed(2); clearTimeout(rmsTimeout); rmsTimeout = setTimeout(() => { if (rmsFill) rmsFill.style.width = '0%'; if (rmsValueSpan) rmsValueSpan.textContent = '0.00'; }, 1000); } function sendCommand(command, payload = {}) { if (!ws || ws.readyState !== WebSocket.OPEN) { addEventToLog(`❌ Cannot send: ${command} — not connected`); return; } const msg = JSON.stringify({ command, data: payload }); ws.send(msg); addEventToLog(`📤 Command: ${command}`); } function handleIncomingMessage(ev) { try { const json = JSON.parse(ev.data); const eventType = json.type || json.event; if (eventType === 'audio-level' && json.data?.level !== undefined) { currentRms = json.data.level; updateRmsMeter(currentRms); } else if (eventType === 'voice-started') { stateText.innerText = 'Listening...'; document.body.classList.add('listening-active'); addEventToLog('🎤 Voice detected'); } else if (eventType === 'voice-stopped') { stateText.innerText = 'Processing'; document.body.classList.remove('listening-active'); addEventToLog('💭 Processing speech'); } else if (eventType === 'transcript' && json.data?.text) { lastSttSpan.innerText = json.data.text; addEventToLog(`🎤 "${json.data.text}"`); } else if (eventType === 'synthesize' && json.data?.text) { lastTtsSpan.innerText = json.data.text; } else if (eventType === 'audio-start') { stateText.innerText = 'Speaking'; addEventToLog('🗣️ Assistant speaking'); } else if (eventType === 'audio-stop' || eventType === 'played') { if (stateText.innerText === 'Speaking') stateText.innerText = 'Idle'; addEventToLog('🔇 TTS finished'); } else if (eventType === 'timer-started') { addEventToLog('⏱️ Timer started'); } else if (eventType === 'timer-finished') { addEventToLog('⏰ Timer triggered!'); stateText.innerText = 'Timer'; } else if (eventType === 'satellite-connected') { haConnected = true; haStatusSpan.innerText = 'connected'; addEventToLog('🔌 Satellite connected'); } else if (eventType === 'satellite-disconnected') { haConnected = false; haStatusSpan.innerText = 'offline'; addEventToLog('🔌 Satellite disconnected'); } else if (eventType === 'error') { addEventToLog(`❌ Error: ${json.data?.message || 'unknown'}`); } else { addEventToLog(`📡 Event: ${eventType}`); } if (json.data) { if (json.data.volume !== undefined) { currentVolume = json.data.volume; volumeSlider.value = currentVolume; volumeValueSpan.innerText = Math.round(currentVolume * 100) + '%'; liveVolSpan.innerText = Math.round(currentVolume * 100) + '%'; } if (json.data.muted !== undefined) { mutedState = json.data.muted; mutedStatusSpan.innerText = mutedState ? 'muted' : 'unmuted'; } } } catch (e) { console.warn(e); addEventToLog(`⚠️ Parse error: ${e.message}`); } } // Визуализация аудио (волны) let rmsSmoothed = 0; function drawVisualization() { requestAnimationFrame(drawVisualization); if (!isConnected) { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#0a1222'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#0ff3'; ctx.font = '10px monospace'; ctx.fillText('⚡ idle', canvas.width - 50, 20); return; } let avg = 10 + (currentRms / 0.15) * 90; avg = Math.min(100, Math.max(10, avg)); rmsSmoothed = rmsSmoothed * 0.7 + avg * 0.3; avg = rmsSmoothed; if (currentRms < 0.001) avg = 10 + Math.random() * 5; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#07121f'; ctx.fillRect(0, 0, canvas.width, canvas.height); const barWidth = canvas.width / 42; for (let i = 0; i < 42; i++) { let barHeight = (avg * (Math.sin(i * 0.5) + 1) / 2 + 5) * (0.5 + Math.random() * 0.3); if (stateText.innerText === 'Speaking') barHeight = avg * 1.6 * (Math.sin(i * 0.3) + 1.2); barHeight = Math.min(barHeight, canvas.height - 10); const x = i * (barWidth + 2); const grad = ctx.createLinearGradient(x, canvas.height - barHeight, x, canvas.height); grad.addColorStop(0, '#0ff'); grad.addColorStop(1, '#a3f'); ctx.fillStyle = grad; ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight); } } drawVisualization(); // WebSocket подключение function toggleConnection() { const url = document.getElementById('wsUrlInput').value.trim(); if (!url) { addEventToLog('❌ Enter WebSocket URL'); return; } if (ws && ws.readyState === WebSocket.OPEN) { ws.close(); return; } ws = new WebSocket(url); ws.onopen = () => { isConnected = true; statusLed.classList.add('connected'); statusLabel.innerText = 'connected'; addEventToLog(`✅ Connected to ${url}`); stateText.innerText = 'Online'; document.getElementById('connectBtn').innerHTML = '🔌 Disconnect'; document.getElementById('connectBtnCard').innerHTML = 'Disconnect'; }; ws.onmessage = handleIncomingMessage; ws.onerror = () => addEventToLog(`⚠️ WebSocket error`); ws.onclose = () => { isConnected = false; statusLed.classList.remove('connected'); statusLabel.innerText = 'disconnected'; addEventToLog(`🔌 Disconnected`); stateText.innerText = 'Offline'; document.body.classList.remove('listening-active'); document.getElementById('connectBtn').innerHTML = '⟳ Connect'; document.getElementById('connectBtnCard').innerHTML = 'Connect'; }; } // Обработчики кнопок document.getElementById('connectBtn').addEventListener('click', toggleConnection); document.getElementById('connectBtnCard').addEventListener('click', toggleConnection); window.addEventListener('load', () => { addEventToLog('🤖 Nova Assistant ready'); if (document.getElementById('autoConnect').checked) { setTimeout(() => toggleConnection(), 500); } // Адаптация canvas при изменении размера window.addEventListener('resize', () => { canvas.width = canvas.parentElement.clientWidth - 24; canvas.height = 80; }); }); // Команды document.getElementById('startListeningBtn').addEventListener('click', () => sendCommand('set_volume', { level: 1.0 })); document.getElementById('stopPipelineBtn').addEventListener('click', () => sendCommand('mute', {})); document.getElementById('stopTimerBtn').addEventListener('click', () => sendCommand('unmute', {})); document.getElementById('muteBtn').addEventListener('click', () => sendCommand('mute', {})); document.getElementById('unmuteBtn').addEventListener('click', () => sendCommand('unmute', {})); document.getElementById('volUpBtn').addEventListener('click', () => { let newVol = Math.min(1.0, currentVolume + 0.1); currentVolume = newVol; sendCommand('set_volume', { level: newVol }); volumeSlider.value = newVol; volumeValueSpan.innerText = Math.round(newVol * 100) + '%'; }); document.getElementById('volDownBtn').addEventListener('click', () => { let newVol = Math.max(0.0, currentVolume - 0.1); currentVolume = newVol; sendCommand('set_volume', { level: newVol }); volumeSlider.value = newVol; volumeValueSpan.innerText = Math.round(newVol * 100) + '%'; }); document.getElementById('requestRmsBtn').addEventListener('click', () => { sendCommand('trigger_event', { event_type: 'request_audio_level', data: {} }); addEventToLog('📊 Requested RMS update'); }); document.getElementById('singlePressBtn').addEventListener('click', () => sendCommand('trigger_event', { event_type: 'single_press', data: {} })); document.getElementById('doublePressBtn').addEventListener('click', () => sendCommand('trigger_event', { event_type: 'double_press', data: {} })); document.getElementById('triplePressBtn').addEventListener('click', () => sendCommand('trigger_event', { event_type: 'triple_press', data: {} })); document.getElementById('longPressBtn').addEventListener('click', () => sendCommand('trigger_event', { event_type: 'long_press', data: {} })); document.getElementById('clearEvents').addEventListener('click', () => { while (eventFeedDiv.children.length > 1) eventFeedDiv.removeChild(eventFeedDiv.lastChild); addEventToLog('🧹 Log cleared'); }); volumeSlider.addEventListener('input', (e) => { let val = parseFloat(e.target.value); currentVolume = val; volumeValueSpan.innerText = Math.round(val * 100) + '%'; sendCommand('set_volume', { level: val }); });