@lokutor/sdk 1.5.0 → 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 +70 -25
- package/dist/index.d.ts +70 -25
- package/dist/index.js +267 -106
- package/dist/index.mjs +265 -106
- package/package.json +1 -1
- package/src/browser-audio.ts +82 -13
- package/src/client.ts +185 -99
- package/src/conversational-panel.ts +61 -15
- package/src/index.ts +1 -1
- package/src/types.ts +38 -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=***');
|
|
@@ -314,11 +339,29 @@ export class VoiceAgentClient {
|
|
|
314
339
|
this.sendConfig();
|
|
315
340
|
|
|
316
341
|
if (this.audioManager && !this.micStarted) {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
this.
|
|
320
|
-
|
|
321
|
-
|
|
342
|
+
try {
|
|
343
|
+
await this.audioManager.startMicrophone((data) => {
|
|
344
|
+
if (this.isConnected) {
|
|
345
|
+
this.sendAudio(data);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
} catch (e) {
|
|
349
|
+
// No microphone, no call. Uncaught here, this left connect() pending forever while the session
|
|
350
|
+
// it had opened went on talking: the caller heard the agent, nothing they said reached it, and
|
|
351
|
+
// the page never learned why (iOS refusing capture with no prompt, 2026-09-28).
|
|
352
|
+
const name = (e as { name?: string } | null)?.name || 'Error';
|
|
353
|
+
const error = new LokutorError('audio.microphone_unavailable',
|
|
354
|
+
name === 'NotAllowedError' || name === 'SecurityError'
|
|
355
|
+
? 'Microphone access was not allowed.'
|
|
356
|
+
: 'The microphone could not be started.',
|
|
357
|
+
{ detail: `${name}: ${(e as Error)?.message ?? e}`, original: e, retryable: false });
|
|
358
|
+
sdkTrace('mic.failed', { name });
|
|
359
|
+
settle(() => reject(error));
|
|
360
|
+
this.isUserDisconnect = true;
|
|
361
|
+
try { this.ws?.close(); } catch (_) { /* already closing */ }
|
|
362
|
+
this.emit('error', error); // reaches onError too
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
322
365
|
this.micStarted = true;
|
|
323
366
|
}
|
|
324
367
|
|
|
@@ -343,7 +386,7 @@ export class VoiceAgentClient {
|
|
|
343
386
|
});
|
|
344
387
|
console.error('❌ WebSocket error:', error.message);
|
|
345
388
|
sdkTrace('ws.error', { code: error.code, message: error.message });
|
|
346
|
-
|
|
389
|
+
this.emit('error', error);
|
|
347
390
|
if (!this.isConnected) {
|
|
348
391
|
settle(() => reject(error));
|
|
349
392
|
}
|
|
@@ -386,8 +429,8 @@ export class VoiceAgentClient {
|
|
|
386
429
|
});
|
|
387
430
|
console.log(`Session ended by the server (code ${event.code})`);
|
|
388
431
|
this.emit('ended', { code: event.code, reason: event.reason || '' });
|
|
389
|
-
|
|
390
|
-
|
|
432
|
+
this.emit('error', ended);
|
|
433
|
+
this.emit('status', 'disconnected');
|
|
391
434
|
return;
|
|
392
435
|
}
|
|
393
436
|
|
|
@@ -398,7 +441,7 @@ export class VoiceAgentClient {
|
|
|
398
441
|
|
|
399
442
|
console.warn(`Connection lost. Reconnecting in ${backoffDelay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
|
400
443
|
|
|
401
|
-
|
|
444
|
+
this.emit('status', 'reconnecting');
|
|
402
445
|
|
|
403
446
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
404
447
|
this.reconnectTimer = setTimeout(() => {
|
|
@@ -409,16 +452,16 @@ export class VoiceAgentClient {
|
|
|
409
452
|
}, backoffDelay);
|
|
410
453
|
} else if (this.isUserDisconnect) {
|
|
411
454
|
console.log('Disconnected');
|
|
412
|
-
|
|
455
|
+
this.emit('status', 'disconnected');
|
|
413
456
|
} else {
|
|
414
457
|
console.log('Max reconnection attempts reached');
|
|
415
|
-
|
|
458
|
+
this.emit('status', 'disconnected');
|
|
416
459
|
}
|
|
417
460
|
};
|
|
418
461
|
|
|
419
462
|
} catch (err) {
|
|
420
463
|
const error = err instanceof LokutorError ? err : new LokutorError('internal.error', 'Failed to create WebSocket connection', { original: err });
|
|
421
|
-
|
|
464
|
+
this.emit('error', error);
|
|
422
465
|
settle(() => reject(error));
|
|
423
466
|
}
|
|
424
467
|
});
|
|
@@ -453,13 +496,14 @@ export class VoiceAgentClient {
|
|
|
453
496
|
this.configConfirmed = false;
|
|
454
497
|
|
|
455
498
|
// Send feature/config flags — order matches the test agent for consistency.
|
|
456
|
-
|
|
457
|
-
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 }));
|
|
458
502
|
if (this.tools && this.tools.length > 0) {
|
|
459
503
|
this.ws.send(JSON.stringify({ type: 'tools', data: this.tools }));
|
|
460
504
|
}
|
|
461
505
|
this.ws.send(JSON.stringify({ type: 'visemes', data: this.wantVisemes }));
|
|
462
|
-
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 }));
|
|
463
507
|
|
|
464
508
|
// Inform the server of our sample rates for echo cancellation.
|
|
465
509
|
this.ws.send(JSON.stringify({ type: 'rates', playback: 44100, input: 16000 }));
|
|
@@ -527,6 +571,12 @@ export class VoiceAgentClient {
|
|
|
527
571
|
sdkTrace('server_rates', { playback: this.serverPlaybackRate, input: this.serverInputRate });
|
|
528
572
|
break;
|
|
529
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
|
+
}
|
|
530
580
|
const role = msg.role === 'user' ? 'user' : 'agent';
|
|
531
581
|
// Store in history
|
|
532
582
|
this.messages.push({
|
|
@@ -536,11 +586,9 @@ export class VoiceAgentClient {
|
|
|
536
586
|
});
|
|
537
587
|
|
|
538
588
|
if (msg.role === 'user') {
|
|
539
|
-
if (this.onTranscription) this.onTranscription(msg.data);
|
|
540
589
|
this.emit('transcription', msg.data);
|
|
541
590
|
console.log(`💬 You: ${msg.data}`);
|
|
542
591
|
} else {
|
|
543
|
-
if (this.onResponse) this.onResponse(msg.data);
|
|
544
592
|
this.emit('response', msg.data);
|
|
545
593
|
console.log(`🤖 Agent: ${msg.data}`);
|
|
546
594
|
}
|
|
@@ -562,7 +610,6 @@ export class VoiceAgentClient {
|
|
|
562
610
|
if (msg.data === 'interrupted' && this.audioManager) {
|
|
563
611
|
this.audioManager.stopPlayback();
|
|
564
612
|
}
|
|
565
|
-
if (this.onStatus) this.onStatus(msg.data);
|
|
566
613
|
this.emit('status', msg.data);
|
|
567
614
|
const icons: Record<string, string> = {
|
|
568
615
|
'interrupted': '⚡',
|
|
@@ -600,8 +647,7 @@ export class VoiceAgentClient {
|
|
|
600
647
|
detail: backendDetail,
|
|
601
648
|
retryable: backendRetryable,
|
|
602
649
|
});
|
|
603
|
-
|
|
604
|
-
this.emit('error', error);
|
|
650
|
+
this.emit('error', error); // reaches onError too (calling it here as well delivered every server error twice)
|
|
605
651
|
console.error(`❌ Server error: [${error.code}] ${error.message}`);
|
|
606
652
|
break;
|
|
607
653
|
}
|
|
@@ -616,11 +662,14 @@ export class VoiceAgentClient {
|
|
|
616
662
|
break;
|
|
617
663
|
}
|
|
618
664
|
|
|
619
|
-
case 'tool_call':
|
|
620
|
-
|
|
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 };
|
|
621
669
|
console.log(`🛠️ Tool Call: ${msg.name}(${msg.arguments})`);
|
|
622
|
-
this.emit('tool_call',
|
|
670
|
+
this.emit('tool_call', call);
|
|
623
671
|
break;
|
|
672
|
+
}
|
|
624
673
|
}
|
|
625
674
|
} catch (e) {
|
|
626
675
|
sdkTrace('ws.recv.parse_error', { preview: text?.slice(0, 120) });
|
|
@@ -748,7 +797,7 @@ export class VoiceAgentClient {
|
|
|
748
797
|
* No authentication required.
|
|
749
798
|
*/
|
|
750
799
|
static async fetchVoices(baseUrl?: string): Promise<VoiceInfo[]> {
|
|
751
|
-
const url =
|
|
800
|
+
const url = apiOrigin(baseUrl);
|
|
752
801
|
const data = await fetchJson<{ voices: VoiceInfo[] }>(`${url}/voices`);
|
|
753
802
|
return data.voices || [];
|
|
754
803
|
}
|
|
@@ -758,7 +807,7 @@ export class VoiceAgentClient {
|
|
|
758
807
|
* No authentication required.
|
|
759
808
|
*/
|
|
760
809
|
static async fetchLanguages(baseUrl?: string): Promise<LanguageInfo[]> {
|
|
761
|
-
const url =
|
|
810
|
+
const url = apiOrigin(baseUrl);
|
|
762
811
|
const data = await fetchJson<{ languages: LanguageInfo[] }>(`${url}/languages`);
|
|
763
812
|
return data.languages || [];
|
|
764
813
|
}
|
|
@@ -768,7 +817,7 @@ export class VoiceAgentClient {
|
|
|
768
817
|
* No authentication required.
|
|
769
818
|
*/
|
|
770
819
|
static async fetchModels(baseUrl?: string): Promise<ModelInfo[]> {
|
|
771
|
-
const url =
|
|
820
|
+
const url = apiOrigin(baseUrl);
|
|
772
821
|
const data = await fetchJson<{ models: ModelInfo[] }>(`${url}/models`);
|
|
773
822
|
return data.models || [];
|
|
774
823
|
}
|
|
@@ -778,7 +827,7 @@ export class VoiceAgentClient {
|
|
|
778
827
|
* No authentication required.
|
|
779
828
|
*/
|
|
780
829
|
static async fetchConfig(baseUrl?: string): Promise<ServerConfig> {
|
|
781
|
-
const url =
|
|
830
|
+
const url = apiOrigin(baseUrl);
|
|
782
831
|
return fetchJson<ServerConfig>(`${url}/config`);
|
|
783
832
|
}
|
|
784
833
|
|
|
@@ -787,17 +836,17 @@ export class VoiceAgentClient {
|
|
|
787
836
|
* No authentication required.
|
|
788
837
|
*/
|
|
789
838
|
static async fetchStatus(baseUrl?: string): Promise<ServerStatus> {
|
|
790
|
-
const url =
|
|
839
|
+
const url = apiOrigin(baseUrl);
|
|
791
840
|
return fetchJson<ServerStatus>(`${url}/status`);
|
|
792
841
|
}
|
|
793
842
|
|
|
794
843
|
/**
|
|
795
|
-
* Fetch
|
|
796
|
-
* 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.)
|
|
797
846
|
*/
|
|
798
847
|
static async fetchHealth(baseUrl?: string): Promise<HealthStatus> {
|
|
799
|
-
const url =
|
|
800
|
-
return fetchJson<HealthStatus>(`${url}/
|
|
848
|
+
const url = apiOrigin(baseUrl);
|
|
849
|
+
return fetchJson<HealthStatus>(`${url}/ready`);
|
|
801
850
|
}
|
|
802
851
|
|
|
803
852
|
/**
|
|
@@ -824,6 +873,7 @@ export class VoiceAgentClient {
|
|
|
824
873
|
*/
|
|
825
874
|
public updateVoice(voice: VoiceId) {
|
|
826
875
|
this.voice = voice;
|
|
876
|
+
this.voiceExplicit = true;
|
|
827
877
|
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.isConnected) {
|
|
828
878
|
this.ws.send(JSON.stringify({ type: 'voice', data: voice }));
|
|
829
879
|
console.log(`⚙️ Updated voice: ${voice}`);
|
|
@@ -835,6 +885,7 @@ export class VoiceAgentClient {
|
|
|
835
885
|
*/
|
|
836
886
|
public updateLanguage(language: Language) {
|
|
837
887
|
this.language = language;
|
|
888
|
+
this.languageExplicit = true;
|
|
838
889
|
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.isConnected) {
|
|
839
890
|
this.ws.send(JSON.stringify({ type: 'language', data: language }));
|
|
840
891
|
console.log(`⚙️ Updated language: ${language}`);
|
|
@@ -918,112 +969,133 @@ export class TTSClient {
|
|
|
918
969
|
onTTFB?: (ms: number) => void;
|
|
919
970
|
onError?: (error: any) => void;
|
|
920
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.
|
|
921
975
|
return new Promise((resolve, reject) => {
|
|
922
|
-
let
|
|
976
|
+
let timer: any;
|
|
923
977
|
let ws: WebSocket;
|
|
924
978
|
let startTime: number;
|
|
925
979
|
let firstByteReceived = false;
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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 {
|
|
932
991
|
resolve();
|
|
933
|
-
}
|
|
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);
|
|
934
1000
|
};
|
|
935
1001
|
|
|
936
1002
|
try {
|
|
937
1003
|
let url = DEFAULT_URLS.TTS;
|
|
938
1004
|
if (this.apiKey) {
|
|
939
1005
|
const separator = url.includes('?') ? '&' : '?';
|
|
940
|
-
url += `${separator}api_key=${this.apiKey}`;
|
|
1006
|
+
url += `${separator}api_key=${encodeURIComponent(this.apiKey)}`;
|
|
941
1007
|
}
|
|
942
1008
|
|
|
943
1009
|
ws = new WebSocket(url);
|
|
944
1010
|
ws.binaryType = 'arraybuffer';
|
|
945
1011
|
|
|
946
1012
|
ws.onopen = () => {
|
|
947
|
-
|
|
948
|
-
|
|
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> = {
|
|
949
1017
|
text: options.text,
|
|
950
1018
|
voice: options.voice || VoiceStyle.F1,
|
|
951
1019
|
language: options.language || Language.ENGLISH,
|
|
952
|
-
speed: options.speed || 1.05,
|
|
953
|
-
steps: options.steps || 24,
|
|
954
1020
|
visemes: options.visemes || false
|
|
955
1021
|
};
|
|
1022
|
+
if (options.speed !== undefined) req.speed = options.speed;
|
|
1023
|
+
if (options.steps !== undefined) req.steps = options.steps;
|
|
956
1024
|
ws.send(JSON.stringify(req));
|
|
957
1025
|
startTime = nowMs();
|
|
958
1026
|
};
|
|
959
1027
|
|
|
960
1028
|
ws.onmessage = async (event) => {
|
|
961
|
-
refreshTimeout();
|
|
962
1029
|
if (event.data instanceof ArrayBuffer) {
|
|
963
1030
|
if (!firstByteReceived) {
|
|
964
1031
|
const ttfb = nowMs() - startTime;
|
|
965
1032
|
if (options.onTTFB) options.onTTFB(ttfb);
|
|
966
1033
|
firstByteReceived = true;
|
|
967
1034
|
}
|
|
1035
|
+
arm();
|
|
968
1036
|
if (options.onAudio) options.onAudio(new Uint8Array(event.data));
|
|
969
1037
|
return;
|
|
970
1038
|
}
|
|
971
1039
|
const text = event.data.toString();
|
|
972
1040
|
if (text === 'EOS') {
|
|
973
|
-
|
|
974
|
-
ws.close();
|
|
975
|
-
resolve();
|
|
1041
|
+
finish();
|
|
976
1042
|
return;
|
|
977
1043
|
}
|
|
978
1044
|
|
|
1045
|
+
let msg: any;
|
|
979
1046
|
try {
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
if (!firstByteReceived) {
|
|
985
|
-
const ttfb = nowMs() - startTime;
|
|
986
|
-
if (options.onTTFB) options.onTTFB(ttfb);
|
|
987
|
-
firstByteReceived = true;
|
|
988
|
-
}
|
|
989
|
-
if (options.onAudio) options.onAudio(audioBuffer);
|
|
990
|
-
return;
|
|
991
|
-
}
|
|
1047
|
+
msg = JSON.parse(text);
|
|
1048
|
+
} catch {
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
992
1051
|
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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
|
+
}
|
|
997
1060
|
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
if (activityTimeout) clearTimeout(activityTimeout);
|
|
1005
|
-
ws.close();
|
|
1006
|
-
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;
|
|
1007
1067
|
}
|
|
1008
|
-
|
|
1068
|
+
arm();
|
|
1069
|
+
if (options.onAudio) options.onAudio(audioBuffer);
|
|
1070
|
+
return;
|
|
1009
1071
|
}
|
|
1010
|
-
};
|
|
1011
1072
|
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
};
|
|
1073
|
+
if (msg.type === 'visemes' && Array.isArray(msg.data) && options.onVisemes) {
|
|
1074
|
+
options.onVisemes(normalizeVisemes(msg.data));
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1017
1077
|
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1078
|
+
if (Array.isArray(msg) && options.onVisemes) {
|
|
1079
|
+
options.onVisemes(normalizeVisemes(msg));
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
if (msg.type === 'eos') {
|
|
1084
|
+
finish();
|
|
1085
|
+
}
|
|
1021
1086
|
};
|
|
1022
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
|
+
};
|
|
1023
1097
|
} catch (err) {
|
|
1024
|
-
|
|
1025
|
-
if (options.onError) options.onError(err);
|
|
1026
|
-
reject(err);
|
|
1098
|
+
finish(err);
|
|
1027
1099
|
}
|
|
1028
1100
|
});
|
|
1029
1101
|
}
|
|
@@ -1153,7 +1225,7 @@ export class SpeechToTextClient {
|
|
|
1153
1225
|
try {
|
|
1154
1226
|
let url = this.serverUrl;
|
|
1155
1227
|
const separator = url.includes('?') ? '&' : '?';
|
|
1156
|
-
url += `${separator}api_key=${this.apiKey}`;
|
|
1228
|
+
url += `${separator}api_key=${encodeURIComponent(this.apiKey)}`;
|
|
1157
1229
|
|
|
1158
1230
|
this.ws = new WebSocket(url);
|
|
1159
1231
|
|
|
@@ -1162,11 +1234,22 @@ export class SpeechToTextClient {
|
|
|
1162
1234
|
this.onStatusChange?.('connected');
|
|
1163
1235
|
this.ws!.send(JSON.stringify({ language: this.language || Language.ENGLISH, vad: this.vad }));
|
|
1164
1236
|
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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
|
+
}
|
|
1170
1253
|
|
|
1171
1254
|
settle(() => resolve(true));
|
|
1172
1255
|
};
|
|
@@ -1182,7 +1265,10 @@ export class SpeechToTextClient {
|
|
|
1182
1265
|
this.onPartialTranscript?.(msg.data ?? '');
|
|
1183
1266
|
}
|
|
1184
1267
|
} else if (msg.type === 'error') {
|
|
1185
|
-
|
|
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
|
+
}));
|
|
1186
1272
|
}
|
|
1187
1273
|
} catch {
|
|
1188
1274
|
// ignore malformed frames
|