feat: WebSocket команды — раздельная громкость mic/snd + запись аудио + обновлённый web-client
- satellite.py: разделён _runtime_volume на _runtime_mic_volume и _runtime_snd_volume - satellite.py: добавлены команды start_recording/stop_recording (WAV через base64) - satellite.py: метод _create_wav_from_buffer() для создания WAV из буфера - web-client: добавлен слайдер громкости микрофона (MIC+/MIC-) - web-client: добавлены кнопки записи и воспроизведения аудио - web-client: улучшенный лог событий и отладочные console.log - docs: websocket-server-plan.md сокращён с 1205 до ~80 строк Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
@@ -0,0 +1,675 @@
|
||||
<!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>
|
||||
<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) */
|
||||
.orb-container {
|
||||
position: relative;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.orb {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 35% 35%, #00ffff, #0066aa);
|
||||
box-shadow:
|
||||
0 0 60px rgba(0, 255, 255, 0.4),
|
||||
inset 0 0 40px rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Анимация пульсации при прослушивании */
|
||||
.orb.listening {
|
||||
animation: orb-pulse 1.5s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 0 100px rgba(0, 255, 255, 0.8),
|
||||
0 0 150px rgba(0, 255, 255, 0.4),
|
||||
inset 0 0 60px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
@keyframes orb-pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
box-shadow:
|
||||
0 0 60px rgba(0, 255, 255, 0.4),
|
||||
inset 0 0 40px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
box-shadow:
|
||||
0 0 120px rgba(0, 255, 255, 0.8),
|
||||
0 0 180px rgba(0, 255, 255, 0.5),
|
||||
inset 0 0 80px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Анимация при говорении */
|
||||
.orb.speaking {
|
||||
animation: orb-speak 0.8s ease-in-out infinite;
|
||||
background: radial-gradient(circle at 35% 35%, #ff00ff, #ff0088);
|
||||
box-shadow:
|
||||
0 0 100px rgba(255, 0, 255, 0.6),
|
||||
0 0 150px rgba(255, 0, 255, 0.3);
|
||||
}
|
||||
|
||||
@keyframes orb-speak {
|
||||
0%, 100% { transform: scale(1); }
|
||||
25% { transform: scale(1.03); }
|
||||
75% { transform: scale(0.98); }
|
||||
}
|
||||
|
||||
/* RMS визуализация внутри сферы */
|
||||
.orb-waves {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wave-bar {
|
||||
width: 6px;
|
||||
height: 20px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 3px;
|
||||
transition: height 0.1s ease;
|
||||
}
|
||||
|
||||
/* Статус текст */
|
||||
.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">
|
||||
|
||||
<!-- Сфера с анимацией -->
|
||||
<div class="orb-container">
|
||||
<div class="orb" id="orb">
|
||||
<div class="orb-waves" id="orbWaves">
|
||||
<!-- Волны будут добавлены через JS -->
|
||||
</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="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 orb = document.getElementById('orb');
|
||||
const orbWaves = document.getElementById('orbWaves');
|
||||
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 initWaveBars() {
|
||||
orbWaves.innerHTML = '';
|
||||
for (let i = 0; i < NUM_WAVE_BARS; i++) {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'wave-bar';
|
||||
bar.dataset.index = i;
|
||||
orbWaves.appendChild(bar);
|
||||
}
|
||||
}
|
||||
|
||||
// Создать фоновые частицы
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== АНИМАЦИЯ ВОЛН ====================
|
||||
function animateWaves() {
|
||||
const bars = orbWaves.querySelectorAll('.wave-bar');
|
||||
|
||||
bars.forEach((bar, index) => {
|
||||
let height = 20;
|
||||
|
||||
if (currentState === 'listening' || currentState === 'speaking') {
|
||||
// Анимация на основе RMS
|
||||
const rmsScaled = Math.min(1, currentRms * 5); // Усиление RMS
|
||||
const baseHeight = 20 + rmsScaled * 80;
|
||||
const wave = Math.sin(Date.now() / 200 + index * 0.5) * 20;
|
||||
height = baseHeight + wave;
|
||||
} else {
|
||||
// Минимальная анимация в idle
|
||||
height = 20 + Math.sin(Date.now() / 500 + index * 0.3) * 5;
|
||||
}
|
||||
|
||||
bar.style.height = height + 'px';
|
||||
});
|
||||
|
||||
requestAnimationFrame(animateWaves);
|
||||
}
|
||||
|
||||
// ==================== 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() {
|
||||
orb.classList.remove('listening', 'speaking');
|
||||
|
||||
if (currentState === 'listening') {
|
||||
orb.classList.add('listening');
|
||||
} else if (currentState === 'speaking') {
|
||||
orb.classList.add('speaking');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== УПРАВЛЕНИЕ ГРОМКОСТЬЮ ====================
|
||||
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');
|
||||
initWaveBars();
|
||||
initParticles();
|
||||
animateWaves();
|
||||
connectWebSocket();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user