@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.
@@ -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
- /** Reference to the ConvoAgent class */
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
- /** Start the conversation (called when user clicks "Start Conversation" or externally) */
511
- async start() {
512
- if (this.isRunning) return;
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 === 'session.ended' && err?.message
581
- ? err.message
582
- : 'Connection issue. Try again in a moment.');
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
- } else {
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
- * Versa 2.0 names each voice for the language its reference speaker was recorded in: `EN_*` are
8
- * English speakers, `ESCA_*` Spanish/Catalan. That is a guide to how a voice sounds, not a
9
- * restriction — the model is zero-shot, so any voice can speak any supported language.
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
- * The bare `F1`–`M5` values are the Versa 1.x names. They still work: the server maps them onto
12
- * the matching Versa 2.0 voice, keeping the language family of the call. Prefer the explicit names
13
- * in new code.
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
- apiKey: string;
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
  }