Voice-AI-Agent / frontend /script.js
rakib72642's picture
added voice module and updated index
75ee53d
raw
history blame
31.9 kB
/**
* 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, '<br>');
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, '<br>');
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, '<br>');
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, '<br>');
chatBox.scrollTop = chatBox.scrollHeight;
break;
case 'end':
if (aiEl && aiTxt) {
aiEl.innerHTML =
typeof marked !== 'undefined'
? marked.parse(aiTxt)
: aiTxt.replace(/\n/g, '<br>');
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 =
'<span class="dot"></span><span class="dot"></span><span class="dot"></span>';
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();