crowdplaysdk 0.6.3 → 0.7.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.
@@ -0,0 +1,259 @@
1
+ /**
2
+ * The agent audio tap, JavaScript side (engine 0.6.0+): the voice AI's call
3
+ * audio as it plays on this phone, in fixed frames of 16-bit mono PCM, for
4
+ * lip sync, visemes and meters. This is CALL audio (Path A), already
5
+ * compressed and decoded by the call; it never touches the recording.
6
+ *
7
+ * Pure TypeScript with no react-native import: option rules, the base64
8
+ * decoder, and the controller that orders start/stop and filters frames.
9
+ * index.ts wires the controller to the native module.
10
+ */
11
+
12
+ /** Options for CrowdPlay.startAgentAudio() and useAgentAudio(). */
13
+ export interface AgentAudioOptions {
14
+ /** Samples per frame, an integer of at least 1. Default 1024 (64 ms at
15
+ * 16 kHz). frameSize / sampleRate must be 10 ms to 1 s. */
16
+ frameSize?: number;
17
+ /** Output rate in Hz, an integer in 8000...48000. Default 16000. The
18
+ * call audio is resampled to it and downmixed to mono. */
19
+ sampleRate?: number;
20
+ }
21
+
22
+ /** Payload of the 'agentAudio' event: one frame of the voice AI's call
23
+ * audio, pulled for playout. */
24
+ export interface AgentAudioFrame {
25
+ /** Base64 of Int16 little-endian mono PCM, exactly `frameSize` samples.
26
+ * decodeAgentAudio(frame.pcm) gives the samples. */
27
+ pcm: string;
28
+ sampleRate: number;
29
+ frameSize: number;
30
+ /** 0, 1, 2... per start, contiguous across delivered frames. */
31
+ seq: number;
32
+ /** True when this frame does not directly continue the previous frame
33
+ * this listener received: its first frame, a track switch or reconnect,
34
+ * a format change, or lost audio. Reset any smoothing state. */
35
+ discontinuity: boolean;
36
+ /** Which start this frame belongs to (frames of a stopped tap are
37
+ * dropped before they reach the app). */
38
+ tap: number;
39
+ /** When the frame's first sample reaches the listener, in Unix ms on this
40
+ * phone's clock (compare with Date.now()): its playout pull plus
41
+ * `outputLatencyMs`. Show the frame's mouth shape at this moment. It is
42
+ * usually already past when the frame arrives on the speaker (a frame is
43
+ * sent once its last sample is pulled, 64 ms at the defaults) and 100 to
44
+ * 300 ms ahead on Bluetooth. */
45
+ heardAtMs: number;
46
+ /** The audio route's delay from playout to the listener, in ms: one IO
47
+ * buffer plus what iOS reports for the route (a few ms on the speaker
48
+ * and wired headphones, 100 to 300 ms on Bluetooth). Re-read at most
49
+ * every 0.5 s, so a route change shows within half a second. */
50
+ outputLatencyMs: number;
51
+ }
52
+
53
+ export const AGENT_AUDIO_DEFAULT_FRAME_SIZE = 1024;
54
+ export const AGENT_AUDIO_DEFAULT_SAMPLE_RATE = 16000;
55
+
56
+ /** The engine's rules (AgentAudioTap.validate), in the engine's order, so
57
+ * a bad option fails here with the same wording instead of in native code.
58
+ * Doubles divide identically in JS and Swift, so the 10 ms and 1 s edges
59
+ * agree exactly. */
60
+ export function normalizeAgentAudioOptions(
61
+ options?: AgentAudioOptions | null
62
+ ): { frameSize: number; sampleRate: number } {
63
+ if (options !== undefined && options !== null && typeof options !== 'object') {
64
+ throw new TypeError('crowdplaysdk: startAgentAudio() options must be an object');
65
+ }
66
+ const frameSize = options?.frameSize ?? AGENT_AUDIO_DEFAULT_FRAME_SIZE;
67
+ const sampleRate = options?.sampleRate ?? AGENT_AUDIO_DEFAULT_SAMPLE_RATE;
68
+ if (typeof sampleRate !== 'number' || !Number.isInteger(sampleRate)) {
69
+ throw new TypeError(`crowdplaysdk: agent audio tap: sampleRate must be an integer (got ${String(sampleRate)})`);
70
+ }
71
+ if (typeof frameSize !== 'number' || !Number.isInteger(frameSize)) {
72
+ throw new TypeError(`crowdplaysdk: agent audio tap: frameSize must be an integer (got ${String(frameSize)})`);
73
+ }
74
+ if (sampleRate < 8000 || sampleRate > 48000) {
75
+ throw new RangeError(`crowdplaysdk: agent audio tap: sampleRate ${sampleRate} is outside 8000...48000`);
76
+ }
77
+ if (frameSize < 1) {
78
+ throw new RangeError(`crowdplaysdk: agent audio tap: frameSize ${frameSize} must be at least 1`);
79
+ }
80
+ const seconds = frameSize / sampleRate;
81
+ if (seconds < 0.010 || seconds > 1.0) {
82
+ throw new RangeError(
83
+ `crowdplaysdk: agent audio tap: frameSize ${frameSize} at ${sampleRate} Hz is ` +
84
+ `${(seconds * 1000).toFixed(1)} ms; a frame must last 10 to 1000 ms`
85
+ );
86
+ }
87
+ return { frameSize, sampleRate };
88
+ }
89
+
90
+ const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
91
+ const INVALID = 255;
92
+ const LOOKUP: Uint8Array = (() => {
93
+ const table = new Uint8Array(128).fill(INVALID);
94
+ for (let i = 0; i < ALPHABET.length; i++) table[ALPHABET.charCodeAt(i)] = i;
95
+ return table;
96
+ })();
97
+
98
+ function notBase64(): TypeError {
99
+ return new TypeError(
100
+ 'crowdplaysdk: decodeAgentAudio() expects the base64 `pcm` string of an agentAudio event'
101
+ );
102
+ }
103
+
104
+ /**
105
+ * Decodes an agentAudio frame's `pcm` (base64 of Int16 little-endian mono)
106
+ * into samples, -32768...32767. Standard base64, padded or not; no atob
107
+ * needed. A trailing odd byte (never sent by the SDK) is ignored. Throws a
108
+ * TypeError on anything that is not base64.
109
+ */
110
+ export function decodeAgentAudio(pcm: string): Int16Array {
111
+ if (typeof pcm !== 'string') throw notBase64();
112
+ let end = pcm.length;
113
+ while (end > 0 && pcm.charCodeAt(end - 1) === 61 /* = */) end--;
114
+ const padding = pcm.length - end;
115
+ if (padding > 2 || end % 4 === 1 || (padding > 0 && pcm.length % 4 !== 0)) throw notBase64();
116
+
117
+ const digit = (index: number): number => {
118
+ const code = pcm.charCodeAt(index);
119
+ const value = code < 128 ? (LOOKUP[code] ?? INVALID) : INVALID;
120
+ if (value === INVALID) throw notBase64();
121
+ return value;
122
+ };
123
+
124
+ const bytes = new Uint8Array(Math.floor((end * 3) / 4));
125
+ let out = 0;
126
+ let i = 0;
127
+ for (; i + 4 <= end; i += 4) {
128
+ const n = (digit(i) << 18) | (digit(i + 1) << 12) | (digit(i + 2) << 6) | digit(i + 3);
129
+ bytes[out++] = (n >> 16) & 0xff;
130
+ bytes[out++] = (n >> 8) & 0xff;
131
+ bytes[out++] = n & 0xff;
132
+ }
133
+ const rest = end - i;
134
+ if (rest === 2) {
135
+ const n = (digit(i) << 18) | (digit(i + 1) << 12);
136
+ bytes[out++] = (n >> 16) & 0xff;
137
+ } else if (rest === 3) {
138
+ const n = (digit(i) << 18) | (digit(i + 1) << 12) | (digit(i + 2) << 6);
139
+ bytes[out++] = (n >> 16) & 0xff;
140
+ bytes[out++] = (n >> 8) & 0xff;
141
+ }
142
+
143
+ const samples = new Int16Array(out >> 1);
144
+ for (let k = 0; k < samples.length; k++) {
145
+ const lo = bytes[2 * k] ?? 0;
146
+ const hi = bytes[2 * k + 1] ?? 0;
147
+ // Little-endian whatever the platform's byte order; storing into an
148
+ // Int16Array wraps 32768...65535 to the negative samples.
149
+ samples[k] = (hi << 8) | lo;
150
+ }
151
+ return samples;
152
+ }
153
+
154
+ /** The native methods the controller needs; optional because a bridge
155
+ * wired by an older crowdplaysdk does not have them. */
156
+ export interface AgentAudioNative {
157
+ startAgentAudio?(options: { frameSize: number; sampleRate: number }): Promise<{ tap: number } | null>;
158
+ stopAgentAudio?(): Promise<void>;
159
+ }
160
+
161
+ export const STALE_BRIDGE_MESSAGE =
162
+ 'crowdplaysdk: the agent audio tap is missing from the native bridge: this app\'s iOS ' +
163
+ 'project still has the bridge files of an older crowdplaysdk. Re-run prebuild or wire.rb ' +
164
+ '(Expo: npx expo prebuild, or a build that runs it; bare React Native: ruby ' +
165
+ 'node_modules/crowdplaysdk/ios/wire.rb <YourProjectName> from ios/), then Product > ' +
166
+ 'Clean Build Folder before building.';
167
+
168
+ interface TapNative {
169
+ startAgentAudio: NonNullable<AgentAudioNative['startAgentAudio']>;
170
+ stopAgentAudio: NonNullable<AgentAudioNative['stopAgentAudio']>;
171
+ }
172
+
173
+ function withTap(module: AgentAudioNative): TapNative {
174
+ if (typeof module.startAgentAudio !== 'function' || typeof module.stopAgentAudio !== 'function') {
175
+ throw new Error(STALE_BRIDGE_MESSAGE);
176
+ }
177
+ return module as TapNative;
178
+ }
179
+
180
+ export interface AgentAudioController {
181
+ start(options?: AgentAudioOptions | null): Promise<void>;
182
+ stop(): Promise<void>;
183
+ /** Wraps an app listener: drops frames of any tap but the active one and
184
+ * marks the first frame it passes, and any frame that does not follow
185
+ * the previous one it passed, as a discontinuity. */
186
+ filter(listener: (frame: AgentAudioFrame) => void): (frame: AgentAudioFrame) => void;
187
+ /** The tap whose frames are delivered, or null (tests and diagnostics). */
188
+ readonly activeTap: number | null;
189
+ }
190
+
191
+ /**
192
+ * Orders every start and stop through one promise chain (separate native
193
+ * calls are not guaranteed to run in call order), and tracks which tap's
194
+ * frames may reach the app. A stop takes effect for the app the moment it
195
+ * is CALLED: frames still in flight from the bridge are dropped, so none
196
+ * arrive after stopAgentAudio() is called, let alone after it resolves.
197
+ */
198
+ export function createAgentAudio(getNative: () => AgentAudioNative): AgentAudioController {
199
+ let chain: Promise<void> = Promise.resolve();
200
+ let stops = 0;
201
+ let activeTap: number | null = null;
202
+
203
+ const enqueue = (operation: () => Promise<void>): Promise<void> => {
204
+ const run = chain.then(operation);
205
+ chain = run.catch(() => undefined);
206
+ return run;
207
+ };
208
+
209
+ return {
210
+ get activeTap() {
211
+ return activeTap;
212
+ },
213
+
214
+ start(options) {
215
+ let parsed: { frameSize: number; sampleRate: number };
216
+ let module: TapNative;
217
+ try {
218
+ parsed = normalizeAgentAudioOptions(options);
219
+ module = withTap(getNative());
220
+ } catch (error) {
221
+ return Promise.reject(error);
222
+ }
223
+ const stopsAtCall = stops;
224
+ return enqueue(async () => {
225
+ const result = await module.startAgentAudio(parsed);
226
+ const tap = result?.tap;
227
+ if (typeof tap !== 'number') throw new Error(STALE_BRIDGE_MESSAGE);
228
+ // A stop called after this start wins, whatever order they finish in.
229
+ if (stopsAtCall === stops) activeTap = tap;
230
+ });
231
+ },
232
+
233
+ stop() {
234
+ let module: TapNative;
235
+ try {
236
+ module = withTap(getNative());
237
+ } catch (error) {
238
+ return Promise.reject(error);
239
+ }
240
+ stops += 1;
241
+ activeTap = null;
242
+ return enqueue(async () => {
243
+ await module.stopAgentAudio();
244
+ });
245
+ },
246
+
247
+ filter(listener) {
248
+ let lastTap: number | undefined;
249
+ let lastSeq: number | undefined;
250
+ return (frame) => {
251
+ if (activeTap === null || frame.tap !== activeTap) return;
252
+ const continues = frame.tap === lastTap && lastSeq !== undefined && frame.seq === lastSeq + 1;
253
+ lastTap = frame.tap;
254
+ lastSeq = frame.seq;
255
+ listener(continues || frame.discontinuity ? frame : { ...frame, discontinuity: true });
256
+ };
257
+ },
258
+ };
259
+ }
package/src/index.ts CHANGED
@@ -4,9 +4,11 @@
4
4
  * The recording engine is 100% native (CrowdPlaySDK): studio-grade audio
5
5
  * (48 kHz / 24-bit, unprocessed) + 1080p30 video captured locally during a
6
6
  * live call, clock-synced across participants, uploaded to CrowdPlay with
7
- * retries and crash recovery. **No media ever crosses the JS bridge**,
8
- * only control calls and state events, so recording quality is identical
9
- * to a fully native app.
7
+ * retries and crash recovery. **No recording media ever crosses the JS
8
+ * bridge**, only control calls and state events, so recording quality is
9
+ * identical to a fully native app. The one audio stream that can cross it
10
+ * is the agent audio tap (startAgentAudio / useAgentAudio): the voice AI's
11
+ * call-path playout audio, and only while the app asks for it.
10
12
  *
11
13
  * import CrowdPlay, { CrowdPlayConsentScreen, CrowdPlayVideoView } from 'crowdplaysdk';
12
14
  *
@@ -21,7 +23,10 @@
21
23
  * path that records without it.
22
24
  */
23
25
 
26
+ import { useEffect, useRef } from 'react';
24
27
  import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
28
+ import { createAgentAudio, STALE_BRIDGE_MESSAGE } from './agentAudio';
29
+ import type { AgentAudioFrame, AgentAudioNative, AgentAudioOptions } from './agentAudio';
25
30
  import type { VoiceActivityEvent, VoiceCaption } from './VoiceView';
26
31
 
27
32
  // ---------------------------------------------------------------------------
@@ -200,6 +205,11 @@ export interface CrowdPlayEvents {
200
205
  /** The latest live transcript line from the voice agent; see
201
206
  * useLatestCaption(). */
202
207
  caption: VoiceCaption;
208
+ /** The voice AI's call audio as it plays, one fixed frame at a time
209
+ * (engine 0.6.0+). Flows ONLY between startAgentAudio() and
210
+ * stopAgentAudio() or leave(); prefer useAgentAudio(). Decode `pcm` with
211
+ * decodeAgentAudio(). */
212
+ agentAudio: AgentAudioFrame;
203
213
  }
204
214
 
205
215
  export type CrowdPlayEventName = keyof CrowdPlayEvents;
@@ -226,7 +236,11 @@ interface NativeCrowdPlay {
226
236
  doctor(): Promise<DoctorCheck[]>;
227
237
  /** Start/stop the 'voiceActivity' fast stream (useVoiceActivity owns this). */
228
238
  startVoiceActivityUpdates(): void;
239
+ startVoiceActivityUpdatesWithInterval?(intervalMs: number): void;
229
240
  stopVoiceActivityUpdates(): void;
241
+ /** The agent audio tap; absent from bridge files wired by an older package. */
242
+ startAgentAudio?: AgentAudioNative['startAgentAudio'];
243
+ stopAgentAudio?: AgentAudioNative['stopAgentAudio'];
230
244
  addListener(eventName: string): void;
231
245
  removeListeners(count: number): void;
232
246
  }
@@ -256,6 +270,9 @@ function events(): NativeEventEmitter {
256
270
 
257
271
  let configured = false;
258
272
 
273
+ // One controller for the app: the native tap is one stream per engine.
274
+ const agentAudio = createAgentAudio(() => native());
275
+
259
276
  // ---------------------------------------------------------------------------
260
277
  // Public API
261
278
  // ---------------------------------------------------------------------------
@@ -412,18 +429,90 @@ const CrowdPlay = {
412
429
  }
413
430
  },
414
431
 
432
+ /**
433
+ * Starts the agent audio tap (engine 0.6.0+): the voice AI's call audio,
434
+ * resampled to `sampleRate` (default 16000) and downmixed to mono, as
435
+ * 'agentAudio' events of exactly `frameSize` samples (default 1024).
436
+ * Call it after join() or when the call screen mounts; it attaches when
437
+ * the AI's audio arrives and follows reconnects. It stays on until
438
+ * stopAgentAudio() or leave() (a failed join() leaves it on, so a retry
439
+ * gets frames); calling it again restarts it (new options, seq from 0).
440
+ * Rejects on invalid options, leaving a running tap as it was. Reads
441
+ * call audio only, never the recording. Prefer useAgentAudio().
442
+ */
443
+ startAgentAudio(options?: AgentAudioOptions): Promise<void> {
444
+ return agentAudio.start(options);
445
+ },
446
+
447
+ /** Stops the agent audio tap. From the moment this is called, no further
448
+ * 'agentAudio' frame reaches the app. A no-op when it is off. */
449
+ stopAgentAudio(): Promise<void> {
450
+ return agentAudio.stop();
451
+ },
452
+
415
453
  /** Subscribe to state events. Returns a subscription; call .remove(). */
416
454
  addListener<E extends CrowdPlayEventName>(
417
455
  event: E,
418
456
  listener: (payload: CrowdPlayEvents[E]) => void
419
457
  ): { remove(): void } {
420
- const subscription = events().addListener(`crowdplay:${event}`, listener as (payload: unknown) => void);
458
+ let handler = listener as (payload: unknown) => void;
459
+ if (event === 'agentAudio') {
460
+ // An older bridge does not list the event, and listening to it there
461
+ // raises a red box in debug builds: name the fix instead.
462
+ const module = NativeModules.CrowdPlayReactNative as NativeCrowdPlay | undefined;
463
+ if (module && typeof module.startAgentAudio !== 'function') {
464
+ throw new Error(STALE_BRIDGE_MESSAGE);
465
+ }
466
+ handler = agentAudio.filter(listener as (frame: AgentAudioFrame) => void) as (payload: unknown) => void;
467
+ }
468
+ const subscription = events().addListener(`crowdplay:${event}`, handler);
421
469
  return { remove: () => subscription.remove() };
422
470
  },
423
471
  };
424
472
 
473
+ /**
474
+ * Runs the agent audio tap while the calling component is mounted: starts
475
+ * on mount, stops on unmount, restarts when the options change. `onFrame`
476
+ * may change on every render (the latest one is called). Mount it in ONE
477
+ * place at a time (the call screen): the tap is one stream per app, and
478
+ * leave() stops it, so a screen that stays mounted across calls must
479
+ * remount (or call startAgentAudio()) after the next join. A failed join()
480
+ * does not stop it: a Retry on the same screen keeps getting frames. Does
481
+ * nothing where the native module is absent (Expo Go, previews).
482
+ */
483
+ export function useAgentAudio(
484
+ onFrame: (frame: AgentAudioFrame) => void,
485
+ options?: AgentAudioOptions
486
+ ): void {
487
+ const latest = useRef(onFrame);
488
+ useEffect(() => {
489
+ latest.current = onFrame;
490
+ }, [onFrame]);
491
+ const frameSize = options?.frameSize;
492
+ const sampleRate = options?.sampleRate;
493
+ useEffect(() => {
494
+ if (!NativeModules.CrowdPlayReactNative) return;
495
+ const report = (error: unknown) =>
496
+ console.error(error instanceof Error ? error.message : String(error));
497
+ let subscription: { remove(): void };
498
+ try {
499
+ subscription = CrowdPlay.addListener('agentAudio', (frame) => latest.current(frame));
500
+ } catch (error) {
501
+ report(error);
502
+ return;
503
+ }
504
+ CrowdPlay.startAgentAudio({ frameSize, sampleRate }).catch(report);
505
+ return () => {
506
+ subscription.remove();
507
+ CrowdPlay.stopAgentAudio().catch(report);
508
+ };
509
+ }, [frameSize, sampleRate]);
510
+ }
511
+
425
512
  export default CrowdPlay;
426
513
  export { CrowdPlayConsentScreen } from './ConsentScreen';
427
514
  export { CrowdPlayVideoView } from './VideoView';
428
515
  export { CrowdPlayVoiceView, VoiceReactive, useVoiceActivity, useLatestCaption } from './VoiceView';
429
- export type { CrowdPlayVoiceViewProps, VoiceActivity, VoiceActivityEvent, VoicePhase, VoiceCaption } from './VoiceView';
516
+ export type { CrowdPlayVoiceViewProps, VoiceActivity, VoiceActivityEvent, VoiceActivityOptions, VoicePhase, VoiceCaption } from './VoiceView';
517
+ export { decodeAgentAudio } from './agentAudio';
518
+ export type { AgentAudioFrame, AgentAudioOptions } from './agentAudio';