@lumavox/client 0.16.0 → 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
@@ -191,6 +191,13 @@ allows it where the browser cancels echo (not on an iPhone or iPad);
191
191
  `assumeNoEcho` with headphones on. `loudness()` is the built-in detector,
192
192
  `detector` your own.
193
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
+
194
201
  ## Runtimes
195
202
 
196
203
  Browsers, React Native (Hermes, ES2020 — and each binary frame reaches JS
package/dist/audio.d.ts CHANGED
@@ -34,6 +34,12 @@ export interface AgentAudio {
34
34
  */
35
35
  startCapture(onPcm: (pcm: ArrayBuffer) => void, options?: {
36
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;
37
43
  }): Promise<{
38
44
  rate: number;
39
45
  }>;
@@ -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;
@@ -19,7 +19,8 @@
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
21
  * three ways (WebKit bugs 218012, 311451, 326286): refused there — and
22
- * wherever the app turned echo cancellation off.
22
+ * wherever the app turned echo cancellation off. Half-duplex runs
23
+ * anywhere (`listen`'s `halfDuplex`): it never asks.
23
24
  * - **The clock and the frames are the app's** (`PlaybackClock`): each
24
25
  * frame told as it is queued, with when it starts; the stop; what the
25
26
  * microphone captures. A handler that throws is rethrown on its own turn,
@@ -47,11 +48,11 @@ export interface WebAudioOptions {
47
48
  /** What `play` is given: the frame's rate, and what `onScheduled` passes on when it has it. */
48
49
  type Frame = Parameters<AgentAudio["play"]>[1];
49
50
  /** Why hands-free is refused on an iPhone or iPad. */
50
- 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.";
51
52
  /** Why it is refused in a browser that cannot cancel echo at all. */
52
- export declare const NO_ECHO = "This browser cannot cancel echo, so the agent would hear itself. Use push-to-talk.";
53
+ export declare const NO_ECHO = "This browser cannot cancel echo, so the agent would hear itself. Use half-duplex or push-to-talk.";
53
54
  /** Why it is refused with echo cancellation turned off (`setProcessing`). */
54
- export declare const ECHO_OFF = "Echo cancellation is turned off, so the agent would hear itself. Turn it on, or use push-to-talk.";
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.";
55
56
  export declare class WebAudio implements AgentAudio, PlaybackClock, DeviceChoices {
56
57
  private player;
57
58
  private recorder;
@@ -2,11 +2,11 @@ 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
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 push-to-talk.";
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.";
10
10
  export class WebAudio {
11
11
  constructor(options = {}) {
12
12
  this.player = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumavox/client",
3
- "version": "0.16.0",
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",