Spaces:
Sleeping
Sleeping
| function escapeHtml(str) { | |
| if (str === null || str === undefined) return ''; | |
| return String(str) | |
| .replace(/&/g, "&") | |
| .replace(/</g, "<") | |
| .replace(/>/g, ">") | |
| .replace(/"/g, """) | |
| .replace(/'/g, "'"); | |
| } | |
| /** | |
| * AssemblyAI Real-Time Speech-to-Text (STT) via WebSocket v3 | |
| * Replaces browser SpeechRecognition with AssemblyAI streaming. | |
| * Same signature: initSpeechToText(inputEl, micBtnEl) — all call sites unchanged. | |
| */ | |
| function initSpeechToText(inputEl, micBtnEl) { | |
| if (!micBtnEl || !inputEl) return; | |
| // Check for required browser APIs | |
| if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { | |
| console.warn('AssemblyAI STT: getUserMedia not supported in this browser.'); | |
| micBtnEl.style.display = 'none'; | |
| return; | |
| } | |
| const ASSEMBLYAI_WS_BASE = 'wss://streaming.assemblyai.com/v3/ws'; | |
| const SAMPLE_RATE = 16000; | |
| const SPEECH_MODEL = 'universal-streaming-english'; | |
| let isListening = false; | |
| let ws = null; | |
| let audioContext = null; | |
| let processor = null; | |
| let mediaStream = null; | |
| let source = null; | |
| let finalizedText = ''; | |
| const originalHTML = micBtnEl.innerHTML; | |
| function setActiveUI() { | |
| isListening = true; | |
| micBtnEl.classList.add('mic-active'); | |
| micBtnEl.innerHTML = '<i class="bi bi-mic-fill text-danger animate-pulse-mic"></i>'; | |
| } | |
| function restoreUI() { | |
| isListening = false; | |
| micBtnEl.classList.remove('mic-active'); | |
| micBtnEl.innerHTML = originalHTML; | |
| } | |
| function cleanup() { | |
| // Close WebSocket | |
| if (ws) { | |
| try { | |
| if (ws.readyState === WebSocket.OPEN) { | |
| ws.send(JSON.stringify({ type: 'Terminate' })); | |
| } | |
| ws.close(); | |
| } catch (e) { /* ignore */ } | |
| ws = null; | |
| } | |
| // Disconnect audio nodes | |
| if (processor) { | |
| try { processor.disconnect(); } catch (e) { /* ignore */ } | |
| processor = null; | |
| } | |
| if (source) { | |
| try { source.disconnect(); } catch (e) { /* ignore */ } | |
| source = null; | |
| } | |
| // Close audio context | |
| if (audioContext && audioContext.state !== 'closed') { | |
| try { audioContext.close(); } catch (e) { /* ignore */ } | |
| audioContext = null; | |
| } | |
| // Stop microphone tracks | |
| if (mediaStream) { | |
| mediaStream.getTracks().forEach(t => t.stop()); | |
| mediaStream = null; | |
| } | |
| restoreUI(); | |
| } | |
| /** | |
| * Convert Float32Array audio samples to Int16 PCM ArrayBuffer. | |
| */ | |
| function float32ToInt16(float32Arr) { | |
| const int16 = new Int16Array(float32Arr.length); | |
| for (let i = 0; i < float32Arr.length; i++) { | |
| const s = Math.max(-1, Math.min(1, float32Arr[i])); | |
| int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; | |
| } | |
| return int16.buffer; | |
| } | |
| async function startListening() { | |
| try { | |
| // Initialize finalized text with whatever is already in the input | |
| finalizedText = inputEl.value.trim(); | |
| // 1. Fetch temporary token from backend | |
| const tokenResp = await fetch('/api/stt/token', { method: 'POST' }); | |
| if (!tokenResp.ok) { | |
| const errData = await tokenResp.json().catch(() => ({})); | |
| throw new Error(errData.error || `Token request failed (${tokenResp.status})`); | |
| } | |
| const { token } = await tokenResp.json(); | |
| if (!token) throw new Error('No token received from server'); | |
| // 2. Get microphone access | |
| mediaStream = await navigator.mediaDevices.getUserMedia({ | |
| audio: { | |
| sampleRate: SAMPLE_RATE, | |
| channelCount: 1, | |
| echoCancellation: true, | |
| noiseSuppression: true | |
| } | |
| }); | |
| setActiveUI(); | |
| // 3. Setup AudioContext for PCM conversion | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)({ | |
| sampleRate: SAMPLE_RATE | |
| }); | |
| source = audioContext.createMediaStreamSource(mediaStream); | |
| processor = audioContext.createScriptProcessor(4096, 1, 1); | |
| // 4. Connect WebSocket to AssemblyAI | |
| const wsUrl = `${ASSEMBLYAI_WS_BASE}?sample_rate=${SAMPLE_RATE}&speech_model=${SPEECH_MODEL}&token=${encodeURIComponent(token)}`; | |
| ws = new WebSocket(wsUrl); | |
| ws.onopen = () => { | |
| console.log('AssemblyAI STT: WebSocket connected'); | |
| // Start streaming audio once WS is open | |
| processor.onaudioprocess = (e) => { | |
| if (ws && ws.readyState === WebSocket.OPEN) { | |
| const float32Data = e.inputBuffer.getChannelData(0); | |
| const pcmBuffer = float32ToInt16(float32Data); | |
| ws.send(pcmBuffer); | |
| } | |
| }; | |
| source.connect(processor); | |
| processor.connect(audioContext.destination); | |
| }; | |
| ws.onmessage = (event) => { | |
| try { | |
| const data = JSON.parse(event.data); | |
| if (data.type === 'Turn') { | |
| const transcript = (data.transcript || '').trim(); | |
| if (transcript) { | |
| if (data.end_of_turn) { | |
| // Final transcript for this turn — commit it to finalizedText | |
| finalizedText = finalizedText ? `${finalizedText} ${transcript}` : transcript; | |
| inputEl.value = finalizedText; | |
| inputEl.dispatchEvent(new Event('input', { bubbles: true })); | |
| inputEl.dispatchEvent(new Event('change', { bubbles: true })); | |
| inputEl.focus(); | |
| } else { | |
| // Interim transcript — preview it on screen, but don't commit to finalizedText | |
| inputEl.value = finalizedText ? `${finalizedText} ${transcript}` : transcript; | |
| inputEl.dispatchEvent(new Event('input', { bubbles: true })); | |
| } | |
| } | |
| } else if (data.type === 'Begin') { | |
| console.log('AssemblyAI STT: Session started', data.id); | |
| } else if (data.type === 'Termination') { | |
| console.log('AssemblyAI STT: Session terminated'); | |
| cleanup(); | |
| } | |
| } catch (e) { | |
| console.error('AssemblyAI STT: Error parsing message:', e); | |
| } | |
| }; | |
| ws.onerror = (err) => { | |
| console.error('AssemblyAI STT: WebSocket error:', err); | |
| cleanup(); | |
| }; | |
| ws.onclose = () => { | |
| console.log('AssemblyAI STT: WebSocket closed'); | |
| // Only cleanup if we didn't already (avoid double cleanup) | |
| if (isListening) cleanup(); | |
| }; | |
| } catch (err) { | |
| console.error('AssemblyAI STT: Failed to start:', err); | |
| cleanup(); | |
| } | |
| } | |
| function stopListening() { | |
| cleanup(); | |
| } | |
| micBtnEl.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (isListening) { | |
| stopListening(); | |
| } else { | |
| startListening(); | |
| } | |
| }); | |
| } | |