ProspectIQ / static /js /utils.js
username-usr
Update project files
5c25c25
Raw
History Blame Contribute Delete
7.99 kB
function escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/**
* 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();
}
});
}