crowdplaysdk 0.3.3 → 0.4.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/ios/CrowdPlayRNModule.swift +25 -2
- package/ios/CrowdPlayReactNative.m +1 -0
- package/ios/wire.rb +1 -1
- package/lib/VoiceView.d.ts +21 -1
- package/lib/VoiceView.js +83 -8
- package/lib/index.d.ts +25 -2
- package/lib/index.js +17 -3
- package/llms.txt +74 -8
- package/package.json +1 -1
- package/src/VoiceView.tsx +127 -10
- package/src/index.ts +44 -6
|
@@ -42,6 +42,16 @@ final class CrowdPlayRNCore {
|
|
|
42
42
|
self?.send("crowdplay:recording", body)
|
|
43
43
|
}.store(in: &cancellables)
|
|
44
44
|
|
|
45
|
+
// Live captions (M8 voice UI): each line the AI or user speaks, as
|
|
46
|
+
// broadcast by the agent on the data channel.
|
|
47
|
+
engine.$latestCaption
|
|
48
|
+
.compactMap { $0 }
|
|
49
|
+
.sink { [weak self] caption in
|
|
50
|
+
self?.send("crowdplay:caption", [
|
|
51
|
+
"role": caption.role, "text": caption.text,
|
|
52
|
+
])
|
|
53
|
+
}.store(in: &cancellables)
|
|
54
|
+
|
|
45
55
|
engine.$uploads
|
|
46
56
|
.combineLatest(engine.$uploadsOnWiFi)
|
|
47
57
|
.sink { [weak self] uploads, onWifi in
|
|
@@ -210,7 +220,7 @@ public final class CrowdPlayRNModule: RCTEventEmitter {
|
|
|
210
220
|
public override static func requiresMainQueueSetup() -> Bool { true }
|
|
211
221
|
|
|
212
222
|
public override func supportedEvents() -> [String]! {
|
|
213
|
-
["crowdplay:phase", "crowdplay:recording", "crowdplay:uploads", "crowdplay:participants", "crowdplay:warning", "crowdplay:audioRoute", "crowdplay:voiceActivity"]
|
|
223
|
+
["crowdplay:phase", "crowdplay:recording", "crowdplay:uploads", "crowdplay:participants", "crowdplay:warning", "crowdplay:audioRoute", "crowdplay:voiceActivity", "crowdplay:caption"]
|
|
214
224
|
}
|
|
215
225
|
|
|
216
226
|
@objc public func startVoiceActivityUpdates() {
|
|
@@ -268,8 +278,20 @@ public final class CrowdPlayRNModule: RCTEventEmitter {
|
|
|
268
278
|
|
|
269
279
|
@objc public func join(_ displayName: NSString, roomCode: NSString,
|
|
270
280
|
consentGrantedAtMs: NSNumber,
|
|
281
|
+
agent: NSDictionary?,
|
|
271
282
|
resolver: @escaping RCTPromiseResolveBlock,
|
|
272
283
|
rejecter: @escaping RCTPromiseRejectBlock) {
|
|
284
|
+
// Per-join voice-AI overrides (D-095): the JS layer already clamped
|
|
285
|
+
// and dropped blanks; nil means "dashboard defaults".
|
|
286
|
+
var agentOptions: CrowdPlayAgentOptions?
|
|
287
|
+
if let agent {
|
|
288
|
+
let voice = agent["voice"] as? String
|
|
289
|
+
let prompt = agent["systemPrompt"] as? String
|
|
290
|
+
let context = agent["context"] as? String
|
|
291
|
+
if voice != nil || prompt != nil || context != nil {
|
|
292
|
+
agentOptions = CrowdPlayAgentOptions(voice: voice, systemPrompt: prompt, context: context)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
273
295
|
Task { @MainActor in
|
|
274
296
|
let core = CrowdPlayRNCore.shared
|
|
275
297
|
core.displayName = displayName as String
|
|
@@ -282,7 +304,8 @@ public final class CrowdPlayRNModule: RCTEventEmitter {
|
|
|
282
304
|
)
|
|
283
305
|
await core.engine.join(displayName: displayName as String,
|
|
284
306
|
roomCode: roomCode as String,
|
|
285
|
-
consent: consent
|
|
307
|
+
consent: consent,
|
|
308
|
+
agent: agentOptions)
|
|
286
309
|
if case let .failed(message) = core.engine.phase {
|
|
287
310
|
rejecter("join_failed", message, nil)
|
|
288
311
|
} else {
|
|
@@ -13,6 +13,7 @@ RCT_EXTERN_METHOD(consentText : (RCTPromiseResolveBlock)resolve
|
|
|
13
13
|
RCT_EXTERN_METHOD(join : (NSString *)displayName
|
|
14
14
|
roomCode : (NSString *)roomCode
|
|
15
15
|
consentGrantedAtMs : (nonnull NSNumber *)consentGrantedAtMs
|
|
16
|
+
agent : (NSDictionary *)agent
|
|
16
17
|
resolver : (RCTPromiseResolveBlock)resolve
|
|
17
18
|
rejecter : (RCTPromiseRejectBlock)reject)
|
|
18
19
|
RCT_EXTERN_METHOD(leave : (RCTPromiseResolveBlock)resolve
|
package/ios/wire.rb
CHANGED
|
@@ -25,7 +25,7 @@ SDK_URL = 'https://github.com/symbiateam/crowdplaysdk'
|
|
|
25
25
|
# The ENGINE release this bridge was tested against. The npm package
|
|
26
26
|
# version may be AHEAD of this (bridge/docs fixes ship without a new
|
|
27
27
|
# engine binary); that is deliberate, not drift.
|
|
28
|
-
SDK_VERSION = '0.
|
|
28
|
+
SDK_VERSION = '0.4.0'
|
|
29
29
|
BRIDGE_FILES = ['CrowdPlayRNModule.swift', 'CrowdPlayRNVideoView.swift', 'CrowdPlayReactNative.m'].freeze
|
|
30
30
|
|
|
31
31
|
project_name = ARGV[0] or abort 'usage: ruby wire.rb <YourProjectName>'
|
package/lib/VoiceView.d.ts
CHANGED
|
@@ -20,7 +20,24 @@ export interface VoiceActivity {
|
|
|
20
20
|
/** Live phase + energy from the call engine. Subscribing starts the native
|
|
21
21
|
* fast stream; the last unmount stops it. */
|
|
22
22
|
export declare function useVoiceActivity(): VoiceActivity;
|
|
23
|
+
export interface VoiceCaption {
|
|
24
|
+
role: 'agent' | 'user';
|
|
25
|
+
text: string;
|
|
26
|
+
}
|
|
27
|
+
/** The latest live transcript line (speech bubbles, subtitles). Captions
|
|
28
|
+
* are broadcast by the voice agent as it speaks; null until the first one. */
|
|
29
|
+
export declare function useLatestCaption(): VoiceCaption | null;
|
|
30
|
+
/** Make ANY child view breathe, pulse, and glow with the conversation —
|
|
31
|
+
* the one-line path from the app's own art (mascot, logo, character) to a
|
|
32
|
+
* living AI presence. */
|
|
33
|
+
export declare function VoiceReactive({ children, glowColor, }: {
|
|
34
|
+
children: React.ReactNode;
|
|
35
|
+
glowColor?: string;
|
|
36
|
+
}): React.JSX.Element;
|
|
23
37
|
export interface CrowdPlayVoiceViewProps {
|
|
38
|
+
/** Built-in visual shape: the breathing orb (default) or equalizer bars.
|
|
39
|
+
* Passing children switches to the halo (ring + glow around YOUR art). */
|
|
40
|
+
variant?: 'orb' | 'bars';
|
|
24
41
|
/** Orb color while the AI is speaking. */
|
|
25
42
|
agentColor?: string;
|
|
26
43
|
/** Ring color while the user is speaking. */
|
|
@@ -29,5 +46,8 @@ export interface CrowdPlayVoiceViewProps {
|
|
|
29
46
|
idleColor?: string;
|
|
30
47
|
/** Diameter in points. */
|
|
31
48
|
size?: number;
|
|
49
|
+
/** App-provided center content (mascot portrait, logo): rendered inside
|
|
50
|
+
* an animated halo instead of the orb. */
|
|
51
|
+
children?: React.ReactNode;
|
|
32
52
|
}
|
|
33
|
-
export declare function CrowdPlayVoiceView({ agentColor, listeningColor, idleColor, size, }: CrowdPlayVoiceViewProps): React.JSX.Element;
|
|
53
|
+
export declare function CrowdPlayVoiceView({ variant, agentColor, listeningColor, idleColor, size, children, }: CrowdPlayVoiceViewProps): React.JSX.Element;
|
package/lib/VoiceView.js
CHANGED
|
@@ -46,6 +46,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
46
46
|
})();
|
|
47
47
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
48
|
exports.useVoiceActivity = useVoiceActivity;
|
|
49
|
+
exports.useLatestCaption = useLatestCaption;
|
|
50
|
+
exports.VoiceReactive = VoiceReactive;
|
|
49
51
|
exports.CrowdPlayVoiceView = CrowdPlayVoiceView;
|
|
50
52
|
const react_1 = __importStar(require("react"));
|
|
51
53
|
const react_native_1 = require("react-native");
|
|
@@ -92,7 +94,45 @@ function useVoiceActivity() {
|
|
|
92
94
|
}, []);
|
|
93
95
|
return activity;
|
|
94
96
|
}
|
|
95
|
-
|
|
97
|
+
/** The latest live transcript line (speech bubbles, subtitles). Captions
|
|
98
|
+
* are broadcast by the voice agent as it speaks; null until the first one. */
|
|
99
|
+
function useLatestCaption() {
|
|
100
|
+
const [caption, setCaption] = (0, react_1.useState)(null);
|
|
101
|
+
(0, react_1.useEffect)(() => {
|
|
102
|
+
const module = react_native_1.NativeModules.CrowdPlayReactNative;
|
|
103
|
+
if (!module)
|
|
104
|
+
return;
|
|
105
|
+
const emitter = new react_native_1.NativeEventEmitter(module);
|
|
106
|
+
const sub = emitter.addListener('crowdplay:caption', (e) => setCaption(e));
|
|
107
|
+
return () => sub.remove();
|
|
108
|
+
}, []);
|
|
109
|
+
return caption;
|
|
110
|
+
}
|
|
111
|
+
/** Make ANY child view breathe, pulse, and glow with the conversation —
|
|
112
|
+
* the one-line path from the app's own art (mascot, logo, character) to a
|
|
113
|
+
* living AI presence. */
|
|
114
|
+
function VoiceReactive({ children, glowColor = '#598CFF', }) {
|
|
115
|
+
const { energy } = useVoiceActivity();
|
|
116
|
+
const scale = (0, react_1.useRef)(new react_native_1.Animated.Value(1)).current;
|
|
117
|
+
const glow = (0, react_1.useRef)(new react_native_1.Animated.Value(0)).current;
|
|
118
|
+
(0, react_1.useEffect)(() => {
|
|
119
|
+
react_native_1.Animated.spring(scale, {
|
|
120
|
+
toValue: 1 + 0.08 * energy, useNativeDriver: true, speed: 20, bounciness: 12,
|
|
121
|
+
}).start();
|
|
122
|
+
react_native_1.Animated.timing(glow, {
|
|
123
|
+
toValue: energy, duration: 120, useNativeDriver: true,
|
|
124
|
+
}).start();
|
|
125
|
+
}, [energy, scale, glow]);
|
|
126
|
+
return (<react_native_1.View style={styles.container}>
|
|
127
|
+
<react_native_1.Animated.View pointerEvents="none" style={[react_native_1.StyleSheet.absoluteFillObject, styles.reactiveGlow, {
|
|
128
|
+
backgroundColor: glowColor,
|
|
129
|
+
opacity: react_native_1.Animated.multiply(glow, 0.35),
|
|
130
|
+
transform: [{ scale: 1.25 }],
|
|
131
|
+
}]}/>
|
|
132
|
+
<react_native_1.Animated.View style={{ transform: [{ scale }] }}>{children}</react_native_1.Animated.View>
|
|
133
|
+
</react_native_1.View>);
|
|
134
|
+
}
|
|
135
|
+
function CrowdPlayVoiceView({ variant = 'orb', agentColor = '#598CFF', listeningColor = '#4DD9B3', idleColor = '#BFBFBF', size = 180, children, }) {
|
|
96
136
|
const { phase, energy } = useVoiceActivity();
|
|
97
137
|
const breathe = (0, react_1.useRef)(new react_native_1.Animated.Value(0)).current;
|
|
98
138
|
const scale = (0, react_1.useRef)(new react_native_1.Animated.Value(0.62)).current;
|
|
@@ -137,20 +177,55 @@ function CrowdPlayVoiceView({ agentColor = '#598CFF', listeningColor = '#4DD9B3'
|
|
|
137
177
|
opacity: 0.18 * dim + 0.25 * energy,
|
|
138
178
|
transform: [{ scale: react_native_1.Animated.multiply(halo, breatheScale) }],
|
|
139
179
|
}]}/>
|
|
140
|
-
{phase === 'listening' && (<react_native_1.View style={[styles.circle, {
|
|
180
|
+
{(phase === 'listening' || children != null) && (<react_native_1.View style={[styles.circle, {
|
|
141
181
|
width: size * 0.8, height: size * 0.8, borderRadius: size * 0.4,
|
|
142
|
-
borderWidth: Math.max(2, size * 0.
|
|
182
|
+
borderWidth: Math.max(2, size * (children != null ? 0.02 : 0.015)),
|
|
183
|
+
borderColor: color,
|
|
143
184
|
backgroundColor: 'transparent',
|
|
144
185
|
}]}/>)}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
186
|
+
{children != null ? (
|
|
187
|
+
// Halo mode: the app's own art at the center, ring + glow around it.
|
|
188
|
+
<react_native_1.Animated.View style={{
|
|
189
|
+
width: size * 0.72, height: size * 0.72,
|
|
190
|
+
borderRadius: size * 0.36, overflow: 'hidden',
|
|
149
191
|
transform: [{ scale: react_native_1.Animated.multiply(scale, breatheScale) }],
|
|
150
|
-
}
|
|
192
|
+
}}>
|
|
193
|
+
{children}
|
|
194
|
+
</react_native_1.Animated.View>) : variant === 'bars' ? (<BarsVisual color={color} dim={dim} energy={energy} size={size}/>) : (<react_native_1.Animated.View style={[styles.circle, {
|
|
195
|
+
width: size, height: size, borderRadius: size / 2,
|
|
196
|
+
backgroundColor: color,
|
|
197
|
+
opacity: dim,
|
|
198
|
+
transform: [{ scale: react_native_1.Animated.multiply(scale, breatheScale) }],
|
|
199
|
+
}]}/>)}
|
|
200
|
+
</react_native_1.View>);
|
|
201
|
+
}
|
|
202
|
+
/** Equalizer-style bars: heights mix energy with per-bar sines so they
|
|
203
|
+
* dance during speech and rest at a low idle. */
|
|
204
|
+
function BarsVisual({ color, dim, energy, size }) {
|
|
205
|
+
const [tick, setTick] = (0, react_1.useState)(0);
|
|
206
|
+
(0, react_1.useEffect)(() => {
|
|
207
|
+
const id = setInterval(() => setTick((t) => t + 1), 90);
|
|
208
|
+
return () => clearInterval(id);
|
|
209
|
+
}, []);
|
|
210
|
+
const t = tick * 0.09;
|
|
211
|
+
return (<react_native_1.View style={styles.barsRow}>
|
|
212
|
+
{[0, 1, 2, 3, 4].map((index) => {
|
|
213
|
+
const phase = index * 1.3;
|
|
214
|
+
const wave = 0.5 + 0.5 * Math.sin(t * (5 + index * 1.7) + phase);
|
|
215
|
+
const height = 0.15 + 0.08 * Math.sin(t * 1.4 + phase) + energy * wave * 0.8;
|
|
216
|
+
return (<react_native_1.View key={index} style={{
|
|
217
|
+
width: size * 0.1,
|
|
218
|
+
height: size * Math.min(1, height),
|
|
219
|
+
borderRadius: size * 0.04,
|
|
220
|
+
backgroundColor: color,
|
|
221
|
+
opacity: dim,
|
|
222
|
+
}}/>);
|
|
223
|
+
})}
|
|
151
224
|
</react_native_1.View>);
|
|
152
225
|
}
|
|
153
226
|
const styles = react_native_1.StyleSheet.create({
|
|
154
227
|
container: { alignItems: 'center', justifyContent: 'center' },
|
|
155
228
|
circle: { position: 'absolute' },
|
|
229
|
+
barsRow: { flexDirection: 'row', alignItems: 'center', columnGap: 8 },
|
|
230
|
+
reactiveGlow: { borderRadius: 9999 },
|
|
156
231
|
});
|
package/lib/index.d.ts
CHANGED
|
@@ -58,6 +58,28 @@ export interface ConsentGrant {
|
|
|
58
58
|
/** Date.now() at the moment the participant agreed. */
|
|
59
59
|
grantedAtMs: number;
|
|
60
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Per-join settings for the voice AI (D-095), for apps with the Voice AI
|
|
63
|
+
* switch on. The dashboard config stays the default; anything passed here
|
|
64
|
+
* overrides it for THIS session only. Fixed once the session starts (leave
|
|
65
|
+
* and join again to change). The options of the join that creates the AI
|
|
66
|
+
* apply to the whole room. The app can never change the provider, the
|
|
67
|
+
* model, or the API key from here.
|
|
68
|
+
*/
|
|
69
|
+
export interface AgentOptions {
|
|
70
|
+
/** A voice of the app's configured provider. Gemini: Charon, Aoede,
|
|
71
|
+
* Fenrir, Kore, Puck. OpenAI: marin, cedar, alloy, ash, ballad, coral,
|
|
72
|
+
* echo, sage, shimmer, verse. Grok: Ara, Rex, Sal, Eve, Leo. Unknown
|
|
73
|
+
* names fall back to the dashboard voice. */
|
|
74
|
+
voice?: string;
|
|
75
|
+
/** Replaces the dashboard persona for this session. Max 8192 chars. */
|
|
76
|
+
systemPrompt?: string;
|
|
77
|
+
/** Short text appended to the persona: what the app knows about this
|
|
78
|
+
* user, where they left off. This is how an app gives the AI memory:
|
|
79
|
+
* keep a few sentences per user in your own storage and pass them
|
|
80
|
+
* here. Max 4000 chars. */
|
|
81
|
+
context?: string;
|
|
82
|
+
}
|
|
61
83
|
export type Phase = 'idle' | 'connecting' | 'connected' | 'failed';
|
|
62
84
|
export interface RecordingSnapshot {
|
|
63
85
|
/** Seconds of audio safely written so far. */
|
|
@@ -161,6 +183,7 @@ declare const CrowdPlay: {
|
|
|
161
183
|
displayName: string;
|
|
162
184
|
roomCode: string;
|
|
163
185
|
consent: ConsentGrant;
|
|
186
|
+
agent?: AgentOptions;
|
|
164
187
|
}): Promise<void>;
|
|
165
188
|
/** Leave the room. Recording stops; uploads proceed automatically (they
|
|
166
189
|
* survive backgrounding; force-quit pauses them until next launch). */
|
|
@@ -193,5 +216,5 @@ declare const CrowdPlay: {
|
|
|
193
216
|
export default CrowdPlay;
|
|
194
217
|
export { CrowdPlayConsentScreen } from './ConsentScreen';
|
|
195
218
|
export { CrowdPlayVideoView } from './VideoView';
|
|
196
|
-
export { CrowdPlayVoiceView, useVoiceActivity } from './VoiceView';
|
|
197
|
-
export type { CrowdPlayVoiceViewProps, VoiceActivity, VoicePhase } from './VoiceView';
|
|
219
|
+
export { CrowdPlayVoiceView, VoiceReactive, useVoiceActivity, useLatestCaption } from './VoiceView';
|
|
220
|
+
export type { CrowdPlayVoiceViewProps, VoiceActivity, VoicePhase, VoiceCaption } from './VoiceView';
|
package/lib/index.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* path that records without it.
|
|
23
23
|
*/
|
|
24
24
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
-
exports.useVoiceActivity = exports.CrowdPlayVoiceView = exports.CrowdPlayVideoView = exports.CrowdPlayConsentScreen = void 0;
|
|
25
|
+
exports.useLatestCaption = exports.useVoiceActivity = exports.VoiceReactive = exports.CrowdPlayVoiceView = exports.CrowdPlayVideoView = exports.CrowdPlayConsentScreen = void 0;
|
|
26
26
|
const react_native_1 = require("react-native");
|
|
27
27
|
function native() {
|
|
28
28
|
const module = react_native_1.NativeModules.CrowdPlayReactNative;
|
|
@@ -81,7 +81,7 @@ const CrowdPlay = {
|
|
|
81
81
|
if (!configured) {
|
|
82
82
|
throw new Error('crowdplaysdk: call CrowdPlay.configure() before join()');
|
|
83
83
|
}
|
|
84
|
-
const { displayName, roomCode, consent } = options ?? {};
|
|
84
|
+
const { displayName, roomCode, consent, agent } = options ?? {};
|
|
85
85
|
if (!displayName?.trim())
|
|
86
86
|
throw new Error('crowdplaysdk: displayName is required');
|
|
87
87
|
if (!roomCode?.trim())
|
|
@@ -91,7 +91,19 @@ const CrowdPlay = {
|
|
|
91
91
|
'(or your own UI displaying CrowdPlay.consentText()) and pass its result. ' +
|
|
92
92
|
'Recording without consent is not supported.');
|
|
93
93
|
}
|
|
94
|
-
|
|
94
|
+
// Flat strings, clamped to the server's caps; blanks are not sent.
|
|
95
|
+
let agentFields = null;
|
|
96
|
+
if (agent && typeof agent === 'object') {
|
|
97
|
+
const put = (key, value, max) => {
|
|
98
|
+
if (typeof value === 'string' && value.trim()) {
|
|
99
|
+
agentFields = { ...(agentFields ?? {}), [key]: value.slice(0, max) };
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
put('voice', agent.voice, 60);
|
|
103
|
+
put('systemPrompt', agent.systemPrompt, 8192);
|
|
104
|
+
put('context', agent.context, 4000);
|
|
105
|
+
}
|
|
106
|
+
return native().join(displayName.trim(), roomCode, consent.grantedAtMs, agentFields);
|
|
95
107
|
},
|
|
96
108
|
/** Leave the room. Recording stops; uploads proceed automatically (they
|
|
97
109
|
* survive backgrounding; force-quit pauses them until next launch). */
|
|
@@ -145,4 +157,6 @@ var VideoView_1 = require("./VideoView");
|
|
|
145
157
|
Object.defineProperty(exports, "CrowdPlayVideoView", { enumerable: true, get: function () { return VideoView_1.CrowdPlayVideoView; } });
|
|
146
158
|
var VoiceView_1 = require("./VoiceView");
|
|
147
159
|
Object.defineProperty(exports, "CrowdPlayVoiceView", { enumerable: true, get: function () { return VoiceView_1.CrowdPlayVoiceView; } });
|
|
160
|
+
Object.defineProperty(exports, "VoiceReactive", { enumerable: true, get: function () { return VoiceView_1.VoiceReactive; } });
|
|
148
161
|
Object.defineProperty(exports, "useVoiceActivity", { enumerable: true, get: function () { return VoiceView_1.useVoiceActivity; } });
|
|
162
|
+
Object.defineProperty(exports, "useLatestCaption", { enumerable: true, get: function () { return VoiceView_1.useLatestCaption; } });
|
package/llms.txt
CHANGED
|
@@ -132,7 +132,9 @@ Default export `CrowdPlay`:
|
|
|
132
132
|
consentText(): Promise<string> // the REQUIRED consent wording
|
|
133
133
|
warmUp(): void // pre-warm backend from the join screen
|
|
134
134
|
join(opts: { displayName: string; roomCode: string;
|
|
135
|
-
consent: ConsentGrant
|
|
135
|
+
consent: ConsentGrant;
|
|
136
|
+
agent?: AgentOptions }): Promise<void> // THROWS without consent
|
|
137
|
+
AgentOptions { voice?: string; systemPrompt?: string; context?: string } // 0.4.0+, voice-AI apps
|
|
136
138
|
leave(): Promise<void>
|
|
137
139
|
setMicMuted(muted: boolean): Promise<void>
|
|
138
140
|
setCameraEnabled(enabled: boolean): Promise<void>
|
|
@@ -151,13 +153,77 @@ Named exports: CrowdPlayConsentScreen (props: onConsent(grant)), CrowdPlayVideoV
|
|
|
151
153
|
CrowdPlayVoiceView (props: agentColor?, listeningColor?, idleColor?, size?),
|
|
152
154
|
useVoiceActivity() -> { phase: 'connecting'|'idle'|'listening'|'speaking', energy: 0…1 }.
|
|
153
155
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
156
|
+
The voice AI (apps with the Voice AI switch enabled on the dashboard):
|
|
157
|
+
|
|
158
|
+
How it works — all server-side, nothing to wire up. When any user joins a
|
|
159
|
+
call, CrowdPlay's servers put a live AI participant into the room within
|
|
160
|
+
~3 seconds. It joins with name "agent" (identity "agent-<8hex>"), greets
|
|
161
|
+
the user, and holds a real spoken conversation (speech-to-speech: it
|
|
162
|
+
hears the user directly, answers in natural voice, can be interrupted).
|
|
163
|
+
The app writes ZERO AI code — no model API calls, no AI keys, no chat
|
|
164
|
+
backend, no STT/TTS. Provider, model and API key are set by the BUILDER on
|
|
165
|
+
the CrowdPlay dashboard, and so are the DEFAULT persona and voice.
|
|
166
|
+
Per-join overrides (0.4.0+): the app MAY pass `agent` to join() to change
|
|
167
|
+
the voice and the persona and give a short context text for one session:
|
|
168
|
+
await CrowdPlay.join({ displayName, roomCode, consent: grant,
|
|
169
|
+
agent: { voice: 'Aoede', context: "The user's name is Sam. Last time you practised past tense." } });
|
|
170
|
+
voice must be one of the configured provider's voices (Gemini: Charon,
|
|
171
|
+
Aoede, Fenrir, Kore, Puck; OpenAI: marin, cedar, alloy, ash, ballad,
|
|
172
|
+
coral, echo, sage, shimmer, verse; Grok: Ara, Rex, Sal, Eve, Leo), unknown
|
|
173
|
+
falls back to the dashboard voice. Caps: systemPrompt 8192 chars, context
|
|
174
|
+
4000. The options of the join that CREATES the AI apply to the whole room;
|
|
175
|
+
fixed for the session (leave and join again to change); provider, model
|
|
176
|
+
and key cannot be changed from the app. MEMORY is built with `context`:
|
|
177
|
+
keep a few sentences per user in the app's own storage and pass them at
|
|
178
|
+
every join.
|
|
179
|
+
Conversations are recorded and transcribed by the platform automatically.
|
|
180
|
+
RECOMMENDED: voice-AI apps should be AUDIO-ONLY (audioOnly: true — no
|
|
181
|
+
camera permission, no video anywhere); a camera adds nothing to talking
|
|
182
|
+
with an AI. Keep video only when the app ALSO has calls between people.
|
|
183
|
+
|
|
184
|
+
The AI conversation screen — the approved standard layout: the animated
|
|
185
|
+
visual front and center, large (it IS the screen); NO self camera
|
|
186
|
+
preview and NO video tiles on this screen, ever; around it only the AI's
|
|
187
|
+
name/persona, a small REC indicator, a leave button, and the small
|
|
188
|
+
audio-output menu. One calm screen, one living visual, nothing competing
|
|
189
|
+
with it.
|
|
190
|
+
|
|
191
|
+
What the app builds — the AI's on-screen presence. The AI publishes
|
|
192
|
+
AUDIO ONLY: never a camera tile, an empty box, or a static image; never
|
|
193
|
+
count it as a generic user. Detect it in the participants event by name
|
|
194
|
+
"agent" (or identity prefix "agent-").
|
|
195
|
+
- The visual is a MENU, zero-effort to fully custom (0.3.4+ for 2-5):
|
|
196
|
+
1. Orb (default): <CrowdPlayVoiceView /> — breathes idle, rings with
|
|
197
|
+
the user's voice, pulses with the AI's. Props: agentColor,
|
|
198
|
+
listeningColor, idleColor, size.
|
|
199
|
+
2. Bars: <CrowdPlayVoiceView variant='bars' /> — equalizer look, good
|
|
200
|
+
under other content (scoreboards, question cards, quizzes).
|
|
201
|
+
3. Halo around the app's own art: pass a mascot portrait / logo /
|
|
202
|
+
character as children of <CrowdPlayVoiceView> — animated ring +
|
|
203
|
+
glow around whatever you provide.
|
|
204
|
+
4. Make ANY view alive: wrap it in <VoiceReactive> — breathing, pulse
|
|
205
|
+
and speech glow on the app's own art in one line.
|
|
206
|
+
5. Fully custom (best for characters): drive ANY visual from the
|
|
207
|
+
useVoiceActivity() hook — { phase: 'connecting'|'idle'|'listening'|
|
|
208
|
+
'speaking', energy: 0…1 smoothed loudness }. Example, an illustrated
|
|
209
|
+
tutor that reacts to the conversation:
|
|
210
|
+
|
|
211
|
+
function TutorAvatar() {
|
|
212
|
+
const { phase, energy } = useVoiceActivity();
|
|
213
|
+
return <Character // the app's own art
|
|
214
|
+
mouthOpen={phase === 'speaking' ? energy : 0}
|
|
215
|
+
pose={phase === 'listening' ? 'leanIn' : 'idleBlink'} />;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
Any art pipeline works (Animated, Lottie, Rive, sprite frames) — bind
|
|
219
|
+
mouth/pose/glow to phase + energy.
|
|
220
|
+
- Live captions (0.3.4+): each line the AI and the user say is streamed
|
|
221
|
+
as it is spoken — the useLatestCaption() hook returns { role, text }.
|
|
222
|
+
For speech bubbles, subtitles, language-learning corrections. Optional
|
|
223
|
+
UI; the full timestamped transcript is always delivered with the
|
|
224
|
+
session regardless.
|
|
225
|
+
- Testing: the AI appears only on a real iPhone (simulators have no
|
|
226
|
+
microphone). Join a room, wait ~3 s, it speaks first.
|
|
161
227
|
|
|
162
228
|
Types:
|
|
163
229
|
CrowdPlayConfig { serverUrl; appKey; audioOnly?=false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crowdplaysdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|
package/src/VoiceView.tsx
CHANGED
|
@@ -73,7 +73,67 @@ export function useVoiceActivity(): VoiceActivity {
|
|
|
73
73
|
return activity;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
export interface VoiceCaption {
|
|
77
|
+
role: 'agent' | 'user';
|
|
78
|
+
text: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The latest live transcript line (speech bubbles, subtitles). Captions
|
|
82
|
+
* are broadcast by the voice agent as it speaks; null until the first one. */
|
|
83
|
+
export function useLatestCaption(): VoiceCaption | null {
|
|
84
|
+
const [caption, setCaption] = useState<VoiceCaption | null>(null);
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const module = NativeModules.CrowdPlayReactNative;
|
|
87
|
+
if (!module) return;
|
|
88
|
+
const emitter = new NativeEventEmitter(module);
|
|
89
|
+
const sub = emitter.addListener('crowdplay:caption', (e: VoiceCaption) => setCaption(e));
|
|
90
|
+
return () => sub.remove();
|
|
91
|
+
}, []);
|
|
92
|
+
return caption;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Make ANY child view breathe, pulse, and glow with the conversation —
|
|
96
|
+
* the one-line path from the app's own art (mascot, logo, character) to a
|
|
97
|
+
* living AI presence. */
|
|
98
|
+
export function VoiceReactive({
|
|
99
|
+
children,
|
|
100
|
+
glowColor = '#598CFF',
|
|
101
|
+
}: {
|
|
102
|
+
children: React.ReactNode;
|
|
103
|
+
glowColor?: string;
|
|
104
|
+
}): React.JSX.Element {
|
|
105
|
+
const { energy } = useVoiceActivity();
|
|
106
|
+
const scale = useRef(new Animated.Value(1)).current;
|
|
107
|
+
const glow = useRef(new Animated.Value(0)).current;
|
|
108
|
+
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
Animated.spring(scale, {
|
|
111
|
+
toValue: 1 + 0.08 * energy, useNativeDriver: true, speed: 20, bounciness: 12,
|
|
112
|
+
}).start();
|
|
113
|
+
Animated.timing(glow, {
|
|
114
|
+
toValue: energy, duration: 120, useNativeDriver: true,
|
|
115
|
+
}).start();
|
|
116
|
+
}, [energy, scale, glow]);
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<View style={styles.container}>
|
|
120
|
+
<Animated.View
|
|
121
|
+
pointerEvents="none"
|
|
122
|
+
style={[StyleSheet.absoluteFillObject, styles.reactiveGlow, {
|
|
123
|
+
backgroundColor: glowColor,
|
|
124
|
+
opacity: Animated.multiply(glow, 0.35),
|
|
125
|
+
transform: [{ scale: 1.25 }],
|
|
126
|
+
}]}
|
|
127
|
+
/>
|
|
128
|
+
<Animated.View style={{ transform: [{ scale }] }}>{children}</Animated.View>
|
|
129
|
+
</View>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
76
133
|
export interface CrowdPlayVoiceViewProps {
|
|
134
|
+
/** Built-in visual shape: the breathing orb (default) or equalizer bars.
|
|
135
|
+
* Passing children switches to the halo (ring + glow around YOUR art). */
|
|
136
|
+
variant?: 'orb' | 'bars';
|
|
77
137
|
/** Orb color while the AI is speaking. */
|
|
78
138
|
agentColor?: string;
|
|
79
139
|
/** Ring color while the user is speaking. */
|
|
@@ -82,13 +142,18 @@ export interface CrowdPlayVoiceViewProps {
|
|
|
82
142
|
idleColor?: string;
|
|
83
143
|
/** Diameter in points. */
|
|
84
144
|
size?: number;
|
|
145
|
+
/** App-provided center content (mascot portrait, logo): rendered inside
|
|
146
|
+
* an animated halo instead of the orb. */
|
|
147
|
+
children?: React.ReactNode;
|
|
85
148
|
}
|
|
86
149
|
|
|
87
150
|
export function CrowdPlayVoiceView({
|
|
151
|
+
variant = 'orb',
|
|
88
152
|
agentColor = '#598CFF',
|
|
89
153
|
listeningColor = '#4DD9B3',
|
|
90
154
|
idleColor = '#BFBFBF',
|
|
91
155
|
size = 180,
|
|
156
|
+
children,
|
|
92
157
|
}: CrowdPlayVoiceViewProps): React.JSX.Element {
|
|
93
158
|
const { phase, energy } = useVoiceActivity();
|
|
94
159
|
const breathe = useRef(new Animated.Value(0)).current;
|
|
@@ -148,23 +213,73 @@ export function CrowdPlayVoiceView({
|
|
|
148
213
|
transform: [{ scale: Animated.multiply(halo, breatheScale) }],
|
|
149
214
|
}]}
|
|
150
215
|
/>
|
|
151
|
-
{phase === 'listening' && (
|
|
216
|
+
{(phase === 'listening' || children != null) && (
|
|
152
217
|
<View
|
|
153
218
|
style={[styles.circle, {
|
|
154
219
|
width: size * 0.8, height: size * 0.8, borderRadius: size * 0.4,
|
|
155
|
-
borderWidth: Math.max(2, size * 0.
|
|
220
|
+
borderWidth: Math.max(2, size * (children != null ? 0.02 : 0.015)),
|
|
221
|
+
borderColor: color,
|
|
156
222
|
backgroundColor: 'transparent',
|
|
157
223
|
}]}
|
|
158
224
|
/>
|
|
159
225
|
)}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
226
|
+
{children != null ? (
|
|
227
|
+
// Halo mode: the app's own art at the center, ring + glow around it.
|
|
228
|
+
<Animated.View
|
|
229
|
+
style={{
|
|
230
|
+
width: size * 0.72, height: size * 0.72,
|
|
231
|
+
borderRadius: size * 0.36, overflow: 'hidden',
|
|
232
|
+
transform: [{ scale: Animated.multiply(scale, breatheScale) }],
|
|
233
|
+
}}
|
|
234
|
+
>
|
|
235
|
+
{children}
|
|
236
|
+
</Animated.View>
|
|
237
|
+
) : variant === 'bars' ? (
|
|
238
|
+
<BarsVisual color={color} dim={dim} energy={energy} size={size} />
|
|
239
|
+
) : (
|
|
240
|
+
<Animated.View
|
|
241
|
+
style={[styles.circle, {
|
|
242
|
+
width: size, height: size, borderRadius: size / 2,
|
|
243
|
+
backgroundColor: color,
|
|
244
|
+
opacity: dim,
|
|
245
|
+
transform: [{ scale: Animated.multiply(scale, breatheScale) }],
|
|
246
|
+
}]}
|
|
247
|
+
/>
|
|
248
|
+
)}
|
|
249
|
+
</View>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Equalizer-style bars: heights mix energy with per-bar sines so they
|
|
254
|
+
* dance during speech and rest at a low idle. */
|
|
255
|
+
function BarsVisual({ color, dim, energy, size }: {
|
|
256
|
+
color: string; dim: number; energy: number; size: number;
|
|
257
|
+
}): React.JSX.Element {
|
|
258
|
+
const [tick, setTick] = useState(0);
|
|
259
|
+
useEffect(() => {
|
|
260
|
+
const id = setInterval(() => setTick((t) => t + 1), 90);
|
|
261
|
+
return () => clearInterval(id);
|
|
262
|
+
}, []);
|
|
263
|
+
const t = tick * 0.09;
|
|
264
|
+
return (
|
|
265
|
+
<View style={styles.barsRow}>
|
|
266
|
+
{[0, 1, 2, 3, 4].map((index) => {
|
|
267
|
+
const phase = index * 1.3;
|
|
268
|
+
const wave = 0.5 + 0.5 * Math.sin(t * (5 + index * 1.7) + phase);
|
|
269
|
+
const height = 0.15 + 0.08 * Math.sin(t * 1.4 + phase) + energy * wave * 0.8;
|
|
270
|
+
return (
|
|
271
|
+
<View
|
|
272
|
+
key={index}
|
|
273
|
+
style={{
|
|
274
|
+
width: size * 0.1,
|
|
275
|
+
height: size * Math.min(1, height),
|
|
276
|
+
borderRadius: size * 0.04,
|
|
277
|
+
backgroundColor: color,
|
|
278
|
+
opacity: dim,
|
|
279
|
+
}}
|
|
280
|
+
/>
|
|
281
|
+
);
|
|
282
|
+
})}
|
|
168
283
|
</View>
|
|
169
284
|
);
|
|
170
285
|
}
|
|
@@ -172,4 +287,6 @@ export function CrowdPlayVoiceView({
|
|
|
172
287
|
const styles = StyleSheet.create({
|
|
173
288
|
container: { alignItems: 'center', justifyContent: 'center' },
|
|
174
289
|
circle: { position: 'absolute' },
|
|
290
|
+
barsRow: { flexDirection: 'row', alignItems: 'center', columnGap: 8 },
|
|
291
|
+
reactiveGlow: { borderRadius: 9999 },
|
|
175
292
|
});
|
package/src/index.ts
CHANGED
|
@@ -68,6 +68,29 @@ export interface ConsentGrant {
|
|
|
68
68
|
grantedAtMs: number;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Per-join settings for the voice AI (D-095), for apps with the Voice AI
|
|
73
|
+
* switch on. The dashboard config stays the default; anything passed here
|
|
74
|
+
* overrides it for THIS session only. Fixed once the session starts (leave
|
|
75
|
+
* and join again to change). The options of the join that creates the AI
|
|
76
|
+
* apply to the whole room. The app can never change the provider, the
|
|
77
|
+
* model, or the API key from here.
|
|
78
|
+
*/
|
|
79
|
+
export interface AgentOptions {
|
|
80
|
+
/** A voice of the app's configured provider. Gemini: Charon, Aoede,
|
|
81
|
+
* Fenrir, Kore, Puck. OpenAI: marin, cedar, alloy, ash, ballad, coral,
|
|
82
|
+
* echo, sage, shimmer, verse. Grok: Ara, Rex, Sal, Eve, Leo. Unknown
|
|
83
|
+
* names fall back to the dashboard voice. */
|
|
84
|
+
voice?: string;
|
|
85
|
+
/** Replaces the dashboard persona for this session. Max 8192 chars. */
|
|
86
|
+
systemPrompt?: string;
|
|
87
|
+
/** Short text appended to the persona: what the app knows about this
|
|
88
|
+
* user, where they left off. This is how an app gives the AI memory:
|
|
89
|
+
* keep a few sentences per user in your own storage and pass them
|
|
90
|
+
* here. Max 4000 chars. */
|
|
91
|
+
context?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
71
94
|
export type Phase = 'idle' | 'connecting' | 'connected' | 'failed';
|
|
72
95
|
|
|
73
96
|
export interface RecordingSnapshot {
|
|
@@ -144,7 +167,8 @@ export type CrowdPlayEventName = keyof CrowdPlayEvents;
|
|
|
144
167
|
interface NativeCrowdPlay {
|
|
145
168
|
configure(config: Record<string, unknown>): void;
|
|
146
169
|
consentText(): Promise<string>;
|
|
147
|
-
join(displayName: string, roomCode: string, consentGrantedAtMs: number
|
|
170
|
+
join(displayName: string, roomCode: string, consentGrantedAtMs: number,
|
|
171
|
+
agent: Record<string, string> | null): Promise<void>;
|
|
148
172
|
leave(): Promise<void>;
|
|
149
173
|
setMicMuted(muted: boolean): Promise<void>;
|
|
150
174
|
setCameraEnabled(enabled: boolean): Promise<void>;
|
|
@@ -220,11 +244,13 @@ const CrowdPlay = {
|
|
|
220
244
|
* your own UI at the moment of agreement. There is no way to record
|
|
221
245
|
* without it, here or natively.
|
|
222
246
|
*/
|
|
223
|
-
async join(options: {
|
|
247
|
+
async join(options: {
|
|
248
|
+
displayName: string; roomCode: string; consent: ConsentGrant; agent?: AgentOptions;
|
|
249
|
+
}): Promise<void> {
|
|
224
250
|
if (!configured) {
|
|
225
251
|
throw new Error('crowdplaysdk: call CrowdPlay.configure() before join()');
|
|
226
252
|
}
|
|
227
|
-
const { displayName, roomCode, consent } = options ?? ({} as never);
|
|
253
|
+
const { displayName, roomCode, consent, agent } = options ?? ({} as never);
|
|
228
254
|
if (!displayName?.trim()) throw new Error('crowdplaysdk: displayName is required');
|
|
229
255
|
if (!roomCode?.trim()) throw new Error('crowdplaysdk: roomCode is required');
|
|
230
256
|
if (!consent || typeof consent.grantedAtMs !== 'number' || consent.grantedAtMs <= 0) {
|
|
@@ -234,7 +260,19 @@ const CrowdPlay = {
|
|
|
234
260
|
'Recording without consent is not supported.'
|
|
235
261
|
);
|
|
236
262
|
}
|
|
237
|
-
|
|
263
|
+
// Flat strings, clamped to the server's caps; blanks are not sent.
|
|
264
|
+
let agentFields: Record<string, string> | null = null;
|
|
265
|
+
if (agent && typeof agent === 'object') {
|
|
266
|
+
const put = (key: string, value: unknown, max: number) => {
|
|
267
|
+
if (typeof value === 'string' && value.trim()) {
|
|
268
|
+
agentFields = { ...(agentFields ?? {}), [key]: value.slice(0, max) };
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
put('voice', agent.voice, 60);
|
|
272
|
+
put('systemPrompt', agent.systemPrompt, 8192);
|
|
273
|
+
put('context', agent.context, 4000);
|
|
274
|
+
}
|
|
275
|
+
return native().join(displayName.trim(), roomCode, consent.grantedAtMs, agentFields);
|
|
238
276
|
},
|
|
239
277
|
|
|
240
278
|
/** Leave the room. Recording stops; uploads proceed automatically (they
|
|
@@ -297,5 +335,5 @@ const CrowdPlay = {
|
|
|
297
335
|
export default CrowdPlay;
|
|
298
336
|
export { CrowdPlayConsentScreen } from './ConsentScreen';
|
|
299
337
|
export { CrowdPlayVideoView } from './VideoView';
|
|
300
|
-
export { CrowdPlayVoiceView, useVoiceActivity } from './VoiceView';
|
|
301
|
-
export type { CrowdPlayVoiceViewProps, VoiceActivity, VoicePhase } from './VoiceView';
|
|
338
|
+
export { CrowdPlayVoiceView, VoiceReactive, useVoiceActivity, useLatestCaption } from './VoiceView';
|
|
339
|
+
export type { CrowdPlayVoiceViewProps, VoiceActivity, VoicePhase, VoiceCaption } from './VoiceView';
|