@lumavox/client 0.15.1 → 0.17.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 CHANGED
@@ -143,12 +143,37 @@ startButton.onclick = () => audio.prime(); // in the click: browsers start sound
143
143
  ```
144
144
 
145
145
  An `AgentAudio` on Web Audio, its own import so the client carries none of
146
- it (~5.5 KB minified): replies play gaplessly as they arrive, at the
146
+ it (~8.5 KB minified): replies play gaplessly as they arrive, at the
147
147
  device's own rate; the microphone records at 24 kHz, with the browser's
148
148
  echo cancellation, noise suppression and gain control. Hand it to
149
149
  `@lumavox/react`'s `useAgent`, or call `play`, `startCapture` and the rest
150
150
  yourself.
151
151
 
152
+ **The clock**, to draw a face or subtitles against the voice:
153
+
154
+ ```ts
155
+ const audio = createWebAudio({ leadInMs: 350 }); // time to read each frame before it plays
156
+ audio.onScheduled(({ pcm, sample_rate, startsAt }) => lipsync.add(pcm, sample_rate, startsAt));
157
+ audio.onStopped(() => lipsync.clear()); // a cancel, a mute, a barge-in
158
+ // each animation frame: lipsync.at(audio.heardTime())
159
+ ```
160
+
161
+ - `onScheduled`: every frame as it is queued, its 16-bit samples and when
162
+ they start on `now()`'s clock. `heardTime()`: what is heard at this
163
+ moment, the clock less the browser's output latency (Bluetooth adds to
164
+ it).
165
+ - `leadInMs`: a reply's first frame, or one after a gap, waits that long —
166
+ 60 ms unless set; it adds as much to the first word.
167
+ - `onStopped`: a `stop()` that dropped audio still to play. `onCapture((pcm,
168
+ rate) => …)`: every frame the microphone captures, for a level meter.
169
+
170
+ **The devices**: `inputs()` and `setInput(id)` for the microphone (one
171
+ unplugged falls back to the default; labels are empty until it was
172
+ allowed), `setProcessing({ echoCancellation, noiseSuppression,
173
+ autoGainControl })` — each on unless `false`, and hands-free refused with
174
+ echo cancellation off — and, where `canChooseOutput` (Chrome and Edge),
175
+ `outputs()` and `setOutputDevice(id)` for the speaker.
176
+
152
177
  ### Hands-free
153
178
 
154
179
  ```ts
@@ -166,6 +191,13 @@ allows it where the browser cancels echo (not on an iPhone or iPad);
166
191
  `assumeNoEcho` with headphones on. `loudness()` is the built-in detector,
167
192
  `detector` your own.
168
193
 
194
+ **Half-duplex**, where echo cannot be cancelled: `listen(conversation,
195
+ audio, { halfDuplex: true })` keeps the microphone open but sends nothing
196
+ while the agent's voice is heard — on the player's clock, then 300 ms more
197
+ (`afterVoiceMs`) for the room's echo — and talking never stops it, so the
198
+ agent never hears itself and it is never refused for echo. Something said
199
+ as the voice starts ends there as a turn, answered after the reply.
200
+
169
201
  ## Runtimes
170
202
 
171
203
  Browsers, React Native (Hermes, ES2020 — and each binary frame reaches JS
package/dist/audio.d.ts CHANGED
@@ -8,7 +8,11 @@
8
8
  * mute or a new turn, records at the rate the recorder was granted, and
9
9
  * throws away a recording the device interrupted. Hands-free — the
10
10
  * microphone left open, a detector saying when the person talks — is
11
- * `listen` (`./hands-free.ts`), on the two optional methods below.
11
+ * `listen` (`./hands-free.ts`), on the two optional methods below. The
12
+ * clock, the frames as they are queued and captured, and the devices
13
+ * (`PlaybackClock`, `DeviceChoices`) are the two adapters' own, for the
14
+ * app: nothing that drives an `AgentAudio` needs them, so an adapter of an
15
+ * app's own need not have them.
12
16
  */
13
17
  export interface AgentAudio {
14
18
  /** Queue one frame of a reply: 16-bit little-endian PCM, in wire order. */
@@ -30,6 +34,12 @@ export interface AgentAudio {
30
34
  */
31
35
  startCapture(onPcm: (pcm: ArrayBuffer) => void, options?: {
32
36
  rate?: number;
37
+ /**
38
+ * Hands-free asks it, once `handsFree` said yes: take the agent's
39
+ * voice out of the microphone — a phone's voice processing and call
40
+ * audio. An adapter that always does, as a browser's, ignores it.
41
+ */
42
+ echoCancellation?: boolean;
33
43
  }): Promise<{
34
44
  rate: number;
35
45
  }>;
@@ -62,3 +72,86 @@ export interface AgentAudio {
62
72
  /** Optional: play the reply quieter, or as before — barge-in's first step, on sound alone. */
63
73
  duck?(on: boolean): void;
64
74
  }
75
+ /** One frame of a reply as the player queued it: what a face or subtitles are drawn against. */
76
+ export interface ScheduledAudio {
77
+ /** The 16-bit little-endian samples queued: a sample split across two frames already joined. */
78
+ pcm: ArrayBuffer;
79
+ sample_rate: number;
80
+ channels: number;
81
+ /** As the socket sent it: the text chunk this is the voice of, in its turn. */
82
+ chunk: number;
83
+ turn: string;
84
+ /** When it starts to play, in seconds on `now()`'s clock. */
85
+ startsAt: number;
86
+ /** Seconds. */
87
+ duration: number;
88
+ }
89
+ /**
90
+ * The player's clock and what passes through it, for an app that draws
91
+ * against the voice — a face lip-synced, subtitles revealed chunk by chunk
92
+ * — or against the microphone, a level meter. `@lumavox/client/web-audio`'s
93
+ * and `@lumavox/react-native`'s audio have it; `useAgent` needs none of it.
94
+ */
95
+ export interface PlaybackClock {
96
+ /** The player's clock, in seconds: its audio context's. `0` before `prime()`. */
97
+ now(): number;
98
+ /**
99
+ * What the listener hears at this moment, on `now()`'s clock: `now()`
100
+ * less the time the sound takes to leave the device, which Bluetooth
101
+ * makes longer. Never negative.
102
+ */
103
+ heardTime(): number;
104
+ /**
105
+ * Every frame as it is queued, before it plays: its samples and when
106
+ * they start. A handler that throws is rethrown on its own. Answers the
107
+ * unsubscribe.
108
+ */
109
+ onScheduled(fn: (audio: ScheduledAudio) => void): () => void;
110
+ /**
111
+ * When `stop()` drops audio still to play — a cancel, a mute, a
112
+ * barge-in, a new turn. `onDrained` fires then too, and when a reply
113
+ * plays out. Answers the unsubscribe.
114
+ */
115
+ onStopped(fn: () => void): () => void;
116
+ /**
117
+ * Every frame the microphone captures while it is open — what
118
+ * `startCapture` hands on, and what hands-free does not send — as 16-bit
119
+ * PCM at `rate`. Answers the unsubscribe.
120
+ */
121
+ onCapture(fn: (pcm: ArrayBuffer, rate: number) => void): () => void;
122
+ }
123
+ /** A microphone or a speaker, as the browser lists it. */
124
+ export interface AudioDevice {
125
+ id: string;
126
+ /** Empty until the person has allowed the microphone. */
127
+ label: string;
128
+ }
129
+ /** The browser's own cleanup of the microphone: each on unless `false`. */
130
+ export interface Processing {
131
+ /** Off, hands-free is refused: the agent would hear itself. */
132
+ echoCancellation?: boolean;
133
+ noiseSuppression?: boolean;
134
+ autoGainControl?: boolean;
135
+ }
136
+ /**
137
+ * The microphone and the speaker a browser lets a page choose. On a phone
138
+ * the system chooses: no devices are listed and none can be set.
139
+ */
140
+ export interface DeviceChoices {
141
+ /** The microphones; labels are empty until the microphone was allowed. */
142
+ inputs(): Promise<AudioDevice[]>;
143
+ /** The microphone, by its id, from the next capture; none, the default. One unplugged since falls back to the default. */
144
+ setInput(id?: string): void;
145
+ /** The browser's processing from the next capture: what is left out is on. */
146
+ setProcessing(processing: Processing): void;
147
+ /** The speakers a page can play through; `[]` where it cannot choose. */
148
+ outputs(): Promise<AudioDevice[]>;
149
+ /**
150
+ * Play through the speaker `id`, kept for a player made later. Answers
151
+ * whether it took: `false` where the browser cannot choose, or the
152
+ * speaker is gone.
153
+ */
154
+ setOutputDevice(id: string): Promise<boolean>;
155
+ /** Whether this browser lets a page choose its speaker: Chrome and Edge. */
156
+ readonly canChooseOutput: boolean;
157
+ }
@@ -25,10 +25,36 @@
25
25
  * cancellation leaves of its voice is louder than a quiet room.
26
26
  * - **Refused where the agent would hear itself** (D16): an adapter that
27
27
  * says so, or cannot say (`AgentAudio.handsFree`) unless the app knows.
28
+ * On its yes the recorder is asked to cancel the echo
29
+ * (`startCapture`'s `echoCancellation`): a phone's is a mode of its own —
30
+ * the call's audio, quieter — never wanted with headphones on or in
31
+ * half-duplex.
32
+ * - **Half-duplex** (D19, `halfDuplex`), where there is no echo to cancel —
33
+ * a phone on `react-native-audio-api` 0.13, a browser that cannot cancel
34
+ * it: the microphone stays open, but **while the agent's voice is heard
35
+ * nothing is sent, the detector rests and there is no barge-in**, so the
36
+ * agent never hears itself and `handsFree()` is not asked. Heard is the
37
+ * player's clock (`PlaybackClock`: each frame's `startsAt` and
38
+ * `duration`, against `heardTime()`), never when frames are queued: a
39
+ * phone queues a reply seconds ahead of its sound. An adapter with no
40
+ * clock is taken at its word, from an `audio.start` to `onDrained`.
41
+ * **The voice has the floor**: an utterance open when it starts ends
42
+ * there as a turn — what was said before it is kept, and answered after
43
+ * the reply. **Then `afterVoiceMs`, 300 ms, before the detector listens
44
+ * again**: what the clock cannot know of a phone's sound path (0.13 says
45
+ * none; most phones' loudspeakers take 20 to 100 ms), the microphone's own
46
+ * latency (10 to 50 ms), and the room's echo falling 30 dB (half of a
47
+ * furnished room's 0.3 to 0.6 s reverberation) — else the agent's last
48
+ * syllable, ringing on, opens a recording of itself. It costs no reply
49
+ * any time: those frames are kept as pre-roll, so words begun in it keep
50
+ * their start; only a word over before it ends — a quick "yes" under
51
+ * 0.3 s — goes unheard.
28
52
  *
29
53
  * Time here is audio time, counted in 20 ms frames, never the clock's: a
30
54
  * busy thread delivers late, and the frames still say how long the
31
- * silence was.
55
+ * silence was. Half-duplex alone reads the player's clock, to set each
56
+ * frame against the voice: the last sample delivered is heard now, and
57
+ * each before it a sample earlier.
32
58
  */
33
59
  import type { AgentAudio } from "./audio.js";
34
60
  import type { Conversation, TurnOptions } from "./conversation.js";
@@ -88,6 +114,19 @@ export interface ListenOptions extends TurnOptions {
88
114
  wordsWithinMs?: number;
89
115
  /** The app knows the agent's voice stays out of the microphone — headphones, say — though the adapter cannot tell. */
90
116
  assumeNoEcho?: boolean;
117
+ /**
118
+ * Half-duplex: the microphone stays open, but nothing is sent while the
119
+ * agent's voice is heard, and talking never stops it — for where echo
120
+ * cannot be cancelled, such as a phone, so `handsFree()` is not asked.
121
+ */
122
+ halfDuplex?: boolean;
123
+ /**
124
+ * Half-duplex: after the agent's voice is heard ending, how long the
125
+ * detector still waits, ms: 300 — so the room's echo of its last word
126
+ * does not open a recording. Words begun in it keep their start; one
127
+ * over before it ends is not heard. Lower with headphones on.
128
+ */
129
+ afterVoiceMs?: number;
91
130
  /** `listening` for the person, `hearing` them, `paused` while the device has the audio, then `stopped`. */
92
131
  onState?(state: ListeningState): void;
93
132
  /** The person talked over the agent: its voice was stopped. */
@@ -107,12 +146,13 @@ export type ListenOutcome = {
107
146
  reason: string;
108
147
  };
109
148
  /** Why `listen` refuses an adapter that cannot say whether the agent hears itself. */
110
- export declare const NO_ECHO_ANSWER = "This audio adapter cannot say whether the agent's voice stays out of the microphone (`AgentAudio.handsFree`). Use push-to-talk, or pass `assumeNoEcho` if you know it does.";
149
+ export declare const NO_ECHO_ANSWER = "This audio adapter cannot say whether the agent's voice stays out of the microphone (`AgentAudio.handsFree`). Use half-duplex (`halfDuplex`) or push-to-talk, or pass `assumeNoEcho` if you know it does.";
111
150
  /**
112
151
  * Leave the microphone open on `conversation`: every utterance the
113
- * detector hears becomes a turn, and talking over the agent stops it.
114
- * Answers `ok: false` with the reason, in words an app can show, where the
115
- * agent would hear itself or the microphone would not open — push-to-talk
116
- * still works there.
152
+ * detector hears becomes a turn, and talking over the agent stops it — or,
153
+ * with `halfDuplex`, nothing is heard while the agent speaks. Answers
154
+ * `ok: false` with the reason, in words an app can show, where the agent
155
+ * would hear itself or the microphone would not open — half-duplex and
156
+ * push-to-talk still work there.
117
157
  */
118
158
  export declare function listen(conversation: Conversation, audio: AgentAudio, options?: ListenOptions): Promise<ListenOutcome>;
@@ -40,16 +40,17 @@ export function loudness(options = {}) {
40
40
  };
41
41
  }
42
42
  /** Why `listen` refuses an adapter that cannot say whether the agent hears itself. */
43
- export const NO_ECHO_ANSWER = "This audio adapter cannot say whether the agent's voice stays out of the microphone (`AgentAudio.handsFree`). Use push-to-talk, or pass `assumeNoEcho` if you know it does.";
43
+ export const NO_ECHO_ANSWER = "This audio adapter cannot say whether the agent's voice stays out of the microphone (`AgentAudio.handsFree`). Use half-duplex (`halfDuplex`) or push-to-talk, or pass `assumeNoEcho` if you know it does.";
44
44
  /**
45
45
  * Leave the microphone open on `conversation`: every utterance the
46
- * detector hears becomes a turn, and talking over the agent stops it.
47
- * Answers `ok: false` with the reason, in words an app can show, where the
48
- * agent would hear itself or the microphone would not open — push-to-talk
49
- * still works there.
46
+ * detector hears becomes a turn, and talking over the agent stops it — or,
47
+ * with `halfDuplex`, nothing is heard while the agent speaks. Answers
48
+ * `ok: false` with the reason, in words an app can show, where the agent
49
+ * would hear itself or the microphone would not open — half-duplex and
50
+ * push-to-talk still work there.
50
51
  */
51
52
  export async function listen(conversation, audio, options = {}) {
52
- if (!options.assumeNoEcho) {
53
+ if (!options.assumeNoEcho && !options.halfDuplex) {
53
54
  const echo = audio.handsFree
54
55
  ? await audio.handsFree()
55
56
  : { ok: false, reason: NO_ECHO_ANSWER };
@@ -80,6 +81,12 @@ class HandsFree {
80
81
  this.playingTurn = null;
81
82
  /** The reply a barge-in stopped: its late windows are not the agent speaking. */
82
83
  this.stoppedTurn = null;
84
+ /** Half-duplex, on that clock: the agent's voice as scheduled, in runs of frames back to back. */
85
+ this.voice = [];
86
+ /** The clock as last read, to see it go back. */
87
+ this.read = 0;
88
+ /** Half-duplex with no clock: audio time since the voice was last playing. */
89
+ this.sinceVoiceMs = Number.POSITIVE_INFINITY;
83
90
  this.off = [];
84
91
  this.conversation = conversation;
85
92
  this.audio = audio;
@@ -89,12 +96,18 @@ class HandsFree {
89
96
  this.startAfterMs = options.startAfterMs ?? 100;
90
97
  this.wordsWithinMs = options.wordsWithinMs ?? 3000;
91
98
  this.ringFrames = Math.ceil(((options.preRollMs ?? 300) + this.startAfterMs) / FRAME_MS);
99
+ this.halfDuplex = options.halfDuplex === true;
100
+ this.afterVoiceMs = options.afterVoiceMs ?? 300;
101
+ this.clock = this.halfDuplex ? clockOf(audio) : null;
92
102
  }
93
103
  /** The microphone, and everything watched: `null`, or why it did not open. */
94
104
  async start() {
95
105
  this.off.push(this.conversation.onFrame((frame) => this.frame(frame)), this.audio.onDrained(() => {
96
106
  this.playing = false;
97
107
  }));
108
+ if (this.clock) {
109
+ this.off.push(this.clock.onScheduled((audio) => this.scheduled(audio)), this.clock.onStopped(() => this.cut()));
110
+ }
98
111
  const interrupted = this.audio.onInterruption?.((now) => this.interrupted(now));
99
112
  if (interrupted)
100
113
  this.off.push(interrupted);
@@ -119,7 +132,14 @@ class HandsFree {
119
132
  async capture() {
120
133
  let granted;
121
134
  try {
122
- ({ rate: granted } = await this.audio.startCapture((pcm) => this.pcm(pcm), { rate: this.options.rate ?? 16000 }));
135
+ ({ rate: granted } = await this.audio.startCapture((pcm) => this.pcm(pcm), {
136
+ rate: this.options.rate ?? 16000,
137
+ // On the adapter's word that it can: half-duplex and the app's
138
+ // own word need none, and a phone's sounds better without.
139
+ ...(this.options.halfDuplex || this.options.assumeNoEcho
140
+ ? {}
141
+ : { echoCancellation: true }),
142
+ }));
123
143
  }
124
144
  catch (failure) {
125
145
  // swallowed: it is the answer's reason, the app's to show.
@@ -170,11 +190,16 @@ class HandsFree {
170
190
  void this.capture().then((failed) => {
171
191
  if (this.state !== "paused")
172
192
  return;
173
- // Still taken, or gone: stopped, and the app's next `listen` tries again.
174
- if (failed === null)
193
+ if (failed === null) {
175
194
  this.set("listening");
176
- else
177
- this.close();
195
+ return;
196
+ }
197
+ // Still taken, or gone: stopped, and the app's next `listen` tries
198
+ // again. Given back, so what the capture set up is undone.
199
+ this.close();
200
+ this.audio.stopCapture().catch(() => {
201
+ // swallowed: nothing was open; the adapter has let go either way.
202
+ });
178
203
  });
179
204
  }
180
205
  pcm(buffer) {
@@ -187,14 +212,25 @@ class HandsFree {
187
212
  samples.set(this.carry, 0);
188
213
  samples.set(fresh, this.carry.length);
189
214
  }
215
+ // Half-duplex: the last sample is heard now, on the player's clock.
216
+ const heard = this.clock ? this.heardNow() : 0;
190
217
  let at = 0;
191
218
  for (; at + this.frameSamples <= samples.length; at += this.frameSamples) {
192
- this.step(samples.slice(at, at + this.frameSamples));
219
+ const end = at + this.frameSamples;
220
+ this.step(samples.slice(at, end), heard - (samples.length - end) / this.rate);
193
221
  }
194
222
  this.carry = samples.slice(at);
195
223
  }
196
- step(frame) {
197
- const voice = this.detector.push(frame, this.rate, this.playing);
224
+ /** One 20 ms frame; `endsAt`, half-duplex on a clock, when its last sample was heard. */
225
+ step(frame, endsAt) {
226
+ if (this.halfDuplex) {
227
+ const ago = this.voiceAgo(endsAt);
228
+ if (ago === 0 || ago < this.afterVoiceMs) {
229
+ this.voiceHeard(ago === 0 ? null : frame);
230
+ return;
231
+ }
232
+ }
233
+ const voice = this.detector.push(frame, this.rate, this.playing && !this.halfDuplex);
198
234
  const talking = voice === "talking";
199
235
  if (talking) {
200
236
  this.talkMs += FRAME_MS;
@@ -206,9 +242,7 @@ class HandsFree {
206
242
  }
207
243
  const open = this.open;
208
244
  if (!open) {
209
- this.ring.push(frame);
210
- if (this.ring.length > this.ringFrames)
211
- this.ring.shift();
245
+ this.keep(frame);
212
246
  if (this.waitForQuiet && this.quietMs >= this.endAfterMs) {
213
247
  this.waitForQuiet = false;
214
248
  }
@@ -228,12 +262,96 @@ class HandsFree {
228
262
  this.end();
229
263
  }
230
264
  }
265
+ /** A frame of the room kept for the next utterance's pre-roll. */
266
+ keep(frame) {
267
+ this.ring.push(frame);
268
+ if (this.ring.length > this.ringFrames)
269
+ this.ring.shift();
270
+ }
271
+ /**
272
+ * Half-duplex: ms between the agent's voice last heard and the frame
273
+ * ending at `endsAt` — 0 while it is heard, infinite before it ever was.
274
+ */
275
+ voiceAgo(endsAt) {
276
+ if (!this.clock) {
277
+ // Taken at the adapter's word: playing from `audio.start` to `onDrained`.
278
+ this.sinceVoiceMs = this.playing ? 0 : this.sinceVoiceMs + FRAME_MS;
279
+ return this.playing ? 0 : this.sinceVoiceMs;
280
+ }
281
+ const startsAt = endsAt - FRAME_MS / 1000;
282
+ let ago = Number.POSITIVE_INFINITY;
283
+ for (const run of this.voice) {
284
+ if (run.from < endsAt && run.until > startsAt)
285
+ return 0;
286
+ if (run.until <= startsAt)
287
+ ago = (startsAt - run.until) * 1000;
288
+ }
289
+ return ago;
290
+ }
291
+ /**
292
+ * Half-duplex: the agent's voice is heard, or its tail (`tail`, the
293
+ * frame). The voice has the floor — an utterance open ends as a turn
294
+ * before any of it is sent — and the detector rests; nothing of the
295
+ * voice is pre-roll, its tail is.
296
+ */
297
+ voiceHeard(tail) {
298
+ if (this.open)
299
+ this.end();
300
+ this.talkMs = 0;
301
+ this.quietMs = 0;
302
+ if (tail)
303
+ this.keep(tail);
304
+ else
305
+ this.ring = [];
306
+ }
307
+ /** The clock now; one that went back — a new context, the phone's for a reply at another rate — leaves the runs kept on the old one. */
308
+ now() {
309
+ const now = this.clock.now();
310
+ // The stop that came with it was told; the new run starts at once.
311
+ if (now < this.read)
312
+ this.voice = [];
313
+ this.read = now;
314
+ return now;
315
+ }
316
+ /** What is heard now on the clock, the runs long past forgotten. */
317
+ heardNow() {
318
+ this.now();
319
+ const heard = this.clock.heardTime();
320
+ const past = heard - (this.afterVoiceMs + 1000) / 1000;
321
+ while (this.voice.length > 1 && (this.voice[0]?.until ?? 0) < past) {
322
+ this.voice.shift();
323
+ }
324
+ return heard;
325
+ }
326
+ /** A frame of the reply, queued to start at `startsAt`: its run of the voice, or a new one after a gap. */
327
+ scheduled(audio) {
328
+ this.now();
329
+ const until = audio.startsAt + audio.duration;
330
+ const last = this.voice[this.voice.length - 1];
331
+ // Back to back with the one before, to a millisecond's rounding.
332
+ if (last &&
333
+ audio.startsAt >= last.from &&
334
+ audio.startsAt <= last.until + 0.001) {
335
+ last.until = Math.max(last.until, until);
336
+ }
337
+ else {
338
+ this.voice.push({ from: audio.startsAt, until });
339
+ }
340
+ }
341
+ /** The player dropped what it had queued: the voice ends where the clock is. */
342
+ cut() {
343
+ const now = this.now();
344
+ this.voice = this.voice.filter((run) => run.from < now);
345
+ for (const run of this.voice)
346
+ run.until = Math.min(run.until, now);
347
+ }
231
348
  begin() {
232
- const barging = this.playing;
349
+ // Half-duplex never opens over the voice: no barge-in to decide.
350
+ const barging = this.playing && !this.halfDuplex;
233
351
  const options = this.options;
234
352
  const id = this.conversation.startAudio({
235
353
  rate: this.rate,
236
- bargeIn: true,
354
+ bargeIn: !this.halfDuplex,
237
355
  ...(options.language ? { language: options.language } : {}),
238
356
  ...(options.model ? { model: options.model } : {}),
239
357
  ...(options.speech === undefined ? {} : { speech: options.speech }),
@@ -273,6 +391,16 @@ class HandsFree {
273
391
  this.options.onState?.(state);
274
392
  }
275
393
  }
394
+ /** The adapter's `PlaybackClock`, when it has one: both of this SDK's do; one of an app's own need not. */
395
+ function clockOf(audio) {
396
+ const clock = audio;
397
+ return typeof clock.now === "function" &&
398
+ typeof clock.heardTime === "function" &&
399
+ typeof clock.onScheduled === "function" &&
400
+ typeof clock.onStopped === "function"
401
+ ? audio
402
+ : null;
403
+ }
276
404
  /** 16-bit little-endian PCM as samples, on a big-endian device too. */
277
405
  function samplesOf(buffer) {
278
406
  const count = buffer.byteLength >> 1;
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * vocabulary (`src/protocol.gen.ts`); methods are camelCase, and the SDK's
8
8
  * own options go last.
9
9
  */
10
- export type { AgentAudio } from "./audio.js";
10
+ export type { AgentAudio, AudioDevice, DeviceChoices, PlaybackClock, Processing, ScheduledAudio, } from "./audio.js";
11
11
  export { type Client, type ClientOptions, createClient, DEFAULT_BASE_URL, type FactListParams, type Facts, type RelationListParams, type Relations, type SessionGetParams, type SessionListParams, type Sessions, } from "./client.js";
12
12
  export { type CloseInfo, type ConnectOptions, Conversation, type ConversationHandlers, type ConversationState, type Disconnect, type Dropped, READY_DEADLINE_MS, type Snapshot, type StartAudioOptions, type ToolHandler, type TranscriptOutcome, type TurnOptions, type TurnOutcome, type TurnSnapshot, } from "./conversation.js";
13
13
  export { AuthenticationError, ConflictError, ConnectionError, InsufficientCreditsError, NotFoundError, PermissionError, PlatformError, type PlatformErrorInit, RateLimitError, ServerError, TimeoutError, ValidationError, } from "./errors.js";
@@ -9,7 +9,7 @@
9
9
  * button.onclick = () => audio.prime(); // in the click: browsers start audio only from one
10
10
  * ```
11
11
  */
12
- import { WebAudio } from "./web-audio.js";
13
- export type { WebAudio };
12
+ import { WebAudio, type WebAudioOptions } from "./web-audio.js";
13
+ export type { WebAudio, WebAudioOptions };
14
14
  /** A player and a recorder for one conversation. Call `prime()` from the click that starts it. */
15
- export declare function createWebAudio(): WebAudio;
15
+ export declare function createWebAudio(options?: WebAudioOptions): WebAudio;
@@ -11,6 +11,6 @@
11
11
  */
12
12
  import { WebAudio } from "./web-audio.js";
13
13
  /** A player and a recorder for one conversation. Call `prime()` from the click that starts it. */
14
- export function createWebAudio() {
15
- return new WebAudio();
14
+ export function createWebAudio(options) {
15
+ return new WebAudio(options);
16
16
  }
@@ -18,7 +18,15 @@
18
18
  * in the past — otherwise it is scheduled to have already happened and is
19
19
  * simply dropped, which is heard as a missing sentence.
20
20
  * - **A tiny lead-in** (`SCHEDULE_AHEAD`) absorbs the jitter between a frame
21
- * arriving and it being decoded, at the cost of a few milliseconds.
21
+ * arriving and it being decoded, at the cost of a few milliseconds. An
22
+ * app that reads each frame before it plays — a lipsync model — sets a
23
+ * longer one (`leadIn`), and every reply's first word waits that long.
24
+ *
25
+ * **`push` answers when the frame starts**, on the context's clock, and the
26
+ * samples it queued: an app draws a face against them (`web-audio.ts`'s
27
+ * `onScheduled`). What is heard is that clock less the device's latency
28
+ * (`latency`), read live: Bluetooth, and a speaker chosen (`setSink`),
29
+ * change it.
22
30
  *
23
31
  * **The context runs at the device's own rate**; each buffer is made at the
24
32
  * stream's (24 kHz unless `audio.start` says otherwise) and the browser
@@ -37,6 +45,19 @@ export interface PcmPlayerOptions {
37
45
  channels?: number;
38
46
  /** Called when the queue drains — every scheduled source has finished. */
39
47
  onIdle?: () => void;
48
+ /** Seconds a run starts after it is pushed, once the queue ran dry: `SCHEDULE_AHEAD`. */
49
+ leadIn?: number;
50
+ /** The speaker the context plays through from its start (`setSink`). */
51
+ sinkId?: string;
52
+ }
53
+ /** A frame as `push` queued it. */
54
+ export interface Pushed {
55
+ /** The whole samples queued: a split one joined, a trailing byte held back. */
56
+ pcm: ArrayBuffer;
57
+ sample_rate: number;
58
+ /** On the context's clock, in seconds. */
59
+ startsAt: number;
60
+ duration: number;
40
61
  }
41
62
  export declare class PcmPlayer {
42
63
  private context;
@@ -54,7 +75,24 @@ export declare class PcmPlayer {
54
75
  private volume;
55
76
  private readonly channels;
56
77
  private readonly onIdle?;
78
+ private readonly leadIn;
79
+ private sinkId?;
57
80
  constructor(options?: PcmPlayerOptions);
81
+ /** The context's clock, in seconds; `0` before `resume`. */
82
+ get now(): number;
83
+ /**
84
+ * Seconds from a sample leaving the context to it being heard: the
85
+ * browser's own processing and the device's. Read each time, since a
86
+ * Bluetooth headset or a new speaker changes it; `0` where a browser does
87
+ * not say (Safari has no `outputLatency`).
88
+ */
89
+ get latency(): number;
90
+ /**
91
+ * Play through the speaker `id` from now on, and on a context made later.
92
+ * Answers whether it took: a speaker unplugged since it was listed, or
93
+ * one the page may not use, leaves the one before.
94
+ */
95
+ setSink(id: string): Promise<boolean>;
58
96
  /**
59
97
  * The rate of the PCM pushed from here on: each `audio.start` carries its
60
98
  * window's own, since an agent's voice may be edited mid-conversation.
@@ -79,22 +117,23 @@ export declare class PcmPlayer {
79
117
  /** Whether anything is scheduled and still to play. */
80
118
  get playing(): boolean;
81
119
  /**
82
- * Queue one blob of PCM.
120
+ * Queue one blob of PCM, and answer what was queued and when it starts —
121
+ * `null` when nothing was: a lone byte held for its other half.
83
122
  *
84
123
  * Ignored before {@link resume} — a caller that pushes audio into a player
85
124
  * nobody unlocked has a gesture problem, and buffering it would hide that
86
125
  * until the sound arrived minutes later in one burst.
87
126
  */
88
- push(pcm: ArrayBuffer): void;
127
+ push(pcm: ArrayBuffer): Pushed | null;
89
128
  /**
90
129
  * Stop everything now — barge-in, or the turn being cancelled.
91
130
  *
92
131
  * Disconnecting the gain node rather than stopping each source: sources are
93
132
  * fire-and-forget once scheduled and holding references to them purely to
94
133
  * stop them is a leak waiting to happen. A fresh node is built on the next
95
- * push.
134
+ * push. Answers whether audio still to play was dropped.
96
135
  */
97
- stop(): void;
136
+ stop(): boolean;
98
137
  /** Release the audio device. Safe to call more than once. */
99
138
  close(): Promise<void>;
100
139
  }
@@ -18,7 +18,15 @@
18
18
  * in the past — otherwise it is scheduled to have already happened and is
19
19
  * simply dropped, which is heard as a missing sentence.
20
20
  * - **A tiny lead-in** (`SCHEDULE_AHEAD`) absorbs the jitter between a frame
21
- * arriving and it being decoded, at the cost of a few milliseconds.
21
+ * arriving and it being decoded, at the cost of a few milliseconds. An
22
+ * app that reads each frame before it plays — a lipsync model — sets a
23
+ * longer one (`leadIn`), and every reply's first word waits that long.
24
+ *
25
+ * **`push` answers when the frame starts**, on the context's clock, and the
26
+ * samples it queued: an app draws a face against them (`web-audio.ts`'s
27
+ * `onScheduled`). What is heard is that clock less the device's latency
28
+ * (`latency`), read live: Bluetooth, and a speaker chosen (`setSink`),
29
+ * change it.
22
30
  *
23
31
  * **The context runs at the device's own rate**; each buffer is made at the
24
32
  * stream's (24 kHz unless `audio.start` says otherwise) and the browser
@@ -57,6 +65,40 @@ export class PcmPlayer {
57
65
  this.sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;
58
66
  this.channels = options.channels ?? 1;
59
67
  this.onIdle = options.onIdle;
68
+ this.leadIn = options.leadIn ?? SCHEDULE_AHEAD;
69
+ this.sinkId = options.sinkId;
70
+ }
71
+ /** The context's clock, in seconds; `0` before `resume`. */
72
+ get now() {
73
+ return this.context?.currentTime ?? 0;
74
+ }
75
+ /**
76
+ * Seconds from a sample leaving the context to it being heard: the
77
+ * browser's own processing and the device's. Read each time, since a
78
+ * Bluetooth headset or a new speaker changes it; `0` where a browser does
79
+ * not say (Safari has no `outputLatency`).
80
+ */
81
+ get latency() {
82
+ const context = this.context;
83
+ return context
84
+ ? (context.outputLatency || 0) + (context.baseLatency || 0)
85
+ : 0;
86
+ }
87
+ /**
88
+ * Play through the speaker `id` from now on, and on a context made later.
89
+ * Answers whether it took: a speaker unplugged since it was listed, or
90
+ * one the page may not use, leaves the one before.
91
+ */
92
+ async setSink(id) {
93
+ try {
94
+ await this.context?.setSinkId(id);
95
+ }
96
+ catch {
97
+ // swallowed: the answer is `false`, and the speaker before plays on.
98
+ return false;
99
+ }
100
+ this.sinkId = id;
101
+ return true;
60
102
  }
61
103
  /**
62
104
  * The rate of the PCM pushed from here on: each `audio.start` carries its
@@ -83,6 +125,12 @@ export class PcmPlayer {
83
125
  this.gain = this.context.createGain();
84
126
  this.gain.gain.value = this.volume;
85
127
  this.gain.connect(this.context.destination);
128
+ // Not awaited: the click's `resume` below comes first.
129
+ if (this.sinkId) {
130
+ this.context.setSinkId(this.sinkId).catch(() => {
131
+ // swallowed: gone since it was chosen — the default speaker plays.
132
+ });
133
+ }
86
134
  }
87
135
  if (this.context.state === "suspended")
88
136
  await this.context.resume();
@@ -102,7 +150,8 @@ export class PcmPlayer {
102
150
  return this.scheduled > 0;
103
151
  }
104
152
  /**
105
- * Queue one blob of PCM.
153
+ * Queue one blob of PCM, and answer what was queued and when it starts —
154
+ * `null` when nothing was: a lone byte held for its other half.
106
155
  *
107
156
  * Ignored before {@link resume} — a caller that pushes audio into a player
108
157
  * nobody unlocked has a gesture problem, and buffering it would hide that
@@ -111,7 +160,7 @@ export class PcmPlayer {
111
160
  push(pcm) {
112
161
  const context = this.context;
113
162
  if (!context)
114
- return;
163
+ return null;
115
164
  // **A split sample is carried, never dropped.** The edge sends whole
116
165
  // samples per frame (`apps/edge/src/voice/tts.ts` holds an odd byte for
117
166
  // the next blob), so this should never fire — but truncating instead is
@@ -129,7 +178,7 @@ export class PcmPlayer {
129
178
  const usable = bytes.length - (bytes.length % 2);
130
179
  this.remainder = bytes.slice(usable);
131
180
  if (usable < 2)
132
- return;
181
+ return null;
133
182
  // Copied into its own buffer rather than viewed in place: `Int16Array`
134
183
  // needs a 2-byte-aligned offset, and a frame joined to a carried byte has
135
184
  // no such promise.
@@ -150,7 +199,7 @@ export class PcmPlayer {
150
199
  // "start before now", which the API resolves as "start immediately" for the
151
200
  // first blob and drops for every one after it.
152
201
  const now = context.currentTime;
153
- const startAt = this.playhead > now ? this.playhead : now + SCHEDULE_AHEAD;
202
+ const startAt = this.playhead > now ? this.playhead : now + this.leadIn;
154
203
  source.start(startAt);
155
204
  this.playhead = startAt + buffer.duration;
156
205
  this.scheduled += 1;
@@ -162,6 +211,12 @@ export class PcmPlayer {
162
211
  if (this.scheduled === 0)
163
212
  this.onIdle?.();
164
213
  };
214
+ return {
215
+ pcm: samples.buffer,
216
+ sample_rate: this.sampleRate,
217
+ startsAt: startAt,
218
+ duration: buffer.duration,
219
+ };
165
220
  }
166
221
  /**
167
222
  * Stop everything now — barge-in, or the turn being cancelled.
@@ -169,11 +224,11 @@ export class PcmPlayer {
169
224
  * Disconnecting the gain node rather than stopping each source: sources are
170
225
  * fire-and-forget once scheduled and holding references to them purely to
171
226
  * stop them is a leak waiting to happen. A fresh node is built on the next
172
- * push.
227
+ * push. Answers whether audio still to play was dropped.
173
228
  */
174
229
  stop() {
175
230
  if (!this.context || !this.gain)
176
- return;
231
+ return false;
177
232
  this.gain.disconnect();
178
233
  this.gain = this.context.createGain();
179
234
  this.gain.gain.value = this.volume;
@@ -186,6 +241,7 @@ export class PcmPlayer {
186
241
  this.remainder = new Uint8Array(0);
187
242
  if (wasPlaying)
188
243
  this.onIdle?.();
244
+ return wasPlaying;
189
245
  }
190
246
  /** Release the audio device. Safe to call more than once. */
191
247
  async close() {
@@ -30,6 +30,7 @@
30
30
  * Loaded on the first press (`web-audio.ts`), so a page that never records
31
31
  * never parses it.
32
32
  */
33
+ import type { Processing } from "../audio.js";
33
34
  /** The rate this records at, whatever is asked: the socket's default. */
34
35
  export declare const RECORD_SAMPLE_RATE = 24000;
35
36
  /** Samples per delivered chunk — about 85 ms at 24 kHz. Small enough that the
@@ -46,6 +47,10 @@ export interface PcmRecorderOptions {
46
47
  /** Called with each block of samples, already 16-bit little-endian. */
47
48
  onChunk: (pcm: ArrayBuffer) => void;
48
49
  sampleRate?: number;
50
+ /** The microphone's id (`enumerateDevices`); the system's default when unset or gone. */
51
+ input?: string;
52
+ /** The browser's cleanup: each on unless `false`. */
53
+ processing?: Processing;
49
54
  }
50
55
  /** Float samples to 16-bit signed little-endian, which is what the wire wants. */
51
56
  export declare function toPcm16(samples: Float32Array): ArrayBuffer;
@@ -72,6 +77,8 @@ export declare class PcmRecorder {
72
77
  private source;
73
78
  private readonly onChunk;
74
79
  private readonly sampleRate;
80
+ private readonly input?;
81
+ private readonly processing;
75
82
  /** Samples not yet delivered — see `FRAME_SAMPLES`. */
76
83
  private pending;
77
84
  private pendingSamples;
@@ -95,6 +95,8 @@ export class PcmRecorder {
95
95
  this.pendingSamples = 0;
96
96
  this.onChunk = options.onChunk;
97
97
  this.sampleRate = options.sampleRate ?? RECORD_SAMPLE_RATE;
98
+ this.input = options.input;
99
+ this.processing = options.processing ?? {};
98
100
  }
99
101
  /** True once `start()` has a live microphone and has not been stopped. */
100
102
  get recording() {
@@ -121,16 +123,30 @@ export class PcmRecorder {
121
123
  async start() {
122
124
  if (this.stream)
123
125
  return;
124
- this.stream = await navigator.mediaDevices.getUserMedia({
125
- audio: {
126
- // The browser's own cleanup, which is better than anything worth doing
127
- // here and costs nothing: a recogniser fed room echo transcribes it.
128
- echoCancellation: true,
129
- noiseSuppression: true,
130
- autoGainControl: true,
131
- channelCount: 1,
132
- },
126
+ // The browser's own cleanup, which is better than anything worth doing
127
+ // here and costs nothing: a recogniser fed room echo transcribes it. An
128
+ // app may turn a part off — a music app its noise suppression.
129
+ const { echoCancellation, noiseSuppression, autoGainControl } = this.processing;
130
+ const audio = {
131
+ echoCancellation: echoCancellation !== false,
132
+ noiseSuppression: noiseSuppression !== false,
133
+ autoGainControl: autoGainControl !== false,
134
+ channelCount: 1,
135
+ };
136
+ const ask = (deviceId) => navigator.mediaDevices.getUserMedia({
137
+ audio: deviceId ? { ...audio, deviceId: { exact: deviceId } } : audio,
133
138
  });
139
+ try {
140
+ // `exact`, so the microphone chosen is the one that records...
141
+ this.stream = await ask(this.input);
142
+ }
143
+ catch (failure) {
144
+ // ...and one unplugged since it was chosen records from the default,
145
+ // rather than failing the press.
146
+ if (!this.input || failure.name !== "OverconstrainedError")
147
+ throw failure;
148
+ this.stream = await ask();
149
+ }
134
150
  try {
135
151
  this.context = new AudioContext({ sampleRate: this.sampleRate });
136
152
  // Autoplay policy suspends a context created outside a gesture. Pressing
@@ -18,25 +18,70 @@
18
18
  * Edge and Firefox remove a tab's own sound from its microphone — Firefox
19
19
  * only with the context at the device's rate, which the player keeps.
20
20
  * Every browser on an iPhone or iPad is WebKit, whose canceller fails
21
- * three ways (WebKit bugs 218012, 311451, 326286): refused there.
21
+ * three ways (WebKit bugs 218012, 311451, 326286): refused there — and
22
+ * wherever the app turned echo cancellation off. Half-duplex runs
23
+ * anywhere (`listen`'s `halfDuplex`): it never asks.
24
+ * - **The clock and the frames are the app's** (`PlaybackClock`): each
25
+ * frame told as it is queued, with when it starts; the stop; what the
26
+ * microphone captures. A handler that throws is rethrown on its own turn,
27
+ * never inside `play`, `stop` or a capture — a lipsync bug must not cut
28
+ * the voice.
29
+ * - **The microphone, its processing and the speaker are the app's to
30
+ * choose** (`DeviceChoices`): the speaker through
31
+ * `AudioContext.setSinkId`, Chrome's and Edge's alone. Whether echo is
32
+ * still cancelled with a speaker that is not the default is Chrome's,
33
+ * and unchecked (left-overs #222).
22
34
  */
23
- import type { AgentAudio } from "../audio.js";
35
+ import type { AgentAudio, AudioDevice, DeviceChoices, PlaybackClock, Processing, ScheduledAudio } from "../audio.js";
36
+ export interface WebAudioOptions {
37
+ /**
38
+ * Milliseconds a reply's first frame — or one after a gap — waits before
39
+ * it plays: 60 unless set. Longer gives an app time to read each frame
40
+ * before it is heard (`onScheduled`), and adds as much to every first word.
41
+ */
42
+ leadInMs?: number;
43
+ /** The microphone, by its `inputs()` id: the default unless set. */
44
+ input?: string;
45
+ /** The browser's cleanup of the microphone: each on unless `false`. */
46
+ processing?: Processing;
47
+ }
48
+ /** What `play` is given: the frame's rate, and what `onScheduled` passes on when it has it. */
49
+ type Frame = Parameters<AgentAudio["play"]>[1];
24
50
  /** Why hands-free is refused on an iPhone or iPad. */
25
- export declare const IOS_ECHO = "Hands-free is not available on an iPhone or iPad browser: its echo cancellation is broken (WebKit bugs 218012, 311451 and 326286). Use push-to-talk.";
51
+ export declare const IOS_ECHO = "Hands-free is not available on an iPhone or iPad browser: its echo cancellation is broken (WebKit bugs 218012, 311451 and 326286). Use half-duplex or push-to-talk.";
26
52
  /** Why it is refused in a browser that cannot cancel echo at all. */
27
- export declare const NO_ECHO = "This browser cannot cancel echo, so the agent would hear itself. Use push-to-talk.";
28
- export declare class WebAudio implements AgentAudio {
53
+ export declare const NO_ECHO = "This browser cannot cancel echo, so the agent would hear itself. Use half-duplex or push-to-talk.";
54
+ /** Why it is refused with echo cancellation turned off (`setProcessing`). */
55
+ export declare const ECHO_OFF = "Echo cancellation is turned off, so the agent would hear itself. Turn it on, or use half-duplex or push-to-talk.";
56
+ export declare class WebAudio implements AgentAudio, PlaybackClock, DeviceChoices {
29
57
  private player;
30
58
  private recorder;
31
59
  private onPcm;
32
60
  private readonly drained;
61
+ private readonly scheduled;
62
+ private readonly stopped;
63
+ private readonly captured;
64
+ private readonly leadIn?;
65
+ private input?;
66
+ private processing;
67
+ private sink?;
68
+ constructor(options?: WebAudioOptions);
33
69
  /** Make the player and unlock it. **Call it from the click**, before any `await`. */
34
70
  prime(): Promise<void>;
35
- play(pcm: ArrayBuffer, frame: {
36
- sample_rate: number;
37
- }): void;
71
+ play(pcm: ArrayBuffer, frame: Pick<Frame, "sample_rate"> & Partial<Frame>): void;
38
72
  stop(): void;
39
73
  onDrained(fn: () => void): () => void;
74
+ now(): number;
75
+ heardTime(): number;
76
+ onScheduled(fn: (audio: ScheduledAudio) => void): () => void;
77
+ onStopped(fn: () => void): () => void;
78
+ onCapture(fn: (pcm: ArrayBuffer, rate: number) => void): () => void;
79
+ inputs(): Promise<AudioDevice[]>;
80
+ setInput(id?: string): void;
81
+ setProcessing(processing: Processing): void;
82
+ outputs(): Promise<AudioDevice[]>;
83
+ setOutputDevice(id: string): Promise<boolean>;
84
+ get canChooseOutput(): boolean;
40
85
  /** At the recorder's own rate, 24 kHz, whatever is asked. */
41
86
  startCapture(onPcm: (pcm: ArrayBuffer) => void, _options?: {
42
87
  rate?: number;
@@ -54,3 +99,4 @@ export declare class WebAudio implements AgentAudio {
54
99
  /** The conversation is over: the player's context goes too. */
55
100
  dispose(): void;
56
101
  }
102
+ export {};
@@ -2,23 +2,31 @@ import { PcmPlayer } from "./player.js";
2
2
  /** The reply's loudness while the person may be talking over it: about -10 dB. */
3
3
  const DUCKED = 0.3;
4
4
  /** Why hands-free is refused on an iPhone or iPad. */
5
- export const IOS_ECHO = "Hands-free is not available on an iPhone or iPad browser: its echo cancellation is broken (WebKit bugs 218012, 311451 and 326286). Use push-to-talk.";
5
+ export const IOS_ECHO = "Hands-free is not available on an iPhone or iPad browser: its echo cancellation is broken (WebKit bugs 218012, 311451 and 326286). Use half-duplex or push-to-talk.";
6
6
  /** Why it is refused in a browser that cannot cancel echo at all. */
7
- export const NO_ECHO = "This browser cannot cancel echo, so the agent would hear itself. Use push-to-talk.";
7
+ export const NO_ECHO = "This browser cannot cancel echo, so the agent would hear itself. Use half-duplex or push-to-talk.";
8
+ /** Why it is refused with echo cancellation turned off (`setProcessing`). */
9
+ export const ECHO_OFF = "Echo cancellation is turned off, so the agent would hear itself. Turn it on, or use half-duplex or push-to-talk.";
8
10
  export class WebAudio {
9
- constructor() {
11
+ constructor(options = {}) {
10
12
  this.player = null;
11
13
  this.recorder = null;
12
14
  this.onPcm = null;
13
15
  this.drained = new Set();
16
+ this.scheduled = new Set();
17
+ this.stopped = new Set();
18
+ this.captured = new Set();
19
+ if (options.leadInMs !== undefined)
20
+ this.leadIn = options.leadInMs / 1000;
21
+ this.input = options.input;
22
+ this.processing = options.processing ?? {};
14
23
  }
15
24
  /** Make the player and unlock it. **Call it from the click**, before any `await`. */
16
25
  prime() {
17
26
  this.player ?? (this.player = new PcmPlayer({
18
- onIdle: () => {
19
- for (const fn of this.drained)
20
- fn();
21
- },
27
+ leadIn: this.leadIn,
28
+ sinkId: this.sink,
29
+ onIdle: () => tell(this.drained),
22
30
  }));
23
31
  return this.player.resume();
24
32
  }
@@ -27,21 +35,73 @@ export class WebAudio {
27
35
  void this.prime();
28
36
  const player = this.player;
29
37
  player.setSampleRate(frame.sample_rate);
30
- player.push(pcm);
38
+ const queued = player.push(pcm);
39
+ if (queued) {
40
+ tell(this.scheduled, {
41
+ ...queued,
42
+ channels: frame.channels ?? 1,
43
+ chunk: frame.chunk ?? 0,
44
+ turn: frame.turn ?? "",
45
+ });
46
+ }
31
47
  }
32
48
  stop() {
33
- this.player?.stop();
49
+ if (this.player?.stop())
50
+ tell(this.stopped);
34
51
  }
35
52
  onDrained(fn) {
36
- this.drained.add(fn);
37
- return () => {
38
- this.drained.delete(fn);
39
- };
53
+ return add(this.drained, fn);
54
+ }
55
+ now() {
56
+ return this.player?.now ?? 0;
57
+ }
58
+ heardTime() {
59
+ return Math.max(0, this.now() - (this.player?.latency ?? 0));
60
+ }
61
+ onScheduled(fn) {
62
+ return add(this.scheduled, fn);
63
+ }
64
+ onStopped(fn) {
65
+ return add(this.stopped, fn);
66
+ }
67
+ onCapture(fn) {
68
+ return add(this.captured, fn);
69
+ }
70
+ inputs() {
71
+ return devices("audioinput");
72
+ }
73
+ setInput(id) {
74
+ this.input = id;
75
+ }
76
+ setProcessing(processing) {
77
+ this.processing = processing;
78
+ }
79
+ async outputs() {
80
+ return this.canChooseOutput ? devices("audiooutput") : [];
81
+ }
82
+ async setOutputDevice(id) {
83
+ if (!this.canChooseOutput)
84
+ return false;
85
+ if (this.player && !(await this.player.setSink(id)))
86
+ return false;
87
+ this.sink = id;
88
+ return true;
89
+ }
90
+ get canChooseOutput() {
91
+ return (typeof AudioContext !== "undefined" &&
92
+ "setSinkId" in AudioContext.prototype);
40
93
  }
41
94
  /** At the recorder's own rate, 24 kHz, whatever is asked. */
42
95
  async startCapture(onPcm, _options) {
43
96
  const { PcmRecorder } = await import("./recorder.js");
44
- const mic = new PcmRecorder({ onChunk: (pcm) => this.onPcm?.(pcm) });
97
+ const mic = new PcmRecorder({
98
+ input: this.input,
99
+ processing: this.processing,
100
+ onChunk: (pcm) => {
101
+ this.onPcm?.(pcm);
102
+ tell(this.captured, pcm, mic.rate);
103
+ },
104
+ });
45
105
  this.onPcm = onPcm;
46
106
  this.recorder = mic;
47
107
  try {
@@ -59,16 +119,24 @@ export class WebAudio {
59
119
  const deliver = this.onPcm;
60
120
  this.recorder = null;
61
121
  this.onPcm = null;
62
- const tail = mic?.stop();
63
- if (tail)
64
- deliver?.(tail);
122
+ if (!mic)
123
+ return;
124
+ const rate = mic.rate;
125
+ const tail = mic.stop();
126
+ if (!tail)
127
+ return;
128
+ deliver?.(tail);
129
+ tell(this.captured, tail, rate);
65
130
  }
66
131
  duck(on) {
67
132
  this.player?.setVolume(on ? DUCKED : 1);
68
133
  }
69
134
  async handsFree() {
70
- const devices = typeof navigator === "undefined" ? undefined : navigator.mediaDevices;
71
- if (!devices?.getSupportedConstraints?.().echoCancellation) {
135
+ if (this.processing.echoCancellation === false) {
136
+ return { ok: false, reason: ECHO_OFF };
137
+ }
138
+ const media = mediaDevices();
139
+ if (!media?.getSupportedConstraints?.().echoCancellation) {
72
140
  return { ok: false, reason: NO_ECHO };
73
141
  }
74
142
  // iPadOS says it is a Mac: a Mac with a touch screen is an iPad.
@@ -83,3 +151,34 @@ export class WebAudio {
83
151
  this.player = null;
84
152
  }
85
153
  }
154
+ const mediaDevices = () => typeof navigator === "undefined" ? undefined : navigator.mediaDevices;
155
+ /** The browser's devices of `kind`; ids and labels empty until the microphone was allowed. */
156
+ async function devices(kind) {
157
+ const listed = (await mediaDevices()?.enumerateDevices?.()) ?? [];
158
+ return listed
159
+ .filter((device) => device.kind === kind)
160
+ .map(({ deviceId, label }) => ({ id: deviceId, label }));
161
+ }
162
+ function add(listeners, fn) {
163
+ listeners.add(fn);
164
+ return () => {
165
+ listeners.delete(fn);
166
+ };
167
+ }
168
+ /**
169
+ * Call each of the app's handlers. One that throws is rethrown on its own
170
+ * turn of the event loop, where the app's error reporting sees it — not
171
+ * here, where it would stop the player or the recorder halfway.
172
+ */
173
+ function tell(listeners, ...args) {
174
+ for (const fn of listeners) {
175
+ try {
176
+ fn(...args);
177
+ }
178
+ catch (error) {
179
+ setTimeout(() => {
180
+ throw error;
181
+ });
182
+ }
183
+ }
184
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumavox/client",
3
- "version": "0.15.1",
3
+ "version": "0.17.0",
4
4
  "description": "Talk to Lumavox agents from a browser, a React Native app or Node, with an end user's ephemeral key: conversations on the agent's socket, sessions, memory and relations. Zero dependencies.",
5
5
  "license": "MIT",
6
6
  "type": "module",