Files
audio-chat/static/index.html
noturum 1edfd5d62f 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
2026-05-01 13:01:06 +00:00

571 lines
18 KiB
HTML
Raw Permalink 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">
<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>