crowdplaysdk 0.6.2 → 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,189 @@
1
+ "use strict";
2
+ /**
3
+ * The agent audio tap, JavaScript side (engine 0.6.0+): the voice AI's call
4
+ * audio as it plays on this phone, in fixed frames of 16-bit mono PCM, for
5
+ * lip sync, visemes and meters. This is CALL audio (Path A), already
6
+ * compressed and decoded by the call; it never touches the recording.
7
+ *
8
+ * Pure TypeScript with no react-native import: option rules, the base64
9
+ * decoder, and the controller that orders start/stop and filters frames.
10
+ * index.ts wires the controller to the native module.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.STALE_BRIDGE_MESSAGE = exports.AGENT_AUDIO_DEFAULT_SAMPLE_RATE = exports.AGENT_AUDIO_DEFAULT_FRAME_SIZE = void 0;
14
+ exports.normalizeAgentAudioOptions = normalizeAgentAudioOptions;
15
+ exports.decodeAgentAudio = decodeAgentAudio;
16
+ exports.createAgentAudio = createAgentAudio;
17
+ exports.AGENT_AUDIO_DEFAULT_FRAME_SIZE = 1024;
18
+ exports.AGENT_AUDIO_DEFAULT_SAMPLE_RATE = 16000;
19
+ /** The engine's rules (AgentAudioTap.validate), in the engine's order, so
20
+ * a bad option fails here with the same wording instead of in native code.
21
+ * Doubles divide identically in JS and Swift, so the 10 ms and 1 s edges
22
+ * agree exactly. */
23
+ function normalizeAgentAudioOptions(options) {
24
+ if (options !== undefined && options !== null && typeof options !== 'object') {
25
+ throw new TypeError('crowdplaysdk: startAgentAudio() options must be an object');
26
+ }
27
+ const frameSize = options?.frameSize ?? exports.AGENT_AUDIO_DEFAULT_FRAME_SIZE;
28
+ const sampleRate = options?.sampleRate ?? exports.AGENT_AUDIO_DEFAULT_SAMPLE_RATE;
29
+ if (typeof sampleRate !== 'number' || !Number.isInteger(sampleRate)) {
30
+ throw new TypeError(`crowdplaysdk: agent audio tap: sampleRate must be an integer (got ${String(sampleRate)})`);
31
+ }
32
+ if (typeof frameSize !== 'number' || !Number.isInteger(frameSize)) {
33
+ throw new TypeError(`crowdplaysdk: agent audio tap: frameSize must be an integer (got ${String(frameSize)})`);
34
+ }
35
+ if (sampleRate < 8000 || sampleRate > 48000) {
36
+ throw new RangeError(`crowdplaysdk: agent audio tap: sampleRate ${sampleRate} is outside 8000...48000`);
37
+ }
38
+ if (frameSize < 1) {
39
+ throw new RangeError(`crowdplaysdk: agent audio tap: frameSize ${frameSize} must be at least 1`);
40
+ }
41
+ const seconds = frameSize / sampleRate;
42
+ if (seconds < 0.010 || seconds > 1.0) {
43
+ throw new RangeError(`crowdplaysdk: agent audio tap: frameSize ${frameSize} at ${sampleRate} Hz is ` +
44
+ `${(seconds * 1000).toFixed(1)} ms; a frame must last 10 to 1000 ms`);
45
+ }
46
+ return { frameSize, sampleRate };
47
+ }
48
+ const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
49
+ const INVALID = 255;
50
+ const LOOKUP = (() => {
51
+ const table = new Uint8Array(128).fill(INVALID);
52
+ for (let i = 0; i < ALPHABET.length; i++)
53
+ table[ALPHABET.charCodeAt(i)] = i;
54
+ return table;
55
+ })();
56
+ function notBase64() {
57
+ return new TypeError('crowdplaysdk: decodeAgentAudio() expects the base64 `pcm` string of an agentAudio event');
58
+ }
59
+ /**
60
+ * Decodes an agentAudio frame's `pcm` (base64 of Int16 little-endian mono)
61
+ * into samples, -32768...32767. Standard base64, padded or not; no atob
62
+ * needed. A trailing odd byte (never sent by the SDK) is ignored. Throws a
63
+ * TypeError on anything that is not base64.
64
+ */
65
+ function decodeAgentAudio(pcm) {
66
+ if (typeof pcm !== 'string')
67
+ throw notBase64();
68
+ let end = pcm.length;
69
+ while (end > 0 && pcm.charCodeAt(end - 1) === 61 /* = */)
70
+ end--;
71
+ const padding = pcm.length - end;
72
+ if (padding > 2 || end % 4 === 1 || (padding > 0 && pcm.length % 4 !== 0))
73
+ throw notBase64();
74
+ const digit = (index) => {
75
+ const code = pcm.charCodeAt(index);
76
+ const value = code < 128 ? (LOOKUP[code] ?? INVALID) : INVALID;
77
+ if (value === INVALID)
78
+ throw notBase64();
79
+ return value;
80
+ };
81
+ const bytes = new Uint8Array(Math.floor((end * 3) / 4));
82
+ let out = 0;
83
+ let i = 0;
84
+ for (; i + 4 <= end; i += 4) {
85
+ const n = (digit(i) << 18) | (digit(i + 1) << 12) | (digit(i + 2) << 6) | digit(i + 3);
86
+ bytes[out++] = (n >> 16) & 0xff;
87
+ bytes[out++] = (n >> 8) & 0xff;
88
+ bytes[out++] = n & 0xff;
89
+ }
90
+ const rest = end - i;
91
+ if (rest === 2) {
92
+ const n = (digit(i) << 18) | (digit(i + 1) << 12);
93
+ bytes[out++] = (n >> 16) & 0xff;
94
+ }
95
+ else if (rest === 3) {
96
+ const n = (digit(i) << 18) | (digit(i + 1) << 12) | (digit(i + 2) << 6);
97
+ bytes[out++] = (n >> 16) & 0xff;
98
+ bytes[out++] = (n >> 8) & 0xff;
99
+ }
100
+ const samples = new Int16Array(out >> 1);
101
+ for (let k = 0; k < samples.length; k++) {
102
+ const lo = bytes[2 * k] ?? 0;
103
+ const hi = bytes[2 * k + 1] ?? 0;
104
+ // Little-endian whatever the platform's byte order; storing into an
105
+ // Int16Array wraps 32768...65535 to the negative samples.
106
+ samples[k] = (hi << 8) | lo;
107
+ }
108
+ return samples;
109
+ }
110
+ exports.STALE_BRIDGE_MESSAGE = 'crowdplaysdk: the agent audio tap is missing from the native bridge: this app\'s iOS ' +
111
+ 'project still has the bridge files of an older crowdplaysdk. Re-run prebuild or wire.rb ' +
112
+ '(Expo: npx expo prebuild, or a build that runs it; bare React Native: ruby ' +
113
+ 'node_modules/crowdplaysdk/ios/wire.rb <YourProjectName> from ios/), then Product > ' +
114
+ 'Clean Build Folder before building.';
115
+ function withTap(module) {
116
+ if (typeof module.startAgentAudio !== 'function' || typeof module.stopAgentAudio !== 'function') {
117
+ throw new Error(exports.STALE_BRIDGE_MESSAGE);
118
+ }
119
+ return module;
120
+ }
121
+ /**
122
+ * Orders every start and stop through one promise chain (separate native
123
+ * calls are not guaranteed to run in call order), and tracks which tap's
124
+ * frames may reach the app. A stop takes effect for the app the moment it
125
+ * is CALLED: frames still in flight from the bridge are dropped, so none
126
+ * arrive after stopAgentAudio() is called, let alone after it resolves.
127
+ */
128
+ function createAgentAudio(getNative) {
129
+ let chain = Promise.resolve();
130
+ let stops = 0;
131
+ let activeTap = null;
132
+ const enqueue = (operation) => {
133
+ const run = chain.then(operation);
134
+ chain = run.catch(() => undefined);
135
+ return run;
136
+ };
137
+ return {
138
+ get activeTap() {
139
+ return activeTap;
140
+ },
141
+ start(options) {
142
+ let parsed;
143
+ let module;
144
+ try {
145
+ parsed = normalizeAgentAudioOptions(options);
146
+ module = withTap(getNative());
147
+ }
148
+ catch (error) {
149
+ return Promise.reject(error);
150
+ }
151
+ const stopsAtCall = stops;
152
+ return enqueue(async () => {
153
+ const result = await module.startAgentAudio(parsed);
154
+ const tap = result?.tap;
155
+ if (typeof tap !== 'number')
156
+ throw new Error(exports.STALE_BRIDGE_MESSAGE);
157
+ // A stop called after this start wins, whatever order they finish in.
158
+ if (stopsAtCall === stops)
159
+ activeTap = tap;
160
+ });
161
+ },
162
+ stop() {
163
+ let module;
164
+ try {
165
+ module = withTap(getNative());
166
+ }
167
+ catch (error) {
168
+ return Promise.reject(error);
169
+ }
170
+ stops += 1;
171
+ activeTap = null;
172
+ return enqueue(async () => {
173
+ await module.stopAgentAudio();
174
+ });
175
+ },
176
+ filter(listener) {
177
+ let lastTap;
178
+ let lastSeq;
179
+ return (frame) => {
180
+ if (activeTap === null || frame.tap !== activeTap)
181
+ return;
182
+ const continues = frame.tap === lastTap && lastSeq !== undefined && frame.seq === lastSeq + 1;
183
+ lastTap = frame.tap;
184
+ lastSeq = frame.seq;
185
+ listener(continues || frame.discontinuity ? frame : { ...frame, discontinuity: true });
186
+ };
187
+ },
188
+ };
189
+ }
package/lib/index.d.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
  *
@@ -20,6 +22,7 @@
20
22
  * native layer requires the consent record structurally; there is no code
21
23
  * path that records without it.
22
24
  */
25
+ import type { AgentAudioFrame, AgentAudioOptions } from './agentAudio';
23
26
  import type { VoiceActivityEvent, VoiceCaption } from './VoiceView';
24
27
  /** Live-call (Path A) quality. The RECORDING is always 1080p30 + 24-bit WAV
25
28
  * regardless; raising these trades upload + thermal headroom (which the
@@ -207,6 +210,11 @@ export interface CrowdPlayEvents {
207
210
  /** The latest live transcript line from the voice agent; see
208
211
  * useLatestCaption(). */
209
212
  caption: VoiceCaption;
213
+ /** The voice AI's call audio as it plays, one fixed frame at a time
214
+ * (engine 0.6.0+). Flows ONLY between startAgentAudio() and
215
+ * stopAgentAudio() or leave(); prefer useAgentAudio(). Decode `pcm` with
216
+ * decodeAgentAudio(). */
217
+ agentAudio: AgentAudioFrame;
210
218
  }
211
219
  export type CrowdPlayEventName = keyof CrowdPlayEvents;
212
220
  declare const CrowdPlay: {
@@ -269,13 +277,41 @@ declare const CrowdPlay: {
269
277
  * Sam was eliminated."). Keep it to a sentence or two; long pushes are
270
278
  * truncated. GPT-Live provider only; other providers ignore it. */
271
279
  updateAgentContext(text: string): void;
280
+ /**
281
+ * Starts the agent audio tap (engine 0.6.0+): the voice AI's call audio,
282
+ * resampled to `sampleRate` (default 16000) and downmixed to mono, as
283
+ * 'agentAudio' events of exactly `frameSize` samples (default 1024).
284
+ * Call it after join() or when the call screen mounts; it attaches when
285
+ * the AI's audio arrives and follows reconnects. It stays on until
286
+ * stopAgentAudio() or leave() (a failed join() leaves it on, so a retry
287
+ * gets frames); calling it again restarts it (new options, seq from 0).
288
+ * Rejects on invalid options, leaving a running tap as it was. Reads
289
+ * call audio only, never the recording. Prefer useAgentAudio().
290
+ */
291
+ startAgentAudio(options?: AgentAudioOptions): Promise<void>;
292
+ /** Stops the agent audio tap. From the moment this is called, no further
293
+ * 'agentAudio' frame reaches the app. A no-op when it is off. */
294
+ stopAgentAudio(): Promise<void>;
272
295
  /** Subscribe to state events. Returns a subscription; call .remove(). */
273
296
  addListener<E extends CrowdPlayEventName>(event: E, listener: (payload: CrowdPlayEvents[E]) => void): {
274
297
  remove(): void;
275
298
  };
276
299
  };
300
+ /**
301
+ * Runs the agent audio tap while the calling component is mounted: starts
302
+ * on mount, stops on unmount, restarts when the options change. `onFrame`
303
+ * may change on every render (the latest one is called). Mount it in ONE
304
+ * place at a time (the call screen): the tap is one stream per app, and
305
+ * leave() stops it, so a screen that stays mounted across calls must
306
+ * remount (or call startAgentAudio()) after the next join. A failed join()
307
+ * does not stop it: a Retry on the same screen keeps getting frames. Does
308
+ * nothing where the native module is absent (Expo Go, previews).
309
+ */
310
+ export declare function useAgentAudio(onFrame: (frame: AgentAudioFrame) => void, options?: AgentAudioOptions): void;
277
311
  export default CrowdPlay;
278
312
  export { CrowdPlayConsentScreen } from './ConsentScreen';
279
313
  export { CrowdPlayVideoView } from './VideoView';
280
314
  export { CrowdPlayVoiceView, VoiceReactive, useVoiceActivity, useLatestCaption } from './VoiceView';
281
- export type { CrowdPlayVoiceViewProps, VoiceActivity, VoiceActivityEvent, VoicePhase, VoiceCaption } from './VoiceView';
315
+ export type { CrowdPlayVoiceViewProps, VoiceActivity, VoiceActivityEvent, VoiceActivityOptions, VoicePhase, VoiceCaption } from './VoiceView';
316
+ export { decodeAgentAudio } from './agentAudio';
317
+ export type { AgentAudioFrame, AgentAudioOptions } from './agentAudio';
package/lib/index.js CHANGED
@@ -5,9 +5,11 @@
5
5
  * The recording engine is 100% native (CrowdPlaySDK): studio-grade audio
6
6
  * (48 kHz / 24-bit, unprocessed) + 1080p30 video captured locally during a
7
7
  * live call, clock-synced across participants, uploaded to CrowdPlay with
8
- * retries and crash recovery. **No media ever crosses the JS bridge**,
9
- * only control calls and state events, so recording quality is identical
10
- * to a fully native app.
8
+ * retries and crash recovery. **No recording media ever crosses the JS
9
+ * bridge**, only control calls and state events, so recording quality is
10
+ * identical to a fully native app. The one audio stream that can cross it
11
+ * is the agent audio tap (startAgentAudio / useAgentAudio): the voice AI's
12
+ * call-path playout audio, and only while the app asks for it.
11
13
  *
12
14
  * import CrowdPlay, { CrowdPlayConsentScreen, CrowdPlayVideoView } from 'crowdplaysdk';
13
15
  *
@@ -22,8 +24,11 @@
22
24
  * path that records without it.
23
25
  */
24
26
  Object.defineProperty(exports, "__esModule", { value: true });
25
- exports.useLatestCaption = exports.useVoiceActivity = exports.VoiceReactive = exports.CrowdPlayVoiceView = exports.CrowdPlayVideoView = exports.CrowdPlayConsentScreen = exports.CROWDPLAY_TERMS_URL = void 0;
27
+ exports.decodeAgentAudio = exports.useLatestCaption = exports.useVoiceActivity = exports.VoiceReactive = exports.CrowdPlayVoiceView = exports.CrowdPlayVideoView = exports.CrowdPlayConsentScreen = exports.CROWDPLAY_TERMS_URL = void 0;
28
+ exports.useAgentAudio = useAgentAudio;
29
+ const react_1 = require("react");
26
30
  const react_native_1 = require("react-native");
31
+ const agentAudio_1 = require("./agentAudio");
27
32
  /** The CrowdPlay Terms of Use and Privacy Notice every participant accepts,
28
33
  * as a PDF. CrowdPlayConsentScreen links here; a custom consent UI must too. */
29
34
  exports.CROWDPLAY_TERMS_URL = 'https://www.crowdplay.ai/legal/consumer-terms';
@@ -47,6 +52,8 @@ function events() {
47
52
  return emitter;
48
53
  }
49
54
  let configured = false;
55
+ // One controller for the app: the native tap is one stream per engine.
56
+ const agentAudio = (0, agentAudio_1.createAgentAudio)(() => native());
50
57
  // ---------------------------------------------------------------------------
51
58
  // Public API
52
59
  // ---------------------------------------------------------------------------
@@ -184,12 +191,77 @@ const CrowdPlay = {
184
191
  native().updateAgentContext(text.trim());
185
192
  }
186
193
  },
194
+ /**
195
+ * Starts the agent audio tap (engine 0.6.0+): the voice AI's call audio,
196
+ * resampled to `sampleRate` (default 16000) and downmixed to mono, as
197
+ * 'agentAudio' events of exactly `frameSize` samples (default 1024).
198
+ * Call it after join() or when the call screen mounts; it attaches when
199
+ * the AI's audio arrives and follows reconnects. It stays on until
200
+ * stopAgentAudio() or leave() (a failed join() leaves it on, so a retry
201
+ * gets frames); calling it again restarts it (new options, seq from 0).
202
+ * Rejects on invalid options, leaving a running tap as it was. Reads
203
+ * call audio only, never the recording. Prefer useAgentAudio().
204
+ */
205
+ startAgentAudio(options) {
206
+ return agentAudio.start(options);
207
+ },
208
+ /** Stops the agent audio tap. From the moment this is called, no further
209
+ * 'agentAudio' frame reaches the app. A no-op when it is off. */
210
+ stopAgentAudio() {
211
+ return agentAudio.stop();
212
+ },
187
213
  /** Subscribe to state events. Returns a subscription; call .remove(). */
188
214
  addListener(event, listener) {
189
- const subscription = events().addListener(`crowdplay:${event}`, listener);
215
+ let handler = listener;
216
+ if (event === 'agentAudio') {
217
+ // An older bridge does not list the event, and listening to it there
218
+ // raises a red box in debug builds: name the fix instead.
219
+ const module = react_native_1.NativeModules.CrowdPlayReactNative;
220
+ if (module && typeof module.startAgentAudio !== 'function') {
221
+ throw new Error(agentAudio_1.STALE_BRIDGE_MESSAGE);
222
+ }
223
+ handler = agentAudio.filter(listener);
224
+ }
225
+ const subscription = events().addListener(`crowdplay:${event}`, handler);
190
226
  return { remove: () => subscription.remove() };
191
227
  },
192
228
  };
229
+ /**
230
+ * Runs the agent audio tap while the calling component is mounted: starts
231
+ * on mount, stops on unmount, restarts when the options change. `onFrame`
232
+ * may change on every render (the latest one is called). Mount it in ONE
233
+ * place at a time (the call screen): the tap is one stream per app, and
234
+ * leave() stops it, so a screen that stays mounted across calls must
235
+ * remount (or call startAgentAudio()) after the next join. A failed join()
236
+ * does not stop it: a Retry on the same screen keeps getting frames. Does
237
+ * nothing where the native module is absent (Expo Go, previews).
238
+ */
239
+ function useAgentAudio(onFrame, options) {
240
+ const latest = (0, react_1.useRef)(onFrame);
241
+ (0, react_1.useEffect)(() => {
242
+ latest.current = onFrame;
243
+ }, [onFrame]);
244
+ const frameSize = options?.frameSize;
245
+ const sampleRate = options?.sampleRate;
246
+ (0, react_1.useEffect)(() => {
247
+ if (!react_native_1.NativeModules.CrowdPlayReactNative)
248
+ return;
249
+ const report = (error) => console.error(error instanceof Error ? error.message : String(error));
250
+ let subscription;
251
+ try {
252
+ subscription = CrowdPlay.addListener('agentAudio', (frame) => latest.current(frame));
253
+ }
254
+ catch (error) {
255
+ report(error);
256
+ return;
257
+ }
258
+ CrowdPlay.startAgentAudio({ frameSize, sampleRate }).catch(report);
259
+ return () => {
260
+ subscription.remove();
261
+ CrowdPlay.stopAgentAudio().catch(report);
262
+ };
263
+ }, [frameSize, sampleRate]);
264
+ }
193
265
  exports.default = CrowdPlay;
194
266
  var ConsentScreen_1 = require("./ConsentScreen");
195
267
  Object.defineProperty(exports, "CrowdPlayConsentScreen", { enumerable: true, get: function () { return ConsentScreen_1.CrowdPlayConsentScreen; } });
@@ -200,3 +272,5 @@ Object.defineProperty(exports, "CrowdPlayVoiceView", { enumerable: true, get: fu
200
272
  Object.defineProperty(exports, "VoiceReactive", { enumerable: true, get: function () { return VoiceView_1.VoiceReactive; } });
201
273
  Object.defineProperty(exports, "useVoiceActivity", { enumerable: true, get: function () { return VoiceView_1.useVoiceActivity; } });
202
274
  Object.defineProperty(exports, "useLatestCaption", { enumerable: true, get: function () { return VoiceView_1.useLatestCaption; } });
275
+ var agentAudio_2 = require("./agentAudio");
276
+ Object.defineProperty(exports, "decodeAgentAudio", { enumerable: true, get: function () { return agentAudio_2.decodeAgentAudio; } });
package/llms.txt CHANGED
@@ -6,8 +6,10 @@
6
6
  > participant on a shared clock, and uploads everything to CrowdPlay
7
7
  > automatically. The engine is compiled native code (the same CrowdPlaySDK core
8
8
  > the native iOS SDK ships); JavaScript sends control calls and receives
9
- > state events. NO media ever crosses the JS bridge, so recording quality
10
- > is identical to a fully native app.
9
+ > state events. NO recording media ever crosses the JS bridge, so recording
10
+ > quality is identical to a fully native app. The only audio that can
11
+ > cross it is the opt-in agent audio tap: the voice AI's call audio for lip
12
+ > sync, and only while the app asks for it.
11
13
 
12
14
  This file is the complete integration contract for an AI coding agent.
13
15
  Follow it exactly. When this file and your instincts disagree, this file
@@ -81,6 +83,14 @@ EXPO (managed, prebuild or EAS Build; app builders such as X1):
81
83
  Xcode project. Do not add those Info.plist keys or any native code
82
84
  yourself. Expo SDK 50 or newer.
83
85
  3. Rebuild: npx expo prebuild, a development build, or eas build.
86
+ UPDATING the package later: npx expo prebuild again so the plugin
87
+ re-pins the engine to the version the package ships with (0.6.3+). An
88
+ ios/ folder committed to git is not re-wired by eas build on its own:
89
+ run prebuild and commit the result. Then clean before a LOCAL build
90
+ (Xcode: Product > Clean Build Folder); an incremental build can reuse
91
+ the previous engine's cached interface and fail with "has no member
92
+ '$storageWarning'". That error after an update means "clean", never
93
+ "edit the bridge". EAS Build starts clean.
84
94
  4. Expo Go and any preview that never ran the plugin cannot load native
85
95
  modules: there the consent text, the call and recording fail with a
86
96
  clear error and the video view shows a placeholder. That is EXPECTED;
@@ -92,7 +102,8 @@ BARE REACT NATIVE (no Expo):
92
102
  2. cd ios && pod install (installs RN's own pods; CrowdPlay has none)
93
103
  3. gem install xcodeproj --user-install (one-time; wire.rb needs it
94
104
  and says so itself if missing)
95
- ruby node_modules/crowdplaysdk/ios/wire.rb <YourProjectName>
105
+ ruby ../node_modules/crowdplaysdk/ios/wire.rb <YourProjectName>
106
+ (from ios/, where step 2 left you; wire.rb must run there)
96
107
  Idempotent. It: adds the CrowdPlaySDK binary Swift package to the Xcode
97
108
  project, copies the three native bridge files into the app target,
98
109
  creates/points the Swift bridging header at the React headers, raises
@@ -100,7 +111,10 @@ BARE REACT NATIVE (no Expo):
100
111
  sets SWIFT_ENABLE_EXPLICIT_MODULES = YES on the app target (React Native
101
112
  0.8x sets it to NO project-wide on Xcode 26, and with implicit modules
102
113
  the compiled engine's interface cannot be imported). Rerun it any time;
103
- it never duplicates. Never undo that setting to fix an unrelated error.
114
+ it never duplicates, and after updating the package it re-pins the
115
+ engine to the version the package ships with (0.6.3+); then Product >
116
+ Clean Build Folder before building. Never undo that setting to fix an
117
+ unrelated error.
104
118
  4. Info.plist: NSMicrophoneUsageDescription, NSCameraUsageDescription,
105
119
  UIBackgroundModes = [audio]. A missing usage string makes iOS kill
106
120
  the app at first mic/camera access (uncatchable).
@@ -170,13 +184,23 @@ Default export `CrowdPlay`:
170
184
  setAudioOutput(output: 'automatic' | 'speaker'): void
171
185
  // 'automatic' = the connected device (AirPods, wired, ...);
172
186
  // pair with the 'audioRoute' event for the output menu
187
+ startAgentAudio(opts?: { frameSize?: number; sampleRate?: number }): Promise<void>
188
+ stopAgentAudio(): Promise<void>
189
+ // 0.7.0+ (engine 0.6.0): the voice AI's call audio as 'agentAudio'
190
+ // frames, for lip sync; see "Lip sync" below. Prefer useAgentAudio().
173
191
  addListener<E extends CrowdPlayEventName>(event: E,
174
192
  handler: (payload: CrowdPlayEvents[E]) => void): { remove(): void }
175
193
 
176
194
  Named exports: CrowdPlayConsentScreen (props: onConsent(grant)), CrowdPlayVideoView
177
195
  (props: participant: string | 'local', plus standard View style),
178
196
  CrowdPlayVoiceView (props: agentColor?, listeningColor?, idleColor?, size?),
179
- useVoiceActivity() -> { phase: 'connecting'|'idle'|'listening'|'speaking', energy: 0…1 }.
197
+ useVoiceActivity(opts?: { intervalMs?: number })
198
+ -> { phase: 'connecting'|'idle'|'listening'|'speaking', energy: 0…1 }
199
+ // intervalMs: update rate, default 150, floor 33; mounted hooks share
200
+ // one stream at the smallest interval asked for,
201
+ useAgentAudio(onFrame: (frame: AgentAudioFrame) => void,
202
+ opts?: { frameSize?: number; sampleRate?: number }): void,
203
+ decodeAgentAudio(pcm: string): Int16Array // frame.pcm -> samples
180
204
 
181
205
  The voice AI (apps with the Voice AI switch enabled on the dashboard):
182
206
 
@@ -272,6 +296,57 @@ count it as a generic user. Detect it in the participants event by name
272
296
  For speech bubbles, subtitles, language-learning corrections. Optional
273
297
  UI; the full timestamped transcript is always delivered with the
274
298
  session regardless.
299
+ - Lip sync (0.7.0+, engine 0.6.0): the agent audio tap hands the app the
300
+ AI's voice as raw PCM while it plays, for mouth shapes, visemes or
301
+ custom meters (useVoiceActivity's energy is a few updates a second; this
302
+ is every sample). The recipe: start it after join() resolves or on the
303
+ call screen's mount, feed decodeAgentAudio(e.pcm) to the app's viseme
304
+ classifier, stop it on leave.
305
+
306
+ import { useAgentAudio, decodeAgentAudio } from 'crowdplaysdk';
307
+ function Avatar() {
308
+ useAgentAudio((frame) => {
309
+ const samples = decodeAgentAudio(frame.pcm); // Int16Array
310
+ visemes.feed(samples, { reset: frame.discontinuity, // app code
311
+ showAt: frame.heardAtMs });
312
+ }); // defaults: frameSize 1024, sampleRate 16000
313
+ return <Character mouth={visemes.current} />;
314
+ }
315
+
316
+ Imperative form: await CrowdPlay.startAgentAudio({ frameSize, sampleRate })
317
+ after join(), CrowdPlay.addListener('agentAudio', handler), and
318
+ CrowdPlay.stopAgentAudio() before or with leave().
319
+ RULES: frames are exactly frameSize samples, 16-bit mono, at sampleRate;
320
+ sampleRate an integer 8000 to 48000, frameSize an integer >= 1, a frame
321
+ 10 ms to 1 s (otherwise start rejects and a running tap is left as it
322
+ was). seq counts frames from 0 per start; discontinuity is true on the
323
+ first frame a listener gets and after any gap (reconnect, dropped
324
+ audio): reset smoothing there. Starting again restarts it with the new
325
+ options. leave() turns it off: start it again after the next join (the
326
+ hook does that when its screen remounts). A failed join() leaves it on,
327
+ so a Retry on the same screen keeps getting frames. Mount useAgentAudio
328
+ in ONE component at a time; the tap is one stream for the whole app.
329
+ Nothing is delivered while no AI is in the room. It listens only to the
330
+ AI participant (identity "agent-" plus 8 lowercase hex, name "agent"),
331
+ never to a person whose name merely looks like it; the identity is not
332
+ reserved, so a participant that joins under exactly that identity is
333
+ taken for the AI (a person named exactly "agent" always is from the web
334
+ SDK, and now and then from a phone).
335
+ TIMING: frames are taken as the audio is pulled for playout. Each frame
336
+ carries heardAtMs, when its first sample reaches the listener (Unix ms
337
+ on the phone's clock, compare with Date.now()) = the pull plus
338
+ outputLatencyMs, the route's delay (a few ms on the speaker and wired
339
+ headphones, 100 to 300 ms on Bluetooth/AirPods). Show each frame's mouth
340
+ shape at heardAtMs, never on arrival: on AirPods frames arrive well
341
+ before they are heard, on the speaker just after (a frame is sent once
342
+ its last sample is pulled; smaller frames shorten that). On Bluetooth
343
+ the delay is the one iOS reports for the headphones, so the timing
344
+ there is only as exact as that figure.
345
+ It reads the CALL audio only: it never touches the recording, and
346
+ nothing crosses the bridge unless the app starts it. Never capture the
347
+ AI's audio any other way (rule 2). After updating to a package with it,
348
+ re-wire (prebuild or wire.rb) and Clean Build Folder; an old bridge
349
+ rejects with a message saying exactly that.
275
350
  - Testing: the AI appears only on a real iPhone (simulators have no
276
351
  microphone). Join a room, wait ~3 s, it speaks first.
277
352
 
@@ -330,6 +405,11 @@ Events (CrowdPlay.addListener):
330
405
  // stopped. The call continues. The SDK shows nothing
331
406
  // itself; listening is optional. Kept out of 'warning' so
332
407
  // apps that render every warning do not show it.
408
+ 'agentAudio' { pcm: string; sampleRate; frameSize; seq; discontinuity; tap;
409
+ heardAtMs; outputLatencyMs }
410
+ // 0.7.0+: only between startAgentAudio() and
411
+ // stopAgentAudio() or leave(). pcm = base64 Int16 LE mono,
412
+ // decodeAgentAudio(pcm) -> Int16Array. Prefer useAgentAudio().
333
413
 
334
414
  ## Checking sessions (no AWS, no SDK, plain HTTPS)
335
415
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crowdplaysdk",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "CrowdPlay lossless conversation capture for React Native (iOS). Studio-grade per-participant recording during live video calls, delivered to CrowdPlay automatically.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -17,6 +17,7 @@
17
17
  "scripts": {
18
18
  "build": "tsc",
19
19
  "typecheck": "tsc --noEmit",
20
+ "test": "tsc -p test/tsconfig.json && node --test --experimental-test-module-mocks \"test/*.test.mjs\"",
20
21
  "prepack": "npm run build"
21
22
  },
22
23
  "homepage": "https://github.com/symbiateam/crowdplaysdk",
package/src/VoiceView.tsx CHANGED
@@ -33,11 +33,60 @@ export interface VoiceActivityEvent {
33
33
  connected: boolean;
34
34
  }
35
35
 
36
- /** Live phase + energy from the call engine. Subscribing starts the native
37
- * fast stream; the last unmount stops it. */
38
- export function useVoiceActivity(): VoiceActivity {
36
+ export interface VoiceActivityOptions {
37
+ /** How often the native stream sends 'voiceActivity', in ms (floor 33).
38
+ * Default 150. With several hooks mounted, the stream runs at the
39
+ * smallest interval any of them asks for. */
40
+ intervalMs?: number;
41
+ }
42
+
43
+ /** Every mounted useVoiceActivity() shares ONE native stream: each hook
44
+ * registers its requested interval (undefined = the default) here, the
45
+ * stream runs at the smallest, and only the last unmount stops it. */
46
+ const voiceRequests = new Map<object, number | undefined>();
47
+ /** What the native stream was last told: 'stopped', 'default' (the
48
+ * zero-argument start, 150 ms, exactly as before intervals existed), or
49
+ * an interval in ms. */
50
+ let voiceStream: 'stopped' | 'default' | number = 'stopped';
51
+
52
+ function voiceIntervalOf(intervalMs: unknown): number | undefined {
53
+ if (typeof intervalMs !== 'number' || !Number.isFinite(intervalMs) || intervalMs <= 0) return undefined;
54
+ return Math.max(33, Math.round(intervalMs));
55
+ }
56
+
57
+ function applyVoiceRequests(module: {
58
+ startVoiceActivityUpdates?: () => void;
59
+ startVoiceActivityUpdatesWithInterval?: (intervalMs: number) => void;
60
+ stopVoiceActivityUpdates?: () => void;
61
+ }): void {
62
+ if (voiceRequests.size === 0) {
63
+ if (voiceStream !== 'stopped') module.stopVoiceActivityUpdates?.();
64
+ voiceStream = 'stopped';
65
+ return;
66
+ }
67
+ let smallest: number | undefined;
68
+ for (const interval of voiceRequests.values()) {
69
+ if (interval !== undefined && (smallest === undefined || interval < smallest)) smallest = interval;
70
+ }
71
+ const want = smallest ?? 'default';
72
+ if (want === voiceStream) return;
73
+ if (want !== 'default' && typeof module.startVoiceActivityUpdatesWithInterval === 'function') {
74
+ module.startVoiceActivityUpdatesWithInterval(want);
75
+ } else {
76
+ // The default, or a bridge wired by an older package (fixed 150 ms).
77
+ module.startVoiceActivityUpdates?.();
78
+ }
79
+ voiceStream = want;
80
+ }
81
+
82
+ /** Live phase + energy from the call engine. The first mounted hook starts
83
+ * the native fast stream; the last unmount stops it. */
84
+ export function useVoiceActivity(options?: VoiceActivityOptions): VoiceActivity {
39
85
  const [activity, setActivity] = useState<VoiceActivity>({ phase: 'connecting', energy: 0 });
40
86
  const energyRef = useRef(0);
87
+ const token = useRef<object>({}).current;
88
+ const intervalMs = voiceIntervalOf(options?.intervalMs);
89
+ const intervalRef = useRef(intervalMs);
41
90
 
42
91
  useEffect(() => {
43
92
  const module = NativeModules.CrowdPlayReactNative;
@@ -65,12 +114,24 @@ export function useVoiceActivity(): VoiceActivity {
65
114
  energyRef.current += (target - energyRef.current) * rate;
66
115
  setActivity({ phase, energy: energyRef.current });
67
116
  });
68
- module.startVoiceActivityUpdates?.();
117
+ voiceRequests.set(token, intervalRef.current);
118
+ applyVoiceRequests(module);
69
119
  return () => {
70
120
  sub.remove();
71
- module.stopVoiceActivityUpdates?.();
121
+ voiceRequests.delete(token);
122
+ applyVoiceRequests(module);
72
123
  };
73
- }, []);
124
+ }, [token]);
125
+
126
+ // A changed interval updates this hook's request in place, so the shared
127
+ // stream never stops and restarts for it.
128
+ useEffect(() => {
129
+ intervalRef.current = intervalMs;
130
+ const module = NativeModules.CrowdPlayReactNative;
131
+ if (!module || !voiceRequests.has(token)) return;
132
+ voiceRequests.set(token, intervalMs);
133
+ applyVoiceRequests(module);
134
+ }, [token, intervalMs]);
74
135
 
75
136
  return activity;
76
137
  }