@lumavox/react-native 0.15.1 → 0.16.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
@@ -58,6 +58,18 @@ function Talk({ agentId }: { agentId: string }) {
58
58
  echo; on a phone it is refused until `react-native-audio-api` 1.0 does —
59
59
  `listen({ assumeNoEcho: true })` with headphones on. Background audio
60
60
  comes with 1.0 too.
61
+ - **A clock for a lip-synced face or subtitles**, on a phone and the web:
62
+ `audio.onScheduled(({ pcm, sample_rate, chunk, startsAt }) => …)` tells
63
+ every frame as it is queued, before it plays; draw against
64
+ `audio.heardTime()`; `audio.onStopped` when a stop drops what was
65
+ queued; `audio.onCapture` for the microphone's frames.
66
+ `createAudio({ leadInMs: 350 })` gives a model that long to read a
67
+ reply's first frame (and adds it to the first word); `outputLatencyMs`
68
+ is what the phone's sound takes to the ear, which the library cannot
69
+ say — Bluetooth earbuds add about 150–250 ms.
70
+ - On the web the app may choose the microphone, its processing and the
71
+ speaker (`inputs()`, `setInput`, `setProcessing`, `outputs()`,
72
+ `setOutputDevice`); on a phone the system does — `[]` and `false`.
61
73
 
62
74
  The guide, with the bare React Native setup: the docs' **SDKs** tab,
63
75
  *React Native*.
@@ -1,3 +1,46 @@
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
4
+ * `session.ts`, the only file that touches the library.
5
+ *
6
+ * - **The reply plays through one `AudioBufferQueueSourceNode`**, started
7
+ * once: each frame's PCM is a buffer enqueued behind the last, gapless,
8
+ * and a queue that runs dry plays silence until the next. `clearBuffers`
9
+ * stops it at once.
10
+ * - **The context runs at the reply's rate**, rebuilt — what is queued
11
+ * dropped — when an `audio.start` brings another: 0.13.6's queue plays
12
+ * every buffer at its context's rate whatever the buffer says (its
13
+ * `AudioBufferBaseSourceNode`), so a 24 kHz reply in a 48 kHz context
14
+ * would play an octave up. The device's own resampling does the rest.
15
+ * - **The agent stopped speaking when the last buffer enqueued has ended**,
16
+ * counted by the ids `enqueueBuffer` answers: the queue's own
17
+ * `isLastBufferInQueue` can say so just as the next frame is enqueued, and
18
+ * a buffer `clearBuffers` dropped never ends.
19
+ * - **The clock is the context's, and the playhead ours**: the queue says
20
+ * nothing of when a buffer starts, so each frame starts where the last
21
+ * ends — `max(playhead, currentTime)`, as the web's player keeps it — and
22
+ * is told so (`onScheduled`). A lead-in (`leadInMs`) is a buffer of
23
+ * silence enqueued ahead of a frame that finds the queue dry. A new rate's
24
+ * context starts its clock at 0 again, what the old one queued stopped.
25
+ * - **What is heard is the clock less the app's `outputLatencyMs`**:
26
+ * 0.13.6 says nothing of the device's latency — its source nodes'
27
+ * `getLatency()` is their time-stretcher's — so the app, which can know
28
+ * its earbuds are on, says it.
29
+ * - **The microphone records at 16 kHz**, mono, 100 ms a buffer — less to
30
+ * send than 24 kHz, and a rate the socket takes. `onAudioReady` hands the
31
+ * samples over JSI as floats, at the rate the device chose, which need
32
+ * not be the one asked: read from the buffer and resampled (`resample`).
33
+ * - **An interruption** — a call, Siri, another app, the app leaving the
34
+ * screen — suspends the context, so the reply's frames keep queueing and
35
+ * play from where they stopped; and closes the recorder, whose audio is
36
+ * thrown away (the hook discards the recording on the socket). The audio
37
+ * comes back on its own when the system says it may, else on `resume()`
38
+ * 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`.
42
+ */
43
+ import { type AgentAudio, type AudioDevice, type ScheduledAudio } from "@lumavox/client";
1
44
  import type { AudioOptions, CreateAudio, DeviceAudio, Output } from "./device-audio.js";
2
45
  /** Hz the microphone records at unless a rate the socket takes is asked. */
3
46
  export declare const RECORD_RATE = 16000;
@@ -5,23 +48,42 @@ export declare const RECORD_RATE = 16000;
5
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.";
6
49
  export declare class PhoneAudio implements DeviceAudio {
7
50
  interrupted: boolean;
51
+ /** On a phone the system chooses the speaker. */
52
+ readonly canChooseOutput = false;
8
53
  private context;
9
54
  private queue;
10
55
  /** The ids of the buffers enqueued and not yet played. */
11
56
  private readonly playing;
57
+ /** Where the last buffer enqueued ends, on the context's clock. */
58
+ private playhead;
12
59
  /** A byte of a sample split across two frames. */
13
60
  private carry;
14
61
  private capture;
15
62
  private readonly drained;
63
+ private readonly stopped;
64
+ private readonly scheduled;
65
+ private readonly captured;
16
66
  private readonly interruptions;
17
67
  private readonly unwatch;
68
+ /** Seconds. */
69
+ private readonly leadIn;
70
+ private readonly latency;
18
71
  constructor(options?: AudioOptions);
19
72
  prime(): Promise<void>;
20
- play(pcm: ArrayBuffer, frame: {
21
- sample_rate: number;
22
- }): void;
73
+ play(pcm: ArrayBuffer, frame: Parameters<AgentAudio["play"]>[1]): void;
23
74
  stop(): void;
24
75
  onDrained(fn: () => void): () => void;
76
+ now(): number;
77
+ heardTime(): number;
78
+ onScheduled(fn: (audio: ScheduledAudio) => void): () => void;
79
+ onStopped(fn: () => void): () => void;
80
+ onCapture(fn: (pcm: ArrayBuffer, rate: number) => void): () => void;
81
+ /** The system's microphone and speaker: none listed, none set. */
82
+ inputs(): Promise<AudioDevice[]>;
83
+ outputs(): Promise<AudioDevice[]>;
84
+ setInput(): void;
85
+ setProcessing(): void;
86
+ setOutputDevice(): Promise<boolean>;
25
87
  /** At 16 kHz, or at the rate asked when the socket takes it. */
26
88
  startCapture(onPcm: (pcm: ArrayBuffer) => void, options?: {
27
89
  rate?: number;
@@ -34,18 +96,25 @@ export declare class PhoneAudio implements DeviceAudio {
34
96
  handsFree(): ReturnType<DeviceAudio["handsFree"]>;
35
97
  onInterruption(fn: (interrupted: boolean) => void): () => void;
36
98
  dispose(): void;
99
+ /** Behind what is queued; the playhead moves to its end. */
100
+ private enqueue;
37
101
  /** The queue for a reply at `rate`: this one, or a new context's. */
38
102
  private player;
39
103
  /**
40
- * The wire's PCM as floats. **A split sample is carried, never dropped**:
41
- * the edge sends whole samples (`apps/edge/src/voice/tts.ts`), but an odd
42
- * byte dropped shifts every byte after it and turns the rest into noise —
43
- * the bug that once made the console's playback white noise.
104
+ * The wire's PCM in whole samples. **A split sample is carried, never
105
+ * dropped**: the edge sends whole samples (`apps/edge/src/voice/tts.ts`),
106
+ * but an odd byte dropped shifts every byte after it and turns the rest
107
+ * into noise — the bug that once made the console's playback white noise.
44
108
  */
45
- private samples;
109
+ private whole;
46
110
  /** The audio was taken: the reply waits, the recording is thrown away. */
47
111
  private interrupt;
48
112
  private quiet;
113
+ /**
114
+ * Call each of the app's handlers. One that throws is rethrown on its own
115
+ * turn, where the app's error reporting sees it — not here, where it
116
+ * would stop the player or the recorder halfway.
117
+ */
49
118
  private tell;
50
119
  }
51
120
  export declare const createAudio: CreateAudio;
@@ -16,6 +16,16 @@
16
16
  * counted by the ids `enqueueBuffer` answers: the queue's own
17
17
  * `isLastBufferInQueue` can say so just as the next frame is enqueued, and
18
18
  * a buffer `clearBuffers` dropped never ends.
19
+ * - **The clock is the context's, and the playhead ours**: the queue says
20
+ * nothing of when a buffer starts, so each frame starts where the last
21
+ * ends — `max(playhead, currentTime)`, as the web's player keeps it — and
22
+ * is told so (`onScheduled`). A lead-in (`leadInMs`) is a buffer of
23
+ * silence enqueued ahead of a frame that finds the queue dry. A new rate's
24
+ * context starts its clock at 0 again, what the old one queued stopped.
25
+ * - **What is heard is the clock less the app's `outputLatencyMs`**:
26
+ * 0.13.6 says nothing of the device's latency — its source nodes'
27
+ * `getLatency()` is their time-stretcher's — so the app, which can know
28
+ * its earbuds are on, says it.
19
29
  * - **The microphone records at 16 kHz**, mono, 100 ms a buffer — less to
20
30
  * send than 24 kHz, and a rate the socket takes. `onAudioReady` hands the
21
31
  * samples over JSI as floats, at the rate the device chose, which need
@@ -42,15 +52,24 @@ const RECORD_SECONDS = 0.1;
42
52
  export class PhoneAudio {
43
53
  constructor(options = {}) {
44
54
  this.interrupted = false;
55
+ /** On a phone the system chooses the speaker. */
56
+ this.canChooseOutput = false;
45
57
  this.context = null;
46
58
  this.queue = null;
47
59
  /** The ids of the buffers enqueued and not yet played. */
48
60
  this.playing = new Set();
61
+ /** Where the last buffer enqueued ends, on the context's clock. */
62
+ this.playhead = 0;
49
63
  /** A byte of a sample split across two frames. */
50
64
  this.carry = null;
51
65
  this.capture = null;
52
66
  this.drained = new Set();
67
+ this.stopped = new Set();
68
+ this.scheduled = new Set();
69
+ this.captured = new Set();
53
70
  this.interruptions = new Set();
71
+ this.leadIn = (options.leadInMs ?? 0) / 1000;
72
+ this.latency = (options.outputLatencyMs ?? 0) / 1000;
54
73
  session.configure(options.output ?? "speaker");
55
74
  this.unwatch = session.watch(({ began, shouldResume }) => {
56
75
  if (began)
@@ -67,28 +86,80 @@ export class PhoneAudio {
67
86
  this.player(this.context?.sampleRate ?? DEFAULT_SAMPLE_RATE);
68
87
  }
69
88
  play(pcm, frame) {
70
- const queue = this.player(frame.sample_rate);
71
- const samples = this.samples(pcm);
72
- if (samples.length === 0)
89
+ const rate = frame.sample_rate;
90
+ this.player(rate);
91
+ const whole = this.whole(pcm);
92
+ if (whole.byteLength === 0)
73
93
  return;
74
- const buffer = this.context.createBuffer(1, samples.length, frame.sample_rate);
94
+ const context = this.context;
95
+ const samples = pcm16ToFloat(whole);
96
+ const buffer = context.createBuffer(1, samples.length, rate);
75
97
  buffer.copyToChannel(samples, 0);
76
- this.playing.add(queue.enqueueBuffer(buffer));
98
+ // Read once the buffer is made, as near its enqueue as can be: copying
99
+ // a frame over JSI took 11 ms on Android's emulator, and a frame that
100
+ // starts at once was told as starting that long before it was queued.
101
+ const now = context.currentTime;
102
+ if (this.playhead <= now) {
103
+ // The queue ran dry: this frame starts a run, after the lead-in.
104
+ this.playhead = now;
105
+ // Never an empty buffer: the library throws on a length of 0.
106
+ const silence = Math.round(this.leadIn * rate);
107
+ if (silence > 0)
108
+ this.enqueue(context.createBuffer(1, silence, rate));
109
+ }
110
+ const startsAt = this.playhead;
111
+ this.enqueue(buffer);
112
+ this.tell(this.scheduled, {
113
+ pcm: whole,
114
+ sample_rate: rate,
115
+ channels: frame.channels,
116
+ chunk: frame.chunk,
117
+ turn: frame.turn,
118
+ startsAt,
119
+ duration: buffer.duration,
120
+ });
77
121
  }
78
122
  stop() {
79
123
  const was = this.playing.size > 0;
80
124
  this.queue?.clearBuffers();
81
125
  this.playing.clear();
126
+ this.playhead = 0;
82
127
  // Half a sample of audio that will never play now.
83
128
  this.carry = null;
84
- if (was)
85
- this.tell(this.drained);
129
+ if (!was)
130
+ return;
131
+ this.tell(this.drained);
132
+ this.tell(this.stopped);
86
133
  }
87
134
  onDrained(fn) {
88
- this.drained.add(fn);
89
- return () => {
90
- this.drained.delete(fn);
91
- };
135
+ return add(this.drained, fn);
136
+ }
137
+ now() {
138
+ return this.context?.currentTime ?? 0;
139
+ }
140
+ heardTime() {
141
+ return Math.max(0, this.now() - this.latency);
142
+ }
143
+ onScheduled(fn) {
144
+ return add(this.scheduled, fn);
145
+ }
146
+ onStopped(fn) {
147
+ return add(this.stopped, fn);
148
+ }
149
+ onCapture(fn) {
150
+ return add(this.captured, fn);
151
+ }
152
+ /** The system's microphone and speaker: none listed, none set. */
153
+ async inputs() {
154
+ return [];
155
+ }
156
+ async outputs() {
157
+ return [];
158
+ }
159
+ setInput() { }
160
+ setProcessing() { }
161
+ async setOutputDevice() {
162
+ return false;
92
163
  }
93
164
  /** At 16 kHz, or at the rate asked when the socket takes it. */
94
165
  async startCapture(onPcm, options) {
@@ -118,7 +189,9 @@ export class PhoneAudio {
118
189
  const at = buffer.sampleRate === rate
119
190
  ? samples
120
191
  : resample(samples, buffer.sampleRate, rate);
121
- onPcm(floatToPcm16(at).buffer);
192
+ const pcm = floatToPcm16(at).buffer;
193
+ onPcm(pcm);
194
+ this.tell(this.captured, pcm, rate);
122
195
  });
123
196
  if (ready.status === "error")
124
197
  throw new Error(ready.message);
@@ -165,10 +238,7 @@ export class PhoneAudio {
165
238
  return { ok: false, reason: PHONE_ECHO };
166
239
  }
167
240
  onInterruption(fn) {
168
- this.interruptions.add(fn);
169
- return () => {
170
- this.interruptions.delete(fn);
171
- };
241
+ return add(this.interruptions, fn);
172
242
  }
173
243
  dispose() {
174
244
  this.stop();
@@ -178,6 +248,11 @@ export class PhoneAudio {
178
248
  this.context = null;
179
249
  this.queue = null;
180
250
  }
251
+ /** Behind what is queued; the playhead moves to its end. */
252
+ enqueue(buffer) {
253
+ this.playing.add(this.queue.enqueueBuffer(buffer));
254
+ this.playhead += buffer.duration;
255
+ }
181
256
  /** The queue for a reply at `rate`: this one, or a new context's. */
182
257
  player(rate) {
183
258
  if (this.queue && this.context?.sampleRate === rate)
@@ -207,12 +282,12 @@ export class PhoneAudio {
207
282
  return queue;
208
283
  }
209
284
  /**
210
- * The wire's PCM as floats. **A split sample is carried, never dropped**:
211
- * the edge sends whole samples (`apps/edge/src/voice/tts.ts`), but an odd
212
- * byte dropped shifts every byte after it and turns the rest into noise —
213
- * the bug that once made the console's playback white noise.
285
+ * The wire's PCM in whole samples. **A split sample is carried, never
286
+ * dropped**: the edge sends whole samples (`apps/edge/src/voice/tts.ts`),
287
+ * but an odd byte dropped shifts every byte after it and turns the rest
288
+ * into noise — the bug that once made the console's playback white noise.
214
289
  */
215
- samples(pcm) {
290
+ whole(pcm) {
216
291
  let bytes = new Uint8Array(pcm);
217
292
  if (this.carry !== null) {
218
293
  const joined = new Uint8Array(bytes.length + 1);
@@ -225,7 +300,7 @@ export class PhoneAudio {
225
300
  this.carry = bytes[bytes.length - 1];
226
301
  bytes = bytes.slice(0, -1);
227
302
  }
228
- return pcm16ToFloat(bytes.buffer);
303
+ return bytes.buffer;
229
304
  }
230
305
  /** The audio was taken: the reply waits, the recording is thrown away. */
231
306
  interrupt() {
@@ -252,11 +327,30 @@ export class PhoneAudio {
252
327
  // swallowed: an interruption may have stopped the engine already.
253
328
  });
254
329
  }
330
+ /**
331
+ * Call each of the app's handlers. One that throws is rethrown on its own
332
+ * turn, where the app's error reporting sees it — not here, where it
333
+ * would stop the player or the recorder halfway.
334
+ */
255
335
  tell(listeners, ...args) {
256
- for (const fn of listeners)
257
- fn(...args);
336
+ for (const fn of listeners) {
337
+ try {
338
+ fn(...args);
339
+ }
340
+ catch (error) {
341
+ setTimeout(() => {
342
+ throw error;
343
+ });
344
+ }
345
+ }
258
346
  }
259
347
  }
348
+ function add(listeners, fn) {
349
+ listeners.add(fn);
350
+ return () => {
351
+ listeners.delete(fn);
352
+ };
353
+ }
260
354
  export const createAudio = (options) => new PhoneAudio(options);
261
355
  /** Ask for the microphone, if it was not granted: whether it is now. */
262
356
  export const requestMicrophone = () => session.microphone();
@@ -1,4 +1,5 @@
1
1
  import type { CreateAudio } from "./device-audio.js";
2
+ /** The lead-in, the microphone and its processing are the browser's; `output` and `outputLatencyMs` the phone's. */
2
3
  export declare const createAudio: CreateAudio;
3
4
  /** Ask for the microphone, if it was not granted: whether it is now. */
4
5
  export declare function requestMicrophone(): Promise<boolean>;
package/dist/audio.web.js CHANGED
@@ -1,45 +1,36 @@
1
+ /**
2
+ * A browser's player and recorder: what Metro picks for `./audio` on the
3
+ * web (`index.ts`), and what anything picking no platform file gets
4
+ * (`audio.ts`). `react-native-audio-api` has no recorder, no queue and no
5
+ * real permission request on the web, so this is the client's own
6
+ * `@lumavox/client/web-audio` — the console's — with the phone's extras
7
+ * answered as a browser can.
8
+ *
9
+ * - **`prime()` must run in the press that starts the conversation**:
10
+ * browsers start sound only from a gesture.
11
+ * - **No interruptions are told**: a browser pauses a tab's audio itself.
12
+ * - **No earpiece**: `setOutput` does nothing; the speaker is chosen by its
13
+ * id (`setOutputDevice`), where the browser lets a page.
14
+ * - **Hands-free where the browser cancels echo**, as the client's says
15
+ * (`handsFree`), its reply lowered while the person may be talking.
16
+ * - **The client's adapter itself, with the phone's extras added**: every
17
+ * method it has — the clock, the frames, the devices — is passed through
18
+ * by being it, so none can be left out.
19
+ * - **The import needs Metro's package `exports`** — on by default from
20
+ * React Native 0.79 and Expo SDK 53; before, an app sets
21
+ * `unstable_enablePackageExports` (the docs' React Native page).
22
+ */
1
23
  import { createWebAudio } from "@lumavox/client/web-audio";
2
- class BrowserAudio {
3
- constructor() {
4
- this.interrupted = false;
5
- this.web = createWebAudio();
6
- }
7
- prime() {
8
- return this.web.prime();
9
- }
10
- play(pcm, frame) {
11
- this.web.play(pcm, frame);
12
- }
13
- stop() {
14
- this.web.stop();
15
- }
16
- onDrained(fn) {
17
- return this.web.onDrained(fn);
18
- }
19
- startCapture(onPcm, options) {
20
- return this.web.startCapture(onPcm, options);
21
- }
22
- stopCapture() {
23
- return this.web.stopCapture();
24
- }
25
- dispose() {
26
- this.web.dispose();
27
- }
28
- resume() {
29
- return this.web.prime();
30
- }
31
- setOutput(_output) { }
32
- handsFree() {
33
- return this.web.handsFree();
34
- }
35
- duck(on) {
36
- this.web.duck(on);
37
- }
38
- onInterruption(_fn) {
39
- return () => { };
40
- }
41
- }
42
- export const createAudio = () => new BrowserAudio();
24
+ /** The lead-in, the microphone and its processing are the browser's; `output` and `outputLatencyMs` the phone's. */
25
+ export const createAudio = (options) => {
26
+ const web = createWebAudio(options);
27
+ return Object.assign(web, {
28
+ interrupted: false,
29
+ resume: () => web.prime(),
30
+ setOutput: () => { },
31
+ onInterruption: () => () => { },
32
+ });
33
+ };
43
34
  /** Ask for the microphone, if it was not granted: whether it is now. */
44
35
  export async function requestMicrophone() {
45
36
  try {
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * What `createAudio()` answers on every platform — a phone's
3
3
  * (`audio.native.ts`) and a browser's (`audio.web.ts`) — so an app written
4
- * once runs on both: the `AgentAudio` that `useAgent` drives, and what an
4
+ * once runs on both: the `AgentAudio` that `useAgent` drives, the clock
5
+ * and the frames an app draws against (`PlaybackClock`), the devices a
6
+ * browser lets it choose (`DeviceChoices`, none on a phone), and what an
5
7
  * app does with the device itself.
6
8
  */
7
- import type { AgentAudio } from "@lumavox/client";
9
+ import type { AgentAudio, DeviceChoices, PlaybackClock, Processing } from "@lumavox/client";
8
10
  /** Where the agent is heard with no headphones on. */
9
11
  export type Output = "speaker" | "earpiece";
10
12
  export interface AudioOptions {
@@ -15,8 +17,26 @@ export interface AudioOptions {
15
17
  * 1.0 lets the earpiece be chosen.
16
18
  */
17
19
  output?: Output;
20
+ /**
21
+ * Milliseconds a reply's first frame — or one after a gap — waits before
22
+ * it plays, for an app that reads each frame first (`onScheduled`): as
23
+ * much is added to the first word heard. None on a phone unless set, 60
24
+ * in a browser.
25
+ */
26
+ leadInMs?: number;
27
+ /**
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.
32
+ */
33
+ outputLatencyMs?: number;
34
+ /** The microphone, by its `inputs()` id, in a browser; a phone's system chooses. */
35
+ input?: string;
36
+ /** The browser's cleanup of the microphone; a phone's is the system's. */
37
+ processing?: Processing;
18
38
  }
19
- export interface DeviceAudio extends AgentAudio {
39
+ export interface DeviceAudio extends AgentAudio, PlaybackClock, DeviceChoices {
20
40
  /**
21
41
  * Get ready to play. **On the web, call it from the press that starts
22
42
  * the conversation**: browsers start sound only from a gesture. On a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumavox/react-native",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
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.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -30,8 +30,8 @@
30
30
  "typecheck": "tsc --noEmit && tsc --noEmit -p test"
31
31
  },
32
32
  "dependencies": {
33
- "@lumavox/client": "0.15.1",
34
- "@lumavox/react": "0.15.1"
33
+ "@lumavox/client": "0.16.0",
34
+ "@lumavox/react": "0.16.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "react": ">=18",