Files
satellite/web-client/client.html
T
sirmrak c7bf061611 feat: Web Client разработка — план и документация
Исправления:
- fix: баг volume/level — поддерживаются оба названия параметра
- docs: добавлен раздел Web Client в websocket-server-plan.md
- docs: создан web-client/README.md с полной документацией

Функции Web клиента:
- ✅ set_volume (громкость) — работает
- ✅ mute/unmute — работает
- ⏳ audio-level RMS — требует тестирования
- ⏳ LED управление — требует тестирования
- ⏳ Transcript — требует тестирования

Этапы разработки:
- WC-1: Базовая интеграция ✅
- WC-2: Визуализация (в процессе)
- WC-3: Дополнительные функции
- WC-4: UI/UX
2026-05-21 13:02:35 +03:00

613 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>NOVA: LVA Cognitive Interface</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
user-select: none; /* более чистый вид на панелях */
}
body {
background: radial-gradient(circle at 20% 30%, #0a0f1e, #03060c);
font-family: 'Segoe UI', 'Poppins', 'Inter', system-ui, -apple-system, 'Orbitron', monospace;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 24px;
backdrop-filter: blur(2px);
}
/* ГЛАВНАЯ КАРТА – голографический неон */
.glass-panel {
max-width: 1400px;
width: 100%;
background: rgba(12, 20, 35, 0.45);
backdrop-filter: blur(20px);
border-radius: 4rem;
border: 1px solid rgba(0, 255, 255, 0.25);
box-shadow: 0 30px 50px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.1);
padding: 2rem 2rem 2rem 2rem;
transition: all 0.3s ease;
}
/* GRID LAYOUT */
.dashboard {
display: grid;
grid-template-columns: 1fr 0.9fr 1.2fr;
gap: 1.8rem;
}
/* общие стили блоков */
.card {
background: rgba(8, 15, 26, 0.6);
backdrop-filter: blur(12px);
border-radius: 2rem;
border: 1px solid rgba(0, 255, 255, 0.2);
padding: 1.4rem 1.5rem;
transition: all 0.2s;
box-shadow: 0 8px 20px rgba(0,0,0,0.3);
}
.card-header {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 3px;
font-weight: 500;
color: #7f9fcf;
border-left: 3px solid #0ff;
padding-left: 12px;
margin-bottom: 1.2rem;
display: flex;
align-items: center;
gap: 8px;
}
/* статус-индикатор */
.connection-status {
display: flex;
align-items: center;
gap: 12px;
background: rgba(0,0,0,0.4);
padding: 10px 18px;
border-radius: 60px;
width: fit-content;
margin-bottom: 20px;
}
.led {
width: 14px;
height: 14px;
border-radius: 50%;
background: #ff4d4d;
box-shadow: 0 0 6px #ff4d4d;
transition: all 0.2s;
}
.led.connected {
background: #0ff;
box-shadow: 0 0 12px #0ff, 0 0 6px #0ff;
}
.status-text {
font-weight: 500;
font-family: monospace;
color: #b0e0ff;
}
/* голосовая сфера */
.avatar-area {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.orb {
width: 160px;
height: 160px;
background: radial-gradient(circle at 30% 30%, #0ff, #0066aa);
border-radius: 50%;
margin: 20px auto;
box-shadow: 0 0 40px rgba(0, 255, 255, 0.5);
transition: all 0.2s cubic-bezier(0.2, 0.9, 0.4, 1.1);
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
font-weight: bold;
color: white;
text-shadow: 0 0 12px cyan;
}
.listening-active .orb {
animation: pulse-glow 1.2s infinite alternate;
transform: scale(1.02);
box-shadow: 0 0 70px rgba(0, 255, 200, 0.8);
}
@keyframes pulse-glow {
0% { box-shadow: 0 0 20px rgba(0,255,200,0.4); background: radial-gradient(circle at 30% 30%, #0ff, #0055aa);}
100% { box-shadow: 0 0 65px rgba(0,255,230,0.9); background: radial-gradient(circle at 30% 30%, #3effff, #0088dd);}
}
.state-label {
font-size: 1rem;
font-weight: 600;
background: rgba(0,255,255,0.15);
padding: 6px 20px;
border-radius: 40px;
backdrop-filter: blur(4px);
margin-top: 10px;
letter-spacing: 1px;
}
/* визуализатор аудио */
.viz-wrapper {
margin: 15px 0;
background: #03101755;
border-radius: 40px;
padding: 12px;
}
canvas {
width: 100%;
height: 80px;
display: block;
border-radius: 30px;
}
/* команды кнопки – неоновые */
.command-group {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 20px 0 10px;
}
.neo-btn {
background: rgba(16, 28, 48, 0.7);
border: 1px solid rgba(0, 255, 255, 0.6);
padding: 8px 16px;
border-radius: 40px;
color: #b3f0ff;
font-weight: 500;
font-size: 0.8rem;
cursor: pointer;
transition: 0.15s;
backdrop-filter: blur(4px);
display: inline-flex;
align-items: center;
gap: 8px;
letter-spacing: 0.5px;
}
.neo-btn:hover {
background: #0ff22a20;
border-color: #0ff;
color: white;
box-shadow: 0 0 10px #0ff;
transform: translateY(-2px);
}
.danger-btn {
border-color: #ff44aa;
color: #ffb0e0;
}
.danger-btn:hover {
border-color: #ff66cc;
background: #ff336620;
}
/* лента событий */
.event-feed {
background: #010a1480;
border-radius: 1.5rem;
padding: 0.8rem;
height: 260px;
overflow-y: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
}
.event-item {
border-bottom: 1px solid #2a4c6e50;
padding: 8px 10px;
color: #cae9ff;
display: flex;
gap: 12px;
}
.event-time {
color: #4effb5;
font-weight: 400;
}
.transcript-block {
background: #0a1222;
border-radius: 1.2rem;
padding: 12px;
margin-top: 12px;
}
.quote {
font-style: italic;
color: #eef5ff;
font-size: 0.9rem;
}
hr {
border-color: #0ff22a30;
margin: 10px 0;
}
input.volume-slider {
width: 100%;
accent-color: #0ff;
}
@media (max-width: 1000px) {
.dashboard { grid-template-columns: 1fr; gap: 1.5rem; }
.glass-panel { padding: 1.5rem; }
}
.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
.microphone-btn {
background: #ff20;
border-radius: 60px;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.6rem;
cursor: pointer;
border: 1px solid cyan;
transition: 0.1s;
}
.microphone-btn:active { transform: scale(0.96);}
</style>
</head>
<body>
<div class="glass-panel">
<!-- заголовок -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<div style="font-family: 'Orbitron'; font-weight: 700; font-size: 1.6rem; letter-spacing: 2px; background: linear-gradient(135deg, #aaffff, #3b8eff); -webkit-background-clip: text; background-clip: text; color: transparent;">
⚡ LVA • NEXUS
</div>
<div class="connection-status">
<div class="led" id="statusLed"></div>
<div class="status-text" id="statusLabel">disconnected</div>
<button id="connectBtn" class="neo-btn" style="margin-left: 12px;">⟳ CONNECT</button>
</div>
</div>
<div class="dashboard">
<!-- ЛЕВАЯ КОЛОНКА: Аватар + Аудиовизуализация и Состояние -->
<div class="card avatar-area">
<div class="card-header">
<span>🎙️ NEURAL CORE</span>
</div>
<div class="orb" id="orbCore">
🔊
</div>
<div class="state-label" id="stateText">IDLE</div>
<div class="viz-wrapper">
<canvas id="audioCanvas" width="400" height="60" style="width:100%; height: 70px"></canvas>
</div>
<div class="flex-between" style="width:100%; margin-top: 8px;">
<span>🔊 VOLUME</span>
<span id="volumeValue" style="color:#0ff">0.80</span>
</div>
<input type="range" id="volumeSlider" min="0" max="1" step="0.01" value="0.8" class="volume-slider">
<div class="command-group" style="justify-content: center;">
<button id="muteBtn" class="neo-btn">🔇 MUTE</button>
<button id="unmuteBtn" class="neo-btn">🎤 UNMUTE</button>
</div>
</div>
<!-- ЦЕНТР: команды и транскрипция -->
<div class="card">
<div class="card-header">
<span>🕹️ УПРАВЛЕНИЕ • ЖЕСТЫ</span>
</div>
<div class="command-group">
<button id="startListeningBtn" class="neo-btn">🎧 START LISTEN</button>
<button id="stopPipelineBtn" class="neo-btn danger-btn">⏹️ STOP PIPELINE</button>
<button id="stopTimerBtn" class="neo-btn">⏲️ STOP TIMER</button>
</div>
<div class="command-group">
<button id="volUpBtn" class="neo-btn">➕ VOL+</button>
<button id="volDownBtn" class="neo-btn">➖ VOL-</button>
<button id="pauseMediaBtn" class="neo-btn">⏸️ PAUSE</button>
<button id="resumeMediaBtn" class="neo-btn">▶️ RESUME</button>
<button id="stopMediaBtn" class="neo-btn">⏹️ STOP</button>
</div>
<div class="command-group">
<button id="singlePressBtn" class="neo-btn">🔘 SINGLE</button>
<button id="doublePressBtn" class="neo-btn">🔘🔘 DOUBLE</button>
<button id="triplePressBtn" class="neo-btn">🔘🔘🔘 TRIPLE</button>
<button id="longPressBtn" class="neo-btn">⏺️ LONG</button>
</div>
<hr>
<div>
<div class="card-header" style="margin-bottom: 8px;">🗣️ ПОСЛЕДНИЕ ФРАЗЫ</div>
<div class="transcript-block">
<div style="color:#88ccff;">YOU SAID:</div>
<div id="lastStt" class="quote">—</div>
<div style="color:#88ccff; margin-top: 10px;">ASSISTANT SAID:</div>
<div id="lastTts" class="quote">—</div>
</div>
</div>
</div>
<!-- ПРАВАЯ КОЛОНКА: ЛОГИ + HA СТАТУС -->
<div class="card">
<div class="card-header">
<span>📡 ТЕЛЕМЕТРИЯ & СОБЫТИЯ</span>
</div>
<div style="display: flex; gap: 16px; margin-bottom: 18px;">
<div><span style="color:#aaa;">🔊 MUTED: </span><strong id="mutedStatus">—</strong></div>
<div><span style="color:#aaa;">🏠 HA: </span><strong id="haStatus">—</strong></div>
<div><span style="color:#aaa;">🎚️ VOL: </span><strong id="liveVol">—</strong></div>
</div>
<div class="event-feed" id="eventFeed">
<div class="event-item"><span class="event-time">⬤</span> <span>готов к подключению...</span></div>
</div>
<button id="clearEvents" class="neo-btn" style="margin-top: 14px; width: 100%;">🧹 ОЧИСТИТЬ ЛОГ</button>
</div>
</div>
</div>
<script>
// ------------------ WEBSOCKET & STATE ------------------
let ws = null;
let isConnected = false;
let currentVolume = 0.8;
let mutedState = false;
let haConnected = false;
let animationFrame = null;
let audioCtx = null;
let analyser = null;
let source = null;
let mediaStream = null;
const canvas = document.getElementById('audioCanvas');
const ctx = canvas.getContext('2d');
let width = canvas.parentElement.clientWidth - 24;
let height = 70;
canvas.width = width;
canvas.height = height;
// DOM элементы
const statusLed = document.getElementById('statusLed');
const statusLabel = document.getElementById('statusLabel');
const connectBtn = document.getElementById('connectBtn');
const orb = 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');
function addEventToLog(message, type = 'info') {
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 = `<span class="event-time">${time}</span> <span>${message}</span>`;
eventFeedDiv.prepend(div);
if(eventFeedDiv.children.length > 45) eventFeedDiv.removeChild(eventFeedDiv.lastChild);
}
function updateUIFromSnapshot(data) {
if(data.volume !== undefined) {
currentVolume = data.volume;
volumeSlider.value = currentVolume;
volumeValueSpan.innerText = currentVolume.toFixed(2);
liveVolSpan.innerText = Math.round(currentVolume*100)+'%';
}
if(data.muted !== undefined) {
mutedState = data.muted;
mutedStatusSpan.innerText = mutedState ? '✅ muted' : '❌ unmuted';
}
if(data.ha_connected !== undefined) {
haConnected = data.ha_connected;
haStatusSpan.innerText = haConnected ? '⚡ connected' : '⚠️ offline';
}
if(data.last_stt_text) {
lastSttSpan.innerText = data.last_stt_text.substring(0, 100);
}
if(data.last_tts_text) {
lastTtsSpan.innerText = data.last_tts_text.substring(0, 100);
}
}
function sendCommand(command, payload = {}) {
if(!ws || ws.readyState !== WebSocket.OPEN) {
addEventToLog(`❌ Не удалось отправить: ${command} — нет соединения`);
return;
}
const msg = JSON.stringify({ command, data: payload });
ws.send(msg);
addEventToLog(`📤 cmd: ${command}`);
}
// Обработчик входящих событий LVA
function handleIncomingMessage(ev) {
try {
const json = JSON.parse(ev.data);
if(json.event === 'snapshot') {
updateUIFromSnapshot(json.data);
addEventToLog('📸 Снэпшот получен');
}
else if(json.event === 'wake_word_detected') {
stateText.innerText = '🔍 WAKE WORD';
orb.style.animation = 'pulse-glow 0.5s';
setTimeout(()=> orb.style.animation = '', 500);
addEventToLog('🔥 Пробуждение (wake word)');
}
else if(json.event === 'listening') {
stateText.innerText = '🎙️ LISTENING...';
document.body.classList.add('listening-active');
addEventToLog('🎤 Микрофон активен');
}
else if(json.event === 'processing') {
stateText.innerText = '🧠 PROCESSING';
addEventToLog('⚙️ Обработка речи...');
}
else if(json.event === 'tts_start') {
stateText.innerText = '🔊 SPEAKING';
addEventToLog('🗣️ Ассистент отвечает (TTS)');
}
else if(json.event === 'tts_end') {
if(stateText.innerText === '🔊 SPEAKING') stateText.innerText = 'IDLE';
addEventToLog('🔇 TTS завершён');
}
else if(json.event === 'timer_ringing') {
addEventToLog('⏰ ТАЙМЕР СРАБОТАЛ!');
stateText.innerText = '⏰ TIMER';
}
else if(json.event === 'stt_text') {
if(json.data && json.data.text) lastSttSpan.innerText = json.data.text;
addEventToLog(`🎤 Распознано: ${json.data?.text || ''}`);
}
else if(json.event === 'tts_text') {
if(json.data && json.data.text) lastTtsSpan.innerText = json.data.text;
}
else if(json.event === 'state_change') {
if(json.data?.state) stateText.innerText = json.data.state.toUpperCase();
addEventToLog(`🔄 состояние: ${json.data?.state}`);
}
else {
addEventToLog(`📡 событие: ${json.event}`);
}
if(json.data?.volume !== undefined) {
currentVolume = json.data.volume;
volumeSlider.value = currentVolume;
volumeValueSpan.innerText = currentVolume.toFixed(2);
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); }
}
// Визуализация микрофона (если доступен) — рисуем абстракцию ритма
let fakeAudioArray = new Uint8Array(64);
function drawVisualization() {
if(!ctx) return;
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 = 20;
if(analyser && analyser.fftSize) {
const dataArray = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(dataArray);
let sum = 0;
for(let i=0;i<dataArray.length;i++) sum+=dataArray[i];
avg = Math.min(100, sum/(dataArray.length||1));
} else {
avg = 12 + Math.random() * 25;
if(stateText.innerText.includes('LISTEN') || stateText.innerText.includes('SPEAK')) avg = 45 + Math.random() * 55;
}
ctx.clearRect(0,0,canvas.width, canvas.height);
ctx.fillStyle = '#03151f';
ctx.fillRect(0,0,canvas.width, canvas.height);
const barWidth = (canvas.width / 42);
const numBars = 42;
for(let i=0;i<numBars;i++) {
let barHeight = (avg * (Math.sin(i * 0.5) + 1)/2 + 5) * (0.5 + (Math.random()*0.3));
if(stateText.innerText.includes('SPEAK')) 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, '#3f87ff');
ctx.fillStyle = grad;
ctx.fillRect(x, canvas.height-barHeight, barWidth, barHeight);
}
}
drawVisualization();
// Подключение микрофона для визуализации (опционально)
async function initMicVisualizer() {
try {
if(audioCtx && audioCtx.state !== 'closed') await audioCtx.close();
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
analyser = audioCtx.createAnalyser();
analyser.fftSize = 128;
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaStream = stream;
source = audioCtx.createMediaStreamSource(stream);
source.connect(analyser);
if(audioCtx.state === 'suspended') await audioCtx.resume();
} catch(e) { console.log('mic visual not available', e); }
}
// WebSocket подключение
function connectWebSocket() {
const hostInput = prompt("Введите адрес LVA WebSocket (по умолчанию ws://192.168.200.183:6055):", "ws://192.168.200.183:6055");
if(!hostInput) return;
if(ws && ws.readyState === WebSocket.OPEN) ws.close();
ws = new WebSocket(hostInput);
ws.onopen = () => {
isConnected = true;
statusLed.classList.add('connected');
statusLabel.innerText = 'connected';
addEventToLog(`✅ Соединение установлено с ${hostInput}`);
stateText.innerText = 'ONLINE';
orb.style.animation = 'pulse-glow 0.8s';
setTimeout(()=> orb.style.animation='',700);
initMicVisualizer();
};
ws.onmessage = handleIncomingMessage;
ws.onerror = (err) => { addEventToLog(`⚠️ WebSocket ошибка`); };
ws.onclose = () => {
isConnected = false;
statusLed.classList.remove('connected');
statusLabel.innerText = 'disconnected';
addEventToLog(`🔌 Отключено`);
stateText.innerText = 'OFFLINE';
document.body.classList.remove('listening-active');
if(mediaStream) mediaStream.getTracks().forEach(t=>t.stop());
if(audioCtx) audioCtx.close();
};
}
// кнопки управления
document.getElementById('connectBtn').addEventListener('click', connectWebSocket);
document.getElementById('startListeningBtn').addEventListener('click', () => sendCommand('start_listening'));
document.getElementById('stopPipelineBtn').addEventListener('click', () => sendCommand('stop_pipeline'));
document.getElementById('stopTimerBtn').addEventListener('click', () => sendCommand('stop_timer_ringing'));
document.getElementById('muteBtn').addEventListener('click', () => sendCommand('mute_microphone'));
document.getElementById('unmuteBtn').addEventListener('click', () => sendCommand('unmute_microphone'));
document.getElementById('volUpBtn').addEventListener('click', () => sendCommand('volume_up'));
document.getElementById('volDownBtn').addEventListener('click', () => sendCommand('volume_down'));
document.getElementById('pauseMediaBtn').addEventListener('click', () => sendCommand('pause_media_player'));
document.getElementById('resumeMediaBtn').addEventListener('click', () => sendCommand('resume_media_player'));
document.getElementById('stopMediaBtn').addEventListener('click', () => sendCommand('stop_media_player'));
document.getElementById('singlePressBtn').addEventListener('click', () => sendCommand('single_press'));
document.getElementById('doublePressBtn').addEventListener('click', () => sendCommand('double_press'));
document.getElementById('triplePressBtn').addEventListener('click', () => sendCommand('triple_press'));
document.getElementById('longPressBtn').addEventListener('click', () => sendCommand('long_press'));
document.getElementById('clearEvents').addEventListener('click', () => { while(eventFeedDiv.children.length > 1) eventFeedDiv.removeChild(eventFeedDiv.lastChild); addEventToLog('🧹 Лог очищен'); });
volumeSlider.addEventListener('input', (e) => {
const val = parseFloat(e.target.value);
volumeValueSpan.innerText = val.toFixed(2);
currentVolume = val;
sendCommand('set_volume', { volume: val });
});
// автоподключение при загрузке (опционально)
window.addEventListener('load', () => {
addEventToLog('🤖 Интерфейс LVA NEXUS • готов к подключению');
});
</script>
</body>
</html>