@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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { requestMicrophone } from './browser-audio';
|
|
2
|
+
|
|
1
3
|
const PANEL_CSS = /*css*/ `
|
|
2
4
|
.cv-panel {
|
|
3
5
|
position: relative;
|
|
@@ -349,7 +351,11 @@ export interface ConversationalPanelConfig {
|
|
|
349
351
|
backgroundColor?: string;
|
|
350
352
|
/** API key for connecting to the voice agent */
|
|
351
353
|
apiKey: string;
|
|
352
|
-
/**
|
|
354
|
+
/**
|
|
355
|
+
* Reference to the ConvoAgent class. Its constructor options include `microphone`, a
|
|
356
|
+
* Promise<MediaStream> requested inside the Start tap (see requestMicrophone); pass it on to
|
|
357
|
+
* BrowserAudioManager as `microphone` so the call uses it instead of asking again later.
|
|
358
|
+
*/
|
|
353
359
|
ConvoAgent: any;
|
|
354
360
|
/** Reference to the SphereVisualizer class */
|
|
355
361
|
SphereVisualizer: any;
|
|
@@ -365,6 +371,20 @@ export interface ConversationalPanelConfig {
|
|
|
365
371
|
silenceTimeout?: number;
|
|
366
372
|
}
|
|
367
373
|
|
|
374
|
+
/** What to tell the caller when the microphone did not start: a DOMException from getUserMedia, or the
|
|
375
|
+
* SDK's 'audio.microphone_unavailable' error wrapping one. */
|
|
376
|
+
export function microphoneMessage(err: any): string {
|
|
377
|
+
const name = String(err?.original?.name ?? err?.name ?? '');
|
|
378
|
+
const detail = String(err?.detail ?? err?.message ?? '');
|
|
379
|
+
if (/NotAllowedError|SecurityError/.test(name + ' ' + detail)) {
|
|
380
|
+
return 'Microphone access is blocked. Allow it for this site in your browser settings, then try again.';
|
|
381
|
+
}
|
|
382
|
+
if (/NotFoundError|OverconstrainedError/.test(name + ' ' + detail)) {
|
|
383
|
+
return 'No microphone was found on this device.';
|
|
384
|
+
}
|
|
385
|
+
return 'The microphone could not start. Please try again.';
|
|
386
|
+
}
|
|
387
|
+
|
|
368
388
|
export class ConversationalPanel {
|
|
369
389
|
private cfg: ConversationalPanelConfig;
|
|
370
390
|
private container: HTMLElement;
|
|
@@ -394,6 +414,8 @@ export class ConversationalPanel {
|
|
|
394
414
|
private countdownEl!: HTMLElement;
|
|
395
415
|
private noteEl!: HTMLElement;
|
|
396
416
|
private countdownTicker: number | null = null;
|
|
417
|
+
private microphone: Promise<MediaStream> | null = null;
|
|
418
|
+
private micError: unknown = null;
|
|
397
419
|
|
|
398
420
|
// Callbacks
|
|
399
421
|
onTranscription?: (text: string) => void;
|
|
@@ -507,14 +529,25 @@ export class ConversationalPanel {
|
|
|
507
529
|
return d.innerHTML;
|
|
508
530
|
}
|
|
509
531
|
|
|
510
|
-
/**
|
|
511
|
-
|
|
512
|
-
|
|
532
|
+
/**
|
|
533
|
+
* Start the conversation (called when user clicks "Start Conversation" or externally). Call it from
|
|
534
|
+
* inside the click or tap, before any await: it asks for the microphone there. Resolves true once the
|
|
535
|
+
* call is live, false when it could not start (the reason is shown, and passed to onError).
|
|
536
|
+
*/
|
|
537
|
+
async start(): Promise<boolean> {
|
|
538
|
+
if (this.isRunning) return false;
|
|
513
539
|
if (this._locked) {
|
|
514
540
|
this.showError('This session has ended. Refresh the page to start a new one.');
|
|
515
|
-
return;
|
|
541
|
+
return false;
|
|
516
542
|
}
|
|
517
543
|
this.isRunning = true;
|
|
544
|
+
// First, while this is still the tap: iOS Safari only reliably shows its microphone prompt for a request
|
|
545
|
+
// made inside a user gesture, and refuses capture with no prompt at all while the page's audio session
|
|
546
|
+
// is in playback mode. Asked for later, after the connection opened, the call went live with the agent
|
|
547
|
+
// talking and the caller's microphone never captured (2026-09-28).
|
|
548
|
+
this.microphone = requestMicrophone();
|
|
549
|
+
this.micError = null;
|
|
550
|
+
this.microphone.catch((e) => { this.micError = e; });
|
|
518
551
|
this.startBtn.disabled = true;
|
|
519
552
|
this.errorEl.classList.remove('is-visible');
|
|
520
553
|
this.playConnectingSound();
|
|
@@ -577,10 +610,13 @@ export class ConversationalPanel {
|
|
|
577
610
|
this.onError?.(err);
|
|
578
611
|
// The server's own words when it ended the session on purpose (e.g. "Demo time limit
|
|
579
612
|
// reached..."); a generic line for anything else.
|
|
580
|
-
this.end(err?.code === '
|
|
581
|
-
? err
|
|
582
|
-
: '
|
|
583
|
-
|
|
613
|
+
this.end(err?.code === 'audio.microphone_unavailable'
|
|
614
|
+
? microphoneMessage(err)
|
|
615
|
+
: err?.code === 'session.ended' && err?.message
|
|
616
|
+
? err.message
|
|
617
|
+
: 'Connection issue. Try again in a moment.');
|
|
618
|
+
},
|
|
619
|
+
microphone: this.microphone,
|
|
584
620
|
});
|
|
585
621
|
|
|
586
622
|
this.agent = agentHandle;
|
|
@@ -591,6 +627,8 @@ export class ConversationalPanel {
|
|
|
591
627
|
try { agentHandle.unlockAudioForMobile?.(); } catch (_) { /* ignore */ }
|
|
592
628
|
|
|
593
629
|
const ok = await agentHandle.connect();
|
|
630
|
+
// Ended while connecting (the microphone was refused, the server closed): the reason is already shown.
|
|
631
|
+
if (!this.isRunning) return false;
|
|
594
632
|
if (ok) {
|
|
595
633
|
this.fadeOutConnectingSound();
|
|
596
634
|
// Resize canvas to container
|
|
@@ -605,19 +643,22 @@ export class ConversationalPanel {
|
|
|
605
643
|
this.startTimer();
|
|
606
644
|
this.onStart?.();
|
|
607
645
|
if ((window as any).lucide) (window as any).lucide.createIcons();
|
|
608
|
-
|
|
609
|
-
this.fadeOutConnectingSound();
|
|
610
|
-
this.playErrorTone();
|
|
611
|
-
this.curtain.classList.remove('is-up');
|
|
612
|
-
this.showError('Could not connect. Please try again.');
|
|
613
|
-
this.isRunning = false;
|
|
646
|
+
return true;
|
|
614
647
|
}
|
|
648
|
+
this.fadeOutConnectingSound();
|
|
649
|
+
this.playErrorTone();
|
|
650
|
+
// The microphone's own failure when that is what stopped the call, not a generic connection line.
|
|
651
|
+
const micError = this.micError;
|
|
652
|
+
this.stop();
|
|
653
|
+
this.showError(micError ? microphoneMessage(micError) : 'Could not connect. Please try again.');
|
|
654
|
+
return false;
|
|
615
655
|
} catch (err: any) {
|
|
616
656
|
this.fadeOutConnectingSound();
|
|
617
657
|
this.playErrorTone();
|
|
618
658
|
console.error('ConversationalPanel start error:', err);
|
|
619
659
|
this.stop();
|
|
620
660
|
this.showError('Something went wrong. Please try again.');
|
|
661
|
+
return false;
|
|
621
662
|
} finally {
|
|
622
663
|
this.startBtn.disabled = false;
|
|
623
664
|
}
|
|
@@ -633,6 +674,11 @@ export class ConversationalPanel {
|
|
|
633
674
|
try { this.agent.disconnect(); } catch (_) {}
|
|
634
675
|
this.agent = null;
|
|
635
676
|
}
|
|
677
|
+
// Release the stream asked for at Start even if the agent never took it over (a ConvoAgent that
|
|
678
|
+
// ignores `microphone` asks again itself); stopping tracks the agent already stopped is harmless.
|
|
679
|
+
const mic = this.microphone;
|
|
680
|
+
this.microphone = null;
|
|
681
|
+
mic?.then((s) => s.getTracks().forEach((t) => t.stop()), () => {});
|
|
636
682
|
if (this.timerTicker) { clearInterval(this.timerTicker); this.timerTicker = null; }
|
|
637
683
|
this.stopCountdown();
|
|
638
684
|
this.setNote('');
|
package/src/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ export {
|
|
|
14
14
|
} from './client';
|
|
15
15
|
export { BrowserAudioManager } from './browser-audio';
|
|
16
16
|
export { NodeAudioManager } from './node-audio';
|
|
17
|
-
export { ConversationalPanel } from './conversational-panel';
|
|
17
|
+
export { ConversationalPanel, microphoneMessage } from './conversational-panel';
|
|
18
18
|
export type { ConversationalPanelConfig } from './conversational-panel';
|
|
19
19
|
export {
|
|
20
20
|
pcm16ToFloat32,
|
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,7 +347,13 @@ 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'
|
|
356
|
+
| 'audio.microphone_unavailable'
|
|
342
357
|
| 'internal.error'
|
|
343
358
|
| 'internal.timeout'
|
|
344
359
|
| 'internal.cancelled'
|
|
@@ -371,6 +386,7 @@ function isRetryableCode(code: ErrorCode): boolean {
|
|
|
371
386
|
'auth.invalid_key',
|
|
372
387
|
'auth.time_limited',
|
|
373
388
|
'session.ended',
|
|
389
|
+
'audio.microphone_unavailable',
|
|
374
390
|
'validation.invalid_voice',
|
|
375
391
|
'validation.invalid_language',
|
|
376
392
|
'validation.text_too_long',
|
|
@@ -378,6 +394,11 @@ function isRetryableCode(code: ErrorCode): boolean {
|
|
|
378
394
|
'validation.steps_out_of_range',
|
|
379
395
|
'validation.invalid_request_format',
|
|
380
396
|
'internal.cancelled',
|
|
397
|
+
'agent.not_found',
|
|
398
|
+
'billing.insufficient_balance',
|
|
399
|
+
'billing.topup_required',
|
|
400
|
+
'billing.limit_reached',
|
|
401
|
+
'billing.account_disputed',
|
|
381
402
|
];
|
|
382
403
|
return !fatal.includes(code);
|
|
383
404
|
}
|