- Заменена CSS сфера на dotlottie-player - Анимация реагирует на состояния (idle/listening/speaking) - Скорость анимации зависит от RMS уровня микрофона - Добавлена обработка ошибок загрузки - Сохранены все WebSocket команды и события
653 lines
17 KiB
HTML
653 lines
17 KiB
HTML
<!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>
|