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:
2026-05-25 17:58:37 +03:00
co-authored by Qwen-Coder
parent 61294485ad
commit 2b4e6f824b
7 changed files with 2017 additions and 1209 deletions
+675
View File
@@ -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>