@lokutor/sdk 1.1.17 → 1.1.19

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,159 +15,8 @@ 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, {
@@ -180,6 +24,7 @@ __export(index_exports, {
180
24
  BrowserAudioManager: () => BrowserAudioManager,
181
25
  DEFAULT_URLS: () => DEFAULT_URLS,
182
26
  Language: () => Language,
27
+ LokutorError: () => LokutorError,
183
28
  StreamResampler: () => StreamResampler,
184
29
  TTSClient: () => TTSClient,
185
30
  VoiceAgentClient: () => VoiceAgentClient,
@@ -188,6 +33,7 @@ __export(index_exports, {
188
33
  bytesToPcm16: () => bytesToPcm16,
189
34
  calculateRMS: () => calculateRMS,
190
35
  float32ToPcm16: () => float32ToPcm16,
36
+ isRetryable: () => isRetryable,
191
37
  normalizeAudio: () => normalizeAudio,
192
38
  pcm16ToBytes: () => pcm16ToBytes,
193
39
  pcm16ToFloat32: () => pcm16ToFloat32,
@@ -197,13 +43,79 @@ __export(index_exports, {
197
43
  simpleTTS: () => simpleTTS
198
44
  });
199
45
  module.exports = __toCommonJS(index_exports);
200
- init_types();
201
-
202
- // src/client.ts
203
- init_types();
204
46
 
205
- // src/browser-audio.ts
206
- init_types();
47
+ // src/types.ts
48
+ var VoiceStyle = /* @__PURE__ */ ((VoiceStyle2) => {
49
+ VoiceStyle2["F1"] = "F1";
50
+ VoiceStyle2["F2"] = "F2";
51
+ VoiceStyle2["F3"] = "F3";
52
+ VoiceStyle2["F4"] = "F4";
53
+ VoiceStyle2["F5"] = "F5";
54
+ VoiceStyle2["M1"] = "M1";
55
+ VoiceStyle2["M2"] = "M2";
56
+ VoiceStyle2["M3"] = "M3";
57
+ VoiceStyle2["M4"] = "M4";
58
+ VoiceStyle2["M5"] = "M5";
59
+ return VoiceStyle2;
60
+ })(VoiceStyle || {});
61
+ var Language = /* @__PURE__ */ ((Language2) => {
62
+ Language2["ENGLISH"] = "en";
63
+ Language2["SPANISH"] = "es";
64
+ Language2["FRENCH"] = "fr";
65
+ Language2["PORTUGUESE"] = "pt";
66
+ Language2["KOREAN"] = "ko";
67
+ return Language2;
68
+ })(Language || {});
69
+ var AUDIO_CONFIG = {
70
+ SAMPLE_RATE: 16e3,
71
+ SAMPLE_RATE_INPUT: 16e3,
72
+ SPEAKER_SAMPLE_RATE: 44100,
73
+ SAMPLE_RATE_OUTPUT: 44100,
74
+ CHANNELS: 1,
75
+ CHUNK_DURATION_MS: 20,
76
+ get CHUNK_SIZE() {
77
+ return Math.floor(this.SAMPLE_RATE * this.CHUNK_DURATION_MS / 1e3);
78
+ }
79
+ };
80
+ var DEFAULT_URLS = {
81
+ VOICE_AGENT: "wss://api.lokutor.com/ws/agent",
82
+ TTS: "wss://api.lokutor.com/ws/tts"
83
+ };
84
+ var LokutorError = class extends Error {
85
+ code;
86
+ detail;
87
+ retryable;
88
+ original;
89
+ constructor(code, message, opts) {
90
+ super(message);
91
+ this.name = "LokutorError";
92
+ this.code = code;
93
+ this.detail = opts?.detail;
94
+ this.retryable = opts?.retryable ?? isRetryableCode(code);
95
+ this.original = opts?.original;
96
+ }
97
+ };
98
+ function isRetryableCode(code) {
99
+ const fatal = [
100
+ "auth.missing_key",
101
+ "auth.invalid_key",
102
+ "auth.time_limited",
103
+ "validation.invalid_voice",
104
+ "validation.invalid_language",
105
+ "validation.text_too_long",
106
+ "validation.speed_out_of_range",
107
+ "validation.steps_out_of_range",
108
+ "validation.invalid_request_format",
109
+ "internal.cancelled"
110
+ ];
111
+ return !fatal.includes(code);
112
+ }
113
+ function isRetryable(error) {
114
+ if (error instanceof LokutorError) {
115
+ return error.retryable;
116
+ }
117
+ return false;
118
+ }
207
119
 
208
120
  // src/audio-utils.ts
209
121
  function pcm16ToFloat32(int16Data) {
@@ -264,6 +176,9 @@ function pcm16ToBytes(data) {
264
176
  return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
265
177
  }
266
178
  function bytesToPcm16(bytes) {
179
+ if (bytes.length % 2 !== 0) {
180
+ bytes = bytes.slice(0, bytes.length - 1);
181
+ }
267
182
  return new Int16Array(bytes.buffer, bytes.byteOffset, bytes.length / 2);
268
183
  }
269
184
  function normalizeAudio(data, targetPeak = 0.95) {
@@ -313,11 +228,14 @@ var StreamResampler = class {
313
228
  combined.set(this.inputBuffer);
314
229
  combined.set(inputChunk, this.inputBuffer.length);
315
230
  const ratio = this.inputRate / this.outputRate;
316
- const outputLength = Math.floor(combined.length / ratio);
231
+ let outputLength = Math.floor(combined.length / ratio);
317
232
  if (outputLength === 0 && !flush) {
318
233
  this.inputBuffer = combined;
319
234
  return new Float32Array(0);
320
235
  }
236
+ if (flush && outputLength === 0 && combined.length > 0) {
237
+ outputLength = 1;
238
+ }
321
239
  const output = new Float32Array(outputLength);
322
240
  for (let i = 0; i < outputLength; i++) {
323
241
  const pos = i * ratio;
@@ -326,10 +244,8 @@ var StreamResampler = class {
326
244
  const weight = pos - left;
327
245
  output[i] = combined[left] * (1 - weight) + combined[right] * weight;
328
246
  }
329
- const remainingSamples = Math.ceil(combined.length - outputLength * ratio);
330
- this.inputBuffer = combined.slice(
331
- combined.length - remainingSamples
332
- );
247
+ const consumed = Math.floor(outputLength * ratio);
248
+ this.inputBuffer = combined.slice(consumed);
333
249
  return output;
334
250
  }
335
251
  reset() {
@@ -491,6 +407,10 @@ var BrowserAudioManager = class {
491
407
  console.warn("AudioContext not initialized");
492
408
  return;
493
409
  }
410
+ if (pcm16Data.length % 2 !== 0) {
411
+ console.warn(`Discarding odd-length PCM buffer (${pcm16Data.length} bytes)`);
412
+ return;
413
+ }
494
414
  const int16Array = new Int16Array(
495
415
  pcm16Data.buffer,
496
416
  pcm16Data.byteOffset,
@@ -619,6 +539,54 @@ var BrowserAudioManager = class {
619
539
  };
620
540
 
621
541
  // src/client.ts
542
+ function sdkTraceEnabled() {
543
+ try {
544
+ if (typeof window === "undefined") return false;
545
+ const w = window;
546
+ return Boolean(w.LOKUTOR_TRACE) || window.localStorage?.getItem("lokutorTrace") === "1";
547
+ } catch {
548
+ return false;
549
+ }
550
+ }
551
+ function sdkTrace(...args) {
552
+ if (sdkTraceEnabled()) {
553
+ console.log("[SDK TRACE]", ...args);
554
+ }
555
+ }
556
+ function nowMs() {
557
+ if (typeof performance !== "undefined" && performance.now) {
558
+ return performance.now();
559
+ }
560
+ return Date.now();
561
+ }
562
+ function wsToHttp(url) {
563
+ return url.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
564
+ }
565
+ async function fetchJson(url, timeoutMs = 1e4) {
566
+ const controller = new AbortController();
567
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
568
+ try {
569
+ const res = await fetch(url, {
570
+ signal: controller.signal,
571
+ headers: { Accept: "application/json" }
572
+ });
573
+ clearTimeout(timer);
574
+ if (!res.ok) {
575
+ throw new LokutorError("internal.error", `HTTP ${res.status} from ${url}`, {
576
+ detail: await res.text().catch(() => ""),
577
+ retryable: res.status >= 500
578
+ });
579
+ }
580
+ return await res.json();
581
+ } catch (err) {
582
+ clearTimeout(timer);
583
+ if (err instanceof LokutorError) throw err;
584
+ throw new LokutorError("internal.error", `Failed to fetch ${url}`, {
585
+ original: err,
586
+ retryable: true
587
+ });
588
+ }
589
+ }
622
590
  function base64ToUint8Array(base64) {
623
591
  const binaryString = atob(base64);
624
592
  const bytes = new Uint8Array(binaryString.length);
@@ -627,6 +595,39 @@ function base64ToUint8Array(base64) {
627
595
  }
628
596
  return bytes;
629
597
  }
598
+ function normalizeVisemes(payload) {
599
+ if (!Array.isArray(payload)) return [];
600
+ const normalized = [];
601
+ for (const item of payload) {
602
+ if (!item || typeof item !== "object") continue;
603
+ const c = String(item.c ?? item.char ?? "sil").toLowerCase();
604
+ const t = Number(item.t ?? item.timestamp ?? 0);
605
+ const v = Number(item.v ?? item.id ?? 0);
606
+ normalized.push({
607
+ v: Number.isFinite(v) ? v : 0,
608
+ c,
609
+ t: Number.isFinite(t) ? t : 0
610
+ });
611
+ }
612
+ return normalized;
613
+ }
614
+ function extractVisemePayload(msg) {
615
+ if (Array.isArray(msg?.data)) {
616
+ return normalizeVisemes(msg.data);
617
+ }
618
+ if (Array.isArray(msg?.data?.visemes)) {
619
+ return normalizeVisemes(msg.data.visemes);
620
+ }
621
+ if (msg?.data && !Array.isArray(msg.data) && typeof msg.data === "object") {
622
+ const singularInData = normalizeVisemes([msg.data]);
623
+ if (singularInData.length > 0) return singularInData;
624
+ }
625
+ if (msg && !Array.isArray(msg) && typeof msg === "object") {
626
+ const singularAtRoot = normalizeVisemes([msg]);
627
+ if (singularAtRoot.length > 0) return singularAtRoot;
628
+ }
629
+ return [];
630
+ }
630
631
  var VoiceAgentClient = class {
631
632
  ws = null;
632
633
  apiKey;
@@ -654,11 +655,13 @@ var VoiceAgentClient = class {
654
655
  reconnecting = false;
655
656
  reconnectAttempts = 0;
656
657
  maxReconnectAttempts = 5;
658
+ serverUrl;
657
659
  constructor(config) {
658
660
  this.apiKey = config.apiKey;
659
661
  this.prompt = config.prompt;
660
662
  this.voice = config.voice || "F1" /* F1 */;
661
663
  this.language = config.language || "en" /* ENGLISH */;
664
+ this.serverUrl = config.serverUrl || DEFAULT_URLS.VOICE_AGENT;
662
665
  this.onTranscription = config.onTranscription;
663
666
  this.onResponse = config.onResponse;
664
667
  this.onAudioCallback = config.onAudio;
@@ -686,13 +689,28 @@ var VoiceAgentClient = class {
686
689
  }
687
690
  }
688
691
  return new Promise((resolve, reject) => {
692
+ let settled = false;
693
+ const settle = (fn) => {
694
+ if (!settled) {
695
+ settled = true;
696
+ fn();
697
+ }
698
+ };
689
699
  try {
690
- let url = DEFAULT_URLS.VOICE_AGENT;
700
+ let url = this.serverUrl;
691
701
  if (this.apiKey) {
692
702
  const separator = url.includes("?") ? "&" : "?";
693
703
  url += `${separator}api_key=${this.apiKey}`;
694
704
  }
695
- console.log(`\u{1F517} Connecting to ${DEFAULT_URLS.VOICE_AGENT}...`);
705
+ const redactedUrl = url.replace(/api_key=[^&]+/, "api_key=***");
706
+ sdkTrace("ws.connect", {
707
+ endpoint: this.serverUrl,
708
+ url: redactedUrl,
709
+ enableAudio: this.enableAudio,
710
+ wantVisemes: this.wantVisemes,
711
+ hasAudioManager: Boolean(this.audioManager)
712
+ });
713
+ console.log(`\u{1F517} Connecting to ${this.serverUrl}...`);
696
714
  this.ws = new WebSocket(url);
697
715
  this.ws.binaryType = "arraybuffer";
698
716
  this.ws.onopen = async () => {
@@ -700,6 +718,7 @@ var VoiceAgentClient = class {
700
718
  this.reconnectAttempts = 0;
701
719
  this.reconnecting = false;
702
720
  console.log("\u2705 Connected to voice agent!");
721
+ sdkTrace("ws.open");
703
722
  this.sendConfig();
704
723
  if (this.audioManager) {
705
724
  await this.audioManager.startMicrophone((data) => {
@@ -708,22 +727,54 @@ var VoiceAgentClient = class {
708
727
  }
709
728
  });
710
729
  }
711
- resolve(true);
730
+ settle(() => resolve(true));
712
731
  };
713
732
  this.ws.onmessage = async (event) => {
714
733
  if (event.data instanceof ArrayBuffer) {
734
+ sdkTrace("ws.message.binary", { bytes: event.data.byteLength });
715
735
  this.handleBinaryMessage(new Uint8Array(event.data));
716
736
  } else {
737
+ sdkTrace("ws.message.text", { length: String(event.data).length });
717
738
  this.handleTextMessage(event.data.toString());
718
739
  }
719
740
  };
720
741
  this.ws.onerror = (err) => {
721
- console.error("\u274C WebSocket error:", err);
722
- if (this.onError) this.onError(err);
723
- if (!this.isConnected) reject(err);
742
+ const error = new LokutorError("ws.close", "WebSocket connection error", {
743
+ detail: `readyState=${this.ws?.readyState}, bufferedAmount=${this.ws?.bufferedAmount}`,
744
+ original: err,
745
+ retryable: true
746
+ });
747
+ console.error("\u274C WebSocket error:", error.message);
748
+ sdkTrace("ws.error", { code: error.code, message: error.message });
749
+ if (this.onError) this.onError(error);
750
+ if (!this.isConnected) {
751
+ settle(() => reject(error));
752
+ }
724
753
  };
725
- this.ws.onclose = () => {
754
+ this.ws.onclose = (event) => {
726
755
  this.isConnected = false;
756
+ const diagnostic = {
757
+ code: event.code,
758
+ reason: event.reason,
759
+ wasClean: event.wasClean,
760
+ url: this.serverUrl,
761
+ isUserDisconnect: this.isUserDisconnect,
762
+ reconnectAttempts: this.reconnectAttempts
763
+ };
764
+ sdkTrace("ws.close", diagnostic);
765
+ if (!settled && !this.isUserDisconnect) {
766
+ const error = new LokutorError("ws.close", `WebSocket closed unexpectedly (code ${event.code})`, {
767
+ detail: event.reason || "No reason provided",
768
+ retryable: event.code !== 1008
769
+ });
770
+ settle(() => reject(error));
771
+ return;
772
+ }
773
+ if (!event.wasClean && event.code === 1006 && this.reconnectAttempts === 0 && !this.isUserDisconnect) {
774
+ console.error("\u274C Connection rejected (code 1006). Likely causes: invalid API key, endpoint unavailable, or CORS blocked.");
775
+ console.error(" URL:", this.serverUrl.replace(/api_key=[^&]+/, "api_key=***"));
776
+ }
777
+ console.log(`\u{1F50C} WebSocket closed \u2014 code: ${event.code}, reason: "${event.reason || "none"}", clean: ${event.wasClean}`);
727
778
  if (!this.isUserDisconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
728
779
  this.reconnecting = true;
729
780
  this.reconnectAttempts++;
@@ -739,37 +790,27 @@ var VoiceAgentClient = class {
739
790
  }
740
791
  };
741
792
  } catch (err) {
742
- if (this.onError) this.onError(err);
743
- reject(err);
793
+ const error = err instanceof LokutorError ? err : new LokutorError("internal.error", "Failed to create WebSocket connection", { original: err });
794
+ if (this.onError) this.onError(error);
795
+ settle(() => reject(error));
744
796
  }
745
797
  });
746
798
  }
747
799
  /**
748
800
  * 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.
801
+ * This is the recommended way to start a conversation in browser environments.
750
802
  */
751
803
  async startManaged(config) {
752
804
  this.enableAudio = true;
753
805
  if (config?.audioManager) {
754
806
  this.audioManager = config.audioManager;
755
807
  } 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
- }
808
+ if (typeof window === "undefined") {
809
+ throw new LokutorError("internal.error", "startManaged() requires a browser environment. Pass a custom audioManager for non-browser runtimes.", { retryable: false });
765
810
  }
811
+ this.audioManager = new BrowserAudioManager();
766
812
  }
767
813
  await this.connect();
768
- if (this.audioManager && this.isConnected) {
769
- await this.audioManager.startMicrophone((data) => {
770
- this.sendAudio(data);
771
- });
772
- }
773
814
  return this;
774
815
  }
775
816
  /**
@@ -777,10 +818,18 @@ var VoiceAgentClient = class {
777
818
  */
778
819
  sendConfig() {
779
820
  if (!this.ws || !this.isConnected) return;
780
- this.ws.send(JSON.stringify({ type: "prompt", data: this.prompt }));
821
+ this.ws.send(JSON.stringify({ type: "visemes", data: this.wantVisemes }));
781
822
  this.ws.send(JSON.stringify({ type: "voice", data: this.voice }));
782
823
  this.ws.send(JSON.stringify({ type: "language", data: this.language }));
783
- this.ws.send(JSON.stringify({ type: "visemes", data: this.wantVisemes }));
824
+ this.ws.send(JSON.stringify({ type: "prompt", data: this.prompt }));
825
+ this.ws.send(JSON.stringify({ type: "rates", playback: 44100, input: 16e3 }));
826
+ sdkTrace("ws.send.config", {
827
+ promptLen: this.prompt?.length || 0,
828
+ voice: this.voice,
829
+ language: this.language,
830
+ visemes: this.wantVisemes,
831
+ tools: this.tools?.length || 0
832
+ });
784
833
  if (this.tools && this.tools.length > 0) {
785
834
  this.ws.send(JSON.stringify({ type: "tools", data: this.tools }));
786
835
  }
@@ -814,6 +863,15 @@ var VoiceAgentClient = class {
814
863
  handleTextMessage(text) {
815
864
  try {
816
865
  const msg = JSON.parse(text);
866
+ if (!msg || typeof msg !== "object") {
867
+ return;
868
+ }
869
+ sdkTrace("ws.recv.type", {
870
+ type: msg.type,
871
+ hasData: Object.prototype.hasOwnProperty.call(msg, "data"),
872
+ dataKind: Array.isArray(msg.data) ? "array" : typeof msg.data,
873
+ generation: msg.generation ?? null
874
+ });
817
875
  switch (msg.type) {
818
876
  case "audio":
819
877
  if (msg.data) {
@@ -826,7 +884,7 @@ var VoiceAgentClient = class {
826
884
  this.messages.push({
827
885
  role,
828
886
  text: msg.data,
829
- timestamp: Date.now()
887
+ timestamp: nowMs()
830
888
  });
831
889
  if (msg.role === "user") {
832
890
  if (this.onTranscription) this.onTranscription(msg.data);
@@ -858,19 +916,44 @@ var VoiceAgentClient = class {
858
916
  console.log(`${icons[msg.data] || ""} Status: ${msg.data}`);
859
917
  break;
860
918
  case "visemes":
861
- if (Array.isArray(msg.data) && msg.data.length > 0) {
862
- this.emit("visemes", msg.data);
919
+ case "viseme": {
920
+ const msgGen = msg.generation ?? this.currentGeneration;
921
+ if (msgGen < this.currentGeneration) {
922
+ sdkTrace("visemes.discard", { msgGen, currentGen: this.currentGeneration });
923
+ break;
924
+ }
925
+ const normalized = extractVisemePayload(msg);
926
+ const explicitlyEmptyArray = Array.isArray(msg?.data) || Array.isArray(msg?.data?.visemes);
927
+ sdkTrace("visemes.recv", {
928
+ rawType: msg.type,
929
+ normalizedCount: normalized.length,
930
+ first: normalized[0] ?? null
931
+ });
932
+ if (normalized.length > 0 || explicitlyEmptyArray) {
933
+ this.emit("visemes", normalized);
863
934
  }
864
935
  break;
865
- case "error":
866
- if (this.onError) this.onError(msg.data);
867
- console.error(`\u274C Server error: ${msg.data}`);
936
+ }
937
+ case "error": {
938
+ const backendCode = msg.data?.code ?? "internal.error";
939
+ const backendMessage = msg.data?.message ?? msg.data ?? "Unknown server error";
940
+ const backendDetail = msg.data?.detail;
941
+ const backendRetryable = msg.data?.retryable ?? true;
942
+ const error = new LokutorError(backendCode, backendMessage, {
943
+ detail: backendDetail,
944
+ retryable: backendRetryable
945
+ });
946
+ if (this.onError) this.onError(error);
947
+ console.error(`\u274C Server error: [${error.code}] ${error.message}`);
868
948
  break;
949
+ }
869
950
  case "tool_call":
870
951
  console.log(`\u{1F6E0}\uFE0F Tool Call: ${msg.name}(${msg.arguments})`);
871
952
  break;
872
953
  }
873
954
  } catch (e) {
955
+ sdkTrace("ws.recv.parse_error", { preview: text?.slice(0, 120) });
956
+ console.debug("Failed to parse message:", e);
874
957
  }
875
958
  }
876
959
  /**
@@ -932,6 +1015,21 @@ var VoiceAgentClient = class {
932
1015
  this.audioManager.cleanup();
933
1016
  }
934
1017
  this.isConnected = false;
1018
+ this.reconnecting = false;
1019
+ this.reconnectAttempts = 0;
1020
+ }
1021
+ /**
1022
+ * Returns true if the client is currently connected.
1023
+ */
1024
+ get connected() {
1025
+ return this.isConnected;
1026
+ }
1027
+ /**
1028
+ * Returns the current generation counter.
1029
+ * Useful for correlating audio/viseme chunks with utterances.
1030
+ */
1031
+ get generation() {
1032
+ return this.currentGeneration;
935
1033
  }
936
1034
  /**
937
1035
  * Toggles the microphone mute state (if managed by client)
@@ -954,17 +1052,70 @@ var VoiceAgentClient = class {
954
1052
  }
955
1053
  return 0;
956
1054
  }
1055
+ /**
1056
+ * Fetch available voice styles from the server.
1057
+ * No authentication required.
1058
+ */
1059
+ static async fetchVoices(baseUrl) {
1060
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1061
+ const data = await fetchJson(`${url}/voices`);
1062
+ return data.voices || [];
1063
+ }
1064
+ /**
1065
+ * Fetch supported languages from the server.
1066
+ * No authentication required.
1067
+ */
1068
+ static async fetchLanguages(baseUrl) {
1069
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1070
+ const data = await fetchJson(`${url}/languages`);
1071
+ return data.languages || [];
1072
+ }
1073
+ /**
1074
+ * Fetch loaded TTS model versions from the server.
1075
+ * No authentication required.
1076
+ */
1077
+ static async fetchModels(baseUrl) {
1078
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1079
+ const data = await fetchJson(`${url}/models`);
1080
+ return data.models || [];
1081
+ }
1082
+ /**
1083
+ * Fetch server configuration (limits and defaults).
1084
+ * No authentication required.
1085
+ */
1086
+ static async fetchConfig(baseUrl) {
1087
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1088
+ return fetchJson(`${url}/config`);
1089
+ }
1090
+ /**
1091
+ * Fetch rich runtime status from the server.
1092
+ * No authentication required.
1093
+ */
1094
+ static async fetchStatus(baseUrl) {
1095
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1096
+ return fetchJson(`${url}/status`);
1097
+ }
1098
+ /**
1099
+ * Fetch health/liveness status from the server.
1100
+ * No authentication required.
1101
+ */
1102
+ static async fetchHealth(baseUrl) {
1103
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1104
+ return fetchJson(`${url}/health`);
1105
+ }
957
1106
  /**
958
1107
  * Update the system prompt mid-conversation
959
1108
  */
960
1109
  updatePrompt(newPrompt) {
961
1110
  this.prompt = newPrompt;
962
- if (this.ws && this.isConnected) {
1111
+ if (this.ws && this.ws.readyState === WebSocket.OPEN && this.isConnected) {
963
1112
  try {
964
1113
  this.ws.send(JSON.stringify({ type: "prompt", data: newPrompt }));
965
1114
  console.log(`\u2699\uFE0F Updated prompt: ${newPrompt.substring(0, 50)}...`);
966
1115
  } catch (error) {
967
- console.error("Error updating prompt:", error);
1116
+ const err = new LokutorError("internal.error", "Failed to update prompt", { original: error });
1117
+ if (this.onError) this.onError(err);
1118
+ console.error("Error updating prompt:", err.message);
968
1119
  }
969
1120
  } else {
970
1121
  console.warn("Not connected - prompt will be updated on next connection");
@@ -1027,37 +1178,52 @@ var TTSClient = class {
1027
1178
  visemes: options.visemes || false
1028
1179
  };
1029
1180
  ws.send(JSON.stringify(req));
1030
- startTime = Date.now();
1181
+ startTime = nowMs();
1031
1182
  };
1032
1183
  ws.onmessage = async (event) => {
1033
1184
  refreshTimeout();
1034
1185
  if (event.data instanceof ArrayBuffer) {
1035
1186
  if (!firstByteReceived) {
1036
- const ttfb = Date.now() - startTime;
1187
+ const ttfb = nowMs() - startTime;
1037
1188
  if (options.onTTFB) options.onTTFB(ttfb);
1038
1189
  firstByteReceived = true;
1039
1190
  }
1040
1191
  if (options.onAudio) options.onAudio(new Uint8Array(event.data));
1041
- } else {
1042
- const text = event.data.toString();
1043
- if (text === "EOS") {
1192
+ return;
1193
+ }
1194
+ const text = event.data.toString();
1195
+ if (text === "EOS") {
1196
+ if (activityTimeout) clearTimeout(activityTimeout);
1197
+ ws.close();
1198
+ resolve();
1199
+ return;
1200
+ }
1201
+ try {
1202
+ const msg = JSON.parse(text);
1203
+ if (msg.type === "audio" && msg.data) {
1204
+ const audioBuffer = base64ToUint8Array(msg.data);
1205
+ if (!firstByteReceived) {
1206
+ const ttfb = nowMs() - startTime;
1207
+ if (options.onTTFB) options.onTTFB(ttfb);
1208
+ firstByteReceived = true;
1209
+ }
1210
+ if (options.onAudio) options.onAudio(audioBuffer);
1211
+ return;
1212
+ }
1213
+ if (msg.type === "visemes" && Array.isArray(msg.data) && options.onVisemes) {
1214
+ options.onVisemes(normalizeVisemes(msg.data));
1215
+ return;
1216
+ }
1217
+ if (Array.isArray(msg) && options.onVisemes) {
1218
+ options.onVisemes(normalizeVisemes(msg));
1219
+ return;
1220
+ }
1221
+ if (msg.type === "eos") {
1044
1222
  if (activityTimeout) clearTimeout(activityTimeout);
1045
1223
  ws.close();
1046
1224
  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
1225
  }
1226
+ } catch (e) {
1061
1227
  }
1062
1228
  };
1063
1229
  ws.onerror = (err) => {
@@ -1092,6 +1258,7 @@ async function simpleTTS(options) {
1092
1258
  BrowserAudioManager,
1093
1259
  DEFAULT_URLS,
1094
1260
  Language,
1261
+ LokutorError,
1095
1262
  StreamResampler,
1096
1263
  TTSClient,
1097
1264
  VoiceAgentClient,
@@ -1100,6 +1267,7 @@ async function simpleTTS(options) {
1100
1267
  bytesToPcm16,
1101
1268
  calculateRMS,
1102
1269
  float32ToPcm16,
1270
+ isRetryable,
1103
1271
  normalizeAudio,
1104
1272
  pcm16ToBytes,
1105
1273
  pcm16ToFloat32,