feat: русский UI, уровень микрофона, прослушивание, таймауты
This commit is contained in:
+123
-40
@@ -3,69 +3,152 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Say</h1>
|
||||
|
||||
<h1>🎙️ Say</h1>
|
||||
|
||||
<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 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="recorder">
|
||||
<button id="mic-btn">Start Recording</button>
|
||||
<span id="status"></span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let mediaRecorder, chunks = [];
|
||||
const micBtn = document.getElementById('mic-btn');
|
||||
const btnRecord = document.getElementById('btnRecord');
|
||||
const btnPlay = document.getElementById('btnPlay');
|
||||
const status = document.getElementById('status');
|
||||
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 => {
|
||||
b.addEventListener('click', () => {
|
||||
document.querySelectorAll('.model-btn').forEach(x => x.classList.remove('active'));
|
||||
b.classList.add('active');
|
||||
activeModel = b.dataset.model;
|
||||
});
|
||||
});
|
||||
let mediaRecorder, chunks = [], userBlob = null;
|
||||
let isRecording = false, micAnalyser = null, micAnimId = null;
|
||||
let audioContext = null;
|
||||
|
||||
micBtn.addEventListener('click', async () => {
|
||||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||
mediaRecorder.stop();
|
||||
micBtn.textContent = 'Processing...';
|
||||
return;
|
||||
function setStatus(msg) { status.textContent = msg; }
|
||||
|
||||
btnRecord.onclick = async () => {
|
||||
if (isRecording) { stopRecording(); 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 = [];
|
||||
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.ondataavailable = e => chunks.push(e.data);
|
||||
mediaRecorder.onstop = async () => {
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
const blob = new Blob(chunks, { type: 'audio/webm' });
|
||||
const fd = new FormData(); fd.append('audio', blob);
|
||||
status.textContent = 'Transcribing...';
|
||||
const t = await fetch('/api/whisper', { method: 'POST', body: fd }).then(r => r.json());
|
||||
chat.innerHTML += '<div class="user">You: ' + t.text + '</div>';
|
||||
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 = '';
|
||||
cancelAnimationFrame(micAnimId);
|
||||
if (audioContext) audioContext.close();
|
||||
userBlob = new Blob(chunks, { type: 'audio/webm' });
|
||||
btnPlay.disabled = false;
|
||||
await transcribeAndChat();
|
||||
};
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user