@lokutor/sdk 1.4.1 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -38,9 +38,11 @@ __export(index_exports, {
38
38
  calculateRMS: () => calculateRMS,
39
39
  float32ToPcm16: () => float32ToPcm16,
40
40
  isRetryable: () => isRetryable,
41
+ microphoneMessage: () => microphoneMessage,
41
42
  normalizeAudio: () => normalizeAudio,
42
43
  pcm16ToBytes: () => pcm16ToBytes,
43
44
  pcm16ToFloat32: () => pcm16ToFloat32,
45
+ requestMicrophone: () => requestMicrophone,
44
46
  resample: () => resample,
45
47
  resampleWithAntiAliasing: () => resampleWithAntiAliasing,
46
48
  simpleConversation: () => simpleConversation,
@@ -130,6 +132,8 @@ function isRetryableCode(code) {
130
132
  "auth.missing_key",
131
133
  "auth.invalid_key",
132
134
  "auth.time_limited",
135
+ "session.ended",
136
+ "audio.microphone_unavailable",
133
137
  "validation.invalid_voice",
134
138
  "validation.invalid_language",
135
139
  "validation.text_too_long",
@@ -284,6 +288,36 @@ var StreamResampler = class {
284
288
  };
285
289
 
286
290
  // src/browser-audio.ts
291
+ function requestMicrophone(constraints = {}) {
292
+ if (typeof navigator === "undefined") {
293
+ return Promise.reject(new Error("Microphone capture needs a browser"));
294
+ }
295
+ const session = navigator.audioSession;
296
+ if (session && session.type !== "auto" && session.type !== "play-and-record") {
297
+ try {
298
+ session.type = "auto";
299
+ } catch (_) {
300
+ }
301
+ }
302
+ if (!navigator.mediaDevices?.getUserMedia) {
303
+ return Promise.reject(new Error("Microphone capture is not available here (it needs HTTPS)"));
304
+ }
305
+ return navigator.mediaDevices.getUserMedia({
306
+ audio: {
307
+ autoGainControl: constraints.autoGainControl ?? true,
308
+ echoCancellation: constraints.echoCancellation ?? true,
309
+ noiseSuppression: constraints.noiseSuppression ?? true
310
+ }
311
+ });
312
+ }
313
+ async function resumeWithin(ctx, ms) {
314
+ if (ctx.state === "running") return;
315
+ await Promise.race([
316
+ ctx.resume().catch(() => {
317
+ }),
318
+ new Promise((r) => setTimeout(r, ms))
319
+ ]);
320
+ }
287
321
  var BrowserAudioManager = class {
288
322
  audioContext = null;
289
323
  mediaStreamAudioSourceNode = null;
@@ -305,6 +339,12 @@ var BrowserAudioManager = class {
305
339
  autoGainControl;
306
340
  echoCancellation;
307
341
  noiseSuppression;
342
+ keepAwake;
343
+ providedMicrophone;
344
+ // Screen wake lock, held while the microphone is live. Browsers release it whenever the page is
345
+ // hidden, so it is taken again when the page becomes visible.
346
+ wakeLock = null;
347
+ visibilityHandler = null;
308
348
  // Callbacks
309
349
  onAudioInput;
310
350
  onInputError;
@@ -317,6 +357,10 @@ var BrowserAudioManager = class {
317
357
  this.autoGainControl = config.autoGainControl ?? true;
318
358
  this.echoCancellation = config.echoCancellation ?? true;
319
359
  this.noiseSuppression = config.noiseSuppression ?? true;
360
+ this.keepAwake = config.keepAwake ?? true;
361
+ this.providedMicrophone = config.microphone ?? null;
362
+ if (this.providedMicrophone instanceof Promise) this.providedMicrophone.catch(() => {
363
+ });
320
364
  this.onInputError = config.onInputError;
321
365
  }
322
366
  /**
@@ -332,9 +376,8 @@ var BrowserAudioManager = class {
332
376
  if (!this.audioContext) {
333
377
  throw new Error("Failed to initialize AudioContext");
334
378
  }
335
- if (this.audioContext.state === "suspended") {
336
- await this.audioContext.resume();
337
- console.log("\u{1F442} AudioContext resumed");
379
+ if (this.audioContext.state !== "running") {
380
+ await resumeWithin(this.audioContext, 500);
338
381
  }
339
382
  if (analyserConfig?.enabled !== false) {
340
383
  this.analyserNode = this.audioContext.createAnalyser();
@@ -348,10 +391,17 @@ var BrowserAudioManager = class {
348
391
  if (!this.audioContext) {
349
392
  await this.init();
350
393
  }
394
+ if (this.isListening && this.mediaStream) {
395
+ this.onAudioInput = onAudioInput;
396
+ return;
397
+ }
351
398
  try {
352
399
  this.onAudioInput = onAudioInput;
353
400
  this.isListening = true;
354
- this.mediaStream = await navigator.mediaDevices.getUserMedia({
401
+ const provided = this.providedMicrophone;
402
+ this.providedMicrophone = null;
403
+ const stream = provided ? await provided : null;
404
+ this.mediaStream = stream && stream.getAudioTracks().some((t) => t.readyState === "live") ? stream : await navigator.mediaDevices.getUserMedia({
355
405
  audio: {
356
406
  autoGainControl: this.autoGainControl,
357
407
  echoCancellation: this.echoCancellation,
@@ -382,7 +432,9 @@ var BrowserAudioManager = class {
382
432
  this._processAudioInput(event);
383
433
  };
384
434
  console.log("\u{1F3A4} Microphone started");
435
+ this.holdScreenAwake();
385
436
  } catch (error) {
437
+ this.isListening = false;
386
438
  const err = error instanceof Error ? error : new Error(String(error));
387
439
  if (this.onInputError) this.onInputError(err);
388
440
  throw err;
@@ -420,6 +472,7 @@ var BrowserAudioManager = class {
420
472
  */
421
473
  stopMicrophone() {
422
474
  this.isListening = false;
475
+ this.releaseScreenAwake();
423
476
  if (this.mediaStream) {
424
477
  this.mediaStream.getTracks().forEach((track) => track.stop());
425
478
  this.mediaStream = null;
@@ -434,6 +487,53 @@ var BrowserAudioManager = class {
434
487
  }
435
488
  console.log("\u{1F3A4} Microphone stopped");
436
489
  }
490
+ /**
491
+ * Keep the screen on while the microphone is live, and bring audio back when the page returns
492
+ * to the foreground (iOS leaves the context "interrupted" after the screen locks).
493
+ */
494
+ holdScreenAwake() {
495
+ if (typeof document === "undefined") return;
496
+ if (!this.visibilityHandler) {
497
+ this.visibilityHandler = () => {
498
+ if (document.visibilityState !== "visible" || !this.isListening) return;
499
+ if (this.audioContext && this.audioContext.state !== "running") {
500
+ this.audioContext.resume().catch(() => {
501
+ });
502
+ }
503
+ void this.requestWakeLock();
504
+ };
505
+ document.addEventListener("visibilitychange", this.visibilityHandler);
506
+ }
507
+ void this.requestWakeLock();
508
+ }
509
+ async requestWakeLock() {
510
+ if (!this.keepAwake || this.wakeLock || typeof navigator === "undefined") return;
511
+ const api = navigator.wakeLock;
512
+ if (!api?.request || document.visibilityState !== "visible") return;
513
+ try {
514
+ const lock = await api.request("screen");
515
+ if (!this.isListening) {
516
+ await lock.release().catch(() => {
517
+ });
518
+ return;
519
+ }
520
+ this.wakeLock = lock;
521
+ lock.addEventListener?.("release", () => {
522
+ if (this.wakeLock === lock) this.wakeLock = null;
523
+ });
524
+ } catch (_) {
525
+ }
526
+ }
527
+ releaseScreenAwake() {
528
+ if (this.visibilityHandler && typeof document !== "undefined") {
529
+ document.removeEventListener("visibilitychange", this.visibilityHandler);
530
+ this.visibilityHandler = null;
531
+ }
532
+ const lock = this.wakeLock;
533
+ this.wakeLock = null;
534
+ lock?.release().catch(() => {
535
+ });
536
+ }
437
537
  /**
438
538
  * Play back audio received from the server
439
539
  * @param pcm16Data Int16 PCM audio data at SPEAKER_SAMPLE_RATE
@@ -569,6 +669,13 @@ var BrowserAudioManager = class {
569
669
  this.analyserNode.disconnect();
570
670
  this.analyserNode = null;
571
671
  }
672
+ if (this.audioContext) {
673
+ const ctx = this.audioContext;
674
+ this.audioContext = null;
675
+ this.nextPlaybackTime = 0;
676
+ ctx.close().catch(() => {
677
+ });
678
+ }
572
679
  }
573
680
  /**
574
681
  * Get current audio context state
@@ -633,6 +740,7 @@ async function fetchJson(url, timeoutMs = 1e4) {
633
740
  });
634
741
  }
635
742
  }
743
+ var RECONNECTABLE_CLOSE_CODES = /* @__PURE__ */ new Set([1001, 1006, 1011, 1012, 1013, 1014]);
636
744
  function base64ToUint8Array(base64) {
637
745
  const binaryString = atob(base64);
638
746
  const bytes = new Uint8Array(binaryString.length);
@@ -712,6 +820,9 @@ var VoiceAgentClient = class {
712
820
  reconnectAttempts = 0;
713
821
  maxReconnectAttempts = 5;
714
822
  reconnectTimer = null;
823
+ // The microphone is started once per session of this client, not once per socket: a reconnect
824
+ // reuses it. Starting it again on every open ran two capture pipelines into one resampler.
825
+ micStarted = false;
715
826
  // Runtime state
716
827
  serverPlaybackRate = 44100;
717
828
  serverInputRate = 16e3;
@@ -782,15 +893,35 @@ var VoiceAgentClient = class {
782
893
  this.isConnected = true;
783
894
  this.reconnectAttempts = 0;
784
895
  this.reconnecting = false;
896
+ this.currentGeneration = 0;
785
897
  console.log("\u2705 Connected to voice agent!");
786
898
  sdkTrace("ws.open");
787
899
  this.sendConfig();
788
- if (this.audioManager) {
789
- await this.audioManager.startMicrophone((data) => {
790
- if (this.isConnected) {
791
- this.sendAudio(data);
900
+ if (this.audioManager && !this.micStarted) {
901
+ try {
902
+ await this.audioManager.startMicrophone((data) => {
903
+ if (this.isConnected) {
904
+ this.sendAudio(data);
905
+ }
906
+ });
907
+ } catch (e) {
908
+ const name = e?.name || "Error";
909
+ const error = new LokutorError(
910
+ "audio.microphone_unavailable",
911
+ name === "NotAllowedError" || name === "SecurityError" ? "Microphone access was not allowed." : "The microphone could not be started.",
912
+ { detail: `${name}: ${e?.message ?? e}`, original: e, retryable: false }
913
+ );
914
+ sdkTrace("mic.failed", { name });
915
+ settle(() => reject(error));
916
+ this.isUserDisconnect = true;
917
+ try {
918
+ this.ws?.close();
919
+ } catch (_) {
792
920
  }
793
- });
921
+ this.emit("error", error);
922
+ return;
923
+ }
924
+ this.micStarted = true;
794
925
  }
795
926
  settle(() => resolve(true));
796
927
  };
@@ -841,6 +972,17 @@ var VoiceAgentClient = class {
841
972
  console.error(" URL:", this.serverUrl.replace(/api_key=[^&]+/, "api_key=***"));
842
973
  }
843
974
  console.log(`\u{1F50C} WebSocket closed \u2014 code: ${event.code}, reason: "${event.reason || "none"}", clean: ${event.wasClean}`);
975
+ if (!this.isUserDisconnect && !RECONNECTABLE_CLOSE_CODES.has(event.code)) {
976
+ const ended = new LokutorError("session.ended", event.reason || "The session was ended by the server.", {
977
+ detail: `close code ${event.code}`,
978
+ retryable: false
979
+ });
980
+ console.log(`Session ended by the server (code ${event.code})`);
981
+ this.emit("ended", { code: event.code, reason: event.reason || "" });
982
+ if (this.onError) this.onError(ended);
983
+ if (this.onStatus) this.onStatus("disconnected");
984
+ return;
985
+ }
844
986
  if (!this.isUserDisconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
845
987
  this.reconnecting = true;
846
988
  this.reconnectAttempts++;
@@ -1030,11 +1172,18 @@ var VoiceAgentClient = class {
1030
1172
  detail: backendDetail,
1031
1173
  retryable: backendRetryable
1032
1174
  });
1033
- if (this.onError) this.onError(error);
1034
1175
  this.emit("error", error);
1035
1176
  console.error(`\u274C Server error: [${error.code}] ${error.message}`);
1036
1177
  break;
1037
1178
  }
1179
+ case "session_limit": {
1180
+ const seconds = Number(msg.data?.seconds ?? msg.seconds);
1181
+ if (Number.isFinite(seconds) && seconds >= 0) {
1182
+ const limit = { seconds, reason: msg.data?.reason ?? msg.reason };
1183
+ this.emit("session_limit", limit);
1184
+ }
1185
+ break;
1186
+ }
1038
1187
  case "tool_call":
1039
1188
  this.emit("tool_call", { name: msg.name, arguments: msg.arguments });
1040
1189
  console.log(`\u{1F6E0}\uFE0F Tool Call: ${msg.name}(${msg.arguments})`);
@@ -1108,6 +1257,7 @@ var VoiceAgentClient = class {
1108
1257
  if (this.audioManager) {
1109
1258
  this.audioManager.cleanup();
1110
1259
  }
1260
+ this.micStarted = false;
1111
1261
  this.isConnected = false;
1112
1262
  this.configConfirmed = false;
1113
1263
  this.reconnecting = false;
@@ -1757,6 +1907,22 @@ var PANEL_CSS = (
1757
1907
  margin-bottom: auto;
1758
1908
  padding-top: 1.5rem;
1759
1909
  }
1910
+ .cv-countdown, .cv-note {
1911
+ display: none;
1912
+ margin-top: 0.4rem;
1913
+ padding: 0.15rem 0.6rem;
1914
+ border-radius: 999px;
1915
+ border: 1px solid var(--cv-ui-border, rgba(255,255,255,0.1));
1916
+ background: var(--cv-ui-bg, rgba(255,255,255,0.05));
1917
+ color: var(--cv-text-dim, rgba(255,255,255,0.6));
1918
+ font-size: 0.75rem;
1919
+ font-variant-numeric: tabular-nums;
1920
+ width: fit-content;
1921
+ margin-left: auto;
1922
+ margin-right: auto;
1923
+ }
1924
+ .cv-countdown.is-visible, .cv-note.is-visible { display: block; }
1925
+ .cv-countdown.is-low { color: var(--cv-accent); border-color: var(--cv-accent); }
1760
1926
  .cv-title {
1761
1927
  font-size: clamp(1rem, 2.5vw, 1.75rem);
1762
1928
  font-weight: 700;
@@ -1967,6 +2133,17 @@ function injectStyles() {
1967
2133
  document.head.appendChild(style);
1968
2134
  styleInjected = true;
1969
2135
  }
2136
+ function microphoneMessage(err) {
2137
+ const name = String(err?.original?.name ?? err?.name ?? "");
2138
+ const detail = String(err?.detail ?? err?.message ?? "");
2139
+ if (/NotAllowedError|SecurityError/.test(name + " " + detail)) {
2140
+ return "Microphone access is blocked. Allow it for this site in your browser settings, then try again.";
2141
+ }
2142
+ if (/NotFoundError|OverconstrainedError/.test(name + " " + detail)) {
2143
+ return "No microphone was found on this device.";
2144
+ }
2145
+ return "The microphone could not start. Please try again.";
2146
+ }
1970
2147
  var ConversationalPanel = class {
1971
2148
  cfg;
1972
2149
  container;
@@ -1993,6 +2170,11 @@ var ConversationalPanel = class {
1993
2170
  muteSvg;
1994
2171
  stopBtn;
1995
2172
  visualizerWrap;
2173
+ countdownEl;
2174
+ noteEl;
2175
+ countdownTicker = null;
2176
+ microphone = null;
2177
+ micError = null;
1996
2178
  // Callbacks
1997
2179
  onTranscription;
1998
2180
  onResponse;
@@ -2000,6 +2182,12 @@ var ConversationalPanel = class {
2000
2182
  onStop;
2001
2183
  onError;
2002
2184
  onToolCall;
2185
+ /**
2186
+ * The call ended without the visitor pressing End: a time limit, the silence timeout, the server
2187
+ * closing the session, or the connection being lost. Receives the message the panel shows, so a
2188
+ * page that renders its own chrome (and hides this panel's curtain) can show it too.
2189
+ */
2190
+ onEnded;
2003
2191
  constructor(cfg) {
2004
2192
  this.cfg = cfg;
2005
2193
  this.container = cfg.container;
@@ -2040,6 +2228,8 @@ var ConversationalPanel = class {
2040
2228
  </div>
2041
2229
  <div class="cv-header">
2042
2230
  <h2 class="cv-title">${this.esc(this.cfg.title || "Voice Chat")}</h2>
2231
+ <div class="cv-countdown" aria-live="off"></div>
2232
+ <div class="cv-note" role="status"></div>
2043
2233
  </div>
2044
2234
  <div class="cv-visualizer-wrap">
2045
2235
  <canvas class="cv-canvas"></canvas>
@@ -2075,6 +2265,8 @@ var ConversationalPanel = class {
2075
2265
  this.muteSvg = this.muteBtn.querySelector("svg");
2076
2266
  this.stopBtn = this.el.querySelector(".cv-btn--end");
2077
2267
  this.visualizerWrap = this.el.querySelector(".cv-visualizer-wrap");
2268
+ this.countdownEl = this.el.querySelector(".cv-countdown");
2269
+ this.noteEl = this.el.querySelector(".cv-note");
2078
2270
  this.startBtn.addEventListener("click", () => this.start());
2079
2271
  this.stopBtn.addEventListener("click", () => this.stop());
2080
2272
  this.muteBtn.addEventListener("click", () => this.toggleMute());
@@ -2084,14 +2276,23 @@ var ConversationalPanel = class {
2084
2276
  d.textContent = s;
2085
2277
  return d.innerHTML;
2086
2278
  }
2087
- /** Start the conversation (called when user clicks "Start Conversation" or externally) */
2279
+ /**
2280
+ * Start the conversation (called when user clicks "Start Conversation" or externally). Call it from
2281
+ * inside the click or tap, before any await: it asks for the microphone there. Resolves true once the
2282
+ * call is live, false when it could not start (the reason is shown, and passed to onError).
2283
+ */
2088
2284
  async start() {
2089
- if (this.isRunning) return;
2285
+ if (this.isRunning) return false;
2090
2286
  if (this._locked) {
2091
2287
  this.showError("This session has ended. Refresh the page to start a new one.");
2092
- return;
2288
+ return false;
2093
2289
  }
2094
2290
  this.isRunning = true;
2291
+ this.microphone = requestMicrophone();
2292
+ this.micError = null;
2293
+ this.microphone.catch((e) => {
2294
+ this.micError = e;
2295
+ });
2095
2296
  this.startBtn.disabled = true;
2096
2297
  this.errorEl.classList.remove("is-visible");
2097
2298
  this.playConnectingSound();
@@ -2122,7 +2323,12 @@ var ConversationalPanel = class {
2122
2323
  this.el.classList.remove("cv-is-speaking", "cv-is-thinking");
2123
2324
  if (status === "speaking") this.el.classList.add("cv-is-speaking");
2124
2325
  else if (status === "thinking") this.el.classList.add("cv-is-thinking");
2326
+ this.setNote(status === "reconnecting" ? "Reconnecting\u2026" : "");
2327
+ if (status === "disconnected" && this.isRunning) {
2328
+ this.end("The call has ended.");
2329
+ }
2125
2330
  },
2331
+ onSessionLimit: (seconds) => this.startCountdown(seconds),
2126
2332
  onTranscription: (text) => {
2127
2333
  this._lastSpeechTime = Date.now();
2128
2334
  this.onTranscription?.(text);
@@ -2137,9 +2343,9 @@ var ConversationalPanel = class {
2137
2343
  onError: (err) => {
2138
2344
  if (!this.isRunning) return;
2139
2345
  this.onError?.(err);
2140
- this.stop();
2141
- this.showError("Connection issue. Try again in a moment.");
2142
- }
2346
+ this.end(err?.code === "audio.microphone_unavailable" ? microphoneMessage(err) : err?.code === "session.ended" && err?.message ? err.message : "Connection issue. Try again in a moment.");
2347
+ },
2348
+ microphone: this.microphone
2143
2349
  });
2144
2350
  this.agent = agentHandle;
2145
2351
  visualizer.setAudioClient(agentHandle);
@@ -2149,6 +2355,7 @@ var ConversationalPanel = class {
2149
2355
  } catch (_) {
2150
2356
  }
2151
2357
  const ok = await agentHandle.connect();
2358
+ if (!this.isRunning) return false;
2152
2359
  if (ok) {
2153
2360
  this.fadeOutConnectingSound();
2154
2361
  const wrap = this.visualizerWrap;
@@ -2162,19 +2369,21 @@ var ConversationalPanel = class {
2162
2369
  this.startTimer();
2163
2370
  this.onStart?.();
2164
2371
  if (window.lucide) window.lucide.createIcons();
2165
- } else {
2166
- this.fadeOutConnectingSound();
2167
- this.playErrorTone();
2168
- this.curtain.classList.remove("is-up");
2169
- this.showError("Could not connect. Please try again.");
2170
- this.isRunning = false;
2372
+ return true;
2171
2373
  }
2374
+ this.fadeOutConnectingSound();
2375
+ this.playErrorTone();
2376
+ const micError = this.micError;
2377
+ this.stop();
2378
+ this.showError(micError ? microphoneMessage(micError) : "Could not connect. Please try again.");
2379
+ return false;
2172
2380
  } catch (err) {
2173
2381
  this.fadeOutConnectingSound();
2174
2382
  this.playErrorTone();
2175
2383
  console.error("ConversationalPanel start error:", err);
2176
2384
  this.stop();
2177
2385
  this.showError("Something went wrong. Please try again.");
2386
+ return false;
2178
2387
  } finally {
2179
2388
  this.startBtn.disabled = false;
2180
2389
  }
@@ -2192,10 +2401,16 @@ var ConversationalPanel = class {
2192
2401
  }
2193
2402
  this.agent = null;
2194
2403
  }
2404
+ const mic = this.microphone;
2405
+ this.microphone = null;
2406
+ mic?.then((s) => s.getTracks().forEach((t) => t.stop()), () => {
2407
+ });
2195
2408
  if (this.timerTicker) {
2196
2409
  clearInterval(this.timerTicker);
2197
2410
  this.timerTicker = null;
2198
2411
  }
2412
+ this.stopCountdown();
2413
+ this.setNote("");
2199
2414
  if (this.visualizer) {
2200
2415
  this.visualizer.stop();
2201
2416
  this.visualizer = null;
@@ -2271,6 +2486,38 @@ var ConversationalPanel = class {
2271
2486
  this.errorEl.classList.remove("is-visible");
2272
2487
  }, 5e3);
2273
2488
  }
2489
+ /** End the call for a reason other than the End button, and say why. */
2490
+ end(message) {
2491
+ this.stop();
2492
+ this.showError(message);
2493
+ this.onEnded?.(message);
2494
+ }
2495
+ /** Show how long the session may still run, as announced by the server. */
2496
+ startCountdown(seconds) {
2497
+ this.stopCountdown();
2498
+ const endsAt = Date.now() + seconds * 1e3;
2499
+ const render = () => {
2500
+ const left = Math.max(0, Math.round((endsAt - Date.now()) / 1e3));
2501
+ this.countdownEl.textContent = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")} left`;
2502
+ this.countdownEl.classList.toggle("is-low", left <= 30);
2503
+ if (left === 0) this.stopCountdown(false);
2504
+ };
2505
+ this.countdownEl.classList.add("is-visible");
2506
+ render();
2507
+ this.countdownTicker = window.setInterval(render, 1e3);
2508
+ }
2509
+ stopCountdown(hide = true) {
2510
+ if (this.countdownTicker) {
2511
+ clearInterval(this.countdownTicker);
2512
+ this.countdownTicker = null;
2513
+ }
2514
+ if (hide) this.countdownEl?.classList.remove("is-visible", "is-low");
2515
+ }
2516
+ setNote(text) {
2517
+ if (!this.noteEl) return;
2518
+ this.noteEl.textContent = text;
2519
+ this.noteEl.classList.toggle("is-visible", Boolean(text));
2520
+ }
2274
2521
  /** Destroy the component, removing all DOM and stopping any active session */
2275
2522
  destroy() {
2276
2523
  this.stop();
@@ -2286,14 +2533,12 @@ var ConversationalPanel = class {
2286
2533
  elapsed++;
2287
2534
  if (elapsed >= maxDur) {
2288
2535
  this._locked = true;
2289
- this.stop();
2290
- this.showError("Tiempo l\xEDmite alcanzado.");
2536
+ this.end("Time limit reached.");
2291
2537
  return;
2292
2538
  }
2293
2539
  if (Date.now() - this._lastSpeechTime > silentMax * 1e3) {
2294
2540
  this._locked = true;
2295
- this.stop();
2296
- this.showError("Sesi\xF3n finalizada por inactividad.");
2541
+ this.end("The call ended after a long silence.");
2297
2542
  }
2298
2543
  }, 1e3);
2299
2544
  }
@@ -2384,9 +2629,11 @@ var ConversationalPanel = class {
2384
2629
  calculateRMS,
2385
2630
  float32ToPcm16,
2386
2631
  isRetryable,
2632
+ microphoneMessage,
2387
2633
  normalizeAudio,
2388
2634
  pcm16ToBytes,
2389
2635
  pcm16ToFloat32,
2636
+ requestMicrophone,
2390
2637
  resample,
2391
2638
  resampleWithAntiAliasing,
2392
2639
  simpleConversation,