@lokutor/sdk 1.5.1 → 1.6.0
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/README.md +29 -2
- package/dist/index.d.mts +34 -21
- package/dist/index.d.ts +34 -21
- package/dist/index.js +151 -86
- package/dist/index.mjs +151 -86
- package/package.json +1 -1
- package/src/client.ts +161 -92
- package/src/types.ts +36 -17
package/src/client.ts
CHANGED
|
@@ -50,6 +50,12 @@ function wsToHttp(url: string): string {
|
|
|
50
50
|
return url.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:');
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// The discovery routes live at the API's root (https://api.lokutor.com/voices). They were built from the
|
|
54
|
+
// agent socket's URL, so every helper requested /ws/agent/voices and got a 404.
|
|
55
|
+
function apiOrigin(base?: string): string {
|
|
56
|
+
return new URL(wsToHttp(base || DEFAULT_URLS.VOICE_AGENT)).origin;
|
|
57
|
+
}
|
|
58
|
+
|
|
53
59
|
async function fetchJson<T>(url: string, timeoutMs = 10000): Promise<T> {
|
|
54
60
|
const controller = new AbortController();
|
|
55
61
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -216,9 +222,13 @@ export class VoiceAgentClient {
|
|
|
216
222
|
private serverInputRate: number = 16000;
|
|
217
223
|
|
|
218
224
|
private serverUrl: string;
|
|
225
|
+
private authProvider?: () => string | Promise<string>;
|
|
226
|
+
private voiceExplicit = false;
|
|
227
|
+
private languageExplicit = false;
|
|
219
228
|
|
|
220
229
|
constructor(config: LokutorConfig & {
|
|
221
|
-
prompt
|
|
230
|
+
/** Required unless agentId is set (a dashboard agent brings its own prompt). */
|
|
231
|
+
prompt?: string,
|
|
222
232
|
voice?: VoiceId,
|
|
223
233
|
language?: Language,
|
|
224
234
|
visemes?: boolean,
|
|
@@ -227,9 +237,15 @@ export class VoiceAgentClient {
|
|
|
227
237
|
tools?: ToolDefinition[],
|
|
228
238
|
serverUrl?: string,
|
|
229
239
|
}) {
|
|
230
|
-
this.apiKey = config.apiKey;
|
|
240
|
+
this.apiKey = config.apiKey || "";
|
|
241
|
+
this.authProvider = config.auth;
|
|
231
242
|
this.agentId = config.agentId || "";
|
|
232
|
-
this.prompt = config.prompt;
|
|
243
|
+
this.prompt = config.prompt ?? "";
|
|
244
|
+
// With agentId, only what the caller set explicitly is sent: the server applies voice, language and
|
|
245
|
+
// prompt on top of the agent it loaded, and a default prompt replaced the agent's own (with its
|
|
246
|
+
// knowledge base and evaluation rules), F1/en replaced its voice and language.
|
|
247
|
+
this.voiceExplicit = config.voice !== undefined;
|
|
248
|
+
this.languageExplicit = config.language !== undefined;
|
|
233
249
|
// Default stays the Versa 1.x name on purpose: the server resolves F1 to the voice of the
|
|
234
250
|
// call's own language family (esca_f1 for Spanish, en_f1 for English), which adapts better
|
|
235
251
|
// than pinning one explicit voice here would.
|
|
@@ -254,6 +270,15 @@ export class VoiceAgentClient {
|
|
|
254
270
|
*/
|
|
255
271
|
public async connect(customAudioManager?: AudioManager): Promise<boolean> {
|
|
256
272
|
this.isUserDisconnect = false;
|
|
273
|
+
if (this.authProvider) {
|
|
274
|
+
try {
|
|
275
|
+
this.apiKey = await this.authProvider();
|
|
276
|
+
} catch (err) {
|
|
277
|
+
const error = new LokutorError('auth.missing_key', 'The auth callback failed to return a credential', { original: err, retryable: true });
|
|
278
|
+
this.emit('error', error);
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
257
282
|
|
|
258
283
|
if (this.enableAudio || customAudioManager) {
|
|
259
284
|
if (customAudioManager) {
|
|
@@ -280,11 +305,11 @@ export class VoiceAgentClient {
|
|
|
280
305
|
let url = this.serverUrl;
|
|
281
306
|
if (this.apiKey) {
|
|
282
307
|
const separator = url.includes('?') ? '&' : '?';
|
|
283
|
-
url += `${separator}api_key=${this.apiKey}`;
|
|
308
|
+
url += `${separator}api_key=${encodeURIComponent(this.apiKey)}`;
|
|
284
309
|
}
|
|
285
310
|
if (this.agentId) {
|
|
286
311
|
const separator = url.includes('?') ? '&' : '?';
|
|
287
|
-
url += `${separator}agent_id=${this.agentId}`;
|
|
312
|
+
url += `${separator}agent_id=${encodeURIComponent(this.agentId)}`;
|
|
288
313
|
}
|
|
289
314
|
|
|
290
315
|
const redactedUrl = url.replace(/api_key=[^&]+/, 'api_key=***');
|
|
@@ -361,7 +386,7 @@ export class VoiceAgentClient {
|
|
|
361
386
|
});
|
|
362
387
|
console.error('❌ WebSocket error:', error.message);
|
|
363
388
|
sdkTrace('ws.error', { code: error.code, message: error.message });
|
|
364
|
-
|
|
389
|
+
this.emit('error', error);
|
|
365
390
|
if (!this.isConnected) {
|
|
366
391
|
settle(() => reject(error));
|
|
367
392
|
}
|
|
@@ -404,8 +429,8 @@ export class VoiceAgentClient {
|
|
|
404
429
|
});
|
|
405
430
|
console.log(`Session ended by the server (code ${event.code})`);
|
|
406
431
|
this.emit('ended', { code: event.code, reason: event.reason || '' });
|
|
407
|
-
|
|
408
|
-
|
|
432
|
+
this.emit('error', ended);
|
|
433
|
+
this.emit('status', 'disconnected');
|
|
409
434
|
return;
|
|
410
435
|
}
|
|
411
436
|
|
|
@@ -416,7 +441,7 @@ export class VoiceAgentClient {
|
|
|
416
441
|
|
|
417
442
|
console.warn(`Connection lost. Reconnecting in ${backoffDelay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
|
418
443
|
|
|
419
|
-
|
|
444
|
+
this.emit('status', 'reconnecting');
|
|
420
445
|
|
|
421
446
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
422
447
|
this.reconnectTimer = setTimeout(() => {
|
|
@@ -427,16 +452,16 @@ export class VoiceAgentClient {
|
|
|
427
452
|
}, backoffDelay);
|
|
428
453
|
} else if (this.isUserDisconnect) {
|
|
429
454
|
console.log('Disconnected');
|
|
430
|
-
|
|
455
|
+
this.emit('status', 'disconnected');
|
|
431
456
|
} else {
|
|
432
457
|
console.log('Max reconnection attempts reached');
|
|
433
|
-
|
|
458
|
+
this.emit('status', 'disconnected');
|
|
434
459
|
}
|
|
435
460
|
};
|
|
436
461
|
|
|
437
462
|
} catch (err) {
|
|
438
463
|
const error = err instanceof LokutorError ? err : new LokutorError('internal.error', 'Failed to create WebSocket connection', { original: err });
|
|
439
|
-
|
|
464
|
+
this.emit('error', error);
|
|
440
465
|
settle(() => reject(error));
|
|
441
466
|
}
|
|
442
467
|
});
|
|
@@ -471,13 +496,14 @@ export class VoiceAgentClient {
|
|
|
471
496
|
this.configConfirmed = false;
|
|
472
497
|
|
|
473
498
|
// Send feature/config flags — order matches the test agent for consistency.
|
|
474
|
-
|
|
475
|
-
this.ws.send(JSON.stringify({ type: '
|
|
499
|
+
const agentSession = Boolean(this.agentId);
|
|
500
|
+
if (!agentSession || this.voiceExplicit) this.ws.send(JSON.stringify({ type: 'voice', data: this.voice }));
|
|
501
|
+
if (!agentSession || this.languageExplicit) this.ws.send(JSON.stringify({ type: 'language', data: this.language }));
|
|
476
502
|
if (this.tools && this.tools.length > 0) {
|
|
477
503
|
this.ws.send(JSON.stringify({ type: 'tools', data: this.tools }));
|
|
478
504
|
}
|
|
479
505
|
this.ws.send(JSON.stringify({ type: 'visemes', data: this.wantVisemes }));
|
|
480
|
-
this.ws.send(JSON.stringify({ type: 'prompt', data: this.prompt }));
|
|
506
|
+
if (!agentSession || this.prompt) this.ws.send(JSON.stringify({ type: 'prompt', data: this.prompt }));
|
|
481
507
|
|
|
482
508
|
// Inform the server of our sample rates for echo cancellation.
|
|
483
509
|
this.ws.send(JSON.stringify({ type: 'rates', playback: 44100, input: 16000 }));
|
|
@@ -545,6 +571,12 @@ export class VoiceAgentClient {
|
|
|
545
571
|
sdkTrace('server_rates', { playback: this.serverPlaybackRate, input: this.serverInputRate });
|
|
546
572
|
break;
|
|
547
573
|
case 'transcript':
|
|
574
|
+
// Interim recognition while the caller is still speaking: its own event, never history. The
|
|
575
|
+
// final line follows without "partial" (it used to be stored and reported as final every time).
|
|
576
|
+
if (msg.partial) {
|
|
577
|
+
this.emit('transcription_partial', msg.data);
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
548
580
|
const role = msg.role === 'user' ? 'user' : 'agent';
|
|
549
581
|
// Store in history
|
|
550
582
|
this.messages.push({
|
|
@@ -554,11 +586,9 @@ export class VoiceAgentClient {
|
|
|
554
586
|
});
|
|
555
587
|
|
|
556
588
|
if (msg.role === 'user') {
|
|
557
|
-
if (this.onTranscription) this.onTranscription(msg.data);
|
|
558
589
|
this.emit('transcription', msg.data);
|
|
559
590
|
console.log(`💬 You: ${msg.data}`);
|
|
560
591
|
} else {
|
|
561
|
-
if (this.onResponse) this.onResponse(msg.data);
|
|
562
592
|
this.emit('response', msg.data);
|
|
563
593
|
console.log(`🤖 Agent: ${msg.data}`);
|
|
564
594
|
}
|
|
@@ -580,7 +610,6 @@ export class VoiceAgentClient {
|
|
|
580
610
|
if (msg.data === 'interrupted' && this.audioManager) {
|
|
581
611
|
this.audioManager.stopPlayback();
|
|
582
612
|
}
|
|
583
|
-
if (this.onStatus) this.onStatus(msg.data);
|
|
584
613
|
this.emit('status', msg.data);
|
|
585
614
|
const icons: Record<string, string> = {
|
|
586
615
|
'interrupted': '⚡',
|
|
@@ -633,11 +662,14 @@ export class VoiceAgentClient {
|
|
|
633
662
|
break;
|
|
634
663
|
}
|
|
635
664
|
|
|
636
|
-
case 'tool_call':
|
|
637
|
-
|
|
665
|
+
case 'tool_call': {
|
|
666
|
+
// Once, with the call_id submitToolResult needs. It was emitted twice (the first time
|
|
667
|
+
// without call_id), so a handler ran every tool twice and its first result was dropped.
|
|
668
|
+
const call: ToolCall = { name: msg.name, arguments: msg.arguments, call_id: msg.call_id };
|
|
638
669
|
console.log(`🛠️ Tool Call: ${msg.name}(${msg.arguments})`);
|
|
639
|
-
this.emit('tool_call',
|
|
670
|
+
this.emit('tool_call', call);
|
|
640
671
|
break;
|
|
672
|
+
}
|
|
641
673
|
}
|
|
642
674
|
} catch (e) {
|
|
643
675
|
sdkTrace('ws.recv.parse_error', { preview: text?.slice(0, 120) });
|
|
@@ -765,7 +797,7 @@ export class VoiceAgentClient {
|
|
|
765
797
|
* No authentication required.
|
|
766
798
|
*/
|
|
767
799
|
static async fetchVoices(baseUrl?: string): Promise<VoiceInfo[]> {
|
|
768
|
-
const url =
|
|
800
|
+
const url = apiOrigin(baseUrl);
|
|
769
801
|
const data = await fetchJson<{ voices: VoiceInfo[] }>(`${url}/voices`);
|
|
770
802
|
return data.voices || [];
|
|
771
803
|
}
|
|
@@ -775,7 +807,7 @@ export class VoiceAgentClient {
|
|
|
775
807
|
* No authentication required.
|
|
776
808
|
*/
|
|
777
809
|
static async fetchLanguages(baseUrl?: string): Promise<LanguageInfo[]> {
|
|
778
|
-
const url =
|
|
810
|
+
const url = apiOrigin(baseUrl);
|
|
779
811
|
const data = await fetchJson<{ languages: LanguageInfo[] }>(`${url}/languages`);
|
|
780
812
|
return data.languages || [];
|
|
781
813
|
}
|
|
@@ -785,7 +817,7 @@ export class VoiceAgentClient {
|
|
|
785
817
|
* No authentication required.
|
|
786
818
|
*/
|
|
787
819
|
static async fetchModels(baseUrl?: string): Promise<ModelInfo[]> {
|
|
788
|
-
const url =
|
|
820
|
+
const url = apiOrigin(baseUrl);
|
|
789
821
|
const data = await fetchJson<{ models: ModelInfo[] }>(`${url}/models`);
|
|
790
822
|
return data.models || [];
|
|
791
823
|
}
|
|
@@ -795,7 +827,7 @@ export class VoiceAgentClient {
|
|
|
795
827
|
* No authentication required.
|
|
796
828
|
*/
|
|
797
829
|
static async fetchConfig(baseUrl?: string): Promise<ServerConfig> {
|
|
798
|
-
const url =
|
|
830
|
+
const url = apiOrigin(baseUrl);
|
|
799
831
|
return fetchJson<ServerConfig>(`${url}/config`);
|
|
800
832
|
}
|
|
801
833
|
|
|
@@ -804,17 +836,17 @@ export class VoiceAgentClient {
|
|
|
804
836
|
* No authentication required.
|
|
805
837
|
*/
|
|
806
838
|
static async fetchStatus(baseUrl?: string): Promise<ServerStatus> {
|
|
807
|
-
const url =
|
|
839
|
+
const url = apiOrigin(baseUrl);
|
|
808
840
|
return fetchJson<ServerStatus>(`${url}/status`);
|
|
809
841
|
}
|
|
810
842
|
|
|
811
843
|
/**
|
|
812
|
-
* Fetch
|
|
813
|
-
* No authentication required.
|
|
844
|
+
* Fetch readiness from the server (GET /ready: 200 once it can serve).
|
|
845
|
+
* No authentication required. (/health is the operator dashboard's and needs its password.)
|
|
814
846
|
*/
|
|
815
847
|
static async fetchHealth(baseUrl?: string): Promise<HealthStatus> {
|
|
816
|
-
const url =
|
|
817
|
-
return fetchJson<HealthStatus>(`${url}/
|
|
848
|
+
const url = apiOrigin(baseUrl);
|
|
849
|
+
return fetchJson<HealthStatus>(`${url}/ready`);
|
|
818
850
|
}
|
|
819
851
|
|
|
820
852
|
/**
|
|
@@ -841,6 +873,7 @@ export class VoiceAgentClient {
|
|
|
841
873
|
*/
|
|
842
874
|
public updateVoice(voice: VoiceId) {
|
|
843
875
|
this.voice = voice;
|
|
876
|
+
this.voiceExplicit = true;
|
|
844
877
|
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.isConnected) {
|
|
845
878
|
this.ws.send(JSON.stringify({ type: 'voice', data: voice }));
|
|
846
879
|
console.log(`⚙️ Updated voice: ${voice}`);
|
|
@@ -852,6 +885,7 @@ export class VoiceAgentClient {
|
|
|
852
885
|
*/
|
|
853
886
|
public updateLanguage(language: Language) {
|
|
854
887
|
this.language = language;
|
|
888
|
+
this.languageExplicit = true;
|
|
855
889
|
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.isConnected) {
|
|
856
890
|
this.ws.send(JSON.stringify({ type: 'language', data: language }));
|
|
857
891
|
console.log(`⚙️ Updated language: ${language}`);
|
|
@@ -935,112 +969,133 @@ export class TTSClient {
|
|
|
935
969
|
onTTFB?: (ms: number) => void;
|
|
936
970
|
onError?: (error: any) => void;
|
|
937
971
|
}): Promise<void> {
|
|
972
|
+
// Done when the server says so ("EOS"); a server error frame rejects. It used to resolve after 2 s
|
|
973
|
+
// without audio, so an error looked like success and a request whose first audio was queued for more
|
|
974
|
+
// than 2 s (the synthesis pool at its cap) was cut off empty.
|
|
938
975
|
return new Promise((resolve, reject) => {
|
|
939
|
-
let
|
|
976
|
+
let timer: any;
|
|
940
977
|
let ws: WebSocket;
|
|
941
978
|
let startTime: number;
|
|
942
979
|
let firstByteReceived = false;
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
980
|
+
let finished = false;
|
|
981
|
+
|
|
982
|
+
const finish = (err?: unknown) => {
|
|
983
|
+
if (finished) return;
|
|
984
|
+
finished = true;
|
|
985
|
+
if (timer) clearTimeout(timer);
|
|
986
|
+
try { ws?.close(); } catch { /* already closed */ }
|
|
987
|
+
if (err) {
|
|
988
|
+
if (options.onError) options.onError(err);
|
|
989
|
+
reject(err);
|
|
990
|
+
} else {
|
|
949
991
|
resolve();
|
|
950
|
-
}
|
|
992
|
+
}
|
|
993
|
+
};
|
|
994
|
+
// 30 s for the first audio, then 15 s of silence between chunks before giving up.
|
|
995
|
+
const arm = () => {
|
|
996
|
+
if (timer) clearTimeout(timer);
|
|
997
|
+
timer = setTimeout(() => finish(new LokutorError('internal.timeout' as ErrorCode,
|
|
998
|
+
firstByteReceived ? 'TTS stream stalled' : 'No audio within 30 s', { retryable: true })),
|
|
999
|
+
firstByteReceived ? 15000 : 30000);
|
|
951
1000
|
};
|
|
952
1001
|
|
|
953
1002
|
try {
|
|
954
1003
|
let url = DEFAULT_URLS.TTS;
|
|
955
1004
|
if (this.apiKey) {
|
|
956
1005
|
const separator = url.includes('?') ? '&' : '?';
|
|
957
|
-
url += `${separator}api_key=${this.apiKey}`;
|
|
1006
|
+
url += `${separator}api_key=${encodeURIComponent(this.apiKey)}`;
|
|
958
1007
|
}
|
|
959
1008
|
|
|
960
1009
|
ws = new WebSocket(url);
|
|
961
1010
|
ws.binaryType = 'arraybuffer';
|
|
962
1011
|
|
|
963
1012
|
ws.onopen = () => {
|
|
964
|
-
|
|
965
|
-
|
|
1013
|
+
arm();
|
|
1014
|
+
// speed and steps only when set: the server's defaults are what production is tuned for (the
|
|
1015
|
+
// SDK used to send speed 1.05 and steps 24 on every request).
|
|
1016
|
+
const req: Record<string, unknown> = {
|
|
966
1017
|
text: options.text,
|
|
967
1018
|
voice: options.voice || VoiceStyle.F1,
|
|
968
1019
|
language: options.language || Language.ENGLISH,
|
|
969
|
-
speed: options.speed || 1.05,
|
|
970
|
-
steps: options.steps || 24,
|
|
971
1020
|
visemes: options.visemes || false
|
|
972
1021
|
};
|
|
1022
|
+
if (options.speed !== undefined) req.speed = options.speed;
|
|
1023
|
+
if (options.steps !== undefined) req.steps = options.steps;
|
|
973
1024
|
ws.send(JSON.stringify(req));
|
|
974
1025
|
startTime = nowMs();
|
|
975
1026
|
};
|
|
976
1027
|
|
|
977
1028
|
ws.onmessage = async (event) => {
|
|
978
|
-
refreshTimeout();
|
|
979
1029
|
if (event.data instanceof ArrayBuffer) {
|
|
980
1030
|
if (!firstByteReceived) {
|
|
981
1031
|
const ttfb = nowMs() - startTime;
|
|
982
1032
|
if (options.onTTFB) options.onTTFB(ttfb);
|
|
983
1033
|
firstByteReceived = true;
|
|
984
1034
|
}
|
|
1035
|
+
arm();
|
|
985
1036
|
if (options.onAudio) options.onAudio(new Uint8Array(event.data));
|
|
986
1037
|
return;
|
|
987
1038
|
}
|
|
988
1039
|
const text = event.data.toString();
|
|
989
1040
|
if (text === 'EOS') {
|
|
990
|
-
|
|
991
|
-
ws.close();
|
|
992
|
-
resolve();
|
|
1041
|
+
finish();
|
|
993
1042
|
return;
|
|
994
1043
|
}
|
|
995
1044
|
|
|
1045
|
+
let msg: any;
|
|
996
1046
|
try {
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
if (!firstByteReceived) {
|
|
1002
|
-
const ttfb = nowMs() - startTime;
|
|
1003
|
-
if (options.onTTFB) options.onTTFB(ttfb);
|
|
1004
|
-
firstByteReceived = true;
|
|
1005
|
-
}
|
|
1006
|
-
if (options.onAudio) options.onAudio(audioBuffer);
|
|
1007
|
-
return;
|
|
1008
|
-
}
|
|
1047
|
+
msg = JSON.parse(text);
|
|
1048
|
+
} catch {
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1009
1051
|
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1052
|
+
if (msg.type === 'error') {
|
|
1053
|
+
const data = typeof msg.data === 'object' && msg.data ? msg.data : { message: msg.data };
|
|
1054
|
+
finish(new LokutorError((data.code ?? 'internal.error') as ErrorCode, data.message ?? 'TTS failed', {
|
|
1055
|
+
detail: data.detail,
|
|
1056
|
+
retryable: data.retryable ?? false,
|
|
1057
|
+
}));
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1014
1060
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
if (activityTimeout) clearTimeout(activityTimeout);
|
|
1022
|
-
ws.close();
|
|
1023
|
-
resolve();
|
|
1061
|
+
if (msg.type === 'audio' && msg.data) {
|
|
1062
|
+
const audioBuffer = base64ToUint8Array(msg.data);
|
|
1063
|
+
if (!firstByteReceived) {
|
|
1064
|
+
const ttfb = nowMs() - startTime;
|
|
1065
|
+
if (options.onTTFB) options.onTTFB(ttfb);
|
|
1066
|
+
firstByteReceived = true;
|
|
1024
1067
|
}
|
|
1025
|
-
|
|
1068
|
+
arm();
|
|
1069
|
+
if (options.onAudio) options.onAudio(audioBuffer);
|
|
1070
|
+
return;
|
|
1026
1071
|
}
|
|
1027
|
-
};
|
|
1028
1072
|
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
};
|
|
1073
|
+
if (msg.type === 'visemes' && Array.isArray(msg.data) && options.onVisemes) {
|
|
1074
|
+
options.onVisemes(normalizeVisemes(msg.data));
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1034
1077
|
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1078
|
+
if (Array.isArray(msg) && options.onVisemes) {
|
|
1079
|
+
options.onVisemes(normalizeVisemes(msg));
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
if (msg.type === 'eos') {
|
|
1084
|
+
finish();
|
|
1085
|
+
}
|
|
1038
1086
|
};
|
|
1039
1087
|
|
|
1088
|
+
ws.onerror = (err) => finish(err);
|
|
1089
|
+
|
|
1090
|
+
ws.onclose = (ev) => {
|
|
1091
|
+
// A close before EOS is an error unless audio already arrived and the server closed cleanly.
|
|
1092
|
+
if (!finished) {
|
|
1093
|
+
if (firstByteReceived && ev.code === 1000) finish();
|
|
1094
|
+
else finish(new LokutorError('internal.error' as ErrorCode, `TTS socket closed (${ev.code}${ev.reason ? `: ${ev.reason}` : ''})`, { retryable: true }));
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1040
1097
|
} catch (err) {
|
|
1041
|
-
|
|
1042
|
-
if (options.onError) options.onError(err);
|
|
1043
|
-
reject(err);
|
|
1098
|
+
finish(err);
|
|
1044
1099
|
}
|
|
1045
1100
|
});
|
|
1046
1101
|
}
|
|
@@ -1170,7 +1225,7 @@ export class SpeechToTextClient {
|
|
|
1170
1225
|
try {
|
|
1171
1226
|
let url = this.serverUrl;
|
|
1172
1227
|
const separator = url.includes('?') ? '&' : '?';
|
|
1173
|
-
url += `${separator}api_key=${this.apiKey}`;
|
|
1228
|
+
url += `${separator}api_key=${encodeURIComponent(this.apiKey)}`;
|
|
1174
1229
|
|
|
1175
1230
|
this.ws = new WebSocket(url);
|
|
1176
1231
|
|
|
@@ -1179,11 +1234,22 @@ export class SpeechToTextClient {
|
|
|
1179
1234
|
this.onStatusChange?.('connected');
|
|
1180
1235
|
this.ws!.send(JSON.stringify({ language: this.language || Language.ENGLISH, vad: this.vad }));
|
|
1181
1236
|
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1237
|
+
// A denied or missing microphone rejects instead of leaving connect() pending forever (the
|
|
1238
|
+
// same hang 1.5.1 fixed in VoiceAgentClient).
|
|
1239
|
+
try {
|
|
1240
|
+
await this.audioManager!.startMicrophone((data) => {
|
|
1241
|
+
if (this.isConnected && this.ws?.readyState === WebSocket.OPEN) {
|
|
1242
|
+
this.ws.send(data);
|
|
1243
|
+
}
|
|
1244
|
+
});
|
|
1245
|
+
} catch (err) {
|
|
1246
|
+
const error = err instanceof LokutorError ? err
|
|
1247
|
+
: new LokutorError('audio.microphone_unavailable' as ErrorCode, 'Could not start the microphone', { original: err });
|
|
1248
|
+
this.onError?.(error);
|
|
1249
|
+
try { this.ws?.close(); } catch { /* already closed */ }
|
|
1250
|
+
settle(() => reject(error));
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1187
1253
|
|
|
1188
1254
|
settle(() => resolve(true));
|
|
1189
1255
|
};
|
|
@@ -1199,7 +1265,10 @@ export class SpeechToTextClient {
|
|
|
1199
1265
|
this.onPartialTranscript?.(msg.data ?? '');
|
|
1200
1266
|
}
|
|
1201
1267
|
} else if (msg.type === 'error') {
|
|
1202
|
-
|
|
1268
|
+
const data = typeof msg.data === 'object' && msg.data ? msg.data : { message: msg.data };
|
|
1269
|
+
this.onError?.(new LokutorError((data.code ?? 'internal.error') as ErrorCode, data.message ?? 'STT error', {
|
|
1270
|
+
detail: data.detail, retryable: data.retryable ?? false,
|
|
1271
|
+
}));
|
|
1203
1272
|
}
|
|
1204
1273
|
} catch {
|
|
1205
1274
|
// ignore malformed frames
|
package/src/types.ts
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* Built-in voices.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Use `F1`–`F5` / `M1`–`M5` (what GET /voices lists). Each is a slot: the server resolves it to the
|
|
8
|
+
* native speaker of the call's language in that slot, so `F1` in Spanish is a Spanish speaker and `F1`
|
|
9
|
+
* in English an English one.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* `EN_*` pin the English speakers regardless of language. `ESCA_*` is the retired Spanish/Catalan pack
|
|
12
|
+
* (its recordings measure as noisy in the top octave); do not use it in new code — `F1`–`M5` with
|
|
13
|
+
* `language: 'es'` gives the vetted Spanish voices.
|
|
14
14
|
*
|
|
15
15
|
* This enum is a convenience, not the full set — a cloned voice's id (`clone_…`) is also a valid
|
|
16
16
|
* `voice`, which is why the config fields accept any string.
|
|
@@ -29,36 +29,36 @@ export enum VoiceStyle {
|
|
|
29
29
|
EN_M5 = "en_m5",
|
|
30
30
|
|
|
31
31
|
// Spanish / Catalan reference speakers
|
|
32
|
+
/** @deprecated Retired pack; use F1 with language "es". */
|
|
32
33
|
ESCA_F1 = "esca_f1",
|
|
34
|
+
/** @deprecated Retired pack; use F2 with language "es". */
|
|
33
35
|
ESCA_F2 = "esca_f2",
|
|
36
|
+
/** @deprecated Retired pack; use F3 with language "es". */
|
|
34
37
|
ESCA_F3 = "esca_f3",
|
|
38
|
+
/** @deprecated Retired pack; use F4 with language "es". */
|
|
35
39
|
ESCA_F4 = "esca_f4",
|
|
40
|
+
/** @deprecated Retired pack; use F5 with language "es". */
|
|
36
41
|
ESCA_F5 = "esca_f5",
|
|
42
|
+
/** @deprecated Retired pack; use M1 with language "es". */
|
|
37
43
|
ESCA_M1 = "esca_m1",
|
|
44
|
+
/** @deprecated Retired pack; use M2 with language "es". */
|
|
38
45
|
ESCA_M2 = "esca_m2",
|
|
46
|
+
/** @deprecated Retired pack; use M3 with language "es". */
|
|
39
47
|
ESCA_M3 = "esca_m3",
|
|
48
|
+
/** @deprecated Retired pack; use M4 with language "es". */
|
|
40
49
|
ESCA_M4 = "esca_m4",
|
|
50
|
+
/** @deprecated Retired pack; use M5 with language "es". */
|
|
41
51
|
ESCA_M5 = "esca_m5",
|
|
42
52
|
|
|
43
|
-
/** @deprecated Versa 1.x name, mapped server-side. Use EN_F1 / ESCA_F1. */
|
|
44
53
|
F1 = "F1",
|
|
45
|
-
/** @deprecated Versa 1.x name, mapped server-side. */
|
|
46
54
|
F2 = "F2",
|
|
47
|
-
/** @deprecated Versa 1.x name, mapped server-side. */
|
|
48
55
|
F3 = "F3",
|
|
49
|
-
/** @deprecated Versa 1.x name, mapped server-side. */
|
|
50
56
|
F4 = "F4",
|
|
51
|
-
/** @deprecated Versa 1.x name, mapped server-side. */
|
|
52
57
|
F5 = "F5",
|
|
53
|
-
/** @deprecated Versa 1.x name, mapped server-side. */
|
|
54
58
|
M1 = "M1",
|
|
55
|
-
/** @deprecated Versa 1.x name, mapped server-side. */
|
|
56
59
|
M2 = "M2",
|
|
57
|
-
/** @deprecated Versa 1.x name, mapped server-side. */
|
|
58
60
|
M3 = "M3",
|
|
59
|
-
/** @deprecated Versa 1.x name, mapped server-side. */
|
|
60
61
|
M4 = "M4",
|
|
61
|
-
/** @deprecated Versa 1.x name, mapped server-side. */
|
|
62
62
|
M5 = "M5",
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -118,7 +118,14 @@ export const DEFAULT_URLS = {
|
|
|
118
118
|
* SDK Configuration interface
|
|
119
119
|
*/
|
|
120
120
|
export interface LokutorConfig {
|
|
121
|
-
|
|
121
|
+
/** An API key, or a short-lived wst_ ticket from POST /ws-ticket. Optional when `auth` is given. */
|
|
122
|
+
apiKey?: string;
|
|
123
|
+
/**
|
|
124
|
+
* Called before every connect (and reconnect) for the credential to use. In a browser, have it ask
|
|
125
|
+
* your backend for a fresh ticket (POST https://api.lokutor.com/ws-ticket with your API key) so the
|
|
126
|
+
* key itself never reaches the page; a ticket lasts 10 minutes, so reconnects need a new one.
|
|
127
|
+
*/
|
|
128
|
+
auth?: () => string | Promise<string>;
|
|
122
129
|
agentId?: string;
|
|
123
130
|
onTranscription?: (text: string) => void;
|
|
124
131
|
onResponse?: (text: string) => void;
|
|
@@ -313,6 +320,8 @@ export interface ToolDefinition {
|
|
|
313
320
|
export interface ToolCall {
|
|
314
321
|
name: string;
|
|
315
322
|
arguments: string;
|
|
323
|
+
/** Pass to submitToolResult(call_id, result). */
|
|
324
|
+
call_id: string;
|
|
316
325
|
}
|
|
317
326
|
|
|
318
327
|
/**
|
|
@@ -338,6 +347,11 @@ export type ErrorCode =
|
|
|
338
347
|
| 'stt.language_not_supported'
|
|
339
348
|
| 'agent.session_failed'
|
|
340
349
|
| 'agent.provider_error'
|
|
350
|
+
| 'agent.not_found'
|
|
351
|
+
| 'billing.insufficient_balance'
|
|
352
|
+
| 'billing.topup_required'
|
|
353
|
+
| 'billing.limit_reached'
|
|
354
|
+
| 'billing.account_disputed'
|
|
341
355
|
| 'session.ended'
|
|
342
356
|
| 'audio.microphone_unavailable'
|
|
343
357
|
| 'internal.error'
|
|
@@ -380,6 +394,11 @@ function isRetryableCode(code: ErrorCode): boolean {
|
|
|
380
394
|
'validation.steps_out_of_range',
|
|
381
395
|
'validation.invalid_request_format',
|
|
382
396
|
'internal.cancelled',
|
|
397
|
+
'agent.not_found',
|
|
398
|
+
'billing.insufficient_balance',
|
|
399
|
+
'billing.topup_required',
|
|
400
|
+
'billing.limit_reached',
|
|
401
|
+
'billing.account_disputed',
|
|
383
402
|
];
|
|
384
403
|
return !fatal.includes(code);
|
|
385
404
|
}
|