@lokutor/sdk 1.4.0 → 1.5.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/dist/index.d.mts CHANGED
@@ -289,7 +289,7 @@ interface ToolCall {
289
289
  /**
290
290
  * Error code enum matching the backend API error catalog
291
291
  */
292
- type ErrorCode = 'auth.missing_key' | 'auth.invalid_key' | 'auth.rate_limited' | 'auth.time_limited' | 'validation.invalid_voice' | 'validation.invalid_language' | 'validation.text_too_long' | 'validation.speed_out_of_range' | 'validation.steps_out_of_range' | 'validation.invalid_request_format' | 'tts.synthesis_failed' | 'tts.voice_unavailable' | 'tts.model_not_found' | 'tts.session_limit_reached' | 'stt.not_configured' | 'stt.stream_create_failed' | 'stt.language_not_supported' | 'agent.session_failed' | 'agent.provider_error' | 'internal.error' | 'internal.timeout' | 'internal.cancelled' | 'ws.close';
292
+ type ErrorCode = 'auth.missing_key' | 'auth.invalid_key' | 'auth.rate_limited' | 'auth.time_limited' | 'validation.invalid_voice' | 'validation.invalid_language' | 'validation.text_too_long' | 'validation.speed_out_of_range' | 'validation.steps_out_of_range' | 'validation.invalid_request_format' | 'tts.synthesis_failed' | 'tts.voice_unavailable' | 'tts.model_not_found' | 'tts.session_limit_reached' | 'stt.not_configured' | 'stt.stream_create_failed' | 'stt.language_not_supported' | 'agent.session_failed' | 'agent.provider_error' | 'session.ended' | 'internal.error' | 'internal.timeout' | 'internal.cancelled' | 'ws.close';
293
293
  /**
294
294
  * Typed error class for all Lokutor SDK errors.
295
295
  * Includes the backend error code, human-readable message,
@@ -325,6 +325,13 @@ interface AudioManager {
325
325
  setMuted(muted: boolean): void;
326
326
  getAmplitude(): number;
327
327
  }
328
+ /** What the server announced about how long this session may run (landing-page demo sessions). */
329
+ interface SessionLimit {
330
+ /** Seconds the session may still run, from when the message arrived. */
331
+ seconds: number;
332
+ /** Why there is a limit, e.g. "demo". */
333
+ reason?: string;
334
+ }
328
335
  /**
329
336
  * Main client for Lokutor Voice Agent SDK
330
337
  *
@@ -358,6 +365,7 @@ declare class VoiceAgentClient {
358
365
  private reconnectAttempts;
359
366
  private maxReconnectAttempts;
360
367
  private reconnectTimer;
368
+ private micStarted;
361
369
  private serverPlaybackRate;
362
370
  private serverInputRate;
363
371
  private serverUrl;
@@ -685,6 +693,12 @@ interface BrowserAudioConfig {
685
693
  autoGainControl?: boolean;
686
694
  echoCancellation?: boolean;
687
695
  noiseSuppression?: boolean;
696
+ /**
697
+ * Keep the screen awake while the microphone is live (Screen Wake Lock API), as a video player does.
698
+ * Default true: a phone that dims and locks mid-call suspends the page's microphone and audio, and
699
+ * the call goes silent in both directions. Set false if your app manages this itself.
700
+ */
701
+ keepAwake?: boolean;
688
702
  onInputError?: (error: Error) => void;
689
703
  }
690
704
  /**
@@ -714,6 +728,9 @@ declare class BrowserAudioManager {
714
728
  private autoGainControl;
715
729
  private echoCancellation;
716
730
  private noiseSuppression;
731
+ private keepAwake;
732
+ private wakeLock;
733
+ private visibilityHandler;
717
734
  private onAudioInput?;
718
735
  private onInputError?;
719
736
  private isMuted;
@@ -735,6 +752,13 @@ declare class BrowserAudioManager {
735
752
  * Stop capturing microphone input
736
753
  */
737
754
  stopMicrophone(): void;
755
+ /**
756
+ * Keep the screen on while the microphone is live, and bring audio back when the page returns
757
+ * to the foreground (iOS leaves the context "interrupted" after the screen locks).
758
+ */
759
+ private holdScreenAwake;
760
+ private requestWakeLock;
761
+ private releaseScreenAwake;
738
762
  /**
739
763
  * Play back audio received from the server
740
764
  * @param pcm16Data Int16 PCM audio data at SPEAKER_SAMPLE_RATE
@@ -865,12 +889,21 @@ declare class ConversationalPanel {
865
889
  private muteSvg;
866
890
  private stopBtn;
867
891
  private visualizerWrap;
892
+ private countdownEl;
893
+ private noteEl;
894
+ private countdownTicker;
868
895
  onTranscription?: (text: string) => void;
869
896
  onResponse?: (text: string) => void;
870
897
  onStart?: () => void;
871
898
  onStop?: () => void;
872
899
  onError?: (err: any) => void;
873
900
  onToolCall?: (tool: any) => void;
901
+ /**
902
+ * The call ended without the visitor pressing End: a time limit, the silence timeout, the server
903
+ * closing the session, or the connection being lost. Receives the message the panel shows, so a
904
+ * page that renders its own chrome (and hides this panel's curtain) can show it too.
905
+ */
906
+ onEnded?: (message: string) => void;
874
907
  constructor(cfg: ConversationalPanelConfig);
875
908
  private buildDOM;
876
909
  private esc;
@@ -893,6 +926,12 @@ declare class ConversationalPanel {
893
926
  setTools(tools: any[]): void;
894
927
  /** Show an error message */
895
928
  showError(text: string): void;
929
+ /** End the call for a reason other than the End button, and say why. */
930
+ private end;
931
+ /** Show how long the session may still run, as announced by the server. */
932
+ private startCountdown;
933
+ private stopCountdown;
934
+ private setNote;
896
935
  /** Destroy the component, removing all DOM and stopping any active session */
897
936
  destroy(): void;
898
937
  private startTimer;
@@ -902,4 +941,4 @@ declare class ConversationalPanel {
902
941
  private playErrorTone;
903
942
  }
904
943
 
905
- export { AUDIO_CONFIG, type AnalyserConfig, type AudioManager, type BrowserAudioConfig, BrowserAudioManager, type BrowserAudioOptions, ConversationalPanel, type ConversationalPanelConfig, DEFAULT_URLS, type ErrorCode, type HealthStatus, Language, type LanguageInfo, type LokutorConfig, LokutorError, type ModelInfo, NodeAudioManager, STTClient, type ServerConfig, type ServerStatus, SpeechToTextClient, type SpeechToTextOptions, StreamResampler, type SynthesizeOptions, TTSClient, type ToolCall, type ToolDefinition, type TranscribeOptions, type TranscribeResult, type TranscribeSegment, type Viseme, VoiceAgentClient, type VoiceAgentOptions, type VoiceId, type VoiceInfo, VoiceStyle, applyLowPassFilter, bytesToPcm16, calculateRMS, float32ToPcm16, isRetryable, normalizeAudio, pcm16ToBytes, pcm16ToFloat32, resample, resampleWithAntiAliasing, simpleConversation, simpleTTS, simpleTranscribe };
944
+ export { AUDIO_CONFIG, type AnalyserConfig, type AudioManager, type BrowserAudioConfig, BrowserAudioManager, type BrowserAudioOptions, ConversationalPanel, type ConversationalPanelConfig, DEFAULT_URLS, type ErrorCode, type HealthStatus, Language, type LanguageInfo, type LokutorConfig, LokutorError, type ModelInfo, NodeAudioManager, STTClient, type ServerConfig, type ServerStatus, type SessionLimit, SpeechToTextClient, type SpeechToTextOptions, StreamResampler, type SynthesizeOptions, TTSClient, type ToolCall, type ToolDefinition, type TranscribeOptions, type TranscribeResult, type TranscribeSegment, type Viseme, VoiceAgentClient, type VoiceAgentOptions, type VoiceId, type VoiceInfo, VoiceStyle, applyLowPassFilter, bytesToPcm16, calculateRMS, float32ToPcm16, isRetryable, normalizeAudio, pcm16ToBytes, pcm16ToFloat32, resample, resampleWithAntiAliasing, simpleConversation, simpleTTS, simpleTranscribe };
package/dist/index.d.ts CHANGED
@@ -289,7 +289,7 @@ interface ToolCall {
289
289
  /**
290
290
  * Error code enum matching the backend API error catalog
291
291
  */
292
- type ErrorCode = 'auth.missing_key' | 'auth.invalid_key' | 'auth.rate_limited' | 'auth.time_limited' | 'validation.invalid_voice' | 'validation.invalid_language' | 'validation.text_too_long' | 'validation.speed_out_of_range' | 'validation.steps_out_of_range' | 'validation.invalid_request_format' | 'tts.synthesis_failed' | 'tts.voice_unavailable' | 'tts.model_not_found' | 'tts.session_limit_reached' | 'stt.not_configured' | 'stt.stream_create_failed' | 'stt.language_not_supported' | 'agent.session_failed' | 'agent.provider_error' | 'internal.error' | 'internal.timeout' | 'internal.cancelled' | 'ws.close';
292
+ type ErrorCode = 'auth.missing_key' | 'auth.invalid_key' | 'auth.rate_limited' | 'auth.time_limited' | 'validation.invalid_voice' | 'validation.invalid_language' | 'validation.text_too_long' | 'validation.speed_out_of_range' | 'validation.steps_out_of_range' | 'validation.invalid_request_format' | 'tts.synthesis_failed' | 'tts.voice_unavailable' | 'tts.model_not_found' | 'tts.session_limit_reached' | 'stt.not_configured' | 'stt.stream_create_failed' | 'stt.language_not_supported' | 'agent.session_failed' | 'agent.provider_error' | 'session.ended' | 'internal.error' | 'internal.timeout' | 'internal.cancelled' | 'ws.close';
293
293
  /**
294
294
  * Typed error class for all Lokutor SDK errors.
295
295
  * Includes the backend error code, human-readable message,
@@ -325,6 +325,13 @@ interface AudioManager {
325
325
  setMuted(muted: boolean): void;
326
326
  getAmplitude(): number;
327
327
  }
328
+ /** What the server announced about how long this session may run (landing-page demo sessions). */
329
+ interface SessionLimit {
330
+ /** Seconds the session may still run, from when the message arrived. */
331
+ seconds: number;
332
+ /** Why there is a limit, e.g. "demo". */
333
+ reason?: string;
334
+ }
328
335
  /**
329
336
  * Main client for Lokutor Voice Agent SDK
330
337
  *
@@ -358,6 +365,7 @@ declare class VoiceAgentClient {
358
365
  private reconnectAttempts;
359
366
  private maxReconnectAttempts;
360
367
  private reconnectTimer;
368
+ private micStarted;
361
369
  private serverPlaybackRate;
362
370
  private serverInputRate;
363
371
  private serverUrl;
@@ -685,6 +693,12 @@ interface BrowserAudioConfig {
685
693
  autoGainControl?: boolean;
686
694
  echoCancellation?: boolean;
687
695
  noiseSuppression?: boolean;
696
+ /**
697
+ * Keep the screen awake while the microphone is live (Screen Wake Lock API), as a video player does.
698
+ * Default true: a phone that dims and locks mid-call suspends the page's microphone and audio, and
699
+ * the call goes silent in both directions. Set false if your app manages this itself.
700
+ */
701
+ keepAwake?: boolean;
688
702
  onInputError?: (error: Error) => void;
689
703
  }
690
704
  /**
@@ -714,6 +728,9 @@ declare class BrowserAudioManager {
714
728
  private autoGainControl;
715
729
  private echoCancellation;
716
730
  private noiseSuppression;
731
+ private keepAwake;
732
+ private wakeLock;
733
+ private visibilityHandler;
717
734
  private onAudioInput?;
718
735
  private onInputError?;
719
736
  private isMuted;
@@ -735,6 +752,13 @@ declare class BrowserAudioManager {
735
752
  * Stop capturing microphone input
736
753
  */
737
754
  stopMicrophone(): void;
755
+ /**
756
+ * Keep the screen on while the microphone is live, and bring audio back when the page returns
757
+ * to the foreground (iOS leaves the context "interrupted" after the screen locks).
758
+ */
759
+ private holdScreenAwake;
760
+ private requestWakeLock;
761
+ private releaseScreenAwake;
738
762
  /**
739
763
  * Play back audio received from the server
740
764
  * @param pcm16Data Int16 PCM audio data at SPEAKER_SAMPLE_RATE
@@ -865,12 +889,21 @@ declare class ConversationalPanel {
865
889
  private muteSvg;
866
890
  private stopBtn;
867
891
  private visualizerWrap;
892
+ private countdownEl;
893
+ private noteEl;
894
+ private countdownTicker;
868
895
  onTranscription?: (text: string) => void;
869
896
  onResponse?: (text: string) => void;
870
897
  onStart?: () => void;
871
898
  onStop?: () => void;
872
899
  onError?: (err: any) => void;
873
900
  onToolCall?: (tool: any) => void;
901
+ /**
902
+ * The call ended without the visitor pressing End: a time limit, the silence timeout, the server
903
+ * closing the session, or the connection being lost. Receives the message the panel shows, so a
904
+ * page that renders its own chrome (and hides this panel's curtain) can show it too.
905
+ */
906
+ onEnded?: (message: string) => void;
874
907
  constructor(cfg: ConversationalPanelConfig);
875
908
  private buildDOM;
876
909
  private esc;
@@ -893,6 +926,12 @@ declare class ConversationalPanel {
893
926
  setTools(tools: any[]): void;
894
927
  /** Show an error message */
895
928
  showError(text: string): void;
929
+ /** End the call for a reason other than the End button, and say why. */
930
+ private end;
931
+ /** Show how long the session may still run, as announced by the server. */
932
+ private startCountdown;
933
+ private stopCountdown;
934
+ private setNote;
896
935
  /** Destroy the component, removing all DOM and stopping any active session */
897
936
  destroy(): void;
898
937
  private startTimer;
@@ -902,4 +941,4 @@ declare class ConversationalPanel {
902
941
  private playErrorTone;
903
942
  }
904
943
 
905
- export { AUDIO_CONFIG, type AnalyserConfig, type AudioManager, type BrowserAudioConfig, BrowserAudioManager, type BrowserAudioOptions, ConversationalPanel, type ConversationalPanelConfig, DEFAULT_URLS, type ErrorCode, type HealthStatus, Language, type LanguageInfo, type LokutorConfig, LokutorError, type ModelInfo, NodeAudioManager, STTClient, type ServerConfig, type ServerStatus, SpeechToTextClient, type SpeechToTextOptions, StreamResampler, type SynthesizeOptions, TTSClient, type ToolCall, type ToolDefinition, type TranscribeOptions, type TranscribeResult, type TranscribeSegment, type Viseme, VoiceAgentClient, type VoiceAgentOptions, type VoiceId, type VoiceInfo, VoiceStyle, applyLowPassFilter, bytesToPcm16, calculateRMS, float32ToPcm16, isRetryable, normalizeAudio, pcm16ToBytes, pcm16ToFloat32, resample, resampleWithAntiAliasing, simpleConversation, simpleTTS, simpleTranscribe };
944
+ export { AUDIO_CONFIG, type AnalyserConfig, type AudioManager, type BrowserAudioConfig, BrowserAudioManager, type BrowserAudioOptions, ConversationalPanel, type ConversationalPanelConfig, DEFAULT_URLS, type ErrorCode, type HealthStatus, Language, type LanguageInfo, type LokutorConfig, LokutorError, type ModelInfo, NodeAudioManager, STTClient, type ServerConfig, type ServerStatus, type SessionLimit, SpeechToTextClient, type SpeechToTextOptions, StreamResampler, type SynthesizeOptions, TTSClient, type ToolCall, type ToolDefinition, type TranscribeOptions, type TranscribeResult, type TranscribeSegment, type Viseme, VoiceAgentClient, type VoiceAgentOptions, type VoiceId, type VoiceInfo, VoiceStyle, applyLowPassFilter, bytesToPcm16, calculateRMS, float32ToPcm16, isRetryable, normalizeAudio, pcm16ToBytes, pcm16ToFloat32, resample, resampleWithAntiAliasing, simpleConversation, simpleTTS, simpleTranscribe };
package/dist/index.js CHANGED
@@ -130,6 +130,7 @@ function isRetryableCode(code) {
130
130
  "auth.missing_key",
131
131
  "auth.invalid_key",
132
132
  "auth.time_limited",
133
+ "session.ended",
133
134
  "validation.invalid_voice",
134
135
  "validation.invalid_language",
135
136
  "validation.text_too_long",
@@ -305,6 +306,11 @@ var BrowserAudioManager = class {
305
306
  autoGainControl;
306
307
  echoCancellation;
307
308
  noiseSuppression;
309
+ keepAwake;
310
+ // Screen wake lock, held while the microphone is live. Browsers release it whenever the page is
311
+ // hidden, so it is taken again when the page becomes visible.
312
+ wakeLock = null;
313
+ visibilityHandler = null;
308
314
  // Callbacks
309
315
  onAudioInput;
310
316
  onInputError;
@@ -317,6 +323,7 @@ var BrowserAudioManager = class {
317
323
  this.autoGainControl = config.autoGainControl ?? true;
318
324
  this.echoCancellation = config.echoCancellation ?? true;
319
325
  this.noiseSuppression = config.noiseSuppression ?? true;
326
+ this.keepAwake = config.keepAwake ?? true;
320
327
  this.onInputError = config.onInputError;
321
328
  }
322
329
  /**
@@ -348,6 +355,10 @@ var BrowserAudioManager = class {
348
355
  if (!this.audioContext) {
349
356
  await this.init();
350
357
  }
358
+ if (this.isListening && this.mediaStream) {
359
+ this.onAudioInput = onAudioInput;
360
+ return;
361
+ }
351
362
  try {
352
363
  this.onAudioInput = onAudioInput;
353
364
  this.isListening = true;
@@ -382,6 +393,7 @@ var BrowserAudioManager = class {
382
393
  this._processAudioInput(event);
383
394
  };
384
395
  console.log("\u{1F3A4} Microphone started");
396
+ this.holdScreenAwake();
385
397
  } catch (error) {
386
398
  const err = error instanceof Error ? error : new Error(String(error));
387
399
  if (this.onInputError) this.onInputError(err);
@@ -420,6 +432,7 @@ var BrowserAudioManager = class {
420
432
  */
421
433
  stopMicrophone() {
422
434
  this.isListening = false;
435
+ this.releaseScreenAwake();
423
436
  if (this.mediaStream) {
424
437
  this.mediaStream.getTracks().forEach((track) => track.stop());
425
438
  this.mediaStream = null;
@@ -434,6 +447,53 @@ var BrowserAudioManager = class {
434
447
  }
435
448
  console.log("\u{1F3A4} Microphone stopped");
436
449
  }
450
+ /**
451
+ * Keep the screen on while the microphone is live, and bring audio back when the page returns
452
+ * to the foreground (iOS leaves the context "interrupted" after the screen locks).
453
+ */
454
+ holdScreenAwake() {
455
+ if (typeof document === "undefined") return;
456
+ if (!this.visibilityHandler) {
457
+ this.visibilityHandler = () => {
458
+ if (document.visibilityState !== "visible" || !this.isListening) return;
459
+ if (this.audioContext && this.audioContext.state !== "running") {
460
+ this.audioContext.resume().catch(() => {
461
+ });
462
+ }
463
+ void this.requestWakeLock();
464
+ };
465
+ document.addEventListener("visibilitychange", this.visibilityHandler);
466
+ }
467
+ void this.requestWakeLock();
468
+ }
469
+ async requestWakeLock() {
470
+ if (!this.keepAwake || this.wakeLock || typeof navigator === "undefined") return;
471
+ const api = navigator.wakeLock;
472
+ if (!api?.request || document.visibilityState !== "visible") return;
473
+ try {
474
+ const lock = await api.request("screen");
475
+ if (!this.isListening) {
476
+ await lock.release().catch(() => {
477
+ });
478
+ return;
479
+ }
480
+ this.wakeLock = lock;
481
+ lock.addEventListener?.("release", () => {
482
+ if (this.wakeLock === lock) this.wakeLock = null;
483
+ });
484
+ } catch (_) {
485
+ }
486
+ }
487
+ releaseScreenAwake() {
488
+ if (this.visibilityHandler && typeof document !== "undefined") {
489
+ document.removeEventListener("visibilitychange", this.visibilityHandler);
490
+ this.visibilityHandler = null;
491
+ }
492
+ const lock = this.wakeLock;
493
+ this.wakeLock = null;
494
+ lock?.release().catch(() => {
495
+ });
496
+ }
437
497
  /**
438
498
  * Play back audio received from the server
439
499
  * @param pcm16Data Int16 PCM audio data at SPEAKER_SAMPLE_RATE
@@ -633,6 +693,7 @@ async function fetchJson(url, timeoutMs = 1e4) {
633
693
  });
634
694
  }
635
695
  }
696
+ var RECONNECTABLE_CLOSE_CODES = /* @__PURE__ */ new Set([1001, 1006, 1011, 1012, 1013, 1014]);
636
697
  function base64ToUint8Array(base64) {
637
698
  const binaryString = atob(base64);
638
699
  const bytes = new Uint8Array(binaryString.length);
@@ -712,6 +773,9 @@ var VoiceAgentClient = class {
712
773
  reconnectAttempts = 0;
713
774
  maxReconnectAttempts = 5;
714
775
  reconnectTimer = null;
776
+ // The microphone is started once per session of this client, not once per socket: a reconnect
777
+ // reuses it. Starting it again on every open ran two capture pipelines into one resampler.
778
+ micStarted = false;
715
779
  // Runtime state
716
780
  serverPlaybackRate = 44100;
717
781
  serverInputRate = 16e3;
@@ -782,15 +846,17 @@ var VoiceAgentClient = class {
782
846
  this.isConnected = true;
783
847
  this.reconnectAttempts = 0;
784
848
  this.reconnecting = false;
849
+ this.currentGeneration = 0;
785
850
  console.log("\u2705 Connected to voice agent!");
786
851
  sdkTrace("ws.open");
787
852
  this.sendConfig();
788
- if (this.audioManager) {
853
+ if (this.audioManager && !this.micStarted) {
789
854
  await this.audioManager.startMicrophone((data) => {
790
855
  if (this.isConnected) {
791
856
  this.sendAudio(data);
792
857
  }
793
858
  });
859
+ this.micStarted = true;
794
860
  }
795
861
  settle(() => resolve(true));
796
862
  };
@@ -841,6 +907,17 @@ var VoiceAgentClient = class {
841
907
  console.error(" URL:", this.serverUrl.replace(/api_key=[^&]+/, "api_key=***"));
842
908
  }
843
909
  console.log(`\u{1F50C} WebSocket closed \u2014 code: ${event.code}, reason: "${event.reason || "none"}", clean: ${event.wasClean}`);
910
+ if (!this.isUserDisconnect && !RECONNECTABLE_CLOSE_CODES.has(event.code)) {
911
+ const ended = new LokutorError("session.ended", event.reason || "The session was ended by the server.", {
912
+ detail: `close code ${event.code}`,
913
+ retryable: false
914
+ });
915
+ console.log(`Session ended by the server (code ${event.code})`);
916
+ this.emit("ended", { code: event.code, reason: event.reason || "" });
917
+ if (this.onError) this.onError(ended);
918
+ if (this.onStatus) this.onStatus("disconnected");
919
+ return;
920
+ }
844
921
  if (!this.isUserDisconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
845
922
  this.reconnecting = true;
846
923
  this.reconnectAttempts++;
@@ -1035,6 +1112,14 @@ var VoiceAgentClient = class {
1035
1112
  console.error(`\u274C Server error: [${error.code}] ${error.message}`);
1036
1113
  break;
1037
1114
  }
1115
+ case "session_limit": {
1116
+ const seconds = Number(msg.data?.seconds ?? msg.seconds);
1117
+ if (Number.isFinite(seconds) && seconds >= 0) {
1118
+ const limit = { seconds, reason: msg.data?.reason ?? msg.reason };
1119
+ this.emit("session_limit", limit);
1120
+ }
1121
+ break;
1122
+ }
1038
1123
  case "tool_call":
1039
1124
  this.emit("tool_call", { name: msg.name, arguments: msg.arguments });
1040
1125
  console.log(`\u{1F6E0}\uFE0F Tool Call: ${msg.name}(${msg.arguments})`);
@@ -1108,6 +1193,7 @@ var VoiceAgentClient = class {
1108
1193
  if (this.audioManager) {
1109
1194
  this.audioManager.cleanup();
1110
1195
  }
1196
+ this.micStarted = false;
1111
1197
  this.isConnected = false;
1112
1198
  this.configConfirmed = false;
1113
1199
  this.reconnecting = false;
@@ -1757,6 +1843,22 @@ var PANEL_CSS = (
1757
1843
  margin-bottom: auto;
1758
1844
  padding-top: 1.5rem;
1759
1845
  }
1846
+ .cv-countdown, .cv-note {
1847
+ display: none;
1848
+ margin-top: 0.4rem;
1849
+ padding: 0.15rem 0.6rem;
1850
+ border-radius: 999px;
1851
+ border: 1px solid var(--cv-ui-border, rgba(255,255,255,0.1));
1852
+ background: var(--cv-ui-bg, rgba(255,255,255,0.05));
1853
+ color: var(--cv-text-dim, rgba(255,255,255,0.6));
1854
+ font-size: 0.75rem;
1855
+ font-variant-numeric: tabular-nums;
1856
+ width: fit-content;
1857
+ margin-left: auto;
1858
+ margin-right: auto;
1859
+ }
1860
+ .cv-countdown.is-visible, .cv-note.is-visible { display: block; }
1861
+ .cv-countdown.is-low { color: var(--cv-accent); border-color: var(--cv-accent); }
1760
1862
  .cv-title {
1761
1863
  font-size: clamp(1rem, 2.5vw, 1.75rem);
1762
1864
  font-weight: 700;
@@ -1993,6 +2095,9 @@ var ConversationalPanel = class {
1993
2095
  muteSvg;
1994
2096
  stopBtn;
1995
2097
  visualizerWrap;
2098
+ countdownEl;
2099
+ noteEl;
2100
+ countdownTicker = null;
1996
2101
  // Callbacks
1997
2102
  onTranscription;
1998
2103
  onResponse;
@@ -2000,6 +2105,12 @@ var ConversationalPanel = class {
2000
2105
  onStop;
2001
2106
  onError;
2002
2107
  onToolCall;
2108
+ /**
2109
+ * The call ended without the visitor pressing End: a time limit, the silence timeout, the server
2110
+ * closing the session, or the connection being lost. Receives the message the panel shows, so a
2111
+ * page that renders its own chrome (and hides this panel's curtain) can show it too.
2112
+ */
2113
+ onEnded;
2003
2114
  constructor(cfg) {
2004
2115
  this.cfg = cfg;
2005
2116
  this.container = cfg.container;
@@ -2040,6 +2151,8 @@ var ConversationalPanel = class {
2040
2151
  </div>
2041
2152
  <div class="cv-header">
2042
2153
  <h2 class="cv-title">${this.esc(this.cfg.title || "Voice Chat")}</h2>
2154
+ <div class="cv-countdown" aria-live="off"></div>
2155
+ <div class="cv-note" role="status"></div>
2043
2156
  </div>
2044
2157
  <div class="cv-visualizer-wrap">
2045
2158
  <canvas class="cv-canvas"></canvas>
@@ -2075,6 +2188,8 @@ var ConversationalPanel = class {
2075
2188
  this.muteSvg = this.muteBtn.querySelector("svg");
2076
2189
  this.stopBtn = this.el.querySelector(".cv-btn--end");
2077
2190
  this.visualizerWrap = this.el.querySelector(".cv-visualizer-wrap");
2191
+ this.countdownEl = this.el.querySelector(".cv-countdown");
2192
+ this.noteEl = this.el.querySelector(".cv-note");
2078
2193
  this.startBtn.addEventListener("click", () => this.start());
2079
2194
  this.stopBtn.addEventListener("click", () => this.stop());
2080
2195
  this.muteBtn.addEventListener("click", () => this.toggleMute());
@@ -2122,7 +2237,12 @@ var ConversationalPanel = class {
2122
2237
  this.el.classList.remove("cv-is-speaking", "cv-is-thinking");
2123
2238
  if (status === "speaking") this.el.classList.add("cv-is-speaking");
2124
2239
  else if (status === "thinking") this.el.classList.add("cv-is-thinking");
2240
+ this.setNote(status === "reconnecting" ? "Reconnecting\u2026" : "");
2241
+ if (status === "disconnected" && this.isRunning) {
2242
+ this.end("The call has ended.");
2243
+ }
2125
2244
  },
2245
+ onSessionLimit: (seconds) => this.startCountdown(seconds),
2126
2246
  onTranscription: (text) => {
2127
2247
  this._lastSpeechTime = Date.now();
2128
2248
  this.onTranscription?.(text);
@@ -2137,8 +2257,7 @@ var ConversationalPanel = class {
2137
2257
  onError: (err) => {
2138
2258
  if (!this.isRunning) return;
2139
2259
  this.onError?.(err);
2140
- this.stop();
2141
- this.showError("Connection issue. Try again in a moment.");
2260
+ this.end(err?.code === "session.ended" && err?.message ? err.message : "Connection issue. Try again in a moment.");
2142
2261
  }
2143
2262
  });
2144
2263
  this.agent = agentHandle;
@@ -2196,6 +2315,8 @@ var ConversationalPanel = class {
2196
2315
  clearInterval(this.timerTicker);
2197
2316
  this.timerTicker = null;
2198
2317
  }
2318
+ this.stopCountdown();
2319
+ this.setNote("");
2199
2320
  if (this.visualizer) {
2200
2321
  this.visualizer.stop();
2201
2322
  this.visualizer = null;
@@ -2271,6 +2392,38 @@ var ConversationalPanel = class {
2271
2392
  this.errorEl.classList.remove("is-visible");
2272
2393
  }, 5e3);
2273
2394
  }
2395
+ /** End the call for a reason other than the End button, and say why. */
2396
+ end(message) {
2397
+ this.stop();
2398
+ this.showError(message);
2399
+ this.onEnded?.(message);
2400
+ }
2401
+ /** Show how long the session may still run, as announced by the server. */
2402
+ startCountdown(seconds) {
2403
+ this.stopCountdown();
2404
+ const endsAt = Date.now() + seconds * 1e3;
2405
+ const render = () => {
2406
+ const left = Math.max(0, Math.round((endsAt - Date.now()) / 1e3));
2407
+ this.countdownEl.textContent = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")} left`;
2408
+ this.countdownEl.classList.toggle("is-low", left <= 30);
2409
+ if (left === 0) this.stopCountdown(false);
2410
+ };
2411
+ this.countdownEl.classList.add("is-visible");
2412
+ render();
2413
+ this.countdownTicker = window.setInterval(render, 1e3);
2414
+ }
2415
+ stopCountdown(hide = true) {
2416
+ if (this.countdownTicker) {
2417
+ clearInterval(this.countdownTicker);
2418
+ this.countdownTicker = null;
2419
+ }
2420
+ if (hide) this.countdownEl?.classList.remove("is-visible", "is-low");
2421
+ }
2422
+ setNote(text) {
2423
+ if (!this.noteEl) return;
2424
+ this.noteEl.textContent = text;
2425
+ this.noteEl.classList.toggle("is-visible", Boolean(text));
2426
+ }
2274
2427
  /** Destroy the component, removing all DOM and stopping any active session */
2275
2428
  destroy() {
2276
2429
  this.stop();
@@ -2286,14 +2439,12 @@ var ConversationalPanel = class {
2286
2439
  elapsed++;
2287
2440
  if (elapsed >= maxDur) {
2288
2441
  this._locked = true;
2289
- this.stop();
2290
- this.showError("Tiempo l\xEDmite alcanzado.");
2442
+ this.end("Time limit reached.");
2291
2443
  return;
2292
2444
  }
2293
2445
  if (Date.now() - this._lastSpeechTime > silentMax * 1e3) {
2294
2446
  this._locked = true;
2295
- this.stop();
2296
- this.showError("Sesi\xF3n finalizada por inactividad.");
2447
+ this.end("The call ended after a long silence.");
2297
2448
  }
2298
2449
  }, 1e3);
2299
2450
  }
package/dist/index.mjs CHANGED
@@ -79,6 +79,7 @@ function isRetryableCode(code) {
79
79
  "auth.missing_key",
80
80
  "auth.invalid_key",
81
81
  "auth.time_limited",
82
+ "session.ended",
82
83
  "validation.invalid_voice",
83
84
  "validation.invalid_language",
84
85
  "validation.text_too_long",
@@ -254,6 +255,11 @@ var BrowserAudioManager = class {
254
255
  autoGainControl;
255
256
  echoCancellation;
256
257
  noiseSuppression;
258
+ keepAwake;
259
+ // Screen wake lock, held while the microphone is live. Browsers release it whenever the page is
260
+ // hidden, so it is taken again when the page becomes visible.
261
+ wakeLock = null;
262
+ visibilityHandler = null;
257
263
  // Callbacks
258
264
  onAudioInput;
259
265
  onInputError;
@@ -266,6 +272,7 @@ var BrowserAudioManager = class {
266
272
  this.autoGainControl = config.autoGainControl ?? true;
267
273
  this.echoCancellation = config.echoCancellation ?? true;
268
274
  this.noiseSuppression = config.noiseSuppression ?? true;
275
+ this.keepAwake = config.keepAwake ?? true;
269
276
  this.onInputError = config.onInputError;
270
277
  }
271
278
  /**
@@ -297,6 +304,10 @@ var BrowserAudioManager = class {
297
304
  if (!this.audioContext) {
298
305
  await this.init();
299
306
  }
307
+ if (this.isListening && this.mediaStream) {
308
+ this.onAudioInput = onAudioInput;
309
+ return;
310
+ }
300
311
  try {
301
312
  this.onAudioInput = onAudioInput;
302
313
  this.isListening = true;
@@ -331,6 +342,7 @@ var BrowserAudioManager = class {
331
342
  this._processAudioInput(event);
332
343
  };
333
344
  console.log("\u{1F3A4} Microphone started");
345
+ this.holdScreenAwake();
334
346
  } catch (error) {
335
347
  const err = error instanceof Error ? error : new Error(String(error));
336
348
  if (this.onInputError) this.onInputError(err);
@@ -369,6 +381,7 @@ var BrowserAudioManager = class {
369
381
  */
370
382
  stopMicrophone() {
371
383
  this.isListening = false;
384
+ this.releaseScreenAwake();
372
385
  if (this.mediaStream) {
373
386
  this.mediaStream.getTracks().forEach((track) => track.stop());
374
387
  this.mediaStream = null;
@@ -383,6 +396,53 @@ var BrowserAudioManager = class {
383
396
  }
384
397
  console.log("\u{1F3A4} Microphone stopped");
385
398
  }
399
+ /**
400
+ * Keep the screen on while the microphone is live, and bring audio back when the page returns
401
+ * to the foreground (iOS leaves the context "interrupted" after the screen locks).
402
+ */
403
+ holdScreenAwake() {
404
+ if (typeof document === "undefined") return;
405
+ if (!this.visibilityHandler) {
406
+ this.visibilityHandler = () => {
407
+ if (document.visibilityState !== "visible" || !this.isListening) return;
408
+ if (this.audioContext && this.audioContext.state !== "running") {
409
+ this.audioContext.resume().catch(() => {
410
+ });
411
+ }
412
+ void this.requestWakeLock();
413
+ };
414
+ document.addEventListener("visibilitychange", this.visibilityHandler);
415
+ }
416
+ void this.requestWakeLock();
417
+ }
418
+ async requestWakeLock() {
419
+ if (!this.keepAwake || this.wakeLock || typeof navigator === "undefined") return;
420
+ const api = navigator.wakeLock;
421
+ if (!api?.request || document.visibilityState !== "visible") return;
422
+ try {
423
+ const lock = await api.request("screen");
424
+ if (!this.isListening) {
425
+ await lock.release().catch(() => {
426
+ });
427
+ return;
428
+ }
429
+ this.wakeLock = lock;
430
+ lock.addEventListener?.("release", () => {
431
+ if (this.wakeLock === lock) this.wakeLock = null;
432
+ });
433
+ } catch (_) {
434
+ }
435
+ }
436
+ releaseScreenAwake() {
437
+ if (this.visibilityHandler && typeof document !== "undefined") {
438
+ document.removeEventListener("visibilitychange", this.visibilityHandler);
439
+ this.visibilityHandler = null;
440
+ }
441
+ const lock = this.wakeLock;
442
+ this.wakeLock = null;
443
+ lock?.release().catch(() => {
444
+ });
445
+ }
386
446
  /**
387
447
  * Play back audio received from the server
388
448
  * @param pcm16Data Int16 PCM audio data at SPEAKER_SAMPLE_RATE
@@ -582,6 +642,7 @@ async function fetchJson(url, timeoutMs = 1e4) {
582
642
  });
583
643
  }
584
644
  }
645
+ var RECONNECTABLE_CLOSE_CODES = /* @__PURE__ */ new Set([1001, 1006, 1011, 1012, 1013, 1014]);
585
646
  function base64ToUint8Array(base64) {
586
647
  const binaryString = atob(base64);
587
648
  const bytes = new Uint8Array(binaryString.length);
@@ -661,6 +722,9 @@ var VoiceAgentClient = class {
661
722
  reconnectAttempts = 0;
662
723
  maxReconnectAttempts = 5;
663
724
  reconnectTimer = null;
725
+ // The microphone is started once per session of this client, not once per socket: a reconnect
726
+ // reuses it. Starting it again on every open ran two capture pipelines into one resampler.
727
+ micStarted = false;
664
728
  // Runtime state
665
729
  serverPlaybackRate = 44100;
666
730
  serverInputRate = 16e3;
@@ -731,15 +795,17 @@ var VoiceAgentClient = class {
731
795
  this.isConnected = true;
732
796
  this.reconnectAttempts = 0;
733
797
  this.reconnecting = false;
798
+ this.currentGeneration = 0;
734
799
  console.log("\u2705 Connected to voice agent!");
735
800
  sdkTrace("ws.open");
736
801
  this.sendConfig();
737
- if (this.audioManager) {
802
+ if (this.audioManager && !this.micStarted) {
738
803
  await this.audioManager.startMicrophone((data) => {
739
804
  if (this.isConnected) {
740
805
  this.sendAudio(data);
741
806
  }
742
807
  });
808
+ this.micStarted = true;
743
809
  }
744
810
  settle(() => resolve(true));
745
811
  };
@@ -790,6 +856,17 @@ var VoiceAgentClient = class {
790
856
  console.error(" URL:", this.serverUrl.replace(/api_key=[^&]+/, "api_key=***"));
791
857
  }
792
858
  console.log(`\u{1F50C} WebSocket closed \u2014 code: ${event.code}, reason: "${event.reason || "none"}", clean: ${event.wasClean}`);
859
+ if (!this.isUserDisconnect && !RECONNECTABLE_CLOSE_CODES.has(event.code)) {
860
+ const ended = new LokutorError("session.ended", event.reason || "The session was ended by the server.", {
861
+ detail: `close code ${event.code}`,
862
+ retryable: false
863
+ });
864
+ console.log(`Session ended by the server (code ${event.code})`);
865
+ this.emit("ended", { code: event.code, reason: event.reason || "" });
866
+ if (this.onError) this.onError(ended);
867
+ if (this.onStatus) this.onStatus("disconnected");
868
+ return;
869
+ }
793
870
  if (!this.isUserDisconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
794
871
  this.reconnecting = true;
795
872
  this.reconnectAttempts++;
@@ -984,6 +1061,14 @@ var VoiceAgentClient = class {
984
1061
  console.error(`\u274C Server error: [${error.code}] ${error.message}`);
985
1062
  break;
986
1063
  }
1064
+ case "session_limit": {
1065
+ const seconds = Number(msg.data?.seconds ?? msg.seconds);
1066
+ if (Number.isFinite(seconds) && seconds >= 0) {
1067
+ const limit = { seconds, reason: msg.data?.reason ?? msg.reason };
1068
+ this.emit("session_limit", limit);
1069
+ }
1070
+ break;
1071
+ }
987
1072
  case "tool_call":
988
1073
  this.emit("tool_call", { name: msg.name, arguments: msg.arguments });
989
1074
  console.log(`\u{1F6E0}\uFE0F Tool Call: ${msg.name}(${msg.arguments})`);
@@ -1057,6 +1142,7 @@ var VoiceAgentClient = class {
1057
1142
  if (this.audioManager) {
1058
1143
  this.audioManager.cleanup();
1059
1144
  }
1145
+ this.micStarted = false;
1060
1146
  this.isConnected = false;
1061
1147
  this.configConfirmed = false;
1062
1148
  this.reconnecting = false;
@@ -1706,6 +1792,22 @@ var PANEL_CSS = (
1706
1792
  margin-bottom: auto;
1707
1793
  padding-top: 1.5rem;
1708
1794
  }
1795
+ .cv-countdown, .cv-note {
1796
+ display: none;
1797
+ margin-top: 0.4rem;
1798
+ padding: 0.15rem 0.6rem;
1799
+ border-radius: 999px;
1800
+ border: 1px solid var(--cv-ui-border, rgba(255,255,255,0.1));
1801
+ background: var(--cv-ui-bg, rgba(255,255,255,0.05));
1802
+ color: var(--cv-text-dim, rgba(255,255,255,0.6));
1803
+ font-size: 0.75rem;
1804
+ font-variant-numeric: tabular-nums;
1805
+ width: fit-content;
1806
+ margin-left: auto;
1807
+ margin-right: auto;
1808
+ }
1809
+ .cv-countdown.is-visible, .cv-note.is-visible { display: block; }
1810
+ .cv-countdown.is-low { color: var(--cv-accent); border-color: var(--cv-accent); }
1709
1811
  .cv-title {
1710
1812
  font-size: clamp(1rem, 2.5vw, 1.75rem);
1711
1813
  font-weight: 700;
@@ -1942,6 +2044,9 @@ var ConversationalPanel = class {
1942
2044
  muteSvg;
1943
2045
  stopBtn;
1944
2046
  visualizerWrap;
2047
+ countdownEl;
2048
+ noteEl;
2049
+ countdownTicker = null;
1945
2050
  // Callbacks
1946
2051
  onTranscription;
1947
2052
  onResponse;
@@ -1949,6 +2054,12 @@ var ConversationalPanel = class {
1949
2054
  onStop;
1950
2055
  onError;
1951
2056
  onToolCall;
2057
+ /**
2058
+ * The call ended without the visitor pressing End: a time limit, the silence timeout, the server
2059
+ * closing the session, or the connection being lost. Receives the message the panel shows, so a
2060
+ * page that renders its own chrome (and hides this panel's curtain) can show it too.
2061
+ */
2062
+ onEnded;
1952
2063
  constructor(cfg) {
1953
2064
  this.cfg = cfg;
1954
2065
  this.container = cfg.container;
@@ -1989,6 +2100,8 @@ var ConversationalPanel = class {
1989
2100
  </div>
1990
2101
  <div class="cv-header">
1991
2102
  <h2 class="cv-title">${this.esc(this.cfg.title || "Voice Chat")}</h2>
2103
+ <div class="cv-countdown" aria-live="off"></div>
2104
+ <div class="cv-note" role="status"></div>
1992
2105
  </div>
1993
2106
  <div class="cv-visualizer-wrap">
1994
2107
  <canvas class="cv-canvas"></canvas>
@@ -2024,6 +2137,8 @@ var ConversationalPanel = class {
2024
2137
  this.muteSvg = this.muteBtn.querySelector("svg");
2025
2138
  this.stopBtn = this.el.querySelector(".cv-btn--end");
2026
2139
  this.visualizerWrap = this.el.querySelector(".cv-visualizer-wrap");
2140
+ this.countdownEl = this.el.querySelector(".cv-countdown");
2141
+ this.noteEl = this.el.querySelector(".cv-note");
2027
2142
  this.startBtn.addEventListener("click", () => this.start());
2028
2143
  this.stopBtn.addEventListener("click", () => this.stop());
2029
2144
  this.muteBtn.addEventListener("click", () => this.toggleMute());
@@ -2071,7 +2186,12 @@ var ConversationalPanel = class {
2071
2186
  this.el.classList.remove("cv-is-speaking", "cv-is-thinking");
2072
2187
  if (status === "speaking") this.el.classList.add("cv-is-speaking");
2073
2188
  else if (status === "thinking") this.el.classList.add("cv-is-thinking");
2189
+ this.setNote(status === "reconnecting" ? "Reconnecting\u2026" : "");
2190
+ if (status === "disconnected" && this.isRunning) {
2191
+ this.end("The call has ended.");
2192
+ }
2074
2193
  },
2194
+ onSessionLimit: (seconds) => this.startCountdown(seconds),
2075
2195
  onTranscription: (text) => {
2076
2196
  this._lastSpeechTime = Date.now();
2077
2197
  this.onTranscription?.(text);
@@ -2086,8 +2206,7 @@ var ConversationalPanel = class {
2086
2206
  onError: (err) => {
2087
2207
  if (!this.isRunning) return;
2088
2208
  this.onError?.(err);
2089
- this.stop();
2090
- this.showError("Connection issue. Try again in a moment.");
2209
+ this.end(err?.code === "session.ended" && err?.message ? err.message : "Connection issue. Try again in a moment.");
2091
2210
  }
2092
2211
  });
2093
2212
  this.agent = agentHandle;
@@ -2145,6 +2264,8 @@ var ConversationalPanel = class {
2145
2264
  clearInterval(this.timerTicker);
2146
2265
  this.timerTicker = null;
2147
2266
  }
2267
+ this.stopCountdown();
2268
+ this.setNote("");
2148
2269
  if (this.visualizer) {
2149
2270
  this.visualizer.stop();
2150
2271
  this.visualizer = null;
@@ -2220,6 +2341,38 @@ var ConversationalPanel = class {
2220
2341
  this.errorEl.classList.remove("is-visible");
2221
2342
  }, 5e3);
2222
2343
  }
2344
+ /** End the call for a reason other than the End button, and say why. */
2345
+ end(message) {
2346
+ this.stop();
2347
+ this.showError(message);
2348
+ this.onEnded?.(message);
2349
+ }
2350
+ /** Show how long the session may still run, as announced by the server. */
2351
+ startCountdown(seconds) {
2352
+ this.stopCountdown();
2353
+ const endsAt = Date.now() + seconds * 1e3;
2354
+ const render = () => {
2355
+ const left = Math.max(0, Math.round((endsAt - Date.now()) / 1e3));
2356
+ this.countdownEl.textContent = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")} left`;
2357
+ this.countdownEl.classList.toggle("is-low", left <= 30);
2358
+ if (left === 0) this.stopCountdown(false);
2359
+ };
2360
+ this.countdownEl.classList.add("is-visible");
2361
+ render();
2362
+ this.countdownTicker = window.setInterval(render, 1e3);
2363
+ }
2364
+ stopCountdown(hide = true) {
2365
+ if (this.countdownTicker) {
2366
+ clearInterval(this.countdownTicker);
2367
+ this.countdownTicker = null;
2368
+ }
2369
+ if (hide) this.countdownEl?.classList.remove("is-visible", "is-low");
2370
+ }
2371
+ setNote(text) {
2372
+ if (!this.noteEl) return;
2373
+ this.noteEl.textContent = text;
2374
+ this.noteEl.classList.toggle("is-visible", Boolean(text));
2375
+ }
2223
2376
  /** Destroy the component, removing all DOM and stopping any active session */
2224
2377
  destroy() {
2225
2378
  this.stop();
@@ -2235,14 +2388,12 @@ var ConversationalPanel = class {
2235
2388
  elapsed++;
2236
2389
  if (elapsed >= maxDur) {
2237
2390
  this._locked = true;
2238
- this.stop();
2239
- this.showError("Tiempo l\xEDmite alcanzado.");
2391
+ this.end("Time limit reached.");
2240
2392
  return;
2241
2393
  }
2242
2394
  if (Date.now() - this._lastSpeechTime > silentMax * 1e3) {
2243
2395
  this._locked = true;
2244
- this.stop();
2245
- this.showError("Sesi\xF3n finalizada por inactividad.");
2396
+ this.end("The call ended after a long silence.");
2246
2397
  }
2247
2398
  }, 1e3);
2248
2399
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lokutor/sdk",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "JavaScript/TypeScript SDK for Lokutor Real-time Voice AI",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -10,6 +10,12 @@ export interface BrowserAudioConfig {
10
10
  autoGainControl?: boolean;
11
11
  echoCancellation?: boolean;
12
12
  noiseSuppression?: boolean;
13
+ /**
14
+ * Keep the screen awake while the microphone is live (Screen Wake Lock API), as a video player does.
15
+ * Default true: a phone that dims and locks mid-call suspends the page's microphone and audio, and
16
+ * the call goes silent in both directions. Set false if your app manages this itself.
17
+ */
18
+ keepAwake?: boolean;
13
19
  onInputError?: (error: Error) => void;
14
20
  }
15
21
 
@@ -48,6 +54,12 @@ export class BrowserAudioManager {
48
54
  private autoGainControl: boolean;
49
55
  private echoCancellation: boolean;
50
56
  private noiseSuppression: boolean;
57
+ private keepAwake: boolean;
58
+
59
+ // Screen wake lock, held while the microphone is live. Browsers release it whenever the page is
60
+ // hidden, so it is taken again when the page becomes visible.
61
+ private wakeLock: { release(): Promise<void> } | null = null;
62
+ private visibilityHandler: (() => void) | null = null;
51
63
 
52
64
  // Callbacks
53
65
  private onAudioInput?: (pcm16Data: Uint8Array) => void;
@@ -63,6 +75,7 @@ export class BrowserAudioManager {
63
75
  this.autoGainControl = config.autoGainControl ?? true;
64
76
  this.echoCancellation = config.echoCancellation ?? true;
65
77
  this.noiseSuppression = config.noiseSuppression ?? true;
78
+ this.keepAwake = config.keepAwake ?? true;
66
79
  this.onInputError = config.onInputError;
67
80
  }
68
81
 
@@ -107,6 +120,14 @@ export class BrowserAudioManager {
107
120
  await this.init();
108
121
  }
109
122
 
123
+ // Already capturing: take the new callback and keep the one pipeline. A second getUserMedia +
124
+ // ScriptProcessor here used to run two captures into the same resampler, doubling and scrambling
125
+ // what the server heard.
126
+ if (this.isListening && this.mediaStream) {
127
+ this.onAudioInput = onAudioInput;
128
+ return;
129
+ }
130
+
110
131
  try {
111
132
  this.onAudioInput = onAudioInput;
112
133
  this.isListening = true;
@@ -167,6 +188,7 @@ export class BrowserAudioManager {
167
188
  };
168
189
 
169
190
  console.log('🎤 Microphone started');
191
+ this.holdScreenAwake();
170
192
  } catch (error) {
171
193
  const err = error instanceof Error ? error : new Error(String(error));
172
194
  if (this.onInputError) this.onInputError(err);
@@ -226,6 +248,7 @@ export class BrowserAudioManager {
226
248
  */
227
249
  stopMicrophone(): void {
228
250
  this.isListening = false;
251
+ this.releaseScreenAwake();
229
252
 
230
253
  if (this.mediaStream) {
231
254
  this.mediaStream.getTracks().forEach((track) => track.stop());
@@ -245,6 +268,50 @@ export class BrowserAudioManager {
245
268
  console.log('🎤 Microphone stopped');
246
269
  }
247
270
 
271
+ /**
272
+ * Keep the screen on while the microphone is live, and bring audio back when the page returns
273
+ * to the foreground (iOS leaves the context "interrupted" after the screen locks).
274
+ */
275
+ private holdScreenAwake(): void {
276
+ if (typeof document === 'undefined') return;
277
+ if (!this.visibilityHandler) {
278
+ this.visibilityHandler = () => {
279
+ if (document.visibilityState !== 'visible' || !this.isListening) return;
280
+ if (this.audioContext && this.audioContext.state !== 'running') {
281
+ this.audioContext.resume().catch(() => {});
282
+ }
283
+ void this.requestWakeLock();
284
+ };
285
+ document.addEventListener('visibilitychange', this.visibilityHandler);
286
+ }
287
+ void this.requestWakeLock();
288
+ }
289
+
290
+ private async requestWakeLock(): Promise<void> {
291
+ if (!this.keepAwake || this.wakeLock || typeof navigator === 'undefined') return;
292
+ const api = (navigator as any).wakeLock;
293
+ if (!api?.request || document.visibilityState !== 'visible') return;
294
+ try {
295
+ const lock = await api.request('screen');
296
+ if (!this.isListening) { await lock.release().catch(() => {}); return; }
297
+ this.wakeLock = lock;
298
+ // The browser drops the lock when the page is hidden; forget it so it is taken again on return.
299
+ lock.addEventListener?.('release', () => { if (this.wakeLock === lock) this.wakeLock = null; });
300
+ } catch (_) {
301
+ // Not allowed right now (battery saver, permissions policy): the call works, the screen may dim.
302
+ }
303
+ }
304
+
305
+ private releaseScreenAwake(): void {
306
+ if (this.visibilityHandler && typeof document !== 'undefined') {
307
+ document.removeEventListener('visibilitychange', this.visibilityHandler);
308
+ this.visibilityHandler = null;
309
+ }
310
+ const lock = this.wakeLock;
311
+ this.wakeLock = null;
312
+ lock?.release().catch(() => {});
313
+ }
314
+
248
315
  /**
249
316
  * Play back audio received from the server
250
317
  * @param pcm16Data Int16 PCM audio data at SPEAKER_SAMPLE_RATE
package/src/client.ts CHANGED
@@ -91,6 +91,24 @@ export interface AudioManager {
91
91
  getAmplitude(): number;
92
92
  }
93
93
 
94
+ /**
95
+ * Close codes after which the client reconnects: the connection dropped or the server went away
96
+ * (a deploy, a restart, a network change), so another attempt can succeed. Every other close is
97
+ * the server ending the session on purpose -- 1000 when the agent ends the call, 1008 when a limit
98
+ * is reached (the landing demo's time allowance), 4xxx for application reasons -- and reconnecting
99
+ * after one of those used to open a fresh session behind the caller's back: a new conversation
100
+ * with no memory of the old one, a second microphone pipeline, and every reply discarded as stale.
101
+ */
102
+ const RECONNECTABLE_CLOSE_CODES = new Set([1001, 1006, 1011, 1012, 1013, 1014]);
103
+
104
+ /** What the server announced about how long this session may run (landing-page demo sessions). */
105
+ export interface SessionLimit {
106
+ /** Seconds the session may still run, from when the message arrived. */
107
+ seconds: number;
108
+ /** Why there is a limit, e.g. "demo". */
109
+ reason?: string;
110
+ }
111
+
94
112
  // Browser-compatible base64 to Uint8Array
95
113
  function base64ToUint8Array(base64: string): Uint8Array {
96
114
  const binaryString = atob(base64);
@@ -189,6 +207,9 @@ export class VoiceAgentClient {
189
207
  private reconnectAttempts: number = 0;
190
208
  private maxReconnectAttempts: number = 5;
191
209
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
210
+ // The microphone is started once per session of this client, not once per socket: a reconnect
211
+ // reuses it. Starting it again on every open ran two capture pipelines into one resampler.
212
+ private micStarted: boolean = false;
192
213
 
193
214
  // Runtime state
194
215
  private serverPlaybackRate: number = 44100;
@@ -284,16 +305,21 @@ export class VoiceAgentClient {
284
305
  this.isConnected = true;
285
306
  this.reconnectAttempts = 0;
286
307
  this.reconnecting = false;
308
+ // Every socket is a new server session, and a new session numbers its replies from 1.
309
+ // Keeping the previous session's count here discarded every reply after a reconnect as
310
+ // "ghost audio" -- the agent answered and the caller heard nothing.
311
+ this.currentGeneration = 0;
287
312
  console.log('✅ Connected to voice agent!');
288
313
  sdkTrace('ws.open');
289
314
  this.sendConfig();
290
315
 
291
- if (this.audioManager) {
316
+ if (this.audioManager && !this.micStarted) {
292
317
  await this.audioManager.startMicrophone((data) => {
293
318
  if (this.isConnected) {
294
319
  this.sendAudio(data);
295
320
  }
296
321
  });
322
+ this.micStarted = true;
297
323
  }
298
324
 
299
325
  settle(() => resolve(true));
@@ -352,6 +378,19 @@ export class VoiceAgentClient {
352
378
 
353
379
  console.log(`🔌 WebSocket closed — code: ${event.code}, reason: "${event.reason || 'none'}", clean: ${event.wasClean}`);
354
380
 
381
+ if (!this.isUserDisconnect && !RECONNECTABLE_CLOSE_CODES.has(event.code)) {
382
+ // The server ended the session on purpose. Say so, with its reason, and stay closed.
383
+ const ended = new LokutorError('session.ended', event.reason || 'The session was ended by the server.', {
384
+ detail: `close code ${event.code}`,
385
+ retryable: false,
386
+ });
387
+ console.log(`Session ended by the server (code ${event.code})`);
388
+ this.emit('ended', { code: event.code, reason: event.reason || '' });
389
+ if (this.onError) this.onError(ended);
390
+ if (this.onStatus) this.onStatus('disconnected');
391
+ return;
392
+ }
393
+
355
394
  if (!this.isUserDisconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
356
395
  this.reconnecting = true;
357
396
  this.reconnectAttempts++;
@@ -566,6 +605,17 @@ export class VoiceAgentClient {
566
605
  console.error(`❌ Server error: [${error.code}] ${error.message}`);
567
606
  break;
568
607
  }
608
+ case 'session_limit': {
609
+ // How long this session may run, sent by the server when there is a limit (the landing
610
+ // page's key-less demo). A UI can show it as a countdown instead of the call just ending.
611
+ const seconds = Number(msg.data?.seconds ?? msg.seconds);
612
+ if (Number.isFinite(seconds) && seconds >= 0) {
613
+ const limit: SessionLimit = { seconds, reason: msg.data?.reason ?? msg.reason };
614
+ this.emit('session_limit', limit);
615
+ }
616
+ break;
617
+ }
618
+
569
619
  case 'tool_call':
570
620
  this.emit('tool_call', { name: msg.name, arguments: msg.arguments });
571
621
  console.log(`🛠️ Tool Call: ${msg.name}(${msg.arguments})`);
@@ -648,6 +698,7 @@ export class VoiceAgentClient {
648
698
  if (this.audioManager) {
649
699
  this.audioManager.cleanup();
650
700
  }
701
+ this.micStarted = false;
651
702
  this.isConnected = false;
652
703
  this.configConfirmed = false;
653
704
  this.reconnecting = false;
@@ -102,6 +102,22 @@ const PANEL_CSS = /*css*/ `
102
102
  margin-bottom: auto;
103
103
  padding-top: 1.5rem;
104
104
  }
105
+ .cv-countdown, .cv-note {
106
+ display: none;
107
+ margin-top: 0.4rem;
108
+ padding: 0.15rem 0.6rem;
109
+ border-radius: 999px;
110
+ border: 1px solid var(--cv-ui-border, rgba(255,255,255,0.1));
111
+ background: var(--cv-ui-bg, rgba(255,255,255,0.05));
112
+ color: var(--cv-text-dim, rgba(255,255,255,0.6));
113
+ font-size: 0.75rem;
114
+ font-variant-numeric: tabular-nums;
115
+ width: fit-content;
116
+ margin-left: auto;
117
+ margin-right: auto;
118
+ }
119
+ .cv-countdown.is-visible, .cv-note.is-visible { display: block; }
120
+ .cv-countdown.is-low { color: var(--cv-accent); border-color: var(--cv-accent); }
105
121
  .cv-title {
106
122
  font-size: clamp(1rem, 2.5vw, 1.75rem);
107
123
  font-weight: 700;
@@ -375,6 +391,9 @@ export class ConversationalPanel {
375
391
  private muteSvg!: SVGElement;
376
392
  private stopBtn!: HTMLElement;
377
393
  private visualizerWrap!: HTMLElement;
394
+ private countdownEl!: HTMLElement;
395
+ private noteEl!: HTMLElement;
396
+ private countdownTicker: number | null = null;
378
397
 
379
398
  // Callbacks
380
399
  onTranscription?: (text: string) => void;
@@ -383,6 +402,12 @@ export class ConversationalPanel {
383
402
  onStop?: () => void;
384
403
  onError?: (err: any) => void;
385
404
  onToolCall?: (tool: any) => void;
405
+ /**
406
+ * The call ended without the visitor pressing End: a time limit, the silence timeout, the server
407
+ * closing the session, or the connection being lost. Receives the message the panel shows, so a
408
+ * page that renders its own chrome (and hides this panel's curtain) can show it too.
409
+ */
410
+ onEnded?: (message: string) => void;
386
411
 
387
412
  constructor(cfg: ConversationalPanelConfig) {
388
413
  this.cfg = cfg;
@@ -427,6 +452,8 @@ export class ConversationalPanel {
427
452
  </div>
428
453
  <div class="cv-header">
429
454
  <h2 class="cv-title">${this.esc(this.cfg.title || "Voice Chat")}</h2>
455
+ <div class="cv-countdown" aria-live="off"></div>
456
+ <div class="cv-note" role="status"></div>
430
457
  </div>
431
458
  <div class="cv-visualizer-wrap">
432
459
  <canvas class="cv-canvas"></canvas>
@@ -465,6 +492,8 @@ export class ConversationalPanel {
465
492
  this.muteSvg = this.muteBtn.querySelector('svg')!;
466
493
  this.stopBtn = this.el.querySelector('.cv-btn--end')!;
467
494
  this.visualizerWrap = this.el.querySelector('.cv-visualizer-wrap')!;
495
+ this.countdownEl = this.el.querySelector('.cv-countdown')!;
496
+ this.noteEl = this.el.querySelector('.cv-note')!;
468
497
 
469
498
  // Events
470
499
  this.startBtn.addEventListener('click', () => this.start());
@@ -523,7 +552,15 @@ export class ConversationalPanel {
523
552
  this.el.classList.remove('cv-is-speaking', 'cv-is-thinking');
524
553
  if (status === 'speaking') this.el.classList.add('cv-is-speaking');
525
554
  else if (status === 'thinking') this.el.classList.add('cv-is-thinking');
555
+ // A dropped connection is retried by the client; say so instead of looking live.
556
+ this.setNote(status === 'reconnecting' ? 'Reconnecting…' : '');
557
+ // The client gave up, or the server ended the session: the call is over. Never leave a
558
+ // panel that looks live while nothing can answer.
559
+ if (status === 'disconnected' && this.isRunning) {
560
+ this.end('The call has ended.');
561
+ }
526
562
  },
563
+ onSessionLimit: (seconds: number) => this.startCountdown(seconds),
527
564
  onTranscription: (text: string) => {
528
565
  this._lastSpeechTime = Date.now();
529
566
  this.onTranscription?.(text);
@@ -538,8 +575,11 @@ export class ConversationalPanel {
538
575
  onError: (err: any) => {
539
576
  if (!this.isRunning) return;
540
577
  this.onError?.(err);
541
- this.stop();
542
- this.showError('Connection issue. Try again in a moment.');
578
+ // The server's own words when it ended the session on purpose (e.g. "Demo time limit
579
+ // 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.');
543
583
  }
544
584
  });
545
585
 
@@ -594,6 +634,8 @@ export class ConversationalPanel {
594
634
  this.agent = null;
595
635
  }
596
636
  if (this.timerTicker) { clearInterval(this.timerTicker); this.timerTicker = null; }
637
+ this.stopCountdown();
638
+ this.setNote('');
597
639
  if (this.visualizer) { this.visualizer.stop(); this.visualizer = null; }
598
640
  this.curtain.classList.remove('is-up');
599
641
  this.el.classList.remove('cv-is-speaking', 'cv-is-thinking');
@@ -683,6 +725,39 @@ export class ConversationalPanel {
683
725
  }, 5000);
684
726
  }
685
727
 
728
+ /** End the call for a reason other than the End button, and say why. */
729
+ private end(message: string) {
730
+ this.stop();
731
+ this.showError(message);
732
+ this.onEnded?.(message);
733
+ }
734
+
735
+ /** Show how long the session may still run, as announced by the server. */
736
+ private startCountdown(seconds: number) {
737
+ this.stopCountdown();
738
+ const endsAt = Date.now() + seconds * 1000;
739
+ const render = () => {
740
+ const left = Math.max(0, Math.round((endsAt - Date.now()) / 1000));
741
+ this.countdownEl.textContent = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')} left`;
742
+ this.countdownEl.classList.toggle('is-low', left <= 30);
743
+ if (left === 0) this.stopCountdown(false);
744
+ };
745
+ this.countdownEl.classList.add('is-visible');
746
+ render();
747
+ this.countdownTicker = window.setInterval(render, 1000);
748
+ }
749
+
750
+ private stopCountdown(hide = true) {
751
+ if (this.countdownTicker) { clearInterval(this.countdownTicker); this.countdownTicker = null; }
752
+ if (hide) this.countdownEl?.classList.remove('is-visible', 'is-low');
753
+ }
754
+
755
+ private setNote(text: string) {
756
+ if (!this.noteEl) return;
757
+ this.noteEl.textContent = text;
758
+ this.noteEl.classList.toggle('is-visible', Boolean(text));
759
+ }
760
+
686
761
  /** Destroy the component, removing all DOM and stopping any active session */
687
762
  destroy() {
688
763
  this.stop();
@@ -703,15 +778,13 @@ export class ConversationalPanel {
703
778
 
704
779
  if (elapsed >= maxDur) {
705
780
  this._locked = true;
706
- this.stop();
707
- this.showError('Tiempo límite alcanzado.');
781
+ this.end('Time limit reached.');
708
782
  return;
709
783
  }
710
784
 
711
785
  if (Date.now() - this._lastSpeechTime > silentMax * 1000) {
712
786
  this._locked = true;
713
- this.stop();
714
- this.showError('Sesión finalizada por inactividad.');
787
+ this.end('The call ended after a long silence.');
715
788
  }
716
789
  }, 1000);
717
790
  }
package/src/types.ts CHANGED
@@ -338,6 +338,7 @@ export type ErrorCode =
338
338
  | 'stt.language_not_supported'
339
339
  | 'agent.session_failed'
340
340
  | 'agent.provider_error'
341
+ | 'session.ended'
341
342
  | 'internal.error'
342
343
  | 'internal.timeout'
343
344
  | 'internal.cancelled'
@@ -369,6 +370,7 @@ function isRetryableCode(code: ErrorCode): boolean {
369
370
  'auth.missing_key',
370
371
  'auth.invalid_key',
371
372
  'auth.time_limited',
373
+ 'session.ended',
372
374
  'validation.invalid_voice',
373
375
  'validation.invalid_language',
374
376
  'validation.text_too_long',