Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Real-time Audio Stream</title> | |
| <!-- Tailwind CSS for styling --> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <style> | |
| body { | |
| font-family: "Inter", sans-serif; | |
| background-color: #f3f4f6; | |
| } | |
| </style> | |
| </head> | |
| <body class="flex items-center justify-center min-h-screen"> | |
| <div class="bg-white p-8 rounded-xl shadow-lg w-full max-w-2xl mx-4"> | |
| <h1 class="text-3xl font-bold text-center mb-4 text-gray-800">Real-time Audio Stream</h1> | |
| <p class="text-center text-gray-600 mb-6"> | |
| Capturing your microphone audio and sending it to a Python server. The server will echo the audio back with a 2-second delay. | |
| </p> | |
| <!-- Status Indicator --> | |
| <div id="status-container" class="flex items-center justify-center mb-6"> | |
| <span id="status-dot-send" class="block h-3 w-3 rounded-full mr-2"></span> | |
| <span id="status-text-send" class="text-sm font-medium text-gray-700 mr-4">Connecting Send...</span> | |
| <span id="status-dot-receive" class="block h-3 w-3 rounded-full mr-2"></span> | |
| <span id="status-text-receive" class="text-sm font-medium text-gray-700">Connecting Receive...</span> | |
| </div> | |
| <!-- Control Buttons --> | |
| <div class="flex justify-center space-x-4"> | |
| <button id="startButton" disabled class="bg-emerald-500 text-white font-semibold py-3 px-6 rounded-lg shadow-md hover:bg-emerald-600 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed"> | |
| Start | |
| </button> | |
| <button id="stopButton" disabled class="bg-red-500 text-white font-semibold py-3 px-6 rounded-lg shadow-md hover:bg-red-600 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed"> | |
| Stop | |
| </button> | |
| </div> | |
| <!-- Debugging Log --> | |
| <div class="mt-8"> | |
| <h2 class="text-xl font-semibold mb-2 text-gray-800">Chunk Information</h2> | |
| <div id="chunk-log" class="bg-gray-100 p-4 rounded-lg overflow-y-scroll h-48 text-sm text-gray-700"> | |
| <p id="initial-log-message">Waiting for audio chunks...</p> | |
| </div> | |
| <p id="chunk-count" class="text-right text-xs text-gray-500 mt-2">Chunks received: 0</p> | |
| </div> | |
| <!-- Message box for user feedback --> | |
| <div id="message-box" class="mt-6 text-sm text-center text-gray-500"></div> | |
| </div> | |
| <script> | |
| // --- Configuration --- | |
| const SEND_WEBSOCKET_URL = "ws://localhost:8765"; | |
| const RECEIVE_WEBSOCKET_URL = "ws://localhost:8766"; | |
| const CHUNK_DURATION = 1; // seconds per chunk | |
| // --- DOM Elements --- | |
| const startButton = document.getElementById('startButton'); | |
| const stopButton = document.getElementById('stopButton'); | |
| const statusDotSend = document.getElementById('status-dot-send'); | |
| const statusTextSend = document.getElementById('status-text-send'); | |
| const statusDotReceive = document.getElementById('status-dot-receive'); | |
| const statusTextReceive = document.getElementById('status-text-receive'); | |
| const messageBox = document.getElementById('message-box'); | |
| const chunkLog = document.getElementById('chunk-log'); | |
| const initialLogMessage = document.getElementById('initial-log-message'); | |
| const chunkCountDisplay = document.getElementById('chunk-count'); | |
| // --- Global State Variables --- | |
| let audioContext; | |
| let micSource; | |
| let scriptNode; | |
| let gainNode; | |
| let accumulatedAudio = []; | |
| let chunkSamples; | |
| let sendWebSocket; | |
| let receiveWebSocket; | |
| let isRecording = false; | |
| let audioQueue = []; | |
| let isPlaying = false; | |
| let nextPlayTime = 0; | |
| let sendChunkCounter = 0; | |
| let receiveChunkCounter = 0; | |
| let expectedChunkNumber = 1; | |
| // --- Helper Functions --- | |
| function setStatus(connection, dotColor, text) { | |
| if (connection === 'send') { | |
| statusDotSend.className = `block h-3 w-3 rounded-full mr-2 bg-${dotColor}-500`; | |
| statusTextSend.textContent = text; | |
| } else if (connection === 'receive') { | |
| statusDotReceive.className = `block h-3 w-3 rounded-full mr-2 bg-${dotColor}-500`; | |
| statusTextReceive.textContent = text; | |
| } | |
| } | |
| function showMessage(text, isError = false) { | |
| messageBox.textContent = text; | |
| messageBox.className = `mt-6 text-sm text-center ${isError ? 'text-red-500' : 'text-gray-500'}`; | |
| } | |
| function getCurrentTime() { | |
| const now = new Date(); | |
| const hours = String(now.getHours()).padStart(2, '0'); | |
| const minutes = String(now.getMinutes()).padStart(2, '0'); | |
| const seconds = String(now.getSeconds()).padStart(2, '0'); | |
| return `${hours}:${minutes}:${seconds}`; | |
| } | |
| function appendChunkLog(chunkNumber, id, loudness, isSent = false, timeToReceive = null, timeToPlay = null) { | |
| if (initialLogMessage) { | |
| initialLogMessage.remove(); | |
| } | |
| const logEntry = document.createElement('p'); | |
| if (isSent) { | |
| logEntry.textContent = `[${getCurrentTime()}] SENT chunk #${chunkNumber}`; | |
| logEntry.className = 'py-1 border-b border-gray-200 last:border-b-0 text-gray-500 italic'; | |
| } else { | |
| let timeString = ''; | |
| if (timeToReceive !== null) { | |
| timeString += ` | Receive time: ${timeToReceive.toFixed(2)}ms`; | |
| } | |
| if (timeToPlay !== null) { | |
| timeString += ` | Play time: ${timeToPlay.toFixed(2)}ms`; | |
| } | |
| logEntry.textContent = `[${getCurrentTime()}] RECEIVED chunk #${chunkNumber}: Loudness: ${loudness.toFixed(2)}${timeString}`; | |
| logEntry.className = 'py-1 border-b border-gray-200 last:border-b-0 font-bold text-gray-800'; | |
| receiveChunkCounter++; | |
| chunkCountDisplay.textContent = `Chunks received: ${receiveChunkCounter}`; | |
| } | |
| chunkLog.appendChild(logEntry); | |
| chunkLog.scrollTop = chunkLog.scrollHeight; | |
| } | |
| // --- Web Audio Playback Logic --- | |
| function playbackLoop() { | |
| if (!isPlaying) return; | |
| console.log('PlaybackLoop called, audioQueue length:', audioQueue.length, 'expected:', expectedChunkNumber); | |
| // Sort queue by chunkNumber | |
| audioQueue.sort((a, b) => a.chunkNumber - b.chunkNumber); | |
| // Skip missing chunks | |
| while (audioQueue.length > 0 && audioQueue[0].chunkNumber < expectedChunkNumber) { | |
| audioQueue.shift(); | |
| } | |
| let played = false; | |
| while (audioQueue.length > 0 && audioQueue[0].chunkNumber === expectedChunkNumber) { | |
| console.log('Processing chunk #', audioQueue[0].chunkNumber); | |
| played = true; | |
| const chunkData = audioQueue.shift(); | |
| const { audioData, chunkNumber, loudness, receivedTime, id } = chunkData; | |
| const playbackStartTime = performance.now(); | |
| try { | |
| const binaryString = window.atob(audioData); | |
| const len = binaryString.length; | |
| const bytes = new Uint8Array(len); | |
| for (let i = 0; i < len; i++) { | |
| bytes[i] = binaryString.charCodeAt(i); | |
| } | |
| const int16Arr = new Int16Array(bytes.buffer); | |
| const float32Arr = new Float32Array(int16Arr.length); | |
| for (let i = 0; i < int16Arr.length; i++) { | |
| float32Arr[i] = int16Arr[i] / 32768; | |
| } | |
| const audioBuffer = audioContext.createBuffer( | |
| 1, | |
| float32Arr.length, | |
| audioContext.sampleRate | |
| ); | |
| audioBuffer.copyToChannel(float32Arr, 0); | |
| const playbackEndTime = performance.now(); | |
| const timeToPlay = playbackEndTime - playbackStartTime; | |
| const timeToReceive = receivedTime - chunkData.workerPostTime; | |
| appendChunkLog(chunkNumber, id, loudness, false, timeToReceive, timeToPlay); | |
| const source = audioContext.createBufferSource(); | |
| source.buffer = audioBuffer; | |
| source.connect(audioContext.destination); | |
| const currentTime = audioContext.currentTime; | |
| const delay = nextPlayTime - currentTime; | |
| source.start(currentTime + Math.max(0, delay)); | |
| nextPlayTime = Math.max(nextPlayTime, currentTime) + audioBuffer.duration; | |
| expectedChunkNumber++; | |
| // Set onended only if no more to play immediately | |
| if (audioQueue.length === 0 || audioQueue[0].chunkNumber !== expectedChunkNumber) { | |
| source.onended = playbackLoop; | |
| } | |
| } catch (e) { | |
| console.error(`Error processing audio data for chunk #${chunkNumber}:`, e); | |
| } | |
| } | |
| if (!played) { | |
| setTimeout(playbackLoop, 10); | |
| } | |
| } | |
| // --- Main WebSocket and Audio Logic --- | |
| function connectWebSockets() { | |
| startButton.disabled = true; | |
| stopButton.disabled = true; | |
| setStatus('send', 'yellow', 'Connecting Send...'); | |
| setStatus('receive', 'yellow', 'Connecting Receive...'); | |
| showMessage('Attempting to connect to both servers...'); | |
| try { | |
| // Connect the send websocket | |
| sendWebSocket = new WebSocket(SEND_WEBSOCKET_URL); | |
| sendWebSocket.onopen = () => { | |
| console.log("Send WebSocket connection established."); | |
| setStatus('send', 'green', 'Send Connected'); | |
| if (receiveWebSocket && receiveWebSocket.readyState === WebSocket.OPEN) { | |
| startButton.disabled = false; | |
| stopButton.disabled = true; | |
| showMessage('Successfully connected to both servers. You can now start recording.'); | |
| } | |
| }; | |
| sendWebSocket.onclose = () => { | |
| console.log("Send WebSocket connection closed."); | |
| setStatus('send', 'red', 'Send Disconnected'); | |
| startButton.disabled = true; | |
| stopButton.disabled = true; | |
| isRecording = false; | |
| }; | |
| sendWebSocket.onerror = (error) => { | |
| console.error("Send WebSocket error:", error); | |
| setStatus('send', 'red', 'Send Error'); | |
| showMessage('Could not connect to the send server. Is the Python server running?', true); | |
| }; | |
| // Connect the receive websocket | |
| receiveWebSocket = new WebSocket(RECEIVE_WEBSOCKET_URL); | |
| receiveWebSocket.onopen = () => { | |
| console.log("Receive WebSocket connection established."); | |
| setStatus('receive', 'green', 'Receive Connected'); | |
| if (sendWebSocket && sendWebSocket.readyState === WebSocket.OPEN) { | |
| startButton.disabled = false; | |
| stopButton.disabled = true; | |
| showMessage('Successfully connected to both servers. You can now start recording.'); | |
| } | |
| }; | |
| receiveWebSocket.onmessage = (event) => { | |
| console.log('Receive WebSocket message received'); | |
| const workerPostTime = performance.now(); | |
| const chunkData = JSON.parse(event.data); | |
| const receivedTime = performance.now(); | |
| chunkData.workerPostTime = workerPostTime; | |
| chunkData.receivedTime = receivedTime; | |
| audioQueue.push(chunkData); | |
| playbackLoop(); | |
| }; | |
| receiveWebSocket.onclose = () => { | |
| console.log("Receive WebSocket connection closed."); | |
| setStatus('receive', 'red', 'Receive Disconnected'); | |
| startButton.disabled = true; | |
| stopButton.disabled = true; | |
| isRecording = false; | |
| isPlaying = false; | |
| audioQueue = []; | |
| }; | |
| receiveWebSocket.onerror = (error) => { | |
| console.error("Receive WebSocket error."); | |
| setStatus('receive', 'red', 'Receive Error'); | |
| showMessage('Could not connect to the receive server. Is the Python server running?', true); | |
| }; | |
| } catch (e) { | |
| console.error("Failed to create WebSockets:", e); | |
| setStatus('send', 'red', 'Send Failed'); | |
| setStatus('receive', 'red', 'Receive Failed'); | |
| showMessage('An error occurred. Check your server URLs.', true); | |
| startButton.disabled = true; | |
| stopButton.disabled = true; | |
| } | |
| } | |
| async function startRecording() { | |
| if (sendWebSocket && sendWebSocket.readyState === WebSocket.OPEN) { | |
| try { | |
| await audioContext.resume(); | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| micSource = audioContext.createMediaStreamSource(stream); | |
| scriptNode = audioContext.createScriptProcessor(4096, 1, 1); | |
| gainNode = audioContext.createGain(); | |
| gainNode.gain.value = 0; | |
| micSource.connect(scriptNode); | |
| scriptNode.connect(gainNode); | |
| gainNode.connect(audioContext.destination); | |
| chunkSamples = Math.round(audioContext.sampleRate * CHUNK_DURATION); | |
| accumulatedAudio = []; | |
| scriptNode.onaudioprocess = (e) => { | |
| if (!isRecording) return; | |
| const data = e.inputBuffer.getChannelData(0); | |
| for (let i = 0; i < data.length; i++) { | |
| accumulatedAudio.push(data[i]); | |
| } | |
| while (accumulatedAudio.length >= chunkSamples) { | |
| const chunk = accumulatedAudio.splice(0, chunkSamples); | |
| const int16Arr = new Int16Array(chunk.length); | |
| for (let i = 0; i < chunk.length; i++) { | |
| int16Arr[i] = Math.max(-32768, Math.min(32767, chunk[i] * 32767)); | |
| } | |
| const bytes = new Uint8Array(int16Arr.buffer); | |
| let binary = ''; | |
| for (let i = 0; i < bytes.byteLength; i++) { | |
| binary += String.fromCharCode(bytes[i]); | |
| } | |
| const base64Data = btoa(binary); | |
| sendChunkCounter++; | |
| appendChunkLog(sendChunkCounter, null, null, true); | |
| const chunkToSend = { | |
| chunkNumber: sendChunkCounter, | |
| audioData: base64Data | |
| }; | |
| if (sendWebSocket.readyState === WebSocket.OPEN) { | |
| sendWebSocket.send(JSON.stringify(chunkToSend)); | |
| } | |
| } | |
| }; | |
| console.log("Recording started."); | |
| startButton.disabled = true; | |
| stopButton.disabled = false; | |
| isRecording = true; | |
| showMessage("Recording... Please speak into your microphone."); | |
| // Start the playback loop | |
| if (!isPlaying) { | |
| isPlaying = true; | |
| nextPlayTime = audioContext.currentTime; | |
| playbackLoop(); | |
| } | |
| } catch (err) { | |
| console.error("Failed to start recording:", err); | |
| showMessage('Failed to start recording. Check console for errors.', true); | |
| startButton.disabled = false; | |
| } | |
| } else { | |
| showMessage('Not connected to the send server. Please wait or refresh.', true); | |
| } | |
| } | |
| function stopRecording() { | |
| if (isRecording) { | |
| isRecording = false; | |
| isPlaying = false; | |
| if (micSource) micSource.disconnect(); | |
| if (scriptNode) scriptNode.disconnect(); | |
| if (gainNode) gainNode.disconnect(); | |
| const tracks = micSource?.mediaStream.getTracks(); | |
| tracks?.forEach(track => track.stop()); | |
| accumulatedAudio = []; | |
| console.log("Recording stopped."); | |
| startButton.disabled = false; | |
| stopButton.disabled = true; | |
| } | |
| } | |
| startButton.addEventListener('click', startRecording); | |
| stopButton.addEventListener('click', stopRecording); | |
| document.addEventListener('DOMContentLoaded', () => { | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| connectWebSockets(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |