@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.js CHANGED
@@ -1,13 +1,8 @@
1
1
  "use strict";
2
- var __create = Object.create;
3
2
  var __defProp = Object.defineProperty;
4
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
- var __getProtoOf = Object.getPrototypeOf;
7
5
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
- var __esm = (fn, res) => function __init() {
9
- return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
10
- };
11
6
  var __export = (target, all) => {
12
7
  for (var name in all)
13
8
  __defProp(target, name, { get: all[name], enumerable: true });
@@ -20,166 +15,17 @@ var __copyProps = (to, from, except, desc) => {
20
15
  }
21
16
  return to;
22
17
  };
23
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
24
- // If the importer is in node compatibility mode or this is not an ESM
25
- // file that has been converted to a CommonJS file using a Babel-
26
- // compatible transform (i.e. "__esModule" has not been set), then set
27
- // "default" to the CommonJS "module.exports" for node compatibility.
28
- isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
29
- mod
30
- ));
31
18
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
32
19
 
33
- // src/types.ts
34
- var VoiceStyle, Language, AUDIO_CONFIG, DEFAULT_URLS;
35
- var init_types = __esm({
36
- "src/types.ts"() {
37
- "use strict";
38
- VoiceStyle = /* @__PURE__ */ ((VoiceStyle2) => {
39
- VoiceStyle2["F1"] = "F1";
40
- VoiceStyle2["F2"] = "F2";
41
- VoiceStyle2["F3"] = "F3";
42
- VoiceStyle2["F4"] = "F4";
43
- VoiceStyle2["F5"] = "F5";
44
- VoiceStyle2["M1"] = "M1";
45
- VoiceStyle2["M2"] = "M2";
46
- VoiceStyle2["M3"] = "M3";
47
- VoiceStyle2["M4"] = "M4";
48
- VoiceStyle2["M5"] = "M5";
49
- return VoiceStyle2;
50
- })(VoiceStyle || {});
51
- Language = /* @__PURE__ */ ((Language2) => {
52
- Language2["ENGLISH"] = "en";
53
- Language2["SPANISH"] = "es";
54
- Language2["FRENCH"] = "fr";
55
- Language2["PORTUGUESE"] = "pt";
56
- Language2["KOREAN"] = "ko";
57
- return Language2;
58
- })(Language || {});
59
- AUDIO_CONFIG = {
60
- SAMPLE_RATE: 16e3,
61
- SAMPLE_RATE_INPUT: 16e3,
62
- SPEAKER_SAMPLE_RATE: 44100,
63
- SAMPLE_RATE_OUTPUT: 44100,
64
- CHANNELS: 1,
65
- CHUNK_DURATION_MS: 20,
66
- get CHUNK_SIZE() {
67
- return Math.floor(this.SAMPLE_RATE * this.CHUNK_DURATION_MS / 1e3);
68
- }
69
- };
70
- DEFAULT_URLS = {
71
- VOICE_AGENT: "wss://api.lokutor.com/ws/agent",
72
- TTS: "wss://api.lokutor.com/ws/tts"
73
- };
74
- }
75
- });
76
-
77
- // src/node-audio.ts
78
- var node_audio_exports = {};
79
- __export(node_audio_exports, {
80
- NodeAudioManager: () => NodeAudioManager
81
- });
82
- var NodeAudioManager;
83
- var init_node_audio = __esm({
84
- "src/node-audio.ts"() {
85
- "use strict";
86
- init_types();
87
- NodeAudioManager = class {
88
- speaker = null;
89
- recorder = null;
90
- recordingStream = null;
91
- isMuted = false;
92
- isListening = false;
93
- constructor() {
94
- }
95
- async init() {
96
- try {
97
- const Speaker = await import("speaker").catch(() => null);
98
- if (!Speaker) {
99
- console.warn('\u26A0\uFE0F Package "speaker" is missing. Hardware output will be disabled.');
100
- console.warn("\u{1F449} Run: npm install speaker");
101
- }
102
- } catch (e) {
103
- console.error("Error initializing Node audio:", e);
104
- }
105
- }
106
- async startMicrophone(onAudioInput) {
107
- if (this.isListening) return;
108
- try {
109
- const recorder = await import("node-record-lpcm16").catch(() => null);
110
- if (!recorder) {
111
- throw new Error('Package "node-record-lpcm16" is missing. Microphone input failed.\n\u{1F449} Run: npm install node-record-lpcm16');
112
- }
113
- console.log("\u{1F3A4} Starting microphone (Node.js)...");
114
- this.recordingStream = recorder.record({
115
- sampleRate: AUDIO_CONFIG.SAMPLE_RATE,
116
- threshold: 0,
117
- verbose: false,
118
- recordProgram: "sox"
119
- // default
120
- });
121
- this.recordingStream.stream().on("data", (chunk) => {
122
- if (!this.isMuted && onAudioInput) {
123
- onAudioInput(new Uint8Array(chunk));
124
- }
125
- });
126
- this.isListening = true;
127
- } catch (e) {
128
- console.error("Failed to start microphone:", e.message);
129
- throw e;
130
- }
131
- }
132
- stopMicrophone() {
133
- if (this.recordingStream) {
134
- this.recordingStream.stop();
135
- this.recordingStream = null;
136
- }
137
- this.isListening = false;
138
- }
139
- async playAudio(pcm16Data) {
140
- try {
141
- if (!this.speaker) {
142
- const Speaker = (await import("speaker")).default;
143
- this.speaker = new Speaker({
144
- channels: AUDIO_CONFIG.CHANNELS,
145
- bitDepth: 16,
146
- sampleRate: AUDIO_CONFIG.SPEAKER_SAMPLE_RATE
147
- });
148
- }
149
- this.speaker.write(Buffer.from(pcm16Data));
150
- } catch (e) {
151
- }
152
- }
153
- stopPlayback() {
154
- if (this.speaker) {
155
- this.speaker.end();
156
- this.speaker = null;
157
- }
158
- }
159
- cleanup() {
160
- this.stopMicrophone();
161
- this.stopPlayback();
162
- }
163
- isMicMuted() {
164
- return this.isMuted;
165
- }
166
- setMuted(muted) {
167
- this.isMuted = muted;
168
- }
169
- getAmplitude() {
170
- return 0;
171
- }
172
- };
173
- }
174
- });
175
-
176
20
  // src/index.ts
177
21
  var index_exports = {};
178
22
  __export(index_exports, {
179
23
  AUDIO_CONFIG: () => AUDIO_CONFIG,
180
24
  BrowserAudioManager: () => BrowserAudioManager,
25
+ ConversationalPanel: () => ConversationalPanel,
181
26
  DEFAULT_URLS: () => DEFAULT_URLS,
182
27
  Language: () => Language,
28
+ LokutorError: () => LokutorError,
183
29
  StreamResampler: () => StreamResampler,
184
30
  TTSClient: () => TTSClient,
185
31
  VoiceAgentClient: () => VoiceAgentClient,
@@ -188,6 +34,7 @@ __export(index_exports, {
188
34
  bytesToPcm16: () => bytesToPcm16,
189
35
  calculateRMS: () => calculateRMS,
190
36
  float32ToPcm16: () => float32ToPcm16,
37
+ isRetryable: () => isRetryable,
191
38
  normalizeAudio: () => normalizeAudio,
192
39
  pcm16ToBytes: () => pcm16ToBytes,
193
40
  pcm16ToFloat32: () => pcm16ToFloat32,
@@ -197,13 +44,79 @@ __export(index_exports, {
197
44
  simpleTTS: () => simpleTTS
198
45
  });
199
46
  module.exports = __toCommonJS(index_exports);
200
- init_types();
201
-
202
- // src/client.ts
203
- init_types();
204
47
 
205
- // src/browser-audio.ts
206
- init_types();
48
+ // src/types.ts
49
+ var VoiceStyle = /* @__PURE__ */ ((VoiceStyle2) => {
50
+ VoiceStyle2["F1"] = "F1";
51
+ VoiceStyle2["F2"] = "F2";
52
+ VoiceStyle2["F3"] = "F3";
53
+ VoiceStyle2["F4"] = "F4";
54
+ VoiceStyle2["F5"] = "F5";
55
+ VoiceStyle2["M1"] = "M1";
56
+ VoiceStyle2["M2"] = "M2";
57
+ VoiceStyle2["M3"] = "M3";
58
+ VoiceStyle2["M4"] = "M4";
59
+ VoiceStyle2["M5"] = "M5";
60
+ return VoiceStyle2;
61
+ })(VoiceStyle || {});
62
+ var Language = /* @__PURE__ */ ((Language2) => {
63
+ Language2["ENGLISH"] = "en";
64
+ Language2["SPANISH"] = "es";
65
+ Language2["FRENCH"] = "fr";
66
+ Language2["PORTUGUESE"] = "pt";
67
+ Language2["KOREAN"] = "ko";
68
+ return Language2;
69
+ })(Language || {});
70
+ var AUDIO_CONFIG = {
71
+ SAMPLE_RATE: 16e3,
72
+ SAMPLE_RATE_INPUT: 16e3,
73
+ SPEAKER_SAMPLE_RATE: 44100,
74
+ SAMPLE_RATE_OUTPUT: 44100,
75
+ CHANNELS: 1,
76
+ CHUNK_DURATION_MS: 20,
77
+ get CHUNK_SIZE() {
78
+ return Math.floor(this.SAMPLE_RATE * this.CHUNK_DURATION_MS / 1e3);
79
+ }
80
+ };
81
+ var DEFAULT_URLS = {
82
+ VOICE_AGENT: "wss://api.lokutor.com/ws/agent",
83
+ TTS: "wss://api.lokutor.com/ws/tts"
84
+ };
85
+ var LokutorError = class extends Error {
86
+ code;
87
+ detail;
88
+ retryable;
89
+ original;
90
+ constructor(code, message, opts) {
91
+ super(message);
92
+ this.name = "LokutorError";
93
+ this.code = code;
94
+ this.detail = opts?.detail;
95
+ this.retryable = opts?.retryable ?? isRetryableCode(code);
96
+ this.original = opts?.original;
97
+ }
98
+ };
99
+ function isRetryableCode(code) {
100
+ const fatal = [
101
+ "auth.missing_key",
102
+ "auth.invalid_key",
103
+ "auth.time_limited",
104
+ "validation.invalid_voice",
105
+ "validation.invalid_language",
106
+ "validation.text_too_long",
107
+ "validation.speed_out_of_range",
108
+ "validation.steps_out_of_range",
109
+ "validation.invalid_request_format",
110
+ "internal.cancelled"
111
+ ];
112
+ return !fatal.includes(code);
113
+ }
114
+ function isRetryable(error) {
115
+ if (error instanceof LokutorError) {
116
+ return error.retryable;
117
+ }
118
+ return false;
119
+ }
207
120
 
208
121
  // src/audio-utils.ts
209
122
  function pcm16ToFloat32(int16Data) {
@@ -264,6 +177,9 @@ function pcm16ToBytes(data) {
264
177
  return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
265
178
  }
266
179
  function bytesToPcm16(bytes) {
180
+ if (bytes.length % 2 !== 0) {
181
+ bytes = bytes.slice(0, bytes.length - 1);
182
+ }
267
183
  return new Int16Array(bytes.buffer, bytes.byteOffset, bytes.length / 2);
268
184
  }
269
185
  function normalizeAudio(data, targetPeak = 0.95) {
@@ -313,11 +229,14 @@ var StreamResampler = class {
313
229
  combined.set(this.inputBuffer);
314
230
  combined.set(inputChunk, this.inputBuffer.length);
315
231
  const ratio = this.inputRate / this.outputRate;
316
- const outputLength = Math.floor(combined.length / ratio);
232
+ let outputLength = Math.floor(combined.length / ratio);
317
233
  if (outputLength === 0 && !flush) {
318
234
  this.inputBuffer = combined;
319
235
  return new Float32Array(0);
320
236
  }
237
+ if (flush && outputLength === 0 && combined.length > 0) {
238
+ outputLength = 1;
239
+ }
321
240
  const output = new Float32Array(outputLength);
322
241
  for (let i = 0; i < outputLength; i++) {
323
242
  const pos = i * ratio;
@@ -326,10 +245,8 @@ var StreamResampler = class {
326
245
  const weight = pos - left;
327
246
  output[i] = combined[left] * (1 - weight) + combined[right] * weight;
328
247
  }
329
- const remainingSamples = Math.ceil(combined.length - outputLength * ratio);
330
- this.inputBuffer = combined.slice(
331
- combined.length - remainingSamples
332
- );
248
+ const consumed = Math.floor(outputLength * ratio);
249
+ this.inputBuffer = combined.slice(consumed);
333
250
  return output;
334
251
  }
335
252
  reset() {
@@ -491,6 +408,10 @@ var BrowserAudioManager = class {
491
408
  console.warn("AudioContext not initialized");
492
409
  return;
493
410
  }
411
+ if (pcm16Data.length % 2 !== 0) {
412
+ console.warn(`Discarding odd-length PCM buffer (${pcm16Data.length} bytes)`);
413
+ return;
414
+ }
494
415
  const int16Array = new Int16Array(
495
416
  pcm16Data.buffer,
496
417
  pcm16Data.byteOffset,
@@ -619,6 +540,54 @@ var BrowserAudioManager = class {
619
540
  };
620
541
 
621
542
  // src/client.ts
543
+ function sdkTraceEnabled() {
544
+ try {
545
+ if (typeof window === "undefined") return false;
546
+ const w = window;
547
+ return Boolean(w.LOKUTOR_TRACE) || window.localStorage?.getItem("lokutorTrace") === "1";
548
+ } catch {
549
+ return false;
550
+ }
551
+ }
552
+ function sdkTrace(...args) {
553
+ if (sdkTraceEnabled()) {
554
+ console.log("[SDK TRACE]", ...args);
555
+ }
556
+ }
557
+ function nowMs() {
558
+ if (typeof performance !== "undefined" && performance.now) {
559
+ return performance.now();
560
+ }
561
+ return Date.now();
562
+ }
563
+ function wsToHttp(url) {
564
+ return url.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
565
+ }
566
+ async function fetchJson(url, timeoutMs = 1e4) {
567
+ const controller = new AbortController();
568
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
569
+ try {
570
+ const res = await fetch(url, {
571
+ signal: controller.signal,
572
+ headers: { Accept: "application/json" }
573
+ });
574
+ clearTimeout(timer);
575
+ if (!res.ok) {
576
+ throw new LokutorError("internal.error", `HTTP ${res.status} from ${url}`, {
577
+ detail: await res.text().catch(() => ""),
578
+ retryable: res.status >= 500
579
+ });
580
+ }
581
+ return await res.json();
582
+ } catch (err) {
583
+ clearTimeout(timer);
584
+ if (err instanceof LokutorError) throw err;
585
+ throw new LokutorError("internal.error", `Failed to fetch ${url}`, {
586
+ original: err,
587
+ retryable: true
588
+ });
589
+ }
590
+ }
622
591
  function base64ToUint8Array(base64) {
623
592
  const binaryString = atob(base64);
624
593
  const bytes = new Uint8Array(binaryString.length);
@@ -627,6 +596,39 @@ function base64ToUint8Array(base64) {
627
596
  }
628
597
  return bytes;
629
598
  }
599
+ function normalizeVisemes(payload) {
600
+ if (!Array.isArray(payload)) return [];
601
+ const normalized = [];
602
+ for (const item of payload) {
603
+ if (!item || typeof item !== "object") continue;
604
+ const c = String(item.c ?? item.char ?? "sil").toLowerCase();
605
+ const t = Number(item.t ?? item.timestamp ?? 0);
606
+ const v = Number(item.v ?? item.id ?? 0);
607
+ normalized.push({
608
+ v: Number.isFinite(v) ? v : 0,
609
+ c,
610
+ t: Number.isFinite(t) ? t : 0
611
+ });
612
+ }
613
+ return normalized;
614
+ }
615
+ function extractVisemePayload(msg) {
616
+ if (Array.isArray(msg?.data)) {
617
+ return normalizeVisemes(msg.data);
618
+ }
619
+ if (Array.isArray(msg?.data?.visemes)) {
620
+ return normalizeVisemes(msg.data.visemes);
621
+ }
622
+ if (msg?.data && !Array.isArray(msg.data) && typeof msg.data === "object") {
623
+ const singularInData = normalizeVisemes([msg.data]);
624
+ if (singularInData.length > 0) return singularInData;
625
+ }
626
+ if (msg && !Array.isArray(msg) && typeof msg === "object") {
627
+ const singularAtRoot = normalizeVisemes([msg]);
628
+ if (singularAtRoot.length > 0) return singularAtRoot;
629
+ }
630
+ return [];
631
+ }
630
632
  var VoiceAgentClient = class {
631
633
  ws = null;
632
634
  apiKey;
@@ -654,11 +656,13 @@ var VoiceAgentClient = class {
654
656
  reconnecting = false;
655
657
  reconnectAttempts = 0;
656
658
  maxReconnectAttempts = 5;
659
+ serverUrl;
657
660
  constructor(config) {
658
661
  this.apiKey = config.apiKey;
659
662
  this.prompt = config.prompt;
660
663
  this.voice = config.voice || "F1" /* F1 */;
661
664
  this.language = config.language || "en" /* ENGLISH */;
665
+ this.serverUrl = config.serverUrl || DEFAULT_URLS.VOICE_AGENT;
662
666
  this.onTranscription = config.onTranscription;
663
667
  this.onResponse = config.onResponse;
664
668
  this.onAudioCallback = config.onAudio;
@@ -686,13 +690,28 @@ var VoiceAgentClient = class {
686
690
  }
687
691
  }
688
692
  return new Promise((resolve, reject) => {
693
+ let settled = false;
694
+ const settle = (fn) => {
695
+ if (!settled) {
696
+ settled = true;
697
+ fn();
698
+ }
699
+ };
689
700
  try {
690
- let url = DEFAULT_URLS.VOICE_AGENT;
701
+ let url = this.serverUrl;
691
702
  if (this.apiKey) {
692
703
  const separator = url.includes("?") ? "&" : "?";
693
704
  url += `${separator}api_key=${this.apiKey}`;
694
705
  }
695
- console.log(`\u{1F517} Connecting to ${DEFAULT_URLS.VOICE_AGENT}...`);
706
+ const redactedUrl = url.replace(/api_key=[^&]+/, "api_key=***");
707
+ sdkTrace("ws.connect", {
708
+ endpoint: this.serverUrl,
709
+ url: redactedUrl,
710
+ enableAudio: this.enableAudio,
711
+ wantVisemes: this.wantVisemes,
712
+ hasAudioManager: Boolean(this.audioManager)
713
+ });
714
+ console.log(`\u{1F517} Connecting to ${this.serverUrl}...`);
696
715
  this.ws = new WebSocket(url);
697
716
  this.ws.binaryType = "arraybuffer";
698
717
  this.ws.onopen = async () => {
@@ -700,6 +719,7 @@ var VoiceAgentClient = class {
700
719
  this.reconnectAttempts = 0;
701
720
  this.reconnecting = false;
702
721
  console.log("\u2705 Connected to voice agent!");
722
+ sdkTrace("ws.open");
703
723
  this.sendConfig();
704
724
  if (this.audioManager) {
705
725
  await this.audioManager.startMicrophone((data) => {
@@ -708,22 +728,54 @@ var VoiceAgentClient = class {
708
728
  }
709
729
  });
710
730
  }
711
- resolve(true);
731
+ settle(() => resolve(true));
712
732
  };
713
733
  this.ws.onmessage = async (event) => {
714
734
  if (event.data instanceof ArrayBuffer) {
735
+ sdkTrace("ws.message.binary", { bytes: event.data.byteLength });
715
736
  this.handleBinaryMessage(new Uint8Array(event.data));
716
737
  } else {
738
+ sdkTrace("ws.message.text", { length: String(event.data).length });
717
739
  this.handleTextMessage(event.data.toString());
718
740
  }
719
741
  };
720
742
  this.ws.onerror = (err) => {
721
- console.error("\u274C WebSocket error:", err);
722
- if (this.onError) this.onError(err);
723
- if (!this.isConnected) reject(err);
743
+ const error = new LokutorError("ws.close", "WebSocket connection error", {
744
+ detail: `readyState=${this.ws?.readyState}, bufferedAmount=${this.ws?.bufferedAmount}`,
745
+ original: err,
746
+ retryable: true
747
+ });
748
+ console.error("\u274C WebSocket error:", error.message);
749
+ sdkTrace("ws.error", { code: error.code, message: error.message });
750
+ if (this.onError) this.onError(error);
751
+ if (!this.isConnected) {
752
+ settle(() => reject(error));
753
+ }
724
754
  };
725
- this.ws.onclose = () => {
755
+ this.ws.onclose = (event) => {
726
756
  this.isConnected = false;
757
+ const diagnostic = {
758
+ code: event.code,
759
+ reason: event.reason,
760
+ wasClean: event.wasClean,
761
+ url: this.serverUrl,
762
+ isUserDisconnect: this.isUserDisconnect,
763
+ reconnectAttempts: this.reconnectAttempts
764
+ };
765
+ sdkTrace("ws.close", diagnostic);
766
+ if (!settled && !this.isUserDisconnect) {
767
+ const error = new LokutorError("ws.close", `WebSocket closed unexpectedly (code ${event.code})`, {
768
+ detail: event.reason || "No reason provided",
769
+ retryable: event.code !== 1008
770
+ });
771
+ settle(() => reject(error));
772
+ return;
773
+ }
774
+ if (!event.wasClean && event.code === 1006 && this.reconnectAttempts === 0 && !this.isUserDisconnect) {
775
+ console.error("\u274C Connection rejected (code 1006). Likely causes: invalid API key, endpoint unavailable, or CORS blocked.");
776
+ console.error(" URL:", this.serverUrl.replace(/api_key=[^&]+/, "api_key=***"));
777
+ }
778
+ console.log(`\u{1F50C} WebSocket closed \u2014 code: ${event.code}, reason: "${event.reason || "none"}", clean: ${event.wasClean}`);
727
779
  if (!this.isUserDisconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
728
780
  this.reconnecting = true;
729
781
  this.reconnectAttempts++;
@@ -739,37 +791,27 @@ var VoiceAgentClient = class {
739
791
  }
740
792
  };
741
793
  } catch (err) {
742
- if (this.onError) this.onError(err);
743
- reject(err);
794
+ const error = err instanceof LokutorError ? err : new LokutorError("internal.error", "Failed to create WebSocket connection", { original: err });
795
+ if (this.onError) this.onError(error);
796
+ settle(() => reject(error));
744
797
  }
745
798
  });
746
799
  }
747
800
  /**
748
801
  * The "Golden Path" - Starts a managed session with hardware handled automatically.
749
- * This is the recommended way to start a conversation in both Browser and Node.js.
802
+ * This is the recommended way to start a conversation in browser environments.
750
803
  */
751
804
  async startManaged(config) {
752
805
  this.enableAudio = true;
753
806
  if (config?.audioManager) {
754
807
  this.audioManager = config.audioManager;
755
808
  } else if (!this.audioManager) {
756
- if (typeof window !== "undefined") {
757
- this.audioManager = new BrowserAudioManager();
758
- } else {
759
- try {
760
- const { NodeAudioManager: NodeAudioManager2 } = await Promise.resolve().then(() => (init_node_audio(), node_audio_exports));
761
- this.audioManager = new NodeAudioManager2();
762
- } catch (e) {
763
- console.error('\u274C Failed to load NodeAudioManager. Please ensure "speaker" and "node-record-lpcm16" are installed.');
764
- }
809
+ if (typeof window === "undefined") {
810
+ throw new LokutorError("internal.error", "startManaged() requires a browser environment. Pass a custom audioManager for non-browser runtimes.", { retryable: false });
765
811
  }
812
+ this.audioManager = new BrowserAudioManager();
766
813
  }
767
814
  await this.connect();
768
- if (this.audioManager && this.isConnected) {
769
- await this.audioManager.startMicrophone((data) => {
770
- this.sendAudio(data);
771
- });
772
- }
773
815
  return this;
774
816
  }
775
817
  /**
@@ -777,10 +819,18 @@ var VoiceAgentClient = class {
777
819
  */
778
820
  sendConfig() {
779
821
  if (!this.ws || !this.isConnected) return;
780
- this.ws.send(JSON.stringify({ type: "prompt", data: this.prompt }));
822
+ this.ws.send(JSON.stringify({ type: "visemes", data: this.wantVisemes }));
781
823
  this.ws.send(JSON.stringify({ type: "voice", data: this.voice }));
782
824
  this.ws.send(JSON.stringify({ type: "language", data: this.language }));
783
- this.ws.send(JSON.stringify({ type: "visemes", data: this.wantVisemes }));
825
+ this.ws.send(JSON.stringify({ type: "prompt", data: this.prompt }));
826
+ this.ws.send(JSON.stringify({ type: "rates", playback: 44100, input: 16e3 }));
827
+ sdkTrace("ws.send.config", {
828
+ promptLen: this.prompt?.length || 0,
829
+ voice: this.voice,
830
+ language: this.language,
831
+ visemes: this.wantVisemes,
832
+ tools: this.tools?.length || 0
833
+ });
784
834
  if (this.tools && this.tools.length > 0) {
785
835
  this.ws.send(JSON.stringify({ type: "tools", data: this.tools }));
786
836
  }
@@ -814,6 +864,15 @@ var VoiceAgentClient = class {
814
864
  handleTextMessage(text) {
815
865
  try {
816
866
  const msg = JSON.parse(text);
867
+ if (!msg || typeof msg !== "object") {
868
+ return;
869
+ }
870
+ sdkTrace("ws.recv.type", {
871
+ type: msg.type,
872
+ hasData: Object.prototype.hasOwnProperty.call(msg, "data"),
873
+ dataKind: Array.isArray(msg.data) ? "array" : typeof msg.data,
874
+ generation: msg.generation ?? null
875
+ });
817
876
  switch (msg.type) {
818
877
  case "audio":
819
878
  if (msg.data) {
@@ -826,7 +885,7 @@ var VoiceAgentClient = class {
826
885
  this.messages.push({
827
886
  role,
828
887
  text: msg.data,
829
- timestamp: Date.now()
888
+ timestamp: nowMs()
830
889
  });
831
890
  if (msg.role === "user") {
832
891
  if (this.onTranscription) this.onTranscription(msg.data);
@@ -858,19 +917,44 @@ var VoiceAgentClient = class {
858
917
  console.log(`${icons[msg.data] || ""} Status: ${msg.data}`);
859
918
  break;
860
919
  case "visemes":
861
- if (Array.isArray(msg.data) && msg.data.length > 0) {
862
- this.emit("visemes", msg.data);
920
+ case "viseme": {
921
+ const msgGen = msg.generation ?? this.currentGeneration;
922
+ if (msgGen < this.currentGeneration) {
923
+ sdkTrace("visemes.discard", { msgGen, currentGen: this.currentGeneration });
924
+ break;
925
+ }
926
+ const normalized = extractVisemePayload(msg);
927
+ const explicitlyEmptyArray = Array.isArray(msg?.data) || Array.isArray(msg?.data?.visemes);
928
+ sdkTrace("visemes.recv", {
929
+ rawType: msg.type,
930
+ normalizedCount: normalized.length,
931
+ first: normalized[0] ?? null
932
+ });
933
+ if (normalized.length > 0 || explicitlyEmptyArray) {
934
+ this.emit("visemes", normalized);
863
935
  }
864
936
  break;
865
- case "error":
866
- if (this.onError) this.onError(msg.data);
867
- console.error(`\u274C Server error: ${msg.data}`);
937
+ }
938
+ case "error": {
939
+ const backendCode = msg.data?.code ?? "internal.error";
940
+ const backendMessage = msg.data?.message ?? msg.data ?? "Unknown server error";
941
+ const backendDetail = msg.data?.detail;
942
+ const backendRetryable = msg.data?.retryable ?? true;
943
+ const error = new LokutorError(backendCode, backendMessage, {
944
+ detail: backendDetail,
945
+ retryable: backendRetryable
946
+ });
947
+ if (this.onError) this.onError(error);
948
+ console.error(`\u274C Server error: [${error.code}] ${error.message}`);
868
949
  break;
950
+ }
869
951
  case "tool_call":
870
952
  console.log(`\u{1F6E0}\uFE0F Tool Call: ${msg.name}(${msg.arguments})`);
871
953
  break;
872
954
  }
873
955
  } catch (e) {
956
+ sdkTrace("ws.recv.parse_error", { preview: text?.slice(0, 120) });
957
+ console.debug("Failed to parse message:", e);
874
958
  }
875
959
  }
876
960
  /**
@@ -932,6 +1016,21 @@ var VoiceAgentClient = class {
932
1016
  this.audioManager.cleanup();
933
1017
  }
934
1018
  this.isConnected = false;
1019
+ this.reconnecting = false;
1020
+ this.reconnectAttempts = 0;
1021
+ }
1022
+ /**
1023
+ * Returns true if the client is currently connected.
1024
+ */
1025
+ get connected() {
1026
+ return this.isConnected;
1027
+ }
1028
+ /**
1029
+ * Returns the current generation counter.
1030
+ * Useful for correlating audio/viseme chunks with utterances.
1031
+ */
1032
+ get generation() {
1033
+ return this.currentGeneration;
935
1034
  }
936
1035
  /**
937
1036
  * Toggles the microphone mute state (if managed by client)
@@ -954,17 +1053,70 @@ var VoiceAgentClient = class {
954
1053
  }
955
1054
  return 0;
956
1055
  }
1056
+ /**
1057
+ * Fetch available voice styles from the server.
1058
+ * No authentication required.
1059
+ */
1060
+ static async fetchVoices(baseUrl) {
1061
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1062
+ const data = await fetchJson(`${url}/voices`);
1063
+ return data.voices || [];
1064
+ }
1065
+ /**
1066
+ * Fetch supported languages from the server.
1067
+ * No authentication required.
1068
+ */
1069
+ static async fetchLanguages(baseUrl) {
1070
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1071
+ const data = await fetchJson(`${url}/languages`);
1072
+ return data.languages || [];
1073
+ }
1074
+ /**
1075
+ * Fetch loaded TTS model versions from the server.
1076
+ * No authentication required.
1077
+ */
1078
+ static async fetchModels(baseUrl) {
1079
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1080
+ const data = await fetchJson(`${url}/models`);
1081
+ return data.models || [];
1082
+ }
1083
+ /**
1084
+ * Fetch server configuration (limits and defaults).
1085
+ * No authentication required.
1086
+ */
1087
+ static async fetchConfig(baseUrl) {
1088
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1089
+ return fetchJson(`${url}/config`);
1090
+ }
1091
+ /**
1092
+ * Fetch rich runtime status from the server.
1093
+ * No authentication required.
1094
+ */
1095
+ static async fetchStatus(baseUrl) {
1096
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1097
+ return fetchJson(`${url}/status`);
1098
+ }
1099
+ /**
1100
+ * Fetch health/liveness status from the server.
1101
+ * No authentication required.
1102
+ */
1103
+ static async fetchHealth(baseUrl) {
1104
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1105
+ return fetchJson(`${url}/health`);
1106
+ }
957
1107
  /**
958
1108
  * Update the system prompt mid-conversation
959
1109
  */
960
1110
  updatePrompt(newPrompt) {
961
1111
  this.prompt = newPrompt;
962
- if (this.ws && this.isConnected) {
1112
+ if (this.ws && this.ws.readyState === WebSocket.OPEN && this.isConnected) {
963
1113
  try {
964
1114
  this.ws.send(JSON.stringify({ type: "prompt", data: newPrompt }));
965
1115
  console.log(`\u2699\uFE0F Updated prompt: ${newPrompt.substring(0, 50)}...`);
966
1116
  } catch (error) {
967
- console.error("Error updating prompt:", error);
1117
+ const err = new LokutorError("internal.error", "Failed to update prompt", { original: error });
1118
+ if (this.onError) this.onError(err);
1119
+ console.error("Error updating prompt:", err.message);
968
1120
  }
969
1121
  } else {
970
1122
  console.warn("Not connected - prompt will be updated on next connection");
@@ -1027,37 +1179,52 @@ var TTSClient = class {
1027
1179
  visemes: options.visemes || false
1028
1180
  };
1029
1181
  ws.send(JSON.stringify(req));
1030
- startTime = Date.now();
1182
+ startTime = nowMs();
1031
1183
  };
1032
1184
  ws.onmessage = async (event) => {
1033
1185
  refreshTimeout();
1034
1186
  if (event.data instanceof ArrayBuffer) {
1035
1187
  if (!firstByteReceived) {
1036
- const ttfb = Date.now() - startTime;
1188
+ const ttfb = nowMs() - startTime;
1037
1189
  if (options.onTTFB) options.onTTFB(ttfb);
1038
1190
  firstByteReceived = true;
1039
1191
  }
1040
1192
  if (options.onAudio) options.onAudio(new Uint8Array(event.data));
1041
- } else {
1042
- const text = event.data.toString();
1043
- if (text === "EOS") {
1193
+ return;
1194
+ }
1195
+ const text = event.data.toString();
1196
+ if (text === "EOS") {
1197
+ if (activityTimeout) clearTimeout(activityTimeout);
1198
+ ws.close();
1199
+ resolve();
1200
+ return;
1201
+ }
1202
+ try {
1203
+ const msg = JSON.parse(text);
1204
+ if (msg.type === "audio" && msg.data) {
1205
+ const audioBuffer = base64ToUint8Array(msg.data);
1206
+ if (!firstByteReceived) {
1207
+ const ttfb = nowMs() - startTime;
1208
+ if (options.onTTFB) options.onTTFB(ttfb);
1209
+ firstByteReceived = true;
1210
+ }
1211
+ if (options.onAudio) options.onAudio(audioBuffer);
1212
+ return;
1213
+ }
1214
+ if (msg.type === "visemes" && Array.isArray(msg.data) && options.onVisemes) {
1215
+ options.onVisemes(normalizeVisemes(msg.data));
1216
+ return;
1217
+ }
1218
+ if (Array.isArray(msg) && options.onVisemes) {
1219
+ options.onVisemes(normalizeVisemes(msg));
1220
+ return;
1221
+ }
1222
+ if (msg.type === "eos") {
1044
1223
  if (activityTimeout) clearTimeout(activityTimeout);
1045
1224
  ws.close();
1046
1225
  resolve();
1047
- return;
1048
- }
1049
- try {
1050
- const msg = JSON.parse(text);
1051
- if (Array.isArray(msg) && options.onVisemes) {
1052
- options.onVisemes(msg);
1053
- }
1054
- if (msg.type === "eos") {
1055
- if (activityTimeout) clearTimeout(activityTimeout);
1056
- ws.close();
1057
- resolve();
1058
- }
1059
- } catch (e) {
1060
1226
  }
1227
+ } catch (e) {
1061
1228
  }
1062
1229
  };
1063
1230
  ws.onerror = (err) => {
@@ -1086,12 +1253,588 @@ async function simpleTTS(options) {
1086
1253
  const client = new TTSClient({ apiKey: options.apiKey });
1087
1254
  return client.synthesize(options);
1088
1255
  }
1256
+
1257
+ // src/conversational-panel.ts
1258
+ var PANEL_CSS = (
1259
+ /*css*/
1260
+ `
1261
+ .cv-panel {
1262
+ position: relative;
1263
+ width: 100%;
1264
+ aspect-ratio: 16 / 9;
1265
+ max-height: 800px;
1266
+ display: flex;
1267
+ flex-direction: column;
1268
+ align-items: center;
1269
+ justify-content: center;
1270
+ padding: 2rem 0;
1271
+ margin: 0;
1272
+ background: var(--cv-bg, #0a0a0a);
1273
+ box-shadow: inset 0 10px 40px rgba(0, 0, 0, 0.1), inset 0 0 100px rgba(0, 0, 0, 0.05);
1274
+ border-radius: 40px;
1275
+ overflow: hidden;
1276
+ container-type: inline-size;
1277
+ }
1278
+ .cv-curtain {
1279
+ position: absolute;
1280
+ inset: 0;
1281
+ display: flex;
1282
+ flex-direction: column;
1283
+ align-items: center;
1284
+ justify-content: center;
1285
+ background: var(--cv-bg, #0a0a0a);
1286
+ z-index: 100;
1287
+ transition: transform 1s cubic-bezier(0.16, 1, 0.3, 1);
1288
+ }
1289
+ .cv-curtain .cv-curtain-bg {
1290
+ position: absolute;
1291
+ inset: 0;
1292
+ background: url('/background_gradient.jpeg') center / cover no-repeat;
1293
+ z-index: -1;
1294
+ }
1295
+ .cv-curtain .cv-curtain-overlay {
1296
+ position: absolute;
1297
+ inset: 0;
1298
+ background: var(--cv-accent);
1299
+ opacity: 0.35;
1300
+ z-index: -1;
1301
+ }
1302
+ .cv-curtain.is-up { transform: translateY(-100%); }
1303
+ .cv-curtain-content {
1304
+ display: flex;
1305
+ flex-direction: column;
1306
+ align-items: center;
1307
+ gap: clamp(0.75rem, 1.5cqi, 1.5rem);
1308
+ color: #fff;
1309
+ text-align: center;
1310
+ z-index: 2;
1311
+ padding: clamp(1rem, 2cqi, 2rem);
1312
+ }
1313
+ .cv-curtain-title {
1314
+ font-size: clamp(1.2rem, 4cqi, 2.5rem);
1315
+ font-weight: 800;
1316
+ letter-spacing: -0.03em;
1317
+ margin: 0;
1318
+ text-shadow: 0 4px 20px rgba(0,0,0,0.3);
1319
+ }
1320
+ .cv-curtain-desc {
1321
+ font-size: clamp(0.75rem, 2cqi, 1.1rem);
1322
+ opacity: 0.8;
1323
+ max-width: 400px;
1324
+ margin: 0;
1325
+ line-height: 1.5;
1326
+ }
1327
+ .cv-curtain-btn {
1328
+ margin-top: 1rem;
1329
+ padding: clamp(0.6rem, 1.5cqi, 1rem) clamp(1.5rem, 3cqi, 2.5rem);
1330
+ border-radius: 100px;
1331
+ background: #fff;
1332
+ color: #000;
1333
+ border: none;
1334
+ font-weight: 700;
1335
+ font-size: clamp(0.8rem, 1.5cqi, 1rem);
1336
+ display: flex;
1337
+ align-items: center;
1338
+ gap: 0.75rem;
1339
+ cursor: pointer;
1340
+ transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
1341
+ box-shadow: 0 10px 30px rgba(0,0,0,0.2);
1342
+ }
1343
+ .cv-curtain-btn:hover {
1344
+ transform: scale(1.05);
1345
+ background: #f0f0f0;
1346
+ box-shadow: 0 15px 40px rgba(0,0,0,0.3);
1347
+ }
1348
+ .cv-curtain-btn svg { transition: transform 0.3s ease; }
1349
+ .cv-curtain-btn:hover svg { transform: translateX(4px); }
1350
+ .cv-header {
1351
+ width: 100%;
1352
+ display: flex;
1353
+ flex-direction: column;
1354
+ align-items: center;
1355
+ gap: 0.25rem;
1356
+ z-index: 20;
1357
+ margin-bottom: auto;
1358
+ }
1359
+ .cv-title {
1360
+ font-size: clamp(1rem, 2.5cqi, 1.75rem);
1361
+ font-weight: 700;
1362
+ color: #e0e0e0;
1363
+ display: flex;
1364
+ align-items: center;
1365
+ gap: 1rem;
1366
+ letter-spacing: -0.02em;
1367
+ }
1368
+ .cv-title .cv-timer {
1369
+ font-variant-numeric: tabular-nums;
1370
+ color: var(--cv-accent);
1371
+ font-weight: 400;
1372
+ opacity: 0.8;
1373
+ }
1374
+ .cv-visualizer-wrap {
1375
+ position: absolute;
1376
+ top: 50%;
1377
+ left: 50%;
1378
+ transform: translate(-50%, -50%);
1379
+ width: clamp(140px, 40cqi, 280px);
1380
+ height: clamp(140px, 40cqi, 280px);
1381
+ display: flex;
1382
+ align-items: center;
1383
+ justify-content: center;
1384
+ z-index: 10;
1385
+ transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
1386
+ }
1387
+ .cv-is-speaking .cv-visualizer-wrap {
1388
+ animation: cv-pulse 2.5s infinite ease-in-out;
1389
+ }
1390
+ .cv-is-thinking .cv-visualizer-wrap {
1391
+ opacity: 0.5;
1392
+ transform: translate(-50%, -50%) scale(0.9);
1393
+ }
1394
+ @keyframes cv-pulse {
1395
+ 0%, 100% { transform: translate(-50%, -50%) scale(1); }
1396
+ 50% { transform: translate(-50%, -50%) scale(1.05); }
1397
+ }
1398
+ .cv-canvas {
1399
+ width: 100% !important;
1400
+ height: 100% !important;
1401
+ position: relative;
1402
+ z-index: 0;
1403
+ }
1404
+ .cv-canvas {
1405
+ width: 100% !important;
1406
+ height: 100% !important;
1407
+ position: relative;
1408
+ z-index: 0;
1409
+ }
1410
+ .cv-controls {
1411
+ width: 100%;
1412
+ display: flex;
1413
+ flex-direction: column;
1414
+ align-items: center;
1415
+ gap: 1.5rem;
1416
+ padding-top: 1rem;
1417
+ margin-top: auto;
1418
+ z-index: 20;
1419
+ }
1420
+ .cv-pill {
1421
+ display: flex;
1422
+ align-items: center;
1423
+ gap: 0.75rem;
1424
+ background: rgba(255, 255, 255, 0.03);
1425
+ backdrop-filter: blur(30px);
1426
+ -webkit-backdrop-filter: blur(30px);
1427
+ border: 1px solid rgba(255, 255, 255, 0.08);
1428
+ padding: 0.3rem 0.75rem;
1429
+ border-radius: 100px;
1430
+ box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
1431
+ }
1432
+ .cv-btn {
1433
+ display: flex;
1434
+ align-items: center;
1435
+ gap: 0.5rem;
1436
+ background: transparent;
1437
+ border: none;
1438
+ color: rgba(255,255,255,0.55);
1439
+ font-weight: 600;
1440
+ font-size: 0.75rem;
1441
+ cursor: pointer;
1442
+ transition: all 0.2s ease;
1443
+ padding: 0.35rem 0.75rem;
1444
+ border-radius: 50px;
1445
+ }
1446
+ .cv-btn:hover { color: #fff; background: rgba(255,255,255,0.05); }
1447
+ .cv-btn--end { color: #ff4444; }
1448
+ .cv-btn--end .cv-btn-box {
1449
+ background: #ff4444;
1450
+ width: 8px;
1451
+ height: 8px;
1452
+ border-radius: 2px;
1453
+ }
1454
+ .cv-btn.is-muted { color: var(--cv-accent); }
1455
+ .cv-error {
1456
+ position: absolute;
1457
+ bottom: 1.25rem;
1458
+ left: 50%;
1459
+ transform: translateX(-50%);
1460
+ background: #0a0a0a;
1461
+ color: #e0e0e0;
1462
+ padding: 0.75rem 1.5rem;
1463
+ border-radius: 12px;
1464
+ font-size: 0.875rem;
1465
+ font-weight: 500;
1466
+ display: none;
1467
+ align-items: center;
1468
+ gap: 0.75rem;
1469
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
1470
+ z-index: 1000;
1471
+ border: 1px solid rgba(255, 255, 255, 0.06);
1472
+ backdrop-filter: blur(20px);
1473
+ }
1474
+ .cv-error.is-visible { display: flex; }
1475
+ .cv-error-icon { color: var(--cv-accent); flex-shrink: 0; }
1476
+ `
1477
+ );
1478
+ var styleInjected = false;
1479
+ function injectStyles() {
1480
+ if (styleInjected) return;
1481
+ const style = document.createElement("style");
1482
+ style.textContent = PANEL_CSS;
1483
+ document.head.appendChild(style);
1484
+ styleInjected = true;
1485
+ }
1486
+ var ConversationalPanel = class {
1487
+ cfg;
1488
+ container;
1489
+ agent = null;
1490
+ visualizer = null;
1491
+ timerTicker = null;
1492
+ connectingSound = null;
1493
+ connectingFadeTimer = null;
1494
+ isRunning = false;
1495
+ // Cached DOM refs
1496
+ el;
1497
+ curtain;
1498
+ curtainTitle;
1499
+ curtainDesc;
1500
+ startBtn;
1501
+ errorEl;
1502
+ errorText;
1503
+ timerEl;
1504
+ canvas;
1505
+ muteBtn;
1506
+ stopBtn;
1507
+ visualizerWrap;
1508
+ // Callbacks
1509
+ onTranscription;
1510
+ onResponse;
1511
+ onStart;
1512
+ onStop;
1513
+ onError;
1514
+ constructor(cfg) {
1515
+ this.cfg = cfg;
1516
+ this.container = cfg.container;
1517
+ injectStyles();
1518
+ this.buildDOM();
1519
+ }
1520
+ buildDOM() {
1521
+ const accent = this.cfg.accentColor || "#a25a6b";
1522
+ const bg = this.cfg.backgroundColor || "#0a0a0a";
1523
+ this.container.style.setProperty("--cv-accent", accent);
1524
+ this.container.style.setProperty("--cv-bg", bg);
1525
+ this.el = document.createElement("div");
1526
+ this.el.className = "cv-panel";
1527
+ this.el.innerHTML = `
1528
+ <div class="cv-curtain">
1529
+ <div class="cv-curtain-bg"></div>
1530
+ <div class="cv-curtain-overlay"></div>
1531
+ <div class="cv-curtain-content">
1532
+ <h3 class="cv-curtain-title">${this.esc(this.cfg.title)}</h3>
1533
+ <p class="cv-curtain-desc">${this.esc(this.cfg.description)}</p>
1534
+ <button class="cv-curtain-btn">
1535
+ <span>Start Conversation</span>
1536
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="20" height="20">
1537
+ <path d="M5 12h14M12 5l7 7-7 7"/>
1538
+ </svg>
1539
+ </button>
1540
+ </div>
1541
+ <div class="cv-error">
1542
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
1543
+ width="20" height="20" class="cv-error-icon">
1544
+ <circle cx="12" cy="12" r="10"></circle>
1545
+ <path d="M12 8v4"></path>
1546
+ <path d="M12 16h.01"></path>
1547
+ </svg>
1548
+ <span class="cv-error-text"></span>
1549
+ </div>
1550
+ </div>
1551
+ <div class="cv-header">
1552
+ <h2 class="cv-title">${this.esc(this.cfg.title)} <span class="cv-timer">00:00</span></h2>
1553
+ </div>
1554
+ <div class="cv-visualizer-wrap">
1555
+ <canvas class="cv-canvas"></canvas>
1556
+ </div>
1557
+ <div class="cv-controls">
1558
+ <div class="cv-pill">
1559
+ <button class="cv-btn cv-btn--mute">
1560
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="18" height="18">
1561
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
1562
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
1563
+ </svg>
1564
+ <span>Mute</span>
1565
+ </button>
1566
+ <button class="cv-btn cv-btn--end">
1567
+ <div class="cv-btn-box"></div>
1568
+ <span>End call</span>
1569
+ </button>
1570
+ </div>
1571
+ </div>
1572
+ `;
1573
+ this.container.appendChild(this.el);
1574
+ this.curtain = this.el.querySelector(".cv-curtain");
1575
+ if (this.cfg.curtainBgSrc) {
1576
+ this.curtain.style.backgroundImage = `url('${this.cfg.curtainBgSrc}')`;
1577
+ }
1578
+ this.curtainTitle = this.el.querySelector(".cv-curtain-title");
1579
+ this.curtainDesc = this.el.querySelector(".cv-curtain-desc");
1580
+ this.startBtn = this.el.querySelector(".cv-curtain-btn");
1581
+ this.errorEl = this.el.querySelector(".cv-error");
1582
+ this.errorText = this.el.querySelector(".cv-error-text");
1583
+ this.timerEl = this.el.querySelector(".cv-timer");
1584
+ this.canvas = this.el.querySelector(".cv-canvas");
1585
+ this.muteBtn = this.el.querySelector(".cv-btn--mute");
1586
+ this.stopBtn = this.el.querySelector(".cv-btn--end");
1587
+ this.visualizerWrap = this.el.querySelector(".cv-visualizer-wrap");
1588
+ this.startBtn.addEventListener("click", () => this.start());
1589
+ this.stopBtn.addEventListener("click", () => this.stop());
1590
+ this.muteBtn.addEventListener("click", () => this.toggleMute());
1591
+ }
1592
+ esc(s) {
1593
+ const d = document.createElement("div");
1594
+ d.textContent = s;
1595
+ return d.innerHTML;
1596
+ }
1597
+ /** Start the conversation (called when user clicks "Start Conversation" or externally) */
1598
+ async start() {
1599
+ if (this.isRunning) return;
1600
+ this.isRunning = true;
1601
+ this.startBtn.disabled = true;
1602
+ this.errorEl.classList.remove("is-visible");
1603
+ this.playConnectingSound();
1604
+ try {
1605
+ const ConvoAgent = this.cfg.ConvoAgent;
1606
+ const SphereVisualizer = this.cfg.SphereVisualizer;
1607
+ const visualizer = new SphereVisualizer(this.canvas);
1608
+ visualizer.resize(280, 280);
1609
+ this.visualizer = visualizer;
1610
+ if (this.cfg.accentColor && typeof visualizer.setAccentColor === "function") {
1611
+ const hex = this.cfg.accentColor;
1612
+ visualizer.setAccentColor(
1613
+ parseInt(hex.slice(1, 3), 16) / 255,
1614
+ parseInt(hex.slice(3, 5), 16) / 255,
1615
+ parseInt(hex.slice(5, 7), 16) / 255
1616
+ );
1617
+ }
1618
+ const agentHandle = new ConvoAgent({
1619
+ apiKey: this.cfg.apiKey,
1620
+ prompt: this.cfg.prompt,
1621
+ voice: this.cfg.voice || "M1",
1622
+ language: this.cfg.language || "en",
1623
+ onStatusChange: (status) => {
1624
+ this.el.classList.remove("cv-is-speaking", "cv-is-thinking");
1625
+ if (status === "speaking") this.el.classList.add("cv-is-speaking");
1626
+ else if (status === "thinking") this.el.classList.add("cv-is-thinking");
1627
+ },
1628
+ onTranscription: (text) => {
1629
+ this.onTranscription?.(text);
1630
+ },
1631
+ onResponse: (text) => {
1632
+ this.onResponse?.(text);
1633
+ },
1634
+ onError: (err) => {
1635
+ if (!this.isRunning) return;
1636
+ this.onError?.(err);
1637
+ this.stop();
1638
+ this.showError("Connection issue. Try again in a moment.");
1639
+ }
1640
+ });
1641
+ this.agent = agentHandle;
1642
+ visualizer.setAudioClient(agentHandle);
1643
+ this.curtain.classList.add("is-up");
1644
+ try {
1645
+ agentHandle.unlockAudioForMobile?.();
1646
+ } catch (_) {
1647
+ }
1648
+ const ok = await agentHandle.connect();
1649
+ if (ok) {
1650
+ this.fadeOutConnectingSound();
1651
+ const wrap = this.visualizerWrap;
1652
+ if (wrap) {
1653
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
1654
+ const w = Math.floor(wrap.clientWidth * dpr);
1655
+ const h = Math.floor(wrap.clientHeight * dpr);
1656
+ visualizer.resize(w, h);
1657
+ }
1658
+ visualizer.start();
1659
+ this.startTimer();
1660
+ this.onStart?.();
1661
+ if (window.lucide) window.lucide.createIcons();
1662
+ } else {
1663
+ this.fadeOutConnectingSound();
1664
+ this.playErrorTone();
1665
+ this.curtain.classList.remove("is-up");
1666
+ this.showError("Could not connect. Please try again.");
1667
+ this.isRunning = false;
1668
+ }
1669
+ } catch (err) {
1670
+ this.fadeOutConnectingSound();
1671
+ this.playErrorTone();
1672
+ console.error("ConversationalPanel start error:", err);
1673
+ this.stop();
1674
+ this.showError("Something went wrong. Please try again.");
1675
+ } finally {
1676
+ this.startBtn.disabled = false;
1677
+ }
1678
+ }
1679
+ /** Stop / disconnect the conversation */
1680
+ stop() {
1681
+ if (!this.isRunning && !this.agent && !this.visualizer) return;
1682
+ this.isRunning = false;
1683
+ this.fadeOutConnectingSound();
1684
+ this.errorEl.classList.remove("is-visible");
1685
+ if (this.agent) {
1686
+ try {
1687
+ this.agent.disconnect();
1688
+ } catch (_) {
1689
+ }
1690
+ this.agent = null;
1691
+ }
1692
+ if (this.timerTicker) {
1693
+ clearInterval(this.timerTicker);
1694
+ this.timerTicker = null;
1695
+ }
1696
+ if (this.visualizer) {
1697
+ this.visualizer.stop();
1698
+ this.visualizer = null;
1699
+ }
1700
+ this.curtain.classList.remove("is-up");
1701
+ this.el.classList.remove("cv-is-speaking", "cv-is-thinking");
1702
+ this.muteBtn.classList.remove("is-muted");
1703
+ const span = this.muteBtn.querySelector("span");
1704
+ if (span) span.textContent = "Mute";
1705
+ this.onStop?.();
1706
+ this.startBtn.disabled = false;
1707
+ }
1708
+ /** Update accent color dynamically */
1709
+ setColor(color) {
1710
+ this.cfg.accentColor = color;
1711
+ this.container.style.setProperty("--cv-accent", color);
1712
+ if (this.visualizer && typeof this.visualizer.setAccentColor === "function") {
1713
+ const r = parseInt(color.slice(1, 3), 16) / 255;
1714
+ const g = parseInt(color.slice(3, 5), 16) / 255;
1715
+ const b = parseInt(color.slice(5, 7), 16) / 255;
1716
+ this.visualizer.setAccentColor(r, g, b);
1717
+ }
1718
+ }
1719
+ /** Update title text */
1720
+ setTitle(title) {
1721
+ this.cfg.title = title;
1722
+ this.curtainTitle.textContent = title;
1723
+ const h2 = this.el.querySelector(".cv-title");
1724
+ if (h2) h2.innerHTML = `${this.esc(title)} <span class="cv-timer">${this.timerEl?.textContent || "00:00"}</span>`;
1725
+ }
1726
+ /** Update description text */
1727
+ setDescription(desc) {
1728
+ this.cfg.description = desc;
1729
+ this.curtainDesc.textContent = desc;
1730
+ }
1731
+ /** Update prompt (only takes effect on next start()) */
1732
+ setPrompt(prompt) {
1733
+ this.cfg.prompt = prompt;
1734
+ }
1735
+ /** Show an error message */
1736
+ showError(text) {
1737
+ this.errorText.textContent = text;
1738
+ this.errorEl.classList.add("is-visible");
1739
+ setTimeout(() => {
1740
+ this.errorEl.classList.remove("is-visible");
1741
+ }, 5e3);
1742
+ }
1743
+ /** Destroy the component, removing all DOM and stopping any active session */
1744
+ destroy() {
1745
+ this.stop();
1746
+ this.el.remove();
1747
+ }
1748
+ // ─── internal helpers ────────────────────────────────────
1749
+ startTimer() {
1750
+ let elapsed = 0;
1751
+ this.timerEl.textContent = "00:00";
1752
+ this.timerTicker = window.setInterval(() => {
1753
+ elapsed++;
1754
+ const m = Math.floor(elapsed / 60).toString().padStart(2, "0");
1755
+ const s = (elapsed % 60).toString().padStart(2, "0");
1756
+ this.timerEl.textContent = `${m}:${s}`;
1757
+ }, 1e3);
1758
+ }
1759
+ toggleMute() {
1760
+ if (!this.agent) return;
1761
+ try {
1762
+ const muted = this.agent.toggleMute();
1763
+ this.muteBtn.classList.toggle("is-muted", muted);
1764
+ const span = this.muteBtn.querySelector("span");
1765
+ if (span) span.textContent = muted ? "Unmute" : "Mute";
1766
+ if (muted) {
1767
+ this.agent.pauseAudio?.();
1768
+ this.visualizer?.setDeactivated?.(true);
1769
+ } else {
1770
+ this.agent.resumeAudio?.();
1771
+ this.visualizer?.setDeactivated?.(false);
1772
+ }
1773
+ } catch (_) {
1774
+ }
1775
+ }
1776
+ playConnectingSound() {
1777
+ this.fadeOutConnectingSound();
1778
+ const src = this.cfg.connectingSoundSrc || "/connecting.mp3";
1779
+ this.connectingSound = new Audio(src);
1780
+ this.connectingSound.volume = 0.6;
1781
+ this.connectingSound.play().catch(() => {
1782
+ });
1783
+ }
1784
+ fadeOutConnectingSound() {
1785
+ if (this.connectingFadeTimer) {
1786
+ clearInterval(this.connectingFadeTimer);
1787
+ this.connectingFadeTimer = null;
1788
+ }
1789
+ if (!this.connectingSound) return;
1790
+ const startVol = this.connectingSound.volume;
1791
+ const steps = 15;
1792
+ let step = 0;
1793
+ this.connectingFadeTimer = window.setInterval(() => {
1794
+ step++;
1795
+ const progress = step / steps;
1796
+ if (this.connectingSound) this.connectingSound.volume = startVol * Math.max(0, 1 - progress);
1797
+ if (step >= steps) {
1798
+ if (this.connectingFadeTimer) {
1799
+ clearInterval(this.connectingFadeTimer);
1800
+ this.connectingFadeTimer = null;
1801
+ }
1802
+ if (this.connectingSound) {
1803
+ this.connectingSound.pause();
1804
+ this.connectingSound.currentTime = 0;
1805
+ this.connectingSound = null;
1806
+ }
1807
+ }
1808
+ }, 100);
1809
+ }
1810
+ playErrorTone() {
1811
+ try {
1812
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
1813
+ const now = ctx.currentTime;
1814
+ [0, 0.3].forEach((offset, i) => {
1815
+ const osc = ctx.createOscillator();
1816
+ const gain = ctx.createGain();
1817
+ osc.connect(gain);
1818
+ gain.connect(ctx.destination);
1819
+ osc.type = "sine";
1820
+ osc.frequency.setValueAtTime(600 - i * 200, now + offset);
1821
+ gain.gain.setValueAtTime(0.25, now + offset);
1822
+ gain.gain.exponentialRampToValueAtTime(1e-3, now + offset + 0.35);
1823
+ osc.start(now + offset);
1824
+ osc.stop(now + offset + 0.4);
1825
+ });
1826
+ } catch (_) {
1827
+ }
1828
+ }
1829
+ };
1089
1830
  // Annotate the CommonJS export names for ESM import in node:
1090
1831
  0 && (module.exports = {
1091
1832
  AUDIO_CONFIG,
1092
1833
  BrowserAudioManager,
1834
+ ConversationalPanel,
1093
1835
  DEFAULT_URLS,
1094
1836
  Language,
1837
+ LokutorError,
1095
1838
  StreamResampler,
1096
1839
  TTSClient,
1097
1840
  VoiceAgentClient,
@@ -1100,6 +1843,7 @@ async function simpleTTS(options) {
1100
1843
  bytesToPcm16,
1101
1844
  calculateRMS,
1102
1845
  float32ToPcm16,
1846
+ isRetryable,
1103
1847
  normalizeAudio,
1104
1848
  pcm16ToBytes,
1105
1849
  pcm16ToFloat32,