refactor: update web-client files - replace old HTML/JS/CSS with new droidAssist implementation

- Remove old: assist.html, index.html, script.js, styles.css, AI robot.lottie
- Add new: droidAssist.html, droidAssist.css, droidAssist.js, animation.lottie
- Restructure web-client files into html/ directory
This commit is contained in:
2026-05-28 13:19:00 +03:00
parent 8a4686c800
commit 3cc8bb8aef
8 changed files with 702 additions and 1491 deletions
-652
View File
@@ -1,652 +0,0 @@
<!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 Assistant</title>
<!-- Lottie Player -->
<script src="https://unpkg.com/@dotlottie/player-component@latest/dist/dotlottie-player.mjs" type="module"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
user-select: none;
}
body {
background: radial-gradient(circle at 30% 20%, #0a0f1e, #03060c);
font-family: 'Segoe UI', 'Inter', system-ui, -apple-system, 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;
}
.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; }
}
/* Главная сфера (ORB) - Lottie анимация */
.orb-container {
position: relative;
width: 400px;
height: 400px;
display: flex;
align-items: center;
justify-content: center;
}
.lottie-player {
width: 100%;
height: 100%;
filter: drop-shadow(0 0 40px rgba(0, 255, 255, 0.3));
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Эффекты для разных состояний */
.lottie-player.listening {
filter: drop-shadow(0 0 80px rgba(0, 255, 255, 0.8))
drop-shadow(0 0 120px rgba(0, 255, 255, 0.5));
animation: lottie-pulse 1.5s ease-in-out infinite;
}
@keyframes lottie-pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
.lottie-player.speaking {
filter: drop-shadow(0 0 80px rgba(255, 0, 255, 0.8))
drop-shadow(0 0 120px rgba(255, 0, 255, 0.5));
animation: lottie-speak 0.8s ease-in-out infinite;
}
@keyframes lottie-speak {
0%, 100% { transform: scale(1); }
25% { transform: scale(1.03); }
75% { transform: scale(0.98); }
}
/* Статус текст */
.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;
}
@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;
appearance: none;
width: 24px;
height: 24px;
background: #00ffff;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 20px rgba(0, 255, 255, 0.8);
transition: all 0.2s;
}
.volume-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
box-shadow: 0 0 30px rgba(0, 255, 255, 1);
}
.volume-slider::-moz-range-thumb {
width: 24px;
height: 24px;
background: #00ffff;
border-radius: 50%;
cursor: pointer;
border: none;
box-shadow: 0 0 20px rgba(0, 255, 255, 0.8);
transition: all 0.2s;
}
.volume-slider::-moz-range-thumb:hover {
transform: scale(1.2);
box-shadow: 0 0 30px rgba(0, 255, 255, 1);
}
/* Кнопки быстрой регулировки */
.volume-buttons {
display: flex;
gap: 15px;
justify-content: center;
}
.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;
backdrop-filter: blur(10px);
}
.vol-btn:hover {
background: rgba(0, 255, 255, 0.3);
border-color: #00ffff;
box-shadow: 0 0 20px rgba(0, 255, 255, 0.5);
transform: translateY(-2px);
}
.vol-btn:active {
transform: translateY(0);
}
/* Последняя фраза */
.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) {
.assistant-container {
padding: 20px;
gap: 30px;
}
.orb-container {
width: 250px;
height: 250px;
}
.orb {
width: 200px;
height: 200px;
}
.status-text {
font-size: 20px;
}
.connection-badge {
top: 20px;
right: 20px;
font-size: 12px;
padding: 10px 16px;
}
}
</style>
</head>
<body>
<!-- Фоновые частицы -->
<div class="particles" id="particles"></div>
<!-- Статус подключения -->
<div class="connection-badge">
<div class="status-dot" id="statusDot"></div>
<span id="statusLabel">Отключено</span>
</div>
<!-- Главный контейнер -->
<div class="assistant-container">
<!-- Lottie анимация робота -->
<div class="orb-container">
<dotlottie-player
id="lottiePlayer"
class="lottie-player"
src="AI robot.lottie"
background="transparent"
speed="1"
loop
autoplay
intermission="0">
</dotlottie-player>
</div>
<!-- Статус -->
<div class="status-text" id="statusText">В ОЖИДАНИИ</div>
<!-- Управление громкостью -->
<div class="volume-container">
<div class="volume-header">
<span>🔊 Громкость</span>
<span class="volume-value" id="volumeValue">80%</span>
</div>
<input type="range" class="volume-slider" id="volumeSlider"
min="0" max="100" value="80" step="1">
<div class="volume-buttons">
<button class="vol-btn" id="volDownBtn">🔉 -10%</button>
<button class="vol-btn" id="volUpBtn">🔊 +10%</button>
</div>
</div>
<!-- Последняя фраза -->
<div class="transcript-box">
<div class="transcript-label">💬 Последняя фраза</div>
<div class="transcript-text" id="transcriptText">—</div>
</div>
</div>
<script>
// ==================== КОНФИГУРАЦИЯ ====================
const WS_URL = 'ws://192.168.200.111:8675';
const NUM_WAVE_BARS = 12;
const NUM_PARTICLES = 30;
// ==================== СОСТОЯНИЕ ====================
let ws = null;
let isConnected = false;
let currentVolume = 0.8;
let currentRms = 0.0;
let currentState = 'idle'; // idle, listening, speaking
// ==================== DOM ЭЛЕМЕНТЫ ====================
const statusDot = document.getElementById('statusDot');
const statusLabel = document.getElementById('statusLabel');
const lottiePlayer = document.getElementById('lottiePlayer');
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);
}
}
// ==================== УПРАВЛЕНИЕ LOTTIE ====================
function updateLottieSpeed() {
if (!lottiePlayer) return;
// Изменяем скорость анимации в зависимости от состояния и RMS
let speed = 1.0;
if (currentState === 'listening') {
// При прослушивании скорость зависит от RMS
const rmsScaled = Math.min(1, currentRms * 5);
speed = 0.8 + rmsScaled * 1.2; // 0.8 - 2.0
} else if (currentState === 'speaking') {
speed = 1.5; // Быстрее при говорении
} else {
speed = 0.6; // Медленнее в idle
}
lottiePlayer.setSpeed(speed);
}
// Обновление скорости анимации в реальном времени
function animateLottie() {
updateLottieSpeed();
requestAnimationFrame(animateLottie);
}
// ==================== WEBSOCKET ====================
function connectWebSocket() {
if (ws && ws.readyState === WebSocket.OPEN) {
return;
}
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);
handleEvent(data);
} 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 = 'Отключено';
currentState = 'idle';
updateOrbState();
console.log('❌ WebSocket disconnected');
// Переподключение через 3 секунды
setTimeout(connectWebSocket, 3000);
};
}
// ==================== ОБРАБОТКА СОБЫТИЙ ====================
function handleEvent(data) {
const eventType = data.type || data.event;
switch (eventType) {
case 'audio-level':
if (data.data && data.data.level !== undefined) {
currentRms = data.data.level;
}
break;
case 'voice-started':
currentState = 'listening';
statusText.textContent = 'СЛУШАЮ';
updateOrbState();
break;
case 'voice-stopped':
currentState = 'idle';
statusText.textContent = 'ОБРАБОТКА';
updateOrbState();
break;
case 'transcript':
if (data.data && data.data.text) {
transcriptText.textContent = `"${data.data.text}"`;
}
break;
case 'synthesize':
if (data.data && data.data.text) {
transcriptText.textContent = `"${data.data.text}"`;
}
break;
case 'tts-start':
case 'audio-start':
currentState = 'speaking';
statusText.textContent = 'ГОВОРЮ';
updateOrbState();
break;
case 'tts-stop':
case 'audio-stop':
case 'played':
// Проверяем что мы действительно были в режиме speaking
if (currentState === 'speaking') {
currentState = 'idle';
statusText.textContent = 'В ОЖИДАНИИ';
updateOrbState();
}
break;
case 'streaming-started':
currentState = 'listening';
statusText.textContent = 'АКТИВЕН';
updateOrbState();
break;
case 'streaming-stopped':
// Только если мы были в listening, переходим в idle
if (currentState === 'listening') {
currentState = 'idle';
statusText.textContent = 'В ОЖИДАНИИ';
updateOrbState();
}
break;
case 'stt-start':
currentState = 'listening';
statusText.textContent = 'СЛУШАЮ';
updateOrbState();
break;
case 'stt-stop':
currentState = 'idle';
statusText.textContent = 'ОБРАБОТКА';
updateOrbState();
break;
}
}
// ==================== ОБНОВЛЕНИЕ СОСТОЯНИЯ АНИМАЦИИ ====================
function updateOrbState() {
if (!lottiePlayer) return;
lottiePlayer.classList.remove('listening', 'speaking');
if (currentState === 'listening') {
lottiePlayer.classList.add('listening');
} else if (currentState === 'speaking') {
lottiePlayer.classList.add('speaking');
}
updateLottieSpeed();
}
// ==================== УПРАВЛЕНИЕ ГРОМКОСТЬЮ ====================
function sendVolumeCommand(level) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
console.warn('WebSocket not connected');
return;
}
const msg = JSON.stringify({
command: 'set_volume',
data: { level: level }
});
ws.send(msg);
}
function updateVolume(newVolume) {
currentVolume = Math.max(0, Math.min(1, newVolume));
volumeSlider.value = Math.round(currentVolume * 100);
volumeValue.textContent = Math.round(currentVolume * 100) + '%';
sendVolumeCommand(currentVolume);
}
// ==================== ОБРАБОТЧИКИ СОБЫТИЙ ====================
volumeSlider.addEventListener('input', (e) => {
const value = parseInt(e.target.value) / 100;
updateVolume(value);
});
volUpBtn.addEventListener('click', () => {
updateVolume(currentVolume + 0.1);
});
volDownBtn.addEventListener('click', () => {
updateVolume(currentVolume - 0.1);
});
// ==================== ЗАПУСК ====================
window.addEventListener('load', () => {
console.log('🚀 NOVA Assistant initialized');
initParticles();
animateLottie();
connectWebSocket();
// Дождаться загрузки Lottie
if (lottiePlayer) {
lottiePlayer.addEventListener('ready', () => {
console.log('✅ Lottie animation loaded');
updateLottieSpeed();
});
lottiePlayer.addEventListener('error', (e) => {
console.error('❌ Lottie animation error:', e);
console.log('Trying to load first animation...');
// Попробовать загрузить первую анимацию из файла
lottiePlayer.load('AI robot.lottie');
});
lottiePlayer.addEventListener('load', () => {
console.log('✅ Lottie file loaded successfully');
});
}
});
</script>
</body>
</html>
+396
View File
@@ -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; }
}
+62
View File
@@ -0,0 +1,62 @@
<!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>Droid Assistant</title>
<!-- Стили -->
<link rel="stylesheet" href="droidAssist.css">
</head>
<body>
<div class="particles" id="particles"></div>
<div class="connection-badge">
<div class="status-dot" id="statusDot"></div>
<span id="statusLabel">Отключено</span>
</div>
<div class="assistant-container">
<div class="animation-wrapper idle" id="animationWrapper">
<canvas id="lottie-canvas" width="700" height="700"></canvas>
<div class="loading-overlay" id="loadingOverlay">
<div class="spinner"></div>
</div>
</div>
<div class="status-text" id="statusText">В ОЖИДАНИИ</div>
<div class="volume-container">
<div class="volume-header">
<span>🔊 Громкость</span>
<span class="volume-value" id="volumeValue">80%</span>
</div>
<input type="range" class="volume-slider" id="volumeSlider" min="0" max="100" value="80" step="1">
<div class="volume-buttons">
<button class="vol-btn" id="volDownBtn">🔉 -10%</button>
<button class="vol-btn" id="volUpBtn">🔊 +10%</button>
</div>
</div>
<div class="debug-buttons">
<button class="debug-btn" onclick="goToState('yes')">👍 Yes</button>
<button class="debug-btn" onclick="goToState('thinking')">🤔 Think</button>
<button class="debug-btn" onclick="goToState('alert')">⚠️ Alert</button>
<button class="debug-btn" onclick="goToState('no')">❌ No</button>
<button class="debug-btn" onclick="goToState('jump')">🦘 Jump</button>
<button class="debug-btn" onclick="goToState('idle')">😐 Idle</button>
</div>
<div class="transcript-box">
<div class="transcript-label">💬 Последняя фраза</div>
<div class="transcript-text" id="transcriptText">—</div>
</div>
</div>
<!-- Скрипт как модуль (нужен для импорта DotLottie) -->
<script type="module" src="droidAssist.js"></script>
</body>
</html>
+244
View File
@@ -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();
}
-128
View File
@@ -1,128 +0,0 @@
<!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 Voice Assistant</title>
<link rel="stylesheet" href="styles.css">
<script src="script.js" defer></script>
</head>
<body>
<div class="app-container">
<!-- Верхняя панель -->
<header class="app-header">
<div class="logo">
<span class="logo-icon">🎙️</span>
<span class="logo-text">NOVA Assistant</span>
</div>
<div class="connection-status">
<div class="led" id="statusLed"></div>
<span class="status-text" id="statusLabel">disconnected</span>
<button id="connectBtn" class="btn-outline">⟳ Connect</button>
</div>
</header>
<!-- Блок настроек WebSocket -->
<div class="settings-card glass">
<div class="settings-row">
<input type="text" id="wsUrlInput" value="ws://192.168.200.111:8675" placeholder="ws://ip:port" class="input-url">
<button id="connectBtnCard" class="btn-primary">Connect</button>
<label class="auto-connect">
<input type="checkbox" id="autoConnect" checked> Auto-connect
</label>
</div>
</div>
<!-- Основная сетка -->
<div class="dashboard">
<!-- Левая колонка: голосовой аватар -->
<div class="card voice-card glass">
<div class="card-header">
<span class="header-icon">🎤</span>
<h3>Voice Core</h3>
</div>
<div class="avatar-container">
<div class="orb" id="orbCore">
<div class="orb-pulse"></div>
<div class="orb-inner">🎙️</div>
</div>
<div class="state-badge" id="stateText">Idle</div>
</div>
<!-- Визуализатор аудио (волны) -->
<div class="viz-wrapper">
<canvas id="audioCanvas" width="500" height="80"></canvas>
</div>
<!-- RMS-метр -->
<div class="rms-container">
<div class="rms-label">RMS Level</div>
<div class="rms-bar">
<div class="rms-fill" id="rmsFill"></div>
</div>
<div class="rms-value" id="rmsValue">0.00</div>
</div>
<!-- Регулировка громкости -->
<div class="volume-control">
<span>🔊 Volume</span>
<input type="range" id="volumeSlider" min="0" max="1" step="0.01" value="0.8">
<span id="volumeValue" class="volume-percent">80%</span>
</div>
<div class="button-group">
<button id="muteBtn" class="btn-secondary">🔇 Mute</button>
<button id="unmuteBtn" class="btn-secondary">🎤 Unmute</button>
</div>
</div>
<!-- Центр: команды и транскрипция -->
<div class="card commands-card glass">
<div class="card-header">
<span class="header-icon">🕹️</span>
<h3>Commands & Transcripts</h3>
</div>
<div class="button-group">
<button id="startListeningBtn" class="btn-primary">🎧 Start Listen</button>
<button id="stopPipelineBtn" class="btn-danger">⏹️ Stop Pipeline</button>
<button id="stopTimerBtn" class="btn-secondary">⏲️ Stop Timer</button>
</div>
<div class="button-group">
<button id="volUpBtn" class="btn-secondary">➕ Vol+</button>
<button id="volDownBtn" class="btn-secondary">➖ Vol-</button>
<button id="requestRmsBtn" class="btn-secondary">📊 Request RMS</button>
</div>
<div class="button-group">
<button id="singlePressBtn" class="btn-outline">Single</button>
<button id="doublePressBtn" class="btn-outline">Double</button>
<button id="triplePressBtn" class="btn-outline">Triple</button>
<button id="longPressBtn" class="btn-outline">Long</button>
</div>
<div class="transcript-area">
<div class="transcript-item">
<div class="transcript-label">You said:</div>
<div id="lastStt" class="transcript-text">—</div>
</div>
<div class="transcript-item">
<div class="transcript-label">Assistant said:</div>
<div id="lastTts" class="transcript-text">—</div>
</div>
</div>
</div>
<!-- Правая колонка: телеметрия и логи -->
<div class="card telemetry-card glass">
<div class="card-header">
<span class="header-icon">📡</span>
<h3>Telemetry & Events</h3>
</div>
<div class="status-grid">
<div class="status-chip"><span>🔇 Muted:</span> <strong id="mutedStatus">—</strong></div>
<div class="status-chip"><span>🏠 HA:</span> <strong id="haStatus">—</strong></div>
<div class="status-chip"><span>🎚️ Vol:</span> <strong id="liveVol">—</strong></div>
</div>
<div class="event-feed" id="eventFeed">
<div class="event-item">⬤ Ready to connect...</div>
</div>
<button id="clearEvents" class="btn-secondary full-width">Clear log</button>
</div>
</div>
</div>
</body>
</html>
-255
View File
@@ -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 = `<span style="color:#4effb5;">${time}</span> • ${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 });
});
-456
View File
@@ -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;
}
}