Initial commit: audio-chat with fixes
- Created AGENTS.md with architecture documentation - Fixed race conditions and async patterns - Added conversation history to LLM prompts - Fixed TTS audio shape handling - Added buffer limits and graceful shutdown - Fixed client.py with file sending support - Removed duplicate requirements - Added .gitignore
This commit is contained in:
570
static/index.html
Normal file
570
static/index.html
Normal file
@@ -0,0 +1,570 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Голосовой чат</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0f0f23;
|
||||
color: #e0e0e0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #1a1a3e, #2d1b69);
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 16px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #666;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: #4caf50;
|
||||
box-shadow: 0 0 10px #4caf50;
|
||||
}
|
||||
|
||||
.status-dot.recording {
|
||||
background: #f44336;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
background: #2d1b69;
|
||||
margin-left: auto;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
background: rgba(255,255,255,0.1);
|
||||
margin-right: auto;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.message-label {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.controls {
|
||||
background: rgba(255,255,255,0.05);
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.visualizer {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 14px 32px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #6c3ce0, #9b59b6);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(108, 60, 224, 0.4);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c0392b, #e74c3c);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.text-indicator {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.text-indicator.active {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.loading.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(255,255,255,0.1);
|
||||
border-top-color: #6c3ce0;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 12px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.config-section {
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.config-section label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.config-section input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
.message {
|
||||
max-width: 95%;
|
||||
}
|
||||
button {
|
||||
padding: 12px 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🎙️ Голосовой чат с ИИ</h1>
|
||||
<div class="status">
|
||||
<div class="status-dot" id="statusDot"></div>
|
||||
<span id="statusText">Отключено</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="config-section">
|
||||
<label>URL WebSocket сервера</label>
|
||||
<input type="text" id="wsUrl" value="ws://localhost:8000/ws" placeholder="ws://localhost:8000/ws">
|
||||
</div>
|
||||
|
||||
<div class="chat-area" id="chatArea">
|
||||
<div class="message assistant">
|
||||
<div class="message-label">Ассистент</div>
|
||||
<div class="message-text">Привет! Нажми "Начать запись" и говори со мной.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="loading" id="loading">
|
||||
<div class="spinner"></div>
|
||||
<div>Генерирую ответ...</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<canvas class="visualizer" id="visualizer"></canvas>
|
||||
<div class="text-indicator" id="textIndicator"></div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn-primary" id="btnStart" onclick="startRecording()">
|
||||
🎤 Начать запись
|
||||
</button>
|
||||
<button class="btn-danger" id="btnStop" onclick="stopRecording()" disabled>
|
||||
⏹️ Остановить
|
||||
</button>
|
||||
<button class="btn-secondary" onclick="resetChat()">
|
||||
🔄 Сброс
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio id="audioPlayer" style="display:none"></audio>
|
||||
|
||||
<script>
|
||||
const audioPlayer = document.getElementById('audioPlayer');
|
||||
const chatArea = document.getElementById('chatArea');
|
||||
const statusDot = document.getElementById('statusDot');
|
||||
const statusText = document.getElementById('statusText');
|
||||
const textIndicator = document.getElementById('textIndicator');
|
||||
const loading = document.getElementById('loading');
|
||||
const btnStart = document.getElementById('btnStart');
|
||||
const btnStop = document.getElementById('btnStop');
|
||||
const visualizer = document.getElementById('visualizer');
|
||||
const wsUrlInput = document.getElementById('wsUrl');
|
||||
|
||||
let ws = null;
|
||||
let audioContext = null;
|
||||
let mediaStream = null;
|
||||
let analyser = null;
|
||||
let isRecording = false;
|
||||
let buffer = new Uint8Array();
|
||||
let animationId = null;
|
||||
let textTimeout = null;
|
||||
let currentText = '';
|
||||
|
||||
// Resize visualizer
|
||||
function resizeVisualizer() {
|
||||
visualizer.width = visualizer.offsetWidth;
|
||||
visualizer.height = visualizer.offsetHeight;
|
||||
}
|
||||
resizeVisualizer();
|
||||
window.addEventListener('resize', resizeVisualizer);
|
||||
|
||||
function setStatus(status, text) {
|
||||
statusDot.className = 'status-dot ' + status;
|
||||
statusText.textContent = text;
|
||||
}
|
||||
|
||||
function addMessage(role, text) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'message ' + role;
|
||||
div.innerHTML = `
|
||||
<div class="message-label">${role === 'user' ? 'Вы' : 'Ассистент'}</div>
|
||||
<div class="message-text">${text}</div>
|
||||
`;
|
||||
chatArea.appendChild(div);
|
||||
chatArea.scrollTop = chatArea.scrollHeight;
|
||||
}
|
||||
|
||||
function drawVisualizer() {
|
||||
if (!analyser) return;
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
|
||||
const ctx = visualizer.getContext('2d');
|
||||
ctx.clearRect(0, 0, visualizer.width, visualizer.height);
|
||||
|
||||
const barWidth = (visualizer.width / bufferLength) * 2.5;
|
||||
let x = 0;
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const barHeight = (dataArray[i] / 255) * visualizer.height;
|
||||
ctx.fillStyle = `rgba(108, 60, 224, ${dataArray[i] / 255})`;
|
||||
ctx.fillRect(x, visualizer.height - barHeight, barWidth, barHeight);
|
||||
x += barWidth + 1;
|
||||
}
|
||||
|
||||
if (isRecording) {
|
||||
animationId = requestAnimationFrame(drawVisualizer);
|
||||
}
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
const url = wsUrlInput.value || 'ws://localhost:8000/ws';
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
setStatus('connected', 'Подключено');
|
||||
console.log('WebSocket connected');
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setStatus('', 'Отключено');
|
||||
console.log('WebSocket closed');
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
setStatus('', 'Ошибка подключения');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = event.data;
|
||||
|
||||
if (typeof data === 'string') {
|
||||
if (data.startsWith('TEXT:')) {
|
||||
const text = data.substring(5);
|
||||
currentText = text;
|
||||
textIndicator.textContent = text;
|
||||
textIndicator.classList.add('active');
|
||||
|
||||
// Add user message after delay
|
||||
clearTimeout(textTimeout);
|
||||
textTimeout = setTimeout(() => {
|
||||
addMessage('user', text);
|
||||
textIndicator.textContent = '';
|
||||
textIndicator.classList.remove('active');
|
||||
currentText = '';
|
||||
}, 1500);
|
||||
} else if (data === 'RESET') {
|
||||
console.log('Session reset');
|
||||
}
|
||||
} else if (data instanceof ArrayBuffer) {
|
||||
const bytes = new Uint8Array(data);
|
||||
if (bytes[0] === 0x4F) { // 'O'
|
||||
const audioData = bytes.slice(1);
|
||||
playAudio(audioData);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function playAudio(audioData) {
|
||||
// Convert 24kHz WAV to 48kHz for browser playback
|
||||
const sampleRate = 24000;
|
||||
const targetSampleRate = 48000;
|
||||
const wavHeaderSize = 44;
|
||||
const audioContent = audioData.slice(wavHeaderSize);
|
||||
const numSamples = audioContent.length / 2;
|
||||
|
||||
// Create AudioBuffer
|
||||
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const outputBuffer = audioCtx.createBuffer(1, numSamples * (targetSampleRate / sampleRate), targetSampleRate);
|
||||
const outputData = outputBuffer.getChannelData(0);
|
||||
|
||||
// Resample
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
const inputSample = parseInt(audioContent.slice(i * 2, i * 2 + 2).reverse());
|
||||
const outputSample = inputSample / 32768;
|
||||
const outputIndex = i * (targetSampleRate / sampleRate);
|
||||
|
||||
for (let j = 0; j < (targetSampleRate / sampleRate); j++) {
|
||||
const idx = Math.floor(outputIndex + j);
|
||||
if (idx < outputBuffer.length) {
|
||||
outputData[idx] = outputSample;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create source and play
|
||||
const source = audioCtx.createBufferSource();
|
||||
source.buffer = outputBuffer;
|
||||
source.connect(audioCtx.destination);
|
||||
source.start();
|
||||
|
||||
loading.classList.remove('show');
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
try {
|
||||
// Connect WebSocket
|
||||
connectWebSocket();
|
||||
|
||||
// Request microphone access
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
sampleRate: 48000,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true
|
||||
}
|
||||
});
|
||||
|
||||
// Setup audio context
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const source = audioContext.createMediaStreamSource(mediaStream);
|
||||
|
||||
// Setup analyser for visualization
|
||||
analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
source.connect(analyser);
|
||||
|
||||
isRecording = true;
|
||||
btnStart.disabled = true;
|
||||
btnStop.disabled = false;
|
||||
setStatus('recording', 'Запись...');
|
||||
textIndicator.textContent = 'Слушаю...';
|
||||
textIndicator.classList.add('active');
|
||||
drawVisualizer();
|
||||
|
||||
// Resample from 48kHz to 16kHz and send
|
||||
const scriptProcessor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||
scriptProcessor.onaudioprocess = (event) => {
|
||||
if (!isRecording) return;
|
||||
|
||||
const input = event.inputBuffer.getChannelData(0);
|
||||
// Resample from 48kHz to 16kHz (3:1 ratio)
|
||||
const resampled = [];
|
||||
for (let i = 0; i < input.length; i += 3) {
|
||||
resampled.push(input[i]);
|
||||
}
|
||||
|
||||
// Convert to PCM 16-bit
|
||||
const pcm = new Uint8Array(resampled.length * 2);
|
||||
for (let i = 0; i < resampled.length; i++) {
|
||||
const sample = Math.max(-1, Math.min(1, resampled[i]));
|
||||
const int16 = sample < 0 ? sample * 32768 : sample * 32767;
|
||||
pcm[i * 2] = int16 & 0xFF;
|
||||
pcm[i * 2 + 1] = (int16 >> 8) & 0xFF;
|
||||
}
|
||||
|
||||
// Send via WebSocket
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(new Uint8Array([0x41]).concat(pcm).buffer);
|
||||
}
|
||||
};
|
||||
|
||||
source.connect(scriptProcessor);
|
||||
scriptProcessor.connect(audioContext.destination);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error starting recording:', err);
|
||||
alert('Не удалось получить доступ к микрофону: ' + err.message);
|
||||
isRecording = false;
|
||||
btnStart.disabled = false;
|
||||
btnStop.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
isRecording = false;
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId);
|
||||
}
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach(t => t.stop());
|
||||
}
|
||||
if (audioContext) {
|
||||
audioContext.close();
|
||||
}
|
||||
|
||||
btnStart.disabled = false;
|
||||
btnStop.disabled = true;
|
||||
setStatus('connected', 'Подключено');
|
||||
textIndicator.textContent = '';
|
||||
textIndicator.classList.remove('active');
|
||||
}
|
||||
|
||||
async function resetChat() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(new Uint8Array([0x52]).buffer); // 'R'
|
||||
}
|
||||
chatArea.innerHTML = '';
|
||||
addMessage('assistant', 'Чат сброшен. Говорите со мной!');
|
||||
}
|
||||
|
||||
// Auto-connect on page load
|
||||
window.addEventListener('load', () => {
|
||||
connectWebSocket();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user