- 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>
676 lines
18 KiB
HTML
676 lines
18 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>
|
||
<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>
|