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.
@@ -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.3.3'
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>'
@@ -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
- function CrowdPlayVoiceView({ agentColor = '#598CFF', listeningColor = '#4DD9B3', idleColor = '#BFBFBF', size = 180, }) {
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.015), borderColor: color,
182
+ borderWidth: Math.max(2, size * (children != null ? 0.02 : 0.015)),
183
+ borderColor: color,
143
184
  backgroundColor: 'transparent',
144
185
  }]}/>)}
145
- <react_native_1.Animated.View style={[styles.circle, {
146
- width: size, height: size, borderRadius: size / 2,
147
- backgroundColor: color,
148
- opacity: dim,
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
- return native().join(displayName.trim(), roomCode, consent.grantedAtMs);
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 }): Promise<void> // THROWS without consent
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
- Voice-AI apps: when the app includes the CrowdPlay voice AI, render the AI
155
- participant with <CrowdPlayVoiceView /> — the standard animated orb that
156
- breathes when idle, rings while the user talks, and pulses while the AI
157
- talks. The AI publishes NO video: never show it as a camera tile or an
158
- empty box. Detect it in the participants event by identity prefix
159
- "agent-" or name "agent". For a fully custom visual, drive your own
160
- component from useVoiceActivity().
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.3",
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.015), borderColor: color,
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
- <Animated.View
161
- style={[styles.circle, {
162
- width: size, height: size, borderRadius: size / 2,
163
- backgroundColor: color,
164
- opacity: dim,
165
- transform: [{ scale: Animated.multiply(scale, breatheScale) }],
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): Promise<void>;
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: { displayName: string; roomCode: string; consent: ConsentGrant }): Promise<void> {
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
- return native().join(displayName.trim(), roomCode, consent.grantedAtMs);
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';