@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.d.mts +152 -5
- package/dist/index.d.ts +152 -5
- package/dist/index.js +439 -45
- package/dist/index.mjs +472 -51
- package/package.json +11 -16
- package/src/audio-utils.ts +253 -0
- package/src/browser-audio.ts +395 -0
- package/src/client.ts +886 -0
- package/src/index.ts +25 -0
- package/src/node-audio.ts +115 -0
- package/src/types.ts +270 -0
- package/dist/chunk-SNNPJP5R.mjs +0 -42
- package/dist/node-audio.d.mts +0 -25
- package/dist/node-audio.d.ts +0 -25
- package/dist/node-audio.js +0 -132
- package/dist/node-audio.mjs +0 -88
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
610
|
-
|
|
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: "
|
|
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: "
|
|
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:
|
|
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
|
-
|
|
701
|
-
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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.
|
|
1000
|
+
this.on("audio", callback);
|
|
727
1001
|
}
|
|
728
1002
|
onVisemes(callback) {
|
|
729
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
|
|
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,
|