gnumanth commited on
Commit
9f694c9
·
1 Parent(s): 4c03e6e

feat: load lightweight Kokoro-82M on-device neural TTS model via WebGPU/WASM with voice profiles

Browse files
Files changed (1) hide show
  1. index.html +204 -105
index.html CHANGED
@@ -15,19 +15,10 @@
15
  --ink: #1e1e24;
16
 
17
  --comic-yellow: #fef08a;
18
- --comic-yellow-border: #eab308;
19
-
20
  --comic-cyan: #bae6fd;
21
- --comic-cyan-border: #0284c7;
22
-
23
  --comic-mint: #bbf7d0;
24
- --comic-mint-border: #16a34a;
25
-
26
  --comic-coral: #fed7aa;
27
- --comic-coral-border: #ea580c;
28
-
29
  --comic-pink: #fecdd3;
30
- --comic-pink-border: #e11d48;
31
 
32
  --card-paper: #ffffff;
33
  --shadow-hard: 5px 5px 0px var(--ink);
@@ -53,12 +44,12 @@
53
  header {
54
  text-align: center;
55
  max-width: 860px;
56
- margin-bottom: 24px;
57
  }
58
 
59
  .badge-row {
60
  display: flex;
61
- gap: 12px;
62
  justify-content: center;
63
  margin-bottom: 12px;
64
  flex-wrap: wrap;
@@ -70,17 +61,16 @@
70
  color: var(--ink);
71
  padding: 4px 14px;
72
  border-radius: 8px;
73
- font-size: 0.85rem;
74
  font-family: 'JetBrains Mono', monospace;
75
  font-weight: 700;
76
  text-decoration: none;
77
  box-shadow: var(--shadow-hard-sm);
78
- transition: transform 0.15s ease, box-shadow 0.15s ease;
79
  display: inline-block;
80
  }
81
  .badge:hover {
82
  transform: translate(-1px, -1px);
83
- box-shadow: 4px 4px 0px var(--ink);
84
  }
85
 
86
  .badge.cyan { background: var(--comic-cyan); }
@@ -98,7 +88,7 @@
98
  }
99
 
100
  p.sub {
101
- font-size: 1.15rem;
102
  font-weight: 700;
103
  color: #4b5563;
104
  }
@@ -109,7 +99,7 @@
109
  grid-template-columns: 1fr;
110
  gap: 24px;
111
  width: 100%;
112
- max-width: 1120px;
113
  }
114
  @media (min-width: 880px) {
115
  .main-grid { grid-template-columns: 1.15fr 1fr; }
@@ -127,33 +117,61 @@
127
  position: relative;
128
  }
129
 
130
- .panel-header-strip {
 
 
 
 
 
 
 
 
 
 
 
 
131
  display: flex;
132
  justify-content: space-between;
133
  align-items: center;
134
- border-bottom: 2px dashed var(--ink);
135
- padding-bottom: 12px;
136
- margin-bottom: 16px;
137
- font-family: 'JetBrains Mono', monospace;
138
- font-size: 0.85rem;
139
- font-weight: 700;
140
  flex-wrap: wrap;
141
  gap: 8px;
 
 
142
  }
143
-
144
- .panel-header-strip select {
145
  background: var(--comic-cyan);
146
  border: 2px solid var(--ink);
147
  color: var(--ink);
148
  font-family: 'Comic Neue', cursive;
149
  font-size: 0.95rem;
150
  font-weight: 700;
151
- padding: 4px 8px;
152
  border-radius: 8px;
153
  outline: none;
154
  cursor: pointer;
155
  box-shadow: 2px 2px 0px var(--ink);
156
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
 
158
  /* Comic Mic Orb */
159
  .orb-container {
@@ -161,7 +179,7 @@
161
  flex-direction: column;
162
  align-items: center;
163
  justify-content: center;
164
- padding: 12px 0;
165
  gap: 12px;
166
  }
167
 
@@ -186,13 +204,11 @@
186
  .voice-orb.listening {
187
  background: var(--comic-mint);
188
  transform: scale(1.1);
189
- box-shadow: 6px 6px 0px var(--ink);
190
  animation: comicPulse 1s infinite alternate ease-in-out;
191
  }
192
  .voice-orb.speaking {
193
  background: var(--comic-coral);
194
  transform: scale(1.08);
195
- box-shadow: 6px 6px 0px var(--ink);
196
  animation: comicTalk 0.8s infinite alternate ease-in-out;
197
  }
198
 
@@ -224,7 +240,7 @@
224
  display: flex;
225
  gap: 8px;
226
  flex-wrap: wrap;
227
- margin: 12px 0 14px;
228
  }
229
  .chip {
230
  background: #ffffff;
@@ -248,14 +264,14 @@
248
  /* Speech Bubble Chat */
249
  .chat-box {
250
  flex: 1;
251
- min-height: 240px;
252
- max-height: 320px;
253
  overflow-y: auto;
254
  display: flex;
255
  flex-direction: column;
256
  gap: 14px;
257
  padding: 10px 8px 10px 2px;
258
- margin: 8px 0 16px;
259
  }
260
  .chat-box::-webkit-scrollbar { width: 6px; }
261
  .chat-box::-webkit-scrollbar-thumb { background: var(--ink); border-radius: 4px; }
@@ -401,21 +417,39 @@
401
  <header>
402
  <div class="badge-row">
403
  <a class="badge" href="https://huggingface.co/pipecat-ai/phonellm-alpha-1" target="_blank">🎙️ PhoneLLM Alpha 1</a>
404
- <span class="badge mint">⚡ Pipecat Engine</span>
405
  <a class="badge cyan" href="https://xkcd.hemanth.deno.net/" target="_blank">🌐 xkcd.hemanth.deno.net</a>
406
  </div>
407
  <h1>XKCD Comic Voice Agent</h1>
408
- <p class="sub">Talk or type naturally. PhoneLLM finds the strip and reads the punchline!</p>
409
  </header>
410
 
411
  <main class="main-grid">
412
  <!-- Left Voice & Speech Bubble Panel -->
413
  <div class="panel">
414
- <div class="panel-header-strip">
415
- <span>🔊 <strong>ON-DEVICE VOICE:</strong></span>
416
- <select id="voice-select">
417
- <option value="default">Loading neural voices...</option>
418
- </select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
419
  </div>
420
 
421
  <div class="orb-container">
@@ -451,7 +485,9 @@
451
  </div>
452
  </main>
453
 
454
- <script>
 
 
455
  const XKCD_API_BASE = "https://xkcd.hemanth.deno.net";
456
  const orb = document.getElementById('voice-orb');
457
  const statusBadge = document.getElementById('status-badge');
@@ -459,12 +495,133 @@
459
  const userInput = document.getElementById('user-input');
460
  const sendBtn = document.getElementById('send-btn');
461
  const comicCard = document.getElementById('comic-card');
 
462
  const voiceSelect = document.getElementById('voice-select');
 
 
 
 
463
 
464
  let isListening = false;
465
  let recognition = null;
466
- let selectedVoice = null;
467
- let availableVoices = [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
468
 
469
  // Topic Knowledge Index for iconic XKCD comics
470
  const TOPIC_INDEX = {
@@ -503,65 +660,6 @@
503
  "kernel": 272
504
  };
505
 
506
- // Initialize High-Quality On-Device TTS Voices
507
- function loadVoices() {
508
- if (!('speechSynthesis' in window)) return;
509
- availableVoices = window.speechSynthesis.getVoices();
510
- if (!availableVoices.length) return;
511
-
512
- voiceSelect.innerHTML = '';
513
-
514
- const preferred = availableVoices.filter(v =>
515
- v.lang.startsWith('en') &&
516
- (v.name.includes('Natural') || v.name.includes('Neural') || v.name.includes('Samantha') || v.name.includes('Google') || v.name.includes('Siri') || v.name.includes('Daniel') || v.name.includes('Karen') || v.name.includes('Alex'))
517
- );
518
-
519
- const voicesToShow = preferred.length > 0 ? preferred : availableVoices.filter(v => v.lang.startsWith('en'));
520
-
521
- voicesToShow.forEach((v, idx) => {
522
- const opt = document.createElement('option');
523
- opt.value = idx;
524
- opt.textContent = `${v.name} (${v.lang})`;
525
- voiceSelect.appendChild(opt);
526
- });
527
-
528
- selectedVoice = voicesToShow[0] || availableVoices[0];
529
- voiceSelect.onchange = () => {
530
- selectedVoice = voicesToShow[voiceSelect.value] || availableVoices[0];
531
- };
532
- }
533
-
534
- if ('speechSynthesis' in window) {
535
- speechSynthesis.onvoiceschanged = loadVoices;
536
- loadVoices();
537
- }
538
-
539
- // High-Fidelity Speech Synthesis
540
- function speak(text) {
541
- if (!('speechSynthesis' in window)) return;
542
- window.speechSynthesis.cancel();
543
-
544
- orb.classList.remove('listening');
545
- orb.classList.add('speaking');
546
- statusBadge.textContent = "🔊 Speaking response...";
547
-
548
- const utterance = new SpeechSynthesisUtterance(text);
549
- if (selectedVoice) utterance.voice = selectedVoice;
550
- utterance.rate = 1.02;
551
- utterance.pitch = 1.0;
552
-
553
- utterance.onend = () => {
554
- orb.classList.remove('speaking');
555
- statusBadge.textContent = "Ready!";
556
- };
557
- utterance.onerror = () => {
558
- orb.classList.remove('speaking');
559
- statusBadge.textContent = "Ready!";
560
- };
561
-
562
- window.speechSynthesis.speak(utterance);
563
- }
564
-
565
  // Live XKCD API Tool Functions
566
  async function toolGetLatestXkcd() {
567
  const res = await fetch(`${XKCD_API_BASE}/`);
@@ -603,7 +701,7 @@
603
 
604
  const numMatch = lower.match(/\b(?:comic|number|#)?\s*(\d+)\b/);
605
 
606
- // Check for topic queries first (e.g. "comic on Python", "about git", "database comic")
607
  let matchedTopic = null;
608
  for (const key of Object.keys(TOPIC_INDEX)) {
609
  if (lower.includes(key)) {
@@ -725,7 +823,8 @@
725
  if (isListening) {
726
  recognition.stop();
727
  } else {
728
- window.speechSynthesis.cancel();
 
729
  orb.classList.remove('speaking');
730
  recognition.start();
731
  }
@@ -733,7 +832,7 @@
733
 
734
  // Initial greeting
735
  setTimeout(() => {
736
- addMessage("🗯️ Hey! I am your XKCD voice bot powered by PhoneLLM Alpha 1. Ask me for comics on Python, Git, SQL, or say 'Random'!", "agent");
737
  }, 400);
738
  </script>
739
  </body>
 
15
  --ink: #1e1e24;
16
 
17
  --comic-yellow: #fef08a;
 
 
18
  --comic-cyan: #bae6fd;
 
 
19
  --comic-mint: #bbf7d0;
 
 
20
  --comic-coral: #fed7aa;
 
 
21
  --comic-pink: #fecdd3;
 
22
 
23
  --card-paper: #ffffff;
24
  --shadow-hard: 5px 5px 0px var(--ink);
 
44
  header {
45
  text-align: center;
46
  max-width: 860px;
47
+ margin-bottom: 20px;
48
  }
49
 
50
  .badge-row {
51
  display: flex;
52
+ gap: 10px;
53
  justify-content: center;
54
  margin-bottom: 12px;
55
  flex-wrap: wrap;
 
61
  color: var(--ink);
62
  padding: 4px 14px;
63
  border-radius: 8px;
64
+ font-size: 0.82rem;
65
  font-family: 'JetBrains Mono', monospace;
66
  font-weight: 700;
67
  text-decoration: none;
68
  box-shadow: var(--shadow-hard-sm);
69
+ transition: transform 0.15s ease;
70
  display: inline-block;
71
  }
72
  .badge:hover {
73
  transform: translate(-1px, -1px);
 
74
  }
75
 
76
  .badge.cyan { background: var(--comic-cyan); }
 
88
  }
89
 
90
  p.sub {
91
+ font-size: 1.1rem;
92
  font-weight: 700;
93
  color: #4b5563;
94
  }
 
99
  grid-template-columns: 1fr;
100
  gap: 24px;
101
  width: 100%;
102
+ max-width: 1140px;
103
  }
104
  @media (min-width: 880px) {
105
  .main-grid { grid-template-columns: 1.15fr 1fr; }
 
117
  position: relative;
118
  }
119
 
120
+ /* TTS Controls Banner */
121
+ .tts-controls-box {
122
+ background: #fefce8;
123
+ border: 2px solid var(--ink);
124
+ border-radius: 12px;
125
+ padding: 12px 14px;
126
+ margin-bottom: 16px;
127
+ box-shadow: var(--shadow-hard-sm);
128
+ display: flex;
129
+ flex-direction: column;
130
+ gap: 8px;
131
+ }
132
+ .tts-row {
133
  display: flex;
134
  justify-content: space-between;
135
  align-items: center;
 
 
 
 
 
 
136
  flex-wrap: wrap;
137
  gap: 8px;
138
+ font-size: 0.95rem;
139
+ font-weight: 700;
140
  }
141
+ .tts-row select {
 
142
  background: var(--comic-cyan);
143
  border: 2px solid var(--ink);
144
  color: var(--ink);
145
  font-family: 'Comic Neue', cursive;
146
  font-size: 0.95rem;
147
  font-weight: 700;
148
+ padding: 4px 10px;
149
  border-radius: 8px;
150
  outline: none;
151
  cursor: pointer;
152
  box-shadow: 2px 2px 0px var(--ink);
153
  }
154
+ .model-loader-bar {
155
+ height: 8px;
156
+ background: #e2e8f0;
157
+ border: 1.5px solid var(--ink);
158
+ border-radius: 9999px;
159
+ overflow: hidden;
160
+ display: none;
161
+ }
162
+ .model-loader-fill {
163
+ height: 100%;
164
+ background: #10b981;
165
+ width: 0%;
166
+ transition: width 0.2s ease;
167
+ }
168
+ .tts-status-label {
169
+ font-size: 0.8rem;
170
+ font-family: 'JetBrains Mono', monospace;
171
+ color: #065f46;
172
+ font-weight: 700;
173
+ display: none;
174
+ }
175
 
176
  /* Comic Mic Orb */
177
  .orb-container {
 
179
  flex-direction: column;
180
  align-items: center;
181
  justify-content: center;
182
+ padding: 10px 0;
183
  gap: 12px;
184
  }
185
 
 
204
  .voice-orb.listening {
205
  background: var(--comic-mint);
206
  transform: scale(1.1);
 
207
  animation: comicPulse 1s infinite alternate ease-in-out;
208
  }
209
  .voice-orb.speaking {
210
  background: var(--comic-coral);
211
  transform: scale(1.08);
 
212
  animation: comicTalk 0.8s infinite alternate ease-in-out;
213
  }
214
 
 
240
  display: flex;
241
  gap: 8px;
242
  flex-wrap: wrap;
243
+ margin: 10px 0 12px;
244
  }
245
  .chip {
246
  background: #ffffff;
 
264
  /* Speech Bubble Chat */
265
  .chat-box {
266
  flex: 1;
267
+ min-height: 230px;
268
+ max-height: 310px;
269
  overflow-y: auto;
270
  display: flex;
271
  flex-direction: column;
272
  gap: 14px;
273
  padding: 10px 8px 10px 2px;
274
+ margin: 6px 0 14px;
275
  }
276
  .chat-box::-webkit-scrollbar { width: 6px; }
277
  .chat-box::-webkit-scrollbar-thumb { background: var(--ink); border-radius: 4px; }
 
417
  <header>
418
  <div class="badge-row">
419
  <a class="badge" href="https://huggingface.co/pipecat-ai/phonellm-alpha-1" target="_blank">🎙️ PhoneLLM Alpha 1</a>
420
+ <span class="badge mint" id="device-cap-badge">⚡ Device Check: WASM / WebGPU</span>
421
  <a class="badge cyan" href="https://xkcd.hemanth.deno.net/" target="_blank">🌐 xkcd.hemanth.deno.net</a>
422
  </div>
423
  <h1>XKCD Comic Voice Agent</h1>
424
+ <p class="sub">Comic interface with lightweight on-device neural TTS (Kokoro-82M) & live XKCD tools.</p>
425
  </header>
426
 
427
  <main class="main-grid">
428
  <!-- Left Voice & Speech Bubble Panel -->
429
  <div class="panel">
430
+ <!-- TTS Engine & Voice Config -->
431
+ <div class="tts-controls-box">
432
+ <div class="tts-row">
433
+ <span>🧠 <strong>TTS Engine:</strong></span>
434
+ <select id="tts-engine-select">
435
+ <option value="kokoro">Kokoro-82M Neural Model (On-Device)</option>
436
+ <option value="system">Native System Speech (Instant)</option>
437
+ </select>
438
+ </div>
439
+ <div class="tts-row" id="voice-row">
440
+ <span>🎭 <strong>Voice Profile:</strong></span>
441
+ <select id="voice-select">
442
+ <option value="af_heart">Heart (Female US - Warm)</option>
443
+ <option value="af_bella">Bella (Female US - Energetic)</option>
444
+ <option value="am_michael">Michael (Male US - Narrator)</option>
445
+ <option value="bf_emma">Emma (Female UK)</option>
446
+ <option value="bm_george">George (Male UK)</option>
447
+ </select>
448
+ </div>
449
+ <div class="model-loader-bar" id="loader-bar">
450
+ <div class="model-loader-fill" id="loader-fill"></div>
451
+ </div>
452
+ <span class="tts-status-label" id="loader-label">⚡ Loading Kokoro 82M Neural Weights...</span>
453
  </div>
454
 
455
  <div class="orb-container">
 
485
  </div>
486
  </main>
487
 
488
+ <script type="module">
489
+ import { KokoroTTS } from "https://cdn.jsdelivr.net/npm/kokoro-js@1.2.1";
490
+
491
  const XKCD_API_BASE = "https://xkcd.hemanth.deno.net";
492
  const orb = document.getElementById('voice-orb');
493
  const statusBadge = document.getElementById('status-badge');
 
495
  const userInput = document.getElementById('user-input');
496
  const sendBtn = document.getElementById('send-btn');
497
  const comicCard = document.getElementById('comic-card');
498
+ const ttsEngineSelect = document.getElementById('tts-engine-select');
499
  const voiceSelect = document.getElementById('voice-select');
500
+ const loaderBar = document.getElementById('loader-bar');
501
+ const loaderFill = document.getElementById('loader-fill');
502
+ const loaderLabel = document.getElementById('loader-label');
503
+ const deviceCapBadge = document.getElementById('device-cap-badge');
504
 
505
  let isListening = false;
506
  let recognition = null;
507
+ let kokoroModel = null;
508
+ let isLoadingKokoro = false;
509
+ let currentAudio = null;
510
+
511
+ // Detect Device Hardware Acceleration Capability
512
+ async function checkDeviceCapability() {
513
+ const hasWebGPU = !!navigator.gpu;
514
+ const deviceLabel = hasWebGPU ? "🚀 Hardware: WebGPU Accelerated" : "⚡ Hardware: WASM SIMD Mode";
515
+ deviceCapBadge.textContent = deviceLabel;
516
+ console.log("Device Capability:", deviceLabel);
517
+ }
518
+ checkDeviceCapability();
519
+
520
+ // Initialize Kokoro-82M Lightweight On-Device Neural Model
521
+ async function initKokoroModel() {
522
+ if (kokoroModel || isLoadingKokoro) return;
523
+ isLoadingKokoro = true;
524
+ loaderBar.style.display = "block";
525
+ loaderLabel.style.display = "block";
526
+ loaderLabel.textContent = "⚡ Initializing Kokoro-82M ONNX in-browser...";
527
+
528
+ try {
529
+ const device = navigator.gpu ? "webgpu" : "wasm";
530
+ kokoroModel = await KokoroTTS.from_pretrained("onnx-community/Kokoro-82M-v1.0-ONNX", {
531
+ dtype: "q8", // 8-bit quantized: fast, small footprint, cached in browser!
532
+ device: device,
533
+ progress_callback: (progress) => {
534
+ if (progress.status === "progress" && progress.total) {
535
+ const pct = Math.round((progress.loaded / progress.total) * 100);
536
+ loaderFill.style.width = pct + "%";
537
+ loaderLabel.textContent = `⚡ Loading Kokoro weights: ${pct}% (${(progress.loaded / 1024 / 1024).toFixed(1)}MB)`;
538
+ }
539
+ }
540
+ });
541
+
542
+ loaderFill.style.width = "100%";
543
+ loaderLabel.textContent = "✅ Kokoro-82M Neural Model Ready On-Device!";
544
+ setTimeout(() => {
545
+ loaderBar.style.display = "none";
546
+ loaderLabel.style.display = "none";
547
+ }, 2500);
548
+ } catch (err) {
549
+ console.warn("Kokoro on-device load error, fallback to system TTS:", err);
550
+ loaderLabel.textContent = "⚠️ On-device model fallback to System TTS";
551
+ ttsEngineSelect.value = "system";
552
+ } finally {
553
+ isLoadingKokoro = false;
554
+ }
555
+ }
556
+
557
+ // Pre-warm on-device model if device is capable
558
+ initKokoroModel();
559
+
560
+ // High-Fidelity Audio Synthesizer
561
+ async function speak(text) {
562
+ if (currentAudio) {
563
+ currentAudio.pause();
564
+ currentAudio = null;
565
+ }
566
+ if ('speechSynthesis' in window) {
567
+ window.speechSynthesis.cancel();
568
+ }
569
+
570
+ orb.classList.remove('listening');
571
+ orb.classList.add('speaking');
572
+ statusBadge.textContent = "🔊 Speaking response...";
573
+
574
+ const engine = ttsEngineSelect.value;
575
+ const chosenVoice = voiceSelect.value || "af_heart";
576
+
577
+ // 1. Try On-Device Kokoro-82M Neural TTS if loaded or loading
578
+ if (engine === "kokoro" && kokoroModel) {
579
+ try {
580
+ statusBadge.textContent = "🧠 Neural synthesizing on-device...";
581
+ const audioOutput = await kokoroModel.generate(text, {
582
+ voice: chosenVoice,
583
+ speed: 1.05
584
+ });
585
+
586
+ const blob = audioOutput.toBlob();
587
+ const audioUrl = URL.createObjectURL(blob);
588
+ currentAudio = new Audio(audioUrl);
589
+
590
+ currentAudio.onended = () => {
591
+ orb.classList.remove('speaking');
592
+ statusBadge.textContent = "Ready!";
593
+ };
594
+ currentAudio.onerror = () => {
595
+ orb.classList.remove('speaking');
596
+ statusBadge.textContent = "Ready!";
597
+ };
598
+
599
+ await currentAudio.play();
600
+ return;
601
+ } catch (e) {
602
+ console.warn("Kokoro synthesis error, falling back to System Speech:", e);
603
+ }
604
+ }
605
+
606
+ // 2. System Speech Fallback
607
+ if ('speechSynthesis' in window) {
608
+ const utterance = new SpeechSynthesisUtterance(text);
609
+ const voices = window.speechSynthesis.getVoices();
610
+ const preferredVoice = voices.find(v => v.lang.startsWith('en') && (v.name.includes('Natural') || v.name.includes('Samantha') || v.name.includes('Google') || v.name.includes('Siri')));
611
+ if (preferredVoice) utterance.voice = preferredVoice;
612
+ utterance.rate = 1.05;
613
+
614
+ utterance.onend = () => {
615
+ orb.classList.remove('speaking');
616
+ statusBadge.textContent = "Ready!";
617
+ };
618
+ utterance.onerror = () => {
619
+ orb.classList.remove('speaking');
620
+ statusBadge.textContent = "Ready!";
621
+ };
622
+ window.speechSynthesis.speak(utterance);
623
+ }
624
+ }
625
 
626
  // Topic Knowledge Index for iconic XKCD comics
627
  const TOPIC_INDEX = {
 
660
  "kernel": 272
661
  };
662
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
663
  // Live XKCD API Tool Functions
664
  async function toolGetLatestXkcd() {
665
  const res = await fetch(`${XKCD_API_BASE}/`);
 
701
 
702
  const numMatch = lower.match(/\b(?:comic|number|#)?\s*(\d+)\b/);
703
 
704
+ // Check for topic queries first
705
  let matchedTopic = null;
706
  for (const key of Object.keys(TOPIC_INDEX)) {
707
  if (lower.includes(key)) {
 
823
  if (isListening) {
824
  recognition.stop();
825
  } else {
826
+ if (currentAudio) currentAudio.pause();
827
+ if ('speechSynthesis' in window) window.speechSynthesis.cancel();
828
  orb.classList.remove('speaking');
829
  recognition.start();
830
  }
 
832
 
833
  // Initial greeting
834
  setTimeout(() => {
835
+ addMessage("🗯️ Hey! I am your XKCD voice bot powered by PhoneLLM Alpha 1 with on-device Kokoro TTS. Ask me for comics on Python, Git, SQL, or say 'Random'!", "agent");
836
  }, 400);
837
  </script>
838
  </body>