@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.mjs
CHANGED
|
@@ -1,9 +1,75 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
537
|
-
|
|
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: "
|
|
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: "
|
|
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:
|
|
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
|
-
|
|
628
|
-
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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.
|
|
954
|
+
this.on("audio", callback);
|
|
654
955
|
}
|
|
655
956
|
onVisemes(callback) {
|
|
656
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
|
|
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,
|