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.
- package/README.md +64 -4
- package/ios/CrowdPlayRNModule.swift +201 -8
- package/ios/CrowdPlayReactNative.m +6 -0
- package/ios/wire.rb +2 -1
- package/lib/VoiceView.d.ts +9 -3
- package/lib/VoiceView.js +58 -6
- package/lib/agentAudio.d.ts +96 -0
- package/lib/agentAudio.js +189 -0
- package/lib/index.d.ts +40 -4
- package/lib/index.js +79 -5
- package/llms.txt +71 -3
- package/package.json +2 -1
- package/src/VoiceView.tsx +67 -6
- package/src/agentAudio.ts +259 -0
- package/src/index.ts +94 -5
|
@@ -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
|
|
8
|
-
* only control calls and state events, so recording quality is
|
|
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
|
|
9
|
-
* only control calls and state events, so recording quality is
|
|
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
|
-
|
|
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
|
|
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
|
|
@@ -182,13 +184,23 @@ Default export `CrowdPlay`:
|
|
|
182
184
|
setAudioOutput(output: 'automatic' | 'speaker'): void
|
|
183
185
|
// 'automatic' = the connected device (AirPods, wired, ...);
|
|
184
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().
|
|
185
191
|
addListener<E extends CrowdPlayEventName>(event: E,
|
|
186
192
|
handler: (payload: CrowdPlayEvents[E]) => void): { remove(): void }
|
|
187
193
|
|
|
188
194
|
Named exports: CrowdPlayConsentScreen (props: onConsent(grant)), CrowdPlayVideoView
|
|
189
195
|
(props: participant: string | 'local', plus standard View style),
|
|
190
196
|
CrowdPlayVoiceView (props: agentColor?, listeningColor?, idleColor?, size?),
|
|
191
|
-
useVoiceActivity(
|
|
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
|
|
192
204
|
|
|
193
205
|
The voice AI (apps with the Voice AI switch enabled on the dashboard):
|
|
194
206
|
|
|
@@ -284,6 +296,57 @@ count it as a generic user. Detect it in the participants event by name
|
|
|
284
296
|
For speech bubbles, subtitles, language-learning corrections. Optional
|
|
285
297
|
UI; the full timestamped transcript is always delivered with the
|
|
286
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.
|
|
287
350
|
- Testing: the AI appears only on a real iPhone (simulators have no
|
|
288
351
|
microphone). Join a room, wait ~3 s, it speaks first.
|
|
289
352
|
|
|
@@ -342,6 +405,11 @@ Events (CrowdPlay.addListener):
|
|
|
342
405
|
// stopped. The call continues. The SDK shows nothing
|
|
343
406
|
// itself; listening is optional. Kept out of 'warning' so
|
|
344
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().
|
|
345
413
|
|
|
346
414
|
## Checking sessions (no AWS, no SDK, plain HTTPS)
|
|
347
415
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crowdplaysdk",
|
|
3
|
-
"version": "0.
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
117
|
+
voiceRequests.set(token, intervalRef.current);
|
|
118
|
+
applyVoiceRequests(module);
|
|
69
119
|
return () => {
|
|
70
120
|
sub.remove();
|
|
71
|
-
|
|
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
|
}
|