diff --git a/web-client/assist.html b/web-client/assist.html deleted file mode 100644 index f076086..0000000 --- a/web-client/assist.html +++ /dev/null @@ -1,652 +0,0 @@ - - - - - - NOVA Assistant - - - - - - - -
- - -
-
- Отключено -
- - -
- - -
- - -
- - -
В ОЖИДАНИИ
- - -
-
- 🔊 Громкость - 80% -
- -
- - -
-
- - -
-
💬 Последняя фраза
-
—
-
- -
- - - - - diff --git a/web-client/AI robot.lottie b/web-client/html/animation.lottie similarity index 100% rename from web-client/AI robot.lottie rename to web-client/html/animation.lottie diff --git a/web-client/html/droidAssist.css b/web-client/html/droidAssist.css new file mode 100644 index 0000000..668d1b4 --- /dev/null +++ b/web-client/html/droidAssist.css @@ -0,0 +1,396 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; + user-select: none; +} + +/* ===== СЛОЙ 1: BODY — общий фон страницы ===== */ +body { + background: radial-gradient(circle at 30% 20%, #0a0f1e, #03060c); + font-family: 'Segoe UI', 'Inter', system-ui, sans-serif; + min-height: 100vh; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; + position: relative; +} + +/* ===== Частицы ===== */ +.particles { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 0; +} + +.particle { + position: absolute; + width: 2px; + height: 2px; + background: rgba(0, 255, 255, 0.3); + border-radius: 50%; + animation: float 20s infinite; +} + +@keyframes float { + 0%, 100% { transform: translateY(0) translateX(0); opacity: 0; } + 10% { opacity: 1; } + 90% { opacity: 1; } + 100% { transform: translateY(-100vh) translateX(50px); opacity: 0; } +} + +/* ===== Контейнер ассистента ===== */ +.assistant-container { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 40px; + padding: 40px; + max-width: 600px; + width: 100%; +} + +/* ===== Бейдж подключения ===== */ +.connection-badge { + position: absolute; + top: 30px; + right: 30px; + display: flex; + align-items: center; + gap: 10px; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(10px); + padding: 12px 20px; + border-radius: 50px; + border: 1px solid rgba(0, 255, 255, 0.3); + font-size: 14px; + color: #b0e0ff; + font-weight: 500; + z-index: 2; +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: #ff4d4d; + box-shadow: 0 0 10px #ff4d4d; + transition: all 0.3s; +} + +.status-dot.connected { + background: #0ff; + box-shadow: 0 0 20px #0ff; + animation: pulse-dot 2s infinite; +} + +@keyframes pulse-dot { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.2); opacity: 0.8; } +} + +/* ===== СЛОЙ 3: ANIMATION-WRAPPER — непрозрачный тёмный круг ===== + isolation: isolate создаёт локальный stacking context, + чтобы ::before с z-index: -1 был ПОД wrapper, но НАД body +*/ +.animation-wrapper { + position: relative; + width: 420px; + height: 420px; + border-radius: 50%; + isolation: isolate; + + /* CSS-переменные для фона круга и ореола */ + --bg-light: #1a2540; + --bg-dark: #03060c; + --glow-color: rgba(59, 130, 246, 0.55); + + /* Непрозрачный тёмный фон — свой для каждого состояния */ + background: radial-gradient( + circle at 30% 30%, + var(--bg-light) 0%, + #0a0f1e 50%, + var(--bg-dark) 100% + ); + + /* Лёгкая внутренняя подсветка (inset) */ + box-shadow: inset 0 0 40px rgba(0, 0, 0, 0.5); + + transition: background 0.8s ease; +} + +/* ===== СЛОЙ 2: ::BEFORE — свечение-ореол, выходит за границы ===== */ +.animation-wrapper::before { + content: ''; + position: absolute; + /* Выходим за границы круга на 70px с каждой стороны */ + inset: -70px; + border-radius: 50%; + + /* Светящийся ореол */ + background: radial-gradient( + circle at center, + var(--glow-color) 0%, + var(--glow-color) 30%, + rgba(0, 0, 0, 0) 70% + ); + + filter: blur(25px); + opacity: 0.85; + + /* ПОД wrapper, но НАД body благодаря isolation: isolate */ + z-index: -1; + pointer-events: none; + + transition: background 0.8s ease, opacity 0.8s ease; + animation: halo-pulse 3s ease-in-out infinite; +} + +@keyframes halo-pulse { + 0%, 100% { + transform: scale(1); + opacity: 0.7; + } + 50% { + transform: scale(1.08); + opacity: 1; + } +} + +/* ===== СЛОЙ 4: LOTTIE-CANVAS — анимация с прозрачным фоном ===== */ +#lottie-canvas { + width: 100% !important; + height: 100% !important; + display: block; + transform: scale(2); + transform-origin: center center; + position: relative; + z-index: 2; + + /* Обрезаем canvas по кругу, чтобы не вылезал за wrapper */ + clip-path: circle(50% at center); + + /* Фон прозрачный — видно непрозрачный фон wrapper под ним */ + background: transparent; +} + +/* ===== ЦВЕТА ДЛЯ КАЖДОГО СОСТОЯНИЯ ===== */ +.animation-wrapper.idle { + --bg-light: #1a2540; + --bg-dark: #03060c; + --glow-color: rgba(59, 130, 246, 0.55); +} + +.animation-wrapper.yes { + --bg-light: #0f2818; + --bg-dark: #030a06; + --glow-color: rgba(34, 197, 94, 0.65); +} + +.animation-wrapper.no { + --bg-light: #2a1010; + --bg-dark: #0a0303; + --glow-color: rgba(239, 68, 68, 0.7); +} +.animation-wrapper.no::before { + animation: halo-pulse 1s ease-in-out infinite; +} + +.animation-wrapper.alert { + --bg-light: #2a1f08; + --bg-dark: #0a0803; + --glow-color: rgba(245, 158, 11, 0.7); +} +.animation-wrapper.alert::before { + animation: halo-pulse 0.5s ease-in-out infinite; +} + +.animation-wrapper.thinking { + --bg-light: #1a0f2a; + --bg-dark: #06030a; + --glow-color: rgba(168, 85, 247, 0.65); +} +.animation-wrapper.thinking::before { + animation: halo-pulse 1.5s ease-in-out infinite; +} + +.animation-wrapper.jump { + --bg-light: #2a0f1f; + --bg-dark: #0a0306; + --glow-color: rgba(236, 72, 153, 0.75); +} +.animation-wrapper.jump::before { + animation: halo-pulse 0.3s ease-in-out infinite; +} + +/* ===== Оверлей загрузки ===== */ +.loading-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(3, 6, 12, 0.95); + z-index: 10; + border-radius: 50%; + transition: opacity 0.5s; +} +.loading-overlay.hidden { opacity: 0; pointer-events: none; } + +/* ===== Текст статуса ===== */ +.status-text { + font-size: 24px; + font-weight: 600; + color: #00ffff; + text-transform: uppercase; + letter-spacing: 4px; + text-shadow: 0 0 20px rgba(0, 255, 255, 0.5); + animation: text-glow 2s ease-in-out infinite; + min-height: 30px; +} + +@keyframes text-glow { + 0%, 100% { text-shadow: 0 0 20px rgba(0, 255, 255, 0.5); } + 50% { text-shadow: 0 0 40px rgba(0, 255, 255, 0.8); } +} + +/* ===== Контейнер громкости ===== */ +.volume-container { + width: 100%; + max-width: 400px; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(20px); + border: 1px solid rgba(0, 255, 255, 0.3); + border-radius: 30px; + padding: 30px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.volume-header { + display: flex; + justify-content: space-between; + align-items: center; + color: #b0e0ff; + font-size: 14px; + text-transform: uppercase; + letter-spacing: 2px; +} + +.volume-value { + font-size: 24px; + font-weight: 700; + color: #00ffff; + font-family: 'Courier New', monospace; +} + +.volume-slider { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 8px; + background: rgba(0, 255, 255, 0.2); + border-radius: 10px; + outline: none; + transition: all 0.3s; +} + +.volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 24px; + height: 24px; + background: #00ffff; + border-radius: 50%; + cursor: pointer; + box-shadow: 0 0 20px rgba(0, 255, 255, 0.8); +} + +/* ===== Кнопки громкости ===== */ +.vol-btn { + background: rgba(0, 255, 255, 0.1); + border: 1px solid rgba(0, 255, 255, 0.5); + color: #00ffff; + padding: 12px 24px; + border-radius: 50px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.vol-btn:hover { + background: rgba(0, 255, 255, 0.3); + border-color: #00ffff; + box-shadow: 0 0 20px rgba(0, 255, 255, 0.5); +} + +/* ===== Debug-кнопки ===== */ +.debug-buttons { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 12px; + margin-top: 15px; +} + +.debug-btn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(0, 255, 255, 0.4); + color: #0ff; + padding: 8px 16px; + border-radius: 40px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: 0.2s; +} + +.debug-btn:hover { + background: rgba(0, 255, 255, 0.2); + border-color: #0ff; + box-shadow: 0 0 10px rgba(0, 255, 255, 0.4); +} + +/* ===== Transcript box ===== */ +.transcript-box { + width: 100%; + max-width: 500px; + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(15px); + border: 1px solid rgba(0, 255, 255, 0.2); + border-radius: 20px; + padding: 20px; + min-height: 80px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.transcript-label { + font-size: 12px; + color: #7f9fcf; + text-transform: uppercase; + letter-spacing: 2px; +} + +.transcript-text { + font-size: 18px; + color: #eef5ff; + font-style: italic; + line-height: 1.6; +} + +/* ===== Адаптив ===== */ +@media (max-width: 768px) { + .animation-wrapper { width: 280px; height: 280px; } + .status-text { font-size: 20px; } +} \ No newline at end of file diff --git a/web-client/html/droidAssist.html b/web-client/html/droidAssist.html new file mode 100644 index 0000000..b3b4aeb --- /dev/null +++ b/web-client/html/droidAssist.html @@ -0,0 +1,62 @@ + + + + + + Droid Assistant + + + + + + +
+ +
+
+ Отключено +
+ +
+ +
+ +
+
+
+
+ +
В ОЖИДАНИИ
+ +
+
+ 🔊 Громкость + 80% +
+ +
+ + +
+
+ +
+ + + + + + +
+ +
+
💬 Последняя фраза
+
—
+
+
+ + + + + + \ No newline at end of file diff --git a/web-client/html/droidAssist.js b/web-client/html/droidAssist.js new file mode 100644 index 0000000..aeef2e0 --- /dev/null +++ b/web-client/html/droidAssist.js @@ -0,0 +1,244 @@ +import { DotLottie } from 'https://cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-web@0.38.0/+esm'; + +// ==================== КОНФИГУРАЦИЯ ==================== +const WS_URL = 'ws://192.168.200.111:8675'; +const NUM_PARTICLES = 30; +const LOTTIE_SRC = 'animation.lottie'; + +// ==================== СЕГМЕНТЫ АНИМАЦИИ ==================== +const segments = { + idle: { start: 0, end: 29 }, + yes: { start: 31, end: 105 }, + no: { start: 106, end: 180 }, + alert: { start: 181, end: 270 }, + thinking: { start: 271, end: 390 }, + jump: { start: 391, end: 479 } +}; + +// ==================== СОСТОЯНИЕ ==================== +let ws = null; +let isConnected = false; +let currentVolume = 0.8; +let dotLottie = null; +let currentState = 'idle'; +let animationReady = false; + +// ==================== DOM ==================== +const statusDot = document.getElementById('statusDot'); +const statusLabel = document.getElementById('statusLabel'); +const wrapper = document.getElementById('animationWrapper'); +const statusText = document.getElementById('statusText'); +const volumeSlider = document.getElementById('volumeSlider'); +const volumeValue = document.getElementById('volumeValue'); +const volUpBtn = document.getElementById('volUpBtn'); +const volDownBtn = document.getElementById('volDownBtn'); +const transcriptText = document.getElementById('transcriptText'); + +// ==================== ЧАСТИЦЫ ==================== +function initParticles() { + const particles = document.getElementById('particles'); + for (let i = 0; i < NUM_PARTICLES; i++) { + const particle = document.createElement('div'); + particle.className = 'particle'; + particle.style.left = Math.random() * 100 + '%'; + particle.style.animationDelay = Math.random() * 20 + 's'; + particle.style.animationDuration = (15 + Math.random() * 10) + 's'; + particles.appendChild(particle); + } +} + +// ==================== УПРАВЛЕНИЕ АНИМАЦИЕЙ ==================== +window.goToState = function(stateName, statusLabelText) { + if (!animationReady || !dotLottie) return; + + currentState = stateName; + const seg = segments[stateName]; + if (!seg) return; + + dotLottie.stop(); + dotLottie.setSegment(seg.start, seg.end); + + if (stateName === 'idle') { + dotLottie.setSpeed(0.1); + } else { + dotLottie.setSpeed(1); + } + + dotLottie.setLoop(stateName === 'idle'); + dotLottie.play(); + + if (statusLabelText !== undefined) { + statusText.textContent = statusLabelText; + } + + // Обновляем класс для свечения фона + wrapper.className = `animation-wrapper ${stateName}`; +}; + +// ==================== МАППИНГ СОБЫТИЙ SATELLITE → АНИМАЦИЯ ==================== +function handleSatelliteEvent(eventType, eventData) { + const data = eventData || {}; + + switch (eventType) { + // ─── yes: СЛУШАЮ, ГОВОРЮ, АКТИВЕН ─── + case 'voice-started': + case 'stt-start': + goToState('yes', 'СЛУШАЮ'); + break; + + case 'tts-start': + case 'audio-start': + goToState('yes', 'ГОВОРЮ'); + break; + + case 'streaming-started': + goToState('yes', 'АКТИВЕН'); + break; + + // ─── thinking: ОБРАБОТКА ─── + case 'voice-stopped': + case 'stt-stop': + goToState('thinking', 'ОБРАБОТКА'); + break; + + // ─── idle: В ОЖИДАНИИ ─── + case 'played': + case 'tts-stop': + case 'audio-stop': + goToState('idle', 'В ОЖИДАНИИ'); + break; + + // ─── no: ОШИБКА, СПЛЮ ─── + case 'satellite-disconnected': + case 'error': + goToState('no', 'ОШИБКА'); + break; + + case 'pause-satellite': + goToState('no', 'СПЛЮ...'); + break; + + // ─── jump: ПРИВЕТ!!! ─── + case 'detection': + goToState('jump', 'ПРИВЕТ!!!'); + break; + + // ─── transcript / synthesize ─── + case 'transcript': + if (data.text) { + transcriptText.textContent = `"${data.text}"`; + goToState('yes'); + } + break; + + case 'synthesize': + if (data.text) { + transcriptText.textContent = `"${data.text}"`; + goToState('yes'); + } + break; + + // ─── audio-level ─── + case 'audio-level': + break; + + default: + if (eventType !== 'audio-level') { + console.log('Unhandled event:', eventType, data); + } + break; + } +} + +// ==================== WEBSOCKET ==================== +function connectWebSocket() { + ws = new WebSocket(WS_URL); + + ws.onopen = () => { + isConnected = true; + statusDot.classList.add('connected'); + statusLabel.textContent = 'Подключено'; + console.log('✅ WebSocket connected'); + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + const eventType = data.type || data.event; + const eventData = data.data || {}; + if (eventType != "audio-level") { + console.info('EventType:', eventType); + console.info('EventData:', eventData); + } + handleSatelliteEvent(eventType, eventData); + } catch (e) { + console.error('Parse error:', e); + } + }; + + ws.onerror = (error) => console.error('WebSocket error:', error); + + ws.onclose = () => { + isConnected = false; + statusDot.classList.remove('connected'); + statusLabel.textContent = 'Отключено'; + handleSatelliteEvent('satellite-disconnected'); + console.log('❌ WebSocket disconnected'); + setTimeout(connectWebSocket, 3000); + }; +} + +// ==================== ГРОМКОСТЬ ==================== +function updateVolume(newVolume) { + currentVolume = Math.max(0, Math.min(1, newVolume)); + volumeSlider.value = Math.round(currentVolume * 100); + volumeValue.textContent = Math.round(currentVolume * 100) + '%'; + + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + command: 'set_volume', + data: { level: currentVolume } + })); + } +} + +// ==================== ИНИЦИАЛИЗАЦИЯ ==================== +async function init() { + const canvas = document.getElementById('lottie-canvas'); + const overlay = document.getElementById('loadingOverlay'); + + initParticles(); + connectWebSocket(); + + dotLottie = new DotLottie({ + canvas: canvas, + src: LOTTIE_SRC, + autoplay: false, + loop: true, + renderConfig: { preserveAspectRatio: 'xMidYMid meet' } + }); + + dotLottie.addEventListener('load', () => { + animationReady = true; + overlay.classList.add('hidden'); + goToState('idle', 'В ОЖИДАНИИ'); + }); + + dotLottie.addEventListener('complete', () => { + if (currentState !== 'idle') { + goToState('idle', 'В ОЖИДАНИИ'); + } + }); + + // Volume controls + volumeSlider.addEventListener('input', (e) => updateVolume(e.target.value / 100)); + volUpBtn.addEventListener('click', () => updateVolume(currentVolume + 0.1)); + volDownBtn.addEventListener('click', () => updateVolume(currentVolume - 0.1)); +} + +// Запуск после загрузки страницы +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} \ No newline at end of file diff --git a/web-client/html/index.html b/web-client/html/index.html deleted file mode 100644 index 11b7d1e..0000000 --- a/web-client/html/index.html +++ /dev/null @@ -1,128 +0,0 @@ - - - - - - NOVA Voice Assistant - - - - -
- -
- -
-
- disconnected - -
-
- - -
-
- - - -
-
- - -
- -
-
- 🎤 -

Voice Core

-
-
-
-
-
🎙️
-
-
Idle
-
- -
- -
- -
-
RMS Level
-
-
-
-
0.00
-
- -
- 🔊 Volume - - 80% -
-
- - -
-
- - -
-
- 🕹️ -

Commands & Transcripts

-
-
- - - -
-
- - - -
-
- - - - -
-
-
-
You said:
-
—
-
-
-
Assistant said:
-
—
-
-
-
- - -
-
- 📡 -

Telemetry & Events

-
-
-
🔇 Muted: —
-
🏠 HA: —
-
🎚️ Vol: —
-
-
-
⬤ Ready to connect...
-
- -
-
-
- - \ No newline at end of file diff --git a/web-client/html/script.js b/web-client/html/script.js deleted file mode 100644 index 9836145..0000000 --- a/web-client/html/script.js +++ /dev/null @@ -1,255 +0,0 @@ -// ------------------ 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 }); -}); \ No newline at end of file diff --git a/web-client/html/styles.css b/web-client/html/styles.css deleted file mode 100644 index 207e8bc..0000000 --- a/web-client/html/styles.css +++ /dev/null @@ -1,456 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; - user-select: none; -} - -body { - background: radial-gradient(circle at 10% 20%, #0b1120, #04070c); - font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, 'Poppins', sans-serif; - min-height: 100vh; - padding: 24px; - color: #eef5ff; -} - -.app-container { - max-width: 1400px; - margin: 0 auto; -} - -/* Glassmorphism */ -.glass { - background: rgba(15, 25, 45, 0.55); - backdrop-filter: blur(16px); - border-radius: 32px; - border: 1px solid rgba(0, 255, 255, 0.15); - box-shadow: 0 20px 35px -10px rgba(0, 0, 0, 0.4); - transition: all 0.2s ease; -} - -.app-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 24px; - flex-wrap: wrap; - gap: 16px; -} - -.logo { - display: flex; - align-items: center; - gap: 12px; - font-size: 1.8rem; - font-weight: 700; - background: linear-gradient(135deg, #a0f0ff, #6c63ff); - -webkit-background-clip: text; - background-clip: text; - color: transparent; -} - -.logo-icon { - font-size: 2rem; - filter: drop-shadow(0 0 6px cyan); -} - -.logo-text { - letter-spacing: 1px; -} - -.connection-status { - display: flex; - align-items: center; - gap: 16px; - background: rgba(0, 0, 0, 0.3); - padding: 8px 20px; - border-radius: 60px; -} - -.led { - width: 12px; - height: 12px; - border-radius: 50%; - background: #ff4d4d; - box-shadow: 0 0 6px #ff4d4d; - transition: 0.2s; -} - -.led.connected { - background: #0ff; - box-shadow: 0 0 12px #0ff; -} - -.status-text { - font-weight: 500; - font-family: monospace; - font-size: 0.9rem; -} - -.settings-card { - padding: 16px 24px; - margin-bottom: 28px; -} - -.settings-row { - display: flex; - gap: 16px; - align-items: center; - flex-wrap: wrap; -} - -.input-url { - flex: 1; - background: rgba(0, 0, 0, 0.4); - border: 1px solid rgba(0, 255, 255, 0.3); - border-radius: 40px; - padding: 10px 18px; - color: #eef5ff; - font-size: 0.9rem; - outline: none; - transition: 0.2s; -} - -.input-url:focus { - border-color: cyan; - box-shadow: 0 0 8px rgba(0, 255, 255, 0.3); -} - -.auto-connect { - display: flex; - align-items: center; - gap: 8px; - color: #aaa; - font-size: 0.85rem; - cursor: pointer; -} - -.dashboard { - display: grid; - grid-template-columns: 1fr 1.1fr 0.9fr; - gap: 24px; -} - -.card { - padding: 24px; - transition: transform 0.2s, box-shadow 0.2s; -} - -.card:hover { - transform: translateY(-4px); - box-shadow: 0 25px 40px -12px rgba(0, 0, 0, 0.5); -} - -.card-header { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 20px; - border-left: 3px solid #0ff; - padding-left: 14px; -} - -.card-header h3 { - font-weight: 500; - font-size: 1.1rem; - letter-spacing: 0.5px; - color: #bfd9ff; -} - -.header-icon { - font-size: 1.3rem; -} - -/* Голосовая сфера */ -.avatar-container { - display: flex; - flex-direction: column; - align-items: center; - margin: 20px 0; -} - -.orb { - position: relative; - width: 150px; - height: 150px; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 16px; -} - -.orb-inner { - width: 130px; - height: 130px; - background: radial-gradient(circle at 35% 35%, #1ad, #0a4); - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 3.5rem; - box-shadow: 0 0 30px rgba(0, 255, 200, 0.4); - transition: all 0.2s ease; - z-index: 2; -} - -.orb-pulse { - position: absolute; - width: 150px; - height: 150px; - border-radius: 50%; - background: rgba(0, 255, 200, 0.2); - animation: pulse-ring 2s infinite; - z-index: 1; -} - -@keyframes pulse-ring { - 0% { transform: scale(0.9); opacity: 0.6; } - 100% { transform: scale(1.4); opacity: 0; } -} - -/* Активное слушание */ -.listening-active .orb-inner { - animation: listen-pulse 0.8s infinite alternate; - box-shadow: 0 0 50px cyan; -} - -@keyframes listen-pulse { - 0% { transform: scale(1); background: radial-gradient(circle at 35% 35%, #1ad, #0a4); } - 100% { transform: scale(1.05); background: radial-gradient(circle at 35% 35%, #3ff, #2c9); } -} - -.state-badge { - background: rgba(0, 255, 255, 0.15); - backdrop-filter: blur(4px); - padding: 6px 18px; - border-radius: 40px; - font-weight: 600; - font-size: 0.9rem; -} - -/* Визуализатор */ -.viz-wrapper { - background: rgba(0, 0, 0, 0.3); - border-radius: 24px; - padding: 12px; - margin: 15px 0; -} - -canvas { - width: 100%; - height: 80px; - display: block; - border-radius: 16px; -} - -/* RMS meter */ -.rms-container { - margin: 16px 0; -} - -.rms-label { - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 1px; - color: #8aa; - margin-bottom: 6px; -} - -.rms-bar { - height: 6px; - background: #1e2a3a; - border-radius: 3px; - overflow: hidden; - margin-bottom: 6px; -} - -.rms-fill { - height: 100%; - width: 0%; - background: linear-gradient(90deg, #0ff, #f0f); - border-radius: 3px; - transition: width 0.08s linear; -} - -.rms-value { - font-family: monospace; - font-size: 0.8rem; - text-align: right; - color: cyan; -} - -.volume-control { - display: flex; - align-items: center; - gap: 12px; - margin: 20px 0 12px; -} - -.volume-control span:first-child { - font-size: 0.9rem; -} - -.volume-control input { - flex: 1; - height: 4px; - -webkit-appearance: none; - appearance: none; /* стандартное свойство для совместимости */ - background: #2a3a4a; - border-radius: 2px; - outline: none; -} - -.volume-control input::-webkit-slider-thumb { - -webkit-appearance: none; - width: 16px; - height: 16px; - background: cyan; - border-radius: 50%; - cursor: pointer; - box-shadow: 0 0 6px cyan; -} - -.volume-percent { - width: 45px; - text-align: right; - font-family: monospace; -} - -.button-group { - display: flex; - flex-wrap: wrap; - gap: 12px; - margin: 16px 0 8px; -} - -.btn-primary, .btn-secondary, .btn-danger, .btn-outline { - padding: 8px 18px; - border-radius: 40px; - font-weight: 500; - font-size: 0.8rem; - cursor: pointer; - transition: 0.15s; - border: none; - background: rgba(20, 30, 50, 0.7); - backdrop-filter: blur(4px); - color: #eef5ff; - display: inline-flex; - align-items: center; - gap: 8px; - letter-spacing: 0.5px; - border: 1px solid rgba(0, 255, 255, 0.3); -} - -.btn-primary { - background: rgba(0, 200, 255, 0.2); - border-color: #0ff; -} - -.btn-primary:hover { - background: rgba(0, 200, 255, 0.4); - box-shadow: 0 0 10px rgba(0, 255, 255, 0.4); - transform: translateY(-2px); -} - -.btn-secondary:hover { - background: rgba(100, 100, 200, 0.3); - border-color: #8af; - transform: translateY(-2px); -} - -.btn-danger { - border-color: #ff66aa; -} -.btn-danger:hover { - background: #ff336620; - border-color: #ff88cc; -} - -.btn-outline { - background: transparent; - border-color: #6c7a8a; -} -.btn-outline:hover { - border-color: cyan; - background: rgba(0, 255, 255, 0.1); -} - -.full-width { - width: 100%; - justify-content: center; - margin-top: 12px; -} - -.transcript-area { - background: rgba(0, 0, 0, 0.3); - border-radius: 24px; - padding: 16px; - margin-top: 18px; -} - -.transcript-item { - margin-bottom: 14px; -} - -.transcript-label { - font-size: 0.7rem; - text-transform: uppercase; - color: #8ad; - margin-bottom: 6px; -} - -.transcript-text { - font-style: italic; - color: #f0f9ff; - word-break: break-word; - font-size: 0.9rem; -} - -.status-grid { - display: flex; - gap: 16px; - margin-bottom: 20px; - flex-wrap: wrap; -} - -.status-chip { - background: rgba(0, 0, 0, 0.3); - padding: 6px 14px; - border-radius: 40px; - font-size: 0.8rem; -} - -.event-feed { - background: rgba(0, 0, 0, 0.25); - border-radius: 20px; - height: 220px; - overflow-y: auto; - padding: 10px; - font-family: monospace; - font-size: 0.75rem; -} - -.event-item { - padding: 8px 0; - border-bottom: 1px solid #2a4c6e50; - color: #bde; -} - -@media (max-width: 1000px) { - .dashboard { - grid-template-columns: 1fr; - gap: 20px; - } - body { - padding: 16px; - } - .orb { - width: 120px; - height: 120px; - } - .orb-inner { - width: 100px; - height: 100px; - font-size: 2.5rem; - } -} \ No newline at end of file