Spaces:
Runtime error
Runtime error
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Voice AI Assistant</title> | |
| <style> | |
| :root { | |
| --primary-color: #4a90e2; | |
| --success-color: #52c41a; | |
| --danger-color: #ff4d4f; | |
| --bg-color: #f0f2f5; | |
| --card-bg: #ffffff; | |
| --text-color: #333333; | |
| --border-color: #d9d9d9; | |
| } | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| min-height: 100vh; | |
| color: var(--text-color); | |
| } | |
| .container { | |
| max-width: 900px; | |
| margin: 0 auto; | |
| padding: 20px; | |
| } | |
| header { | |
| text-align: center; | |
| color: white; | |
| margin-bottom: 30px; | |
| } | |
| header h1 { | |
| font-size: 2.5em; | |
| margin-bottom: 10px; | |
| } | |
| .subtitle { | |
| font-size: 1.1em; | |
| opacity: 0.9; | |
| } | |
| .main-card { | |
| background: var(--card-bg); | |
| border-radius: 20px; | |
| padding: 30px; | |
| box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1); | |
| margin-bottom: 30px; | |
| } | |
| .recording-section { | |
| text-align: center; | |
| margin-bottom: 30px; | |
| } | |
| .record-btn { | |
| width: 150px; | |
| height: 150px; | |
| border-radius: 50%; | |
| border: none; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| cursor: pointer; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| margin: 0 auto 20px; | |
| transition: all 0.3s ease; | |
| box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4); | |
| } | |
| .record-btn:hover { | |
| transform: scale(1.05); | |
| box-shadow: 0 8px 30px rgba(102, 126, 234, 0.6); | |
| } | |
| .record-btn.recording { | |
| background: linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%); | |
| animation: pulse 1.5s infinite; | |
| } | |
| @keyframes pulse { | |
| 0% { transform: scale(1); } | |
| 50% { transform: scale(1.05); } | |
| 100% { transform: scale(1); } | |
| } | |
| .record-icon { | |
| font-size: 3em; | |
| margin-bottom: 10px; | |
| } | |
| .visualizer { | |
| margin: 20px 0; | |
| height: 100px; | |
| background: #f5f5f5; | |
| border-radius: 10px; | |
| overflow: hidden; | |
| } | |
| .visualizer canvas { | |
| width: 100%; | |
| height: 100%; | |
| } | |
| .status { | |
| font-size: 1.1em; | |
| color: #666; | |
| margin-top: 10px; | |
| } | |
| .status.recording { | |
| color: var(--danger-color); | |
| font-weight: bold; | |
| } | |
| .status.processing { | |
| color: var(--primary-color); | |
| } | |
| .status.success { | |
| color: var(--success-color); | |
| } | |
| .tts-controls { | |
| display: flex; | |
| align-items: center; | |
| gap: 15px; | |
| margin-bottom: 20px; | |
| padding: 15px; | |
| background: #f5f5f5; | |
| border-radius: 10px; | |
| } | |
| .switch { | |
| position: relative; | |
| display: inline-block; | |
| width: 50px; | |
| height: 24px; | |
| } | |
| .switch input { | |
| opacity: 0; | |
| width: 0; | |
| height: 0; | |
| } | |
| .slider { | |
| position: absolute; | |
| cursor: pointer; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background-color: #ccc; | |
| transition: .4s; | |
| border-radius: 24px; | |
| } | |
| .slider:before { | |
| position: absolute; | |
| content: ""; | |
| height: 18px; | |
| width: 18px; | |
| left: 3px; | |
| bottom: 3px; | |
| background-color: white; | |
| transition: .4s; | |
| border-radius: 50%; | |
| } | |
| input:checked + .slider { | |
| background-color: var(--primary-color); | |
| } | |
| input:checked + .slider:before { | |
| transform: translateX(26px); | |
| } | |
| .voice-select { | |
| padding: 8px 12px; | |
| border: 1px solid var(--border-color); | |
| border-radius: 5px; | |
| background: white; | |
| font-size: 14px; | |
| } | |
| .conversation-display { | |
| margin-top: 30px; | |
| padding: 20px; | |
| background: #f9f9f9; | |
| border-radius: 10px; | |
| } | |
| .user-query, .ai-response { | |
| margin-bottom: 20px; | |
| } | |
| .user-query h3, .ai-response h3 { | |
| color: var(--primary-color); | |
| margin-bottom: 10px; | |
| font-size: 1.1em; | |
| } | |
| .user-query p, .ai-response p { | |
| line-height: 1.6; | |
| color: var(--text-color); | |
| } | |
| .speak-btn { | |
| margin-top: 10px; | |
| padding: 8px 16px; | |
| background: var(--primary-color); | |
| color: white; | |
| border: none; | |
| border-radius: 5px; | |
| cursor: pointer; | |
| font-size: 14px; | |
| transition: background 0.3s; | |
| } | |
| .speak-btn:hover { | |
| background: #3a7bc8; | |
| } | |
| .metadata { | |
| margin-top: 20px; | |
| padding: 15px; | |
| background: #f5f5f5; | |
| border-radius: 10px; | |
| } | |
| .metadata h4 { | |
| margin-bottom: 10px; | |
| color: #666; | |
| } | |
| .metadata pre { | |
| font-size: 12px; | |
| color: #666; | |
| white-space: pre-wrap; | |
| word-wrap: break-word; | |
| } | |
| .history-section { | |
| background: var(--card-bg); | |
| border-radius: 20px; | |
| padding: 25px; | |
| box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1); | |
| } | |
| .history-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 20px; | |
| } | |
| .history-header h2 { | |
| color: var(--primary-color); | |
| } | |
| .clear-btn { | |
| padding: 8px 16px; | |
| background: var(--danger-color); | |
| color: white; | |
| border: none; | |
| border-radius: 5px; | |
| cursor: pointer; | |
| font-size: 14px; | |
| } | |
| .clear-btn:hover { | |
| background: #ff7875; | |
| } | |
| .history-list { | |
| max-height: 400px; | |
| overflow-y: auto; | |
| } | |
| .history-item { | |
| padding: 15px; | |
| margin-bottom: 10px; | |
| background: #f9f9f9; | |
| border-radius: 10px; | |
| border-left: 4px solid var(--primary-color); | |
| } | |
| .history-item .timestamp { | |
| font-size: 12px; | |
| color: #999; | |
| margin-bottom: 5px; | |
| } | |
| .history-item .query { | |
| font-weight: 500; | |
| margin-bottom: 5px; | |
| } | |
| .history-item .response { | |
| color: #666; | |
| font-size: 14px; | |
| } | |
| .hidden { | |
| display: none ; | |
| } | |
| .error { | |
| color: var(--danger-color); | |
| padding: 10px; | |
| background: #fff2f0; | |
| border-radius: 5px; | |
| margin-top: 10px; | |
| } | |
| @media (max-width: 768px) { | |
| .container { | |
| padding: 10px; | |
| } | |
| header h1 { | |
| font-size: 2em; | |
| } | |
| .record-btn { | |
| width: 120px; | |
| height: 120px; | |
| } | |
| .record-icon { | |
| font-size: 2.5em; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <header> | |
| <h1>🎙️ Voice AI Assistant</h1> | |
| <p class="subtitle">Ask questions using your voice</p> | |
| </header> | |
| <div class="main-card"> | |
| <!-- Recording Controls --> | |
| <div class="recording-section"> | |
| <button id="recordBtn" class="record-btn"> | |
| <span class="record-icon">🎤</span> | |
| <span class="record-text">Start Recording</span> | |
| </button> | |
| <div id="visualizer" class="visualizer hidden"> | |
| <canvas id="waveform"></canvas> | |
| </div> | |
| <div id="status" class="status"> | |
| Ready to record | |
| </div> | |
| </div> | |
| <!-- TTS Controls --> | |
| <div class="tts-controls"> | |
| <label class="switch"> | |
| <input type="checkbox" id="autoSpeak" checked> | |
| <span class="slider"></span> | |
| </label> | |
| <span>Auto-speak responses</span> | |
| <select id="voiceSelect" class="voice-select"> | |
| <option value="">Default Voice</option> | |
| </select> | |
| </div> | |
| <!-- Current Conversation --> | |
| <div id="currentConversation" class="conversation-display hidden"> | |
| <div class="user-query"> | |
| <h3>You asked:</h3> | |
| <p id="userText"></p> | |
| </div> | |
| <div class="ai-response"> | |
| <h3>AI Response:</h3> | |
| <p id="aiText"></p> | |
| <button id="speakBtn" class="speak-btn">🔊 Speak</button> | |
| </div> | |
| </div> | |
| <!-- Metadata Display --> | |
| <div id="metadata" class="metadata hidden"> | |
| <h4>Session Details</h4> | |
| <pre id="metadataContent"></pre> | |
| </div> | |
| </div> | |
| <!-- Conversation History --> | |
| <div class="history-section"> | |
| <div class="history-header"> | |
| <h2>Conversation History</h2> | |
| <button id="clearHistory" class="clear-btn">Clear All</button> | |
| </div> | |
| <div id="historyList" class="history-list"></div> | |
| </div> | |
| </div> | |
| <script> | |
| class VoiceAIApp { | |
| constructor() { | |
| this.backendUrl = 'http://localhost:8000'; | |
| this.mediaRecorder = null; | |
| this.audioChunks = []; | |
| this.isRecording = false; | |
| this.recognition = null; | |
| this.synthesis = window.speechSynthesis; | |
| this.voices = []; | |
| this.currentSession = null; | |
| this.initializeElements(); | |
| this.initializeEventListeners(); | |
| this.loadVoices(); | |
| this.loadHistory(); | |
| } | |
| initializeElements() { | |
| this.elements = { | |
| recordBtn: document.getElementById('recordBtn'), | |
| status: document.getElementById('status'), | |
| visualizer: document.getElementById('visualizer'), | |
| waveform: document.getElementById('waveform'), | |
| autoSpeak: document.getElementById('autoSpeak'), | |
| voiceSelect: document.getElementById('voiceSelect'), | |
| currentConversation: document.getElementById('currentConversation'), | |
| userText: document.getElementById('userText'), | |
| aiText: document.getElementById('aiText'), | |
| speakBtn: document.getElementById('speakBtn'), | |
| metadata: document.getElementById('metadata'), | |
| metadataContent: document.getElementById('metadataContent'), | |
| historyList: document.getElementById('historyList'), | |
| clearHistory: document.getElementById('clearHistory') | |
| }; | |
| } | |
| initializeEventListeners() { | |
| this.elements.recordBtn.addEventListener('click', () => this.toggleRecording()); | |
| this.elements.speakBtn.addEventListener('click', () => this.speakResponse()); | |
| this.elements.clearHistory.addEventListener('click', () => this.clearHistory()); | |
| // Load voices when they change | |
| this.synthesis.addEventListener('voiceschanged', () => this.loadVoices()); | |
| } | |
| loadVoices() { | |
| this.voices = this.synthesis.getVoices(); | |
| this.elements.voiceSelect.innerHTML = '<option value="">Default Voice</option>'; | |
| this.voices.forEach((voice, index) => { | |
| const option = document.createElement('option'); | |
| option.value = index; | |
| option.textContent = `${voice.name} (${voice.lang})`; | |
| this.elements.voiceSelect.appendChild(option); | |
| }); | |
| } | |
| async toggleRecording() { | |
| if (this.isRecording) { | |
| this.stopRecording(); | |
| } else { | |
| this.startRecording(); | |
| } | |
| } | |
| async startRecording() { | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| // Setup MediaRecorder | |
| const mimeType = 'audio/webm'; | |
| this.mediaRecorder = new MediaRecorder(stream, { mimeType }); | |
| this.audioChunks = []; | |
| this.mediaRecorder.ondataavailable = (event) => { | |
| if (event.data.size > 0) { | |
| this.audioChunks.push(event.data); | |
| } | |
| }; | |
| this.mediaRecorder.onstop = async () => { | |
| const audioBlob = new Blob(this.audioChunks, { type: mimeType }); | |
| await this.processAudio(audioBlob); | |
| stream.getTracks().forEach(track => track.stop()); | |
| }; | |
| this.mediaRecorder.start(); | |
| this.isRecording = true; | |
| // Update UI | |
| this.elements.recordBtn.classList.add('recording'); | |
| this.elements.recordBtn.querySelector('.record-text').textContent = 'Stop Recording'; | |
| this.elements.status.textContent = 'Recording... Speak now'; | |
| this.elements.status.className = 'status recording'; | |
| this.elements.visualizer.classList.remove('hidden'); | |
| // Start visualizer | |
| this.startVisualizer(stream); | |
| } catch (error) { | |
| console.error('Error accessing microphone:', error); | |
| this.showError('Could not access microphone. Please check permissions.'); | |
| } | |
| } | |
| stopRecording() { | |
| if (this.mediaRecorder && this.isRecording) { | |
| this.mediaRecorder.stop(); | |
| this.isRecording = false; | |
| // Update UI | |
| this.elements.recordBtn.classList.remove('recording'); | |
| this.elements.recordBtn.querySelector('.record-text').textContent = 'Start Recording'; | |
| this.elements.status.textContent = 'Processing audio...'; | |
| this.elements.status.className = 'status processing'; | |
| this.elements.visualizer.classList.add('hidden'); | |
| } | |
| } | |
| startVisualizer(stream) { | |
| const audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| const analyser = audioContext.createAnalyser(); | |
| const microphone = audioContext.createMediaStreamSource(stream); | |
| const canvas = this.elements.waveform; | |
| const ctx = canvas.getContext('2d'); | |
| analyser.fftSize = 256; | |
| microphone.connect(analyser); | |
| const bufferLength = analyser.frequencyBinCount; | |
| const dataArray = new Uint8Array(bufferLength); | |
| canvas.width = canvas.offsetWidth; | |
| canvas.height = canvas.offsetHeight; | |
| const draw = () => { | |
| if (!this.isRecording) return; | |
| requestAnimationFrame(draw); | |
| analyser.getByteFrequencyData(dataArray); | |
| ctx.fillStyle = '#f5f5f5'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| const barWidth = (canvas.width / bufferLength) * 2.5; | |
| let barHeight; | |
| let x = 0; | |
| for (let i = 0; i < bufferLength; i++) { | |
| barHeight = (dataArray[i] / 255) * canvas.height; | |
| const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height); | |
| gradient.addColorStop(0, '#667eea'); | |
| gradient.addColorStop(1, '#764ba2'); | |
| ctx.fillStyle = gradient; | |
| ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight); | |
| x += barWidth + 1; | |
| } | |
| }; | |
| draw(); | |
| } | |
| async processAudio(audioBlob) { | |
| try { | |
| const formData = new FormData(); | |
| formData.append('audio', audioBlob, 'recording.webm'); | |
| const response = await fetch(`${this.backendUrl}/api/process-audio`, { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`HTTP error! status: ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| if (data.success) { | |
| this.displayConversation(data); | |
| this.saveToHistory(data); | |
| if (this.elements.autoSpeak.checked) { | |
| this.speakText(data.ai_response); | |
| } | |
| this.elements.status.textContent = 'Success! Response received'; | |
| this.elements.status.className = 'status success'; | |
| } else { | |
| throw new Error(data.error || 'Processing failed'); | |
| } | |
| } catch (error) { | |
| console.error('Error processing audio:', error); | |
| this.showError(`Error: ${error.message}`); | |
| } | |
| } | |
| displayConversation(data) { | |
| this.currentSession = data; | |
| this.elements.userText.textContent = data.user_query; | |
| this.elements.aiText.textContent = data.ai_response; | |
| this.elements.currentConversation.classList.remove('hidden'); | |
| // Display metadata | |
| this.elements.metadataContent.textContent = JSON.stringify({ | |
| session_id: data.session_id, | |
| timestamp: data.timestamp, | |
| ...data.metadata | |
| }, null, 2); | |
| this.elements.metadata.classList.remove('hidden'); | |
| } | |
| speakResponse() { | |
| if (this.currentSession) { | |
| this.speakText(this.currentSession.ai_response); | |
| } | |
| } | |
| speakText(text) { | |
| // Cancel any ongoing speech | |
| this.synthesis.cancel(); | |
| const utterance = new SpeechSynthesisUtterance(text); | |
| // Set voice if selected | |
| const selectedVoiceIndex = this.elements.voiceSelect.value; | |
| if (selectedVoiceIndex && this.voices[selectedVoiceIndex]) { | |
| utterance.voice = this.voices[selectedVoiceIndex]; | |
| } | |
| // Set speech parameters | |
| utterance.rate = 0.9; | |
| utterance.pitch = 1; | |
| utterance.volume = 1; | |
| this.synthesis.speak(utterance); | |
| } | |
| saveToHistory(data) { | |
| // Update history display | |
| this.loadHistory(); | |
| } | |
| async loadHistory() { | |
| try { | |
| const response = await fetch(`${this.backendUrl}/api/history`); | |
| const data = await response.json(); | |
| this.elements.historyList.innerHTML = ''; | |
| data.sessions.reverse().forEach(session => { | |
| const item = document.createElement('div'); | |
| item.className = 'history-item'; | |
| item.innerHTML = ` | |
| <div class="timestamp">${new Date(session.timestamp).toLocaleString()}</div> | |
| <div class="query"><strong>Q:</strong> ${session.user_query}</div> | |
| <div class="response"><strong>A:</strong> ${session.ai_response}</div> | |
| `; | |
| this.elements.historyList.appendChild(item); | |
| }); | |
| } catch (error) { | |
| console.error('Error loading history:', error); | |
| } | |
| } | |
| async clearHistory() { | |
| if (confirm('Are you sure you want to clear all conversation history?')) { | |
| try { | |
| await fetch(`${this.backendUrl}/api/history`, { | |
| method: 'DELETE' | |
| }); | |
| this.elements.historyList.innerHTML = ''; | |
| } catch (error) { | |
| console.error('Error clearing history:', error); | |
| } | |
| } | |
| } | |
| showError(message) { | |
| this.elements.status.textContent = message; | |
| this.elements.status.className = 'status error'; | |
| } | |
| } | |
| // Initialize app when DOM is loaded | |
| document.addEventListener('DOMContentLoaded', () => { | |
| new VoiceAIApp(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |