@lokutor/sdk 1.5.0 → 1.5.1
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 +37 -5
- package/dist/index.d.ts +37 -5
- package/dist/index.js +116 -20
- package/dist/index.mjs +114 -20
- package/package.json +1 -1
- package/src/browser-audio.ts +82 -13
- package/src/client.ts +24 -7
- package/src/conversational-panel.ts +61 -15
- package/src/index.ts +1 -1
- 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' | 'session.ended' | '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' | 'audio.microphone_unavailable' | '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,
|
|
@@ -699,8 +699,26 @@ interface BrowserAudioConfig {
|
|
|
699
699
|
* the call goes silent in both directions. Set false if your app manages this itself.
|
|
700
700
|
*/
|
|
701
701
|
keepAwake?: boolean;
|
|
702
|
+
/**
|
|
703
|
+
* A microphone stream the page already asked for, ideally with requestMicrophone() inside the tap that
|
|
704
|
+
* started the call. startMicrophone uses it instead of calling getUserMedia again later, after the
|
|
705
|
+
* connection opens -- which iOS Safari can refuse without showing its prompt.
|
|
706
|
+
*/
|
|
707
|
+
microphone?: MediaStream | Promise<MediaStream>;
|
|
702
708
|
onInputError?: (error: Error) => void;
|
|
703
709
|
}
|
|
710
|
+
/**
|
|
711
|
+
* Ask for the microphone now, from inside the tap or click that starts a call, and return the pending stream.
|
|
712
|
+
*
|
|
713
|
+
* Call it synchronously in the gesture handler, before any await, and pass the result to BrowserAudioManager
|
|
714
|
+
* as `microphone` (ConversationalPanel does this itself). Two iOS Safari behaviours made a later request fail
|
|
715
|
+
* with no prompt at all -- the call connected, the agent spoke, and nothing the caller said was heard:
|
|
716
|
+
* - a request outside a user gesture may be refused instead of prompting;
|
|
717
|
+
* - while the page's audio session is in 'playback' mode (set by pages so audio plays through the mute
|
|
718
|
+
* switch), capture is refused outright. It is put back to 'auto', under which Safari switches to
|
|
719
|
+
* play-and-record itself when capture starts and keeps the loudspeaker.
|
|
720
|
+
*/
|
|
721
|
+
declare function requestMicrophone(constraints?: Pick<BrowserAudioConfig, 'autoGainControl' | 'echoCancellation' | 'noiseSuppression'>): Promise<MediaStream>;
|
|
704
722
|
/**
|
|
705
723
|
* Analyser configuration for audio visualization
|
|
706
724
|
*/
|
|
@@ -729,6 +747,7 @@ declare class BrowserAudioManager {
|
|
|
729
747
|
private echoCancellation;
|
|
730
748
|
private noiseSuppression;
|
|
731
749
|
private keepAwake;
|
|
750
|
+
private providedMicrophone;
|
|
732
751
|
private wakeLock;
|
|
733
752
|
private visibilityHandler;
|
|
734
753
|
private onAudioInput?;
|
|
@@ -848,7 +867,11 @@ interface ConversationalPanelConfig {
|
|
|
848
867
|
backgroundColor?: string;
|
|
849
868
|
/** API key for connecting to the voice agent */
|
|
850
869
|
apiKey: string;
|
|
851
|
-
/**
|
|
870
|
+
/**
|
|
871
|
+
* Reference to the ConvoAgent class. Its constructor options include `microphone`, a
|
|
872
|
+
* Promise<MediaStream> requested inside the Start tap (see requestMicrophone); pass it on to
|
|
873
|
+
* BrowserAudioManager as `microphone` so the call uses it instead of asking again later.
|
|
874
|
+
*/
|
|
852
875
|
ConvoAgent: any;
|
|
853
876
|
/** Reference to the SphereVisualizer class */
|
|
854
877
|
SphereVisualizer: any;
|
|
@@ -863,6 +886,9 @@ interface ConversationalPanelConfig {
|
|
|
863
886
|
/** Seconds of silence before auto-close (default 60) */
|
|
864
887
|
silenceTimeout?: number;
|
|
865
888
|
}
|
|
889
|
+
/** What to tell the caller when the microphone did not start: a DOMException from getUserMedia, or the
|
|
890
|
+
* SDK's 'audio.microphone_unavailable' error wrapping one. */
|
|
891
|
+
declare function microphoneMessage(err: any): string;
|
|
866
892
|
declare class ConversationalPanel {
|
|
867
893
|
private cfg;
|
|
868
894
|
private container;
|
|
@@ -892,6 +918,8 @@ declare class ConversationalPanel {
|
|
|
892
918
|
private countdownEl;
|
|
893
919
|
private noteEl;
|
|
894
920
|
private countdownTicker;
|
|
921
|
+
private microphone;
|
|
922
|
+
private micError;
|
|
895
923
|
onTranscription?: (text: string) => void;
|
|
896
924
|
onResponse?: (text: string) => void;
|
|
897
925
|
onStart?: () => void;
|
|
@@ -907,8 +935,12 @@ declare class ConversationalPanel {
|
|
|
907
935
|
constructor(cfg: ConversationalPanelConfig);
|
|
908
936
|
private buildDOM;
|
|
909
937
|
private esc;
|
|
910
|
-
/**
|
|
911
|
-
|
|
938
|
+
/**
|
|
939
|
+
* Start the conversation (called when user clicks "Start Conversation" or externally). Call it from
|
|
940
|
+
* inside the click or tap, before any await: it asks for the microphone there. Resolves true once the
|
|
941
|
+
* call is live, false when it could not start (the reason is shown, and passed to onError).
|
|
942
|
+
*/
|
|
943
|
+
start(): Promise<boolean>;
|
|
912
944
|
/** Stop / disconnect the conversation */
|
|
913
945
|
stop(): void;
|
|
914
946
|
/** Update accent color dynamically */
|
|
@@ -941,4 +973,4 @@ declare class ConversationalPanel {
|
|
|
941
973
|
private playErrorTone;
|
|
942
974
|
}
|
|
943
975
|
|
|
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 };
|
|
976
|
+
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, microphoneMessage, normalizeAudio, pcm16ToBytes, pcm16ToFloat32, requestMicrophone, 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' | 'session.ended' | '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' | 'audio.microphone_unavailable' | '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,
|
|
@@ -699,8 +699,26 @@ interface BrowserAudioConfig {
|
|
|
699
699
|
* the call goes silent in both directions. Set false if your app manages this itself.
|
|
700
700
|
*/
|
|
701
701
|
keepAwake?: boolean;
|
|
702
|
+
/**
|
|
703
|
+
* A microphone stream the page already asked for, ideally with requestMicrophone() inside the tap that
|
|
704
|
+
* started the call. startMicrophone uses it instead of calling getUserMedia again later, after the
|
|
705
|
+
* connection opens -- which iOS Safari can refuse without showing its prompt.
|
|
706
|
+
*/
|
|
707
|
+
microphone?: MediaStream | Promise<MediaStream>;
|
|
702
708
|
onInputError?: (error: Error) => void;
|
|
703
709
|
}
|
|
710
|
+
/**
|
|
711
|
+
* Ask for the microphone now, from inside the tap or click that starts a call, and return the pending stream.
|
|
712
|
+
*
|
|
713
|
+
* Call it synchronously in the gesture handler, before any await, and pass the result to BrowserAudioManager
|
|
714
|
+
* as `microphone` (ConversationalPanel does this itself). Two iOS Safari behaviours made a later request fail
|
|
715
|
+
* with no prompt at all -- the call connected, the agent spoke, and nothing the caller said was heard:
|
|
716
|
+
* - a request outside a user gesture may be refused instead of prompting;
|
|
717
|
+
* - while the page's audio session is in 'playback' mode (set by pages so audio plays through the mute
|
|
718
|
+
* switch), capture is refused outright. It is put back to 'auto', under which Safari switches to
|
|
719
|
+
* play-and-record itself when capture starts and keeps the loudspeaker.
|
|
720
|
+
*/
|
|
721
|
+
declare function requestMicrophone(constraints?: Pick<BrowserAudioConfig, 'autoGainControl' | 'echoCancellation' | 'noiseSuppression'>): Promise<MediaStream>;
|
|
704
722
|
/**
|
|
705
723
|
* Analyser configuration for audio visualization
|
|
706
724
|
*/
|
|
@@ -729,6 +747,7 @@ declare class BrowserAudioManager {
|
|
|
729
747
|
private echoCancellation;
|
|
730
748
|
private noiseSuppression;
|
|
731
749
|
private keepAwake;
|
|
750
|
+
private providedMicrophone;
|
|
732
751
|
private wakeLock;
|
|
733
752
|
private visibilityHandler;
|
|
734
753
|
private onAudioInput?;
|
|
@@ -848,7 +867,11 @@ interface ConversationalPanelConfig {
|
|
|
848
867
|
backgroundColor?: string;
|
|
849
868
|
/** API key for connecting to the voice agent */
|
|
850
869
|
apiKey: string;
|
|
851
|
-
/**
|
|
870
|
+
/**
|
|
871
|
+
* Reference to the ConvoAgent class. Its constructor options include `microphone`, a
|
|
872
|
+
* Promise<MediaStream> requested inside the Start tap (see requestMicrophone); pass it on to
|
|
873
|
+
* BrowserAudioManager as `microphone` so the call uses it instead of asking again later.
|
|
874
|
+
*/
|
|
852
875
|
ConvoAgent: any;
|
|
853
876
|
/** Reference to the SphereVisualizer class */
|
|
854
877
|
SphereVisualizer: any;
|
|
@@ -863,6 +886,9 @@ interface ConversationalPanelConfig {
|
|
|
863
886
|
/** Seconds of silence before auto-close (default 60) */
|
|
864
887
|
silenceTimeout?: number;
|
|
865
888
|
}
|
|
889
|
+
/** What to tell the caller when the microphone did not start: a DOMException from getUserMedia, or the
|
|
890
|
+
* SDK's 'audio.microphone_unavailable' error wrapping one. */
|
|
891
|
+
declare function microphoneMessage(err: any): string;
|
|
866
892
|
declare class ConversationalPanel {
|
|
867
893
|
private cfg;
|
|
868
894
|
private container;
|
|
@@ -892,6 +918,8 @@ declare class ConversationalPanel {
|
|
|
892
918
|
private countdownEl;
|
|
893
919
|
private noteEl;
|
|
894
920
|
private countdownTicker;
|
|
921
|
+
private microphone;
|
|
922
|
+
private micError;
|
|
895
923
|
onTranscription?: (text: string) => void;
|
|
896
924
|
onResponse?: (text: string) => void;
|
|
897
925
|
onStart?: () => void;
|
|
@@ -907,8 +935,12 @@ declare class ConversationalPanel {
|
|
|
907
935
|
constructor(cfg: ConversationalPanelConfig);
|
|
908
936
|
private buildDOM;
|
|
909
937
|
private esc;
|
|
910
|
-
/**
|
|
911
|
-
|
|
938
|
+
/**
|
|
939
|
+
* Start the conversation (called when user clicks "Start Conversation" or externally). Call it from
|
|
940
|
+
* inside the click or tap, before any await: it asks for the microphone there. Resolves true once the
|
|
941
|
+
* call is live, false when it could not start (the reason is shown, and passed to onError).
|
|
942
|
+
*/
|
|
943
|
+
start(): Promise<boolean>;
|
|
912
944
|
/** Stop / disconnect the conversation */
|
|
913
945
|
stop(): void;
|
|
914
946
|
/** Update accent color dynamically */
|
|
@@ -941,4 +973,4 @@ declare class ConversationalPanel {
|
|
|
941
973
|
private playErrorTone;
|
|
942
974
|
}
|
|
943
975
|
|
|
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 };
|
|
976
|
+
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, microphoneMessage, normalizeAudio, pcm16ToBytes, pcm16ToFloat32, requestMicrophone, resample, resampleWithAntiAliasing, simpleConversation, simpleTTS, simpleTranscribe };
|
package/dist/index.js
CHANGED
|
@@ -38,9 +38,11 @@ __export(index_exports, {
|
|
|
38
38
|
calculateRMS: () => calculateRMS,
|
|
39
39
|
float32ToPcm16: () => float32ToPcm16,
|
|
40
40
|
isRetryable: () => isRetryable,
|
|
41
|
+
microphoneMessage: () => microphoneMessage,
|
|
41
42
|
normalizeAudio: () => normalizeAudio,
|
|
42
43
|
pcm16ToBytes: () => pcm16ToBytes,
|
|
43
44
|
pcm16ToFloat32: () => pcm16ToFloat32,
|
|
45
|
+
requestMicrophone: () => requestMicrophone,
|
|
44
46
|
resample: () => resample,
|
|
45
47
|
resampleWithAntiAliasing: () => resampleWithAntiAliasing,
|
|
46
48
|
simpleConversation: () => simpleConversation,
|
|
@@ -131,6 +133,7 @@ function isRetryableCode(code) {
|
|
|
131
133
|
"auth.invalid_key",
|
|
132
134
|
"auth.time_limited",
|
|
133
135
|
"session.ended",
|
|
136
|
+
"audio.microphone_unavailable",
|
|
134
137
|
"validation.invalid_voice",
|
|
135
138
|
"validation.invalid_language",
|
|
136
139
|
"validation.text_too_long",
|
|
@@ -285,6 +288,36 @@ var StreamResampler = class {
|
|
|
285
288
|
};
|
|
286
289
|
|
|
287
290
|
// src/browser-audio.ts
|
|
291
|
+
function requestMicrophone(constraints = {}) {
|
|
292
|
+
if (typeof navigator === "undefined") {
|
|
293
|
+
return Promise.reject(new Error("Microphone capture needs a browser"));
|
|
294
|
+
}
|
|
295
|
+
const session = navigator.audioSession;
|
|
296
|
+
if (session && session.type !== "auto" && session.type !== "play-and-record") {
|
|
297
|
+
try {
|
|
298
|
+
session.type = "auto";
|
|
299
|
+
} catch (_) {
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (!navigator.mediaDevices?.getUserMedia) {
|
|
303
|
+
return Promise.reject(new Error("Microphone capture is not available here (it needs HTTPS)"));
|
|
304
|
+
}
|
|
305
|
+
return navigator.mediaDevices.getUserMedia({
|
|
306
|
+
audio: {
|
|
307
|
+
autoGainControl: constraints.autoGainControl ?? true,
|
|
308
|
+
echoCancellation: constraints.echoCancellation ?? true,
|
|
309
|
+
noiseSuppression: constraints.noiseSuppression ?? true
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
async function resumeWithin(ctx, ms) {
|
|
314
|
+
if (ctx.state === "running") return;
|
|
315
|
+
await Promise.race([
|
|
316
|
+
ctx.resume().catch(() => {
|
|
317
|
+
}),
|
|
318
|
+
new Promise((r) => setTimeout(r, ms))
|
|
319
|
+
]);
|
|
320
|
+
}
|
|
288
321
|
var BrowserAudioManager = class {
|
|
289
322
|
audioContext = null;
|
|
290
323
|
mediaStreamAudioSourceNode = null;
|
|
@@ -307,6 +340,7 @@ var BrowserAudioManager = class {
|
|
|
307
340
|
echoCancellation;
|
|
308
341
|
noiseSuppression;
|
|
309
342
|
keepAwake;
|
|
343
|
+
providedMicrophone;
|
|
310
344
|
// Screen wake lock, held while the microphone is live. Browsers release it whenever the page is
|
|
311
345
|
// hidden, so it is taken again when the page becomes visible.
|
|
312
346
|
wakeLock = null;
|
|
@@ -324,6 +358,9 @@ var BrowserAudioManager = class {
|
|
|
324
358
|
this.echoCancellation = config.echoCancellation ?? true;
|
|
325
359
|
this.noiseSuppression = config.noiseSuppression ?? true;
|
|
326
360
|
this.keepAwake = config.keepAwake ?? true;
|
|
361
|
+
this.providedMicrophone = config.microphone ?? null;
|
|
362
|
+
if (this.providedMicrophone instanceof Promise) this.providedMicrophone.catch(() => {
|
|
363
|
+
});
|
|
327
364
|
this.onInputError = config.onInputError;
|
|
328
365
|
}
|
|
329
366
|
/**
|
|
@@ -339,9 +376,8 @@ var BrowserAudioManager = class {
|
|
|
339
376
|
if (!this.audioContext) {
|
|
340
377
|
throw new Error("Failed to initialize AudioContext");
|
|
341
378
|
}
|
|
342
|
-
if (this.audioContext.state
|
|
343
|
-
await this.audioContext
|
|
344
|
-
console.log("\u{1F442} AudioContext resumed");
|
|
379
|
+
if (this.audioContext.state !== "running") {
|
|
380
|
+
await resumeWithin(this.audioContext, 500);
|
|
345
381
|
}
|
|
346
382
|
if (analyserConfig?.enabled !== false) {
|
|
347
383
|
this.analyserNode = this.audioContext.createAnalyser();
|
|
@@ -362,7 +398,10 @@ var BrowserAudioManager = class {
|
|
|
362
398
|
try {
|
|
363
399
|
this.onAudioInput = onAudioInput;
|
|
364
400
|
this.isListening = true;
|
|
365
|
-
|
|
401
|
+
const provided = this.providedMicrophone;
|
|
402
|
+
this.providedMicrophone = null;
|
|
403
|
+
const stream = provided ? await provided : null;
|
|
404
|
+
this.mediaStream = stream && stream.getAudioTracks().some((t) => t.readyState === "live") ? stream : await navigator.mediaDevices.getUserMedia({
|
|
366
405
|
audio: {
|
|
367
406
|
autoGainControl: this.autoGainControl,
|
|
368
407
|
echoCancellation: this.echoCancellation,
|
|
@@ -395,6 +434,7 @@ var BrowserAudioManager = class {
|
|
|
395
434
|
console.log("\u{1F3A4} Microphone started");
|
|
396
435
|
this.holdScreenAwake();
|
|
397
436
|
} catch (error) {
|
|
437
|
+
this.isListening = false;
|
|
398
438
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
399
439
|
if (this.onInputError) this.onInputError(err);
|
|
400
440
|
throw err;
|
|
@@ -629,6 +669,13 @@ var BrowserAudioManager = class {
|
|
|
629
669
|
this.analyserNode.disconnect();
|
|
630
670
|
this.analyserNode = null;
|
|
631
671
|
}
|
|
672
|
+
if (this.audioContext) {
|
|
673
|
+
const ctx = this.audioContext;
|
|
674
|
+
this.audioContext = null;
|
|
675
|
+
this.nextPlaybackTime = 0;
|
|
676
|
+
ctx.close().catch(() => {
|
|
677
|
+
});
|
|
678
|
+
}
|
|
632
679
|
}
|
|
633
680
|
/**
|
|
634
681
|
* Get current audio context state
|
|
@@ -851,11 +898,29 @@ var VoiceAgentClient = class {
|
|
|
851
898
|
sdkTrace("ws.open");
|
|
852
899
|
this.sendConfig();
|
|
853
900
|
if (this.audioManager && !this.micStarted) {
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
this.
|
|
901
|
+
try {
|
|
902
|
+
await this.audioManager.startMicrophone((data) => {
|
|
903
|
+
if (this.isConnected) {
|
|
904
|
+
this.sendAudio(data);
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
} catch (e) {
|
|
908
|
+
const name = e?.name || "Error";
|
|
909
|
+
const error = new LokutorError(
|
|
910
|
+
"audio.microphone_unavailable",
|
|
911
|
+
name === "NotAllowedError" || name === "SecurityError" ? "Microphone access was not allowed." : "The microphone could not be started.",
|
|
912
|
+
{ detail: `${name}: ${e?.message ?? e}`, original: e, retryable: false }
|
|
913
|
+
);
|
|
914
|
+
sdkTrace("mic.failed", { name });
|
|
915
|
+
settle(() => reject(error));
|
|
916
|
+
this.isUserDisconnect = true;
|
|
917
|
+
try {
|
|
918
|
+
this.ws?.close();
|
|
919
|
+
} catch (_) {
|
|
857
920
|
}
|
|
858
|
-
|
|
921
|
+
this.emit("error", error);
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
859
924
|
this.micStarted = true;
|
|
860
925
|
}
|
|
861
926
|
settle(() => resolve(true));
|
|
@@ -1107,7 +1172,6 @@ var VoiceAgentClient = class {
|
|
|
1107
1172
|
detail: backendDetail,
|
|
1108
1173
|
retryable: backendRetryable
|
|
1109
1174
|
});
|
|
1110
|
-
if (this.onError) this.onError(error);
|
|
1111
1175
|
this.emit("error", error);
|
|
1112
1176
|
console.error(`\u274C Server error: [${error.code}] ${error.message}`);
|
|
1113
1177
|
break;
|
|
@@ -2069,6 +2133,17 @@ function injectStyles() {
|
|
|
2069
2133
|
document.head.appendChild(style);
|
|
2070
2134
|
styleInjected = true;
|
|
2071
2135
|
}
|
|
2136
|
+
function microphoneMessage(err) {
|
|
2137
|
+
const name = String(err?.original?.name ?? err?.name ?? "");
|
|
2138
|
+
const detail = String(err?.detail ?? err?.message ?? "");
|
|
2139
|
+
if (/NotAllowedError|SecurityError/.test(name + " " + detail)) {
|
|
2140
|
+
return "Microphone access is blocked. Allow it for this site in your browser settings, then try again.";
|
|
2141
|
+
}
|
|
2142
|
+
if (/NotFoundError|OverconstrainedError/.test(name + " " + detail)) {
|
|
2143
|
+
return "No microphone was found on this device.";
|
|
2144
|
+
}
|
|
2145
|
+
return "The microphone could not start. Please try again.";
|
|
2146
|
+
}
|
|
2072
2147
|
var ConversationalPanel = class {
|
|
2073
2148
|
cfg;
|
|
2074
2149
|
container;
|
|
@@ -2098,6 +2173,8 @@ var ConversationalPanel = class {
|
|
|
2098
2173
|
countdownEl;
|
|
2099
2174
|
noteEl;
|
|
2100
2175
|
countdownTicker = null;
|
|
2176
|
+
microphone = null;
|
|
2177
|
+
micError = null;
|
|
2101
2178
|
// Callbacks
|
|
2102
2179
|
onTranscription;
|
|
2103
2180
|
onResponse;
|
|
@@ -2199,14 +2276,23 @@ var ConversationalPanel = class {
|
|
|
2199
2276
|
d.textContent = s;
|
|
2200
2277
|
return d.innerHTML;
|
|
2201
2278
|
}
|
|
2202
|
-
/**
|
|
2279
|
+
/**
|
|
2280
|
+
* Start the conversation (called when user clicks "Start Conversation" or externally). Call it from
|
|
2281
|
+
* inside the click or tap, before any await: it asks for the microphone there. Resolves true once the
|
|
2282
|
+
* call is live, false when it could not start (the reason is shown, and passed to onError).
|
|
2283
|
+
*/
|
|
2203
2284
|
async start() {
|
|
2204
|
-
if (this.isRunning) return;
|
|
2285
|
+
if (this.isRunning) return false;
|
|
2205
2286
|
if (this._locked) {
|
|
2206
2287
|
this.showError("This session has ended. Refresh the page to start a new one.");
|
|
2207
|
-
return;
|
|
2288
|
+
return false;
|
|
2208
2289
|
}
|
|
2209
2290
|
this.isRunning = true;
|
|
2291
|
+
this.microphone = requestMicrophone();
|
|
2292
|
+
this.micError = null;
|
|
2293
|
+
this.microphone.catch((e) => {
|
|
2294
|
+
this.micError = e;
|
|
2295
|
+
});
|
|
2210
2296
|
this.startBtn.disabled = true;
|
|
2211
2297
|
this.errorEl.classList.remove("is-visible");
|
|
2212
2298
|
this.playConnectingSound();
|
|
@@ -2257,8 +2343,9 @@ var ConversationalPanel = class {
|
|
|
2257
2343
|
onError: (err) => {
|
|
2258
2344
|
if (!this.isRunning) return;
|
|
2259
2345
|
this.onError?.(err);
|
|
2260
|
-
this.end(err?.code === "session.ended" && err?.message ? err.message : "Connection issue. Try again in a moment.");
|
|
2261
|
-
}
|
|
2346
|
+
this.end(err?.code === "audio.microphone_unavailable" ? microphoneMessage(err) : err?.code === "session.ended" && err?.message ? err.message : "Connection issue. Try again in a moment.");
|
|
2347
|
+
},
|
|
2348
|
+
microphone: this.microphone
|
|
2262
2349
|
});
|
|
2263
2350
|
this.agent = agentHandle;
|
|
2264
2351
|
visualizer.setAudioClient(agentHandle);
|
|
@@ -2268,6 +2355,7 @@ var ConversationalPanel = class {
|
|
|
2268
2355
|
} catch (_) {
|
|
2269
2356
|
}
|
|
2270
2357
|
const ok = await agentHandle.connect();
|
|
2358
|
+
if (!this.isRunning) return false;
|
|
2271
2359
|
if (ok) {
|
|
2272
2360
|
this.fadeOutConnectingSound();
|
|
2273
2361
|
const wrap = this.visualizerWrap;
|
|
@@ -2281,19 +2369,21 @@ var ConversationalPanel = class {
|
|
|
2281
2369
|
this.startTimer();
|
|
2282
2370
|
this.onStart?.();
|
|
2283
2371
|
if (window.lucide) window.lucide.createIcons();
|
|
2284
|
-
|
|
2285
|
-
this.fadeOutConnectingSound();
|
|
2286
|
-
this.playErrorTone();
|
|
2287
|
-
this.curtain.classList.remove("is-up");
|
|
2288
|
-
this.showError("Could not connect. Please try again.");
|
|
2289
|
-
this.isRunning = false;
|
|
2372
|
+
return true;
|
|
2290
2373
|
}
|
|
2374
|
+
this.fadeOutConnectingSound();
|
|
2375
|
+
this.playErrorTone();
|
|
2376
|
+
const micError = this.micError;
|
|
2377
|
+
this.stop();
|
|
2378
|
+
this.showError(micError ? microphoneMessage(micError) : "Could not connect. Please try again.");
|
|
2379
|
+
return false;
|
|
2291
2380
|
} catch (err) {
|
|
2292
2381
|
this.fadeOutConnectingSound();
|
|
2293
2382
|
this.playErrorTone();
|
|
2294
2383
|
console.error("ConversationalPanel start error:", err);
|
|
2295
2384
|
this.stop();
|
|
2296
2385
|
this.showError("Something went wrong. Please try again.");
|
|
2386
|
+
return false;
|
|
2297
2387
|
} finally {
|
|
2298
2388
|
this.startBtn.disabled = false;
|
|
2299
2389
|
}
|
|
@@ -2311,6 +2401,10 @@ var ConversationalPanel = class {
|
|
|
2311
2401
|
}
|
|
2312
2402
|
this.agent = null;
|
|
2313
2403
|
}
|
|
2404
|
+
const mic = this.microphone;
|
|
2405
|
+
this.microphone = null;
|
|
2406
|
+
mic?.then((s) => s.getTracks().forEach((t) => t.stop()), () => {
|
|
2407
|
+
});
|
|
2314
2408
|
if (this.timerTicker) {
|
|
2315
2409
|
clearInterval(this.timerTicker);
|
|
2316
2410
|
this.timerTicker = null;
|
|
@@ -2535,9 +2629,11 @@ var ConversationalPanel = class {
|
|
|
2535
2629
|
calculateRMS,
|
|
2536
2630
|
float32ToPcm16,
|
|
2537
2631
|
isRetryable,
|
|
2632
|
+
microphoneMessage,
|
|
2538
2633
|
normalizeAudio,
|
|
2539
2634
|
pcm16ToBytes,
|
|
2540
2635
|
pcm16ToFloat32,
|
|
2636
|
+
requestMicrophone,
|
|
2541
2637
|
resample,
|
|
2542
2638
|
resampleWithAntiAliasing,
|
|
2543
2639
|
simpleConversation,
|
package/dist/index.mjs
CHANGED
|
@@ -80,6 +80,7 @@ function isRetryableCode(code) {
|
|
|
80
80
|
"auth.invalid_key",
|
|
81
81
|
"auth.time_limited",
|
|
82
82
|
"session.ended",
|
|
83
|
+
"audio.microphone_unavailable",
|
|
83
84
|
"validation.invalid_voice",
|
|
84
85
|
"validation.invalid_language",
|
|
85
86
|
"validation.text_too_long",
|
|
@@ -234,6 +235,36 @@ var StreamResampler = class {
|
|
|
234
235
|
};
|
|
235
236
|
|
|
236
237
|
// src/browser-audio.ts
|
|
238
|
+
function requestMicrophone(constraints = {}) {
|
|
239
|
+
if (typeof navigator === "undefined") {
|
|
240
|
+
return Promise.reject(new Error("Microphone capture needs a browser"));
|
|
241
|
+
}
|
|
242
|
+
const session = navigator.audioSession;
|
|
243
|
+
if (session && session.type !== "auto" && session.type !== "play-and-record") {
|
|
244
|
+
try {
|
|
245
|
+
session.type = "auto";
|
|
246
|
+
} catch (_) {
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (!navigator.mediaDevices?.getUserMedia) {
|
|
250
|
+
return Promise.reject(new Error("Microphone capture is not available here (it needs HTTPS)"));
|
|
251
|
+
}
|
|
252
|
+
return navigator.mediaDevices.getUserMedia({
|
|
253
|
+
audio: {
|
|
254
|
+
autoGainControl: constraints.autoGainControl ?? true,
|
|
255
|
+
echoCancellation: constraints.echoCancellation ?? true,
|
|
256
|
+
noiseSuppression: constraints.noiseSuppression ?? true
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
async function resumeWithin(ctx, ms) {
|
|
261
|
+
if (ctx.state === "running") return;
|
|
262
|
+
await Promise.race([
|
|
263
|
+
ctx.resume().catch(() => {
|
|
264
|
+
}),
|
|
265
|
+
new Promise((r) => setTimeout(r, ms))
|
|
266
|
+
]);
|
|
267
|
+
}
|
|
237
268
|
var BrowserAudioManager = class {
|
|
238
269
|
audioContext = null;
|
|
239
270
|
mediaStreamAudioSourceNode = null;
|
|
@@ -256,6 +287,7 @@ var BrowserAudioManager = class {
|
|
|
256
287
|
echoCancellation;
|
|
257
288
|
noiseSuppression;
|
|
258
289
|
keepAwake;
|
|
290
|
+
providedMicrophone;
|
|
259
291
|
// Screen wake lock, held while the microphone is live. Browsers release it whenever the page is
|
|
260
292
|
// hidden, so it is taken again when the page becomes visible.
|
|
261
293
|
wakeLock = null;
|
|
@@ -273,6 +305,9 @@ var BrowserAudioManager = class {
|
|
|
273
305
|
this.echoCancellation = config.echoCancellation ?? true;
|
|
274
306
|
this.noiseSuppression = config.noiseSuppression ?? true;
|
|
275
307
|
this.keepAwake = config.keepAwake ?? true;
|
|
308
|
+
this.providedMicrophone = config.microphone ?? null;
|
|
309
|
+
if (this.providedMicrophone instanceof Promise) this.providedMicrophone.catch(() => {
|
|
310
|
+
});
|
|
276
311
|
this.onInputError = config.onInputError;
|
|
277
312
|
}
|
|
278
313
|
/**
|
|
@@ -288,9 +323,8 @@ var BrowserAudioManager = class {
|
|
|
288
323
|
if (!this.audioContext) {
|
|
289
324
|
throw new Error("Failed to initialize AudioContext");
|
|
290
325
|
}
|
|
291
|
-
if (this.audioContext.state
|
|
292
|
-
await this.audioContext
|
|
293
|
-
console.log("\u{1F442} AudioContext resumed");
|
|
326
|
+
if (this.audioContext.state !== "running") {
|
|
327
|
+
await resumeWithin(this.audioContext, 500);
|
|
294
328
|
}
|
|
295
329
|
if (analyserConfig?.enabled !== false) {
|
|
296
330
|
this.analyserNode = this.audioContext.createAnalyser();
|
|
@@ -311,7 +345,10 @@ var BrowserAudioManager = class {
|
|
|
311
345
|
try {
|
|
312
346
|
this.onAudioInput = onAudioInput;
|
|
313
347
|
this.isListening = true;
|
|
314
|
-
|
|
348
|
+
const provided = this.providedMicrophone;
|
|
349
|
+
this.providedMicrophone = null;
|
|
350
|
+
const stream = provided ? await provided : null;
|
|
351
|
+
this.mediaStream = stream && stream.getAudioTracks().some((t) => t.readyState === "live") ? stream : await navigator.mediaDevices.getUserMedia({
|
|
315
352
|
audio: {
|
|
316
353
|
autoGainControl: this.autoGainControl,
|
|
317
354
|
echoCancellation: this.echoCancellation,
|
|
@@ -344,6 +381,7 @@ var BrowserAudioManager = class {
|
|
|
344
381
|
console.log("\u{1F3A4} Microphone started");
|
|
345
382
|
this.holdScreenAwake();
|
|
346
383
|
} catch (error) {
|
|
384
|
+
this.isListening = false;
|
|
347
385
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
348
386
|
if (this.onInputError) this.onInputError(err);
|
|
349
387
|
throw err;
|
|
@@ -578,6 +616,13 @@ var BrowserAudioManager = class {
|
|
|
578
616
|
this.analyserNode.disconnect();
|
|
579
617
|
this.analyserNode = null;
|
|
580
618
|
}
|
|
619
|
+
if (this.audioContext) {
|
|
620
|
+
const ctx = this.audioContext;
|
|
621
|
+
this.audioContext = null;
|
|
622
|
+
this.nextPlaybackTime = 0;
|
|
623
|
+
ctx.close().catch(() => {
|
|
624
|
+
});
|
|
625
|
+
}
|
|
581
626
|
}
|
|
582
627
|
/**
|
|
583
628
|
* Get current audio context state
|
|
@@ -800,11 +845,29 @@ var VoiceAgentClient = class {
|
|
|
800
845
|
sdkTrace("ws.open");
|
|
801
846
|
this.sendConfig();
|
|
802
847
|
if (this.audioManager && !this.micStarted) {
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
this.
|
|
848
|
+
try {
|
|
849
|
+
await this.audioManager.startMicrophone((data) => {
|
|
850
|
+
if (this.isConnected) {
|
|
851
|
+
this.sendAudio(data);
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
} catch (e) {
|
|
855
|
+
const name = e?.name || "Error";
|
|
856
|
+
const error = new LokutorError(
|
|
857
|
+
"audio.microphone_unavailable",
|
|
858
|
+
name === "NotAllowedError" || name === "SecurityError" ? "Microphone access was not allowed." : "The microphone could not be started.",
|
|
859
|
+
{ detail: `${name}: ${e?.message ?? e}`, original: e, retryable: false }
|
|
860
|
+
);
|
|
861
|
+
sdkTrace("mic.failed", { name });
|
|
862
|
+
settle(() => reject(error));
|
|
863
|
+
this.isUserDisconnect = true;
|
|
864
|
+
try {
|
|
865
|
+
this.ws?.close();
|
|
866
|
+
} catch (_) {
|
|
806
867
|
}
|
|
807
|
-
|
|
868
|
+
this.emit("error", error);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
808
871
|
this.micStarted = true;
|
|
809
872
|
}
|
|
810
873
|
settle(() => resolve(true));
|
|
@@ -1056,7 +1119,6 @@ var VoiceAgentClient = class {
|
|
|
1056
1119
|
detail: backendDetail,
|
|
1057
1120
|
retryable: backendRetryable
|
|
1058
1121
|
});
|
|
1059
|
-
if (this.onError) this.onError(error);
|
|
1060
1122
|
this.emit("error", error);
|
|
1061
1123
|
console.error(`\u274C Server error: [${error.code}] ${error.message}`);
|
|
1062
1124
|
break;
|
|
@@ -2018,6 +2080,17 @@ function injectStyles() {
|
|
|
2018
2080
|
document.head.appendChild(style);
|
|
2019
2081
|
styleInjected = true;
|
|
2020
2082
|
}
|
|
2083
|
+
function microphoneMessage(err) {
|
|
2084
|
+
const name = String(err?.original?.name ?? err?.name ?? "");
|
|
2085
|
+
const detail = String(err?.detail ?? err?.message ?? "");
|
|
2086
|
+
if (/NotAllowedError|SecurityError/.test(name + " " + detail)) {
|
|
2087
|
+
return "Microphone access is blocked. Allow it for this site in your browser settings, then try again.";
|
|
2088
|
+
}
|
|
2089
|
+
if (/NotFoundError|OverconstrainedError/.test(name + " " + detail)) {
|
|
2090
|
+
return "No microphone was found on this device.";
|
|
2091
|
+
}
|
|
2092
|
+
return "The microphone could not start. Please try again.";
|
|
2093
|
+
}
|
|
2021
2094
|
var ConversationalPanel = class {
|
|
2022
2095
|
cfg;
|
|
2023
2096
|
container;
|
|
@@ -2047,6 +2120,8 @@ var ConversationalPanel = class {
|
|
|
2047
2120
|
countdownEl;
|
|
2048
2121
|
noteEl;
|
|
2049
2122
|
countdownTicker = null;
|
|
2123
|
+
microphone = null;
|
|
2124
|
+
micError = null;
|
|
2050
2125
|
// Callbacks
|
|
2051
2126
|
onTranscription;
|
|
2052
2127
|
onResponse;
|
|
@@ -2148,14 +2223,23 @@ var ConversationalPanel = class {
|
|
|
2148
2223
|
d.textContent = s;
|
|
2149
2224
|
return d.innerHTML;
|
|
2150
2225
|
}
|
|
2151
|
-
/**
|
|
2226
|
+
/**
|
|
2227
|
+
* Start the conversation (called when user clicks "Start Conversation" or externally). Call it from
|
|
2228
|
+
* inside the click or tap, before any await: it asks for the microphone there. Resolves true once the
|
|
2229
|
+
* call is live, false when it could not start (the reason is shown, and passed to onError).
|
|
2230
|
+
*/
|
|
2152
2231
|
async start() {
|
|
2153
|
-
if (this.isRunning) return;
|
|
2232
|
+
if (this.isRunning) return false;
|
|
2154
2233
|
if (this._locked) {
|
|
2155
2234
|
this.showError("This session has ended. Refresh the page to start a new one.");
|
|
2156
|
-
return;
|
|
2235
|
+
return false;
|
|
2157
2236
|
}
|
|
2158
2237
|
this.isRunning = true;
|
|
2238
|
+
this.microphone = requestMicrophone();
|
|
2239
|
+
this.micError = null;
|
|
2240
|
+
this.microphone.catch((e) => {
|
|
2241
|
+
this.micError = e;
|
|
2242
|
+
});
|
|
2159
2243
|
this.startBtn.disabled = true;
|
|
2160
2244
|
this.errorEl.classList.remove("is-visible");
|
|
2161
2245
|
this.playConnectingSound();
|
|
@@ -2206,8 +2290,9 @@ var ConversationalPanel = class {
|
|
|
2206
2290
|
onError: (err) => {
|
|
2207
2291
|
if (!this.isRunning) return;
|
|
2208
2292
|
this.onError?.(err);
|
|
2209
|
-
this.end(err?.code === "session.ended" && err?.message ? err.message : "Connection issue. Try again in a moment.");
|
|
2210
|
-
}
|
|
2293
|
+
this.end(err?.code === "audio.microphone_unavailable" ? microphoneMessage(err) : err?.code === "session.ended" && err?.message ? err.message : "Connection issue. Try again in a moment.");
|
|
2294
|
+
},
|
|
2295
|
+
microphone: this.microphone
|
|
2211
2296
|
});
|
|
2212
2297
|
this.agent = agentHandle;
|
|
2213
2298
|
visualizer.setAudioClient(agentHandle);
|
|
@@ -2217,6 +2302,7 @@ var ConversationalPanel = class {
|
|
|
2217
2302
|
} catch (_) {
|
|
2218
2303
|
}
|
|
2219
2304
|
const ok = await agentHandle.connect();
|
|
2305
|
+
if (!this.isRunning) return false;
|
|
2220
2306
|
if (ok) {
|
|
2221
2307
|
this.fadeOutConnectingSound();
|
|
2222
2308
|
const wrap = this.visualizerWrap;
|
|
@@ -2230,19 +2316,21 @@ var ConversationalPanel = class {
|
|
|
2230
2316
|
this.startTimer();
|
|
2231
2317
|
this.onStart?.();
|
|
2232
2318
|
if (window.lucide) window.lucide.createIcons();
|
|
2233
|
-
|
|
2234
|
-
this.fadeOutConnectingSound();
|
|
2235
|
-
this.playErrorTone();
|
|
2236
|
-
this.curtain.classList.remove("is-up");
|
|
2237
|
-
this.showError("Could not connect. Please try again.");
|
|
2238
|
-
this.isRunning = false;
|
|
2319
|
+
return true;
|
|
2239
2320
|
}
|
|
2321
|
+
this.fadeOutConnectingSound();
|
|
2322
|
+
this.playErrorTone();
|
|
2323
|
+
const micError = this.micError;
|
|
2324
|
+
this.stop();
|
|
2325
|
+
this.showError(micError ? microphoneMessage(micError) : "Could not connect. Please try again.");
|
|
2326
|
+
return false;
|
|
2240
2327
|
} catch (err) {
|
|
2241
2328
|
this.fadeOutConnectingSound();
|
|
2242
2329
|
this.playErrorTone();
|
|
2243
2330
|
console.error("ConversationalPanel start error:", err);
|
|
2244
2331
|
this.stop();
|
|
2245
2332
|
this.showError("Something went wrong. Please try again.");
|
|
2333
|
+
return false;
|
|
2246
2334
|
} finally {
|
|
2247
2335
|
this.startBtn.disabled = false;
|
|
2248
2336
|
}
|
|
@@ -2260,6 +2348,10 @@ var ConversationalPanel = class {
|
|
|
2260
2348
|
}
|
|
2261
2349
|
this.agent = null;
|
|
2262
2350
|
}
|
|
2351
|
+
const mic = this.microphone;
|
|
2352
|
+
this.microphone = null;
|
|
2353
|
+
mic?.then((s) => s.getTracks().forEach((t) => t.stop()), () => {
|
|
2354
|
+
});
|
|
2263
2355
|
if (this.timerTicker) {
|
|
2264
2356
|
clearInterval(this.timerTicker);
|
|
2265
2357
|
this.timerTicker = null;
|
|
@@ -2483,9 +2575,11 @@ export {
|
|
|
2483
2575
|
calculateRMS,
|
|
2484
2576
|
float32ToPcm16,
|
|
2485
2577
|
isRetryable,
|
|
2578
|
+
microphoneMessage,
|
|
2486
2579
|
normalizeAudio,
|
|
2487
2580
|
pcm16ToBytes,
|
|
2488
2581
|
pcm16ToFloat32,
|
|
2582
|
+
requestMicrophone,
|
|
2489
2583
|
resample,
|
|
2490
2584
|
resampleWithAntiAliasing,
|
|
2491
2585
|
simpleConversation,
|
package/package.json
CHANGED
package/src/browser-audio.ts
CHANGED
|
@@ -16,9 +16,61 @@ export interface BrowserAudioConfig {
|
|
|
16
16
|
* the call goes silent in both directions. Set false if your app manages this itself.
|
|
17
17
|
*/
|
|
18
18
|
keepAwake?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* A microphone stream the page already asked for, ideally with requestMicrophone() inside the tap that
|
|
21
|
+
* started the call. startMicrophone uses it instead of calling getUserMedia again later, after the
|
|
22
|
+
* connection opens -- which iOS Safari can refuse without showing its prompt.
|
|
23
|
+
*/
|
|
24
|
+
microphone?: MediaStream | Promise<MediaStream>;
|
|
19
25
|
onInputError?: (error: Error) => void;
|
|
20
26
|
}
|
|
21
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Ask for the microphone now, from inside the tap or click that starts a call, and return the pending stream.
|
|
30
|
+
*
|
|
31
|
+
* Call it synchronously in the gesture handler, before any await, and pass the result to BrowserAudioManager
|
|
32
|
+
* as `microphone` (ConversationalPanel does this itself). Two iOS Safari behaviours made a later request fail
|
|
33
|
+
* with no prompt at all -- the call connected, the agent spoke, and nothing the caller said was heard:
|
|
34
|
+
* - a request outside a user gesture may be refused instead of prompting;
|
|
35
|
+
* - while the page's audio session is in 'playback' mode (set by pages so audio plays through the mute
|
|
36
|
+
* switch), capture is refused outright. It is put back to 'auto', under which Safari switches to
|
|
37
|
+
* play-and-record itself when capture starts and keeps the loudspeaker.
|
|
38
|
+
*/
|
|
39
|
+
export function requestMicrophone(
|
|
40
|
+
constraints: Pick<BrowserAudioConfig, 'autoGainControl' | 'echoCancellation' | 'noiseSuppression'> = {}
|
|
41
|
+
): Promise<MediaStream> {
|
|
42
|
+
if (typeof navigator === 'undefined') {
|
|
43
|
+
return Promise.reject(new Error('Microphone capture needs a browser'));
|
|
44
|
+
}
|
|
45
|
+
const session = (navigator as any).audioSession;
|
|
46
|
+
if (session && session.type !== 'auto' && session.type !== 'play-and-record') {
|
|
47
|
+
try { session.type = 'auto'; } catch (_) { /* read-only in this browser */ }
|
|
48
|
+
}
|
|
49
|
+
if (!navigator.mediaDevices?.getUserMedia) {
|
|
50
|
+
return Promise.reject(new Error('Microphone capture is not available here (it needs HTTPS)'));
|
|
51
|
+
}
|
|
52
|
+
return navigator.mediaDevices.getUserMedia({
|
|
53
|
+
audio: {
|
|
54
|
+
autoGainControl: constraints.autoGainControl ?? true,
|
|
55
|
+
echoCancellation: constraints.echoCancellation ?? true,
|
|
56
|
+
noiseSuppression: constraints.noiseSuppression ?? true,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resume an AudioContext without waiting on it forever. Outside a user gesture iOS Safari leaves resume()'s
|
|
63
|
+
* promise pending, which stalled the whole call start; the context starts on its own once capture is live,
|
|
64
|
+
* and playback resumes it again anyway.
|
|
65
|
+
*/
|
|
66
|
+
async function resumeWithin(ctx: AudioContext, ms: number): Promise<void> {
|
|
67
|
+
if (ctx.state === 'running') return;
|
|
68
|
+
await Promise.race([
|
|
69
|
+
ctx.resume().catch(() => {}),
|
|
70
|
+
new Promise<void>((r) => setTimeout(r, ms)),
|
|
71
|
+
]);
|
|
72
|
+
}
|
|
73
|
+
|
|
22
74
|
/**
|
|
23
75
|
* Analyser configuration for audio visualization
|
|
24
76
|
*/
|
|
@@ -55,6 +107,7 @@ export class BrowserAudioManager {
|
|
|
55
107
|
private echoCancellation: boolean;
|
|
56
108
|
private noiseSuppression: boolean;
|
|
57
109
|
private keepAwake: boolean;
|
|
110
|
+
private providedMicrophone: MediaStream | Promise<MediaStream> | null;
|
|
58
111
|
|
|
59
112
|
// Screen wake lock, held while the microphone is live. Browsers release it whenever the page is
|
|
60
113
|
// hidden, so it is taken again when the page becomes visible.
|
|
@@ -76,6 +129,9 @@ export class BrowserAudioManager {
|
|
|
76
129
|
this.echoCancellation = config.echoCancellation ?? true;
|
|
77
130
|
this.noiseSuppression = config.noiseSuppression ?? true;
|
|
78
131
|
this.keepAwake = config.keepAwake ?? true;
|
|
132
|
+
this.providedMicrophone = config.microphone ?? null;
|
|
133
|
+
// Never an unhandled rejection: startMicrophone reports it.
|
|
134
|
+
if (this.providedMicrophone instanceof Promise) this.providedMicrophone.catch(() => {});
|
|
79
135
|
this.onInputError = config.onInputError;
|
|
80
136
|
}
|
|
81
137
|
|
|
@@ -98,9 +154,8 @@ export class BrowserAudioManager {
|
|
|
98
154
|
throw new Error('Failed to initialize AudioContext');
|
|
99
155
|
}
|
|
100
156
|
|
|
101
|
-
if (this.audioContext.state
|
|
102
|
-
await this.audioContext
|
|
103
|
-
console.log('👂 AudioContext resumed');
|
|
157
|
+
if (this.audioContext.state !== 'running') {
|
|
158
|
+
await resumeWithin(this.audioContext, 500);
|
|
104
159
|
}
|
|
105
160
|
|
|
106
161
|
// Setup analyser for visualization if enabled
|
|
@@ -132,14 +187,20 @@ export class BrowserAudioManager {
|
|
|
132
187
|
this.onAudioInput = onAudioInput;
|
|
133
188
|
this.isListening = true;
|
|
134
189
|
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
190
|
+
// The page's own request (made inside the tap), when it has one and its track is still live;
|
|
191
|
+
// otherwise ask now.
|
|
192
|
+
const provided = this.providedMicrophone;
|
|
193
|
+
this.providedMicrophone = null;
|
|
194
|
+
const stream = provided ? await provided : null;
|
|
195
|
+
this.mediaStream = stream && stream.getAudioTracks().some((t) => t.readyState === 'live')
|
|
196
|
+
? stream
|
|
197
|
+
: await navigator.mediaDevices.getUserMedia({
|
|
198
|
+
audio: {
|
|
199
|
+
autoGainControl: this.autoGainControl,
|
|
200
|
+
echoCancellation: this.echoCancellation,
|
|
201
|
+
noiseSuppression: this.noiseSuppression,
|
|
202
|
+
},
|
|
203
|
+
});
|
|
143
204
|
|
|
144
205
|
// Create source from microphone stream
|
|
145
206
|
this.mediaStreamAudioSourceNode =
|
|
@@ -190,6 +251,7 @@ export class BrowserAudioManager {
|
|
|
190
251
|
console.log('🎤 Microphone started');
|
|
191
252
|
this.holdScreenAwake();
|
|
192
253
|
} catch (error) {
|
|
254
|
+
this.isListening = false;
|
|
193
255
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
194
256
|
if (this.onInputError) this.onInputError(err);
|
|
195
257
|
throw err;
|
|
@@ -478,8 +540,15 @@ export class BrowserAudioManager {
|
|
|
478
540
|
this.analyserNode = null;
|
|
479
541
|
}
|
|
480
542
|
|
|
481
|
-
//
|
|
482
|
-
//
|
|
543
|
+
// Closed, not left open: a page makes a new manager per call, so every open context was one more
|
|
544
|
+
// left running for the life of the page (iOS Safari showed five at the start of a second call, which
|
|
545
|
+
// then hung). init() makes a fresh one if this manager is used again.
|
|
546
|
+
if (this.audioContext) {
|
|
547
|
+
const ctx = this.audioContext;
|
|
548
|
+
this.audioContext = null;
|
|
549
|
+
this.nextPlaybackTime = 0;
|
|
550
|
+
ctx.close().catch(() => {});
|
|
551
|
+
}
|
|
483
552
|
}
|
|
484
553
|
|
|
485
554
|
/**
|
package/src/client.ts
CHANGED
|
@@ -314,11 +314,29 @@ export class VoiceAgentClient {
|
|
|
314
314
|
this.sendConfig();
|
|
315
315
|
|
|
316
316
|
if (this.audioManager && !this.micStarted) {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
this.
|
|
320
|
-
|
|
321
|
-
|
|
317
|
+
try {
|
|
318
|
+
await this.audioManager.startMicrophone((data) => {
|
|
319
|
+
if (this.isConnected) {
|
|
320
|
+
this.sendAudio(data);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
} catch (e) {
|
|
324
|
+
// No microphone, no call. Uncaught here, this left connect() pending forever while the session
|
|
325
|
+
// it had opened went on talking: the caller heard the agent, nothing they said reached it, and
|
|
326
|
+
// the page never learned why (iOS refusing capture with no prompt, 2026-09-28).
|
|
327
|
+
const name = (e as { name?: string } | null)?.name || 'Error';
|
|
328
|
+
const error = new LokutorError('audio.microphone_unavailable',
|
|
329
|
+
name === 'NotAllowedError' || name === 'SecurityError'
|
|
330
|
+
? 'Microphone access was not allowed.'
|
|
331
|
+
: 'The microphone could not be started.',
|
|
332
|
+
{ detail: `${name}: ${(e as Error)?.message ?? e}`, original: e, retryable: false });
|
|
333
|
+
sdkTrace('mic.failed', { name });
|
|
334
|
+
settle(() => reject(error));
|
|
335
|
+
this.isUserDisconnect = true;
|
|
336
|
+
try { this.ws?.close(); } catch (_) { /* already closing */ }
|
|
337
|
+
this.emit('error', error); // reaches onError too
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
322
340
|
this.micStarted = true;
|
|
323
341
|
}
|
|
324
342
|
|
|
@@ -600,8 +618,7 @@ export class VoiceAgentClient {
|
|
|
600
618
|
detail: backendDetail,
|
|
601
619
|
retryable: backendRetryable,
|
|
602
620
|
});
|
|
603
|
-
|
|
604
|
-
this.emit('error', error);
|
|
621
|
+
this.emit('error', error); // reaches onError too (calling it here as well delivered every server error twice)
|
|
605
622
|
console.error(`❌ Server error: [${error.code}] ${error.message}`);
|
|
606
623
|
break;
|
|
607
624
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { requestMicrophone } from './browser-audio';
|
|
2
|
+
|
|
1
3
|
const PANEL_CSS = /*css*/ `
|
|
2
4
|
.cv-panel {
|
|
3
5
|
position: relative;
|
|
@@ -349,7 +351,11 @@ export interface ConversationalPanelConfig {
|
|
|
349
351
|
backgroundColor?: string;
|
|
350
352
|
/** API key for connecting to the voice agent */
|
|
351
353
|
apiKey: string;
|
|
352
|
-
/**
|
|
354
|
+
/**
|
|
355
|
+
* Reference to the ConvoAgent class. Its constructor options include `microphone`, a
|
|
356
|
+
* Promise<MediaStream> requested inside the Start tap (see requestMicrophone); pass it on to
|
|
357
|
+
* BrowserAudioManager as `microphone` so the call uses it instead of asking again later.
|
|
358
|
+
*/
|
|
353
359
|
ConvoAgent: any;
|
|
354
360
|
/** Reference to the SphereVisualizer class */
|
|
355
361
|
SphereVisualizer: any;
|
|
@@ -365,6 +371,20 @@ export interface ConversationalPanelConfig {
|
|
|
365
371
|
silenceTimeout?: number;
|
|
366
372
|
}
|
|
367
373
|
|
|
374
|
+
/** What to tell the caller when the microphone did not start: a DOMException from getUserMedia, or the
|
|
375
|
+
* SDK's 'audio.microphone_unavailable' error wrapping one. */
|
|
376
|
+
export function microphoneMessage(err: any): string {
|
|
377
|
+
const name = String(err?.original?.name ?? err?.name ?? '');
|
|
378
|
+
const detail = String(err?.detail ?? err?.message ?? '');
|
|
379
|
+
if (/NotAllowedError|SecurityError/.test(name + ' ' + detail)) {
|
|
380
|
+
return 'Microphone access is blocked. Allow it for this site in your browser settings, then try again.';
|
|
381
|
+
}
|
|
382
|
+
if (/NotFoundError|OverconstrainedError/.test(name + ' ' + detail)) {
|
|
383
|
+
return 'No microphone was found on this device.';
|
|
384
|
+
}
|
|
385
|
+
return 'The microphone could not start. Please try again.';
|
|
386
|
+
}
|
|
387
|
+
|
|
368
388
|
export class ConversationalPanel {
|
|
369
389
|
private cfg: ConversationalPanelConfig;
|
|
370
390
|
private container: HTMLElement;
|
|
@@ -394,6 +414,8 @@ export class ConversationalPanel {
|
|
|
394
414
|
private countdownEl!: HTMLElement;
|
|
395
415
|
private noteEl!: HTMLElement;
|
|
396
416
|
private countdownTicker: number | null = null;
|
|
417
|
+
private microphone: Promise<MediaStream> | null = null;
|
|
418
|
+
private micError: unknown = null;
|
|
397
419
|
|
|
398
420
|
// Callbacks
|
|
399
421
|
onTranscription?: (text: string) => void;
|
|
@@ -507,14 +529,25 @@ export class ConversationalPanel {
|
|
|
507
529
|
return d.innerHTML;
|
|
508
530
|
}
|
|
509
531
|
|
|
510
|
-
/**
|
|
511
|
-
|
|
512
|
-
|
|
532
|
+
/**
|
|
533
|
+
* Start the conversation (called when user clicks "Start Conversation" or externally). Call it from
|
|
534
|
+
* inside the click or tap, before any await: it asks for the microphone there. Resolves true once the
|
|
535
|
+
* call is live, false when it could not start (the reason is shown, and passed to onError).
|
|
536
|
+
*/
|
|
537
|
+
async start(): Promise<boolean> {
|
|
538
|
+
if (this.isRunning) return false;
|
|
513
539
|
if (this._locked) {
|
|
514
540
|
this.showError('This session has ended. Refresh the page to start a new one.');
|
|
515
|
-
return;
|
|
541
|
+
return false;
|
|
516
542
|
}
|
|
517
543
|
this.isRunning = true;
|
|
544
|
+
// First, while this is still the tap: iOS Safari only reliably shows its microphone prompt for a request
|
|
545
|
+
// made inside a user gesture, and refuses capture with no prompt at all while the page's audio session
|
|
546
|
+
// is in playback mode. Asked for later, after the connection opened, the call went live with the agent
|
|
547
|
+
// talking and the caller's microphone never captured (2026-09-28).
|
|
548
|
+
this.microphone = requestMicrophone();
|
|
549
|
+
this.micError = null;
|
|
550
|
+
this.microphone.catch((e) => { this.micError = e; });
|
|
518
551
|
this.startBtn.disabled = true;
|
|
519
552
|
this.errorEl.classList.remove('is-visible');
|
|
520
553
|
this.playConnectingSound();
|
|
@@ -577,10 +610,13 @@ export class ConversationalPanel {
|
|
|
577
610
|
this.onError?.(err);
|
|
578
611
|
// The server's own words when it ended the session on purpose (e.g. "Demo time limit
|
|
579
612
|
// reached..."); a generic line for anything else.
|
|
580
|
-
this.end(err?.code === '
|
|
581
|
-
? err
|
|
582
|
-
: '
|
|
583
|
-
|
|
613
|
+
this.end(err?.code === 'audio.microphone_unavailable'
|
|
614
|
+
? microphoneMessage(err)
|
|
615
|
+
: err?.code === 'session.ended' && err?.message
|
|
616
|
+
? err.message
|
|
617
|
+
: 'Connection issue. Try again in a moment.');
|
|
618
|
+
},
|
|
619
|
+
microphone: this.microphone,
|
|
584
620
|
});
|
|
585
621
|
|
|
586
622
|
this.agent = agentHandle;
|
|
@@ -591,6 +627,8 @@ export class ConversationalPanel {
|
|
|
591
627
|
try { agentHandle.unlockAudioForMobile?.(); } catch (_) { /* ignore */ }
|
|
592
628
|
|
|
593
629
|
const ok = await agentHandle.connect();
|
|
630
|
+
// Ended while connecting (the microphone was refused, the server closed): the reason is already shown.
|
|
631
|
+
if (!this.isRunning) return false;
|
|
594
632
|
if (ok) {
|
|
595
633
|
this.fadeOutConnectingSound();
|
|
596
634
|
// Resize canvas to container
|
|
@@ -605,19 +643,22 @@ export class ConversationalPanel {
|
|
|
605
643
|
this.startTimer();
|
|
606
644
|
this.onStart?.();
|
|
607
645
|
if ((window as any).lucide) (window as any).lucide.createIcons();
|
|
608
|
-
|
|
609
|
-
this.fadeOutConnectingSound();
|
|
610
|
-
this.playErrorTone();
|
|
611
|
-
this.curtain.classList.remove('is-up');
|
|
612
|
-
this.showError('Could not connect. Please try again.');
|
|
613
|
-
this.isRunning = false;
|
|
646
|
+
return true;
|
|
614
647
|
}
|
|
648
|
+
this.fadeOutConnectingSound();
|
|
649
|
+
this.playErrorTone();
|
|
650
|
+
// The microphone's own failure when that is what stopped the call, not a generic connection line.
|
|
651
|
+
const micError = this.micError;
|
|
652
|
+
this.stop();
|
|
653
|
+
this.showError(micError ? microphoneMessage(micError) : 'Could not connect. Please try again.');
|
|
654
|
+
return false;
|
|
615
655
|
} catch (err: any) {
|
|
616
656
|
this.fadeOutConnectingSound();
|
|
617
657
|
this.playErrorTone();
|
|
618
658
|
console.error('ConversationalPanel start error:', err);
|
|
619
659
|
this.stop();
|
|
620
660
|
this.showError('Something went wrong. Please try again.');
|
|
661
|
+
return false;
|
|
621
662
|
} finally {
|
|
622
663
|
this.startBtn.disabled = false;
|
|
623
664
|
}
|
|
@@ -633,6 +674,11 @@ export class ConversationalPanel {
|
|
|
633
674
|
try { this.agent.disconnect(); } catch (_) {}
|
|
634
675
|
this.agent = null;
|
|
635
676
|
}
|
|
677
|
+
// Release the stream asked for at Start even if the agent never took it over (a ConvoAgent that
|
|
678
|
+
// ignores `microphone` asks again itself); stopping tracks the agent already stopped is harmless.
|
|
679
|
+
const mic = this.microphone;
|
|
680
|
+
this.microphone = null;
|
|
681
|
+
mic?.then((s) => s.getTracks().forEach((t) => t.stop()), () => {});
|
|
636
682
|
if (this.timerTicker) { clearInterval(this.timerTicker); this.timerTicker = null; }
|
|
637
683
|
this.stopCountdown();
|
|
638
684
|
this.setNote('');
|
package/src/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ export {
|
|
|
14
14
|
} from './client';
|
|
15
15
|
export { BrowserAudioManager } from './browser-audio';
|
|
16
16
|
export { NodeAudioManager } from './node-audio';
|
|
17
|
-
export { ConversationalPanel } from './conversational-panel';
|
|
17
|
+
export { ConversationalPanel, microphoneMessage } from './conversational-panel';
|
|
18
18
|
export type { ConversationalPanelConfig } from './conversational-panel';
|
|
19
19
|
export {
|
|
20
20
|
pcm16ToFloat32,
|
package/src/types.ts
CHANGED
|
@@ -339,6 +339,7 @@ export type ErrorCode =
|
|
|
339
339
|
| 'agent.session_failed'
|
|
340
340
|
| 'agent.provider_error'
|
|
341
341
|
| 'session.ended'
|
|
342
|
+
| 'audio.microphone_unavailable'
|
|
342
343
|
| 'internal.error'
|
|
343
344
|
| 'internal.timeout'
|
|
344
345
|
| 'internal.cancelled'
|
|
@@ -371,6 +372,7 @@ function isRetryableCode(code: ErrorCode): boolean {
|
|
|
371
372
|
'auth.invalid_key',
|
|
372
373
|
'auth.time_limited',
|
|
373
374
|
'session.ended',
|
|
375
|
+
'audio.microphone_unavailable',
|
|
374
376
|
'validation.invalid_voice',
|
|
375
377
|
'validation.invalid_language',
|
|
376
378
|
'validation.text_too_long',
|