crowdplaysdk 0.3.1 → 0.3.4
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 +51 -1
- package/ios/CrowdPlayReactNative.m +2 -0
- package/ios/wire.rb +1 -1
- package/lib/VoiceView.d.ts +53 -0
- package/lib/VoiceView.js +231 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +6 -1
- package/llms.txt +62 -1
- package/package.json +1 -1
- package/src/VoiceView.tsx +292 -0
- package/src/index.ts +2 -0
|
@@ -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
|
|
@@ -122,6 +132,38 @@ final class CrowdPlayRNCore {
|
|
|
122
132
|
message: engine.micPolicyViolation ?? "")
|
|
123
133
|
}
|
|
124
134
|
|
|
135
|
+
// MARK: - Voice-agent activity (M8 voice UI)
|
|
136
|
+
|
|
137
|
+
private var voiceTimer: Timer?
|
|
138
|
+
|
|
139
|
+
/// Fast (0.15 s) voice-activity stream for the animated voice UI —
|
|
140
|
+
/// runs only while some JS component is subscribed, so the bridge
|
|
141
|
+
/// stays quiet for apps that never render it.
|
|
142
|
+
func startVoiceActivity() {
|
|
143
|
+
guard voiceTimer == nil else { return }
|
|
144
|
+
voiceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: true) { _ in
|
|
145
|
+
Task { @MainActor in CrowdPlayRNCore.shared.voiceTick() }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func stopVoiceActivity() {
|
|
150
|
+
voiceTimer?.invalidate()
|
|
151
|
+
voiceTimer = nil
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private func voiceTick() {
|
|
155
|
+
guard emitter != nil else { return }
|
|
156
|
+
let activity = engine.voiceActivity()
|
|
157
|
+
send("crowdplay:voiceActivity", [
|
|
158
|
+
"agentPresent": activity.agentPresent,
|
|
159
|
+
"agentSpeaking": activity.agentSpeaking,
|
|
160
|
+
"agentLevel": activity.agentLevel,
|
|
161
|
+
"localLevelDbfs": activity.localLevelDbfs as Any,
|
|
162
|
+
"localSpeaking": activity.localSpeaking,
|
|
163
|
+
"connected": engine.phase == .connected,
|
|
164
|
+
])
|
|
165
|
+
}
|
|
166
|
+
|
|
125
167
|
private func updateWarning(_ kind: String, active: Bool, message: String) {
|
|
126
168
|
let was = activeWarnings.contains(kind)
|
|
127
169
|
guard was != active else { return }
|
|
@@ -178,7 +220,15 @@ public final class CrowdPlayRNModule: RCTEventEmitter {
|
|
|
178
220
|
public override static func requiresMainQueueSetup() -> Bool { true }
|
|
179
221
|
|
|
180
222
|
public override func supportedEvents() -> [String]! {
|
|
181
|
-
["crowdplay:phase", "crowdplay:recording", "crowdplay:uploads", "crowdplay:participants", "crowdplay:warning", "crowdplay:audioRoute"]
|
|
223
|
+
["crowdplay:phase", "crowdplay:recording", "crowdplay:uploads", "crowdplay:participants", "crowdplay:warning", "crowdplay:audioRoute", "crowdplay:voiceActivity", "crowdplay:caption"]
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
@objc public func startVoiceActivityUpdates() {
|
|
227
|
+
Task { @MainActor in CrowdPlayRNCore.shared.startVoiceActivity() }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
@objc public func stopVoiceActivityUpdates() {
|
|
231
|
+
Task { @MainActor in CrowdPlayRNCore.shared.stopVoiceActivity() }
|
|
182
232
|
}
|
|
183
233
|
|
|
184
234
|
public override func startObserving() {
|
|
@@ -27,6 +27,8 @@ RCT_EXTERN_METHOD(setAudioOutput : (NSString *)output)
|
|
|
27
27
|
RCT_EXTERN_METHOD(retryRecording)
|
|
28
28
|
RCT_EXTERN_METHOD(retryUploads)
|
|
29
29
|
RCT_EXTERN_METHOD(warmUp)
|
|
30
|
+
RCT_EXTERN_METHOD(startVoiceActivityUpdates)
|
|
31
|
+
RCT_EXTERN_METHOD(stopVoiceActivityUpdates)
|
|
30
32
|
RCT_EXTERN_METHOD(snapshot : (RCTPromiseResolveBlock)resolve
|
|
31
33
|
rejecter : (RCTPromiseRejectBlock)reject)
|
|
32
34
|
RCT_EXTERN_METHOD(doctor : (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.
|
|
28
|
+
SDK_VERSION = '0.3.4'
|
|
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>'
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The standard voice-AI visual (founders, 2026-08-22): a breathing orb in
|
|
3
|
+
* the style of realtime assistants — calm while idle, a cool ring while the
|
|
4
|
+
* user talks, an energetic pulse while the AI speaks. The React Native
|
|
5
|
+
* counterpart of the native SDK's CrowdPlayVoiceView, driven by the same
|
|
6
|
+
* engine signal over a fast bridge event that runs only while mounted.
|
|
7
|
+
*
|
|
8
|
+
* Two levels of customization:
|
|
9
|
+
* 1. Props on <CrowdPlayVoiceView> — colors and size of the built-in orb.
|
|
10
|
+
* 2. useVoiceActivity() — the phase + smoothed energy underneath, for apps
|
|
11
|
+
* that want to draw something entirely their own.
|
|
12
|
+
*/
|
|
13
|
+
import React from 'react';
|
|
14
|
+
export type VoicePhase = 'connecting' | 'idle' | 'listening' | 'speaking';
|
|
15
|
+
export interface VoiceActivity {
|
|
16
|
+
phase: VoicePhase;
|
|
17
|
+
/** 0…1, smoothed (fast attack, slow release). */
|
|
18
|
+
energy: number;
|
|
19
|
+
}
|
|
20
|
+
/** Live phase + energy from the call engine. Subscribing starts the native
|
|
21
|
+
* fast stream; the last unmount stops it. */
|
|
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;
|
|
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';
|
|
41
|
+
/** Orb color while the AI is speaking. */
|
|
42
|
+
agentColor?: string;
|
|
43
|
+
/** Ring color while the user is speaking. */
|
|
44
|
+
listeningColor?: string;
|
|
45
|
+
/** Resting color (dimmed while connecting). */
|
|
46
|
+
idleColor?: string;
|
|
47
|
+
/** Diameter in points. */
|
|
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;
|
|
52
|
+
}
|
|
53
|
+
export declare function CrowdPlayVoiceView({ variant, agentColor, listeningColor, idleColor, size, children, }: CrowdPlayVoiceViewProps): React.JSX.Element;
|
package/lib/VoiceView.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The standard voice-AI visual (founders, 2026-08-22): a breathing orb in
|
|
4
|
+
* the style of realtime assistants — calm while idle, a cool ring while the
|
|
5
|
+
* user talks, an energetic pulse while the AI speaks. The React Native
|
|
6
|
+
* counterpart of the native SDK's CrowdPlayVoiceView, driven by the same
|
|
7
|
+
* engine signal over a fast bridge event that runs only while mounted.
|
|
8
|
+
*
|
|
9
|
+
* Two levels of customization:
|
|
10
|
+
* 1. Props on <CrowdPlayVoiceView> — colors and size of the built-in orb.
|
|
11
|
+
* 2. useVoiceActivity() — the phase + smoothed energy underneath, for apps
|
|
12
|
+
* that want to draw something entirely their own.
|
|
13
|
+
*/
|
|
14
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
17
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
18
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
19
|
+
}
|
|
20
|
+
Object.defineProperty(o, k2, desc);
|
|
21
|
+
}) : (function(o, m, k, k2) {
|
|
22
|
+
if (k2 === undefined) k2 = k;
|
|
23
|
+
o[k2] = m[k];
|
|
24
|
+
}));
|
|
25
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
26
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
27
|
+
}) : function(o, v) {
|
|
28
|
+
o["default"] = v;
|
|
29
|
+
});
|
|
30
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
31
|
+
var ownKeys = function(o) {
|
|
32
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
33
|
+
var ar = [];
|
|
34
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
35
|
+
return ar;
|
|
36
|
+
};
|
|
37
|
+
return ownKeys(o);
|
|
38
|
+
};
|
|
39
|
+
return function (mod) {
|
|
40
|
+
if (mod && mod.__esModule) return mod;
|
|
41
|
+
var result = {};
|
|
42
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
43
|
+
__setModuleDefault(result, mod);
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
})();
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
exports.useVoiceActivity = useVoiceActivity;
|
|
49
|
+
exports.useLatestCaption = useLatestCaption;
|
|
50
|
+
exports.VoiceReactive = VoiceReactive;
|
|
51
|
+
exports.CrowdPlayVoiceView = CrowdPlayVoiceView;
|
|
52
|
+
const react_1 = __importStar(require("react"));
|
|
53
|
+
const react_native_1 = require("react-native");
|
|
54
|
+
/** Live phase + energy from the call engine. Subscribing starts the native
|
|
55
|
+
* fast stream; the last unmount stops it. */
|
|
56
|
+
function useVoiceActivity() {
|
|
57
|
+
const [activity, setActivity] = (0, react_1.useState)({ phase: 'connecting', energy: 0 });
|
|
58
|
+
const energyRef = (0, react_1.useRef)(0);
|
|
59
|
+
(0, react_1.useEffect)(() => {
|
|
60
|
+
const module = react_native_1.NativeModules.CrowdPlayReactNative;
|
|
61
|
+
if (!module)
|
|
62
|
+
return;
|
|
63
|
+
const emitter = new react_native_1.NativeEventEmitter(module);
|
|
64
|
+
const sub = emitter.addListener('crowdplay:voiceActivity', (e) => {
|
|
65
|
+
let phase;
|
|
66
|
+
let target;
|
|
67
|
+
if (!e.connected || !e.agentPresent) {
|
|
68
|
+
phase = 'connecting';
|
|
69
|
+
target = 0;
|
|
70
|
+
}
|
|
71
|
+
else if (e.agentSpeaking) {
|
|
72
|
+
phase = 'speaking';
|
|
73
|
+
target = 0.55 + Math.min(0.45, e.agentLevel * 3.0);
|
|
74
|
+
}
|
|
75
|
+
else if (e.localSpeaking) {
|
|
76
|
+
phase = 'listening';
|
|
77
|
+
const db = e.localLevelDbfs ?? -50;
|
|
78
|
+
target = Math.max(0.2, Math.min(1.0, (db + 50) / 40));
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
phase = 'idle';
|
|
82
|
+
target = 0;
|
|
83
|
+
}
|
|
84
|
+
// Fast attack, slow release — speech onsets snap, tails breathe out.
|
|
85
|
+
const rate = target > energyRef.current ? 0.55 : 0.12;
|
|
86
|
+
energyRef.current += (target - energyRef.current) * rate;
|
|
87
|
+
setActivity({ phase, energy: energyRef.current });
|
|
88
|
+
});
|
|
89
|
+
module.startVoiceActivityUpdates?.();
|
|
90
|
+
return () => {
|
|
91
|
+
sub.remove();
|
|
92
|
+
module.stopVoiceActivityUpdates?.();
|
|
93
|
+
};
|
|
94
|
+
}, []);
|
|
95
|
+
return activity;
|
|
96
|
+
}
|
|
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, }) {
|
|
136
|
+
const { phase, energy } = useVoiceActivity();
|
|
137
|
+
const breathe = (0, react_1.useRef)(new react_native_1.Animated.Value(0)).current;
|
|
138
|
+
const scale = (0, react_1.useRef)(new react_native_1.Animated.Value(0.62)).current;
|
|
139
|
+
const halo = (0, react_1.useRef)(new react_native_1.Animated.Value(0.85)).current;
|
|
140
|
+
// Continuous idle breathing, independent of the event stream.
|
|
141
|
+
(0, react_1.useEffect)(() => {
|
|
142
|
+
const loop = react_native_1.Animated.loop(react_native_1.Animated.sequence([
|
|
143
|
+
react_native_1.Animated.timing(breathe, {
|
|
144
|
+
toValue: 1, duration: 2200, easing: react_native_1.Easing.inOut(react_native_1.Easing.sin), useNativeDriver: true,
|
|
145
|
+
}),
|
|
146
|
+
react_native_1.Animated.timing(breathe, {
|
|
147
|
+
toValue: 0, duration: 2200, easing: react_native_1.Easing.inOut(react_native_1.Easing.sin), useNativeDriver: true,
|
|
148
|
+
}),
|
|
149
|
+
]));
|
|
150
|
+
loop.start();
|
|
151
|
+
return () => loop.stop();
|
|
152
|
+
}, [breathe]);
|
|
153
|
+
// Energy drives the core + halo scale; spring gives the organic wobble
|
|
154
|
+
// that the native version synthesizes with sines.
|
|
155
|
+
(0, react_1.useEffect)(() => {
|
|
156
|
+
react_native_1.Animated.spring(scale, {
|
|
157
|
+
toValue: 0.62 + 0.22 * energy, useNativeDriver: true,
|
|
158
|
+
speed: 20, bounciness: 12,
|
|
159
|
+
}).start();
|
|
160
|
+
react_native_1.Animated.spring(halo, {
|
|
161
|
+
toValue: 0.85 + 0.35 * energy, useNativeDriver: true,
|
|
162
|
+
speed: 14, bounciness: 8,
|
|
163
|
+
}).start();
|
|
164
|
+
}, [energy, scale, halo]);
|
|
165
|
+
const color = phase === 'speaking' ? agentColor
|
|
166
|
+
: phase === 'listening' ? listeningColor
|
|
167
|
+
: idleColor;
|
|
168
|
+
const dim = phase === 'connecting' ? 0.45 : 1;
|
|
169
|
+
const breatheScale = breathe.interpolate({ inputRange: [0, 1], outputRange: [1, 1.05] });
|
|
170
|
+
return (<react_native_1.View style={[styles.container, { width: size, height: size }]} accessibilityLabel={phase === 'speaking' ? 'Assistant is speaking'
|
|
171
|
+
: phase === 'listening' ? 'Listening'
|
|
172
|
+
: phase === 'idle' ? 'Assistant is ready'
|
|
173
|
+
: 'Connecting to assistant'}>
|
|
174
|
+
<react_native_1.Animated.View style={[styles.circle, {
|
|
175
|
+
width: size, height: size, borderRadius: size / 2,
|
|
176
|
+
backgroundColor: color,
|
|
177
|
+
opacity: 0.18 * dim + 0.25 * energy,
|
|
178
|
+
transform: [{ scale: react_native_1.Animated.multiply(halo, breatheScale) }],
|
|
179
|
+
}]}/>
|
|
180
|
+
{(phase === 'listening' || children != null) && (<react_native_1.View style={[styles.circle, {
|
|
181
|
+
width: size * 0.8, height: size * 0.8, borderRadius: size * 0.4,
|
|
182
|
+
borderWidth: Math.max(2, size * (children != null ? 0.02 : 0.015)),
|
|
183
|
+
borderColor: color,
|
|
184
|
+
backgroundColor: 'transparent',
|
|
185
|
+
}]}/>)}
|
|
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',
|
|
191
|
+
transform: [{ scale: react_native_1.Animated.multiply(scale, breatheScale) }],
|
|
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
|
+
})}
|
|
224
|
+
</react_native_1.View>);
|
|
225
|
+
}
|
|
226
|
+
const styles = react_native_1.StyleSheet.create({
|
|
227
|
+
container: { alignItems: 'center', justifyContent: 'center' },
|
|
228
|
+
circle: { position: 'absolute' },
|
|
229
|
+
barsRow: { flexDirection: 'row', alignItems: 'center', columnGap: 8 },
|
|
230
|
+
reactiveGlow: { borderRadius: 9999 },
|
|
231
|
+
});
|
package/lib/index.d.ts
CHANGED
|
@@ -193,3 +193,5 @@ declare const CrowdPlay: {
|
|
|
193
193
|
export default CrowdPlay;
|
|
194
194
|
export { CrowdPlayConsentScreen } from './ConsentScreen';
|
|
195
195
|
export { CrowdPlayVideoView } from './VideoView';
|
|
196
|
+
export { CrowdPlayVoiceView, VoiceReactive, useVoiceActivity, useLatestCaption } from './VoiceView';
|
|
197
|
+
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.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;
|
|
@@ -143,3 +143,8 @@ var ConsentScreen_1 = require("./ConsentScreen");
|
|
|
143
143
|
Object.defineProperty(exports, "CrowdPlayConsentScreen", { enumerable: true, get: function () { return ConsentScreen_1.CrowdPlayConsentScreen; } });
|
|
144
144
|
var VideoView_1 = require("./VideoView");
|
|
145
145
|
Object.defineProperty(exports, "CrowdPlayVideoView", { enumerable: true, get: function () { return VideoView_1.CrowdPlayVideoView; } });
|
|
146
|
+
var VoiceView_1 = require("./VoiceView");
|
|
147
|
+
Object.defineProperty(exports, "CrowdPlayVoiceView", { enumerable: true, get: function () { return VoiceView_1.CrowdPlayVoiceView; } });
|
|
148
|
+
Object.defineProperty(exports, "VoiceReactive", { enumerable: true, get: function () { return VoiceView_1.VoiceReactive; } });
|
|
149
|
+
Object.defineProperty(exports, "useVoiceActivity", { enumerable: true, get: function () { return VoiceView_1.useVoiceActivity; } });
|
|
150
|
+
Object.defineProperty(exports, "useLatestCaption", { enumerable: true, get: function () { return VoiceView_1.useLatestCaption; } });
|
package/llms.txt
CHANGED
|
@@ -147,7 +147,68 @@ Default export `CrowdPlay`:
|
|
|
147
147
|
handler: (payload: CrowdPlayEvents[E]) => void): { remove(): void }
|
|
148
148
|
|
|
149
149
|
Named exports: CrowdPlayConsentScreen (props: onConsent(grant)), CrowdPlayVideoView
|
|
150
|
-
(props: participant: string | 'local', plus standard View style)
|
|
150
|
+
(props: participant: string | 'local', plus standard View style),
|
|
151
|
+
CrowdPlayVoiceView (props: agentColor?, listeningColor?, idleColor?, size?),
|
|
152
|
+
useVoiceActivity() -> { phase: 'connecting'|'idle'|'listening'|'speaking', energy: 0…1 }.
|
|
153
|
+
|
|
154
|
+
The voice AI (apps with the Voice AI switch enabled on the dashboard):
|
|
155
|
+
|
|
156
|
+
How it works — all server-side, nothing to wire up. When any user joins a
|
|
157
|
+
call, CrowdPlay's servers put a live AI participant into the room within
|
|
158
|
+
~3 seconds. It joins with name "agent" (identity "agent-<8hex>"), greets
|
|
159
|
+
the user, and holds a real spoken conversation (speech-to-speech: it
|
|
160
|
+
hears the user directly, answers in natural voice, can be interrupted).
|
|
161
|
+
The app writes ZERO AI code — no model API calls, no AI keys, no chat
|
|
162
|
+
backend, no STT/TTS. Persona (system prompt), voice, and model are set by
|
|
163
|
+
the BUILDER on the CrowdPlay dashboard and apply from the next call.
|
|
164
|
+
Conversations are recorded and transcribed by the platform automatically.
|
|
165
|
+
RECOMMENDED: voice-AI apps should be AUDIO-ONLY (audioOnly: true — no
|
|
166
|
+
camera permission, no video anywhere); a camera adds nothing to talking
|
|
167
|
+
with an AI. Keep video only when the app ALSO has calls between people.
|
|
168
|
+
|
|
169
|
+
The AI conversation screen — the approved standard layout: the animated
|
|
170
|
+
visual front and center, large (it IS the screen); NO self camera
|
|
171
|
+
preview and NO video tiles on this screen, ever; around it only the AI's
|
|
172
|
+
name/persona, a small REC indicator, a leave button, and the small
|
|
173
|
+
audio-output menu. One calm screen, one living visual, nothing competing
|
|
174
|
+
with it.
|
|
175
|
+
|
|
176
|
+
What the app builds — the AI's on-screen presence. The AI publishes
|
|
177
|
+
AUDIO ONLY: never a camera tile, an empty box, or a static image; never
|
|
178
|
+
count it as a generic user. Detect it in the participants event by name
|
|
179
|
+
"agent" (or identity prefix "agent-").
|
|
180
|
+
- The visual is a MENU, zero-effort to fully custom (0.3.4+ for 2-5):
|
|
181
|
+
1. Orb (default): <CrowdPlayVoiceView /> — breathes idle, rings with
|
|
182
|
+
the user's voice, pulses with the AI's. Props: agentColor,
|
|
183
|
+
listeningColor, idleColor, size.
|
|
184
|
+
2. Bars: <CrowdPlayVoiceView variant='bars' /> — equalizer look, good
|
|
185
|
+
under other content (scoreboards, question cards, quizzes).
|
|
186
|
+
3. Halo around the app's own art: pass a mascot portrait / logo /
|
|
187
|
+
character as children of <CrowdPlayVoiceView> — animated ring +
|
|
188
|
+
glow around whatever you provide.
|
|
189
|
+
4. Make ANY view alive: wrap it in <VoiceReactive> — breathing, pulse
|
|
190
|
+
and speech glow on the app's own art in one line.
|
|
191
|
+
5. Fully custom (best for characters): drive ANY visual from the
|
|
192
|
+
useVoiceActivity() hook — { phase: 'connecting'|'idle'|'listening'|
|
|
193
|
+
'speaking', energy: 0…1 smoothed loudness }. Example, an illustrated
|
|
194
|
+
tutor that reacts to the conversation:
|
|
195
|
+
|
|
196
|
+
function TutorAvatar() {
|
|
197
|
+
const { phase, energy } = useVoiceActivity();
|
|
198
|
+
return <Character // the app's own art
|
|
199
|
+
mouthOpen={phase === 'speaking' ? energy : 0}
|
|
200
|
+
pose={phase === 'listening' ? 'leanIn' : 'idleBlink'} />;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
Any art pipeline works (Animated, Lottie, Rive, sprite frames) — bind
|
|
204
|
+
mouth/pose/glow to phase + energy.
|
|
205
|
+
- Live captions (0.3.4+): each line the AI and the user say is streamed
|
|
206
|
+
as it is spoken — the useLatestCaption() hook returns { role, text }.
|
|
207
|
+
For speech bubbles, subtitles, language-learning corrections. Optional
|
|
208
|
+
UI; the full timestamped transcript is always delivered with the
|
|
209
|
+
session regardless.
|
|
210
|
+
- Testing: the AI appears only on a real iPhone (simulators have no
|
|
211
|
+
microphone). Join a room, wait ~3 s, it speaks first.
|
|
151
212
|
|
|
152
213
|
Types:
|
|
153
214
|
CrowdPlayConfig { serverUrl; appKey; audioOnly?=false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crowdplaysdk",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
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",
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The standard voice-AI visual (founders, 2026-08-22): a breathing orb in
|
|
3
|
+
* the style of realtime assistants — calm while idle, a cool ring while the
|
|
4
|
+
* user talks, an energetic pulse while the AI speaks. The React Native
|
|
5
|
+
* counterpart of the native SDK's CrowdPlayVoiceView, driven by the same
|
|
6
|
+
* engine signal over a fast bridge event that runs only while mounted.
|
|
7
|
+
*
|
|
8
|
+
* Two levels of customization:
|
|
9
|
+
* 1. Props on <CrowdPlayVoiceView> — colors and size of the built-in orb.
|
|
10
|
+
* 2. useVoiceActivity() — the phase + smoothed energy underneath, for apps
|
|
11
|
+
* that want to draw something entirely their own.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
15
|
+
import { Animated, Easing, NativeEventEmitter, NativeModules, StyleSheet, View } from 'react-native';
|
|
16
|
+
|
|
17
|
+
export type VoicePhase = 'connecting' | 'idle' | 'listening' | 'speaking';
|
|
18
|
+
|
|
19
|
+
export interface VoiceActivity {
|
|
20
|
+
phase: VoicePhase;
|
|
21
|
+
/** 0…1, smoothed (fast attack, slow release). */
|
|
22
|
+
energy: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface VoiceActivityEvent {
|
|
26
|
+
agentPresent: boolean;
|
|
27
|
+
agentSpeaking: boolean;
|
|
28
|
+
agentLevel: number;
|
|
29
|
+
localLevelDbfs: number | null;
|
|
30
|
+
localSpeaking: boolean;
|
|
31
|
+
connected: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Live phase + energy from the call engine. Subscribing starts the native
|
|
35
|
+
* fast stream; the last unmount stops it. */
|
|
36
|
+
export function useVoiceActivity(): VoiceActivity {
|
|
37
|
+
const [activity, setActivity] = useState<VoiceActivity>({ phase: 'connecting', energy: 0 });
|
|
38
|
+
const energyRef = useRef(0);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const module = NativeModules.CrowdPlayReactNative;
|
|
42
|
+
if (!module) return;
|
|
43
|
+
const emitter = new NativeEventEmitter(module);
|
|
44
|
+
const sub = emitter.addListener('crowdplay:voiceActivity', (e: VoiceActivityEvent) => {
|
|
45
|
+
let phase: VoicePhase;
|
|
46
|
+
let target: number;
|
|
47
|
+
if (!e.connected || !e.agentPresent) {
|
|
48
|
+
phase = 'connecting';
|
|
49
|
+
target = 0;
|
|
50
|
+
} else if (e.agentSpeaking) {
|
|
51
|
+
phase = 'speaking';
|
|
52
|
+
target = 0.55 + Math.min(0.45, e.agentLevel * 3.0);
|
|
53
|
+
} else if (e.localSpeaking) {
|
|
54
|
+
phase = 'listening';
|
|
55
|
+
const db = e.localLevelDbfs ?? -50;
|
|
56
|
+
target = Math.max(0.2, Math.min(1.0, (db + 50) / 40));
|
|
57
|
+
} else {
|
|
58
|
+
phase = 'idle';
|
|
59
|
+
target = 0;
|
|
60
|
+
}
|
|
61
|
+
// Fast attack, slow release — speech onsets snap, tails breathe out.
|
|
62
|
+
const rate = target > energyRef.current ? 0.55 : 0.12;
|
|
63
|
+
energyRef.current += (target - energyRef.current) * rate;
|
|
64
|
+
setActivity({ phase, energy: energyRef.current });
|
|
65
|
+
});
|
|
66
|
+
module.startVoiceActivityUpdates?.();
|
|
67
|
+
return () => {
|
|
68
|
+
sub.remove();
|
|
69
|
+
module.stopVoiceActivityUpdates?.();
|
|
70
|
+
};
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
return activity;
|
|
74
|
+
}
|
|
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
|
+
|
|
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';
|
|
137
|
+
/** Orb color while the AI is speaking. */
|
|
138
|
+
agentColor?: string;
|
|
139
|
+
/** Ring color while the user is speaking. */
|
|
140
|
+
listeningColor?: string;
|
|
141
|
+
/** Resting color (dimmed while connecting). */
|
|
142
|
+
idleColor?: string;
|
|
143
|
+
/** Diameter in points. */
|
|
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;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function CrowdPlayVoiceView({
|
|
151
|
+
variant = 'orb',
|
|
152
|
+
agentColor = '#598CFF',
|
|
153
|
+
listeningColor = '#4DD9B3',
|
|
154
|
+
idleColor = '#BFBFBF',
|
|
155
|
+
size = 180,
|
|
156
|
+
children,
|
|
157
|
+
}: CrowdPlayVoiceViewProps): React.JSX.Element {
|
|
158
|
+
const { phase, energy } = useVoiceActivity();
|
|
159
|
+
const breathe = useRef(new Animated.Value(0)).current;
|
|
160
|
+
const scale = useRef(new Animated.Value(0.62)).current;
|
|
161
|
+
const halo = useRef(new Animated.Value(0.85)).current;
|
|
162
|
+
|
|
163
|
+
// Continuous idle breathing, independent of the event stream.
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
const loop = Animated.loop(
|
|
166
|
+
Animated.sequence([
|
|
167
|
+
Animated.timing(breathe, {
|
|
168
|
+
toValue: 1, duration: 2200, easing: Easing.inOut(Easing.sin), useNativeDriver: true,
|
|
169
|
+
}),
|
|
170
|
+
Animated.timing(breathe, {
|
|
171
|
+
toValue: 0, duration: 2200, easing: Easing.inOut(Easing.sin), useNativeDriver: true,
|
|
172
|
+
}),
|
|
173
|
+
]),
|
|
174
|
+
);
|
|
175
|
+
loop.start();
|
|
176
|
+
return () => loop.stop();
|
|
177
|
+
}, [breathe]);
|
|
178
|
+
|
|
179
|
+
// Energy drives the core + halo scale; spring gives the organic wobble
|
|
180
|
+
// that the native version synthesizes with sines.
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
Animated.spring(scale, {
|
|
183
|
+
toValue: 0.62 + 0.22 * energy, useNativeDriver: true,
|
|
184
|
+
speed: 20, bounciness: 12,
|
|
185
|
+
}).start();
|
|
186
|
+
Animated.spring(halo, {
|
|
187
|
+
toValue: 0.85 + 0.35 * energy, useNativeDriver: true,
|
|
188
|
+
speed: 14, bounciness: 8,
|
|
189
|
+
}).start();
|
|
190
|
+
}, [energy, scale, halo]);
|
|
191
|
+
|
|
192
|
+
const color = phase === 'speaking' ? agentColor
|
|
193
|
+
: phase === 'listening' ? listeningColor
|
|
194
|
+
: idleColor;
|
|
195
|
+
const dim = phase === 'connecting' ? 0.45 : 1;
|
|
196
|
+
const breatheScale = breathe.interpolate({ inputRange: [0, 1], outputRange: [1, 1.05] });
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<View
|
|
200
|
+
style={[styles.container, { width: size, height: size }]}
|
|
201
|
+
accessibilityLabel={
|
|
202
|
+
phase === 'speaking' ? 'Assistant is speaking'
|
|
203
|
+
: phase === 'listening' ? 'Listening'
|
|
204
|
+
: phase === 'idle' ? 'Assistant is ready'
|
|
205
|
+
: 'Connecting to assistant'
|
|
206
|
+
}
|
|
207
|
+
>
|
|
208
|
+
<Animated.View
|
|
209
|
+
style={[styles.circle, {
|
|
210
|
+
width: size, height: size, borderRadius: size / 2,
|
|
211
|
+
backgroundColor: color,
|
|
212
|
+
opacity: 0.18 * dim + 0.25 * energy,
|
|
213
|
+
transform: [{ scale: Animated.multiply(halo, breatheScale) }],
|
|
214
|
+
}]}
|
|
215
|
+
/>
|
|
216
|
+
{(phase === 'listening' || children != null) && (
|
|
217
|
+
<View
|
|
218
|
+
style={[styles.circle, {
|
|
219
|
+
width: size * 0.8, height: size * 0.8, borderRadius: size * 0.4,
|
|
220
|
+
borderWidth: Math.max(2, size * (children != null ? 0.02 : 0.015)),
|
|
221
|
+
borderColor: color,
|
|
222
|
+
backgroundColor: 'transparent',
|
|
223
|
+
}]}
|
|
224
|
+
/>
|
|
225
|
+
)}
|
|
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
|
+
})}
|
|
283
|
+
</View>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const styles = StyleSheet.create({
|
|
288
|
+
container: { alignItems: 'center', justifyContent: 'center' },
|
|
289
|
+
circle: { position: 'absolute' },
|
|
290
|
+
barsRow: { flexDirection: 'row', alignItems: 'center', columnGap: 8 },
|
|
291
|
+
reactiveGlow: { borderRadius: 9999 },
|
|
292
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -297,3 +297,5 @@ const CrowdPlay = {
|
|
|
297
297
|
export default CrowdPlay;
|
|
298
298
|
export { CrowdPlayConsentScreen } from './ConsentScreen';
|
|
299
299
|
export { CrowdPlayVideoView } from './VideoView';
|
|
300
|
+
export { CrowdPlayVoiceView, VoiceReactive, useVoiceActivity, useLatestCaption } from './VoiceView';
|
|
301
|
+
export type { CrowdPlayVoiceViewProps, VoiceActivity, VoicePhase, VoiceCaption } from './VoiceView';
|