Files
satellite/web-client/assist.html
T
sirmrakandQwen-Coder 2b4e6f824b 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>
2026-05-25 17:58:37 +03:00

676 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>