@lokutor/sdk 1.1.15 → 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
@@ -24,6 +24,7 @@ __export(index_exports, {
24
24
  BrowserAudioManager: () => BrowserAudioManager,
25
25
  DEFAULT_URLS: () => DEFAULT_URLS,
26
26
  Language: () => Language,
27
+ LokutorError: () => LokutorError,
27
28
  StreamResampler: () => StreamResampler,
28
29
  TTSClient: () => TTSClient,
29
30
  VoiceAgentClient: () => VoiceAgentClient,
@@ -32,6 +33,7 @@ __export(index_exports, {
32
33
  bytesToPcm16: () => bytesToPcm16,
33
34
  calculateRMS: () => calculateRMS,
34
35
  float32ToPcm16: () => float32ToPcm16,
36
+ isRetryable: () => isRetryable,
35
37
  normalizeAudio: () => normalizeAudio,
36
38
  pcm16ToBytes: () => pcm16ToBytes,
37
39
  pcm16ToFloat32: () => pcm16ToFloat32,
@@ -66,7 +68,9 @@ var Language = /* @__PURE__ */ ((Language2) => {
66
68
  })(Language || {});
67
69
  var AUDIO_CONFIG = {
68
70
  SAMPLE_RATE: 16e3,
71
+ SAMPLE_RATE_INPUT: 16e3,
69
72
  SPEAKER_SAMPLE_RATE: 44100,
73
+ SAMPLE_RATE_OUTPUT: 44100,
70
74
  CHANNELS: 1,
71
75
  CHUNK_DURATION_MS: 20,
72
76
  get CHUNK_SIZE() {
@@ -77,6 +81,41 @@ var DEFAULT_URLS = {
77
81
  VOICE_AGENT: "wss://api.lokutor.com/ws/agent",
78
82
  TTS: "wss://api.lokutor.com/ws/tts"
79
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
+ }
80
119
 
81
120
  // src/audio-utils.ts
82
121
  function pcm16ToFloat32(int16Data) {
@@ -137,6 +176,9 @@ function pcm16ToBytes(data) {
137
176
  return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
138
177
  }
139
178
  function bytesToPcm16(bytes) {
179
+ if (bytes.length % 2 !== 0) {
180
+ bytes = bytes.slice(0, bytes.length - 1);
181
+ }
140
182
  return new Int16Array(bytes.buffer, bytes.byteOffset, bytes.length / 2);
141
183
  }
142
184
  function normalizeAudio(data, targetPeak = 0.95) {
@@ -186,11 +228,14 @@ var StreamResampler = class {
186
228
  combined.set(this.inputBuffer);
187
229
  combined.set(inputChunk, this.inputBuffer.length);
188
230
  const ratio = this.inputRate / this.outputRate;
189
- const outputLength = Math.floor(combined.length / ratio);
231
+ let outputLength = Math.floor(combined.length / ratio);
190
232
  if (outputLength === 0 && !flush) {
191
233
  this.inputBuffer = combined;
192
234
  return new Float32Array(0);
193
235
  }
236
+ if (flush && outputLength === 0 && combined.length > 0) {
237
+ outputLength = 1;
238
+ }
194
239
  const output = new Float32Array(outputLength);
195
240
  for (let i = 0; i < outputLength; i++) {
196
241
  const pos = i * ratio;
@@ -199,10 +244,8 @@ var StreamResampler = class {
199
244
  const weight = pos - left;
200
245
  output[i] = combined[left] * (1 - weight) + combined[right] * weight;
201
246
  }
202
- const remainingSamples = Math.ceil(combined.length - outputLength * ratio);
203
- this.inputBuffer = combined.slice(
204
- combined.length - remainingSamples
205
- );
247
+ const consumed = Math.floor(outputLength * ratio);
248
+ this.inputBuffer = combined.slice(consumed);
206
249
  return output;
207
250
  }
208
251
  reset() {
@@ -364,6 +407,10 @@ var BrowserAudioManager = class {
364
407
  console.warn("AudioContext not initialized");
365
408
  return;
366
409
  }
410
+ if (pcm16Data.length % 2 !== 0) {
411
+ console.warn(`Discarding odd-length PCM buffer (${pcm16Data.length} bytes)`);
412
+ return;
413
+ }
367
414
  const int16Array = new Int16Array(
368
415
  pcm16Data.buffer,
369
416
  pcm16Data.byteOffset,
@@ -492,6 +539,54 @@ var BrowserAudioManager = class {
492
539
  };
493
540
 
494
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
+ }
495
590
  function base64ToUint8Array(base64) {
496
591
  const binaryString = atob(base64);
497
592
  const bytes = new Uint8Array(binaryString.length);
@@ -500,6 +595,39 @@ function base64ToUint8Array(base64) {
500
595
  }
501
596
  return bytes;
502
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
+ }
503
631
  var VoiceAgentClient = class {
504
632
  ws = null;
505
633
  apiKey;
@@ -521,16 +649,19 @@ var VoiceAgentClient = class {
521
649
  audioManager = null;
522
650
  enableAudio = false;
523
651
  currentGeneration = 0;
652
+ listeners = {};
524
653
  // Connection resilience
525
654
  isUserDisconnect = false;
526
655
  reconnecting = false;
527
656
  reconnectAttempts = 0;
528
657
  maxReconnectAttempts = 5;
658
+ serverUrl;
529
659
  constructor(config) {
530
660
  this.apiKey = config.apiKey;
531
661
  this.prompt = config.prompt;
532
662
  this.voice = config.voice || "F1" /* F1 */;
533
663
  this.language = config.language || "en" /* ENGLISH */;
664
+ this.serverUrl = config.serverUrl || DEFAULT_URLS.VOICE_AGENT;
534
665
  this.onTranscription = config.onTranscription;
535
666
  this.onResponse = config.onResponse;
536
667
  this.onAudioCallback = config.onAudio;
@@ -543,23 +674,43 @@ var VoiceAgentClient = class {
543
674
  }
544
675
  /**
545
676
  * Connect to the Lokutor Voice Agent server
677
+ * @param customAudioManager Optional replacement for the default audio hardware handler
546
678
  */
547
- async connect() {
679
+ async connect(customAudioManager) {
548
680
  this.isUserDisconnect = false;
549
- if (this.enableAudio) {
550
- if (!this.audioManager) {
681
+ if (this.enableAudio || customAudioManager) {
682
+ if (customAudioManager) {
683
+ this.audioManager = customAudioManager;
684
+ } else if (!this.audioManager && typeof window !== "undefined") {
551
685
  this.audioManager = new BrowserAudioManager();
552
686
  }
553
- await this.audioManager.init();
687
+ if (this.audioManager) {
688
+ await this.audioManager.init();
689
+ }
554
690
  }
555
691
  return new Promise((resolve, reject) => {
692
+ let settled = false;
693
+ const settle = (fn) => {
694
+ if (!settled) {
695
+ settled = true;
696
+ fn();
697
+ }
698
+ };
556
699
  try {
557
- let url = DEFAULT_URLS.VOICE_AGENT;
700
+ let url = this.serverUrl;
558
701
  if (this.apiKey) {
559
702
  const separator = url.includes("?") ? "&" : "?";
560
703
  url += `${separator}api_key=${this.apiKey}`;
561
704
  }
562
- 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}...`);
563
714
  this.ws = new WebSocket(url);
564
715
  this.ws.binaryType = "arraybuffer";
565
716
  this.ws.onopen = async () => {
@@ -567,6 +718,7 @@ var VoiceAgentClient = class {
567
718
  this.reconnectAttempts = 0;
568
719
  this.reconnecting = false;
569
720
  console.log("\u2705 Connected to voice agent!");
721
+ sdkTrace("ws.open");
570
722
  this.sendConfig();
571
723
  if (this.audioManager) {
572
724
  await this.audioManager.startMicrophone((data) => {
@@ -575,22 +727,54 @@ var VoiceAgentClient = class {
575
727
  }
576
728
  });
577
729
  }
578
- resolve(true);
730
+ settle(() => resolve(true));
579
731
  };
580
732
  this.ws.onmessage = async (event) => {
581
733
  if (event.data instanceof ArrayBuffer) {
734
+ sdkTrace("ws.message.binary", { bytes: event.data.byteLength });
582
735
  this.handleBinaryMessage(new Uint8Array(event.data));
583
736
  } else {
737
+ sdkTrace("ws.message.text", { length: String(event.data).length });
584
738
  this.handleTextMessage(event.data.toString());
585
739
  }
586
740
  };
587
741
  this.ws.onerror = (err) => {
588
- console.error("\u274C WebSocket error:", err);
589
- if (this.onError) this.onError(err);
590
- 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
+ }
591
753
  };
592
- this.ws.onclose = () => {
754
+ this.ws.onclose = (event) => {
593
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}`);
594
778
  if (!this.isUserDisconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
595
779
  this.reconnecting = true;
596
780
  this.reconnectAttempts++;
@@ -606,20 +790,46 @@ var VoiceAgentClient = class {
606
790
  }
607
791
  };
608
792
  } catch (err) {
609
- if (this.onError) this.onError(err);
610
- 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));
611
796
  }
612
797
  });
613
798
  }
799
+ /**
800
+ * The "Golden Path" - Starts a managed session with hardware handled automatically.
801
+ * This is the recommended way to start a conversation in browser environments.
802
+ */
803
+ async startManaged(config) {
804
+ this.enableAudio = true;
805
+ if (config?.audioManager) {
806
+ this.audioManager = config.audioManager;
807
+ } else if (!this.audioManager) {
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 });
810
+ }
811
+ this.audioManager = new BrowserAudioManager();
812
+ }
813
+ await this.connect();
814
+ return this;
815
+ }
614
816
  /**
615
817
  * Send initial configuration to the server
616
818
  */
617
819
  sendConfig() {
618
820
  if (!this.ws || !this.isConnected) return;
619
- this.ws.send(JSON.stringify({ type: "prompt", data: this.prompt }));
821
+ this.ws.send(JSON.stringify({ type: "visemes", data: this.wantVisemes }));
620
822
  this.ws.send(JSON.stringify({ type: "voice", data: this.voice }));
621
823
  this.ws.send(JSON.stringify({ type: "language", data: this.language }));
622
- 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
+ });
623
833
  if (this.tools && this.tools.length > 0) {
624
834
  this.ws.send(JSON.stringify({ type: "tools", data: this.tools }));
625
835
  }
@@ -653,6 +863,15 @@ var VoiceAgentClient = class {
653
863
  handleTextMessage(text) {
654
864
  try {
655
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
+ });
656
875
  switch (msg.type) {
657
876
  case "audio":
658
877
  if (msg.data) {
@@ -665,7 +884,7 @@ var VoiceAgentClient = class {
665
884
  this.messages.push({
666
885
  role,
667
886
  text: msg.data,
668
- timestamp: Date.now()
887
+ timestamp: nowMs()
669
888
  });
670
889
  if (msg.role === "user") {
671
890
  if (this.onTranscription) this.onTranscription(msg.data);
@@ -697,36 +916,91 @@ var VoiceAgentClient = class {
697
916
  console.log(`${icons[msg.data] || ""} Status: ${msg.data}`);
698
917
  break;
699
918
  case "visemes":
700
- if (Array.isArray(msg.data) && msg.data.length > 0) {
701
- 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);
702
934
  }
703
935
  break;
704
- case "error":
705
- if (this.onError) this.onError(msg.data);
706
- 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}`);
707
948
  break;
949
+ }
708
950
  case "tool_call":
709
951
  console.log(`\u{1F6E0}\uFE0F Tool Call: ${msg.name}(${msg.arguments})`);
710
952
  break;
711
953
  }
712
954
  } catch (e) {
955
+ sdkTrace("ws.recv.parse_error", { preview: text?.slice(0, 120) });
956
+ console.debug("Failed to parse message:", e);
713
957
  }
714
958
  }
715
- audioListeners = [];
716
- emit(event, data) {
717
- if (event === "audio") {
718
- if (this.onAudioCallback) this.onAudioCallback(data);
719
- this.audioListeners.forEach((l) => l(data));
720
- } else if (event === "visemes") {
721
- if (this.onVisemesCallback) this.onVisemesCallback(data);
722
- this.visemeListeners.forEach((l) => l(data));
959
+ /**
960
+ * Register an event listener (for Python parity)
961
+ */
962
+ on(event, callback) {
963
+ if (!this.listeners[event]) {
964
+ this.listeners[event] = [];
965
+ }
966
+ this.listeners[event].push(callback);
967
+ return this;
968
+ }
969
+ /**
970
+ * Internal emitter for all events
971
+ */
972
+ emit(event, ...args) {
973
+ const legacyMap = {
974
+ "transcription": "onTranscription",
975
+ "response": "onResponse",
976
+ "audio": "onAudioCallback",
977
+ "visemes": "onVisemesCallback",
978
+ "status": "onStatus",
979
+ "error": "onError"
980
+ };
981
+ const legacyKey = legacyMap[event];
982
+ if (legacyKey && this[legacyKey]) {
983
+ try {
984
+ this[legacyKey](...args);
985
+ } catch (e) {
986
+ console.error(`Error in legacy callback ${legacyKey}:`, e);
987
+ }
988
+ }
989
+ if (this.listeners[event]) {
990
+ this.listeners[event].forEach((cb) => {
991
+ try {
992
+ cb(...args);
993
+ } catch (e) {
994
+ console.error(`Error in listener for ${event}:`, e);
995
+ }
996
+ });
723
997
  }
724
998
  }
725
999
  onAudio(callback) {
726
- this.audioListeners.push(callback);
1000
+ this.on("audio", callback);
727
1001
  }
728
1002
  onVisemes(callback) {
729
- this.visemeListeners.push(callback);
1003
+ this.on("visemes", callback);
730
1004
  }
731
1005
  /**
732
1006
  * Disconnect from the server
@@ -741,6 +1015,21 @@ var VoiceAgentClient = class {
741
1015
  this.audioManager.cleanup();
742
1016
  }
743
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;
744
1033
  }
745
1034
  /**
746
1035
  * Toggles the microphone mute state (if managed by client)
@@ -763,17 +1052,70 @@ var VoiceAgentClient = class {
763
1052
  }
764
1053
  return 0;
765
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
+ }
766
1106
  /**
767
1107
  * Update the system prompt mid-conversation
768
1108
  */
769
1109
  updatePrompt(newPrompt) {
770
1110
  this.prompt = newPrompt;
771
- if (this.ws && this.isConnected) {
1111
+ if (this.ws && this.ws.readyState === WebSocket.OPEN && this.isConnected) {
772
1112
  try {
773
1113
  this.ws.send(JSON.stringify({ type: "prompt", data: newPrompt }));
774
1114
  console.log(`\u2699\uFE0F Updated prompt: ${newPrompt.substring(0, 50)}...`);
775
1115
  } catch (error) {
776
- 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);
777
1119
  }
778
1120
  } else {
779
1121
  console.warn("Not connected - prompt will be updated on next connection");
@@ -805,15 +1147,28 @@ var TTSClient = class {
805
1147
  */
806
1148
  synthesize(options) {
807
1149
  return new Promise((resolve, reject) => {
1150
+ let activityTimeout;
1151
+ let ws;
1152
+ let startTime;
1153
+ let firstByteReceived = false;
1154
+ const refreshTimeout = () => {
1155
+ if (activityTimeout) clearTimeout(activityTimeout);
1156
+ activityTimeout = setTimeout(() => {
1157
+ console.log("\u23F1\uFE0F TTS synthesis reached inactivity timeout (2s) - resolving");
1158
+ if (ws) ws.close();
1159
+ resolve();
1160
+ }, 2e3);
1161
+ };
808
1162
  try {
809
1163
  let url = DEFAULT_URLS.TTS;
810
1164
  if (this.apiKey) {
811
1165
  const separator = url.includes("?") ? "&" : "?";
812
1166
  url += `${separator}api_key=${this.apiKey}`;
813
1167
  }
814
- const ws = new WebSocket(url);
1168
+ ws = new WebSocket(url);
815
1169
  ws.binaryType = "arraybuffer";
816
1170
  ws.onopen = () => {
1171
+ refreshTimeout();
817
1172
  const req = {
818
1173
  text: options.text,
819
1174
  voice: options.voice || "F1" /* F1 */,
@@ -823,28 +1178,65 @@ var TTSClient = class {
823
1178
  visemes: options.visemes || false
824
1179
  };
825
1180
  ws.send(JSON.stringify(req));
1181
+ startTime = nowMs();
826
1182
  };
827
1183
  ws.onmessage = async (event) => {
1184
+ refreshTimeout();
828
1185
  if (event.data instanceof ArrayBuffer) {
1186
+ if (!firstByteReceived) {
1187
+ const ttfb = nowMs() - startTime;
1188
+ if (options.onTTFB) options.onTTFB(ttfb);
1189
+ firstByteReceived = true;
1190
+ }
829
1191
  if (options.onAudio) options.onAudio(new Uint8Array(event.data));
830
- } else {
831
- try {
832
- const msg = JSON.parse(event.data.toString());
833
- if (Array.isArray(msg) && options.onVisemes) {
834
- options.onVisemes(msg);
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;
835
1209
  }
836
- } catch (e) {
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") {
1222
+ if (activityTimeout) clearTimeout(activityTimeout);
1223
+ ws.close();
1224
+ resolve();
837
1225
  }
1226
+ } catch (e) {
838
1227
  }
839
1228
  };
840
1229
  ws.onerror = (err) => {
1230
+ if (activityTimeout) clearTimeout(activityTimeout);
841
1231
  if (options.onError) options.onError(err);
842
1232
  reject(err);
843
1233
  };
844
1234
  ws.onclose = () => {
1235
+ if (activityTimeout) clearTimeout(activityTimeout);
845
1236
  resolve();
846
1237
  };
847
1238
  } catch (err) {
1239
+ if (activityTimeout) clearTimeout(activityTimeout);
848
1240
  if (options.onError) options.onError(err);
849
1241
  reject(err);
850
1242
  }
@@ -866,6 +1258,7 @@ async function simpleTTS(options) {
866
1258
  BrowserAudioManager,
867
1259
  DEFAULT_URLS,
868
1260
  Language,
1261
+ LokutorError,
869
1262
  StreamResampler,
870
1263
  TTSClient,
871
1264
  VoiceAgentClient,
@@ -874,6 +1267,7 @@ async function simpleTTS(options) {
874
1267
  bytesToPcm16,
875
1268
  calculateRMS,
876
1269
  float32ToPcm16,
1270
+ isRetryable,
877
1271
  normalizeAudio,
878
1272
  pcm16ToBytes,
879
1273
  pcm16ToFloat32,