rakib72642 commited on
Commit
fc967af
·
1 Parent(s): e33d11d

fixed stt and added whisper and elevenlabs stt + updated ++ done

Browse files
Files changed (6) hide show
  1. .env +1 -1
  2. frontend/index.html +69 -67
  3. frontend/script.js +273 -121
  4. frontend/style.css +952 -459
  5. services/streaming.py +2 -1
  6. services/tts.py +12 -5
.env CHANGED
@@ -9,7 +9,7 @@ GOOGLE_API_KEY="AIzaSyA9sqz4YKQHKXR9TU1imw0DPOghzHOMiBo"
9
 
10
 
11
  ELEVENLABS_API_KEY="b3af3a938c8e15d5eae700ea47eea7d88dfe397f34fbd4b0c75c24f143b032b8"
12
- ELEVENLABS_VOICE_ID="4O1sYUnmtThcBoSBrri7"
13
  ELEVENLABS_MODEL_ID="eleven_v3"
14
 
15
  # TWILIO_ACCOUNT_SID="ACfafc0d2d007bdf14b21bb3e14a7a7b31"
 
9
 
10
 
11
  ELEVENLABS_API_KEY="b3af3a938c8e15d5eae700ea47eea7d88dfe397f34fbd4b0c75c24f143b032b8"
12
+ ELEVENLABS_VOICE_ID="rxvktZTNrsQlsGIpOQGz"
13
  ELEVENLABS_MODEL_ID="eleven_v3"
14
 
15
  # TWILIO_ACCOUNT_SID="ACfafc0d2d007bdf14b21bb3e14a7a7b31"
frontend/index.html CHANGED
@@ -3,7 +3,7 @@
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
- <title>DAA — ডাক্তার অ্যাপয়েন্টমেন্ট সহকারী</title>
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
  <link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=JetBrains+Mono:wght@300;400&family=Hind+Siliguri:wght@300;400;500;600&display=swap" rel="stylesheet">
@@ -11,69 +11,6 @@
11
  </head>
12
  <body>
13
 
14
- <!-- ── Ambient background ── -->
15
- <div class="bg-orb orb-1"></div>
16
- <div class="bg-orb orb-2"></div>
17
- <div class="bg-orb orb-3"></div>
18
-
19
- <!-- ══════════════════════════════════════════════════════════════
20
- INIT OVERLAY — shown until WS ready + animations done
21
- Hard 8 s failsafe closes overlay if backend is slow.
22
- ══════════════════════════════════════════════════════════════ -->
23
- <div id="init-overlay" class="init-overlay">
24
- <div class="init-card">
25
- <div class="init-logo">
26
- <svg width="56" height="56" viewBox="0 0 56 56" fill="none">
27
- <circle cx="28" cy="28" r="26" stroke="url(#g1)" stroke-width="2"/>
28
- <path d="M18 28 Q28 16 38 28 Q28 40 18 28Z" fill="url(#g2)" opacity="0.9"/>
29
- <defs>
30
- <linearGradient id="g1" x1="0" y1="0" x2="56" y2="56">
31
- <stop offset="0%" stop-color="#22d3ee"/><stop offset="100%" stop-color="#818cf8"/>
32
- </linearGradient>
33
- <linearGradient id="g2" x1="0" y1="0" x2="56" y2="56">
34
- <stop offset="0%" stop-color="#22d3ee"/><stop offset="100%" stop-color="#818cf8"/>
35
- </linearGradient>
36
- </defs>
37
- </svg>
38
- </div>
39
- <h2 class="init-title">AI Voice Assistant</h2>
40
- <p class="init-subtitle">বাংলা ভয়েস সহকারী</p>
41
-
42
- <div class="init-stages">
43
- <div class="stage" id="stage-1">
44
- <div class="stage-dot"></div>
45
- <span>AI Engine শুরু হচ্ছে…</span>
46
- <div class="stage-check">✓</div>
47
- </div>
48
- <div class="stage" id="stage-2">
49
- <div class="stage-dot"></div>
50
- <span>Speech Recognition মডেল লোড হচ্ছে…</span>
51
- <div class="stage-check">✓</div>
52
- </div>
53
- <div class="stage" id="stage-3">
54
- <div class="stage-dot"></div>
55
- <span>GPU Warmup চলছে…</span>
56
- <div class="stage-check">✓</div>
57
- </div>
58
- <div class="stage" id="stage-4">
59
- <div class="stage-dot"></div>
60
- <span>Voice Pipeline প্রস্তুত হচ্ছে…</span>
61
- <div class="stage-check">✓</div>
62
- </div>
63
- </div>
64
-
65
- <div class="init-bar-wrap">
66
- <div class="init-bar" id="init-bar"></div>
67
- </div>
68
- <p class="init-status" id="init-status">সংযোগ স্থাপন করা হচ্ছে…</p>
69
- </div>
70
- </div>
71
-
72
- <!-- ══════════════════════════════════════════════════════════════
73
- MAIN APP
74
- FIX-7: Hidden via .app CSS class (not inline opacity:0) to
75
- prevent FOUC. JS adds class "visible" after init closes.
76
- ══════════════════════════════════════════════════════════════ -->
77
  <div class="app" id="app">
78
 
79
  <!-- ── Sidebar ── -->
@@ -92,7 +29,7 @@
92
  </linearGradient>
93
  </defs>
94
  </svg>
95
- <span>DAA Assistant</span>
96
  </div>
97
  <button class="sidebar-toggle" id="sidebar-toggle" title="Toggle sidebar">‹</button>
98
  </div>
@@ -161,7 +98,7 @@
161
  <span id="s-timeout-val">900 ms</span>
162
  </div>
163
  </div>
164
- <div class="setting-row">
165
  <label>TTS Voice</label>
166
  <select id="s-voice" class="setting-select">
167
  <option value="bn-BD-NabanitaNeural">Nabanita (Female)</option>
@@ -169,7 +106,7 @@
169
  <option value="bn-IN-BashkarNeural">Bashkar (IN Male)</option>
170
  <option value="bn-IN-TanishaaNeural">Tanishaa (IN Female)</option>
171
  </select>
172
- </div>
173
  </div>
174
 
175
  <div class="sidebar-divider"></div>
@@ -207,13 +144,78 @@
207
  <span class="topbar-title">🏥 ডাক্তার অ্যাপয়েন্টমেন্ট সহকারী</span>
208
  </div>
209
  <div class="topbar-right">
 
 
 
 
 
 
 
 
 
210
  <button class="clear-btn" id="clear-btn" title="Clear conversation">↺ Clear</button>
211
  </div>
212
  </header>
213
 
 
 
214
  <!-- Chat -->
215
  <div id="chat-box"></div>
216
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  <!-- Voice visualizer — shown only while mic is active -->
218
  <div class="voice-visualizer" id="voice-viz">
219
  <div class="viz-bar"></div><div class="viz-bar"></div><div class="viz-bar"></div>
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
+ <title>Hospital Assistant</title>
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
  <link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=JetBrains+Mono:wght@300;400&family=Hind+Siliguri:wght@300;400;500;600&display=swap" rel="stylesheet">
 
11
  </head>
12
  <body>
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  <div class="app" id="app">
15
 
16
  <!-- ── Sidebar ── -->
 
29
  </linearGradient>
30
  </defs>
31
  </svg>
32
+ <span>Hospital Assistant</span>
33
  </div>
34
  <button class="sidebar-toggle" id="sidebar-toggle" title="Toggle sidebar">‹</button>
35
  </div>
 
98
  <span id="s-timeout-val">900 ms</span>
99
  </div>
100
  </div>
101
+ <!-- <div class="setting-row">
102
  <label>TTS Voice</label>
103
  <select id="s-voice" class="setting-select">
104
  <option value="bn-BD-NabanitaNeural">Nabanita (Female)</option>
 
106
  <option value="bn-IN-BashkarNeural">Bashkar (IN Male)</option>
107
  <option value="bn-IN-TanishaaNeural">Tanishaa (IN Female)</option>
108
  </select>
109
+ </div> -->
110
  </div>
111
 
112
  <div class="sidebar-divider"></div>
 
144
  <span class="topbar-title">🏥 ডাক্তার অ্যাপয়েন্টমেন্ট সহকারী</span>
145
  </div>
146
  <div class="topbar-right">
147
+ <button class="brain-btn" id="brain-mode-btn" title="Brain mode" aria-pressed="false" aria-label="Brain mode">
148
+ <svg viewBox="0 0 24 24" aria-hidden="true">
149
+ <path d="M8.5 5.5c-1.7 0-3 1.4-3 3.1 0 .8.3 1.5.8 2.1-1.1.5-1.8 1.5-1.8 2.8 0 1.7 1.4 3.1 3.1 3.1h.3c.2 1.3 1.4 2.3 2.8 2.3.9 0 1.8-.4 2.3-1.1.5.7 1.4 1.1 2.3 1.1 1.4 0 2.6-1 2.8-2.3h.3c1.7 0 3.1-1.4 3.1-3.1 0-1.2-.7-2.3-1.8-2.8.5-.6.8-1.3.8-2.1 0-1.7-1.3-3.1-3-3.1-.6 0-1.2.2-1.7.5-.5-1.1-1.5-1.8-2.7-1.8-1.2 0-2.2.7-2.7 1.8-.5-.3-1.1-.5-1.7-.5Z"/>
150
+ <path d="M7.6 8.4 9.8 11M16.4 8.4 14.2 11M6.8 13.4 9.4 13.8M17.2 13.4 14.6 13.8M10.1 15.8 12 17.5 13.9 15.8"/>
151
+ <circle cx="10" cy="11" r="0.8"/>
152
+ <circle cx="14" cy="11" r="0.8"/>
153
+ <circle cx="12" cy="13.8" r="0.9"/>
154
+ </svg>
155
+ </button>
156
  <button class="clear-btn" id="clear-btn" title="Clear conversation">↺ Clear</button>
157
  </div>
158
  </header>
159
 
160
+ <div class="voice-caption" id="voice-caption" aria-live="polite"></div>
161
+
162
  <!-- Chat -->
163
  <div id="chat-box"></div>
164
 
165
+ <!-- Brain mode -->
166
+ <section class="brain-stage" id="brain-stage" aria-hidden="true">
167
+ <div class="brain-shell">
168
+ <div class="brain-bubbles" aria-hidden="true">
169
+ <div class="brain-bubble brain-bubble-stt" id="brain-bubble-stt">
170
+ <div class="brain-bubble-label">You</div>
171
+ <div class="brain-bubble-text" id="brain-bubble-stt-text">Listening…</div>
172
+ </div>
173
+ <div class="brain-bubble brain-bubble-tts" id="brain-bubble-tts">
174
+ <div class="brain-bubble-label">AI</div>
175
+ <div class="brain-bubble-text" id="brain-bubble-tts-text">Waiting…</div>
176
+ </div>
177
+ </div>
178
+ <div class="brain-scan" aria-hidden="true"></div>
179
+ <div class="brain-pulse pulse-a" aria-hidden="true"></div>
180
+ <div class="brain-pulse pulse-b" aria-hidden="true"></div>
181
+ <div class="brain-pulse pulse-c" aria-hidden="true"></div>
182
+ <svg class="brain-svg" viewBox="0 0 960 620" role="presentation" aria-hidden="true">
183
+ <defs>
184
+ <linearGradient id="brainGlow" x1="0" y1="0" x2="1" y2="1">
185
+ <stop offset="0%" stop-color="#0ea5e9"/>
186
+ <stop offset="50%" stop-color="#8b5cf6"/>
187
+ <stop offset="100%" stop-color="#22c55e"/>
188
+ </linearGradient>
189
+ <filter id="brainBlur" x="-20%" y="-20%" width="140%" height="140%">
190
+ <feGaussianBlur stdDeviation="8"/>
191
+ </filter>
192
+ </defs>
193
+ <g class="brain-net">
194
+ <path class="brain-wire wire-a" d="M190 330C280 190 380 150 480 150s200 40 290 180"/>
195
+ <path class="brain-wire wire-b" d="M160 250C250 180 350 160 480 160s230 20 340 130"/>
196
+ <path class="brain-wire wire-c" d="M160 390C250 460 360 480 480 480s220-20 340-140"/>
197
+ <path class="brain-wire wire-d" d="M220 180C290 240 360 260 480 260s190-20 260-80"/>
198
+ <path class="brain-wire wire-e" d="M220 440C300 380 390 350 480 350s170 30 260 100"/>
199
+ <circle class="brain-node node-1" cx="190" cy="330" r="9"/>
200
+ <circle class="brain-node node-2" cx="300" cy="220" r="7"/>
201
+ <circle class="brain-node node-3" cx="410" cy="180" r="8"/>
202
+ <circle class="brain-node node-4" cx="480" cy="150" r="10"/>
203
+ <circle class="brain-node node-5" cx="560" cy="185" r="7"/>
204
+ <circle class="brain-node node-6" cx="670" cy="240" r="9"/>
205
+ <circle class="brain-node node-7" cx="760" cy="330" r="10"/>
206
+ <circle class="brain-node node-8" cx="660" cy="430" r="8"/>
207
+ <circle class="brain-node node-9" cx="520" cy="485" r="9"/>
208
+ <circle class="brain-node node-10" cx="360" cy="455" r="7"/>
209
+ <circle class="brain-node node-11" cx="260" cy="390" r="8"/>
210
+ <circle class="brain-node node-12" cx="300" cy="280" r="6"/>
211
+ </g>
212
+ <path class="brain-outline" d="M332 144c33-44 87-70 148-70 52 0 99 18 135 48 31 26 50 57 58 91 61 18 105 72 105 135 0 60-37 111-90 131-11 67-73 117-146 117-41 0-78-15-108-40-25 12-53 18-83 18-85 0-154-58-166-139-46-15-79-56-79-105 0-60 42-110 99-127 10-20 23-39 37-59 25-37 61-63 90-70z"/>
213
+ <circle class="brain-core" cx="480" cy="310" r="62" filter="url(#brainBlur)"/>
214
+ <circle class="brain-core ring" cx="480" cy="310" r="88"/>
215
+ </svg>
216
+ </div>
217
+ </section>
218
+
219
  <!-- Voice visualizer — shown only while mic is active -->
220
  <div class="voice-visualizer" id="voice-viz">
221
  <div class="viz-bar"></div><div class="viz-bar"></div><div class="viz-bar"></div>
frontend/script.js CHANGED
@@ -12,13 +12,17 @@ const stopBtn = document.getElementById('stop-btn');
12
  const stateLabel = document.getElementById('state-label');
13
  const stateDot = document.getElementById('state-dot');
14
  const clearBtn = document.getElementById('clear-btn');
 
 
 
 
 
 
 
15
  const voiceViz = document.getElementById('voice-viz');
16
  const vizBars = Array.from(voiceViz.querySelectorAll('.viz-bar'));
17
  const queueBars = Array.from(document.querySelectorAll('.queue-bar'));
18
  const chunksCount = document.getElementById('chunks-count');
19
- const initOverlay = document.getElementById('init-overlay');
20
- const initBar = document.getElementById('init-bar');
21
- const initStatus = document.getElementById('init-status');
22
  const sidebarEl = document.getElementById('sidebar');
23
  const sidebarToggle = document.getElementById('sidebar-toggle');
24
  const mobileMenuBtn = document.getElementById('mobile-menu-btn');
@@ -51,8 +55,24 @@ const USER_ID = (() => {
51
  })();
52
 
53
  // ─── WebSocket base URL ────────────────────────────────────────────────────────
54
- const WS_BASE = 'http://127.0.0.1:8679';
55
- console.log('[Boot] WS base:', WS_BASE);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
  // ─── WS handles ───────────────────────────────────────────────────────────────
58
  let chatWS = null;
@@ -75,6 +95,12 @@ let _endTimer = null;
75
  let _cancelled = false;
76
  let _inFlight = 0;
77
  let _ttsPlaying = false;
 
 
 
 
 
 
78
 
79
  // ─── Recording state ──────────────────────────────────────────────────────────
80
  let micStream = null;
@@ -96,6 +122,8 @@ let _recorderMime = 'audio/webm';
96
  let aiEl = null;
97
  let aiTxt = '';
98
  let thinkingEl = null;
 
 
99
 
100
  // ─── Latency timestamps ───────────────────────────────────────────────────────
101
  let tSend = 0,
@@ -103,72 +131,10 @@ let tSend = 0,
103
  tLlm = 0,
104
  tTts = 0;
105
 
106
- // ═══════════════════════════════════════════════════════════════════════════════
107
- // INIT OVERLAY
108
- // ═══════════════════════════════════════════════════════════════════════════════
109
-
110
- const STAGES = [
111
- { id: 'stage-1', text: 'AI Engine শুরু হচ্ছে…', at: 400, pct: 20 },
112
- {
113
- id: 'stage-2',
114
- text: 'Speech Recognition মডেল লোড হচ্ছে…',
115
- at: 1100,
116
- pct: 50,
117
- },
118
- { id: 'stage-3', text: 'GPU Warmup চলছে…', at: 1900, pct: 75 },
119
- { id: 'stage-4', text: 'Voice Pipeline প্রস্তুত হচ্ছে…', at: 2700, pct: 90 },
120
- ];
121
-
122
- let _wsGate = false;
123
- let _stageGate = false;
124
- let _initClosed = false;
125
-
126
- function _tryClose() {
127
- if (_initClosed || !_wsGate || !_stageGate) return;
128
- _initClosed = true;
129
- initBar.style.width = '100%';
130
- initStatus.textContent = 'সিস্টেম প্রস্তুত ✓';
131
- setTimeout(() => {
132
- initOverlay.classList.add('hidden');
133
- appEl.style.opacity = '1';
134
- appEl.style.pointerEvents = 'auto';
135
- setState('ready');
136
- }, 450);
137
- }
138
-
139
  function boot() {
140
  initWebSockets();
141
- STAGES.forEach(({ id, text, at, pct }, i) => {
142
- setTimeout(() => {
143
- if (i > 0) _stageDone(STAGES[i - 1].id);
144
- const el = document.getElementById(id);
145
- if (el) el.classList.add('active');
146
- initStatus.textContent = text;
147
- initBar.style.width = pct + '%';
148
- }, at);
149
- });
150
- setTimeout(
151
- () => {
152
- _stageDone(STAGES[STAGES.length - 1].id);
153
- _stageGate = true;
154
- _tryClose();
155
- },
156
- STAGES[STAGES.length - 1].at + 650,
157
- );
158
- setTimeout(() => {
159
- if (!_initClosed) {
160
- _wsGate = _stageGate = true;
161
- _tryClose();
162
- }
163
- }, 8000);
164
- }
165
-
166
- function _stageDone(id) {
167
- const el = document.getElementById(id);
168
- if (el) {
169
- el.classList.remove('active');
170
- el.classList.add('done');
171
- }
172
  }
173
 
174
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -179,6 +145,17 @@ function _backoff(r) {
179
  return Math.min(1000 * Math.pow(2, r), 16000);
180
  }
181
 
 
 
 
 
 
 
 
 
 
 
 
182
  function _setSysStatus(online) {
183
  if (!sysStat) return;
184
  sysStat.textContent = online ? 'Ready' : 'Reconnecting';
@@ -188,7 +165,7 @@ function _setSysStatus(online) {
188
 
189
  function _connectChat() {
190
  if (chatWS && chatWS.readyState <= WebSocket.OPEN) return;
191
- chatWS = new WebSocket(`${WS_BASE}/ws/chat`);
192
  chatWS.onopen = () => {
193
  _chatRetry = 0;
194
  console.log('[Chat WS] connected');
@@ -197,6 +174,7 @@ function _connectChat() {
197
  chatWS.onerror = (e) => console.error('[Chat WS] error:', e);
198
  chatWS.onclose = (ev) => {
199
  console.log(`[Chat WS] closed (${ev.code})`);
 
200
  clearTimeout(_chatRetryTimer);
201
  _chatRetryTimer = setTimeout(() => {
202
  _chatRetry++;
@@ -208,7 +186,7 @@ function _connectChat() {
208
 
209
  function _connectVoice() {
210
  if (voiceWS && voiceWS.readyState <= WebSocket.OPEN) return;
211
- voiceWS = new WebSocket(`${WS_BASE}/ws/voice`);
212
  voiceWS.binaryType = 'arraybuffer';
213
 
214
  voiceWS.onopen = () => {
@@ -216,17 +194,13 @@ function _connectVoice() {
216
  console.log('[Voice WS] connected, uid:', USER_ID);
217
  voiceWS.send(JSON.stringify({ type: 'init', user_id: USER_ID }));
218
  _setSysStatus(true);
219
- _wsGate = true;
220
- _tryClose();
221
  };
222
  voiceWS.onerror = (e) => console.error('[Voice WS] error:', e);
223
  voiceWS.onclose = (ev) => {
224
  console.log(`[Voice WS] closed (${ev.code})`);
225
  _setSysStatus(false);
226
- if (!_initClosed) {
227
- _wsGate = true;
228
- _tryClose();
229
- }
230
  if (isListening || isSpeaking || isProcessing) {
231
  _teardownMicHardware();
232
  _resetVoiceState();
@@ -235,10 +209,14 @@ function _connectVoice() {
235
  micBtn.disabled = false;
236
  }
237
  clearTimeout(_voiceRetryTimer);
 
238
  _voiceRetryTimer = setTimeout(() => {
239
  _voiceRetry++;
240
  _connectVoice();
241
  }, _backoff(_voiceRetry));
 
 
 
242
  };
243
  voiceWS.onmessage = onVoiceMsg;
244
  }
@@ -272,11 +250,7 @@ function onChatMsg(ev) {
272
  chatBox.appendChild(aiEl);
273
  }
274
  aiTxt += msg.token;
275
- aiEl.innerHTML =
276
- typeof marked !== 'undefined'
277
- ? marked.parse(aiTxt)
278
- : aiTxt.replace(/\n/g, '<br>');
279
- chatBox.scrollTop = chatBox.scrollHeight;
280
  break;
281
 
282
  case 'chat':
@@ -288,24 +262,15 @@ function onChatMsg(ev) {
288
  chatBox.appendChild(aiEl);
289
  }
290
  aiTxt = msg.text;
291
- aiEl.innerHTML =
292
- typeof marked !== 'undefined'
293
- ? marked.parse(aiTxt)
294
- : aiTxt.replace(/\n/g, '<br>');
295
- chatBox.scrollTop = chatBox.scrollHeight;
296
  break;
297
 
298
  case 'end':
299
  _removeThinking();
300
- if (aiEl && aiTxt) {
301
- aiEl.innerHTML =
302
- typeof marked !== 'undefined'
303
- ? marked.parse(aiTxt)
304
- : aiTxt.replace(/\n/g, '<br>');
305
- chatBox.scrollTop = chatBox.scrollHeight;
306
- }
307
  aiEl = null;
308
  aiTxt = '';
 
309
  if (tSend > 0) mTotal.textContent = Date.now() - tSend + ' ms';
310
  tSend = tStt = tLlm = tTts = 0;
311
  isProcessing = false;
@@ -317,6 +282,7 @@ function onChatMsg(ev) {
317
  appendMsg('⚠️ ' + msg.text, 'system');
318
  aiEl = null;
319
  aiTxt = '';
 
320
  isProcessing = false;
321
  setState('ready');
322
  break;
@@ -348,9 +314,12 @@ function onVoiceMsg(ev) {
348
  tStt = Date.now();
349
  if (tSend > 0) mStt.textContent = tStt - tSend + ' ms';
350
  _removeThinking();
351
- appendMsg('🎤 ' + msg.text, 'user');
352
  aiEl = null;
353
  aiTxt = '';
 
 
 
354
  appendThinking();
355
  setState('processing');
356
  break;
@@ -362,30 +331,28 @@ function onVoiceMsg(ev) {
362
  if (tStt > 0) mLlm.textContent = tLlm - tStt + ' ms';
363
  }
364
  _removeThinking();
365
- if (!aiEl) {
366
- aiEl = document.createElement('div');
367
- aiEl.className = 'message ai';
368
- chatBox.appendChild(aiEl);
 
 
 
 
 
 
 
 
 
369
  }
370
- aiTxt += msg.token;
371
- aiEl.innerHTML =
372
- typeof marked !== 'undefined'
373
- ? marked.parse(aiTxt)
374
- : aiTxt.replace(/\n/g, '<br>');
375
- chatBox.scrollTop = chatBox.scrollHeight;
376
  break;
377
 
378
  case 'end':
379
- if (aiEl && aiTxt) {
380
- aiEl.innerHTML =
381
- typeof marked !== 'undefined'
382
- ? marked.parse(aiTxt)
383
- : aiTxt.replace(/\n/g, '<br>');
384
- chatBox.scrollTop = chatBox.scrollHeight;
385
- }
386
  _removeThinking();
387
  aiEl = null;
388
  aiTxt = '';
 
389
  if (tSend > 0) mTotal.textContent = Date.now() - tSend + ' ms';
390
  tSend = tStt = tLlm = tTts = 0;
391
  isProcessing = false;
@@ -399,6 +366,9 @@ function onVoiceMsg(ev) {
399
  appendMsg('⚠️ ' + msg.text, 'system');
400
  aiEl = null;
401
  aiTxt = '';
 
 
 
402
  isProcessing = false;
403
  // BUG-FIX-C: unconditionally unlock on error
404
  _done();
@@ -414,6 +384,7 @@ function onVoiceMsg(ev) {
414
 
415
  // ─── Thinking bubble ──────────────────────────────────────────────────────────
416
  function appendThinking() {
 
417
  if (thinkingEl) return;
418
  thinkingEl = document.createElement('div');
419
  thinkingEl.className = 'message ai thinking';
@@ -429,6 +400,28 @@ function _removeThinking() {
429
  }
430
  }
431
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
  // ═══════════════════════════════════════════════════════════════════════════════
433
  // AUDIO PLAYBACK
434
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -515,11 +508,22 @@ function _done() {
515
  _ttsPlaying = false;
516
  isProcessing = false;
517
  isRecordingLocked = false;
 
 
518
  _inFlight = 0;
519
  _vizQ();
520
  micBtn.disabled = false;
521
  setState('ready');
522
  setMic('off');
 
 
 
 
 
 
 
 
 
523
  console.log('[Voice] Idle — ready for next manual press');
524
  }
525
 
@@ -585,6 +589,10 @@ micBtn.onclick = async () => {
585
  return;
586
  }
587
  if (isListening) {
 
 
 
 
588
  _teardownMicHardware();
589
  _resetVoiceState();
590
  setState('ready');
@@ -595,6 +603,10 @@ micBtn.onclick = async () => {
595
  };
596
 
597
  stopBtn.onclick = () => {
 
 
 
 
598
  stopAllAudio();
599
  if (isListening || isSpeaking) _teardownMicHardware();
600
  _resetVoiceState();
@@ -833,9 +845,16 @@ function startRecorder() {
833
  const captured = audioChunks.slice();
834
  audioChunks = [];
835
 
836
- // ── 2. Tear down mic hardware (safe — captured is local) ───────────────
837
- _teardownMicHardware();
838
- setMic('off');
 
 
 
 
 
 
 
839
 
840
  console.log(
841
  `[Recorder] onstop: ${captured.length} chunk(s), ${captured
@@ -851,8 +870,9 @@ function startRecorder() {
851
  'system',
852
  );
853
  _resetVoiceState();
854
- setState('ready');
855
  micBtn.disabled = false;
 
856
  return;
857
  }
858
 
@@ -864,9 +884,10 @@ function startRecorder() {
864
  } catch (err) {
865
  console.error('[Recorder] arrayBuffer() error:', err);
866
  _resetVoiceState();
867
- setState('ready');
868
  setMic('off');
869
  micBtn.disabled = false;
 
870
  return;
871
  }
872
 
@@ -878,12 +899,14 @@ function startRecorder() {
878
  voiceWS.send(buf);
879
  // isProcessing + isRecordingLocked stay true until _done() fires
880
  } else {
881
- console.warn('[VAD] Voice WS not open — utterance dropped');
882
- appendMsg('⚠️ সার্ভারের সাথে সংযোগ নেই — আবার চেষ্টা করুন।', 'system');
 
883
  _resetVoiceState();
884
- setState('ready');
885
  setMic('off');
886
  micBtn.disabled = false;
 
887
  }
888
  };
889
 
@@ -927,6 +950,7 @@ function setState(s) {
927
  const cfg = STATE_MAP[s] || STATE_MAP.ready;
928
  stateLabel.textContent = cfg.label;
929
  stateDot.className = 'state-dot' + (cfg.cls ? ' ' + cfg.cls : '');
 
930
  }
931
 
932
  const MIC_MAP = {
@@ -948,6 +972,7 @@ function setMic(s) {
948
  }
949
 
950
  function appendMsg(text, who) {
 
951
  const d = document.createElement('div');
952
  d.className = 'message ' + who;
953
  if (who === 'ai' && typeof marked !== 'undefined') {
@@ -963,7 +988,11 @@ function appendMsg(text, who) {
963
  clearBtn.onclick = () => {
964
  chatBox.innerHTML = '';
965
  thinkingEl = null;
966
- appendMsg('চ্যাট পরিষ্কার করা হয়েছে।', 'system');
 
 
 
 
967
  };
968
 
969
  sidebarToggle.onclick = () => {
@@ -974,6 +1003,129 @@ sidebarToggle.onclick = () => {
974
  };
975
  mobileMenuBtn.onclick = () => sidebarEl.classList.toggle('mobile-open');
976
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
977
  sThreshold.value = SILENCE_DB;
978
  sThresholdVal.textContent = SILENCE_DB + ' dB';
979
  sThreshold.oninput = () => {
 
12
  const stateLabel = document.getElementById('state-label');
13
  const stateDot = document.getElementById('state-dot');
14
  const clearBtn = document.getElementById('clear-btn');
15
+ const brainBtn = document.getElementById('brain-mode-btn');
16
+ const voiceCaption = document.getElementById('voice-caption');
17
+ const brainStage = document.getElementById('brain-stage');
18
+ const brainBubbleStt = document.getElementById('brain-bubble-stt');
19
+ const brainBubbleTts = document.getElementById('brain-bubble-tts');
20
+ const brainBubbleSttText = document.getElementById('brain-bubble-stt-text');
21
+ const brainBubbleTtsText = document.getElementById('brain-bubble-tts-text');
22
  const voiceViz = document.getElementById('voice-viz');
23
  const vizBars = Array.from(voiceViz.querySelectorAll('.viz-bar'));
24
  const queueBars = Array.from(document.querySelectorAll('.queue-bar'));
25
  const chunksCount = document.getElementById('chunks-count');
 
 
 
26
  const sidebarEl = document.getElementById('sidebar');
27
  const sidebarToggle = document.getElementById('sidebar-toggle');
28
  const mobileMenuBtn = document.getElementById('mobile-menu-btn');
 
55
  })();
56
 
57
  // ─── WebSocket base URL ────────────────────────────────────────────────────────
58
+ const WS_BASES = (() => {
59
+ const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
60
+ const bases = [];
61
+ const host = window.location.host && window.location.host !== 'null'
62
+ ? `${scheme}//${window.location.host}`
63
+ : '';
64
+ const push = (base) => {
65
+ if (base && !bases.includes(base)) bases.push(base);
66
+ };
67
+ push(host);
68
+ push(`${scheme}//127.0.0.1:8000`);
69
+ push(`${scheme}//127.0.0.1:8679`);
70
+ push(`${scheme}//localhost:8000`);
71
+ push(`${scheme}//localhost:8679`);
72
+ return bases;
73
+ })();
74
+ let _wsBaseIndex = 0;
75
+ console.log('[Boot] WS bases:', WS_BASES.join(', '));
76
 
77
  // ─── WS handles ───────────────────────────────────────────────────────────────
78
  let chatWS = null;
 
95
  let _cancelled = false;
96
  let _inFlight = 0;
97
  let _ttsPlaying = false;
98
+ let brainMode = false;
99
+ let brainVoiceActive = false;
100
+ let brainRestartTimer = null;
101
+ let brainAutoRestartTimer = null;
102
+ let brainPendingAudio = null;
103
+ let voicePendingPackets = [];
104
 
105
  // ─── Recording state ──────────────────────────────────────────────────────────
106
  let micStream = null;
 
122
  let aiEl = null;
123
  let aiTxt = '';
124
  let thinkingEl = null;
125
+ let _captionRaf = 0;
126
+ let _captionText = '';
127
 
128
  // ─── Latency timestamps ───────────────────────────────────────────────────────
129
  let tSend = 0,
 
131
  tLlm = 0,
132
  tTts = 0;
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  function boot() {
135
  initWebSockets();
136
+ appEl.classList.add('visible');
137
+ setState('ready');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  }
139
 
140
  // ═══════════════════════════════════════════════════════════════════════════════
 
145
  return Math.min(1000 * Math.pow(2, r), 16000);
146
  }
147
 
148
+ function _wsBase() {
149
+ return WS_BASES[Math.min(_wsBaseIndex, WS_BASES.length - 1)] || WS_BASES[0];
150
+ }
151
+
152
+ function _advanceWsBase() {
153
+ if (WS_BASES.length <= 1) return _wsBase();
154
+ _wsBaseIndex = (_wsBaseIndex + 1) % WS_BASES.length;
155
+ console.log('[WS] Switching base to:', _wsBase());
156
+ return _wsBase();
157
+ }
158
+
159
  function _setSysStatus(online) {
160
  if (!sysStat) return;
161
  sysStat.textContent = online ? 'Ready' : 'Reconnecting';
 
165
 
166
  function _connectChat() {
167
  if (chatWS && chatWS.readyState <= WebSocket.OPEN) return;
168
+ chatWS = new WebSocket(`${_wsBase()}/ws/chat`);
169
  chatWS.onopen = () => {
170
  _chatRetry = 0;
171
  console.log('[Chat WS] connected');
 
174
  chatWS.onerror = (e) => console.error('[Chat WS] error:', e);
175
  chatWS.onclose = (ev) => {
176
  console.log(`[Chat WS] closed (${ev.code})`);
177
+ _advanceWsBase();
178
  clearTimeout(_chatRetryTimer);
179
  _chatRetryTimer = setTimeout(() => {
180
  _chatRetry++;
 
186
 
187
  function _connectVoice() {
188
  if (voiceWS && voiceWS.readyState <= WebSocket.OPEN) return;
189
+ voiceWS = new WebSocket(`${_wsBase()}/ws/voice`);
190
  voiceWS.binaryType = 'arraybuffer';
191
 
192
  voiceWS.onopen = () => {
 
194
  console.log('[Voice WS] connected, uid:', USER_ID);
195
  voiceWS.send(JSON.stringify({ type: 'init', user_id: USER_ID }));
196
  _setSysStatus(true);
197
+ _flushVoicePendingPackets();
198
+ _flushBrainPendingAudio();
199
  };
200
  voiceWS.onerror = (e) => console.error('[Voice WS] error:', e);
201
  voiceWS.onclose = (ev) => {
202
  console.log(`[Voice WS] closed (${ev.code})`);
203
  _setSysStatus(false);
 
 
 
 
204
  if (isListening || isSpeaking || isProcessing) {
205
  _teardownMicHardware();
206
  _resetVoiceState();
 
209
  micBtn.disabled = false;
210
  }
211
  clearTimeout(_voiceRetryTimer);
212
+ _advanceWsBase();
213
  _voiceRetryTimer = setTimeout(() => {
214
  _voiceRetry++;
215
  _connectVoice();
216
  }, _backoff(_voiceRetry));
217
+ if (brainMode && brainVoiceActive) {
218
+ _queueBrainReconnect();
219
+ }
220
  };
221
  voiceWS.onmessage = onVoiceMsg;
222
  }
 
250
  chatBox.appendChild(aiEl);
251
  }
252
  aiTxt += msg.token;
253
+ _renderAiText();
 
 
 
 
254
  break;
255
 
256
  case 'chat':
 
262
  chatBox.appendChild(aiEl);
263
  }
264
  aiTxt = msg.text;
265
+ _renderAiText();
 
 
 
 
266
  break;
267
 
268
  case 'end':
269
  _removeThinking();
270
+ _renderAiText(true);
 
 
 
 
 
 
271
  aiEl = null;
272
  aiTxt = '';
273
+ _setCaption('');
274
  if (tSend > 0) mTotal.textContent = Date.now() - tSend + ' ms';
275
  tSend = tStt = tLlm = tTts = 0;
276
  isProcessing = false;
 
282
  appendMsg('⚠️ ' + msg.text, 'system');
283
  aiEl = null;
284
  aiTxt = '';
285
+ _setCaption('');
286
  isProcessing = false;
287
  setState('ready');
288
  break;
 
314
  tStt = Date.now();
315
  if (tSend > 0) mStt.textContent = tStt - tSend + ' ms';
316
  _removeThinking();
317
+ if (!brainMode) appendMsg('🎤 ' + msg.text, 'user');
318
  aiEl = null;
319
  aiTxt = '';
320
+ _setCaption('');
321
+ _brainSetSttBubble(msg.text);
322
+ _brainModeSetSearch(true);
323
  appendThinking();
324
  setState('processing');
325
  break;
 
331
  if (tStt > 0) mLlm.textContent = tLlm - tStt + ' ms';
332
  }
333
  _removeThinking();
334
+ _setCaption(aiTxt + msg.token);
335
+ _brainSetTtsBubble(aiTxt + msg.token);
336
+ _brainModeSetSearch(true);
337
+ if (!brainMode) {
338
+ if (!aiEl) {
339
+ aiEl = document.createElement('div');
340
+ aiEl.className = 'message ai';
341
+ chatBox.appendChild(aiEl);
342
+ }
343
+ aiTxt += msg.token;
344
+ _renderAiText();
345
+ } else {
346
+ aiTxt += msg.token;
347
  }
 
 
 
 
 
 
348
  break;
349
 
350
  case 'end':
351
+ _renderAiText(true);
 
 
 
 
 
 
352
  _removeThinking();
353
  aiEl = null;
354
  aiTxt = '';
355
+ _setCaption('');
356
  if (tSend > 0) mTotal.textContent = Date.now() - tSend + ' ms';
357
  tSend = tStt = tLlm = tTts = 0;
358
  isProcessing = false;
 
366
  appendMsg('⚠️ ' + msg.text, 'system');
367
  aiEl = null;
368
  aiTxt = '';
369
+ _setCaption('');
370
+ _brainSetTtsBubble('', false);
371
+ _brainModeSetSearch(false);
372
  isProcessing = false;
373
  // BUG-FIX-C: unconditionally unlock on error
374
  _done();
 
384
 
385
  // ─── Thinking bubble ──────────────────────────────────────────────────────────
386
  function appendThinking() {
387
+ if (brainMode) return;
388
  if (thinkingEl) return;
389
  thinkingEl = document.createElement('div');
390
  thinkingEl.className = 'message ai thinking';
 
400
  }
401
  }
402
 
403
+ function _renderAiText(force = false) {
404
+ if (!aiEl || !aiTxt) {
405
+ if (force && aiEl) aiEl.innerHTML = '';
406
+ return;
407
+ }
408
+ aiEl.innerHTML =
409
+ typeof marked !== 'undefined'
410
+ ? marked.parse(aiTxt)
411
+ : aiTxt.replace(/\n/g, '<br>');
412
+ chatBox.scrollTop = chatBox.scrollHeight;
413
+ }
414
+
415
+ function _setCaption(text) {
416
+ _captionText = text || '';
417
+ if (_captionRaf) return;
418
+ _captionRaf = requestAnimationFrame(() => {
419
+ _captionRaf = 0;
420
+ if (!voiceCaption) return;
421
+ voiceCaption.textContent = brainMode ? '' : _captionText;
422
+ });
423
+ }
424
+
425
  // ═══════════════════════════════════════════════════════════════════════════════
426
  // AUDIO PLAYBACK
427
  // ═══════════════════════════════════════════════════════════════════════════════
 
508
  _ttsPlaying = false;
509
  isProcessing = false;
510
  isRecordingLocked = false;
511
+ _brainModeSetSearch(false);
512
+ _brainSetTtsBubble('', false);
513
  _inFlight = 0;
514
  _vizQ();
515
  micBtn.disabled = false;
516
  setState('ready');
517
  setMic('off');
518
+ if (brainMode && brainVoiceActive) {
519
+ clearTimeout(brainAutoRestartTimer);
520
+ brainAutoRestartTimer = setTimeout(() => {
521
+ if (!brainMode || !brainVoiceActive || isListening || isProcessing || isRecordingLocked) {
522
+ return;
523
+ }
524
+ _brainResumeListening();
525
+ }, 180);
526
+ }
527
  console.log('[Voice] Idle — ready for next manual press');
528
  }
529
 
 
589
  return;
590
  }
591
  if (isListening) {
592
+ if (brainMode && brainVoiceActive) {
593
+ console.log('[Brain] Continuous mode active — use Stop to exit');
594
+ return;
595
+ }
596
  _teardownMicHardware();
597
  _resetVoiceState();
598
  setState('ready');
 
603
  };
604
 
605
  stopBtn.onclick = () => {
606
+ brainVoiceActive = false;
607
+ clearTimeout(brainAutoRestartTimer);
608
+ clearTimeout(brainRestartTimer);
609
+ brainPendingAudio = null;
610
  stopAllAudio();
611
  if (isListening || isSpeaking) _teardownMicHardware();
612
  _resetVoiceState();
 
845
  const captured = audioChunks.slice();
846
  audioChunks = [];
847
 
848
+ const keepBrainMicWarm = brainMode && brainVoiceActive;
849
+
850
+ // ── 2. Tear down mic hardware unless brain mode wants a live loop ─────
851
+ if (keepBrainMicWarm) {
852
+ mediaRecorder = null;
853
+ setMic('off');
854
+ } else {
855
+ _teardownMicHardware();
856
+ setMic('off');
857
+ }
858
 
859
  console.log(
860
  `[Recorder] onstop: ${captured.length} chunk(s), ${captured
 
870
  'system',
871
  );
872
  _resetVoiceState();
873
+ setState(keepBrainMicWarm ? 'listening' : 'ready');
874
  micBtn.disabled = false;
875
+ if (keepBrainMicWarm) _brainResumeListening();
876
  return;
877
  }
878
 
 
884
  } catch (err) {
885
  console.error('[Recorder] arrayBuffer() error:', err);
886
  _resetVoiceState();
887
+ setState(keepBrainMicWarm ? 'listening' : 'ready');
888
  setMic('off');
889
  micBtn.disabled = false;
890
+ if (keepBrainMicWarm) _brainResumeListening();
891
  return;
892
  }
893
 
 
899
  voiceWS.send(buf);
900
  // isProcessing + isRecordingLocked stay true until _done() fires
901
  } else {
902
+ console.warn('[VAD] Voice WS not open — queueing utterance');
903
+ voicePendingPackets.push(buf);
904
+ _connectVoice();
905
  _resetVoiceState();
906
+ setState(keepBrainMicWarm ? 'listening' : 'ready');
907
  setMic('off');
908
  micBtn.disabled = false;
909
+ if (keepBrainMicWarm) _brainResumeListening();
910
  }
911
  };
912
 
 
950
  const cfg = STATE_MAP[s] || STATE_MAP.ready;
951
  stateLabel.textContent = cfg.label;
952
  stateDot.className = 'state-dot' + (cfg.cls ? ' ' + cfg.cls : '');
953
+ if (brainStage) brainStage.dataset.state = s;
954
  }
955
 
956
  const MIC_MAP = {
 
972
  }
973
 
974
  function appendMsg(text, who) {
975
+ if (brainMode && who !== 'system') return null;
976
  const d = document.createElement('div');
977
  d.className = 'message ' + who;
978
  if (who === 'ai' && typeof marked !== 'undefined') {
 
988
  clearBtn.onclick = () => {
989
  chatBox.innerHTML = '';
990
  thinkingEl = null;
991
+ if (!brainMode) appendMsg('চ্যাট পরিষ্কার করা হয়েছে।', 'system');
992
+ };
993
+
994
+ brainBtn.onclick = () => {
995
+ setBrainMode(!brainMode);
996
  };
997
 
998
  sidebarToggle.onclick = () => {
 
1003
  };
1004
  mobileMenuBtn.onclick = () => sidebarEl.classList.toggle('mobile-open');
1005
 
1006
+ function setBrainMode(on) {
1007
+ brainMode = !!on;
1008
+ document.body.classList.toggle('brain-mode', brainMode);
1009
+ brainBtn.classList.toggle('active', brainMode);
1010
+ brainBtn.setAttribute('aria-pressed', String(brainMode));
1011
+ if (brainStage) brainStage.setAttribute('aria-hidden', String(!brainMode));
1012
+ if (voiceCaption) voiceCaption.textContent = '';
1013
+ if (brainMode) {
1014
+ brainBubbleSttText.textContent = 'Listening…';
1015
+ brainBubbleTtsText.textContent = 'Waiting…';
1016
+ brainVoiceActive = true;
1017
+ sidebarEl.classList.add('collapsed');
1018
+ sidebarToggle.textContent = '›';
1019
+ chatBox.scrollTop = chatBox.scrollHeight;
1020
+ textInput.blur();
1021
+ _brainModeSetSearch(isProcessing || isListening || isSpeaking || _ttsPlaying);
1022
+ if (!isListening && !isProcessing && !isRecordingLocked) {
1023
+ setTimeout(() => {
1024
+ if (brainMode && brainVoiceActive && !isListening && !isProcessing && !isRecordingLocked) {
1025
+ _brainResumeListening();
1026
+ }
1027
+ }, 180);
1028
+ }
1029
+ } else {
1030
+ brainVoiceActive = false;
1031
+ clearTimeout(brainAutoRestartTimer);
1032
+ clearTimeout(brainRestartTimer);
1033
+ brainPendingAudio = null;
1034
+ sidebarEl.classList.remove('collapsed');
1035
+ sidebarToggle.textContent = '‹';
1036
+ _brainModeSetSearch(false);
1037
+ _brainSetSttBubble('');
1038
+ _brainSetTtsBubble('');
1039
+ }
1040
+ }
1041
+
1042
+ function _brainModeSetSearch(active) {
1043
+ if (!brainStage) return;
1044
+ brainStage.classList.toggle('searching', !!active);
1045
+ }
1046
+
1047
+ function _brainSetSttBubble(text) {
1048
+ if (!brainBubbleStt || !brainBubbleSttText) return;
1049
+ const value = (text || '').trim();
1050
+ brainBubbleSttText.textContent = value || 'Listening…';
1051
+ brainBubbleStt.classList.toggle('active', !!value);
1052
+ }
1053
+
1054
+ function _brainSetTtsBubble(text, active = true) {
1055
+ if (!brainBubbleTts || !brainBubbleTtsText) return;
1056
+ const value = (text || '').trim();
1057
+ brainBubbleTtsText.textContent = value || 'Waiting…';
1058
+ brainBubbleTts.classList.toggle('active', active || !!value);
1059
+ brainBubbleTts.classList.toggle('speaking', active || !!value);
1060
+ }
1061
+
1062
+ function _brainResumeListening() {
1063
+ if (!brainMode || !brainVoiceActive || isListening || isProcessing || isRecordingLocked) {
1064
+ return;
1065
+ }
1066
+ if (micStream && analyserCtx && analyser) {
1067
+ isListening = true;
1068
+ setMic('listening');
1069
+ setState('listening');
1070
+ voiceViz.classList.add('active');
1071
+ vadInt = setInterval(vadTick, VAD_MS);
1072
+ vizInt = setInterval(vizTick, 60);
1073
+ _brainModeSetSearch(false);
1074
+ console.log('[Brain] Mic re-armed');
1075
+ return;
1076
+ }
1077
+ startListening().catch((err) => {
1078
+ console.error('[Brain] resume failed:', err);
1079
+ });
1080
+ }
1081
+
1082
+ function _queueBrainReconnect() {
1083
+ if (!brainMode || !brainVoiceActive) return;
1084
+ clearTimeout(brainRestartTimer);
1085
+ brainRestartTimer = setTimeout(() => {
1086
+ if (!brainMode || !brainVoiceActive) return;
1087
+ _flushBrainPendingAudio();
1088
+ }, 700);
1089
+ }
1090
+
1091
+ function _flushVoicePendingPackets() {
1092
+ if (!voiceWS || voiceWS.readyState !== WebSocket.OPEN || !voicePendingPackets.length) {
1093
+ return;
1094
+ }
1095
+ const packets = voicePendingPackets.splice(0);
1096
+ for (const packet of packets) {
1097
+ try {
1098
+ voiceWS.send(packet);
1099
+ appendThinking();
1100
+ console.log('[Voice] queued packet flushed');
1101
+ } catch (err) {
1102
+ console.error('[Voice] flush failed:', err);
1103
+ voicePendingPackets.unshift(packet);
1104
+ _connectVoice();
1105
+ break;
1106
+ }
1107
+ }
1108
+ }
1109
+
1110
+ function _flushBrainPendingAudio() {
1111
+ if (!brainPendingAudio) return;
1112
+ if (!voiceWS || voiceWS.readyState !== WebSocket.OPEN) {
1113
+ _queueBrainReconnect();
1114
+ return;
1115
+ }
1116
+ const buf = brainPendingAudio;
1117
+ brainPendingAudio = null;
1118
+ try {
1119
+ appendThinking();
1120
+ voiceWS.send(buf);
1121
+ console.log('[Brain] queued utterance flushed');
1122
+ } catch (err) {
1123
+ console.error('[Brain] flush failed:', err);
1124
+ brainPendingAudio = buf;
1125
+ _queueBrainReconnect();
1126
+ }
1127
+ }
1128
+
1129
  sThreshold.value = SILENCE_DB;
1130
  sThresholdVal.textContent = SILENCE_DB + ' dB';
1131
  sThreshold.oninput = () => {
frontend/style.css CHANGED
@@ -1,235 +1,97 @@
1
- /* ── Reset & base ── */
2
  *,
3
  *::before,
4
  *::after {
5
- margin: 0;
6
- padding: 0;
7
  box-sizing: border-box;
8
  }
9
 
10
  :root {
11
- --bg: #07090f;
12
- --bg2: #0d1117;
13
- --bg3: #121820;
14
- --border: rgba(255, 255, 255, 0.07);
15
- --border2: rgba(255, 255, 255, 0.12);
16
- --text: #e2e8f0;
17
- --text2: #8892a4;
18
- --text3: #4a5568;
19
- --accent: #22d3ee;
20
- --accent2: #818cf8;
21
- --accent3: #f472b6;
22
- --green: #4ade80;
23
- --red: #f87171;
24
- --yellow: #fbbf24;
25
- --user-bg: rgba(34, 211, 238, 0.1);
26
- --ai-bg: rgba(129, 140, 248, 0.08);
27
- --sidebar-w: 270px;
28
- --transition: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
 
 
 
 
29
  }
30
 
31
  html,
32
  body {
33
  height: 100%;
34
- background: var(--bg);
 
 
 
 
 
35
  color: var(--text);
36
  font-family: 'Hind Siliguri', 'Syne', sans-serif;
37
  overflow: hidden;
38
  }
39
 
40
- /* ── Ambient orbs ── */
41
- .bg-orb {
42
- position: fixed;
43
- border-radius: 50%;
44
- filter: blur(80px);
45
- pointer-events: none;
46
- z-index: 0;
47
- opacity: 0.18;
48
- animation: orb-float 12s ease-in-out infinite;
49
- }
50
- .orb-1 {
51
- width: 500px; height: 500px;
52
- background: radial-gradient(circle, #22d3ee, transparent);
53
- top: -200px; left: -150px;
54
- animation-delay: 0s;
55
- }
56
- .orb-2 {
57
- width: 400px; height: 400px;
58
- background: radial-gradient(circle, #818cf8, transparent);
59
- bottom: -100px; right: -100px;
60
- animation-delay: -4s;
61
- }
62
- .orb-3 {
63
- width: 300px; height: 300px;
64
- background: radial-gradient(circle, #f472b6, transparent);
65
- top: 50%; left: 50%;
66
- transform: translate(-50%, -50%);
67
- animation-delay: -8s;
68
- }
69
- @keyframes orb-float {
70
- 0%, 100% { transform: translate(0, 0) scale(1); }
71
- 33% { transform: translate(30px, -20px) scale(1.05); }
72
- 66% { transform: translate(-20px, 15px) scale(0.97); }
73
  }
74
 
75
- /* ── Init overlay ── */
76
- .init-overlay {
77
- position: fixed;
78
- inset: 0;
79
- z-index: 1000;
80
- display: flex;
81
- align-items: center;
82
- justify-content: center;
83
- background: var(--bg);
84
- transition: opacity 0.6s ease, visibility 0.6s ease;
85
- }
86
- .init-overlay.hidden {
87
- opacity: 0;
88
- visibility: hidden;
89
- pointer-events: none;
90
  }
91
 
92
- .init-card {
93
- background: var(--bg2);
94
- border: 1px solid var(--border2);
95
- border-radius: 24px;
96
- padding: 48px 56px;
97
- width: 480px;
98
- max-width: 95vw;
99
- text-align: center;
100
- box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
101
- }
102
- .init-logo {
103
- margin-bottom: 20px;
104
- animation: logo-pulse 2s ease-in-out infinite;
105
- }
106
- @keyframes logo-pulse {
107
- 0%, 100% { filter: drop-shadow(0 0 12px rgba(34, 211, 238, 0.4)); transform: scale(1); }
108
- 50% { filter: drop-shadow(0 0 24px rgba(129, 140, 248, 0.6)); transform: scale(1.06); }
109
- }
110
- .init-title {
111
- font-family: 'Syne', sans-serif;
112
- font-size: 26px;
113
- font-weight: 800;
114
- background: linear-gradient(135deg, var(--accent), var(--accent2));
115
- -webkit-background-clip: text;
116
- -webkit-text-fill-color: transparent;
117
- background-clip: text;
118
- margin-bottom: 6px;
119
- }
120
- .init-subtitle {
121
- font-family: 'Hind Siliguri', sans-serif;
122
- color: var(--text2);
123
- font-size: 15px;
124
- margin-bottom: 36px;
125
- }
126
- .init-stages {
127
- text-align: left;
128
- margin-bottom: 28px;
129
- }
130
- .stage {
131
- display: flex;
132
- align-items: center;
133
- gap: 12px;
134
- padding: 10px 0;
135
- font-size: 13px;
136
- color: var(--text3);
137
- border-bottom: 1px solid var(--border);
138
- transition: color 0.3s;
139
- }
140
- .stage.active { color: var(--accent); }
141
- .stage.done { color: var(--green); }
142
- .stage-dot {
143
- width: 8px; height: 8px;
144
- border-radius: 50%;
145
- background: var(--text3);
146
- flex-shrink: 0;
147
- transition: background 0.3s, box-shadow 0.3s;
148
- }
149
- .stage.active .stage-dot {
150
- background: var(--accent);
151
- box-shadow: 0 0 8px var(--accent);
152
- animation: blink-dot 0.8s ease-in-out infinite;
153
- }
154
- .stage.done .stage-dot { background: var(--green); }
155
- @keyframes blink-dot {
156
- 0%, 100% { opacity: 1; }
157
- 50% { opacity: 0.3; }
158
- }
159
- .stage-check {
160
- margin-left: auto;
161
- opacity: 0;
162
- transition: opacity 0.3s;
163
- }
164
- .stage.done .stage-check { opacity: 1; }
165
- .stage span {
166
- flex: 1;
167
- font-family: 'Hind Siliguri', sans-serif;
168
- }
169
- .init-bar-wrap {
170
- background: var(--bg3);
171
- border-radius: 99px;
172
- height: 6px;
173
- overflow: hidden;
174
- margin-bottom: 16px;
175
- border: 1px solid var(--border);
176
- }
177
- .init-bar {
178
- height: 100%;
179
- background: linear-gradient(90deg, var(--accent), var(--accent2));
180
- border-radius: 99px;
181
- width: 0%;
182
- transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
183
- box-shadow: 0 0 12px rgba(34, 211, 238, 0.5);
184
- }
185
- .init-status {
186
- font-size: 12px;
187
- color: var(--text2);
188
- font-family: 'JetBrains Mono', monospace;
189
  }
190
 
191
- /* ── App layout ──
192
- FIX-7: App is hidden by default via opacity/pointer-events.
193
- JS adds class .visible after init overlay closes. This prevents
194
- any flash of unstyled content (FOUC) during JS execution.
195
- ── */
196
  .app {
197
  position: fixed;
198
  inset: 0;
199
- z-index: 1;
200
  display: flex;
201
- opacity: 0;
202
- pointer-events: none;
203
- transition: opacity 0.5s ease;
204
- }
205
- .app.visible {
206
  opacity: 1;
207
  pointer-events: auto;
208
  }
209
 
210
- /* ── Sidebar ── */
211
  .sidebar {
212
  width: var(--sidebar-w);
213
- background: var(--bg2);
214
- border-right: 1px solid var(--border);
215
  display: flex;
216
  flex-direction: column;
217
- flex-shrink: 0;
 
 
 
218
  overflow-y: auto;
219
- transition: width var(--transition), transform var(--transition);
220
- z-index: 10;
221
  }
 
222
  .sidebar.collapsed {
223
  width: 0;
224
  overflow: hidden;
225
  }
 
226
  .sidebar-header {
227
  display: flex;
228
  align-items: center;
229
  justify-content: space-between;
230
- padding: 20px 16px 16px;
231
- border-bottom: 1px solid var(--border);
232
  }
 
233
  .brand {
234
  display: flex;
235
  align-items: center;
@@ -237,109 +99,130 @@ body {
237
  font-family: 'Syne', sans-serif;
238
  font-weight: 700;
239
  font-size: 14px;
240
- color: var(--text);
241
  }
242
- .sidebar-toggle {
243
- background: none;
 
 
 
 
 
 
 
 
244
  border: 1px solid var(--border);
245
- color: var(--text2);
246
- border-radius: 8px;
247
- padding: 4px 8px;
 
 
 
 
 
 
 
248
  cursor: pointer;
249
- font-size: 16px;
250
- transition: all var(--transition);
251
  }
 
252
  .sidebar-toggle:hover {
253
- background: var(--border);
254
- color: var(--text);
255
  }
256
 
257
- .status-panel { padding: 16px; }
258
- .status-row {
259
- display: flex;
260
- align-items: center;
261
- justify-content: space-between;
262
- padding: 6px 0;
263
- }
264
- .status-label { font-size: 12px; color: var(--text2); }
265
- .status-badge {
266
- font-size: 10px;
267
- font-family: 'JetBrains Mono', monospace;
268
- padding: 2px 8px;
269
- border-radius: 99px;
270
- font-weight: 600;
271
- letter-spacing: 0.03em;
272
  }
273
- .badge-green { background: rgba(74, 222, 128, 0.12); color: var(--green); }
274
- .badge-yellow { background: rgba(251, 191, 36, 0.12); color: var(--yellow); }
275
- .badge-red { background: rgba(248, 113, 113, 0.12); color: var(--red); }
276
 
277
- .sidebar-divider { height: 1px; background: var(--border); margin: 4px 0; }
 
 
278
 
279
- .dash-section { padding: 16px; }
280
  .dash-title {
281
  font-size: 11px;
282
- font-weight: 700;
283
  text-transform: uppercase;
284
- letter-spacing: 0.08em;
285
- color: var(--text2);
286
  margin-bottom: 12px;
287
  }
 
288
  .metric-grid {
289
  display: grid;
290
- grid-template-columns: 1fr 1fr;
291
- gap: 8px;
292
  }
 
293
  .metric-card {
294
- background: var(--bg3);
295
  border: 1px solid var(--border);
296
- border-radius: 10px;
297
- padding: 10px;
 
298
  text-align: center;
299
  }
 
300
  .metric-val {
301
  font-family: 'JetBrains Mono', monospace;
302
  font-size: 18px;
303
- font-weight: 400;
304
  color: var(--accent);
305
  line-height: 1;
306
  margin-bottom: 4px;
307
  }
308
- .metric-label { font-size: 10px; color: var(--text3); }
309
 
310
- .setting-row { margin-bottom: 14px; }
 
 
 
 
 
 
 
 
 
 
311
  .setting-row label {
312
  display: block;
313
  font-size: 11px;
314
- color: var(--text2);
315
- margin-bottom: 6px;
316
  }
317
- .slider-wrap { display: flex; align-items: center; gap: 8px; }
 
 
 
 
 
 
318
  .slider-wrap input[type='range'] {
319
  flex: 1;
320
- accent-color: var(--accent);
321
- height: 4px;
322
- cursor: pointer;
323
  }
 
324
  .slider-wrap span {
 
 
325
  font-size: 11px;
 
326
  font-family: 'JetBrains Mono', monospace;
327
- color: var(--accent);
328
- min-width: 58px;
329
- text-align: right;
330
  }
 
331
  .setting-select {
332
  width: 100%;
333
- background: var(--bg3);
334
- border: 1px solid var(--border);
 
335
  color: var(--text);
336
- border-radius: 8px;
337
- padding: 6px 10px;
338
- font-size: 12px;
339
- font-family: 'Hind Siliguri', sans-serif;
340
- cursor: pointer;
341
  }
342
- .setting-select:focus { outline: none; border-color: var(--accent); }
 
 
 
 
 
 
343
 
344
  .queue-vis {
345
  display: flex;
@@ -348,342 +231,952 @@ body {
348
  height: 48px;
349
  margin-bottom: 8px;
350
  }
 
351
  .queue-bar {
352
  flex: 1;
353
- background: var(--accent);
354
- border-radius: 3px;
355
- opacity: 0.3;
356
- transition: height 0.15s ease, opacity 0.15s ease;
357
  min-height: 4px;
 
 
 
 
358
  }
359
- .queue-bar.active { opacity: 0.9; }
360
- .queue-label {
361
- font-size: 11px;
362
- color: var(--text2);
363
- font-family: 'JetBrains Mono', monospace;
364
  }
365
 
366
- /* ── Main ── */
367
  .main {
368
  flex: 1;
 
369
  display: flex;
370
  flex-direction: column;
371
  overflow: hidden;
372
- min-width: 0;
373
  }
374
 
375
- /* ── Topbar ── */
376
  .topbar {
377
  display: flex;
378
  align-items: center;
379
  justify-content: space-between;
380
- padding: 14px 20px;
381
- background: var(--bg2);
382
  border-bottom: 1px solid var(--border);
 
 
383
  flex-shrink: 0;
384
  }
385
- .topbar-left { display: flex; align-items: center; gap: 12px; }
 
 
 
 
 
 
 
386
  .topbar-center {
387
- font-family: 'Syne', sans-serif;
388
- font-weight: 700;
389
- font-size: 15px;
390
- color: var(--text);
391
  position: absolute;
392
  left: 50%;
393
  transform: translateX(-50%);
 
 
 
 
 
394
  }
395
- .topbar-right { display: flex; gap: 8px; }
396
- .mobile-menu-btn {
397
- display: none;
398
- background: none;
 
 
 
 
 
 
399
  border: 1px solid var(--border);
400
- color: var(--text2);
401
- border-radius: 8px;
402
- padding: 6px 10px;
403
- cursor: pointer;
404
- font-size: 16px;
405
  }
 
406
  .state-dot {
407
- width: 8px; height: 8px;
408
- border-radius: 50%;
409
- background: var(--green);
410
- box-shadow: 0 0 6px var(--green);
411
- flex-shrink: 0;
412
- transition: background 0.3s, box-shadow 0.3s;
413
  }
414
- .state-dot.listening { background: var(--accent); box-shadow: 0 0 8px var(--accent); animation: blink-dot 0.8s infinite; }
415
- .state-dot.recording { background: var(--red); box-shadow: 0 0 10px var(--red); animation: blink-dot 0.4s infinite; }
416
- .state-dot.processing { background: var(--yellow); box-shadow: 0 0 8px var(--yellow); animation: blink-dot 1s infinite; }
417
- .state-dot.speaking { background: var(--accent2); box-shadow: 0 0 10px var(--accent2);animation: blink-dot 0.6s infinite; }
418
- #state-label { font-size: 13px; color: var(--text2); font-family: 'JetBrains Mono', monospace; }
419
 
420
- .clear-btn {
421
- background: none;
422
- border: 1px solid var(--border);
423
- color: var(--text2);
424
- border-radius: 8px;
425
- padding: 6px 12px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  cursor: pointer;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  font-size: 12px;
428
- font-family: 'Syne', sans-serif;
429
- transition: all var(--transition);
 
 
 
 
 
 
 
 
 
 
 
430
  }
431
- .clear-btn:hover { border-color: var(--accent); color: var(--accent); }
432
 
433
- /* ── Chat ── */
434
  #chat-box {
 
 
 
435
  flex: 1;
436
  overflow-y: auto;
437
- padding: 24px 20px 12px;
438
  display: flex;
439
  flex-direction: column;
440
  gap: 12px;
441
  scroll-behavior: smooth;
442
  }
443
- #chat-box::-webkit-scrollbar { width: 4px; }
444
- #chat-box::-webkit-scrollbar-track { background: transparent; }
445
- #chat-box::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 99px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
446
 
447
  .message {
448
- max-width: 75%;
449
- padding: 14px 18px;
450
- border-radius: 16px;
451
  line-height: 1.65;
452
- font-size: 14.5px;
453
- word-wrap: break-word;
454
- overflow-wrap: break-word;
455
- animation: msg-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
456
- font-family: 'Hind Siliguri', sans-serif;
 
457
  }
 
458
  @keyframes msg-in {
459
- from { opacity: 0; transform: translateY(10px) scale(0.97); }
460
- to { opacity: 1; transform: translateY(0) scale(1); }
 
 
 
 
 
 
461
  }
 
462
  .message.user {
463
- background: var(--user-bg);
464
- border: 1px solid rgba(34, 211, 238, 0.2);
465
- margin-left: auto;
466
- border-bottom-right-radius: 4px;
467
  }
 
468
  .message.ai {
469
- background: var(--ai-bg);
470
- border: 1px solid rgba(129, 140, 248, 0.15);
471
- border-bottom-left-radius: 4px;
472
  }
 
473
  .message.system {
474
- background: rgba(251, 191, 36, 0.08);
475
- border: 1px solid rgba(251, 191, 36, 0.2);
476
- color: var(--yellow);
477
- font-size: 12px;
478
- font-family: 'JetBrains Mono', monospace;
479
  align-self: center;
480
- max-width: 90%;
 
 
 
 
 
 
 
 
 
481
  }
482
- .message ul, .message ol { padding-left: 20px; margin: 8px 0; }
483
- .message li { margin-bottom: 4px; }
484
- .message p { margin: 6px 0; }
485
  .message code {
486
- background: rgba(0, 0, 0, 0.3);
487
- border-radius: 4px;
488
  padding: 1px 6px;
 
 
489
  font-family: 'JetBrains Mono', monospace;
490
  font-size: 13px;
491
  }
 
492
  .message pre {
493
- background: rgba(0, 0, 0, 0.3);
494
- border-radius: 8px;
495
- padding: 12px;
496
  overflow-x: auto;
497
- margin: 8px 0;
 
 
498
  }
499
 
500
- /* ── Voice visualizer ── */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
  .voice-visualizer {
 
 
 
 
502
  display: flex;
503
  align-items: center;
504
  justify-content: center;
505
  gap: 4px;
506
- height: 0;
507
- overflow: hidden;
508
- transition: height 0.3s ease;
509
- padding: 0 20px;
 
510
  }
511
- .voice-visualizer.active { height: 56px; }
512
  .viz-bar {
513
  width: 4px;
514
- border-radius: 99px;
515
- background: linear-gradient(180deg, var(--accent), var(--accent2));
516
  height: 6px;
 
 
517
  transition: height 0.08s ease;
518
- flex-shrink: 0;
519
  }
520
 
521
- /* ── Controls ── */
522
  .controls {
523
- padding: 16px 20px 20px;
524
- background: var(--bg2);
525
- border-top: 1px solid var(--border);
 
 
 
 
 
526
  flex-shrink: 0;
527
  }
 
528
  .text-row {
529
  display: flex;
 
530
  gap: 10px;
531
- margin-bottom: 12px;
532
- align-items: flex-end; /* FIX-4: align send button to bottom when textarea grows */
533
  }
534
 
535
- /* ── FIX-4: Auto-growing textarea replaces <input type="text"> ── */
536
  #text-input {
537
  flex: 1;
538
- background: var(--bg3);
539
- border: 1px solid var(--border);
540
- border-radius: 12px;
541
- padding: 12px 16px;
 
 
 
542
  color: var(--text);
543
- font-size: 14px;
544
- font-family: 'Hind Siliguri', sans-serif;
545
- outline: none;
546
- transition: border-color var(--transition);
547
- resize: none; /* FIX-4: no manual resize handle */
548
- overflow-y: hidden; /* hidden until 10 lines exceeded; JS manages */
549
- line-height: 1.57; /* ~22px per line at font-size 14px */
550
- min-height: 44px; /* single line min */
551
- max-height: 226px; /* 10 lines × 22px + 16px padding */
552
- display: block;
553
- /* smooth height animation */
554
- transition: border-color var(--transition), height 0.1s ease;
555
  }
556
- #text-input::placeholder { color: var(--text3); }
557
- #text-input:focus { border-color: var(--accent); }
558
- /* Custom scrollbar inside textarea once > 10 lines */
559
- #text-input::-webkit-scrollbar { width: 4px; }
560
- #text-input::-webkit-scrollbar-track { background: transparent; }
561
- #text-input::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 99px; }
562
 
563
  #send-btn {
564
- background: linear-gradient(135deg, var(--accent), var(--accent2));
565
- border: none;
566
- border-radius: 12px;
567
- padding: 12px 16px;
 
568
  cursor: pointer;
569
- color: #000;
570
- display: flex;
571
  align-items: center;
572
- transition: opacity var(--transition), transform 0.1s;
573
- flex-shrink: 0;
574
- align-self: flex-end; /* FIX-4: stays at bottom as textarea grows */
575
- height: 44px;
 
 
576
  }
577
- #send-btn:hover { opacity: 0.88; }
578
- #send-btn:active { transform: scale(0.95); }
579
 
580
- .voice-row { display: flex; gap: 10px; }
581
  .mic-btn {
582
  flex: 1;
 
 
 
 
583
  display: flex;
584
  align-items: center;
585
  justify-content: center;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
586
  gap: 8px;
587
- padding: 13px 20px;
588
- border-radius: 14px;
589
- border: 1.5px solid var(--border2);
590
- background: var(--bg3);
591
- color: var(--text);
592
  cursor: pointer;
593
- font-size: 14px;
594
- font-family: 'Hind Siliguri', sans-serif;
595
- transition: all var(--transition);
 
 
 
 
 
 
 
 
 
 
 
 
596
  position: relative;
 
 
 
 
 
 
 
 
 
597
  overflow: hidden;
598
  }
599
- .mic-btn::before {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
600
  content: '';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
601
  position: absolute;
602
  inset: 0;
603
- background: linear-gradient(135deg, var(--accent), var(--accent2));
604
- opacity: 0;
605
- transition: opacity var(--transition);
 
 
 
 
 
 
606
  }
607
- .mic-btn:hover::before { opacity: 0.08; }
608
- .mic-btn.mic-listening {
609
- border-color: var(--accent);
610
- box-shadow: 0 0 0 2px rgba(34, 211, 238, 0.2), inset 0 0 20px rgba(34, 211, 238, 0.05);
 
 
 
 
 
 
 
 
611
  }
612
- .mic-btn.mic-recording {
613
- border-color: var(--red);
614
- animation: pulse-red 0.8s ease-in-out infinite;
 
615
  }
616
- @keyframes pulse-red {
617
- 0%, 100% { box-shadow: 0 0 0 0 rgba(248, 113, 113, 0.4); }
618
- 50% { box-shadow: 0 0 0 8px rgba(248, 113, 113, 0); }
 
 
 
 
 
 
 
 
 
619
  }
620
- .mic-btn.mic-processing {
621
- border-color: var(--yellow);
622
- box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.15);
 
623
  }
624
- .mic-icon { font-size: 18px; position: relative; z-index: 1; }
625
- .mic-label { position: relative; z-index: 1; }
626
 
627
- .stop-btn {
628
- background: rgba(248, 113, 113, 0.1);
629
- border: 1.5px solid rgba(248, 113, 113, 0.3);
630
- color: var(--red);
631
- border-radius: 14px;
632
- padding: 13px 16px;
633
- cursor: pointer;
634
- font-size: 13px;
635
- font-family: 'Hind Siliguri', sans-serif;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
636
  display: flex;
637
- align-items: center;
638
- gap: 6px;
639
- transition: all var(--transition);
640
  }
641
- .stop-btn:hover { background: rgba(248, 113, 113, 0.2); border-color: var(--red); }
642
- .stop-btn:active { transform: scale(0.95); }
643
 
644
- /* ── Scrollbar (sidebar) ── */
645
- .sidebar::-webkit-scrollbar { width: 4px; }
646
- .sidebar::-webkit-scrollbar-track { background: transparent; }
647
- .sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 99px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
648
 
649
- /* ── Responsive ── */
650
- @media (max-width: 680px) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
651
  .sidebar {
652
  position: fixed;
653
- left: 0; top: 0; bottom: 0;
654
  transform: translateX(-100%);
655
- z-index: 100;
 
 
656
  }
657
- .sidebar.mobile-open { transform: translateX(0); }
658
- .mobile-menu-btn { display: flex; }
659
- .topbar-center { font-size: 13px; }
660
- .message { max-width: 90%; font-size: 14px; }
661
- }
662
 
663
- /* ── Thinking bubble (animated "..." while AI processes) ── */
664
- .message.thinking {
665
- display: flex;
666
- align-items: center;
667
- gap: 5px;
668
- padding: 12px 16px;
669
- background: var(--ai-bg);
670
- border: 1px solid var(--border);
671
- border-radius: 16px 16px 16px 4px;
672
- align-self: flex-start;
673
- max-width: 80px;
 
 
 
 
674
  }
675
- .message.thinking .dot {
676
- display: inline-block;
677
- width: 7px; height: 7px;
678
- border-radius: 50%;
679
- background: var(--accent2);
680
- opacity: 0.4;
681
- animation: dot-bounce 1.2s ease-in-out infinite;
682
- }
683
- .message.thinking .dot:nth-child(2) { animation-delay: 0.2s; }
684
- .message.thinking .dot:nth-child(3) { animation-delay: 0.4s; }
685
-
686
- @keyframes dot-bounce {
687
- 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
688
- 40% { transform: translateY(-6px); opacity: 1; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
689
  }
 
1
+ /* ── Reset & base ─────────────────────────────────────────────────────────── */
2
  *,
3
  *::before,
4
  *::after {
 
 
5
  box-sizing: border-box;
6
  }
7
 
8
  :root {
9
+ --bg: #05070d;
10
+ --panel: rgba(10, 14, 24, 0.76);
11
+ --panel-strong: rgba(13, 18, 30, 0.94);
12
+ --panel-soft: rgba(255, 255, 255, 0.03);
13
+ --border: rgba(255, 255, 255, 0.08);
14
+ --border-strong: rgba(255, 255, 255, 0.14);
15
+ --text: #e5eefb;
16
+ --text-2: #a9b7cc;
17
+ --text-3: #6f7d92;
18
+ --accent: #f5f7fb;
19
+ --accent-2: #64b5ff;
20
+ --good: #16a34a;
21
+ --warn: #f59e0b;
22
+ --danger: #ff5e7b;
23
+ --shadow: 0 22px 60px rgba(0, 0, 0, 0.45);
24
+ --shadow-soft: 0 10px 26px rgba(0, 0, 0, 0.32);
25
+ --radius-xl: 28px;
26
+ --radius-lg: 20px;
27
+ --radius-md: 14px;
28
+ --radius-sm: 10px;
29
+ --sidebar-w: 304px;
30
+ --max-chat: 860px;
31
  }
32
 
33
  html,
34
  body {
35
  height: 100%;
36
+ margin: 0;
37
+ background:
38
+ radial-gradient(circle at top left, rgba(100, 181, 255, 0.14), transparent 20%),
39
+ radial-gradient(circle at 75% 25%, rgba(133, 95, 255, 0.12), transparent 18%),
40
+ radial-gradient(circle at bottom right, rgba(22, 163, 74, 0.08), transparent 22%),
41
+ var(--bg);
42
  color: var(--text);
43
  font-family: 'Hind Siliguri', 'Syne', sans-serif;
44
  overflow: hidden;
45
  }
46
 
47
+ body {
48
+ -webkit-font-smoothing: antialiased;
49
+ text-rendering: optimizeLegibility;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  }
51
 
52
+ button,
53
+ input,
54
+ select {
55
+ font: inherit;
 
 
 
 
 
 
 
 
 
 
 
56
  }
57
 
58
+ button {
59
+ appearance: none;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  }
61
 
62
+ /* ── Layout ───────────────────────────────────────────────────────────────── */
 
 
 
 
63
  .app {
64
  position: fixed;
65
  inset: 0;
 
66
  display: flex;
 
 
 
 
 
67
  opacity: 1;
68
  pointer-events: auto;
69
  }
70
 
 
71
  .sidebar {
72
  width: var(--sidebar-w);
73
+ flex-shrink: 0;
 
74
  display: flex;
75
  flex-direction: column;
76
+ background: rgba(8, 12, 20, 0.82);
77
+ backdrop-filter: blur(20px);
78
+ border-right: 1px solid var(--border);
79
+ box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.03);
80
  overflow-y: auto;
 
 
81
  }
82
+
83
  .sidebar.collapsed {
84
  width: 0;
85
  overflow: hidden;
86
  }
87
+
88
  .sidebar-header {
89
  display: flex;
90
  align-items: center;
91
  justify-content: space-between;
92
+ padding: 18px 16px 14px;
 
93
  }
94
+
95
  .brand {
96
  display: flex;
97
  align-items: center;
 
99
  font-family: 'Syne', sans-serif;
100
  font-weight: 700;
101
  font-size: 14px;
102
+ letter-spacing: 0.02em;
103
  }
104
+
105
+ .sidebar-toggle,
106
+ .brain-btn,
107
+ .clear-btn,
108
+ .mobile-menu-btn,
109
+ .stop-btn,
110
+ #send-btn,
111
+ .mic-btn,
112
+ .setting-select,
113
+ #text-input {
114
  border: 1px solid var(--border);
115
+ }
116
+
117
+ .sidebar-toggle {
118
+ width: 34px;
119
+ height: 34px;
120
+ border-radius: 999px;
121
+ background: rgba(255, 255, 255, 0.04);
122
+ color: var(--text-2);
123
+ display: grid;
124
+ place-items: center;
125
  cursor: pointer;
126
+ transition: transform 0.15s ease, border-color 0.15s ease;
 
127
  }
128
+
129
  .sidebar-toggle:hover {
130
+ border-color: var(--border-strong);
131
+ transform: translateY(-1px);
132
  }
133
 
134
+ .sidebar-divider {
135
+ height: 1px;
136
+ margin: 4px 16px;
137
+ background: var(--border);
 
 
 
 
 
 
 
 
 
 
 
138
  }
 
 
 
139
 
140
+ .dash-section {
141
+ padding: 16px;
142
+ }
143
 
 
144
  .dash-title {
145
  font-size: 11px;
146
+ letter-spacing: 0.12em;
147
  text-transform: uppercase;
148
+ color: var(--text-3);
 
149
  margin-bottom: 12px;
150
  }
151
+
152
  .metric-grid {
153
  display: grid;
154
+ grid-template-columns: repeat(2, minmax(0, 1fr));
155
+ gap: 10px;
156
  }
157
+
158
  .metric-card {
159
+ background: rgba(255, 255, 255, 0.04);
160
  border: 1px solid var(--border);
161
+ border-radius: 16px;
162
+ padding: 12px 10px;
163
+ box-shadow: var(--shadow-soft);
164
  text-align: center;
165
  }
166
+
167
  .metric-val {
168
  font-family: 'JetBrains Mono', monospace;
169
  font-size: 18px;
 
170
  color: var(--accent);
171
  line-height: 1;
172
  margin-bottom: 4px;
173
  }
 
174
 
175
+ .metric-label,
176
+ .queue-label,
177
+ .status-label {
178
+ color: var(--text-3);
179
+ font-size: 11px;
180
+ }
181
+
182
+ .setting-row {
183
+ margin-bottom: 14px;
184
+ }
185
+
186
  .setting-row label {
187
  display: block;
188
  font-size: 11px;
189
+ color: var(--text-2);
190
+ margin-bottom: 8px;
191
  }
192
+
193
+ .slider-wrap {
194
+ display: flex;
195
+ align-items: center;
196
+ gap: 10px;
197
+ }
198
+
199
  .slider-wrap input[type='range'] {
200
  flex: 1;
201
+ accent-color: var(--accent-2);
 
 
202
  }
203
+
204
  .slider-wrap span {
205
+ min-width: 64px;
206
+ text-align: right;
207
  font-size: 11px;
208
+ color: var(--text-2);
209
  font-family: 'JetBrains Mono', monospace;
 
 
 
210
  }
211
+
212
  .setting-select {
213
  width: 100%;
214
+ padding: 11px 12px;
215
+ border-radius: 14px;
216
+ background: rgba(255, 255, 255, 0.04);
217
  color: var(--text);
 
 
 
 
 
218
  }
219
+
220
+ .setting-select:focus,
221
+ #text-input:focus {
222
+ outline: none;
223
+ border-color: rgba(37, 99, 235, 0.4);
224
+ box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.08);
225
+ }
226
 
227
  .queue-vis {
228
  display: flex;
 
231
  height: 48px;
232
  margin-bottom: 8px;
233
  }
234
+
235
  .queue-bar {
236
  flex: 1;
 
 
 
 
237
  min-height: 4px;
238
+ border-radius: 999px;
239
+ background: linear-gradient(180deg, rgba(37, 99, 235, 0.85), rgba(15, 23, 42, 0.55));
240
+ opacity: 0.2;
241
+ transition: height 0.14s ease, opacity 0.14s ease;
242
  }
243
+
244
+ .queue-bar.active {
245
+ opacity: 0.95;
 
 
246
  }
247
 
248
+ /* ── Main column ──────────────────────────────────────────────────────────── */
249
  .main {
250
  flex: 1;
251
+ min-width: 0;
252
  display: flex;
253
  flex-direction: column;
254
  overflow: hidden;
 
255
  }
256
 
 
257
  .topbar {
258
  display: flex;
259
  align-items: center;
260
  justify-content: space-between;
261
+ padding: 14px 18px;
 
262
  border-bottom: 1px solid var(--border);
263
+ background: rgba(8, 12, 20, 0.72);
264
+ backdrop-filter: blur(18px);
265
  flex-shrink: 0;
266
  }
267
+
268
+ .topbar-left,
269
+ .topbar-right {
270
+ display: flex;
271
+ align-items: center;
272
+ gap: 10px;
273
+ }
274
+
275
  .topbar-center {
 
 
 
 
276
  position: absolute;
277
  left: 50%;
278
  transform: translateX(-50%);
279
+ font-family: 'Syne', sans-serif;
280
+ font-weight: 700;
281
+ font-size: 14px;
282
+ color: var(--text);
283
+ letter-spacing: 0.01em;
284
  }
285
+
286
+ .topbar-title {
287
+ opacity: 0.92;
288
+ }
289
+
290
+ .topbar-state {
291
+ display: flex;
292
+ align-items: center;
293
+ gap: 8px;
294
+ padding: 8px 12px;
295
  border: 1px solid var(--border);
296
+ border-radius: 999px;
297
+ background: rgba(255, 255, 255, 0.04);
298
+ box-shadow: var(--shadow-soft);
 
 
299
  }
300
+
301
  .state-dot {
302
+ width: 8px;
303
+ height: 8px;
304
+ border-radius: 999px;
305
+ background: var(--good);
306
+ box-shadow: 0 0 0 4px rgba(22, 163, 74, 0.12);
 
307
  }
 
 
 
 
 
308
 
309
+ .state-dot.listening {
310
+ background: var(--accent-2);
311
+ box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.12);
312
+ }
313
+
314
+ .state-dot.recording {
315
+ background: var(--danger);
316
+ box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.12);
317
+ }
318
+
319
+ .state-dot.processing {
320
+ background: var(--warn);
321
+ box-shadow: 0 0 0 4px rgba(217, 119, 6, 0.12);
322
+ }
323
+
324
+ .state-dot.speaking {
325
+ background: var(--accent);
326
+ box-shadow: 0 0 0 4px rgba(15, 23, 42, 0.08);
327
+ }
328
+
329
+ #state-label {
330
+ font-size: 12px;
331
+ color: var(--text-2);
332
+ }
333
+
334
+ .mobile-menu-btn,
335
+ .clear-btn,
336
+ .brain-btn {
337
+ height: 38px;
338
+ border-radius: 999px;
339
+ background: rgba(255, 255, 255, 0.04);
340
+ color: var(--text);
341
  cursor: pointer;
342
+ padding: 0 14px;
343
+ display: inline-flex;
344
+ align-items: center;
345
+ justify-content: center;
346
+ gap: 8px;
347
+ transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease;
348
+ }
349
+
350
+ .brain-btn {
351
+ width: 40px;
352
+ padding: 0;
353
+ display: grid;
354
+ place-items: center;
355
+ }
356
+
357
+ .brain-btn.active {
358
+ background: var(--accent);
359
+ color: #fff;
360
+ border-color: var(--accent);
361
+ }
362
+
363
+ .brain-btn svg {
364
+ width: 20px;
365
+ height: 20px;
366
+ fill: currentColor;
367
+ }
368
+
369
+ .mobile-menu-btn:hover,
370
+ .clear-btn:hover,
371
+ .brain-btn:hover,
372
+ .sidebar-toggle:hover,
373
+ .stop-btn:hover,
374
+ #send-btn:hover,
375
+ .mic-btn:hover {
376
+ transform: translateY(-1px);
377
+ }
378
+
379
+ .clear-btn {
380
  font-size: 12px;
381
+ color: var(--text-2);
382
+ }
383
+
384
+ .voice-caption {
385
+ width: min(var(--max-chat), calc(100% - 32px));
386
+ margin: 14px auto 0;
387
+ min-height: 24px;
388
+ color: var(--text-2);
389
+ font-size: 12px;
390
+ line-height: 1.5;
391
+ letter-spacing: 0.01em;
392
+ padding: 0 4px;
393
+ transition: opacity 0.2s ease;
394
  }
 
395
 
 
396
  #chat-box {
397
+ width: min(var(--max-chat), calc(100% - 32px));
398
+ margin: 10px auto 0;
399
+ padding: 16px 0 22px;
400
  flex: 1;
401
  overflow-y: auto;
 
402
  display: flex;
403
  flex-direction: column;
404
  gap: 12px;
405
  scroll-behavior: smooth;
406
  }
407
+
408
+ #chat-box::-webkit-scrollbar,
409
+ .sidebar::-webkit-scrollbar {
410
+ width: 8px;
411
+ }
412
+
413
+ #chat-box::-webkit-scrollbar-track,
414
+ .sidebar::-webkit-scrollbar-track {
415
+ background: transparent;
416
+ }
417
+
418
+ #chat-box::-webkit-scrollbar-thumb,
419
+ .sidebar::-webkit-scrollbar-thumb {
420
+ background: rgba(15, 23, 42, 0.15);
421
+ border-radius: 999px;
422
+ }
423
 
424
  .message {
425
+ max-width: min(100%, 760px);
426
+ padding: 14px 16px;
427
+ border-radius: 18px;
428
  line-height: 1.65;
429
+ font-size: 15px;
430
+ border: 1px solid var(--border);
431
+ background: var(--panel);
432
+ box-shadow: var(--shadow-soft);
433
+ animation: msg-in 0.22s ease-out;
434
+ backdrop-filter: blur(16px);
435
  }
436
+
437
  @keyframes msg-in {
438
+ from {
439
+ opacity: 0;
440
+ transform: translateY(8px);
441
+ }
442
+ to {
443
+ opacity: 1;
444
+ transform: translateY(0);
445
+ }
446
  }
447
+
448
  .message.user {
449
+ align-self: flex-end;
450
+ background: rgba(100, 181, 255, 0.08);
451
+ border-bottom-right-radius: 6px;
 
452
  }
453
+
454
  .message.ai {
455
+ align-self: flex-start;
456
+ background: rgba(255, 255, 255, 0.05);
457
+ border-bottom-left-radius: 6px;
458
  }
459
+
460
  .message.system {
 
 
 
 
 
461
  align-self: center;
462
+ max-width: 92%;
463
+ font-size: 12px;
464
+ color: var(--text-2);
465
+ background: rgba(255, 255, 255, 0.04);
466
+ }
467
+
468
+ .message p,
469
+ .message ul,
470
+ .message ol {
471
+ margin: 0.5em 0;
472
  }
473
+
 
 
474
  .message code {
 
 
475
  padding: 1px 6px;
476
+ border-radius: 6px;
477
+ background: rgba(15, 23, 42, 0.06);
478
  font-family: 'JetBrains Mono', monospace;
479
  font-size: 13px;
480
  }
481
+
482
  .message pre {
 
 
 
483
  overflow-x: auto;
484
+ padding: 12px;
485
+ border-radius: 14px;
486
+ background: rgba(15, 23, 42, 0.05);
487
  }
488
 
489
+ .message.thinking {
490
+ display: inline-flex;
491
+ align-items: center;
492
+ gap: 6px;
493
+ width: max-content;
494
+ padding: 12px 14px;
495
+ }
496
+
497
+ .message.thinking .dot {
498
+ width: 7px;
499
+ height: 7px;
500
+ border-radius: 999px;
501
+ background: var(--accent-2);
502
+ opacity: 0.35;
503
+ animation: bob 1.1s infinite ease-in-out;
504
+ }
505
+
506
+ .message.thinking .dot:nth-child(2) {
507
+ animation-delay: 0.16s;
508
+ }
509
+
510
+ .message.thinking .dot:nth-child(3) {
511
+ animation-delay: 0.32s;
512
+ }
513
+
514
+ @keyframes bob {
515
+ 0%,
516
+ 80%,
517
+ 100% {
518
+ transform: translateY(0);
519
+ opacity: 0.35;
520
+ }
521
+ 40% {
522
+ transform: translateY(-5px);
523
+ opacity: 1;
524
+ }
525
+ }
526
+
527
+ /* ── Voice visualizer ─────────────────────────────────────────────────────── */
528
  .voice-visualizer {
529
+ width: min(var(--max-chat), calc(100% - 32px));
530
+ margin: 0 auto;
531
+ height: 0;
532
+ overflow: hidden;
533
  display: flex;
534
  align-items: center;
535
  justify-content: center;
536
  gap: 4px;
537
+ transition: height 0.22s ease;
538
+ }
539
+
540
+ .voice-visualizer.active {
541
+ height: 52px;
542
  }
543
+
544
  .viz-bar {
545
  width: 4px;
 
 
546
  height: 6px;
547
+ border-radius: 999px;
548
+ background: linear-gradient(180deg, rgba(37, 99, 235, 0.95), rgba(15, 23, 42, 0.55));
549
  transition: height 0.08s ease;
 
550
  }
551
 
552
+ /* ── Controls ─────────────────────────────────────────────────────────────── */
553
  .controls {
554
+ width: min(var(--max-chat), calc(100% - 32px));
555
+ margin: 12px auto 18px;
556
+ padding: 14px;
557
+ border: 1px solid var(--border);
558
+ border-radius: 24px;
559
+ background: rgba(8, 12, 20, 0.78);
560
+ backdrop-filter: blur(18px);
561
+ box-shadow: var(--shadow);
562
  flex-shrink: 0;
563
  }
564
+
565
  .text-row {
566
  display: flex;
567
+ align-items: flex-end;
568
  gap: 10px;
569
+ margin-bottom: 10px;
 
570
  }
571
 
 
572
  #text-input {
573
  flex: 1;
574
+ min-height: 46px;
575
+ max-height: 220px;
576
+ resize: none;
577
+ overflow-y: auto;
578
+ padding: 12px 14px;
579
+ border-radius: 16px;
580
+ background: rgba(255, 255, 255, 0.04);
581
  color: var(--text);
582
+ line-height: 1.55;
583
+ }
584
+
585
+ #text-input::placeholder {
586
+ color: var(--text-3);
 
 
 
 
 
 
 
587
  }
 
 
 
 
 
 
588
 
589
  #send-btn {
590
+ width: 48px;
591
+ height: 46px;
592
+ border-radius: 16px;
593
+ background: linear-gradient(135deg, #64b5ff, #8b5cf6);
594
+ color: #05070d;
595
  cursor: pointer;
596
+ display: inline-flex;
 
597
  align-items: center;
598
+ justify-content: center;
599
+ }
600
+
601
+ .voice-row {
602
+ display: flex;
603
+ gap: 10px;
604
  }
 
 
605
 
 
606
  .mic-btn {
607
  flex: 1;
608
+ min-height: 48px;
609
+ border-radius: 16px;
610
+ background: rgba(255, 255, 255, 0.04);
611
+ color: var(--text);
612
  display: flex;
613
  align-items: center;
614
  justify-content: center;
615
+ gap: 10px;
616
+ cursor: pointer;
617
+ padding: 0 16px;
618
+ }
619
+
620
+ .mic-btn.mic-listening {
621
+ border-color: rgba(37, 99, 235, 0.35);
622
+ box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.08);
623
+ }
624
+
625
+ .mic-btn.mic-recording {
626
+ border-color: rgba(239, 68, 68, 0.35);
627
+ box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.08);
628
+ }
629
+
630
+ .mic-btn.mic-processing {
631
+ border-color: rgba(217, 119, 6, 0.35);
632
+ box-shadow: 0 0 0 4px rgba(217, 119, 6, 0.08);
633
+ }
634
+
635
+ .mic-icon {
636
+ font-size: 18px;
637
+ }
638
+
639
+ .stop-btn {
640
+ min-width: 90px;
641
+ height: 48px;
642
+ border-radius: 16px;
643
+ background: rgba(255, 255, 255, 0.04);
644
+ color: #ff8da1;
645
+ display: inline-flex;
646
+ align-items: center;
647
+ justify-content: center;
648
  gap: 8px;
 
 
 
 
 
649
  cursor: pointer;
650
+ padding: 0 14px;
651
+ }
652
+
653
+ /* ── Brain mode ───────────────────────────────────────────────────────────── */
654
+ .brain-stage {
655
+ display: none;
656
+ width: min(var(--max-chat), calc(100% - 32px));
657
+ margin: 16px auto 0;
658
+ flex: 1;
659
+ align-items: center;
660
+ justify-content: center;
661
+ position: relative;
662
+ }
663
+
664
+ .brain-shell {
665
  position: relative;
666
+ width: min(760px, 100%);
667
+ height: min(62vh, 620px);
668
+ border-radius: 32px;
669
+ background:
670
+ radial-gradient(circle at 50% 45%, rgba(100, 181, 255, 0.18), transparent 18%),
671
+ radial-gradient(circle at 50% 55%, rgba(139, 92, 246, 0.12), transparent 38%),
672
+ radial-gradient(circle at 50% 50%, rgba(15, 23, 42, 0.92), rgba(5, 7, 13, 0.98) 72%);
673
+ border: 1px solid rgba(255, 255, 255, 0.08);
674
+ box-shadow: var(--shadow);
675
  overflow: hidden;
676
  }
677
+
678
+ .brain-bubbles {
679
+ position: absolute;
680
+ inset: 0;
681
+ z-index: 3;
682
+ pointer-events: none;
683
+ }
684
+
685
+ .brain-bubble {
686
+ position: absolute;
687
+ width: min(280px, 42vw);
688
+ max-width: 320px;
689
+ min-height: 96px;
690
+ padding: 14px 16px;
691
+ border-radius: 22px;
692
+ border: 1px solid rgba(255, 255, 255, 0.08);
693
+ background: rgba(8, 12, 20, 0.82);
694
+ backdrop-filter: blur(18px);
695
+ box-shadow: 0 18px 50px rgba(0, 0, 0, 0.35);
696
+ transform: translateY(8px) scale(0.96);
697
+ opacity: 0.72;
698
+ transition: transform 0.25s ease, opacity 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
699
+ }
700
+
701
+ .brain-bubble::after {
702
  content: '';
703
+ position: absolute;
704
+ inset: auto 18px -8px auto;
705
+ width: 16px;
706
+ height: 16px;
707
+ background: inherit;
708
+ border-right: 1px solid rgba(255, 255, 255, 0.08);
709
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
710
+ transform: rotate(45deg);
711
+ }
712
+
713
+ .brain-bubble-label {
714
+ font-size: 10px;
715
+ letter-spacing: 0.14em;
716
+ text-transform: uppercase;
717
+ color: var(--text-3);
718
+ margin-bottom: 8px;
719
+ }
720
+
721
+ .brain-bubble-text {
722
+ font-size: 15px;
723
+ line-height: 1.55;
724
+ color: var(--text);
725
+ min-height: 40px;
726
+ white-space: pre-wrap;
727
+ word-break: break-word;
728
+ }
729
+
730
+ .brain-bubble-stt {
731
+ left: 24px;
732
+ top: 24px;
733
+ }
734
+
735
+ .brain-bubble-tts {
736
+ right: 24px;
737
+ bottom: 24px;
738
+ }
739
+
740
+ .brain-bubble.active {
741
+ opacity: 1;
742
+ transform: translateY(0) scale(1);
743
+ border-color: rgba(100, 181, 255, 0.28);
744
+ box-shadow:
745
+ 0 22px 62px rgba(0, 0, 0, 0.48),
746
+ 0 0 0 1px rgba(100, 181, 255, 0.08);
747
+ }
748
+
749
+ .brain-bubble.active .brain-bubble-label {
750
+ color: rgba(100, 181, 255, 0.82);
751
+ }
752
+
753
+ .brain-bubble.speaking {
754
+ animation: bubble-breathe 1.4s ease-in-out infinite;
755
+ }
756
+
757
+ .brain-bubble.speaking .brain-bubble-text {
758
+ position: relative;
759
+ }
760
+
761
+ .brain-bubble.speaking .brain-bubble-text::after {
762
+ content: '';
763
+ display: inline-block;
764
+ width: 10px;
765
+ height: 10px;
766
+ margin-left: 8px;
767
+ border-radius: 999px;
768
+ background: linear-gradient(135deg, #64b5ff, #8b5cf6);
769
+ box-shadow: 0 0 0 0 rgba(100, 181, 255, 0.26);
770
+ animation: bubble-dot 1s ease-in-out infinite;
771
+ }
772
+
773
+ .brain-scan {
774
  position: absolute;
775
  inset: 0;
776
+ background:
777
+ linear-gradient(180deg, transparent 0%, rgba(100, 181, 255, 0.12) 50%, transparent 100%),
778
+ linear-gradient(90deg, transparent 0%, rgba(100, 181, 255, 0.22) 48%, rgba(139, 92, 246, 0.22) 52%, transparent 100%);
779
+ background-size: 100% 180px, 280px 100%;
780
+ background-repeat: repeat-y, no-repeat;
781
+ mix-blend-mode: screen;
782
+ opacity: 0.55;
783
+ animation: scan 6s linear infinite;
784
+ pointer-events: none;
785
  }
786
+
787
+ .brain-shell::before,
788
+ .brain-shell::after {
789
+ content: '';
790
+ position: absolute;
791
+ inset: -20%;
792
+ background:
793
+ radial-gradient(circle at 30% 30%, rgba(100, 181, 255, 0.18), transparent 14%),
794
+ radial-gradient(circle at 70% 40%, rgba(139, 92, 246, 0.12), transparent 16%),
795
+ radial-gradient(circle at 55% 68%, rgba(22, 163, 74, 0.11), transparent 15%);
796
+ animation: drift 16s linear infinite;
797
+ pointer-events: none;
798
  }
799
+
800
+ .brain-shell::after {
801
+ animation-direction: reverse;
802
+ opacity: 0.7;
803
  }
804
+
805
+ .brain-pulse {
806
+ position: absolute;
807
+ inset: 50% auto auto 50%;
808
+ width: 16px;
809
+ height: 16px;
810
+ border-radius: 999px;
811
+ border: 1px solid rgba(100, 181, 255, 0.55);
812
+ box-shadow: 0 0 0 0 rgba(100, 181, 255, 0.22);
813
+ transform: translate(-50%, -50%);
814
+ animation: pulse-ring 3.6s linear infinite;
815
+ pointer-events: none;
816
  }
817
+
818
+ .pulse-a {
819
+ width: 110px;
820
+ height: 110px;
821
  }
 
 
822
 
823
+ .pulse-b {
824
+ width: 220px;
825
+ height: 220px;
826
+ animation-delay: 0.8s;
827
+ }
828
+
829
+ .pulse-c {
830
+ width: 340px;
831
+ height: 340px;
832
+ animation-delay: 1.6s;
833
+ }
834
+
835
+ .brain-svg {
836
+ position: absolute;
837
+ inset: 0;
838
+ width: 100%;
839
+ height: 100%;
840
+ filter: drop-shadow(0 0 16px rgba(37, 99, 235, 0.08));
841
+ }
842
+
843
+ .brain-outline {
844
+ fill: rgba(255, 255, 255, 0.04);
845
+ stroke: rgba(255, 255, 255, 0.12);
846
+ stroke-width: 2;
847
+ stroke-dasharray: 10 12;
848
+ animation: pulse-line 8s linear infinite;
849
+ }
850
+
851
+ .brain-wire {
852
+ fill: none;
853
+ stroke: url(#brainGlow);
854
+ stroke-width: 2.5;
855
+ stroke-linecap: round;
856
+ stroke-dasharray: 10 10;
857
+ animation: dash 7s linear infinite;
858
+ opacity: 0.82;
859
+ }
860
+
861
+ .brain-node {
862
+ fill: rgba(255, 255, 255, 0.9);
863
+ stroke: rgba(100, 181, 255, 0.35);
864
+ stroke-width: 2;
865
+ filter: drop-shadow(0 0 12px rgba(100, 181, 255, 0.38));
866
+ animation: node-pulse 2.4s ease-in-out infinite;
867
+ transform-box: fill-box;
868
+ transform-origin: center;
869
+ }
870
+
871
+ .node-2,
872
+ .node-5,
873
+ .node-8,
874
+ .node-11 {
875
+ animation-delay: 0.2s;
876
+ }
877
+
878
+ .node-3,
879
+ .node-6,
880
+ .node-9,
881
+ .node-12 {
882
+ animation-delay: 0.4s;
883
+ }
884
+
885
+ .brain-core {
886
+ fill: rgba(100, 181, 255, 0.18);
887
+ stroke: rgba(100, 181, 255, 0.65);
888
+ stroke-width: 2;
889
+ animation: core-breath 2.8s ease-in-out infinite;
890
+ transform-box: fill-box;
891
+ transform-origin: center;
892
+ }
893
+
894
+ .brain-core.ring {
895
+ fill: none;
896
+ stroke: rgba(255, 255, 255, 0.12);
897
+ stroke-dasharray: 8 12;
898
+ animation: dash 9s linear infinite;
899
+ }
900
+
901
+ @keyframes dash {
902
+ to {
903
+ stroke-dashoffset: -120;
904
+ }
905
+ }
906
+
907
+ @keyframes pulse-line {
908
+ 0%,
909
+ 100% {
910
+ opacity: 0.65;
911
+ }
912
+ 50% {
913
+ opacity: 1;
914
+ }
915
+ }
916
+
917
+ @keyframes node-pulse {
918
+ 0%,
919
+ 100% {
920
+ transform: scale(1);
921
+ opacity: 0.84;
922
+ }
923
+ 50% {
924
+ transform: scale(1.18);
925
+ opacity: 1;
926
+ }
927
+ }
928
+
929
+ @keyframes core-breath {
930
+ 0%,
931
+ 100% {
932
+ transform: scale(1);
933
+ opacity: 0.7;
934
+ }
935
+ 50% {
936
+ transform: scale(1.08);
937
+ opacity: 1;
938
+ }
939
+ }
940
+
941
+ @keyframes drift {
942
+ 0% {
943
+ transform: translate3d(0, 0, 0) scale(1);
944
+ }
945
+ 50% {
946
+ transform: translate3d(3%, -2%, 0) scale(1.02);
947
+ }
948
+ 100% {
949
+ transform: translate3d(0, 0, 0) scale(1);
950
+ }
951
+ }
952
+
953
+ @keyframes scan {
954
+ 0% {
955
+ transform: translateY(-20%);
956
+ }
957
+ 100% {
958
+ transform: translateY(20%);
959
+ }
960
+ }
961
+
962
+ @keyframes pulse-ring {
963
+ 0% {
964
+ opacity: 0.15;
965
+ transform: translate(-50%, -50%) scale(0.75);
966
+ }
967
+ 50% {
968
+ opacity: 0.75;
969
+ transform: translate(-50%, -50%) scale(1);
970
+ }
971
+ 100% {
972
+ opacity: 0.12;
973
+ transform: translate(-50%, -50%) scale(1.28);
974
+ }
975
+ }
976
+
977
+ @keyframes bubble-breathe {
978
+ 0%,
979
+ 100% {
980
+ transform: translateY(0) scale(1);
981
+ }
982
+ 50% {
983
+ transform: translateY(-2px) scale(1.015);
984
+ }
985
+ }
986
+
987
+ @keyframes bubble-dot {
988
+ 0%,
989
+ 100% {
990
+ transform: scale(0.75);
991
+ opacity: 0.35;
992
+ }
993
+ 50% {
994
+ transform: scale(1);
995
+ opacity: 1;
996
+ }
997
+ }
998
+
999
+ body.brain-mode .sidebar,
1000
+ body.brain-mode #chat-box,
1001
+ body.brain-mode .voice-caption,
1002
+ body.brain-mode .voice-visualizer {
1003
+ display: none;
1004
+ }
1005
+
1006
+ body.brain-mode .brain-stage {
1007
  display: flex;
 
 
 
1008
  }
 
 
1009
 
1010
+ body.brain-mode .controls {
1011
+ display: block;
1012
+ position: fixed;
1013
+ left: 50%;
1014
+ bottom: 18px;
1015
+ transform: translateX(-50%);
1016
+ width: min(420px, calc(100% - 24px));
1017
+ margin: 0;
1018
+ padding: 12px;
1019
+ background: rgba(5, 7, 13, 0.78);
1020
+ border-color: rgba(255, 255, 255, 0.08);
1021
+ }
1022
+
1023
+ body.brain-mode .text-row {
1024
+ display: none;
1025
+ }
1026
+
1027
+ body.brain-mode .voice-row {
1028
+ display: flex;
1029
+ }
1030
+
1031
+ body.brain-mode .mic-btn,
1032
+ body.brain-mode .stop-btn {
1033
+ background: rgba(255, 255, 255, 0.05);
1034
+ }
1035
+
1036
+ body.brain-mode .topbar {
1037
+ background: rgba(5, 7, 13, 0.45);
1038
+ border-bottom-color: transparent;
1039
+ }
1040
+
1041
+ body.brain-mode .topbar-center,
1042
+ body.brain-mode #state-label {
1043
+ opacity: 0;
1044
+ pointer-events: none;
1045
+ }
1046
 
1047
+ body.brain-mode .clear-btn {
1048
+ width: 40px;
1049
+ padding: 0;
1050
+ font-size: 0;
1051
+ color: transparent;
1052
+ background: rgba(255, 255, 255, 0.05);
1053
+ }
1054
+
1055
+ body.brain-mode .clear-btn::before {
1056
+ content: '↺';
1057
+ font-size: 13px;
1058
+ color: var(--text);
1059
+ }
1060
+
1061
+ body.brain-mode .brain-shell {
1062
+ box-shadow:
1063
+ 0 30px 80px rgba(0, 0, 0, 0.5),
1064
+ inset 0 0 0 1px rgba(255, 255, 255, 0.03);
1065
+ }
1066
+
1067
+ body.brain-mode .brain-pulse {
1068
+ border-color: rgba(100, 181, 255, 0.35);
1069
+ }
1070
+
1071
+ body.brain-mode .brain-stage.searching .brain-wire {
1072
+ stroke-width: 3.2;
1073
+ opacity: 1;
1074
+ animation-duration: 2.2s;
1075
+ }
1076
+
1077
+ body.brain-mode .brain-stage.searching .brain-core {
1078
+ animation-duration: 1.8s;
1079
+ filter: drop-shadow(0 0 18px rgba(100, 181, 255, 0.45));
1080
+ }
1081
+
1082
+ body.brain-mode .brain-stage.searching .brain-node {
1083
+ animation-duration: 1.2s;
1084
+ }
1085
+
1086
+ body.brain-mode .brain-stage[data-state='listening'] .brain-shell {
1087
+ box-shadow:
1088
+ 0 30px 80px rgba(0, 0, 0, 0.46),
1089
+ 0 0 0 1px rgba(100, 181, 255, 0.08),
1090
+ 0 0 34px rgba(100, 181, 255, 0.12);
1091
+ }
1092
+
1093
+ body.brain-mode .brain-stage[data-state='processing'] .brain-shell::before {
1094
+ animation-duration: 8s;
1095
+ filter: blur(0.3px);
1096
+ }
1097
+
1098
+ body.brain-mode .brain-stage[data-state='speaking'] .brain-core {
1099
+ fill: rgba(100, 181, 255, 0.26);
1100
+ animation-duration: 1.4s;
1101
+ }
1102
+
1103
+ body.brain-mode .brain-stage[data-state='speaking'] .brain-node {
1104
+ filter: drop-shadow(0 0 14px rgba(100, 181, 255, 0.52));
1105
+ }
1106
+
1107
+ /* ── Responsive ───────────────────────────────────────────────────────────── */
1108
+ @media (max-width: 960px) {
1109
  .sidebar {
1110
  position: fixed;
1111
+ inset: 0 auto 0 0;
1112
  transform: translateX(-100%);
1113
+ z-index: 30;
1114
+ box-shadow: var(--shadow);
1115
+ transition: transform 0.2s ease;
1116
  }
 
 
 
 
 
1117
 
1118
+ .sidebar.mobile-open {
1119
+ transform: translateX(0);
1120
+ }
1121
+
1122
+ .mobile-menu-btn {
1123
+ display: inline-flex;
1124
+ }
1125
+
1126
+ .topbar-center {
1127
+ font-size: 13px;
1128
+ }
1129
+
1130
+ .brain-shell {
1131
+ height: min(56vh, 520px);
1132
+ }
1133
  }
1134
+
1135
+ @media (max-width: 720px) {
1136
+ .topbar {
1137
+ padding: 12px 12px;
1138
+ }
1139
+
1140
+ .topbar-state {
1141
+ padding: 7px 10px;
1142
+ }
1143
+
1144
+ .clear-btn {
1145
+ padding: 0 12px;
1146
+ }
1147
+
1148
+ .voice-caption,
1149
+ #chat-box,
1150
+ .controls,
1151
+ .voice-visualizer,
1152
+ .brain-stage {
1153
+ width: calc(100% - 20px);
1154
+ }
1155
+
1156
+ #chat-box {
1157
+ padding-top: 12px;
1158
+ }
1159
+
1160
+ .message {
1161
+ font-size: 14px;
1162
+ padding: 12px 14px;
1163
+ }
1164
+
1165
+ .controls {
1166
+ padding: 12px;
1167
+ border-radius: 20px;
1168
+ }
1169
+
1170
+ .text-row {
1171
+ margin-bottom: 8px;
1172
+ }
1173
+
1174
+ .voice-row {
1175
+ gap: 8px;
1176
+ }
1177
+
1178
+ .brain-shell {
1179
+ height: 50vh;
1180
+ border-radius: 24px;
1181
+ }
1182
  }
services/streaming.py CHANGED
@@ -13,7 +13,8 @@ FIX-ISSUE4 (Natural, slow, small-chunk TTS):
13
  • Hard limit of 40 chars ensures no chunk ever gets too large.
14
  • Sentence-ending punctuation (।.!?) always flushes immediately
15
  regardless of length, giving natural pause points.
16
- • The TTS rate is set to "-35%" in tts.py (slightly slower than before).
 
17
 
18
  Result: audio arrives in small, fast, overlapping synthesis tasks,
19
  giving a low-latency, smooth, natural speech feel.
 
13
  • Hard limit of 40 chars ensures no chunk ever gets too large.
14
  • Sentence-ending punctuation (।.!?) always flushes immediately
15
  regardless of length, giving natural pause points.
16
+ • The TTS rate is slightly faster than neutral in tts.py for a more
17
+ conversational pace.
18
 
19
  Result: audio arrives in small, fast, overlapping synthesis tasks,
20
  giving a low-latency, smooth, natural speech feel.
services/tts.py CHANGED
@@ -2,7 +2,8 @@
2
  services/tts.py — Ultra Low-Latency Dual TTS Backend
3
 
4
  FIX-ISSUE4 (Normal-speed TTS):
5
- • Default rate changed from "-30%" to "+0%" for normal speech speed.
 
6
  • split_sentences() now splits on ALL clause delimiters (commas, colons,
7
  em-dashes) in addition to sentence endings, so synthesis tasks are
8
  smaller and start sooner. This pairs with streaming.py's 2–3 word
@@ -20,6 +21,7 @@ EDGE_VOICE = "bn-BD-NabanitaNeural"
20
  ELEVENLABS_API_KEY = os.getenv("ELEVENLABS_API_KEY", "")
21
  ELEVENLABS_VOICE_ID = os.getenv("ELEVENLABS_VOICE_ID", "21m00Tcm4TlvDq8ikWAM")
22
  ELEVENLABS_MODEL_ID = os.getenv("ELEVENLABS_MODEL_ID", "eleven_multilingual_v2")
 
23
  ELEVENLABS_OUTPUT_FORMAT = "mp3_22050_32"
24
  ELEVENLABS_STABILITY = 0.45
25
  ELEVENLABS_SIMILARITY = 0.80
@@ -40,7 +42,10 @@ if USE_ELEVENLABS and not ELEVENLABS_API_KEY:
40
  if not EDGE_TTS_AVAILABLE and not ELEVENLABS_API_KEY:
41
  raise RuntimeError("[TTS] Neither edge_tts nor ELEVENLABS_API_KEY is available")
42
 
43
- print(f"[TTS] Backend: {'ElevenLabs' if USE_ELEVENLABS else 'Edge-TTS'} | rate: +0%")
 
 
 
44
 
45
 
46
  def split_sentences(text: str) -> list[str]:
@@ -61,10 +66,10 @@ def split_sentences(text: str) -> list[str]:
61
  return [p.strip() for p in parts if len(p.strip()) > 1]
62
 
63
 
64
- async def _edge_tts_stream(text: str, voice: str = EDGE_VOICE, rate: str = "+0%"):
65
  """
66
  Stream Edge-TTS audio for a single text chunk.
67
- Default rate is normal speed.
68
  """
69
  if edge_tts is None:
70
  raise RuntimeError("edge_tts is not installed")
@@ -86,6 +91,7 @@ async def _elevenlabs_stream(
86
  voice_id: str = ELEVENLABS_VOICE_ID,
87
  model_id: str = ELEVENLABS_MODEL_ID,
88
  output_format: str = ELEVENLABS_OUTPUT_FORMAT,
 
89
  stability: float = ELEVENLABS_STABILITY,
90
  similarity: float = ELEVENLABS_SIMILARITY,
91
  style: float = ELEVENLABS_STYLE,
@@ -109,6 +115,7 @@ async def _elevenlabs_stream(
109
  "similarity_boost": similarity,
110
  "style": style,
111
  "use_speaker_boost": speaker_boost,
 
112
  },
113
  }
114
  try:
@@ -132,7 +139,7 @@ async def _elevenlabs_stream(
132
  async def text_to_speech_stream(
133
  text: str,
134
  voice: str | None = None,
135
- rate: str = "+0%", # normal speed
136
  ):
137
  """
138
  Stream TTS audio for `text`.
 
2
  services/tts.py — Ultra Low-Latency Dual TTS Backend
3
 
4
  FIX-ISSUE4 (Normal-speed TTS):
5
+ • Default rate is now slightly faster than normal for a more natural
6
+ conversational pace.
7
  • split_sentences() now splits on ALL clause delimiters (commas, colons,
8
  em-dashes) in addition to sentence endings, so synthesis tasks are
9
  smaller and start sooner. This pairs with streaming.py's 2–3 word
 
21
  ELEVENLABS_API_KEY = os.getenv("ELEVENLABS_API_KEY", "")
22
  ELEVENLABS_VOICE_ID = os.getenv("ELEVENLABS_VOICE_ID", "21m00Tcm4TlvDq8ikWAM")
23
  ELEVENLABS_MODEL_ID = os.getenv("ELEVENLABS_MODEL_ID", "eleven_multilingual_v2")
24
+ ELEVENLABS_SPEED = float(os.getenv("ELEVENLABS_SPEED", "1.08"))
25
  ELEVENLABS_OUTPUT_FORMAT = "mp3_22050_32"
26
  ELEVENLABS_STABILITY = 0.45
27
  ELEVENLABS_SIMILARITY = 0.80
 
42
  if not EDGE_TTS_AVAILABLE and not ELEVENLABS_API_KEY:
43
  raise RuntimeError("[TTS] Neither edge_tts nor ELEVENLABS_API_KEY is available")
44
 
45
+ print(
46
+ f"[TTS] Backend: {'ElevenLabs' if USE_ELEVENLABS else 'Edge-TTS'} | "
47
+ f"edge rate: +8% | eleven speed: {ELEVENLABS_SPEED:.2f}"
48
+ )
49
 
50
 
51
  def split_sentences(text: str) -> list[str]:
 
66
  return [p.strip() for p in parts if len(p.strip()) > 1]
67
 
68
 
69
+ async def _edge_tts_stream(text: str, voice: str = EDGE_VOICE, rate: str = "+8%"):
70
  """
71
  Stream Edge-TTS audio for a single text chunk.
72
+ Default rate is slightly faster than normal.
73
  """
74
  if edge_tts is None:
75
  raise RuntimeError("edge_tts is not installed")
 
91
  voice_id: str = ELEVENLABS_VOICE_ID,
92
  model_id: str = ELEVENLABS_MODEL_ID,
93
  output_format: str = ELEVENLABS_OUTPUT_FORMAT,
94
+ speed: float = ELEVENLABS_SPEED,
95
  stability: float = ELEVENLABS_STABILITY,
96
  similarity: float = ELEVENLABS_SIMILARITY,
97
  style: float = ELEVENLABS_STYLE,
 
115
  "similarity_boost": similarity,
116
  "style": style,
117
  "use_speaker_boost": speaker_boost,
118
+ "speed": speed,
119
  },
120
  }
121
  try:
 
139
  async def text_to_speech_stream(
140
  text: str,
141
  voice: str | None = None,
142
+ rate: str = "+8%",
143
  ):
144
  """
145
  Stream TTS audio for `text`.