@lokutor/sdk 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lokutor/sdk",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "JavaScript/TypeScript SDK for Lokutor Real-time Voice AI",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -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
  /**