@lumavox/react-native 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
@@ -3,7 +3,7 @@
3
3
  Voice conversations with Lumavox agents in a React Native app, on iOS,
4
4
  Android and the web — **one install**: the client (`@lumavox/client`), the
5
5
  hooks (`@lumavox/react`), and the audio they lack, a player and a recorder
6
- for **push-to-talk**, on
6
+ for **push-to-talk**, **half-duplex** and **hands-free**, on
7
7
  [`react-native-audio-api`](https://github.com/software-mansion/react-native-audio-api)
8
8
  on a phone and Web Audio in a browser.
9
9
 
@@ -54,10 +54,20 @@ function Talk({ agentId }: { agentId: string }) {
54
54
  - A call, Siri or another app pauses the reply and throws away a recording
55
55
  in progress — `interrupted`, `onAudioInterrupted`, `onAudioResumed`; so
56
56
  does leaving the screen: no background audio in this release.
57
- - Hands-free (`agent.listen()`) runs on the web, where the browser cancels
58
- echo; on a phone it is refused until `react-native-audio-api` 1.0 does —
59
- `listen({ assumeNoEcho: true })` with headphones on. Background audio
60
- comes with 1.0 too.
57
+ - **Half-duplex on every phone**: `agent.listen({ halfDuplex: true })` —
58
+ tap once and talk for the whole conversation; the microphone stays open,
59
+ but nothing is heard while the agent speaks, so it never hears itself
60
+ and needs no echo cancellation. It cannot be talked over: give a stop
61
+ button (`agent.cancel()`).
62
+ - **Hands-free** (`agent.listen()`, talking over the agent to stop it):
63
+ on the web where the browser cancels echo, and on a phone **in early
64
+ access**, on one build of the audio library, exactly:
65
+ `npm install react-native-audio-api@1.0.0-nightly-6fd4c68-20261102`,
66
+ then build the app again. While it listens the phone's sound is a
67
+ call's — the echo taken out, the agent a little quieter — and Bluetooth
68
+ earbuds' microphone is used. On 0.13 it is refused (`audio.handsFree()`
69
+ says why): offer half-duplex, or `listen({ assumeNoEcho: true })` with
70
+ headphones on. Background audio comes with the library's 1.0.
61
71
  - **A clock for a lip-synced face or subtitles**, on a phone and the web:
62
72
  `audio.onScheduled(({ pcm, sample_rate, chunk, startsAt }) => …)` tells
63
73
  every frame as it is queued, before it plays; draw against
package/app.plugin.js CHANGED
@@ -68,7 +68,10 @@ function audioApiOptions(options = {}) {
68
68
  androidFSStopWithTask: true,
69
69
  // The library's own defaults: FFmpeg decodes compressed files, which a
70
70
  // conversation's raw PCM never needs — `true` makes the app smaller.
71
+ // 1.0 turned the option round, FFmpeg off unless `enableFFmpeg`: each
72
+ // build reads its own and ignores the other's, so both say the same.
71
73
  disableFFmpeg: options.disableFFmpeg ?? false,
74
+ enableFFmpeg: !(options.disableFFmpeg ?? false),
72
75
  disableStaticExternalLibs: options.disableStaticExternalLibs ?? false,
73
76
  };
74
77
  }
@@ -90,7 +93,8 @@ function withLumavox(config, options, library) {
90
93
  }
91
94
 
92
95
  function loadLibrary() {
93
- const plugin = require(`${LIBRARY}/app.plugin`);
96
+ // With its extension: 1.0's `exports` names `./app.plugin.js` alone.
97
+ const plugin = require(`${LIBRARY}/app.plugin.js`);
94
98
  return plugin.default ?? plugin;
95
99
  }
96
100
 
@@ -1,6 +1,7 @@
1
1
  /**
2
- * A phone's player and recorder, on `react-native-audio-api` 0.13.6: what
3
- * Metro picks for `./audio` on iOS and Android (`index.ts`). With
2
+ * A phone's player and recorder, on `react-native-audio-api` — 0.13.6, or
3
+ * the one 1.0 nightly hands-free runs on (`session.ts`, `PINNED_NIGHTLY`):
4
+ * what Metro picks for `./audio` on iOS and Android (`index.ts`). With
4
5
  * `session.ts`, the only file that touches the library.
5
6
  *
6
7
  * - **The reply plays through one `AudioBufferQueueSourceNode`**, started
@@ -25,7 +26,8 @@
25
26
  * - **What is heard is the clock less the app's `outputLatencyMs`**:
26
27
  * 0.13.6 says nothing of the device's latency — its source nodes'
27
28
  * `getLatency()` is their time-stretcher's — so the app, which can know
28
- * its earbuds are on, says it.
29
+ * its earbuds are on, says it. Unset on the nightly, the context's own
30
+ * (`outputLatency` and `baseLatency`, 1.0's).
29
31
  * - **The microphone records at 16 kHz**, mono, 100 ms a buffer — less to
30
32
  * send than 24 kHz, and a rate the socket takes. `onAudioReady` hands the
31
33
  * samples over JSI as floats, at the rate the device chose, which need
@@ -36,22 +38,60 @@
36
38
  * thrown away (the hook discards the recording on the socket). The audio
37
39
  * comes back on its own when the system says it may, else on `resume()`
38
40
  * or the next press.
39
- * - **No hands-free**: 0.13.6 cancels no echo, so a microphone left open
40
- * hears the agent (D12, D16). `handsFree` refuses until 1.0; with
41
- * headphones on, the app passes `assumeNoEcho`.
41
+ * - **Half-duplex on any build** (`listen`'s `halfDuplex`): the recorder
42
+ * started once for the whole conversation, which suits the library —
43
+ * on iOS, 0.13.6's recorder shares the player's one engine, and each
44
+ * start stops and starts it and each stop rebuilds it, a gap in the
45
+ * voice; the loudspeaker holds (`defaultToSpeaker`), and Android's
46
+ * recorder and player are two streams that open side by side.
47
+ * - **Hands-free on the pinned nightly alone** (D17, D18; `handsFree`):
48
+ * 0.13.6 cancels no echo, so a microphone left open hears the agent
49
+ * (D12, D16) — refused there, `assumeNoEcho` with headphones on. On the
50
+ * nightly a capture that asks for echo cancellation (`listen` does, on
51
+ * `handsFree`'s yes) is iOS's voice processing in `videoChat`, and
52
+ * Android's voice-communication input with the reply on the call's path
53
+ * (`androidOutputProfile`), in its communication mode
54
+ * (`session.configure`): its canceller hears only that path. The reply
55
+ * moves to it — a new context — once nothing is playing, never mid-word,
56
+ * and back once the capture ends. **A recorder that will not start that
57
+ * way fails the capture**, and `listen` answers why: the app offers
58
+ * half-duplex (D16, D19). Whether an Android phone's canceller works is
59
+ * not asked: the platform says it only to native code (left-overs #177).
60
+ * - **`duck`**: the reply goes through a gain, lowered while it may be
61
+ * talked over — barge-in's first step.
42
62
  */
43
63
  import { type AgentAudio, type AudioDevice, type ScheduledAudio } from "@lumavox/client";
44
64
  import type { AudioOptions, CreateAudio, DeviceAudio, Output } from "./device-audio.js";
45
65
  /** Hz the microphone records at unless a rate the socket takes is asked. */
46
66
  export declare const RECORD_RATE = 16000;
47
- /** Why `handsFree` refuses on a phone. */
48
- export declare const PHONE_ECHO = "Hands-free needs echo cancellation, which comes with react-native-audio-api 1.0. Use push-to-talk, or assumeNoEcho with headphones on.";
67
+ /** Why `handsFree` refuses on 0.13. */
68
+ export declare const PHONE_ECHO = "Hands-free needs echo cancellation, which comes with react-native-audio-api 1.0. Use half-duplex or push-to-talk, or assumeNoEcho with headphones on.";
69
+ /** Why it refuses on a 1.0 build that is not the one it was tested on. */
70
+ export declare const untestedBuild: (version: string) => string;
49
71
  export declare class PhoneAudio implements DeviceAudio {
50
72
  interrupted: boolean;
51
73
  /** On a phone the system chooses the speaker. */
52
74
  readonly canChooseOutput = false;
53
75
  private context;
54
76
  private queue;
77
+ /** Between the queue and the speaker: the reply lowered while it may be talked over. */
78
+ private gain;
79
+ private volume;
80
+ /** The context's output on Android: the call's path while hands-free cancels echo. */
81
+ private profile;
82
+ /** A capture is cancelling echo: the session is hands-free's. */
83
+ private cancelling;
84
+ private unroute;
85
+ private output;
86
+ private disposed;
87
+ /** The call's route last asked on Android (`session.route`). */
88
+ private routed;
89
+ /**
90
+ * Until when an interruption is the session made again — Android's focus
91
+ * given up and taken back, told a moment later on its own thread — and
92
+ * not another app's.
93
+ */
94
+ private settling;
55
95
  /** The ids of the buffers enqueued and not yet played. */
56
96
  private readonly playing;
57
97
  /** Where the last buffer enqueued ends, on the context's clock. */
@@ -67,6 +107,7 @@ export declare class PhoneAudio implements DeviceAudio {
67
107
  private readonly unwatch;
68
108
  /** Seconds. */
69
109
  private readonly leadIn;
110
+ /** The app's, or `null`: the context's own where it says one. */
70
111
  private readonly latency;
71
112
  constructor(options?: AudioOptions);
72
113
  prime(): Promise<void>;
@@ -84,20 +125,43 @@ export declare class PhoneAudio implements DeviceAudio {
84
125
  setInput(): void;
85
126
  setProcessing(): void;
86
127
  setOutputDevice(): Promise<boolean>;
87
- /** At 16 kHz, or at the rate asked when the socket takes it. */
128
+ /**
129
+ * At 16 kHz, or at the rate asked when the socket takes it; cancelling
130
+ * echo when hands-free asks and the library can.
131
+ */
88
132
  startCapture(onPcm: (pcm: ArrayBuffer) => void, options?: {
89
133
  rate?: number;
134
+ echoCancellation?: boolean;
90
135
  }): Promise<{
91
136
  rate: number;
92
137
  }>;
138
+ /** The recorder, open: its echo cancelled when `cancel`. */
139
+ private record;
93
140
  stopCapture(): Promise<void>;
94
141
  resume(): Promise<void>;
95
142
  setOutput(output: Output): void;
143
+ /** Hands-free's first step on sound over the reply: lowered, until the words say who it was. */
144
+ duck(on: boolean): void;
96
145
  handsFree(): ReturnType<DeviceAudio["handsFree"]>;
97
146
  onInterruption(fn: (interrupted: boolean) => void): () => void;
98
147
  dispose(): void;
99
148
  /** Behind what is queued; the playhead moves to its end. */
100
149
  private enqueue;
150
+ /**
151
+ * The session hands-free's, Android's reply on the call's path and the
152
+ * call's route: before the recorder starts, so the canceller hears the
153
+ * first reply too.
154
+ */
155
+ private enterHandsFree;
156
+ /** Back to push-to-talk's session; the reply leaves the call's path once it has played out. */
157
+ private leaveHandsFree;
158
+ /**
159
+ * The session for hands-free or not — disposed, the mode given back and
160
+ * nothing taken again for a screen gone — its focus's comings and goings
161
+ * no interruption.
162
+ */
163
+ private reconfigure;
164
+ private reroute;
101
165
  /** The queue for a reply at `rate`: this one, or a new context's. */
102
166
  private player;
103
167
  /**
@@ -1,6 +1,7 @@
1
1
  /**
2
- * A phone's player and recorder, on `react-native-audio-api` 0.13.6: what
3
- * Metro picks for `./audio` on iOS and Android (`index.ts`). With
2
+ * A phone's player and recorder, on `react-native-audio-api` — 0.13.6, or
3
+ * the one 1.0 nightly hands-free runs on (`session.ts`, `PINNED_NIGHTLY`):
4
+ * what Metro picks for `./audio` on iOS and Android (`index.ts`). With
4
5
  * `session.ts`, the only file that touches the library.
5
6
  *
6
7
  * - **The reply plays through one `AudioBufferQueueSourceNode`**, started
@@ -25,7 +26,8 @@
25
26
  * - **What is heard is the clock less the app's `outputLatencyMs`**:
26
27
  * 0.13.6 says nothing of the device's latency — its source nodes'
27
28
  * `getLatency()` is their time-stretcher's — so the app, which can know
28
- * its earbuds are on, says it.
29
+ * its earbuds are on, says it. Unset on the nightly, the context's own
30
+ * (`outputLatency` and `baseLatency`, 1.0's).
29
31
  * - **The microphone records at 16 kHz**, mono, 100 ms a buffer — less to
30
32
  * send than 24 kHz, and a rate the socket takes. `onAudioReady` hands the
31
33
  * samples over JSI as floats, at the rate the device chose, which need
@@ -36,17 +38,41 @@
36
38
  * thrown away (the hook discards the recording on the socket). The audio
37
39
  * comes back on its own when the system says it may, else on `resume()`
38
40
  * or the next press.
39
- * - **No hands-free**: 0.13.6 cancels no echo, so a microphone left open
40
- * hears the agent (D12, D16). `handsFree` refuses until 1.0; with
41
- * headphones on, the app passes `assumeNoEcho`.
41
+ * - **Half-duplex on any build** (`listen`'s `halfDuplex`): the recorder
42
+ * started once for the whole conversation, which suits the library —
43
+ * on iOS, 0.13.6's recorder shares the player's one engine, and each
44
+ * start stops and starts it and each stop rebuilds it, a gap in the
45
+ * voice; the loudspeaker holds (`defaultToSpeaker`), and Android's
46
+ * recorder and player are two streams that open side by side.
47
+ * - **Hands-free on the pinned nightly alone** (D17, D18; `handsFree`):
48
+ * 0.13.6 cancels no echo, so a microphone left open hears the agent
49
+ * (D12, D16) — refused there, `assumeNoEcho` with headphones on. On the
50
+ * nightly a capture that asks for echo cancellation (`listen` does, on
51
+ * `handsFree`'s yes) is iOS's voice processing in `videoChat`, and
52
+ * Android's voice-communication input with the reply on the call's path
53
+ * (`androidOutputProfile`), in its communication mode
54
+ * (`session.configure`): its canceller hears only that path. The reply
55
+ * moves to it — a new context — once nothing is playing, never mid-word,
56
+ * and back once the capture ends. **A recorder that will not start that
57
+ * way fails the capture**, and `listen` answers why: the app offers
58
+ * half-duplex (D16, D19). Whether an Android phone's canceller works is
59
+ * not asked: the platform says it only to native code (left-overs #177).
60
+ * - **`duck`**: the reply goes through a gain, lowered while it may be
61
+ * talked over — barge-in's first step.
42
62
  */
43
63
  import { DEFAULT_SAMPLE_RATE, floatToPcm16, pcm16ToFloat, RECORDING_RATES, resample, } from "@lumavox/client";
44
64
  import { AudioContext, AudioRecorder, } from "react-native-audio-api";
45
65
  import * as session from "./session.js";
46
66
  /** Hz the microphone records at unless a rate the socket takes is asked. */
47
67
  export const RECORD_RATE = 16000;
48
- /** Why `handsFree` refuses on a phone. */
49
- export const PHONE_ECHO = "Hands-free needs echo cancellation, which comes with react-native-audio-api 1.0. Use push-to-talk, or assumeNoEcho with headphones on.";
68
+ /** Why `handsFree` refuses on 0.13. */
69
+ export const PHONE_ECHO = "Hands-free needs echo cancellation, which comes with react-native-audio-api 1.0. Use half-duplex or push-to-talk, or assumeNoEcho with headphones on.";
70
+ /** Why it refuses on a 1.0 build that is not the one it was tested on. */
71
+ export const untestedBuild = (version) => `Hands-free runs on react-native-audio-api ${session.PINNED_NIGHTLY}, the build it was tested on; this app has ${version}. Use half-duplex or push-to-talk.`;
72
+ /** After the session is made again, how long a focus change is still its own: Android tells it on another thread, a moment later. */
73
+ const SETTLE_MS = 500;
74
+ /** The reply's loudness while the person may be talking over it (`duck`): about -10 dB, as the web's. */
75
+ const DUCKED = 0.3;
50
76
  /** Seconds of microphone per buffer delivered: the recorder's own latency. */
51
77
  const RECORD_SECONDS = 0.1;
52
78
  export class PhoneAudio {
@@ -56,6 +82,23 @@ export class PhoneAudio {
56
82
  this.canChooseOutput = false;
57
83
  this.context = null;
58
84
  this.queue = null;
85
+ /** Between the queue and the speaker: the reply lowered while it may be talked over. */
86
+ this.gain = null;
87
+ this.volume = 1;
88
+ /** The context's output on Android: the call's path while hands-free cancels echo. */
89
+ this.profile = "media";
90
+ /** A capture is cancelling echo: the session is hands-free's. */
91
+ this.cancelling = false;
92
+ this.unroute = null;
93
+ this.disposed = false;
94
+ /** The call's route last asked on Android (`session.route`). */
95
+ this.routed = null;
96
+ /**
97
+ * Until when an interruption is the session made again — Android's focus
98
+ * given up and taken back, told a moment later on its own thread — and
99
+ * not another app's.
100
+ */
101
+ this.settling = 0;
59
102
  /** The ids of the buffers enqueued and not yet played. */
60
103
  this.playing = new Set();
61
104
  /** Where the last buffer enqueued ends, on the context's clock. */
@@ -69,9 +112,17 @@ export class PhoneAudio {
69
112
  this.captured = new Set();
70
113
  this.interruptions = new Set();
71
114
  this.leadIn = (options.leadInMs ?? 0) / 1000;
72
- this.latency = (options.outputLatencyMs ?? 0) / 1000;
73
- session.configure(options.output ?? "speaker");
115
+ this.latency =
116
+ options.outputLatencyMs === undefined
117
+ ? null
118
+ : options.outputLatencyMs / 1000;
119
+ this.output = options.output ?? "speaker";
120
+ session.configure(this.output).catch(() => {
121
+ // swallowed: the library's defaults stand until `prime()` or a press sets the session again.
122
+ });
74
123
  this.unwatch = session.watch(({ began, shouldResume }) => {
124
+ if (Date.now() < this.settling)
125
+ return;
75
126
  if (began)
76
127
  this.interrupt();
77
128
  else if (shouldResume && this.interrupted) {
@@ -138,7 +189,10 @@ export class PhoneAudio {
138
189
  return this.context?.currentTime ?? 0;
139
190
  }
140
191
  heardTime() {
141
- return Math.max(0, this.now() - this.latency);
192
+ const context = this.context;
193
+ const latency = this.latency ??
194
+ (context?.outputLatency ?? 0) + (context?.baseLatency ?? 0);
195
+ return Math.max(0, this.now() - latency);
142
196
  }
143
197
  onScheduled(fn) {
144
198
  return add(this.scheduled, fn);
@@ -161,7 +215,10 @@ export class PhoneAudio {
161
215
  async setOutputDevice() {
162
216
  return false;
163
217
  }
164
- /** At 16 kHz, or at the rate asked when the socket takes it. */
218
+ /**
219
+ * At 16 kHz, or at the rate asked when the socket takes it; cancelling
220
+ * echo when hands-free asks and the library can.
221
+ */
165
222
  async startCapture(onPcm, options) {
166
223
  // A press is the person going on: the audio is taken back first.
167
224
  if (this.interrupted)
@@ -176,7 +233,22 @@ export class PhoneAudio {
176
233
  const rate = asked && RECORDING_RATES.includes(asked)
177
234
  ? asked
178
235
  : RECORD_RATE;
179
- const recorder = new AudioRecorder();
236
+ const cancel = options?.echoCancellation === true && session.cancelsEcho();
237
+ try {
238
+ if (cancel)
239
+ await this.enterHandsFree();
240
+ return await this.record(onPcm, rate, cancel);
241
+ }
242
+ catch (failure) {
243
+ await this.leaveHandsFree();
244
+ throw failure;
245
+ }
246
+ }
247
+ /** The recorder, open: its echo cancelled when `cancel`. */
248
+ async record(onPcm, rate, cancel) {
249
+ const recorder = new AudioRecorder(cancel
250
+ ? { iosVoiceProcessing: true, androidInputPreset: "voiceCommunication" }
251
+ : undefined);
180
252
  const mine = { recorder, open: true };
181
253
  const ready = recorder.onAudioReady({
182
254
  sampleRate: rate,
@@ -211,8 +283,10 @@ export class PhoneAudio {
211
283
  async stopCapture() {
212
284
  const mine = this.capture;
213
285
  this.capture = null;
214
- if (!mine)
286
+ if (!mine) {
287
+ await this.leaveHandsFree();
215
288
  return;
289
+ }
216
290
  try {
217
291
  // What the recorder hands over until it has stopped is the end of
218
292
  // the last word: delivered before this settles.
@@ -221,6 +295,8 @@ export class PhoneAudio {
221
295
  finally {
222
296
  mine.open = false;
223
297
  mine.recorder.clearOnAudioReady();
298
+ // Once the recorder has let go: Android's session is made again.
299
+ await this.leaveHandsFree();
224
300
  }
225
301
  }
226
302
  async resume() {
@@ -232,43 +308,126 @@ export class PhoneAudio {
232
308
  this.tell(this.interruptions, false);
233
309
  }
234
310
  setOutput(output) {
235
- session.configure(output);
311
+ this.output = output;
312
+ session.configure(output, this.cancelling).catch(() => {
313
+ // swallowed: the session stays as it was — the route the system chose.
314
+ });
315
+ if (this.cancelling)
316
+ this.reroute();
317
+ }
318
+ /** Hands-free's first step on sound over the reply: lowered, until the words say who it was. */
319
+ duck(on) {
320
+ this.volume = on ? DUCKED : 1;
321
+ if (this.gain)
322
+ this.gain.gain.value = this.volume;
236
323
  }
237
324
  async handsFree() {
238
- return { ok: false, reason: PHONE_ECHO };
325
+ if (session.cancelsEcho())
326
+ return { ok: true };
327
+ const version = session.libraryVersion();
328
+ return {
329
+ ok: false,
330
+ reason: version.startsWith("0.") ? PHONE_ECHO : untestedBuild(version),
331
+ };
239
332
  }
240
333
  onInterruption(fn) {
241
334
  return add(this.interruptions, fn);
242
335
  }
243
336
  dispose() {
337
+ this.disposed = true;
244
338
  this.stop();
339
+ // Hands-free's session undone with the recorder.
245
340
  void this.stopCapture();
246
341
  this.unwatch();
247
342
  void this.context?.close();
248
343
  this.context = null;
249
344
  this.queue = null;
345
+ this.gain = null;
250
346
  }
251
347
  /** Behind what is queued; the playhead moves to its end. */
252
348
  enqueue(buffer) {
253
349
  this.playing.add(this.queue.enqueueBuffer(buffer));
254
350
  this.playhead += buffer.duration;
255
351
  }
352
+ /**
353
+ * The session hands-free's, Android's reply on the call's path and the
354
+ * call's route: before the recorder starts, so the canceller hears the
355
+ * first reply too.
356
+ */
357
+ async enterHandsFree() {
358
+ this.cancelling = true;
359
+ this.routed = null;
360
+ await this.reconfigure(true);
361
+ this.unroute ?? (this.unroute = session.watchRoutes(() => this.reroute()));
362
+ this.reroute();
363
+ if (this.context && this.playing.size === 0) {
364
+ this.player(this.context.sampleRate);
365
+ }
366
+ }
367
+ /** Back to push-to-talk's session; the reply leaves the call's path once it has played out. */
368
+ async leaveHandsFree() {
369
+ if (!this.cancelling)
370
+ return;
371
+ this.cancelling = false;
372
+ this.unroute?.();
373
+ this.unroute = null;
374
+ await this.reconfigure(false);
375
+ }
376
+ /**
377
+ * The session for hands-free or not — disposed, the mode given back and
378
+ * nothing taken again for a screen gone — its focus's comings and goings
379
+ * no interruption.
380
+ */
381
+ async reconfigure(handsFree) {
382
+ this.settling = Number.POSITIVE_INFINITY;
383
+ try {
384
+ await session.configure(this.output, handsFree, !this.disposed);
385
+ }
386
+ finally {
387
+ this.settling = Date.now() + SETTLE_MS;
388
+ }
389
+ }
390
+ reroute() {
391
+ session
392
+ .route(this.output, this.routed)
393
+ .then((device) => {
394
+ this.routed = device;
395
+ })
396
+ .catch(() => {
397
+ // swallowed: the system's route stands — the loudspeaker, or the earpiece in a call.
398
+ });
399
+ }
256
400
  /** The queue for a reply at `rate`: this one, or a new context's. */
257
401
  player(rate) {
258
- if (this.queue && this.context?.sampleRate === rate)
402
+ const profile = this.cancelling && session.android() ? "voiceCommunication" : "media";
403
+ if (this.queue &&
404
+ this.context?.sampleRate === rate &&
405
+ // Onto the call's path or off it, never mid-reply.
406
+ (this.profile === profile || this.playing.size > 0)) {
259
407
  return this.queue;
408
+ }
260
409
  this.stop();
261
410
  void this.context?.close();
262
- const context = new AudioContext({ sampleRate: rate });
411
+ const context = new AudioContext(profile === "media"
412
+ ? { sampleRate: rate }
413
+ : { sampleRate: rate, androidOutputProfile: profile });
414
+ this.profile = profile;
263
415
  const queue = context.createBufferQueueSource();
264
- queue.connect(context.destination);
265
- queue.onBufferEnded = ({ bufferId }) => {
416
+ const gain = context.createGain();
417
+ gain.gain.value = this.volume;
418
+ queue.connect(gain);
419
+ gain.connect(context.destination);
420
+ const ended = ({ bufferId }) => {
266
421
  if (this.queue !== queue)
267
422
  return;
268
423
  if (this.playing.delete(bufferId) && this.playing.size === 0) {
269
424
  this.tell(this.drained);
270
425
  }
271
426
  };
427
+ // 0.13's name, and 1.0's: each takes the other's as a plain property,
428
+ // set for nothing — and with the wrong one alone no reply ever ends.
429
+ queue.onBufferEnded = ended;
430
+ queue.onbufferended = ended;
272
431
  // Never `start()`: 0.13.6's wrapper defaults `offset` to -1 and then
273
432
  // throws on its own default (`offset must be a finite non-negative
274
433
  // number: -1`), which crashed the first reply on Android's emulator.
@@ -279,6 +438,7 @@ export class PhoneAudio {
279
438
  this.quiet(context);
280
439
  this.context = context;
281
440
  this.queue = queue;
441
+ this.gain = gain;
282
442
  return queue;
283
443
  }
284
444
  /**
@@ -26,9 +26,9 @@ export interface AudioOptions {
26
26
  leadInMs?: number;
27
27
  /**
28
28
  * Milliseconds from the phone's player to the ear, which `heardTime()`
29
- * takes off the clock: `react-native-audio-api` 0.13 cannot say it, so
30
- * `0` unless set — Bluetooth earbuds add about 150 to 250. A browser says
31
- * its own.
29
+ * takes off the clock — and half-duplex waits out: `react-native-audio-api`
30
+ * 0.13 cannot say it, so `0` unless set — Bluetooth earbuds add about 150
31
+ * to 250. A browser says its own.
32
32
  */
33
33
  outputLatencyMs?: number;
34
34
  /** The microphone, by its `inputs()` id, in a browser; a phone's system chooses. */
@@ -57,7 +57,9 @@ export interface DeviceAudio extends AgentAudio, PlaybackClock, DeviceChoices {
57
57
  /**
58
58
  * Whether hands-free may run (`useAgent`'s `listen`): on the web where
59
59
  * the browser cancels echo; on a phone not until `react-native-audio-api`
60
- * 1.0 does — with headphones on, an app passes `assumeNoEcho`.
60
+ * 1.0 does — with headphones on, an app passes `assumeNoEcho`. Half-duplex
61
+ * (`listen({ halfDuplex: true })`) needs no echo cancelled, and runs
62
+ * everywhere.
61
63
  */
62
64
  handsFree(): Promise<{
63
65
  ok: true;
@@ -65,6 +67,8 @@ export interface DeviceAudio extends AgentAudio, PlaybackClock, DeviceChoices {
65
67
  ok: false;
66
68
  reason: string;
67
69
  }>;
70
+ /** Hands-free's first step: the reply quieter while it may be talked over, or as before. */
71
+ duck(on: boolean): void;
68
72
  }
69
73
  /** `createAudio`'s signature, which each platform's file implements. */
70
74
  export type CreateAudio = (options?: AudioOptions) => DeviceAudio;
package/dist/session.d.ts CHANGED
@@ -1,6 +1,44 @@
1
+ import { type CommunicationDevice } from "react-native-audio-api";
1
2
  import type { Output } from "./device-audio.js";
2
- /** The audio session's category, mode and options for `output`. */
3
- export declare function configure(output: Output): void;
3
+ /**
4
+ * **The one `react-native-audio-api` build hands-free runs on before 1.0**
5
+ * (D17, D18): 1.0 is the first with echo cancellation — iOS's voice
6
+ * processing, Android's voice-communication audio — and is not released
7
+ * (no date, 2026-11-02), so one nightly, read in its source and tested,
8
+ * never "the latest": nightlies change under an app, and its iOS voice
9
+ * processing was weeks old. This one is commit 6fd4c68, built unchanged on
10
+ * 2026-10-31, 11-01 and 11-02 — the build npm's `audio-api-nightly` tag
11
+ * named on 2026-11-02. **Moved only for a fix we need, after a test on a
12
+ * real iPhone and Android; 1.0 replaces it.** Every other build, 0.13
13
+ * included, refuses hands-free and runs push-to-talk and half-duplex.
14
+ * `package.json`'s peer range names it too (`test/package.test.ts`).
15
+ */
16
+ export declare const PINNED_NIGHTLY = "1.0.0-nightly-6fd4c68-20261102";
17
+ /** The library's version, as installed. */
18
+ export declare const libraryVersion: () => string;
19
+ /** Whether this is the build hands-free runs on: echo can be cancelled. */
20
+ export declare const cancelsEcho: () => boolean;
21
+ /** Android, where the reply must be on the call's path for its echo to be cancelled. */
22
+ export declare const android: () => boolean;
23
+ /**
24
+ * The audio session's category, mode and options for `output`, for
25
+ * hands-free or not. Settles at once but for Android's session on 1.0,
26
+ * which takes a mode only as it is made active, so is made again.
27
+ */
28
+ export declare function configure(output: Output, handsFree?: boolean,
29
+ /** `false` when the adapter is gone: the mode given back, the session left inactive. */
30
+ active?: boolean): Promise<void>;
31
+ /**
32
+ * Hands-free on Android: the call's voice to Bluetooth earbuds when they
33
+ * are connected — and their microphone with it (D8) — else to `output`.
34
+ * Asked again when a device comes or goes, never twice the same: the
35
+ * library's own Bluetooth link sets the device too, and its change is told
36
+ * as a route's. `asked`, the last asked; answers what is now. Nothing on
37
+ * iOS, whose route follows `allowBluetoothHFP` by itself, nor on 0.13.
38
+ */
39
+ export declare function route(output: Output, asked: CommunicationDevice | null): Promise<CommunicationDevice | null>;
40
+ /** Every change of the devices — earbuds connected, taken out — until the answer is called. */
41
+ export declare function watchRoutes(fn: () => void): () => void;
4
42
  /** Make the session active: before the first sound, and after an interruption. */
5
43
  export declare function activate(): Promise<void>;
6
44
  /** Whether the app may record: asked only when not granted yet. */
package/dist/session.js CHANGED
@@ -1,24 +1,33 @@
1
1
  /**
2
2
  * The phone's audio session: how the app's audio sits beside the system's
3
3
  * — the speaker, the earbuds, calls and other apps. With `audio.native.ts`
4
- * the only file that touches `react-native-audio-api`, so its 1.0, which
5
- * renames the session's API (`setSystemOptions`), changes these two and the
6
- * peer range, and nothing an app sees.
4
+ * the only file that touches `react-native-audio-api`, and **the one that
5
+ * knows which of the library it has**: 0.13, or the one 1.0 nightly
6
+ * hands-free runs on (`PINNED_NIGHTLY`), whose session API is renamed
7
+ * (`setSystemOptions`) — told apart by what the library has, at run time,
8
+ * so one build of this package runs on both.
7
9
  *
8
10
  * - **Play and record at once, from the loudspeaker** (`playAndRecord`,
9
11
  * `defaultToSpeaker`): without it iOS plays a recording app's audio from
10
12
  * the earpiece. Headphones and Bluetooth take over when connected; the
11
13
  * earpiece is the app's choice (`Output`). Android keeps the loudspeaker
12
- * until 1.0: 0.13.6 sets no communication mode, and so chooses no
13
- * earpiece.
14
+ * but in hands-free: the library sets no communication mode otherwise,
15
+ * and so chooses no earpiece.
14
16
  * - **The earbuds' microphone on iPhones** (`allowBluetoothHFP`): their
15
17
  * hands-free profile, at call quality, for the whole conversation — not
16
- * switched per press, since every switch moves the route and 0.13.6's
17
- * engine can die on a Bluetooth route change (its #1209, for R3). Android
18
- * records from the phone until 1.0, whose `setInputDevice` works.
19
- * - **`default` mode, not `voiceChat`**: on 0.13.6 `voiceChat` cancels no
20
- * echo — push-to-talk needs none, as the reply stops while the person
21
- * speaks — and plays at the call volume, quieter on the loudspeaker.
18
+ * switched per press, since every switch moves the route and the
19
+ * library's engine can die on a Bluetooth route change (its #1209).
20
+ * - **`default` mode, not `voiceChat`**, for push-to-talk and half-duplex:
21
+ * neither needs echo cancelled — the reply stops while the person speaks,
22
+ * or nothing is heard while it plays — and `voiceChat` plays at the call
23
+ * volume, quieter on the loudspeaker.
24
+ * - **Hands-free, on the pinned nightly** (`handsFree`): iOS's
25
+ * `videoChat` — set before the recorder's voice processing, which would
26
+ * otherwise move the session to `voiceChat` and the earpiece — and
27
+ * Android's communication mode, its voice to the loudspeaker (or the
28
+ * earpiece asked), to Bluetooth earbuds when they are connected: the
29
+ * canceller hears only audio on the call's path, and the earbuds'
30
+ * microphone comes with it (D8).
22
31
  * - **Interruptions are watched** (`observeAudioInterruptions`): a call,
23
32
  * Siri or another app on iOS, a loss of audio focus on Android — which
24
33
  * watching *asks for*, so other apps' audio stops while a conversation is
@@ -26,17 +35,104 @@
26
35
  * audio in this release (the plugin turns the library's off), so the
27
36
  * conversation pauses with the screen and goes on when it is back.
28
37
  */
29
- import { AppState } from "react-native";
38
+ import { AppState, Platform } from "react-native";
30
39
  import { AudioManager } from "react-native-audio-api";
31
- /** The audio session's category, mode and options for `output`. */
32
- export function configure(output) {
33
- AudioManager.setAudioSessionOptions({
34
- iosCategory: "playAndRecord",
35
- iosMode: "default",
36
- iosOptions: output === "speaker"
37
- ? ["defaultToSpeaker", "allowBluetoothHFP"]
38
- : ["allowBluetoothHFP"],
40
+ import library from "react-native-audio-api/package.json";
41
+ /**
42
+ * **The one `react-native-audio-api` build hands-free runs on before 1.0**
43
+ * (D17, D18): 1.0 is the first with echo cancellation — iOS's voice
44
+ * processing, Android's voice-communication audio — and is not released
45
+ * (no date, 2026-11-02), so one nightly, read in its source and tested,
46
+ * never "the latest": nightlies change under an app, and its iOS voice
47
+ * processing was weeks old. This one is commit 6fd4c68, built unchanged on
48
+ * 2026-10-31, 11-01 and 11-02 — the build npm's `audio-api-nightly` tag
49
+ * named on 2026-11-02. **Moved only for a fix we need, after a test on a
50
+ * real iPhone and Android; 1.0 replaces it.** Every other build, 0.13
51
+ * included, refuses hands-free and runs push-to-talk and half-duplex.
52
+ * `package.json`'s peer range names it too (`test/package.test.ts`).
53
+ */
54
+ export const PINNED_NIGHTLY = "1.0.0-nightly-6fd4c68-20261102";
55
+ /** The library's version, as installed. */
56
+ export const libraryVersion = () => library.version;
57
+ /** 1.0's session API: `setSystemOptions` for 0.13's `setAudioSessionOptions`. */
58
+ const isOne = () => typeof AudioManager.setSystemOptions === "function";
59
+ /** Whether this is the build hands-free runs on: echo can be cancelled. */
60
+ export const cancelsEcho = () => isOne() && library.version === PINNED_NIGHTLY;
61
+ /** Android, where the reply must be on the call's path for its echo to be cancelled. */
62
+ export const android = () => Platform.OS === "android";
63
+ /**
64
+ * The audio session's category, mode and options for `output`, for
65
+ * hands-free or not. Settles at once but for Android's session on 1.0,
66
+ * which takes a mode only as it is made active, so is made again.
67
+ */
68
+ export async function configure(output, handsFree = false,
69
+ /** `false` when the adapter is gone: the mode given back, the session left inactive. */
70
+ active = true) {
71
+ const options = output === "speaker"
72
+ ? ["defaultToSpeaker", "allowBluetoothHFP"]
73
+ : ["allowBluetoothHFP"];
74
+ // Voice processing takes a mode that is no call's to `voiceChat` itself,
75
+ // and the library then sets its own again on every activation: a chat
76
+ // mode set first — `videoChat`, the loudspeaker's, or `voiceChat`, the
77
+ // earpiece's. Never without it: the call volume.
78
+ const mode = !handsFree
79
+ ? "default"
80
+ : output === "speaker"
81
+ ? "videoChat"
82
+ : "voiceChat";
83
+ if (!isOne()) {
84
+ AudioManager.setAudioSessionOptions?.({
85
+ iosCategory: "playAndRecord",
86
+ iosMode: mode,
87
+ iosOptions: options,
88
+ });
89
+ return;
90
+ }
91
+ // Both groups every time: one left out is set to its defaults.
92
+ AudioManager.setSystemOptions?.({
93
+ ios: { category: "playAndRecord", mode, options },
94
+ android: handsFree
95
+ ? { mode: "inCommunication", communicationDevice: output }
96
+ : {},
39
97
  });
98
+ if (android() && communicating !== handsFree) {
99
+ // Its communication mode is set, and the mode before it given back,
100
+ // only as the session is made active and inactive — which gives up the
101
+ // audio focus and takes it again: told as an interruption, which the
102
+ // adapter takes for none (`audio.native.ts`, `settling`).
103
+ communicating = handsFree;
104
+ await AudioManager.setAudioSessionActivity(false);
105
+ if (active)
106
+ await AudioManager.setAudioSessionActivity(true);
107
+ }
108
+ }
109
+ /** Android's communication mode is on: hands-free's session was made. */
110
+ let communicating = false;
111
+ /**
112
+ * Hands-free on Android: the call's voice to Bluetooth earbuds when they
113
+ * are connected — and their microphone with it (D8) — else to `output`.
114
+ * Asked again when a device comes or goes, never twice the same: the
115
+ * library's own Bluetooth link sets the device too, and its change is told
116
+ * as a route's. `asked`, the last asked; answers what is now. Nothing on
117
+ * iOS, whose route follows `allowBluetoothHFP` by itself, nor on 0.13.
118
+ */
119
+ export async function route(output, asked) {
120
+ if (!android() || !isOne() || !AudioManager.getDevicesInfo)
121
+ return asked;
122
+ const { availableOutputs } = await AudioManager.getDevicesInfo();
123
+ // Earbuds with a microphone, as the library names Android's device
124
+ // types: a speaker on Bluetooth alone (A2DP) has none, and with
125
+ // `systemDefault` the call would go to the earpiece.
126
+ const earbuds = availableOutputs.some((device) => /^Bluetooth (SCO|LE Headset)$/.test(device.category));
127
+ const device = earbuds ? "systemDefault" : output;
128
+ if (device !== asked)
129
+ await AudioManager.setCommunicationDevice?.(device);
130
+ return device;
131
+ }
132
+ /** Every change of the devices — earbuds connected, taken out — until the answer is called. */
133
+ export function watchRoutes(fn) {
134
+ const subscription = AudioManager.addSystemEventListener("routeChange", () => fn());
135
+ return () => subscription.remove();
40
136
  }
41
137
  /** Make the session active: before the first sound, and after an interruption. */
42
138
  export function activate() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lumavox/react-native",
3
- "version": "0.16.0",
4
- "description": "Voice conversations with Lumavox agents in a React Native app, on iOS, Android and the web: the client, the React hooks, and push-to-talk audio on react-native-audio-api.",
3
+ "version": "0.17.0",
4
+ "description": "Voice conversations with Lumavox agents in a React Native app, on iOS, Android and the web: the client, the React hooks, and push-to-talk, half-duplex and hands-free audio on react-native-audio-api.",
5
5
  "license": "MIT",
6
6
  "files": [
7
7
  "dist",
@@ -30,13 +30,13 @@
30
30
  "typecheck": "tsc --noEmit && tsc --noEmit -p test"
31
31
  },
32
32
  "dependencies": {
33
- "@lumavox/client": "0.16.0",
34
- "@lumavox/react": "0.16.0"
33
+ "@lumavox/client": "0.17.0",
34
+ "@lumavox/react": "0.17.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "react": ">=18",
38
38
  "react-native": ">=0.76",
39
- "react-native-audio-api": ">=0.13.6 <0.14"
39
+ "react-native-audio-api": ">=0.13.6 <0.14 || 1.0.0-nightly-6fd4c68-20261102"
40
40
  },
41
41
  "peerDependenciesMeta": {
42
42
  "react-native": {