@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.mjs CHANGED
@@ -1,9 +1,75 @@
1
- import {
2
- AUDIO_CONFIG,
3
- DEFAULT_URLS,
4
- Language,
5
- VoiceStyle
6
- } from "./chunk-SNNPJP5R.mjs";
1
+ // src/types.ts
2
+ var VoiceStyle = /* @__PURE__ */ ((VoiceStyle2) => {
3
+ VoiceStyle2["F1"] = "F1";
4
+ VoiceStyle2["F2"] = "F2";
5
+ VoiceStyle2["F3"] = "F3";
6
+ VoiceStyle2["F4"] = "F4";
7
+ VoiceStyle2["F5"] = "F5";
8
+ VoiceStyle2["M1"] = "M1";
9
+ VoiceStyle2["M2"] = "M2";
10
+ VoiceStyle2["M3"] = "M3";
11
+ VoiceStyle2["M4"] = "M4";
12
+ VoiceStyle2["M5"] = "M5";
13
+ return VoiceStyle2;
14
+ })(VoiceStyle || {});
15
+ var Language = /* @__PURE__ */ ((Language2) => {
16
+ Language2["ENGLISH"] = "en";
17
+ Language2["SPANISH"] = "es";
18
+ Language2["FRENCH"] = "fr";
19
+ Language2["PORTUGUESE"] = "pt";
20
+ Language2["KOREAN"] = "ko";
21
+ return Language2;
22
+ })(Language || {});
23
+ var AUDIO_CONFIG = {
24
+ SAMPLE_RATE: 16e3,
25
+ SAMPLE_RATE_INPUT: 16e3,
26
+ SPEAKER_SAMPLE_RATE: 44100,
27
+ SAMPLE_RATE_OUTPUT: 44100,
28
+ CHANNELS: 1,
29
+ CHUNK_DURATION_MS: 20,
30
+ get CHUNK_SIZE() {
31
+ return Math.floor(this.SAMPLE_RATE * this.CHUNK_DURATION_MS / 1e3);
32
+ }
33
+ };
34
+ var DEFAULT_URLS = {
35
+ VOICE_AGENT: "wss://api.lokutor.com/ws/agent",
36
+ TTS: "wss://api.lokutor.com/ws/tts"
37
+ };
38
+ var LokutorError = class extends Error {
39
+ code;
40
+ detail;
41
+ retryable;
42
+ original;
43
+ constructor(code, message, opts) {
44
+ super(message);
45
+ this.name = "LokutorError";
46
+ this.code = code;
47
+ this.detail = opts?.detail;
48
+ this.retryable = opts?.retryable ?? isRetryableCode(code);
49
+ this.original = opts?.original;
50
+ }
51
+ };
52
+ function isRetryableCode(code) {
53
+ const fatal = [
54
+ "auth.missing_key",
55
+ "auth.invalid_key",
56
+ "auth.time_limited",
57
+ "validation.invalid_voice",
58
+ "validation.invalid_language",
59
+ "validation.text_too_long",
60
+ "validation.speed_out_of_range",
61
+ "validation.steps_out_of_range",
62
+ "validation.invalid_request_format",
63
+ "internal.cancelled"
64
+ ];
65
+ return !fatal.includes(code);
66
+ }
67
+ function isRetryable(error) {
68
+ if (error instanceof LokutorError) {
69
+ return error.retryable;
70
+ }
71
+ return false;
72
+ }
7
73
 
8
74
  // src/audio-utils.ts
9
75
  function pcm16ToFloat32(int16Data) {
@@ -64,6 +130,9 @@ function pcm16ToBytes(data) {
64
130
  return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
65
131
  }
66
132
  function bytesToPcm16(bytes) {
133
+ if (bytes.length % 2 !== 0) {
134
+ bytes = bytes.slice(0, bytes.length - 1);
135
+ }
67
136
  return new Int16Array(bytes.buffer, bytes.byteOffset, bytes.length / 2);
68
137
  }
69
138
  function normalizeAudio(data, targetPeak = 0.95) {
@@ -113,11 +182,14 @@ var StreamResampler = class {
113
182
  combined.set(this.inputBuffer);
114
183
  combined.set(inputChunk, this.inputBuffer.length);
115
184
  const ratio = this.inputRate / this.outputRate;
116
- const outputLength = Math.floor(combined.length / ratio);
185
+ let outputLength = Math.floor(combined.length / ratio);
117
186
  if (outputLength === 0 && !flush) {
118
187
  this.inputBuffer = combined;
119
188
  return new Float32Array(0);
120
189
  }
190
+ if (flush && outputLength === 0 && combined.length > 0) {
191
+ outputLength = 1;
192
+ }
121
193
  const output = new Float32Array(outputLength);
122
194
  for (let i = 0; i < outputLength; i++) {
123
195
  const pos = i * ratio;
@@ -126,10 +198,8 @@ var StreamResampler = class {
126
198
  const weight = pos - left;
127
199
  output[i] = combined[left] * (1 - weight) + combined[right] * weight;
128
200
  }
129
- const remainingSamples = Math.ceil(combined.length - outputLength * ratio);
130
- this.inputBuffer = combined.slice(
131
- combined.length - remainingSamples
132
- );
201
+ const consumed = Math.floor(outputLength * ratio);
202
+ this.inputBuffer = combined.slice(consumed);
133
203
  return output;
134
204
  }
135
205
  reset() {
@@ -291,6 +361,10 @@ var BrowserAudioManager = class {
291
361
  console.warn("AudioContext not initialized");
292
362
  return;
293
363
  }
364
+ if (pcm16Data.length % 2 !== 0) {
365
+ console.warn(`Discarding odd-length PCM buffer (${pcm16Data.length} bytes)`);
366
+ return;
367
+ }
294
368
  const int16Array = new Int16Array(
295
369
  pcm16Data.buffer,
296
370
  pcm16Data.byteOffset,
@@ -419,6 +493,54 @@ var BrowserAudioManager = class {
419
493
  };
420
494
 
421
495
  // src/client.ts
496
+ function sdkTraceEnabled() {
497
+ try {
498
+ if (typeof window === "undefined") return false;
499
+ const w = window;
500
+ return Boolean(w.LOKUTOR_TRACE) || window.localStorage?.getItem("lokutorTrace") === "1";
501
+ } catch {
502
+ return false;
503
+ }
504
+ }
505
+ function sdkTrace(...args) {
506
+ if (sdkTraceEnabled()) {
507
+ console.log("[SDK TRACE]", ...args);
508
+ }
509
+ }
510
+ function nowMs() {
511
+ if (typeof performance !== "undefined" && performance.now) {
512
+ return performance.now();
513
+ }
514
+ return Date.now();
515
+ }
516
+ function wsToHttp(url) {
517
+ return url.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
518
+ }
519
+ async function fetchJson(url, timeoutMs = 1e4) {
520
+ const controller = new AbortController();
521
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
522
+ try {
523
+ const res = await fetch(url, {
524
+ signal: controller.signal,
525
+ headers: { Accept: "application/json" }
526
+ });
527
+ clearTimeout(timer);
528
+ if (!res.ok) {
529
+ throw new LokutorError("internal.error", `HTTP ${res.status} from ${url}`, {
530
+ detail: await res.text().catch(() => ""),
531
+ retryable: res.status >= 500
532
+ });
533
+ }
534
+ return await res.json();
535
+ } catch (err) {
536
+ clearTimeout(timer);
537
+ if (err instanceof LokutorError) throw err;
538
+ throw new LokutorError("internal.error", `Failed to fetch ${url}`, {
539
+ original: err,
540
+ retryable: true
541
+ });
542
+ }
543
+ }
422
544
  function base64ToUint8Array(base64) {
423
545
  const binaryString = atob(base64);
424
546
  const bytes = new Uint8Array(binaryString.length);
@@ -427,6 +549,39 @@ function base64ToUint8Array(base64) {
427
549
  }
428
550
  return bytes;
429
551
  }
552
+ function normalizeVisemes(payload) {
553
+ if (!Array.isArray(payload)) return [];
554
+ const normalized = [];
555
+ for (const item of payload) {
556
+ if (!item || typeof item !== "object") continue;
557
+ const c = String(item.c ?? item.char ?? "sil").toLowerCase();
558
+ const t = Number(item.t ?? item.timestamp ?? 0);
559
+ const v = Number(item.v ?? item.id ?? 0);
560
+ normalized.push({
561
+ v: Number.isFinite(v) ? v : 0,
562
+ c,
563
+ t: Number.isFinite(t) ? t : 0
564
+ });
565
+ }
566
+ return normalized;
567
+ }
568
+ function extractVisemePayload(msg) {
569
+ if (Array.isArray(msg?.data)) {
570
+ return normalizeVisemes(msg.data);
571
+ }
572
+ if (Array.isArray(msg?.data?.visemes)) {
573
+ return normalizeVisemes(msg.data.visemes);
574
+ }
575
+ if (msg?.data && !Array.isArray(msg.data) && typeof msg.data === "object") {
576
+ const singularInData = normalizeVisemes([msg.data]);
577
+ if (singularInData.length > 0) return singularInData;
578
+ }
579
+ if (msg && !Array.isArray(msg) && typeof msg === "object") {
580
+ const singularAtRoot = normalizeVisemes([msg]);
581
+ if (singularAtRoot.length > 0) return singularAtRoot;
582
+ }
583
+ return [];
584
+ }
430
585
  var VoiceAgentClient = class {
431
586
  ws = null;
432
587
  apiKey;
@@ -448,16 +603,19 @@ var VoiceAgentClient = class {
448
603
  audioManager = null;
449
604
  enableAudio = false;
450
605
  currentGeneration = 0;
606
+ listeners = {};
451
607
  // Connection resilience
452
608
  isUserDisconnect = false;
453
609
  reconnecting = false;
454
610
  reconnectAttempts = 0;
455
611
  maxReconnectAttempts = 5;
612
+ serverUrl;
456
613
  constructor(config) {
457
614
  this.apiKey = config.apiKey;
458
615
  this.prompt = config.prompt;
459
616
  this.voice = config.voice || "F1" /* F1 */;
460
617
  this.language = config.language || "en" /* ENGLISH */;
618
+ this.serverUrl = config.serverUrl || DEFAULT_URLS.VOICE_AGENT;
461
619
  this.onTranscription = config.onTranscription;
462
620
  this.onResponse = config.onResponse;
463
621
  this.onAudioCallback = config.onAudio;
@@ -470,23 +628,43 @@ var VoiceAgentClient = class {
470
628
  }
471
629
  /**
472
630
  * Connect to the Lokutor Voice Agent server
631
+ * @param customAudioManager Optional replacement for the default audio hardware handler
473
632
  */
474
- async connect() {
633
+ async connect(customAudioManager) {
475
634
  this.isUserDisconnect = false;
476
- if (this.enableAudio) {
477
- if (!this.audioManager) {
635
+ if (this.enableAudio || customAudioManager) {
636
+ if (customAudioManager) {
637
+ this.audioManager = customAudioManager;
638
+ } else if (!this.audioManager && typeof window !== "undefined") {
478
639
  this.audioManager = new BrowserAudioManager();
479
640
  }
480
- await this.audioManager.init();
641
+ if (this.audioManager) {
642
+ await this.audioManager.init();
643
+ }
481
644
  }
482
645
  return new Promise((resolve, reject) => {
646
+ let settled = false;
647
+ const settle = (fn) => {
648
+ if (!settled) {
649
+ settled = true;
650
+ fn();
651
+ }
652
+ };
483
653
  try {
484
- let url = DEFAULT_URLS.VOICE_AGENT;
654
+ let url = this.serverUrl;
485
655
  if (this.apiKey) {
486
656
  const separator = url.includes("?") ? "&" : "?";
487
657
  url += `${separator}api_key=${this.apiKey}`;
488
658
  }
489
- console.log(`\u{1F517} Connecting to ${DEFAULT_URLS.VOICE_AGENT}...`);
659
+ const redactedUrl = url.replace(/api_key=[^&]+/, "api_key=***");
660
+ sdkTrace("ws.connect", {
661
+ endpoint: this.serverUrl,
662
+ url: redactedUrl,
663
+ enableAudio: this.enableAudio,
664
+ wantVisemes: this.wantVisemes,
665
+ hasAudioManager: Boolean(this.audioManager)
666
+ });
667
+ console.log(`\u{1F517} Connecting to ${this.serverUrl}...`);
490
668
  this.ws = new WebSocket(url);
491
669
  this.ws.binaryType = "arraybuffer";
492
670
  this.ws.onopen = async () => {
@@ -494,6 +672,7 @@ var VoiceAgentClient = class {
494
672
  this.reconnectAttempts = 0;
495
673
  this.reconnecting = false;
496
674
  console.log("\u2705 Connected to voice agent!");
675
+ sdkTrace("ws.open");
497
676
  this.sendConfig();
498
677
  if (this.audioManager) {
499
678
  await this.audioManager.startMicrophone((data) => {
@@ -502,22 +681,54 @@ var VoiceAgentClient = class {
502
681
  }
503
682
  });
504
683
  }
505
- resolve(true);
684
+ settle(() => resolve(true));
506
685
  };
507
686
  this.ws.onmessage = async (event) => {
508
687
  if (event.data instanceof ArrayBuffer) {
688
+ sdkTrace("ws.message.binary", { bytes: event.data.byteLength });
509
689
  this.handleBinaryMessage(new Uint8Array(event.data));
510
690
  } else {
691
+ sdkTrace("ws.message.text", { length: String(event.data).length });
511
692
  this.handleTextMessage(event.data.toString());
512
693
  }
513
694
  };
514
695
  this.ws.onerror = (err) => {
515
- console.error("\u274C WebSocket error:", err);
516
- if (this.onError) this.onError(err);
517
- if (!this.isConnected) reject(err);
696
+ const error = new LokutorError("ws.close", "WebSocket connection error", {
697
+ detail: `readyState=${this.ws?.readyState}, bufferedAmount=${this.ws?.bufferedAmount}`,
698
+ original: err,
699
+ retryable: true
700
+ });
701
+ console.error("\u274C WebSocket error:", error.message);
702
+ sdkTrace("ws.error", { code: error.code, message: error.message });
703
+ if (this.onError) this.onError(error);
704
+ if (!this.isConnected) {
705
+ settle(() => reject(error));
706
+ }
518
707
  };
519
- this.ws.onclose = () => {
708
+ this.ws.onclose = (event) => {
520
709
  this.isConnected = false;
710
+ const diagnostic = {
711
+ code: event.code,
712
+ reason: event.reason,
713
+ wasClean: event.wasClean,
714
+ url: this.serverUrl,
715
+ isUserDisconnect: this.isUserDisconnect,
716
+ reconnectAttempts: this.reconnectAttempts
717
+ };
718
+ sdkTrace("ws.close", diagnostic);
719
+ if (!settled && !this.isUserDisconnect) {
720
+ const error = new LokutorError("ws.close", `WebSocket closed unexpectedly (code ${event.code})`, {
721
+ detail: event.reason || "No reason provided",
722
+ retryable: event.code !== 1008
723
+ });
724
+ settle(() => reject(error));
725
+ return;
726
+ }
727
+ if (!event.wasClean && event.code === 1006 && this.reconnectAttempts === 0 && !this.isUserDisconnect) {
728
+ console.error("\u274C Connection rejected (code 1006). Likely causes: invalid API key, endpoint unavailable, or CORS blocked.");
729
+ console.error(" URL:", this.serverUrl.replace(/api_key=[^&]+/, "api_key=***"));
730
+ }
731
+ console.log(`\u{1F50C} WebSocket closed \u2014 code: ${event.code}, reason: "${event.reason || "none"}", clean: ${event.wasClean}`);
521
732
  if (!this.isUserDisconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
522
733
  this.reconnecting = true;
523
734
  this.reconnectAttempts++;
@@ -533,20 +744,46 @@ var VoiceAgentClient = class {
533
744
  }
534
745
  };
535
746
  } catch (err) {
536
- if (this.onError) this.onError(err);
537
- reject(err);
747
+ const error = err instanceof LokutorError ? err : new LokutorError("internal.error", "Failed to create WebSocket connection", { original: err });
748
+ if (this.onError) this.onError(error);
749
+ settle(() => reject(error));
538
750
  }
539
751
  });
540
752
  }
753
+ /**
754
+ * The "Golden Path" - Starts a managed session with hardware handled automatically.
755
+ * This is the recommended way to start a conversation in browser environments.
756
+ */
757
+ async startManaged(config) {
758
+ this.enableAudio = true;
759
+ if (config?.audioManager) {
760
+ this.audioManager = config.audioManager;
761
+ } else if (!this.audioManager) {
762
+ if (typeof window === "undefined") {
763
+ throw new LokutorError("internal.error", "startManaged() requires a browser environment. Pass a custom audioManager for non-browser runtimes.", { retryable: false });
764
+ }
765
+ this.audioManager = new BrowserAudioManager();
766
+ }
767
+ await this.connect();
768
+ return this;
769
+ }
541
770
  /**
542
771
  * Send initial configuration to the server
543
772
  */
544
773
  sendConfig() {
545
774
  if (!this.ws || !this.isConnected) return;
546
- this.ws.send(JSON.stringify({ type: "prompt", data: this.prompt }));
775
+ this.ws.send(JSON.stringify({ type: "visemes", data: this.wantVisemes }));
547
776
  this.ws.send(JSON.stringify({ type: "voice", data: this.voice }));
548
777
  this.ws.send(JSON.stringify({ type: "language", data: this.language }));
549
- this.ws.send(JSON.stringify({ type: "visemes", data: this.wantVisemes }));
778
+ this.ws.send(JSON.stringify({ type: "prompt", data: this.prompt }));
779
+ this.ws.send(JSON.stringify({ type: "rates", playback: 44100, input: 16e3 }));
780
+ sdkTrace("ws.send.config", {
781
+ promptLen: this.prompt?.length || 0,
782
+ voice: this.voice,
783
+ language: this.language,
784
+ visemes: this.wantVisemes,
785
+ tools: this.tools?.length || 0
786
+ });
550
787
  if (this.tools && this.tools.length > 0) {
551
788
  this.ws.send(JSON.stringify({ type: "tools", data: this.tools }));
552
789
  }
@@ -580,6 +817,15 @@ var VoiceAgentClient = class {
580
817
  handleTextMessage(text) {
581
818
  try {
582
819
  const msg = JSON.parse(text);
820
+ if (!msg || typeof msg !== "object") {
821
+ return;
822
+ }
823
+ sdkTrace("ws.recv.type", {
824
+ type: msg.type,
825
+ hasData: Object.prototype.hasOwnProperty.call(msg, "data"),
826
+ dataKind: Array.isArray(msg.data) ? "array" : typeof msg.data,
827
+ generation: msg.generation ?? null
828
+ });
583
829
  switch (msg.type) {
584
830
  case "audio":
585
831
  if (msg.data) {
@@ -592,7 +838,7 @@ var VoiceAgentClient = class {
592
838
  this.messages.push({
593
839
  role,
594
840
  text: msg.data,
595
- timestamp: Date.now()
841
+ timestamp: nowMs()
596
842
  });
597
843
  if (msg.role === "user") {
598
844
  if (this.onTranscription) this.onTranscription(msg.data);
@@ -624,36 +870,91 @@ var VoiceAgentClient = class {
624
870
  console.log(`${icons[msg.data] || ""} Status: ${msg.data}`);
625
871
  break;
626
872
  case "visemes":
627
- if (Array.isArray(msg.data) && msg.data.length > 0) {
628
- this.emit("visemes", msg.data);
873
+ case "viseme": {
874
+ const msgGen = msg.generation ?? this.currentGeneration;
875
+ if (msgGen < this.currentGeneration) {
876
+ sdkTrace("visemes.discard", { msgGen, currentGen: this.currentGeneration });
877
+ break;
878
+ }
879
+ const normalized = extractVisemePayload(msg);
880
+ const explicitlyEmptyArray = Array.isArray(msg?.data) || Array.isArray(msg?.data?.visemes);
881
+ sdkTrace("visemes.recv", {
882
+ rawType: msg.type,
883
+ normalizedCount: normalized.length,
884
+ first: normalized[0] ?? null
885
+ });
886
+ if (normalized.length > 0 || explicitlyEmptyArray) {
887
+ this.emit("visemes", normalized);
629
888
  }
630
889
  break;
631
- case "error":
632
- if (this.onError) this.onError(msg.data);
633
- console.error(`\u274C Server error: ${msg.data}`);
890
+ }
891
+ case "error": {
892
+ const backendCode = msg.data?.code ?? "internal.error";
893
+ const backendMessage = msg.data?.message ?? msg.data ?? "Unknown server error";
894
+ const backendDetail = msg.data?.detail;
895
+ const backendRetryable = msg.data?.retryable ?? true;
896
+ const error = new LokutorError(backendCode, backendMessage, {
897
+ detail: backendDetail,
898
+ retryable: backendRetryable
899
+ });
900
+ if (this.onError) this.onError(error);
901
+ console.error(`\u274C Server error: [${error.code}] ${error.message}`);
634
902
  break;
903
+ }
635
904
  case "tool_call":
636
905
  console.log(`\u{1F6E0}\uFE0F Tool Call: ${msg.name}(${msg.arguments})`);
637
906
  break;
638
907
  }
639
908
  } catch (e) {
909
+ sdkTrace("ws.recv.parse_error", { preview: text?.slice(0, 120) });
910
+ console.debug("Failed to parse message:", e);
640
911
  }
641
912
  }
642
- audioListeners = [];
643
- emit(event, data) {
644
- if (event === "audio") {
645
- if (this.onAudioCallback) this.onAudioCallback(data);
646
- this.audioListeners.forEach((l) => l(data));
647
- } else if (event === "visemes") {
648
- if (this.onVisemesCallback) this.onVisemesCallback(data);
649
- this.visemeListeners.forEach((l) => l(data));
913
+ /**
914
+ * Register an event listener (for Python parity)
915
+ */
916
+ on(event, callback) {
917
+ if (!this.listeners[event]) {
918
+ this.listeners[event] = [];
919
+ }
920
+ this.listeners[event].push(callback);
921
+ return this;
922
+ }
923
+ /**
924
+ * Internal emitter for all events
925
+ */
926
+ emit(event, ...args) {
927
+ const legacyMap = {
928
+ "transcription": "onTranscription",
929
+ "response": "onResponse",
930
+ "audio": "onAudioCallback",
931
+ "visemes": "onVisemesCallback",
932
+ "status": "onStatus",
933
+ "error": "onError"
934
+ };
935
+ const legacyKey = legacyMap[event];
936
+ if (legacyKey && this[legacyKey]) {
937
+ try {
938
+ this[legacyKey](...args);
939
+ } catch (e) {
940
+ console.error(`Error in legacy callback ${legacyKey}:`, e);
941
+ }
942
+ }
943
+ if (this.listeners[event]) {
944
+ this.listeners[event].forEach((cb) => {
945
+ try {
946
+ cb(...args);
947
+ } catch (e) {
948
+ console.error(`Error in listener for ${event}:`, e);
949
+ }
950
+ });
650
951
  }
651
952
  }
652
953
  onAudio(callback) {
653
- this.audioListeners.push(callback);
954
+ this.on("audio", callback);
654
955
  }
655
956
  onVisemes(callback) {
656
- this.visemeListeners.push(callback);
957
+ this.on("visemes", callback);
657
958
  }
658
959
  /**
659
960
  * Disconnect from the server
@@ -668,6 +969,21 @@ var VoiceAgentClient = class {
668
969
  this.audioManager.cleanup();
669
970
  }
670
971
  this.isConnected = false;
972
+ this.reconnecting = false;
973
+ this.reconnectAttempts = 0;
974
+ }
975
+ /**
976
+ * Returns true if the client is currently connected.
977
+ */
978
+ get connected() {
979
+ return this.isConnected;
980
+ }
981
+ /**
982
+ * Returns the current generation counter.
983
+ * Useful for correlating audio/viseme chunks with utterances.
984
+ */
985
+ get generation() {
986
+ return this.currentGeneration;
671
987
  }
672
988
  /**
673
989
  * Toggles the microphone mute state (if managed by client)
@@ -690,17 +1006,70 @@ var VoiceAgentClient = class {
690
1006
  }
691
1007
  return 0;
692
1008
  }
1009
+ /**
1010
+ * Fetch available voice styles from the server.
1011
+ * No authentication required.
1012
+ */
1013
+ static async fetchVoices(baseUrl) {
1014
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1015
+ const data = await fetchJson(`${url}/voices`);
1016
+ return data.voices || [];
1017
+ }
1018
+ /**
1019
+ * Fetch supported languages from the server.
1020
+ * No authentication required.
1021
+ */
1022
+ static async fetchLanguages(baseUrl) {
1023
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1024
+ const data = await fetchJson(`${url}/languages`);
1025
+ return data.languages || [];
1026
+ }
1027
+ /**
1028
+ * Fetch loaded TTS model versions from the server.
1029
+ * No authentication required.
1030
+ */
1031
+ static async fetchModels(baseUrl) {
1032
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1033
+ const data = await fetchJson(`${url}/models`);
1034
+ return data.models || [];
1035
+ }
1036
+ /**
1037
+ * Fetch server configuration (limits and defaults).
1038
+ * No authentication required.
1039
+ */
1040
+ static async fetchConfig(baseUrl) {
1041
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1042
+ return fetchJson(`${url}/config`);
1043
+ }
1044
+ /**
1045
+ * Fetch rich runtime status from the server.
1046
+ * No authentication required.
1047
+ */
1048
+ static async fetchStatus(baseUrl) {
1049
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1050
+ return fetchJson(`${url}/status`);
1051
+ }
1052
+ /**
1053
+ * Fetch health/liveness status from the server.
1054
+ * No authentication required.
1055
+ */
1056
+ static async fetchHealth(baseUrl) {
1057
+ const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
1058
+ return fetchJson(`${url}/health`);
1059
+ }
693
1060
  /**
694
1061
  * Update the system prompt mid-conversation
695
1062
  */
696
1063
  updatePrompt(newPrompt) {
697
1064
  this.prompt = newPrompt;
698
- if (this.ws && this.isConnected) {
1065
+ if (this.ws && this.ws.readyState === WebSocket.OPEN && this.isConnected) {
699
1066
  try {
700
1067
  this.ws.send(JSON.stringify({ type: "prompt", data: newPrompt }));
701
1068
  console.log(`\u2699\uFE0F Updated prompt: ${newPrompt.substring(0, 50)}...`);
702
1069
  } catch (error) {
703
- console.error("Error updating prompt:", error);
1070
+ const err = new LokutorError("internal.error", "Failed to update prompt", { original: error });
1071
+ if (this.onError) this.onError(err);
1072
+ console.error("Error updating prompt:", err.message);
704
1073
  }
705
1074
  } else {
706
1075
  console.warn("Not connected - prompt will be updated on next connection");
@@ -732,15 +1101,28 @@ var TTSClient = class {
732
1101
  */
733
1102
  synthesize(options) {
734
1103
  return new Promise((resolve, reject) => {
1104
+ let activityTimeout;
1105
+ let ws;
1106
+ let startTime;
1107
+ let firstByteReceived = false;
1108
+ const refreshTimeout = () => {
1109
+ if (activityTimeout) clearTimeout(activityTimeout);
1110
+ activityTimeout = setTimeout(() => {
1111
+ console.log("\u23F1\uFE0F TTS synthesis reached inactivity timeout (2s) - resolving");
1112
+ if (ws) ws.close();
1113
+ resolve();
1114
+ }, 2e3);
1115
+ };
735
1116
  try {
736
1117
  let url = DEFAULT_URLS.TTS;
737
1118
  if (this.apiKey) {
738
1119
  const separator = url.includes("?") ? "&" : "?";
739
1120
  url += `${separator}api_key=${this.apiKey}`;
740
1121
  }
741
- const ws = new WebSocket(url);
1122
+ ws = new WebSocket(url);
742
1123
  ws.binaryType = "arraybuffer";
743
1124
  ws.onopen = () => {
1125
+ refreshTimeout();
744
1126
  const req = {
745
1127
  text: options.text,
746
1128
  voice: options.voice || "F1" /* F1 */,
@@ -750,28 +1132,65 @@ var TTSClient = class {
750
1132
  visemes: options.visemes || false
751
1133
  };
752
1134
  ws.send(JSON.stringify(req));
1135
+ startTime = nowMs();
753
1136
  };
754
1137
  ws.onmessage = async (event) => {
1138
+ refreshTimeout();
755
1139
  if (event.data instanceof ArrayBuffer) {
1140
+ if (!firstByteReceived) {
1141
+ const ttfb = nowMs() - startTime;
1142
+ if (options.onTTFB) options.onTTFB(ttfb);
1143
+ firstByteReceived = true;
1144
+ }
756
1145
  if (options.onAudio) options.onAudio(new Uint8Array(event.data));
757
- } else {
758
- try {
759
- const msg = JSON.parse(event.data.toString());
760
- if (Array.isArray(msg) && options.onVisemes) {
761
- options.onVisemes(msg);
1146
+ return;
1147
+ }
1148
+ const text = event.data.toString();
1149
+ if (text === "EOS") {
1150
+ if (activityTimeout) clearTimeout(activityTimeout);
1151
+ ws.close();
1152
+ resolve();
1153
+ return;
1154
+ }
1155
+ try {
1156
+ const msg = JSON.parse(text);
1157
+ if (msg.type === "audio" && msg.data) {
1158
+ const audioBuffer = base64ToUint8Array(msg.data);
1159
+ if (!firstByteReceived) {
1160
+ const ttfb = nowMs() - startTime;
1161
+ if (options.onTTFB) options.onTTFB(ttfb);
1162
+ firstByteReceived = true;
762
1163
  }
763
- } catch (e) {
1164
+ if (options.onAudio) options.onAudio(audioBuffer);
1165
+ return;
1166
+ }
1167
+ if (msg.type === "visemes" && Array.isArray(msg.data) && options.onVisemes) {
1168
+ options.onVisemes(normalizeVisemes(msg.data));
1169
+ return;
1170
+ }
1171
+ if (Array.isArray(msg) && options.onVisemes) {
1172
+ options.onVisemes(normalizeVisemes(msg));
1173
+ return;
1174
+ }
1175
+ if (msg.type === "eos") {
1176
+ if (activityTimeout) clearTimeout(activityTimeout);
1177
+ ws.close();
1178
+ resolve();
764
1179
  }
1180
+ } catch (e) {
765
1181
  }
766
1182
  };
767
1183
  ws.onerror = (err) => {
1184
+ if (activityTimeout) clearTimeout(activityTimeout);
768
1185
  if (options.onError) options.onError(err);
769
1186
  reject(err);
770
1187
  };
771
1188
  ws.onclose = () => {
1189
+ if (activityTimeout) clearTimeout(activityTimeout);
772
1190
  resolve();
773
1191
  };
774
1192
  } catch (err) {
1193
+ if (activityTimeout) clearTimeout(activityTimeout);
775
1194
  if (options.onError) options.onError(err);
776
1195
  reject(err);
777
1196
  }
@@ -792,6 +1211,7 @@ export {
792
1211
  BrowserAudioManager,
793
1212
  DEFAULT_URLS,
794
1213
  Language,
1214
+ LokutorError,
795
1215
  StreamResampler,
796
1216
  TTSClient,
797
1217
  VoiceAgentClient,
@@ -800,6 +1220,7 @@ export {
800
1220
  bytesToPcm16,
801
1221
  calculateRMS,
802
1222
  float32ToPcm16,
1223
+ isRetryable,
803
1224
  normalizeAudio,
804
1225
  pcm16ToBytes,
805
1226
  pcm16ToFloat32,