@lokutor/sdk 1.1.17 → 1.1.20

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.mjs CHANGED
@@ -1,9 +1,75 @@
1
- import {
2
- AUDIO_CONFIG,
3
- DEFAULT_URLS,
4
- Language,
5
- VoiceStyle
6
- } from "./chunk-UI24THO7.mjs";
1
+ // src/types.ts
2
+ var VoiceStyle = /* @__PURE__ */ ((VoiceStyle2) => {
3
+ VoiceStyle2["F1"] = "F1";
4
+ VoiceStyle2["F2"] = "F2";
5
+ VoiceStyle2["F3"] = "F3";
6
+ VoiceStyle2["F4"] = "F4";
7
+ VoiceStyle2["F5"] = "F5";
8
+ VoiceStyle2["M1"] = "M1";
9
+ VoiceStyle2["M2"] = "M2";
10
+ VoiceStyle2["M3"] = "M3";
11
+ VoiceStyle2["M4"] = "M4";
12
+ VoiceStyle2["M5"] = "M5";
13
+ return VoiceStyle2;
14
+ })(VoiceStyle || {});
15
+ var Language = /* @__PURE__ */ ((Language2) => {
16
+ Language2["ENGLISH"] = "en";
17
+ Language2["SPANISH"] = "es";
18
+ Language2["FRENCH"] = "fr";
19
+ Language2["PORTUGUESE"] = "pt";
20
+ Language2["KOREAN"] = "ko";
21
+ return Language2;
22
+ })(Language || {});
23
+ var AUDIO_CONFIG = {
24
+ SAMPLE_RATE: 16e3,
25
+ SAMPLE_RATE_INPUT: 16e3,
26
+ SPEAKER_SAMPLE_RATE: 44100,
27
+ SAMPLE_RATE_OUTPUT: 44100,
28
+ CHANNELS: 1,
29
+ CHUNK_DURATION_MS: 20,
30
+ get CHUNK_SIZE() {
31
+ return Math.floor(this.SAMPLE_RATE * this.CHUNK_DURATION_MS / 1e3);
32
+ }
33
+ };
34
+ var DEFAULT_URLS = {
35
+ VOICE_AGENT: "wss://api.lokutor.com/ws/agent",
36
+ TTS: "wss://api.lokutor.com/ws/tts"
37
+ };
38
+ var LokutorError = class extends Error {
39
+ code;
40
+ detail;
41
+ retryable;
42
+ original;
43
+ constructor(code, message, opts) {
44
+ super(message);
45
+ this.name = "LokutorError";
46
+ this.code = code;
47
+ this.detail = opts?.detail;
48
+ this.retryable = opts?.retryable ?? isRetryableCode(code);
49
+ this.original = opts?.original;
50
+ }
51
+ };
52
+ function isRetryableCode(code) {
53
+ const fatal = [
54
+ "auth.missing_key",
55
+ "auth.invalid_key",
56
+ "auth.time_limited",
57
+ "validation.invalid_voice",
58
+ "validation.invalid_language",
59
+ "validation.text_too_long",
60
+ "validation.speed_out_of_range",
61
+ "validation.steps_out_of_range",
62
+ "validation.invalid_request_format",
63
+ "internal.cancelled"
64
+ ];
65
+ return !fatal.includes(code);
66
+ }
67
+ function isRetryable(error) {
68
+ if (error instanceof LokutorError) {
69
+ return error.retryable;
70
+ }
71
+ return false;
72
+ }
7
73
 
8
74
  // src/audio-utils.ts
9
75
  function pcm16ToFloat32(int16Data) {
@@ -64,6 +130,9 @@ function pcm16ToBytes(data) {
64
130
  return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
65
131
  }
66
132
  function bytesToPcm16(bytes) {
133
+ if (bytes.length % 2 !== 0) {
134
+ bytes = bytes.slice(0, bytes.length - 1);
135
+ }
67
136
  return new Int16Array(bytes.buffer, bytes.byteOffset, bytes.length / 2);
68
137
  }
69
138
  function normalizeAudio(data, targetPeak = 0.95) {
@@ -113,11 +182,14 @@ var StreamResampler = class {
113
182
  combined.set(this.inputBuffer);
114
183
  combined.set(inputChunk, this.inputBuffer.length);
115
184
  const ratio = this.inputRate / this.outputRate;
116
- const outputLength = Math.floor(combined.length / ratio);
185
+ let outputLength = Math.floor(combined.length / ratio);
117
186
  if (outputLength === 0 && !flush) {
118
187
  this.inputBuffer = combined;
119
188
  return new Float32Array(0);
120
189
  }
190
+ if (flush && outputLength === 0 && combined.length > 0) {
191
+ outputLength = 1;
192
+ }
121
193
  const output = new Float32Array(outputLength);
122
194
  for (let i = 0; i < outputLength; i++) {
123
195
  const pos = i * ratio;
@@ -126,10 +198,8 @@ var StreamResampler = class {
126
198
  const weight = pos - left;
127
199
  output[i] = combined[left] * (1 - weight) + combined[right] * weight;
128
200
  }
129
- const remainingSamples = Math.ceil(combined.length - outputLength * ratio);
130
- this.inputBuffer = combined.slice(
131
- combined.length - remainingSamples
132
- );
201
+ const consumed = Math.floor(outputLength * ratio);
202
+ this.inputBuffer = combined.slice(consumed);
133
203
  return output;
134
204
  }
135
205
  reset() {
@@ -291,6 +361,10 @@ var BrowserAudioManager = class {
291
361
  console.warn("AudioContext not initialized");
292
362
  return;
293
363
  }
364
+ if (pcm16Data.length % 2 !== 0) {
365
+ console.warn(`Discarding odd-length PCM buffer (${pcm16Data.length} bytes)`);
366
+ return;
367
+ }
294
368
  const int16Array = new Int16Array(
295
369
  pcm16Data.buffer,
296
370
  pcm16Data.byteOffset,
@@ -419,6 +493,54 @@ var BrowserAudioManager = class {
419
493
  };
420
494
 
421
495
  // src/client.ts
496
+ function sdkTraceEnabled() {
497
+ try {
498
+ if (typeof window === "undefined") return false;
499
+ const w = window;
500
+ return Boolean(w.LOKUTOR_TRACE) || window.localStorage?.getItem("lokutorTrace") === "1";
501
+ } catch {
502
+ return false;
503
+ }
504
+ }
505
+ function sdkTrace(...args) {
506
+ if (sdkTraceEnabled()) {
507
+ console.log("[SDK TRACE]", ...args);
508
+ }
509
+ }
510
+ function nowMs() {
511
+ if (typeof performance !== "undefined" && performance.now) {
512
+ return performance.now();
513
+ }
514
+ return Date.now();
515
+ }
516
+ function wsToHttp(url) {
517
+ return url.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
518
+ }
519
+ async function fetchJson(url, timeoutMs = 1e4) {
520
+ const controller = new AbortController();
521
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
522
+ try {
523
+ const res = await fetch(url, {
524
+ signal: controller.signal,
525
+ headers: { Accept: "application/json" }
526
+ });
527
+ clearTimeout(timer);
528
+ if (!res.ok) {
529
+ throw new LokutorError("internal.error", `HTTP ${res.status} from ${url}`, {
530
+ detail: await res.text().catch(() => ""),
531
+ retryable: res.status >= 500
532
+ });
533
+ }
534
+ return await res.json();
535
+ } catch (err) {
536
+ clearTimeout(timer);
537
+ if (err instanceof LokutorError) throw err;
538
+ throw new LokutorError("internal.error", `Failed to fetch ${url}`, {
539
+ original: err,
540
+ retryable: true
541
+ });
542
+ }
543
+ }
422
544
  function base64ToUint8Array(base64) {
423
545
  const binaryString = atob(base64);
424
546
  const bytes = new Uint8Array(binaryString.length);
@@ -427,6 +549,39 @@ function base64ToUint8Array(base64) {
427
549
  }
428
550
  return bytes;
429
551
  }
552
+ function normalizeVisemes(payload) {
553
+ if (!Array.isArray(payload)) return [];
554
+ const normalized = [];
555
+ for (const item of payload) {
556
+ if (!item || typeof item !== "object") continue;
557
+ const c = String(item.c ?? item.char ?? "sil").toLowerCase();
558
+ const t = Number(item.t ?? item.timestamp ?? 0);
559
+ const v = Number(item.v ?? item.id ?? 0);
560
+ normalized.push({
561
+ v: Number.isFinite(v) ? v : 0,
562
+ c,
563
+ t: Number.isFinite(t) ? t : 0
564
+ });
565
+ }
566
+ return normalized;
567
+ }
568
+ function extractVisemePayload(msg) {
569
+ if (Array.isArray(msg?.data)) {
570
+ return normalizeVisemes(msg.data);
571
+ }
572
+ if (Array.isArray(msg?.data?.visemes)) {
573
+ return normalizeVisemes(msg.data.visemes);
574
+ }
575
+ if (msg?.data && !Array.isArray(msg.data) && typeof msg.data === "object") {
576
+ const singularInData = normalizeVisemes([msg.data]);
577
+ if (singularInData.length > 0) return singularInData;
578
+ }
579
+ if (msg && !Array.isArray(msg) && typeof msg === "object") {
580
+ const singularAtRoot = normalizeVisemes([msg]);
581
+ if (singularAtRoot.length > 0) return singularAtRoot;
582
+ }
583
+ return [];
584
+ }
430
585
  var VoiceAgentClient = class {
431
586
  ws = null;
432
587
  apiKey;
@@ -454,11 +609,13 @@ var VoiceAgentClient = class {
454
609
  reconnecting = false;
455
610
  reconnectAttempts = 0;
456
611
  maxReconnectAttempts = 5;
612
+ serverUrl;
457
613
  constructor(config) {
458
614
  this.apiKey = config.apiKey;
459
615
  this.prompt = config.prompt;
460
616
  this.voice = config.voice || "F1" /* F1 */;
461
617
  this.language = config.language || "en" /* ENGLISH */;
618
+ this.serverUrl = config.serverUrl || DEFAULT_URLS.VOICE_AGENT;
462
619
  this.onTranscription = config.onTranscription;
463
620
  this.onResponse = config.onResponse;
464
621
  this.onAudioCallback = config.onAudio;
@@ -486,13 +643,28 @@ var VoiceAgentClient = class {
486
643
  }
487
644
  }
488
645
  return new Promise((resolve, reject) => {
646
+ let settled = false;
647
+ const settle = (fn) => {
648
+ if (!settled) {
649
+ settled = true;
650
+ fn();
651
+ }
652
+ };
489
653
  try {
490
- let url = DEFAULT_URLS.VOICE_AGENT;
654
+ let url = this.serverUrl;
491
655
  if (this.apiKey) {
492
656
  const separator = url.includes("?") ? "&" : "?";
493
657
  url += `${separator}api_key=${this.apiKey}`;
494
658
  }
495
- console.log(`\u{1F517} Connecting to ${DEFAULT_URLS.VOICE_AGENT}...`);
659
+ const redactedUrl = url.replace(/api_key=[^&]+/, "api_key=***");
660
+ sdkTrace("ws.connect", {
661
+ endpoint: this.serverUrl,
662
+ url: redactedUrl,
663
+ enableAudio: this.enableAudio,
664
+ wantVisemes: this.wantVisemes,
665
+ hasAudioManager: Boolean(this.audioManager)
666
+ });
667
+ console.log(`\u{1F517} Connecting to ${this.serverUrl}...`);
496
668
  this.ws = new WebSocket(url);
497
669
  this.ws.binaryType = "arraybuffer";
498
670
  this.ws.onopen = async () => {
@@ -500,6 +672,7 @@ var VoiceAgentClient = class {
500
672
  this.reconnectAttempts = 0;
501
673
  this.reconnecting = false;
502
674
  console.log("\u2705 Connected to voice agent!");
675
+ sdkTrace("ws.open");
503
676
  this.sendConfig();
504
677
  if (this.audioManager) {
505
678
  await this.audioManager.startMicrophone((data) => {
@@ -508,22 +681,54 @@ var VoiceAgentClient = class {
508
681
  }
509
682
  });
510
683
  }
511
- resolve(true);
684
+ settle(() => resolve(true));
512
685
  };
513
686
  this.ws.onmessage = async (event) => {
514
687
  if (event.data instanceof ArrayBuffer) {
688
+ sdkTrace("ws.message.binary", { bytes: event.data.byteLength });
515
689
  this.handleBinaryMessage(new Uint8Array(event.data));
516
690
  } else {
691
+ sdkTrace("ws.message.text", { length: String(event.data).length });
517
692
  this.handleTextMessage(event.data.toString());
518
693
  }
519
694
  };
520
695
  this.ws.onerror = (err) => {
521
- console.error("\u274C WebSocket error:", err);
522
- if (this.onError) this.onError(err);
523
- if (!this.isConnected) reject(err);
696
+ const error = new LokutorError("ws.close", "WebSocket connection error", {
697
+ detail: `readyState=${this.ws?.readyState}, bufferedAmount=${this.ws?.bufferedAmount}`,
698
+ original: err,
699
+ retryable: true
700
+ });
701
+ console.error("\u274C WebSocket error:", error.message);
702
+ sdkTrace("ws.error", { code: error.code, message: error.message });
703
+ if (this.onError) this.onError(error);
704
+ if (!this.isConnected) {
705
+ settle(() => reject(error));
706
+ }
524
707
  };
525
- this.ws.onclose = () => {
708
+ this.ws.onclose = (event) => {
526
709
  this.isConnected = false;
710
+ const diagnostic = {
711
+ code: event.code,
712
+ reason: event.reason,
713
+ wasClean: event.wasClean,
714
+ url: this.serverUrl,
715
+ isUserDisconnect: this.isUserDisconnect,
716
+ reconnectAttempts: this.reconnectAttempts
717
+ };
718
+ sdkTrace("ws.close", diagnostic);
719
+ if (!settled && !this.isUserDisconnect) {
720
+ const error = new LokutorError("ws.close", `WebSocket closed unexpectedly (code ${event.code})`, {
721
+ detail: event.reason || "No reason provided",
722
+ retryable: event.code !== 1008
723
+ });
724
+ settle(() => reject(error));
725
+ return;
726
+ }
727
+ if (!event.wasClean && event.code === 1006 && this.reconnectAttempts === 0 && !this.isUserDisconnect) {
728
+ console.error("\u274C Connection rejected (code 1006). Likely causes: invalid API key, endpoint unavailable, or CORS blocked.");
729
+ console.error(" URL:", this.serverUrl.replace(/api_key=[^&]+/, "api_key=***"));
730
+ }
731
+ console.log(`\u{1F50C} WebSocket closed \u2014 code: ${event.code}, reason: "${event.reason || "none"}", clean: ${event.wasClean}`);
527
732
  if (!this.isUserDisconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
528
733
  this.reconnecting = true;
529
734
  this.reconnectAttempts++;
@@ -539,37 +744,27 @@ var VoiceAgentClient = class {
539
744
  }
540
745
  };
541
746
  } catch (err) {
542
- if (this.onError) this.onError(err);
543
- reject(err);
747
+ const error = err instanceof LokutorError ? err : new LokutorError("internal.error", "Failed to create WebSocket connection", { original: err });
748
+ if (this.onError) this.onError(error);
749
+ settle(() => reject(error));
544
750
  }
545
751
  });
546
752
  }
547
753
  /**
548
754
  * The "Golden Path" - Starts a managed session with hardware handled automatically.
549
- * This is the recommended way to start a conversation in both Browser and Node.js.
755
+ * This is the recommended way to start a conversation in browser environments.
550
756
  */
551
757
  async startManaged(config) {
552
758
  this.enableAudio = true;
553
759
  if (config?.audioManager) {
554
760
  this.audioManager = config.audioManager;
555
761
  } else if (!this.audioManager) {
556
- if (typeof window !== "undefined") {
557
- this.audioManager = new BrowserAudioManager();
558
- } else {
559
- try {
560
- const { NodeAudioManager } = await import("./node-audio-5HOWE6MC.mjs");
561
- this.audioManager = new NodeAudioManager();
562
- } catch (e) {
563
- console.error('\u274C Failed to load NodeAudioManager. Please ensure "speaker" and "node-record-lpcm16" are installed.');
564
- }
762
+ if (typeof window === "undefined") {
763
+ throw new LokutorError("internal.error", "startManaged() requires a browser environment. Pass a custom audioManager for non-browser runtimes.", { retryable: false });
565
764
  }
765
+ this.audioManager = new BrowserAudioManager();
566
766
  }
567
767
  await this.connect();
568
- if (this.audioManager && this.isConnected) {
569
- await this.audioManager.startMicrophone((data) => {
570
- this.sendAudio(data);
571
- });
572
- }
573
768
  return this;
574
769
  }
575
770
  /**
@@ -577,10 +772,18 @@ var VoiceAgentClient = class {
577
772
  */
578
773
  sendConfig() {
579
774
  if (!this.ws || !this.isConnected) return;
580
- this.ws.send(JSON.stringify({ type: "prompt", data: this.prompt }));
775
+ this.ws.send(JSON.stringify({ type: "visemes", data: this.wantVisemes }));
581
776
  this.ws.send(JSON.stringify({ type: "voice", data: this.voice }));
582
777
  this.ws.send(JSON.stringify({ type: "language", data: this.language }));
583
- this.ws.send(JSON.stringify({ type: "visemes", data: this.wantVisemes }));
778
+ this.ws.send(JSON.stringify({ type: "prompt", data: this.prompt }));
779
+ this.ws.send(JSON.stringify({ type: "rates", playback: 44100, input: 16e3 }));
780
+ sdkTrace("ws.send.config", {
781
+ promptLen: this.prompt?.length || 0,
782
+ voice: this.voice,
783
+ language: this.language,
784
+ visemes: this.wantVisemes,
785
+ tools: this.tools?.length || 0
786
+ });
584
787
  if (this.tools && this.tools.length > 0) {
585
788
  this.ws.send(JSON.stringify({ type: "tools", data: this.tools }));
586
789
  }
@@ -614,6 +817,15 @@ var VoiceAgentClient = class {
614
817
  handleTextMessage(text) {
615
818
  try {
616
819
  const msg = JSON.parse(text);
820
+ if (!msg || typeof msg !== "object") {
821
+ return;
822
+ }
823
+ sdkTrace("ws.recv.type", {
824
+ type: msg.type,
825
+ hasData: Object.prototype.hasOwnProperty.call(msg, "data"),
826
+ dataKind: Array.isArray(msg.data) ? "array" : typeof msg.data,
827
+ generation: msg.generation ?? null
828
+ });
617
829
  switch (msg.type) {
618
830
  case "audio":
619
831
  if (msg.data) {
@@ -626,7 +838,7 @@ var VoiceAgentClient = class {
626
838
  this.messages.push({
627
839
  role,
628
840
  text: msg.data,
629
- timestamp: Date.now()
841
+ timestamp: nowMs()
630
842
  });
631
843
  if (msg.role === "user") {
632
844
  if (this.onTranscription) this.onTranscription(msg.data);
@@ -658,19 +870,44 @@ var VoiceAgentClient = class {
658
870
  console.log(`${icons[msg.data] || ""} Status: ${msg.data}`);
659
871
  break;
660
872
  case "visemes":
661
- if (Array.isArray(msg.data) && msg.data.length > 0) {
662
- this.emit("visemes", msg.data);
873
+ case "viseme": {
874
+ const msgGen = msg.generation ?? this.currentGeneration;
875
+ if (msgGen < this.currentGeneration) {
876
+ sdkTrace("visemes.discard", { msgGen, currentGen: this.currentGeneration });
877
+ break;
878
+ }
879
+ const normalized = extractVisemePayload(msg);
880
+ const explicitlyEmptyArray = Array.isArray(msg?.data) || Array.isArray(msg?.data?.visemes);
881
+ sdkTrace("visemes.recv", {
882
+ rawType: msg.type,
883
+ normalizedCount: normalized.length,
884
+ first: normalized[0] ?? null
885
+ });
886
+ if (normalized.length > 0 || explicitlyEmptyArray) {
887
+ this.emit("visemes", normalized);
663
888
  }
664
889
  break;
665
- case "error":
666
- if (this.onError) this.onError(msg.data);
667
- console.error(`\u274C Server error: ${msg.data}`);
890
+ }
891
+ case "error": {
892
+ const backendCode = msg.data?.code ?? "internal.error";
893
+ const backendMessage = msg.data?.message ?? msg.data ?? "Unknown server error";
894
+ const backendDetail = msg.data?.detail;
895
+ const backendRetryable = msg.data?.retryable ?? true;
896
+ const error = new LokutorError(backendCode, backendMessage, {
897
+ detail: backendDetail,
898
+ retryable: backendRetryable
899
+ });
900
+ if (this.onError) this.onError(error);
901
+ console.error(`\u274C Server error: [${error.code}] ${error.message}`);
668
902
  break;
903
+ }
669
904
  case "tool_call":
670
905
  console.log(`\u{1F6E0}\uFE0F Tool Call: ${msg.name}(${msg.arguments})`);
671
906
  break;
672
907
  }
673
908
  } catch (e) {
909
+ sdkTrace("ws.recv.parse_error", { preview: text?.slice(0, 120) });
910
+ console.debug("Failed to parse message:", e);
674
911
  }
675
912
  }
676
913
  /**
@@ -732,6 +969,21 @@ var VoiceAgentClient = class {
732
969
  this.audioManager.cleanup();
733
970
  }
734
971
  this.isConnected = false;
972
+ this.reconnecting = false;
973
+ this.reconnectAttempts = 0;
974
+ }
975
+ /**
976
+ * Returns true if the client is currently connected.
977
+ */
978
+ get connected() {
979
+ return this.isConnected;
980
+ }
981
+ /**
982
+ * Returns the current generation counter.
983
+ * Useful for correlating audio/viseme chunks with utterances.
984
+ */
985
+ get generation() {
986
+ return this.currentGeneration;
735
987
  }
736
988
  /**
737
989
  * Toggles the microphone mute state (if managed by client)
@@ -754,17 +1006,70 @@ var VoiceAgentClient = class {
754
1006
  }
755
1007
  return 0;
756
1008
  }
1009
+ /**
1010
+ * Fetch available voice styles from the server.
1011
+ * No authentication required.
1012
+ */
1013
+ static async fetchVoices(baseUrl) {
1014
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1015
+ const data = await fetchJson(`${url}/voices`);
1016
+ return data.voices || [];
1017
+ }
1018
+ /**
1019
+ * Fetch supported languages from the server.
1020
+ * No authentication required.
1021
+ */
1022
+ static async fetchLanguages(baseUrl) {
1023
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1024
+ const data = await fetchJson(`${url}/languages`);
1025
+ return data.languages || [];
1026
+ }
1027
+ /**
1028
+ * Fetch loaded TTS model versions from the server.
1029
+ * No authentication required.
1030
+ */
1031
+ static async fetchModels(baseUrl) {
1032
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1033
+ const data = await fetchJson(`${url}/models`);
1034
+ return data.models || [];
1035
+ }
1036
+ /**
1037
+ * Fetch server configuration (limits and defaults).
1038
+ * No authentication required.
1039
+ */
1040
+ static async fetchConfig(baseUrl) {
1041
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1042
+ return fetchJson(`${url}/config`);
1043
+ }
1044
+ /**
1045
+ * Fetch rich runtime status from the server.
1046
+ * No authentication required.
1047
+ */
1048
+ static async fetchStatus(baseUrl) {
1049
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1050
+ return fetchJson(`${url}/status`);
1051
+ }
1052
+ /**
1053
+ * Fetch health/liveness status from the server.
1054
+ * No authentication required.
1055
+ */
1056
+ static async fetchHealth(baseUrl) {
1057
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1058
+ return fetchJson(`${url}/health`);
1059
+ }
757
1060
  /**
758
1061
  * Update the system prompt mid-conversation
759
1062
  */
760
1063
  updatePrompt(newPrompt) {
761
1064
  this.prompt = newPrompt;
762
- if (this.ws && this.isConnected) {
1065
+ if (this.ws && this.ws.readyState === WebSocket.OPEN && this.isConnected) {
763
1066
  try {
764
1067
  this.ws.send(JSON.stringify({ type: "prompt", data: newPrompt }));
765
1068
  console.log(`\u2699\uFE0F Updated prompt: ${newPrompt.substring(0, 50)}...`);
766
1069
  } catch (error) {
767
- console.error("Error updating prompt:", error);
1070
+ const err = new LokutorError("internal.error", "Failed to update prompt", { original: error });
1071
+ if (this.onError) this.onError(err);
1072
+ console.error("Error updating prompt:", err.message);
768
1073
  }
769
1074
  } else {
770
1075
  console.warn("Not connected - prompt will be updated on next connection");
@@ -827,37 +1132,52 @@ var TTSClient = class {
827
1132
  visemes: options.visemes || false
828
1133
  };
829
1134
  ws.send(JSON.stringify(req));
830
- startTime = Date.now();
1135
+ startTime = nowMs();
831
1136
  };
832
1137
  ws.onmessage = async (event) => {
833
1138
  refreshTimeout();
834
1139
  if (event.data instanceof ArrayBuffer) {
835
1140
  if (!firstByteReceived) {
836
- const ttfb = Date.now() - startTime;
1141
+ const ttfb = nowMs() - startTime;
837
1142
  if (options.onTTFB) options.onTTFB(ttfb);
838
1143
  firstByteReceived = true;
839
1144
  }
840
1145
  if (options.onAudio) options.onAudio(new Uint8Array(event.data));
841
- } else {
842
- const text = event.data.toString();
843
- if (text === "EOS") {
1146
+ return;
1147
+ }
1148
+ const text = event.data.toString();
1149
+ if (text === "EOS") {
1150
+ if (activityTimeout) clearTimeout(activityTimeout);
1151
+ ws.close();
1152
+ resolve();
1153
+ return;
1154
+ }
1155
+ try {
1156
+ const msg = JSON.parse(text);
1157
+ if (msg.type === "audio" && msg.data) {
1158
+ const audioBuffer = base64ToUint8Array(msg.data);
1159
+ if (!firstByteReceived) {
1160
+ const ttfb = nowMs() - startTime;
1161
+ if (options.onTTFB) options.onTTFB(ttfb);
1162
+ firstByteReceived = true;
1163
+ }
1164
+ if (options.onAudio) options.onAudio(audioBuffer);
1165
+ return;
1166
+ }
1167
+ if (msg.type === "visemes" && Array.isArray(msg.data) && options.onVisemes) {
1168
+ options.onVisemes(normalizeVisemes(msg.data));
1169
+ return;
1170
+ }
1171
+ if (Array.isArray(msg) && options.onVisemes) {
1172
+ options.onVisemes(normalizeVisemes(msg));
1173
+ return;
1174
+ }
1175
+ if (msg.type === "eos") {
844
1176
  if (activityTimeout) clearTimeout(activityTimeout);
845
1177
  ws.close();
846
1178
  resolve();
847
- return;
848
- }
849
- try {
850
- const msg = JSON.parse(text);
851
- if (Array.isArray(msg) && options.onVisemes) {
852
- options.onVisemes(msg);
853
- }
854
- if (msg.type === "eos") {
855
- if (activityTimeout) clearTimeout(activityTimeout);
856
- ws.close();
857
- resolve();
858
- }
859
- } catch (e) {
860
1179
  }
1180
+ } catch (e) {
861
1181
  }
862
1182
  };
863
1183
  ws.onerror = (err) => {
@@ -886,11 +1206,587 @@ async function simpleTTS(options) {
886
1206
  const client = new TTSClient({ apiKey: options.apiKey });
887
1207
  return client.synthesize(options);
888
1208
  }
1209
+
1210
+ // src/conversational-panel.ts
1211
+ var PANEL_CSS = (
1212
+ /*css*/
1213
+ `
1214
+ .cv-panel {
1215
+ position: relative;
1216
+ width: 100%;
1217
+ aspect-ratio: 16 / 9;
1218
+ max-height: 800px;
1219
+ display: flex;
1220
+ flex-direction: column;
1221
+ align-items: center;
1222
+ justify-content: center;
1223
+ padding: 2rem 0;
1224
+ margin: 0;
1225
+ background: var(--cv-bg, #0a0a0a);
1226
+ box-shadow: inset 0 10px 40px rgba(0, 0, 0, 0.1), inset 0 0 100px rgba(0, 0, 0, 0.05);
1227
+ border-radius: 40px;
1228
+ overflow: hidden;
1229
+ container-type: inline-size;
1230
+ }
1231
+ .cv-curtain {
1232
+ position: absolute;
1233
+ inset: 0;
1234
+ display: flex;
1235
+ flex-direction: column;
1236
+ align-items: center;
1237
+ justify-content: center;
1238
+ background: var(--cv-bg, #0a0a0a);
1239
+ z-index: 100;
1240
+ transition: transform 1s cubic-bezier(0.16, 1, 0.3, 1);
1241
+ }
1242
+ .cv-curtain .cv-curtain-bg {
1243
+ position: absolute;
1244
+ inset: 0;
1245
+ background: url('/background_gradient.jpeg') center / cover no-repeat;
1246
+ z-index: -1;
1247
+ }
1248
+ .cv-curtain .cv-curtain-overlay {
1249
+ position: absolute;
1250
+ inset: 0;
1251
+ background: var(--cv-accent);
1252
+ opacity: 0.35;
1253
+ z-index: -1;
1254
+ }
1255
+ .cv-curtain.is-up { transform: translateY(-100%); }
1256
+ .cv-curtain-content {
1257
+ display: flex;
1258
+ flex-direction: column;
1259
+ align-items: center;
1260
+ gap: clamp(0.75rem, 1.5cqi, 1.5rem);
1261
+ color: #fff;
1262
+ text-align: center;
1263
+ z-index: 2;
1264
+ padding: clamp(1rem, 2cqi, 2rem);
1265
+ }
1266
+ .cv-curtain-title {
1267
+ font-size: clamp(1.2rem, 4cqi, 2.5rem);
1268
+ font-weight: 800;
1269
+ letter-spacing: -0.03em;
1270
+ margin: 0;
1271
+ text-shadow: 0 4px 20px rgba(0,0,0,0.3);
1272
+ }
1273
+ .cv-curtain-desc {
1274
+ font-size: clamp(0.75rem, 2cqi, 1.1rem);
1275
+ opacity: 0.8;
1276
+ max-width: 400px;
1277
+ margin: 0;
1278
+ line-height: 1.5;
1279
+ }
1280
+ .cv-curtain-btn {
1281
+ margin-top: 1rem;
1282
+ padding: clamp(0.6rem, 1.5cqi, 1rem) clamp(1.5rem, 3cqi, 2.5rem);
1283
+ border-radius: 100px;
1284
+ background: #fff;
1285
+ color: #000;
1286
+ border: none;
1287
+ font-weight: 700;
1288
+ font-size: clamp(0.8rem, 1.5cqi, 1rem);
1289
+ display: flex;
1290
+ align-items: center;
1291
+ gap: 0.75rem;
1292
+ cursor: pointer;
1293
+ transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
1294
+ box-shadow: 0 10px 30px rgba(0,0,0,0.2);
1295
+ }
1296
+ .cv-curtain-btn:hover {
1297
+ transform: scale(1.05);
1298
+ background: #f0f0f0;
1299
+ box-shadow: 0 15px 40px rgba(0,0,0,0.3);
1300
+ }
1301
+ .cv-curtain-btn svg { transition: transform 0.3s ease; }
1302
+ .cv-curtain-btn:hover svg { transform: translateX(4px); }
1303
+ .cv-header {
1304
+ width: 100%;
1305
+ display: flex;
1306
+ flex-direction: column;
1307
+ align-items: center;
1308
+ gap: 0.25rem;
1309
+ z-index: 20;
1310
+ margin-bottom: auto;
1311
+ }
1312
+ .cv-title {
1313
+ font-size: clamp(1rem, 2.5cqi, 1.75rem);
1314
+ font-weight: 700;
1315
+ color: #e0e0e0;
1316
+ display: flex;
1317
+ align-items: center;
1318
+ gap: 1rem;
1319
+ letter-spacing: -0.02em;
1320
+ }
1321
+ .cv-title .cv-timer {
1322
+ font-variant-numeric: tabular-nums;
1323
+ color: var(--cv-accent);
1324
+ font-weight: 400;
1325
+ opacity: 0.8;
1326
+ }
1327
+ .cv-visualizer-wrap {
1328
+ position: absolute;
1329
+ top: 50%;
1330
+ left: 50%;
1331
+ transform: translate(-50%, -50%);
1332
+ width: clamp(140px, 40cqi, 280px);
1333
+ height: clamp(140px, 40cqi, 280px);
1334
+ display: flex;
1335
+ align-items: center;
1336
+ justify-content: center;
1337
+ z-index: 10;
1338
+ transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
1339
+ }
1340
+ .cv-is-speaking .cv-visualizer-wrap {
1341
+ animation: cv-pulse 2.5s infinite ease-in-out;
1342
+ }
1343
+ .cv-is-thinking .cv-visualizer-wrap {
1344
+ opacity: 0.5;
1345
+ transform: translate(-50%, -50%) scale(0.9);
1346
+ }
1347
+ @keyframes cv-pulse {
1348
+ 0%, 100% { transform: translate(-50%, -50%) scale(1); }
1349
+ 50% { transform: translate(-50%, -50%) scale(1.05); }
1350
+ }
1351
+ .cv-canvas {
1352
+ width: 100% !important;
1353
+ height: 100% !important;
1354
+ position: relative;
1355
+ z-index: 0;
1356
+ }
1357
+ .cv-canvas {
1358
+ width: 100% !important;
1359
+ height: 100% !important;
1360
+ position: relative;
1361
+ z-index: 0;
1362
+ }
1363
+ .cv-controls {
1364
+ width: 100%;
1365
+ display: flex;
1366
+ flex-direction: column;
1367
+ align-items: center;
1368
+ gap: 1.5rem;
1369
+ padding-top: 1rem;
1370
+ margin-top: auto;
1371
+ z-index: 20;
1372
+ }
1373
+ .cv-pill {
1374
+ display: flex;
1375
+ align-items: center;
1376
+ gap: 0.75rem;
1377
+ background: rgba(255, 255, 255, 0.03);
1378
+ backdrop-filter: blur(30px);
1379
+ -webkit-backdrop-filter: blur(30px);
1380
+ border: 1px solid rgba(255, 255, 255, 0.08);
1381
+ padding: 0.3rem 0.75rem;
1382
+ border-radius: 100px;
1383
+ box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
1384
+ }
1385
+ .cv-btn {
1386
+ display: flex;
1387
+ align-items: center;
1388
+ gap: 0.5rem;
1389
+ background: transparent;
1390
+ border: none;
1391
+ color: rgba(255,255,255,0.55);
1392
+ font-weight: 600;
1393
+ font-size: 0.75rem;
1394
+ cursor: pointer;
1395
+ transition: all 0.2s ease;
1396
+ padding: 0.35rem 0.75rem;
1397
+ border-radius: 50px;
1398
+ }
1399
+ .cv-btn:hover { color: #fff; background: rgba(255,255,255,0.05); }
1400
+ .cv-btn--end { color: #ff4444; }
1401
+ .cv-btn--end .cv-btn-box {
1402
+ background: #ff4444;
1403
+ width: 8px;
1404
+ height: 8px;
1405
+ border-radius: 2px;
1406
+ }
1407
+ .cv-btn.is-muted { color: var(--cv-accent); }
1408
+ .cv-error {
1409
+ position: absolute;
1410
+ bottom: 1.25rem;
1411
+ left: 50%;
1412
+ transform: translateX(-50%);
1413
+ background: #0a0a0a;
1414
+ color: #e0e0e0;
1415
+ padding: 0.75rem 1.5rem;
1416
+ border-radius: 12px;
1417
+ font-size: 0.875rem;
1418
+ font-weight: 500;
1419
+ display: none;
1420
+ align-items: center;
1421
+ gap: 0.75rem;
1422
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
1423
+ z-index: 1000;
1424
+ border: 1px solid rgba(255, 255, 255, 0.06);
1425
+ backdrop-filter: blur(20px);
1426
+ }
1427
+ .cv-error.is-visible { display: flex; }
1428
+ .cv-error-icon { color: var(--cv-accent); flex-shrink: 0; }
1429
+ `
1430
+ );
1431
+ var styleInjected = false;
1432
+ function injectStyles() {
1433
+ if (styleInjected) return;
1434
+ const style = document.createElement("style");
1435
+ style.textContent = PANEL_CSS;
1436
+ document.head.appendChild(style);
1437
+ styleInjected = true;
1438
+ }
1439
+ var ConversationalPanel = class {
1440
+ cfg;
1441
+ container;
1442
+ agent = null;
1443
+ visualizer = null;
1444
+ timerTicker = null;
1445
+ connectingSound = null;
1446
+ connectingFadeTimer = null;
1447
+ isRunning = false;
1448
+ // Cached DOM refs
1449
+ el;
1450
+ curtain;
1451
+ curtainTitle;
1452
+ curtainDesc;
1453
+ startBtn;
1454
+ errorEl;
1455
+ errorText;
1456
+ timerEl;
1457
+ canvas;
1458
+ muteBtn;
1459
+ stopBtn;
1460
+ visualizerWrap;
1461
+ // Callbacks
1462
+ onTranscription;
1463
+ onResponse;
1464
+ onStart;
1465
+ onStop;
1466
+ onError;
1467
+ constructor(cfg) {
1468
+ this.cfg = cfg;
1469
+ this.container = cfg.container;
1470
+ injectStyles();
1471
+ this.buildDOM();
1472
+ }
1473
+ buildDOM() {
1474
+ const accent = this.cfg.accentColor || "#a25a6b";
1475
+ const bg = this.cfg.backgroundColor || "#0a0a0a";
1476
+ this.container.style.setProperty("--cv-accent", accent);
1477
+ this.container.style.setProperty("--cv-bg", bg);
1478
+ this.el = document.createElement("div");
1479
+ this.el.className = "cv-panel";
1480
+ this.el.innerHTML = `
1481
+ <div class="cv-curtain">
1482
+ <div class="cv-curtain-bg"></div>
1483
+ <div class="cv-curtain-overlay"></div>
1484
+ <div class="cv-curtain-content">
1485
+ <h3 class="cv-curtain-title">${this.esc(this.cfg.title)}</h3>
1486
+ <p class="cv-curtain-desc">${this.esc(this.cfg.description)}</p>
1487
+ <button class="cv-curtain-btn">
1488
+ <span>Start Conversation</span>
1489
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="20" height="20">
1490
+ <path d="M5 12h14M12 5l7 7-7 7"/>
1491
+ </svg>
1492
+ </button>
1493
+ </div>
1494
+ <div class="cv-error">
1495
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
1496
+ width="20" height="20" class="cv-error-icon">
1497
+ <circle cx="12" cy="12" r="10"></circle>
1498
+ <path d="M12 8v4"></path>
1499
+ <path d="M12 16h.01"></path>
1500
+ </svg>
1501
+ <span class="cv-error-text"></span>
1502
+ </div>
1503
+ </div>
1504
+ <div class="cv-header">
1505
+ <h2 class="cv-title">${this.esc(this.cfg.title)} <span class="cv-timer">00:00</span></h2>
1506
+ </div>
1507
+ <div class="cv-visualizer-wrap">
1508
+ <canvas class="cv-canvas"></canvas>
1509
+ </div>
1510
+ <div class="cv-controls">
1511
+ <div class="cv-pill">
1512
+ <button class="cv-btn cv-btn--mute">
1513
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="18" height="18">
1514
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
1515
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
1516
+ </svg>
1517
+ <span>Mute</span>
1518
+ </button>
1519
+ <button class="cv-btn cv-btn--end">
1520
+ <div class="cv-btn-box"></div>
1521
+ <span>End call</span>
1522
+ </button>
1523
+ </div>
1524
+ </div>
1525
+ `;
1526
+ this.container.appendChild(this.el);
1527
+ this.curtain = this.el.querySelector(".cv-curtain");
1528
+ if (this.cfg.curtainBgSrc) {
1529
+ this.curtain.style.backgroundImage = `url('${this.cfg.curtainBgSrc}')`;
1530
+ }
1531
+ this.curtainTitle = this.el.querySelector(".cv-curtain-title");
1532
+ this.curtainDesc = this.el.querySelector(".cv-curtain-desc");
1533
+ this.startBtn = this.el.querySelector(".cv-curtain-btn");
1534
+ this.errorEl = this.el.querySelector(".cv-error");
1535
+ this.errorText = this.el.querySelector(".cv-error-text");
1536
+ this.timerEl = this.el.querySelector(".cv-timer");
1537
+ this.canvas = this.el.querySelector(".cv-canvas");
1538
+ this.muteBtn = this.el.querySelector(".cv-btn--mute");
1539
+ this.stopBtn = this.el.querySelector(".cv-btn--end");
1540
+ this.visualizerWrap = this.el.querySelector(".cv-visualizer-wrap");
1541
+ this.startBtn.addEventListener("click", () => this.start());
1542
+ this.stopBtn.addEventListener("click", () => this.stop());
1543
+ this.muteBtn.addEventListener("click", () => this.toggleMute());
1544
+ }
1545
+ esc(s) {
1546
+ const d = document.createElement("div");
1547
+ d.textContent = s;
1548
+ return d.innerHTML;
1549
+ }
1550
+ /** Start the conversation (called when user clicks "Start Conversation" or externally) */
1551
+ async start() {
1552
+ if (this.isRunning) return;
1553
+ this.isRunning = true;
1554
+ this.startBtn.disabled = true;
1555
+ this.errorEl.classList.remove("is-visible");
1556
+ this.playConnectingSound();
1557
+ try {
1558
+ const ConvoAgent = this.cfg.ConvoAgent;
1559
+ const SphereVisualizer = this.cfg.SphereVisualizer;
1560
+ const visualizer = new SphereVisualizer(this.canvas);
1561
+ visualizer.resize(280, 280);
1562
+ this.visualizer = visualizer;
1563
+ if (this.cfg.accentColor && typeof visualizer.setAccentColor === "function") {
1564
+ const hex = this.cfg.accentColor;
1565
+ visualizer.setAccentColor(
1566
+ parseInt(hex.slice(1, 3), 16) / 255,
1567
+ parseInt(hex.slice(3, 5), 16) / 255,
1568
+ parseInt(hex.slice(5, 7), 16) / 255
1569
+ );
1570
+ }
1571
+ const agentHandle = new ConvoAgent({
1572
+ apiKey: this.cfg.apiKey,
1573
+ prompt: this.cfg.prompt,
1574
+ voice: this.cfg.voice || "M1",
1575
+ language: this.cfg.language || "en",
1576
+ onStatusChange: (status) => {
1577
+ this.el.classList.remove("cv-is-speaking", "cv-is-thinking");
1578
+ if (status === "speaking") this.el.classList.add("cv-is-speaking");
1579
+ else if (status === "thinking") this.el.classList.add("cv-is-thinking");
1580
+ },
1581
+ onTranscription: (text) => {
1582
+ this.onTranscription?.(text);
1583
+ },
1584
+ onResponse: (text) => {
1585
+ this.onResponse?.(text);
1586
+ },
1587
+ onError: (err) => {
1588
+ if (!this.isRunning) return;
1589
+ this.onError?.(err);
1590
+ this.stop();
1591
+ this.showError("Connection issue. Try again in a moment.");
1592
+ }
1593
+ });
1594
+ this.agent = agentHandle;
1595
+ visualizer.setAudioClient(agentHandle);
1596
+ this.curtain.classList.add("is-up");
1597
+ try {
1598
+ agentHandle.unlockAudioForMobile?.();
1599
+ } catch (_) {
1600
+ }
1601
+ const ok = await agentHandle.connect();
1602
+ if (ok) {
1603
+ this.fadeOutConnectingSound();
1604
+ const wrap = this.visualizerWrap;
1605
+ if (wrap) {
1606
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
1607
+ const w = Math.floor(wrap.clientWidth * dpr);
1608
+ const h = Math.floor(wrap.clientHeight * dpr);
1609
+ visualizer.resize(w, h);
1610
+ }
1611
+ visualizer.start();
1612
+ this.startTimer();
1613
+ this.onStart?.();
1614
+ if (window.lucide) window.lucide.createIcons();
1615
+ } else {
1616
+ this.fadeOutConnectingSound();
1617
+ this.playErrorTone();
1618
+ this.curtain.classList.remove("is-up");
1619
+ this.showError("Could not connect. Please try again.");
1620
+ this.isRunning = false;
1621
+ }
1622
+ } catch (err) {
1623
+ this.fadeOutConnectingSound();
1624
+ this.playErrorTone();
1625
+ console.error("ConversationalPanel start error:", err);
1626
+ this.stop();
1627
+ this.showError("Something went wrong. Please try again.");
1628
+ } finally {
1629
+ this.startBtn.disabled = false;
1630
+ }
1631
+ }
1632
+ /** Stop / disconnect the conversation */
1633
+ stop() {
1634
+ if (!this.isRunning && !this.agent && !this.visualizer) return;
1635
+ this.isRunning = false;
1636
+ this.fadeOutConnectingSound();
1637
+ this.errorEl.classList.remove("is-visible");
1638
+ if (this.agent) {
1639
+ try {
1640
+ this.agent.disconnect();
1641
+ } catch (_) {
1642
+ }
1643
+ this.agent = null;
1644
+ }
1645
+ if (this.timerTicker) {
1646
+ clearInterval(this.timerTicker);
1647
+ this.timerTicker = null;
1648
+ }
1649
+ if (this.visualizer) {
1650
+ this.visualizer.stop();
1651
+ this.visualizer = null;
1652
+ }
1653
+ this.curtain.classList.remove("is-up");
1654
+ this.el.classList.remove("cv-is-speaking", "cv-is-thinking");
1655
+ this.muteBtn.classList.remove("is-muted");
1656
+ const span = this.muteBtn.querySelector("span");
1657
+ if (span) span.textContent = "Mute";
1658
+ this.onStop?.();
1659
+ this.startBtn.disabled = false;
1660
+ }
1661
+ /** Update accent color dynamically */
1662
+ setColor(color) {
1663
+ this.cfg.accentColor = color;
1664
+ this.container.style.setProperty("--cv-accent", color);
1665
+ if (this.visualizer && typeof this.visualizer.setAccentColor === "function") {
1666
+ const r = parseInt(color.slice(1, 3), 16) / 255;
1667
+ const g = parseInt(color.slice(3, 5), 16) / 255;
1668
+ const b = parseInt(color.slice(5, 7), 16) / 255;
1669
+ this.visualizer.setAccentColor(r, g, b);
1670
+ }
1671
+ }
1672
+ /** Update title text */
1673
+ setTitle(title) {
1674
+ this.cfg.title = title;
1675
+ this.curtainTitle.textContent = title;
1676
+ const h2 = this.el.querySelector(".cv-title");
1677
+ if (h2) h2.innerHTML = `${this.esc(title)} <span class="cv-timer">${this.timerEl?.textContent || "00:00"}</span>`;
1678
+ }
1679
+ /** Update description text */
1680
+ setDescription(desc) {
1681
+ this.cfg.description = desc;
1682
+ this.curtainDesc.textContent = desc;
1683
+ }
1684
+ /** Update prompt (only takes effect on next start()) */
1685
+ setPrompt(prompt) {
1686
+ this.cfg.prompt = prompt;
1687
+ }
1688
+ /** Show an error message */
1689
+ showError(text) {
1690
+ this.errorText.textContent = text;
1691
+ this.errorEl.classList.add("is-visible");
1692
+ setTimeout(() => {
1693
+ this.errorEl.classList.remove("is-visible");
1694
+ }, 5e3);
1695
+ }
1696
+ /** Destroy the component, removing all DOM and stopping any active session */
1697
+ destroy() {
1698
+ this.stop();
1699
+ this.el.remove();
1700
+ }
1701
+ // ─── internal helpers ────────────────────────────────────
1702
+ startTimer() {
1703
+ let elapsed = 0;
1704
+ this.timerEl.textContent = "00:00";
1705
+ this.timerTicker = window.setInterval(() => {
1706
+ elapsed++;
1707
+ const m = Math.floor(elapsed / 60).toString().padStart(2, "0");
1708
+ const s = (elapsed % 60).toString().padStart(2, "0");
1709
+ this.timerEl.textContent = `${m}:${s}`;
1710
+ }, 1e3);
1711
+ }
1712
+ toggleMute() {
1713
+ if (!this.agent) return;
1714
+ try {
1715
+ const muted = this.agent.toggleMute();
1716
+ this.muteBtn.classList.toggle("is-muted", muted);
1717
+ const span = this.muteBtn.querySelector("span");
1718
+ if (span) span.textContent = muted ? "Unmute" : "Mute";
1719
+ if (muted) {
1720
+ this.agent.pauseAudio?.();
1721
+ this.visualizer?.setDeactivated?.(true);
1722
+ } else {
1723
+ this.agent.resumeAudio?.();
1724
+ this.visualizer?.setDeactivated?.(false);
1725
+ }
1726
+ } catch (_) {
1727
+ }
1728
+ }
1729
+ playConnectingSound() {
1730
+ this.fadeOutConnectingSound();
1731
+ const src = this.cfg.connectingSoundSrc || "/connecting.mp3";
1732
+ this.connectingSound = new Audio(src);
1733
+ this.connectingSound.volume = 0.6;
1734
+ this.connectingSound.play().catch(() => {
1735
+ });
1736
+ }
1737
+ fadeOutConnectingSound() {
1738
+ if (this.connectingFadeTimer) {
1739
+ clearInterval(this.connectingFadeTimer);
1740
+ this.connectingFadeTimer = null;
1741
+ }
1742
+ if (!this.connectingSound) return;
1743
+ const startVol = this.connectingSound.volume;
1744
+ const steps = 15;
1745
+ let step = 0;
1746
+ this.connectingFadeTimer = window.setInterval(() => {
1747
+ step++;
1748
+ const progress = step / steps;
1749
+ if (this.connectingSound) this.connectingSound.volume = startVol * Math.max(0, 1 - progress);
1750
+ if (step >= steps) {
1751
+ if (this.connectingFadeTimer) {
1752
+ clearInterval(this.connectingFadeTimer);
1753
+ this.connectingFadeTimer = null;
1754
+ }
1755
+ if (this.connectingSound) {
1756
+ this.connectingSound.pause();
1757
+ this.connectingSound.currentTime = 0;
1758
+ this.connectingSound = null;
1759
+ }
1760
+ }
1761
+ }, 100);
1762
+ }
1763
+ playErrorTone() {
1764
+ try {
1765
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
1766
+ const now = ctx.currentTime;
1767
+ [0, 0.3].forEach((offset, i) => {
1768
+ const osc = ctx.createOscillator();
1769
+ const gain = ctx.createGain();
1770
+ osc.connect(gain);
1771
+ gain.connect(ctx.destination);
1772
+ osc.type = "sine";
1773
+ osc.frequency.setValueAtTime(600 - i * 200, now + offset);
1774
+ gain.gain.setValueAtTime(0.25, now + offset);
1775
+ gain.gain.exponentialRampToValueAtTime(1e-3, now + offset + 0.35);
1776
+ osc.start(now + offset);
1777
+ osc.stop(now + offset + 0.4);
1778
+ });
1779
+ } catch (_) {
1780
+ }
1781
+ }
1782
+ };
889
1783
  export {
890
1784
  AUDIO_CONFIG,
891
1785
  BrowserAudioManager,
1786
+ ConversationalPanel,
892
1787
  DEFAULT_URLS,
893
1788
  Language,
1789
+ LokutorError,
894
1790
  StreamResampler,
895
1791
  TTSClient,
896
1792
  VoiceAgentClient,
@@ -899,6 +1795,7 @@ export {
899
1795
  bytesToPcm16,
900
1796
  calculateRMS,
901
1797
  float32ToPcm16,
1798
+ isRetryable,
902
1799
  normalizeAudio,
903
1800
  pcm16ToBytes,
904
1801
  pcm16ToFloat32,