feat: русский UI, уровень микрофона, прослушивание, таймауты

This commit is contained in:
2026-06-01 22:27:19 +03:00
parent 8f62f88bb7
commit 5f0ca9e138
2 changed files with 145 additions and 51 deletions
+122 -39
View File
@@ -3,69 +3,152 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Say — Voice Assistant</title> <title>Say — Голосовой ассистент</title>
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
</head> </head>
<body> <body>
<h1>Say</h1>
<h1>🎙️ Say</h1>
<div id="models"> <div id="models">
<button class="model-btn active" data-model="gpt-oss-120b">GPT-OSS-120B</button> <button class="model-btn active">GPT-OSS-120B</button>
</div> </div>
<div id="visualizer">
<canvas id="meter" width="300" height="60"></canvas>
</div>
<div id="controls">
<button id="btnRecord">🎙 Записать</button>
<button id="btnPlay" disabled>▶ Прослушать</button>
</div>
<div id="status"></div>
<div id="chat"></div> <div id="chat"></div>
<div id="recorder">
<button id="mic-btn">Start Recording</button>
<span id="status"></span>
</div>
<script> <script>
let mediaRecorder, chunks = []; const btnRecord = document.getElementById('btnRecord');
const micBtn = document.getElementById('mic-btn'); const btnPlay = document.getElementById('btnPlay');
const status = document.getElementById('status'); const status = document.getElementById('status');
const chat = document.getElementById('chat'); const chat = document.getElementById('chat');
let activeModel = 'gpt-oss-120b'; const meter = document.getElementById('meter');
const meterCtx = meter.getContext('2d');
document.querySelectorAll('.model-btn').forEach(b => { let mediaRecorder, chunks = [], userBlob = null;
b.addEventListener('click', () => { let isRecording = false, micAnalyser = null, micAnimId = null;
document.querySelectorAll('.model-btn').forEach(x => x.classList.remove('active')); let audioContext = null;
b.classList.add('active');
activeModel = b.dataset.model;
});
});
micBtn.addEventListener('click', async () => { function setStatus(msg) { status.textContent = msg; }
if (mediaRecorder && mediaRecorder.state === 'recording') {
mediaRecorder.stop(); btnRecord.onclick = async () => {
micBtn.textContent = 'Processing...'; if (isRecording) { stopRecording(); return; }
return; try {
isRecording = true;
btnRecord.textContent = '⏹ Стоп';
setStatus('🎤 Запись...');
await startRecording();
} catch (err) {
let msg = '⛔ ';
if (err.name === 'NotAllowedError') msg += 'Доступ к микрофону запрещён. Разрешите в настройках браузера.';
else if (err.name === 'NotFoundError') msg += 'Микрофон не найден.';
else msg += 'Ошибка: ' + (err.message || err.name);
setStatus(msg);
btnRecord.textContent = '🎙 Записать';
isRecording = false;
} }
};
async function startRecording() {
chunks = []; chunks = [];
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioContext = new AudioContext();
const source = audioContext.createMediaStreamSource(stream);
micAnalyser = audioContext.createAnalyser();
micAnalyser.fftSize = 256;
source.connect(micAnalyser);
drawMeter();
mediaRecorder = new MediaRecorder(stream); mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = e => chunks.push(e.data); mediaRecorder.ondataavailable = e => chunks.push(e.data);
mediaRecorder.onstop = async () => { mediaRecorder.onstop = async () => {
stream.getTracks().forEach(t => t.stop()); stream.getTracks().forEach(t => t.stop());
const blob = new Blob(chunks, { type: 'audio/webm' }); cancelAnimationFrame(micAnimId);
const fd = new FormData(); fd.append('audio', blob); if (audioContext) audioContext.close();
status.textContent = 'Transcribing...'; userBlob = new Blob(chunks, { type: 'audio/webm' });
const t = await fetch('/api/whisper', { method: 'POST', body: fd }).then(r => r.json()); btnPlay.disabled = false;
chat.innerHTML += '<div class="user">You: ' + t.text + '</div>'; await transcribeAndChat();
status.textContent = activeModel + ' thinking...';
const r = await fetch('/api/chat', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: activeModel, message: t.text }),
}).then(r => r.json());
chat.innerHTML += '<div class="bot">' + activeModel + ': ' + r.response + '</div>';
chat.scrollTop = chat.scrollHeight;
micBtn.textContent = 'Start Recording';
status.textContent = '';
}; };
mediaRecorder.start(); mediaRecorder.start();
micBtn.textContent = 'Stop Recording'; }
status.textContent = 'Recording...';
function stopRecording() {
btnRecord.disabled = true;
setStatus('⏳ Обработка...');
mediaRecorder.stop();
}
function drawMeter() {
if (!micAnalyser) return;
const data = new Uint8Array(micAnalyser.frequencyBinCount);
micAnalyser.getByteTimeDomainData(data);
const w = meter.width, h = meter.height;
meterCtx.fillStyle = '#1a1a2e';
meterCtx.fillRect(0, 0, w, h);
meterCtx.strokeStyle = '#6c5ce7';
meterCtx.lineWidth = 2;
meterCtx.beginPath();
for (let i = 0; i < data.length; i++) {
const x = (i / data.length) * w;
const y = (data[i] / 255) * h;
i === 0 ? meterCtx.moveTo(x, y) : meterCtx.lineTo(x, y);
}
meterCtx.stroke();
micAnimId = requestAnimationFrame(drawMeter);
}
btnPlay.onclick = () => {
if (!userBlob) return;
const url = URL.createObjectURL(userBlob);
const a = new Audio(url);
a.onended = () => URL.revokeObjectURL(url);
a.play();
setStatus('🔊 Ваша запись...');
};
async function transcribeAndChat() {
try {
setStatus('📝 Распознавание...');
const fd = new FormData();
fd.append('audio', userBlob);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);
const tRes = await fetch('/api/whisper', { method: 'POST', body: fd, signal: controller.signal });
clearTimeout(timeout);
const tData = await tRes.json();
const text = tData.text || '(не распознано)';
chat.innerHTML += '<div class="user">🗣 Вы: ' + text + '</div>';
setStatus('🤖 Думает...');
const cRes = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'gpt-oss-120b', message: text }),
signal: (() => { const ac = new AbortController(); setTimeout(() => ac.abort(), 60000); return ac.signal; })(),
}); });
const cData = await cRes.json();
chat.innerHTML += '<div class="bot">🤖 GPT-OSS: ' + (cData.response || cData.error || 'Ошибка') + '</div>';
chat.scrollTop = chat.scrollHeight;
setStatus('✅ Готово');
} catch (err) {
setStatus('⛔ Ошибка: ' + err.message);
}
btnRecord.textContent = '🎙 Записать';
btnRecord.disabled = false;
isRecording = false;
userBlob = null;
btnPlay.disabled = true;
}
</script> </script>
</body> </body>
</html> </html>
+22 -11
View File
@@ -1,11 +1,22 @@
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; background: #111; color: #eee; } * { box-sizing: border-box; }
h1 { text-align: center; color: #6c5ce7; } body { font-family: system-ui, -apple-system, sans-serif; max-width: 640px; margin: 0 auto; padding: 20px; background: #0d0d1a; color: #e0e0e0; }
#models { display: flex; gap: 8px; justify-content: center; margin: 16px 0; } h1 { text-align: center; color: #7c6ff7; margin-bottom: 8px; }
.model-btn { padding: 8px 16px; border: 1px solid #6c5ce7; background: transparent; color: #6c5ce7; border-radius: 6px; cursor: pointer; }
.model-btn.active { background: #6c5ce7; color: #fff; } #models { display: flex; gap: 8px; justify-content: center; margin: 12px 0; }
#chat { border: 1px solid #333; border-radius: 8px; padding: 16px; min-height: 300px; max-height: 400px; overflow-y: auto; margin: 16px 0; } .model-btn { padding: 6px 14px; border: 1px solid #7c6ff7; background: transparent; color: #7c6ff7; border-radius: 6px; font-size: 13px; cursor: default; }
.user { color: #00cec9; margin: 8px 0; } .model-btn.active { background: #7c6ff7; color: #fff; }
.bot { color: #a29bfe; margin: 8px 0; }
#recorder { text-align: center; } #visualizer { text-align: center; margin: 10px 0; }
#mic-btn { padding: 12px 32px; background: #e84393; color: #fff; border: none; border-radius: 8px; font-size: 16px; cursor: pointer; } #meter { border-radius: 6px; background: #1a1a2e; }
#status { display: inline-block; margin-left: 12px; color: #999; }
#controls { display: flex; gap: 10px; justify-content: center; margin: 12px 0; }
#btnRecord { padding: 10px 28px; background: #e84393; color: #fff; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; }
#btnRecord:disabled { opacity: 0.5; cursor: not-allowed; }
#btnPlay { padding: 10px 20px; background: #00b894; color: #fff; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; }
#btnPlay:disabled { opacity: 0.4; cursor: not-allowed; }
#status { text-align: center; color: #888; font-size: 14px; min-height: 20px; margin: 8px 0; }
#chat { border: 1px solid #2a2a3e; border-radius: 8px; padding: 14px; min-height: 240px; max-height: 380px; overflow-y: auto; margin: 12px 0; background: #13132a; }
.user { color: #00cec9; margin: 8px 0; word-break: break-word; }
.bot { color: #a29bfe; margin: 8px 0; word-break: break-word; }