@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/README.md +29 -2
- package/dist/index.d.mts +70 -25
- package/dist/index.d.ts +70 -25
- package/dist/index.js +267 -106
- package/dist/index.mjs +265 -106
- package/package.json +1 -1
- package/src/browser-audio.ts +82 -13
- package/src/client.ts +185 -99
- package/src/conversational-panel.ts +61 -15
- package/src/index.ts +1 -1
- package/src/types.ts +38 -17
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
|
/**
|