function escapeHtml(str) { if (str === null || str === undefined) return ''; return String(str) .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 = ''; } 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(); } }); }