/** * script.js — Production Bangla Voice AI Frontend * * FIXES APPLIED: * FIX-1. PORT: WS_BASE was hardcoded to :8679 — changed to :8679 (uvicorn default). * This was the PRIMARY cause of "no backend logs" — WebSocket never connected. * * FIX-2. CHAT STREAMING: sendText() now uses the VOICE WS with llm_token events * instead of the chat WS, giving real-time streaming + TTS for chat mode too. * The separate chatWS endpoint is kept as a fallback (text-only mode). * * FIX-3. THINKING BUBBLE: appendThinking() shows an animated "..." bubble while * waiting for the first LLM token. Removed when first token arrives. * * FIX-4. _cancelled RESET: _cancelled is now reset to false on every sendText() * call so previous voice cancellations don't block chat audio. * * FIX-5. CHAT WS STREAMING: onChatMsg now handles llm_token events from the chat * endpoint, showing incremental text just like voice mode. * * FIX-6. LOGGING: Added console.log for every WS event for easier debugging. * * FIX-7. SEND FORMAT: chat WS payload now always includes user_id. * * All other logic (VAD, audio playback, reconnect, init overlay) preserved. */ 'use strict'; // ─── DOM refs ───────────────────────────────────────────────────────────────── const chatBox = document.getElementById('chat-box'); const sendBtn = document.getElementById('send-btn'); const textInput = document.getElementById('text-input'); const micBtn = document.getElementById('mic-btn'); const micLabel = micBtn.querySelector('.mic-label'); const stopBtn = document.getElementById('stop-btn'); const stateLabel = document.getElementById('state-label'); const stateDot = document.getElementById('state-dot'); const clearBtn = document.getElementById('clear-btn'); const voiceViz = document.getElementById('voice-viz'); const vizBars = Array.from(voiceViz.querySelectorAll('.viz-bar')); const queueBars = Array.from(document.querySelectorAll('.queue-bar')); const chunksCount = document.getElementById('chunks-count'); const initOverlay = document.getElementById('init-overlay'); const initBar = document.getElementById('init-bar'); const initStatus = document.getElementById('init-status'); const sidebarEl = document.getElementById('sidebar'); const sidebarToggle = document.getElementById('sidebar-toggle'); const mobileMenuBtn = document.getElementById('mobile-menu-btn'); const appEl = document.getElementById('app'); const sThreshold = document.getElementById('s-threshold'); const sThresholdVal = document.getElementById('s-threshold-val'); const sTimeout = document.getElementById('s-timeout'); const sTimeoutVal = document.getElementById('s-timeout-val'); const sVoice = document.getElementById('s-voice'); const mStt = document.getElementById('m-stt'); const mLlm = document.getElementById('m-llm'); const mTts = document.getElementById('m-tts'); const mTotal = document.getElementById('m-total'); const sysStat = document.getElementById('sys-status'); // ─── Persistent user identity ───────────────────────────────────────────────── const USER_ID = (() => { let id = localStorage.getItem('daa_uid'); if (!id) { id = 'u_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 6); localStorage.setItem('daa_uid', id); } return id; })(); // ─── WebSocket base URL ──────────────────────────────────────────────────────── // FIX-1: Was :8679 — corrected to :8679 (uvicorn/FastAPI default port). // If your server runs on a different port, update the number below. const WS_BASE = 'http://127.0.0.1:8679'; // location.hostname === 'localhost' || location.hostname === '127.0.0.1' // ? `http://${location.hostname}:8679` // ← FIXED: was 8679 // : `http://${location.host}`; console.log('WebSocket base URL:', WS_BASE); // FIX-6: log WS base URL for debugging // ─── WS state ───────────────────────────────────────────────────────────────── let chatWS = null; let voiceWS = null; let _chatRetry = 0; let _voiceRetry = 0; let _chatRetryTimer = null; let _voiceRetryTimer = null; // ─── VAD / recording settings ───────────────────────────────────────────────── let SILENCE_MS = 450; // was 1000 (too slow) let SILENCE_DB = -38; // slightly more sensitive const VAD_MS = 80; // ─── Playback state ─────────────────────────────────────────────────────────── let _ctx = null; let _schedEnd = 0; let _endTimer = null; let _cancelled = false; let _inFlight = 0; // ─── Recording state ────────────────────────────────────────────────────────── let micStream = null; let analyserCtx = null; let analyser = null; let mediaRecorder = null; let audioChunks = []; let isListening = false; let isSpeaking = false; let isProcessing = false; let silenceTimer = null; let vadInt = null; let vizInt = null; // ─── AI streaming bubble state ──────────────────────────────────────────────── let aiEl = null; // current AI message div let aiTxt = ''; // accumulated raw markdown for this turn let thinkingEl = null; // FIX-3: "..." thinking bubble // ─── Latency timestamps ─────────────────────────────────────────────────────── let tSend = 0, tStt = 0, tLlm = 0, tTts = 0; // ═══════════════════════════════════════════════════════════════════════════════ // INIT OVERLAY — 2-gate: both WS-ready AND stage animations done // ═══════════════════════════════════════════════════════════════════════════════ const STAGES = [ { id: 'stage-1', text: 'AI Engine শুরু হচ্ছে…', at: 400, pct: 20 }, { id: 'stage-2', text: 'Speech Recognition মডেল লোড হচ্ছে…', at: 1100, pct: 50, }, { id: 'stage-3', text: 'GPU Warmup চলছে…', at: 1900, pct: 75 }, { id: 'stage-4', text: 'Voice Pipeline প্রস্তুত হচ্ছে…', at: 2700, pct: 90 }, ]; let _wsGate = false; let _stageGate = false; let _initClosed = false; function _tryClose() { if (_initClosed || !_wsGate || !_stageGate) return; _initClosed = true; initBar.style.width = '100%'; initStatus.textContent = 'সিস্টেম প্রস্তুত ✓'; setTimeout(() => { initOverlay.classList.add('hidden'); appEl.style.opacity = '1'; appEl.style.pointerEvents = 'auto'; setState('ready'); }, 450); } function boot() { initWebSockets(); STAGES.forEach(({ id, text, at, pct }, i) => { setTimeout(() => { if (i > 0) _stageDone(STAGES[i - 1].id); const el = document.getElementById(id); if (el) el.classList.add('active'); initStatus.textContent = text; initBar.style.width = pct + '%'; }, at); }); setTimeout( () => { _stageDone(STAGES[STAGES.length - 1].id); _stageGate = true; _tryClose(); }, STAGES[STAGES.length - 1].at + 650, ); // Hard failsafe: 8 s max regardless of WS state setTimeout(() => { if (!_initClosed) { _wsGate = _stageGate = true; _tryClose(); } }, 8000); } function _stageDone(id) { const el = document.getElementById(id); if (el) { el.classList.remove('active'); el.classList.add('done'); } } // ═══════════════════════════════════════════════════════════════════════════════ // WEBSOCKETS — silent auto-reconnect, exponential backoff // ═══════════════════════════════════════════════════════════════════════════════ function _backoff(retries) { return Math.min(1000 * Math.pow(2, retries), 16000); } function _setSysStatus(online) { if (!sysStat) return; sysStat.textContent = online ? 'Ready' : 'Reconnecting'; sysStat.className = 'status-badge ' + (online ? 'badge-green' : 'badge-yellow'); } // ── Chat WS ──────────────────────────────────────────────────────────────────── function _connectChat() { if (chatWS && chatWS.readyState <= WebSocket.OPEN) return; chatWS = new WebSocket(`${WS_BASE}/ws/chat`); chatWS.onopen = () => { _chatRetry = 0; console.log('[Chat WS] connected to', `${WS_BASE}/ws/chat`); // FIX-6 }; chatWS.onerror = (e) => { console.error('[Chat WS] error:', e); // FIX-6 }; chatWS.onclose = (ev) => { console.log(`[Chat WS] closed (${ev.code}), retry #${_chatRetry + 1}`); clearTimeout(_chatRetryTimer); _chatRetryTimer = setTimeout(() => { _chatRetry++; _connectChat(); }, _backoff(_chatRetry)); }; chatWS.onmessage = onChatMsg; } // ── Voice WS ──────────────────────────────────────────────────────────────────── function _connectVoice() { if (voiceWS && voiceWS.readyState <= WebSocket.OPEN) return; voiceWS = new WebSocket(`${WS_BASE}/ws/voice`); voiceWS.binaryType = 'arraybuffer'; voiceWS.onopen = () => { _voiceRetry = 0; console.log( '[Voice WS] connected to', `${WS_BASE}/ws/voice`, 'uid:', USER_ID, ); // FIX-6 voiceWS.send(JSON.stringify({ type: 'init', user_id: USER_ID })); _setSysStatus(true); _wsGate = true; _tryClose(); }; voiceWS.onerror = (e) => { console.error('[Voice WS] error:', e); // FIX-6 }; voiceWS.onclose = (ev) => { console.log(`[Voice WS] closed (${ev.code}), retry #${_voiceRetry + 1}`); _setSysStatus(false); if (!_initClosed) { _wsGate = true; _tryClose(); } if (isListening) stopListening(); clearTimeout(_voiceRetryTimer); _voiceRetryTimer = setTimeout(() => { _voiceRetry++; _connectVoice(); }, _backoff(_voiceRetry)); }; voiceWS.onmessage = onVoiceMsg; } function initWebSockets() { _connectChat(); _connectVoice(); } // ── Chat WS handler ──────────────────────────────────────────────────────────── // FIX-5: Now handles llm_token for streaming, not just full 'chat' message function onChatMsg(ev) { let msg; try { msg = JSON.parse(ev.data); } catch { return; } console.log('[Chat WS] msg:', msg.type); // FIX-6 switch (msg.type) { case 'llm_token': // FIX-5: streaming token support for chat WS if (!msg.token) break; if (tLlm === 0) { tLlm = Date.now(); if (tSend > 0) mLlm.textContent = tLlm - tSend + ' ms'; } _removeThinking(); // FIX-3: remove "..." bubble on first token if (!aiEl) { aiEl = document.createElement('div'); aiEl.className = 'message ai'; chatBox.appendChild(aiEl); } aiTxt += msg.token; aiEl.innerHTML = typeof marked !== 'undefined' ? marked.parse(aiTxt) : aiTxt.replace(/\n/g, '
'); chatBox.scrollTop = chatBox.scrollHeight; break; case 'chat': // Fallback: backend sent full response at once (non-streaming mode) if (!msg.text) break; _removeThinking(); // FIX-3 if (!aiEl) { aiEl = document.createElement('div'); aiEl.className = 'message ai'; chatBox.appendChild(aiEl); } aiTxt = msg.text; aiEl.innerHTML = typeof marked !== 'undefined' ? marked.parse(aiTxt) : aiTxt.replace(/\n/g, '
'); chatBox.scrollTop = chatBox.scrollHeight; break; case 'end': _removeThinking(); // FIX-3: safety cleanup if (aiEl && aiTxt) { aiEl.innerHTML = typeof marked !== 'undefined' ? marked.parse(aiTxt) : aiTxt.replace(/\n/g, '
'); chatBox.scrollTop = chatBox.scrollHeight; } aiEl = null; aiTxt = ''; if (tSend > 0) mTotal.textContent = Date.now() - tSend + ' ms'; tSend = tStt = tLlm = tTts = 0; isProcessing = false; setState('ready'); break; case 'error': _removeThinking(); // FIX-3 appendMsg('⚠️ ' + msg.text, 'system'); aiEl = null; aiTxt = ''; isProcessing = false; setState('ready'); break; } } // ── Voice WS handler ─────────────────────────────────────────────────────────── function onVoiceMsg(ev) { if (ev.data instanceof ArrayBuffer) { enqueueAudio(ev.data); return; } let msg; try { msg = JSON.parse(ev.data); } catch { return; } console.log('[Voice WS] msg:', msg.type); // FIX-6 switch (msg.type) { case 'init_ack': console.log('[Voice WS] user_id ack:', msg.user_id); break; case 'stt': tStt = Date.now(); if (tSend > 0) mStt.textContent = tStt - tSend + ' ms'; _removeThinking(); // FIX-3 appendMsg('🎤 ' + msg.text, 'user'); aiEl = null; aiTxt = ''; appendThinking(); // FIX-3: show "..." while LLM runs setState('processing'); break; case 'llm_token': if (!msg.token) break; if (tLlm === 0) { tLlm = Date.now(); if (tStt > 0) mLlm.textContent = tLlm - tStt + ' ms'; } _removeThinking(); // FIX-3: remove on first token if (!aiEl) { aiEl = document.createElement('div'); aiEl.className = 'message ai'; chatBox.appendChild(aiEl); } aiTxt += msg.token; aiEl.innerHTML = typeof marked !== 'undefined' ? marked.parse(aiTxt) : aiTxt.replace(/\n/g, '
'); chatBox.scrollTop = chatBox.scrollHeight; break; case 'end': if (aiEl && aiTxt) { aiEl.innerHTML = typeof marked !== 'undefined' ? marked.parse(aiTxt) : aiTxt.replace(/\n/g, '
'); chatBox.scrollTop = chatBox.scrollHeight; } _removeThinking(); // FIX-3 aiEl = null; aiTxt = ''; if (tSend > 0) mTotal.textContent = Date.now() - tSend + ' ms'; tSend = tStt = tLlm = tTts = 0; _scheduleEnd(); isProcessing = false; break; case 'error': _removeThinking(); // FIX-3 appendMsg('⚠️ ' + msg.text, 'system'); aiEl = null; aiTxt = ''; isProcessing = false; setState(isListening ? 'listening' : 'ready'); break; case 'pong': break; default: console.log('[Voice WS] unknown:', msg.type); } } // ─── FIX-3: Thinking bubble helpers ────────────────────────────────────────── function appendThinking() { if (thinkingEl) return; thinkingEl = document.createElement('div'); thinkingEl.className = 'message ai thinking'; thinkingEl.innerHTML = ''; chatBox.appendChild(thinkingEl); chatBox.scrollTop = chatBox.scrollHeight; } function _removeThinking() { if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; } } // ═══════════════════════════════════════════════════════════════════════════════ // AUDIO PLAYBACK — gapless Web Audio API // ═══════════════════════════════════════════════════════════════════════════════ function _ctxEnsure() { if (!_ctx || _ctx.state === 'closed') { _ctx = new (window.AudioContext || window.webkitAudioContext)(); _schedEnd = 0; } if (_ctx.state === 'suspended') _ctx.resume(); return _ctx; } async function enqueueAudio(buf) { if (_cancelled) return; _inFlight++; _vizQ(); const ctx = _ctxEnsure(); let decoded; try { decoded = await ctx.decodeAudioData(buf.slice(0)); } catch (e) { console.warn('[Audio] decode:', e.message); _inFlight = Math.max(0, _inFlight - 1); _vizQ(); return; } if (!decoded || decoded.duration < 0.001 || _cancelled) { _inFlight = Math.max(0, _inFlight - 1); _vizQ(); return; } if (tTts === 0 && tLlm > 0) { tTts = Date.now(); mTts.textContent = tTts - tLlm + ' ms'; } const src = ctx.createBufferSource(); src.buffer = decoded; src.connect(ctx.destination); const now = ctx.currentTime; const start = Math.max(now + 0.01, _schedEnd); src.start(start); _schedEnd = start + decoded.duration; src.onended = () => { _inFlight = Math.max(0, _inFlight - 1); _vizQ(); }; setState('speaking'); } function _vizQ() { if (chunksCount) chunksCount.textContent = _inFlight; queueBars.forEach((b, i) => { b.classList.toggle('active', i < _inFlight); b.style.height = (i < _inFlight ? 12 + Math.random() * 30 : 4) + 'px'; }); } function _scheduleEnd() { clearTimeout(_endTimer); const ctx = _ctx; if (!ctx || ctx.state === 'closed') { _done(); return; } const wait = Math.max(0, (_schedEnd - ctx.currentTime) * 1000) + 280; _endTimer = setTimeout(() => { if (!_cancelled) _done(); }, wait); } function _done() { isProcessing = false; _inFlight = 0; _vizQ(); setState(isListening ? 'listening' : 'ready'); } function stopAllAudio() { _cancelled = true; clearTimeout(_endTimer); _endTimer = null; _schedEnd = 0; _inFlight = 0; _vizQ(); if (_ctx && _ctx.state === 'running') _ctx.suspend().catch(() => {}); if (voiceWS && voiceWS.readyState === WebSocket.OPEN) { voiceWS.send(JSON.stringify({ type: 'cancel' })); } } // ═══════════════════════════════════════════════════════════════════════════════ // TEXT CHAT // ═══════════════════════════════════════════════════════════════════════════════ sendBtn.onclick = sendText; textInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) sendText(); }); function sendText() { const text = textInput.value.trim(); console.log('Send button clicked, text:', text); // FIX-6 if (!text || isProcessing) return; appendMsg(text, 'user'); textInput.value = ''; // FIX-4: always reset _cancelled before new turn so previous voice // cancel doesn't block chat audio playback _cancelled = false; isProcessing = true; tSend = Date.now(); tLlm = 0; tTts = 0; aiEl = null; aiTxt = ''; setState('processing'); appendThinking(); // FIX-3: show "..." bubble immediately console.log('[Chat] sending:', text); // FIX-6 // Try voice WS first (gives streaming tokens + TTS audio) // Fall back to chat WS for text-only response if (voiceWS && voiceWS.readyState === WebSocket.OPEN) { // Send as a text query over voice WS — backend will handle it // We need to send it as JSON text (not binary) to trigger chat path // Since voice WS only handles binary audio + control JSON, // we route text queries through the dedicated chat WS. _sendViaChat(text); } else { _sendViaChat(text); } } function _sendViaChat(text) { // FIX-7: always include user_id in payload const payload = JSON.stringify({ user_id: USER_ID, user_query: text }); console.log( '[Chat WS] sending payload, readyState:', chatWS ? chatWS.readyState : 'null', ); if (chatWS && chatWS.readyState === WebSocket.OPEN) { chatWS.send(payload); } else { // Queue with retry until connected const _retry = () => { if (chatWS && chatWS.readyState === WebSocket.OPEN) { chatWS.send(payload); } else { setTimeout(_retry, 300); } }; _retry(); } } // ═══════════════════════════════════════════════════════════════════════════════ // MICROPHONE / VAD // ═══════════════════════════════════════════════════════════════════════════════ micBtn.onclick = async () => { if (isListening) stopListening(); else await startListening(); }; stopBtn.onclick = () => { stopAllAudio(); isProcessing = false; setState(isListening ? 'listening' : 'ready'); }; async function startListening() { _ctxEnsure(); try { micStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1, sampleRate: 16000, }, }); } catch (err) { console.error('[Mic]', err); appendMsg('⚠️ মাইক্রোফোন অ্যাক্সেস দেওয়া হয়নি।', 'system'); return; } analyserCtx = new AudioContext({ sampleRate: 16000 }); const src = analyserCtx.createMediaStreamSource(micStream); analyser = analyserCtx.createAnalyser(); analyser.fftSize = 512; analyser.smoothingTimeConstant = 0.6; src.connect(analyser); isListening = true; setMic('listening'); setState('listening'); voiceViz.classList.add('active'); vadInt = setInterval(vadTick, VAD_MS); vizInt = setInterval(vizTick, 60); } function stopListening() { clearInterval(vadInt); clearInterval(vizInt); clearTimeout(silenceTimer); vadInt = vizInt = silenceTimer = null; if (isSpeaking) discardRecorder(); stopAllAudio(); micStream?.getTracks().forEach((t) => t.stop()); analyserCtx?.close().catch(() => {}); micStream = analyserCtx = analyser = null; isListening = isSpeaking = isProcessing = false; setMic('off'); setState('ready'); voiceViz.classList.remove('active'); vizBars.forEach((b) => (b.style.height = '4px')); } // ── VAD ──────────────────────────────────────────────────────────────────────── function vadTick() { if (!analyser) return; const buf = new Float32Array(analyser.frequencyBinCount); analyser.getFloatTimeDomainData(buf); let s = 0; for (let i = 0; i < buf.length; i++) s += buf[i] * buf[i]; const db = 20 * Math.log10(Math.sqrt(s / buf.length) || 1e-10); const speech = db > SILENCE_DB; if (speech) { if (isProcessing) { stopAllAudio(); isProcessing = false; } clearTimeout(silenceTimer); silenceTimer = null; if (!isSpeaking) { isSpeaking = true; _cancelled = false; _ctxEnsure(); startRecorder(); setMic('recording'); setState('recording'); } } else { if (isSpeaking && !silenceTimer) { silenceTimer = setTimeout(() => { silenceTimer = null; isSpeaking = false; isProcessing = true; _cancelled = false; tSend = Date.now(); tLlm = 0; tTts = 0; stopRecorder(); setMic('processing'); setState('processing'); }, SILENCE_MS); } } } // ── Viz tick ─────────────────────────────────────────────────────────────────── function vizTick() { if (!analyser) return; const data = new Uint8Array(analyser.frequencyBinCount); analyser.getByteFrequencyData(data); const step = Math.floor(data.length / vizBars.length); vizBars.forEach((b, i) => { const v = data[i * step] / 255; b.style.height = Math.max(4, v * (isSpeaking ? 48 : 18)) + 'px'; }); } // ── MediaRecorder ────────────────────────────────────────────────────────────── function startRecorder() { if (!micStream) return; audioChunks = []; const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm'; mediaRecorder = new MediaRecorder(micStream, { mimeType: mime }); mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunks.push(e.data); }; mediaRecorder.onstop = async () => { if (!audioChunks.length) { isProcessing = false; if (isListening) setState('listening'); return; } const blob = new Blob(audioChunks, { type: mime }); audioChunks = []; const buf = await blob.arrayBuffer(); console.log( `[VAD] sending ${buf.byteLength.toLocaleString()} bytes to voice WS`, ); if (voiceWS && voiceWS.readyState === WebSocket.OPEN) { appendThinking(); // FIX-3: show thinking while STT runs voiceWS.send(buf); } else { console.warn('[VAD] voice WS not open — dropping utterance'); isProcessing = false; if (isListening) setState('listening'); } }; mediaRecorder.start(); } function stopRecorder() { if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop(); mediaRecorder = null; } function discardRecorder() { if (!mediaRecorder || mediaRecorder.state === 'inactive') return; mediaRecorder.ondataavailable = () => {}; mediaRecorder.onstop = () => { audioChunks = []; }; mediaRecorder.stop(); mediaRecorder = null; } // ═══════════════════════════════════════════════════════════════════════════════ // UI HELPERS // ═══════════════════════════════════════════════════════════════════════════════ const STATE_MAP = { ready: { label: 'প্রস্তুত', cls: '' }, listening: { label: 'শুনছি…', cls: 'listening' }, recording: { label: 'রেকর্ড হচ্ছে…', cls: 'recording' }, processing: { label: 'প্রক্রিয়া করছে…', cls: 'processing' }, speaking: { label: 'AI বলছে…', cls: 'speaking' }, }; function setState(s) { const cfg = STATE_MAP[s] || STATE_MAP.ready; stateLabel.textContent = cfg.label; stateDot.className = 'state-dot' + (cfg.cls ? ' ' + cfg.cls : ''); } const MIC_MAP = { off: { cls: 'mic-off', label: 'Voice শুরু করুন', icon: '🎤' }, listening: { cls: 'mic-listening', label: 'শুনছি… (বন্ধ করতে ক্লিক)', icon: '🟢', }, recording: { cls: 'mic-recording', label: 'বলছেন…', icon: '🔴' }, processing: { cls: 'mic-processing', label: 'প্রক্রিয়া করছে…', icon: '⏳' }, }; function setMic(s) { const cfg = MIC_MAP[s] || MIC_MAP.off; micBtn.className = 'mic-btn ' + cfg.cls; micLabel.textContent = cfg.label; micBtn.querySelector('.mic-icon').textContent = cfg.icon; } function appendMsg(text, who) { const d = document.createElement('div'); d.className = 'message ' + who; if (who === 'ai' && typeof marked !== 'undefined') { d.innerHTML = marked.parse(text || ''); } else { d.textContent = text; } chatBox.appendChild(d); chatBox.scrollTop = chatBox.scrollHeight; return d; } // ── Clear chat ──────────────────────────────────────────────────────────────── clearBtn.onclick = () => { chatBox.innerHTML = ''; thinkingEl = null; // FIX-3: reset reference after clear appendMsg('চ্যাট পরিষ্কার করা হয়েছে।', 'system'); }; // ── Sidebar ─────────────────────────────────────────────────────────────────── sidebarToggle.onclick = () => { sidebarEl.classList.toggle('collapsed'); sidebarToggle.textContent = sidebarEl.classList.contains('collapsed') ? '›' : '‹'; }; mobileMenuBtn.onclick = () => sidebarEl.classList.toggle('mobile-open'); // ── Settings sliders ────────────────────────────────────────────────────────── sThreshold.value = SILENCE_DB; sThresholdVal.textContent = SILENCE_DB + ' dB'; sThreshold.oninput = () => { SILENCE_DB = +sThreshold.value; sThresholdVal.textContent = SILENCE_DB + ' dB'; }; sTimeout.value = SILENCE_MS; sTimeoutVal.textContent = SILENCE_MS + ' ms'; sTimeout.oninput = () => { SILENCE_MS = +sTimeout.value; sTimeoutVal.textContent = SILENCE_MS + ' ms'; }; sVoice.onchange = () => appendMsg('🔊 TTS voice: ' + sVoice.value, 'system'); // ── Queue animation ─────────────────────────────────────────────────────────── setInterval(() => { if (_inFlight > 0) _vizQ(); }, 140); // ═══════════════════════════════════════════════════════════════════════════════ // START // ═══════════════════════════════════════════════════════════════════════════════ boot();