@lokutor/sdk 1.4.1 → 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 +41 -2
- package/dist/index.d.ts +41 -2
- package/dist/index.js +158 -7
- package/dist/index.mjs +158 -7
- package/package.json +1 -1
- package/src/browser-audio.ts +67 -0
- package/src/client.ts +52 -1
- package/src/conversational-panel.ts +79 -6
- package/src/types.ts +2 -0
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
package/src/browser-audio.ts
CHANGED
|
@@ -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
|
-
|
|
542
|
-
|
|
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.
|
|
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.
|
|
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',
|