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