Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Naija Medical Voice Assistant</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --primary: #00d2ff; | |
| --secondary: #3a7bd5; | |
| --bg: #0f172a; | |
| --card: #1e293b; | |
| --text: #f8fafc; | |
| --accent: #ef4444; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| font-family: 'Outfit', sans-serif; | |
| } | |
| body { | |
| background: var(--bg); | |
| background: radial-gradient(circle at top right, #1e293b, #0f172a); | |
| color: var(--text); | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 20px; | |
| } | |
| .container { | |
| width: 100%; | |
| max-width: 500px; | |
| background: rgba(30, 41, 59, 0.7); | |
| backdrop-filter: blur(10px); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| border-radius: 24px; | |
| padding: 40px; | |
| box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); | |
| text-align: center; | |
| } | |
| h1 { | |
| font-size: 2rem; | |
| margin-bottom: 8px; | |
| background: linear-gradient(to right, var(--primary), var(--secondary)); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| p.subtitle { | |
| color: #94a3b8; | |
| margin-bottom: 40px; | |
| font-weight: 300; | |
| } | |
| .status-badge { | |
| display: inline-block; | |
| padding: 6px 12px; | |
| border-radius: 100px; | |
| background: rgba(255, 255, 255, 0.05); | |
| font-size: 0.8rem; | |
| margin-bottom: 24px; | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| } | |
| .mic-container { | |
| position: relative; | |
| margin: 40px 0; | |
| display: flex; | |
| justify-content: center; | |
| } | |
| .mic-button { | |
| width: 100px; | |
| height: 100px; | |
| border-radius: 50%; | |
| background: linear-gradient(135deg, var(--primary), var(--secondary)); | |
| border: none; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| box-shadow: 0 0 30px rgba(0, 210, 255, 0.3); | |
| z-index: 2; | |
| } | |
| .mic-button svg { | |
| width: 40px; | |
| height: 40px; | |
| fill: white; | |
| } | |
| .mic-button:hover { | |
| transform: scale(1.05); | |
| box-shadow: 0 0 50px rgba(0, 210, 255, 0.5); | |
| } | |
| .mic-button.recording { | |
| background: var(--accent); | |
| box-shadow: 0 0 50px rgba(239, 68, 68, 0.5); | |
| animation: pulse 1.5s infinite; | |
| } | |
| @keyframes pulse { | |
| 0% { | |
| transform: scale(1); | |
| } | |
| 50% { | |
| transform: scale(1.1); | |
| } | |
| 100% { | |
| transform: scale(1); | |
| } | |
| } | |
| .pulse-rings { | |
| position: absolute; | |
| width: 100px; | |
| height: 100px; | |
| border-radius: 50%; | |
| border: 2px solid var(--primary); | |
| opacity: 0; | |
| pointer-events: none; | |
| } | |
| .recording .pulse-rings { | |
| animation: ripple 1.5s infinite; | |
| } | |
| @keyframes ripple { | |
| 0% { | |
| transform: scale(1); | |
| opacity: 0.5; | |
| } | |
| 100% { | |
| transform: scale(2); | |
| opacity: 0; | |
| } | |
| } | |
| .chat-box { | |
| margin-top: 30px; | |
| text-align: left; | |
| display: none; | |
| } | |
| .message { | |
| margin-bottom: 20px; | |
| animation: fadeIn 0.5s ease-out; | |
| } | |
| @keyframes fadeIn { | |
| from { | |
| opacity: 0; | |
| transform: translateY(10px); | |
| } | |
| to { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| } | |
| .label { | |
| font-size: 0.7rem; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| color: #64748b; | |
| margin-bottom: 4px; | |
| } | |
| .content { | |
| background: rgba(255, 255, 255, 0.05); | |
| padding: 12px 16px; | |
| border-radius: 12px; | |
| font-size: 0.95rem; | |
| line-height: 1.5; | |
| border-left: 3px solid var(--primary); | |
| } | |
| .content.assistant { | |
| border-left-color: #10b981; | |
| } | |
| .loader { | |
| display: none; | |
| margin: 20px 0; | |
| color: var(--primary); | |
| font-size: 0.9rem; | |
| } | |
| .loader span { | |
| display: inline-block; | |
| animation: bounce 1.4s infinite ease-in-out both; | |
| } | |
| .loader span:nth-child(1) { | |
| animation-delay: -0.32s; | |
| } | |
| .loader span:nth-child(2) { | |
| animation-delay: -0.16s; | |
| } | |
| @keyframes bounce { | |
| 0%, | |
| 80%, | |
| 100% { | |
| transform: scale(0); | |
| } | |
| 40% { | |
| transform: scale(1); | |
| } | |
| } | |
| audio { | |
| display: block; | |
| margin: 20px auto; | |
| width: 100%; | |
| height: 40px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <div class="status-badge" id="status">System: Ready</div> | |
| <h1>Naija Health AI</h1> | |
| <p class="subtitle">Your empathetic medical assistant</p> | |
| <div class="mic-container"> | |
| <div class="pulse-rings"></div> | |
| <button class="mic-button" id="micBtn" title="Click to Record"> | |
| <svg viewBox="0 0 24 24"> | |
| <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" /> | |
| <path | |
| d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" /> | |
| </svg> | |
| </button> | |
| </div> | |
| <!-- <div style="margin-bottom: 30px;" style="display: none;"> | |
| <input type="file" id="fileInput" accept="audio/*"> | |
| <button onclick="document.getElementById('fileInput').click()" | |
| style="background: transparent; border: 1px solid var(--primary); color: var(--primary); padding: 10px 20px; border-radius: 12px; cursor: pointer; font-size: 0.9rem; transition: all 0.3s;"> | |
| 📁 Upload Audio File | |
| </button> | |
| </div> --> | |
| <div class="loader" id="loader"> | |
| AI is thinking<span>.</span><span>.</span><span>.</span> | |
| </div> | |
| <div class="chat-box" id="chatBox"> | |
| <div class="message"> | |
| <div class="label">You said</div> | |
| <div class="content" id="userMsg">...</div> | |
| </div> | |
| <div class="message"> | |
| <div class="label">Assistant</div> | |
| <div class="content assistant" id="assistantMsg">...</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="container" style="margin-top: 20px; padding: 20px;"> | |
| <div class="label" style="text-align: center;">Assistant's Voice</div> | |
| <audio id="audioPlayer" controls></audio> | |
| </div> | |
| <script> | |
| // --- DOM Elements --- | |
| const micBtn = document.getElementById('micBtn'); | |
| const status = document.getElementById('status'); | |
| const loader = document.getElementById('loader'); | |
| const chatBox = document.getElementById('chatBox'); | |
| const userMsg = document.getElementById('userMsg'); | |
| const assistantMsg = document.getElementById('assistantMsg'); | |
| const audioPlayer = document.getElementById('audioPlayer'); | |
| // const fileInput = document.getElementById('fileInput'); | |
| // --- Audio Recording Variables --- | |
| let audioContext; | |
| let mediaStreamSource; | |
| let processor; | |
| let isRecording = false; | |
| let recordedChunks = []; | |
| // --- File Upload Logic --- | |
| // fileInput.onchange = (e) => { | |
| // const file = e.target.files[0]; | |
| // if (file) { | |
| // status.innerText = "Processing uploaded file..."; | |
| // sendAudio(file); | |
| // } | |
| // }; | |
| // --- Microphone Recording Logic --- | |
| micBtn.onclick = async () => { | |
| if (isRecording) { | |
| isRecording = false; | |
| if (processor) processor.disconnect(); | |
| if (mediaStreamSource) mediaStreamSource.disconnect(); | |
| micBtn.classList.remove('recording'); | |
| status.innerText = "Processing..."; | |
| // Export the recorded audio chunks to a real WAV Blob | |
| const wavBlob = exportWAV(recordedChunks, audioContext.sampleRate); | |
| sendAudio(wavBlob); | |
| return; | |
| } | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| mediaStreamSource = audioContext.createMediaStreamSource(stream); | |
| // Create a ScriptProcessorNode to capture raw audio data | |
| processor = audioContext.createScriptProcessor(4096, 1, 1); | |
| recordedChunks = []; | |
| processor.onaudioprocess = function (e) { | |
| if (!isRecording) return; | |
| const channelData = e.inputBuffer.getChannelData(0); | |
| // Copy the Float32Array | |
| recordedChunks.push(new Float32Array(channelData)); | |
| }; | |
| mediaStreamSource.connect(processor); | |
| processor.connect(audioContext.destination); | |
| isRecording = true; | |
| micBtn.classList.add('recording'); | |
| status.innerText = "Listening..."; | |
| } catch (err) { | |
| console.error("Error accessing mic:", err); | |
| alert("Please allow microphone access!"); | |
| } | |
| }; | |
| // --- WAV Encoding Logic --- | |
| function exportWAV(chunks, sampleRate) { | |
| let length = 0; | |
| for (let i = 0; i < chunks.length; i++) { | |
| length += chunks[i].length; | |
| } | |
| const buffer = new Float32Array(length); | |
| let offset = 0; | |
| for (let i = 0; i < chunks.length; i++) { | |
| buffer.set(chunks[i], offset); | |
| offset += chunks[i].length; | |
| } | |
| const wavBuffer = new ArrayBuffer(44 + buffer.length * 2); | |
| const view = new DataView(wavBuffer); | |
| writeString(view, 0, 'RIFF'); | |
| view.setUint32(4, 36 + buffer.length * 2, true); | |
| writeString(view, 8, 'WAVE'); | |
| writeString(view, 12, 'fmt '); | |
| view.setUint32(16, 16, true); | |
| view.setUint16(20, 1, true); // PCM format | |
| view.setUint16(22, 1, true); // Mono channel | |
| view.setUint32(24, sampleRate, true); | |
| view.setUint32(28, sampleRate * 2, true); | |
| view.setUint16(32, 2, true); | |
| view.setUint16(34, 16, true); // 16-bit | |
| writeString(view, 36, 'data'); | |
| view.setUint32(40, buffer.length * 2, true); | |
| floatTo16BitPCM(view, 44, buffer); | |
| return new Blob([view], { type: 'audio/wav' }); | |
| } | |
| function writeString(view, offset, string) { | |
| for (let i = 0; i < string.length; i++) { | |
| view.setUint8(offset + i, string.charCodeAt(i)); | |
| } | |
| } | |
| function floatTo16BitPCM(output, offset, input) { | |
| for (let i = 0; i < input.length; i++, offset += 2) { | |
| let s = Math.max(-1, Math.min(1, input[i])); | |
| output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true); | |
| } | |
| } | |
| // -------------------------- | |
| async function sendAudio(blob) { | |
| loader.style.display = 'block'; | |
| chatBox.style.display = 'none'; | |
| const formData = new FormData(); | |
| formData.append('file', blob, 'recording.wav'); | |
| try { | |
| const response = await fetch('/voice', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| if (!response.ok) throw new Error("Server error"); | |
| // Get text from headers and decode Yoruba characters | |
| userMsg.innerText = decodeURIComponent(response.headers.get('X-Input-Text') || "No text detected"); | |
| assistantMsg.innerText = decodeURIComponent(response.headers.get('X-Response-Text') || "No response generated"); | |
| // Get audio blob | |
| const audioBlob = await response.blob(); | |
| const audioUrl = URL.createObjectURL(audioBlob); | |
| loader.style.display = 'none'; | |
| chatBox.style.display = 'block'; | |
| status.innerText = "Ready"; | |
| audioPlayer.src = audioUrl; | |
| audioPlayer.load(); // Ensure it's loaded | |
| audioPlayer.play().catch(e => { | |
| console.error("Auto-play failed. Please click play manually:", e); | |
| status.innerText = "Click play to hear response"; | |
| }); | |
| } catch (err) { | |
| console.error("Upload failed:", err); | |
| status.innerText = "Error!"; | |
| loader.style.display = 'none'; | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> |