crowdplaysdk 0.6.3 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -4
- package/ios/CrowdPlayRNModule.swift +201 -8
- package/ios/CrowdPlayReactNative.m +6 -0
- package/ios/wire.rb +2 -1
- package/lib/VoiceView.d.ts +9 -3
- package/lib/VoiceView.js +58 -6
- package/lib/agentAudio.d.ts +96 -0
- package/lib/agentAudio.js +189 -0
- package/lib/index.d.ts +40 -4
- package/lib/index.js +79 -5
- package/llms.txt +71 -3
- package/package.json +2 -1
- package/src/VoiceView.tsx +67 -6
- package/src/agentAudio.ts +259 -0
- package/src/index.ts +94 -5
package/README.md
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
CrowdPlaySDK adds live calls and high-quality conversation capture to React
|
|
4
4
|
Native iOS apps. It is the same compiled engine as the native CrowdPlaySDK
|
|
5
5
|
Swift package, driven from JavaScript with control calls and state events.
|
|
6
|
-
No media ever crosses the JS bridge, so recording quality is
|
|
7
|
-
fully native app.
|
|
6
|
+
No recording media ever crosses the JS bridge, so recording quality is
|
|
7
|
+
identical to a fully native app. The one audio stream that can cross it is
|
|
8
|
+
opt-in and is not the recording: the agent audio tap (below) hands the
|
|
9
|
+
voice AI's call audio to JavaScript for lip sync, only while you ask for it.
|
|
8
10
|
|
|
9
11
|
## What the SDK handles
|
|
10
12
|
|
|
@@ -180,6 +182,7 @@ CrowdPlay.addListener('warning', …) // micSilent / clipping / crossTalk:
|
|
|
180
182
|
CrowdPlay.addListener('storageWarning', …) // optional: message set = storage low (audio only or stopped)
|
|
181
183
|
CrowdPlay.addListener('recording', …) // error means NOT capturing; offer retryRecording()
|
|
182
184
|
CrowdPlay.addListener('audioRoute', …) // current output device, for the output menu
|
|
185
|
+
CrowdPlay.addListener('agentAudio', …) // opt-in, voice AI: the AI's voice as PCM (see Voice AI)
|
|
183
186
|
CrowdPlay.setAudioOutput('speaker') // or 'automatic' (the connected device)
|
|
184
187
|
CrowdPlay.snapshot() // poll at 1 Hz for the REC timer and input level
|
|
185
188
|
```
|
|
@@ -210,8 +213,65 @@ the AI speaks:
|
|
|
210
213
|
```
|
|
211
214
|
|
|
212
215
|
Restyle it with props, wrap your own artwork in `<VoiceReactive>`, or drive
|
|
213
|
-
any visual from the `useVoiceActivity()` hook
|
|
214
|
-
|
|
216
|
+
any visual from the `useVoiceActivity()` hook (`useVoiceActivity({ intervalMs: 50 })`
|
|
217
|
+
updates faster than the default 150 ms; the floor is 33 ms). Show live
|
|
218
|
+
subtitles with `useLatestCaption()`, and identify the AI participant by name
|
|
219
|
+
`agent`.
|
|
220
|
+
|
|
221
|
+
### Lip sync: the agent audio tap
|
|
222
|
+
|
|
223
|
+
For mouth shapes, visemes or your own meters, the SDK can hand you the AI's
|
|
224
|
+
voice as it plays, in fixed frames of 16-bit mono PCM (crowdplaysdk 0.7.0+,
|
|
225
|
+
engine 0.6.0):
|
|
226
|
+
|
|
227
|
+
```tsx
|
|
228
|
+
import { useAgentAudio, decodeAgentAudio } from 'crowdplaysdk';
|
|
229
|
+
|
|
230
|
+
function Avatar() {
|
|
231
|
+
useAgentAudio((frame) => {
|
|
232
|
+
const samples = decodeAgentAudio(frame.pcm); // Int16Array, frame.frameSize samples
|
|
233
|
+
// your classifier; show the shape when the frame is heard, not on arrival
|
|
234
|
+
visemes.feed(samples, { reset: frame.discontinuity, showAt: frame.heardAtMs });
|
|
235
|
+
}, { frameSize: 1024, sampleRate: 16000 }); // the defaults: 64 ms frames
|
|
236
|
+
return <Character mouth={visemes.current} />;
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
The recipe: start it after `join()` resolves or when the call screen
|
|
241
|
+
mounts (the hook does it on mount), feed `decodeAgentAudio(e.pcm)` to your
|
|
242
|
+
viseme classifier, and stop it on leave (`leave()` stops it; unmounting the
|
|
243
|
+
hook's screen stops it too). Without the hook, call
|
|
244
|
+
`CrowdPlay.startAgentAudio(options)` and `CrowdPlay.stopAgentAudio()` and
|
|
245
|
+
listen with `CrowdPlay.addListener('agentAudio', …)`.
|
|
246
|
+
|
|
247
|
+
- Every frame is exactly `frameSize` samples at `sampleRate` (integers;
|
|
248
|
+
`sampleRate` 8000 to 48000, a frame 10 ms to 1 s; anything else
|
|
249
|
+
rejects). `seq` counts frames from 0 for each start; `discontinuity` is
|
|
250
|
+
true on the first frame and after any gap (a reconnect, dropped audio):
|
|
251
|
+
reset your smoothing there.
|
|
252
|
+
- It attaches when the AI's audio arrives and follows it across
|
|
253
|
+
reconnects; before that nothing is delivered. Starting again restarts it
|
|
254
|
+
with the new options; `leave()` turns it off, so start it again after the
|
|
255
|
+
next join. A failed `join()` leaves it on, so a Retry on the same screen
|
|
256
|
+
keeps getting frames. Mount `useAgentAudio` in one place at a time: the
|
|
257
|
+
tap is one stream for the whole app.
|
|
258
|
+
- It listens only to the AI participant (identity `agent-` plus 8
|
|
259
|
+
lowercase hex, name `agent`), never to a person whose name merely looks
|
|
260
|
+
like it. That identity is not reserved, so someone who joins under that
|
|
261
|
+
exact identity is taken for the AI (a person named exactly `agent` always
|
|
262
|
+
is from the web SDK, and now and then from a phone).
|
|
263
|
+
- Timing: frames are taken as the audio is pulled for playout, and each
|
|
264
|
+
one says when the user hears it. `heardAtMs` (Unix ms on the phone's
|
|
265
|
+
clock, compare with `Date.now()`) is the pull plus `outputLatencyMs`, the
|
|
266
|
+
route's delay: a few ms on the speaker or wired headphones, 100 to 300 ms
|
|
267
|
+
on Bluetooth. Show each frame's mouth shape at `heardAtMs`, not when the
|
|
268
|
+
frame arrives. On AirPods a frame arrives well before it is heard; on the
|
|
269
|
+
speaker it arrives just after it starts playing, because a frame is sent
|
|
270
|
+
once its last sample is pulled (smaller frames shorten that). On
|
|
271
|
+
Bluetooth the delay is the one iOS reports for the headphones, so the
|
|
272
|
+
timing there is only as exact as that figure.
|
|
273
|
+
- It reads the call audio only. It never touches the recording, and nothing
|
|
274
|
+
crosses the bridge unless your app starts it.
|
|
215
275
|
|
|
216
276
|
You can also override the voice, personality and context for a single call,
|
|
217
277
|
which is how you give the AI memory of a particular user:
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import AVFoundation
|
|
1
2
|
import Combine
|
|
2
3
|
import Foundation
|
|
3
4
|
import CrowdPlaySDK
|
|
@@ -26,6 +27,11 @@ final class CrowdPlayRNCore {
|
|
|
26
27
|
|
|
27
28
|
func bind(_ module: CrowdPlayRNModule) {
|
|
28
29
|
emitter = module
|
|
30
|
+
// A JS reload makes a new module, but this core and its engine live
|
|
31
|
+
// on: a tap the previous JS started must not feed the new one.
|
|
32
|
+
if agentAudioTap != nil, agentAudioOwner !== module {
|
|
33
|
+
stopAgentAudio()
|
|
34
|
+
}
|
|
29
35
|
guard cancellables.isEmpty else { return } // bind once; emitter may rebind
|
|
30
36
|
|
|
31
37
|
engine.$phase.sink { [weak self] phase in
|
|
@@ -155,13 +161,17 @@ final class CrowdPlayRNCore {
|
|
|
155
161
|
// MARK: - Voice-agent activity (M8 voice UI)
|
|
156
162
|
|
|
157
163
|
private var voiceTimer: Timer?
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
///
|
|
161
|
-
///
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
164
|
+
private var voiceInterval: TimeInterval = 0
|
|
165
|
+
|
|
166
|
+
/// Fast (0.15 s by default) voice-activity stream for the animated voice
|
|
167
|
+
/// UI; it runs only while some JS component is subscribed, so the bridge
|
|
168
|
+
/// stays quiet for apps that never render it. A running stream is left
|
|
169
|
+
/// alone at the same interval and re-created at a new one.
|
|
170
|
+
func startVoiceActivity(interval: TimeInterval = 0.15) {
|
|
171
|
+
if voiceTimer != nil, voiceInterval == interval { return }
|
|
172
|
+
voiceTimer?.invalidate()
|
|
173
|
+
voiceInterval = interval
|
|
174
|
+
voiceTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
|
|
165
175
|
Task { @MainActor in CrowdPlayRNCore.shared.voiceTick() }
|
|
166
176
|
}
|
|
167
177
|
}
|
|
@@ -184,6 +194,62 @@ final class CrowdPlayRNCore {
|
|
|
184
194
|
])
|
|
185
195
|
}
|
|
186
196
|
|
|
197
|
+
// MARK: - Agent audio tap (engine 0.6.0)
|
|
198
|
+
|
|
199
|
+
/// The running tap's id (sent with every frame so JS can drop frames of
|
|
200
|
+
/// a stopped tap) and the module whose JS started it. Ids only grow, and
|
|
201
|
+
/// this core outlives JS reloads, so an id is never reused.
|
|
202
|
+
private var agentAudioTap: Int?
|
|
203
|
+
private weak var agentAudioOwner: CrowdPlayRNModule?
|
|
204
|
+
private var agentAudioTapCount = 0
|
|
205
|
+
private var agentAudioOptions: (frameSize: Int, sampleRate: Int)?
|
|
206
|
+
|
|
207
|
+
func startAgentAudio(frameSize: Int, sampleRate: Int, owner: CrowdPlayRNModule) throws -> Int {
|
|
208
|
+
let tap = agentAudioTapCount + 1
|
|
209
|
+
try startEngineTap(frameSize: frameSize, sampleRate: sampleRate, tap: tap)
|
|
210
|
+
agentAudioTapCount = tap
|
|
211
|
+
agentAudioTap = tap
|
|
212
|
+
agentAudioOptions = (frameSize, sampleRate)
|
|
213
|
+
agentAudioOwner = owner
|
|
214
|
+
return tap
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private func startEngineTap(frameSize: Int, sampleRate: Int, tap: Int) throws {
|
|
218
|
+
// Frames are sent from the engine's drain queue, never the main
|
|
219
|
+
// actor: one hop per frame would add jitter to lip sync.
|
|
220
|
+
try engine.startAgentAudioTap(frameSize: frameSize, sampleRate: sampleRate) { frame in
|
|
221
|
+
CrowdPlayRNAgentAudioEmitter.shared.send(frame, tap: tap)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
func stopAgentAudio() {
|
|
226
|
+
guard agentAudioTap != nil else { return }
|
|
227
|
+
agentAudioTap = nil
|
|
228
|
+
agentAudioOwner = nil
|
|
229
|
+
agentAudioOptions = nil
|
|
230
|
+
engine.stopAgentAudioTap()
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/// The engine stops the tap when a join fails, but JS still has it on:
|
|
234
|
+
/// a call screen with useAgentAudio and a Retry button stays mounted.
|
|
235
|
+
/// Start it again under the same id, so the retry's frames reach the
|
|
236
|
+
/// same listener.
|
|
237
|
+
func agentAudioAfterFailedJoin() {
|
|
238
|
+
guard let tap = agentAudioTap, let options = agentAudioOptions,
|
|
239
|
+
!engine.isAgentAudioTapActive else { return }
|
|
240
|
+
try? startEngineTap(frameSize: options.frameSize, sampleRate: options.sampleRate, tap: tap)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/// leave() turns the tap off for JS too. Forgotten BEFORE the engine's
|
|
244
|
+
/// leave() runs (which stops the engine's tap first thing), so a join
|
|
245
|
+
/// that fails while leave() is still disconnecting cannot bring it
|
|
246
|
+
/// back; a start JS makes during leave() stays on.
|
|
247
|
+
func agentAudioBeforeLeave() {
|
|
248
|
+
agentAudioTap = nil
|
|
249
|
+
agentAudioOwner = nil
|
|
250
|
+
agentAudioOptions = nil
|
|
251
|
+
}
|
|
252
|
+
|
|
187
253
|
private func updateWarning(_ kind: String, active: Bool, message: String) {
|
|
188
254
|
let was = activeWarnings.contains(kind)
|
|
189
255
|
guard was != active else { return }
|
|
@@ -219,6 +285,87 @@ final class CrowdPlayRNCore {
|
|
|
219
285
|
}
|
|
220
286
|
}
|
|
221
287
|
|
|
288
|
+
/// The drain queue's view of the JS emitter, for agent audio frames. The
|
|
289
|
+
/// engine (and so the tap's handler) outlives the module across JS reloads,
|
|
290
|
+
/// so the module is held weakly; it is set while the module has listeners
|
|
291
|
+
/// (startObserving/stopObserving), so nothing is sent with none.
|
|
292
|
+
final class CrowdPlayRNAgentAudioEmitter: @unchecked Sendable {
|
|
293
|
+
static let shared = CrowdPlayRNAgentAudioEmitter()
|
|
294
|
+
|
|
295
|
+
private let lock = NSLock()
|
|
296
|
+
private weak var module: CrowdPlayRNModule?
|
|
297
|
+
|
|
298
|
+
func attach(_ module: CrowdPlayRNModule) {
|
|
299
|
+
lock.lock()
|
|
300
|
+
self.module = module
|
|
301
|
+
lock.unlock()
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/// Only the module that is attached detaches: an old module's late
|
|
305
|
+
/// stopObserving (JS reload) must not silence the new one.
|
|
306
|
+
func detach(_ module: CrowdPlayRNModule) {
|
|
307
|
+
lock.lock()
|
|
308
|
+
if self.module === module { self.module = nil }
|
|
309
|
+
lock.unlock()
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
func send(_ frame: CallEngine.AgentAudioFrame, tap: Int) {
|
|
313
|
+
lock.lock()
|
|
314
|
+
let target = module
|
|
315
|
+
lock.unlock()
|
|
316
|
+
guard let target else { return }
|
|
317
|
+
let hostNow = mach_absolute_time()
|
|
318
|
+
let wallNowMs = Date().timeIntervalSince1970 * 1000
|
|
319
|
+
let latencyMs = outputLatencyMs(at: hostNow)
|
|
320
|
+
target.sendEvent(withName: "crowdplay:agentAudio", body: [
|
|
321
|
+
"pcm": frame.pcm.base64EncodedString(),
|
|
322
|
+
"sampleRate": frame.sampleRate,
|
|
323
|
+
"frameSize": frame.frameSize,
|
|
324
|
+
"seq": NSNumber(value: frame.seq),
|
|
325
|
+
"discontinuity": frame.discontinuity,
|
|
326
|
+
"tap": tap,
|
|
327
|
+
"heardAtMs": Self.heardAtMs(hostTime: frame.hostTime, hostNow: hostNow,
|
|
328
|
+
wallNowMs: wallNowMs, latencyMs: latencyMs),
|
|
329
|
+
"outputLatencyMs": latencyMs,
|
|
330
|
+
] as [String: Any])
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// MARK: When a frame is heard (lip sync)
|
|
334
|
+
|
|
335
|
+
/// Mach ticks per millisecond (24,000 on Apple silicon).
|
|
336
|
+
static let ticksPerMs: Double = {
|
|
337
|
+
var timebase = mach_timebase_info_data_t()
|
|
338
|
+
mach_timebase_info(&timebase)
|
|
339
|
+
return 1e6 * Double(timebase.denom) / Double(timebase.numer)
|
|
340
|
+
}()
|
|
341
|
+
|
|
342
|
+
/// The frame's first sample on the wall clock JS reads (Date.now()):
|
|
343
|
+
/// its playout pull (`hostTime`) plus the output latency. Mach time and
|
|
344
|
+
/// the wall clock are read together at send, so the conversion carries
|
|
345
|
+
/// no clock offset.
|
|
346
|
+
static func heardAtMs(hostTime: UInt64, hostNow: UInt64, wallNowMs: Double, latencyMs: Double) -> Double {
|
|
347
|
+
wallNowMs + (Double(hostTime) - Double(hostNow)) / ticksPerMs + latencyMs
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/// Read only from send(), which the engine calls on its one drain
|
|
351
|
+
/// queue, so it needs no lock.
|
|
352
|
+
private var latencyMs = 0.0
|
|
353
|
+
private var latencyReadAt: UInt64 = 0
|
|
354
|
+
|
|
355
|
+
/// From a playout pull to the listener: one IO buffer plus the route's
|
|
356
|
+
/// reported output latency (a few ms on the speaker, 100 to 300 ms on
|
|
357
|
+
/// Bluetooth). Read from the audio session at most every 0.5 s, so a
|
|
358
|
+
/// route change shows within half a second.
|
|
359
|
+
private func outputLatencyMs(at hostNow: UInt64) -> Double {
|
|
360
|
+
if latencyReadAt == 0 || Double(hostNow &- latencyReadAt) > 500 * Self.ticksPerMs {
|
|
361
|
+
let session = AVAudioSession.sharedInstance()
|
|
362
|
+
latencyMs = (session.outputLatency + session.ioBufferDuration) * 1000
|
|
363
|
+
latencyReadAt = hostNow
|
|
364
|
+
}
|
|
365
|
+
return latencyMs
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
222
369
|
/// ObjC-callable hook for background upload completion. From an ObjC
|
|
223
370
|
/// AppDelegate (via the app's generated -Swift.h header):
|
|
224
371
|
///
|
|
@@ -240,22 +387,66 @@ public final class CrowdPlayRNModule: RCTEventEmitter {
|
|
|
240
387
|
public override static func requiresMainQueueSetup() -> Bool { true }
|
|
241
388
|
|
|
242
389
|
public override func supportedEvents() -> [String]! {
|
|
243
|
-
["crowdplay:phase", "crowdplay:recording", "crowdplay:uploads", "crowdplay:participants", "crowdplay:warning", "crowdplay:storageWarning", "crowdplay:audioRoute", "crowdplay:voiceActivity", "crowdplay:caption", "crowdplay:agentToolCall"]
|
|
390
|
+
["crowdplay:phase", "crowdplay:recording", "crowdplay:uploads", "crowdplay:participants", "crowdplay:warning", "crowdplay:storageWarning", "crowdplay:audioRoute", "crowdplay:voiceActivity", "crowdplay:caption", "crowdplay:agentToolCall", "crowdplay:agentAudio"]
|
|
244
391
|
}
|
|
245
392
|
|
|
246
393
|
@objc public func startVoiceActivityUpdates() {
|
|
247
394
|
Task { @MainActor in CrowdPlayRNCore.shared.startVoiceActivity() }
|
|
248
395
|
}
|
|
249
396
|
|
|
397
|
+
/// The same stream at an app-chosen interval (useVoiceActivity's
|
|
398
|
+
/// intervalMs), floored at 33 ms. The zero-argument method above stays
|
|
399
|
+
/// for bridges and JS that predate it.
|
|
400
|
+
@objc public func startVoiceActivityUpdatesWithInterval(_ intervalMs: NSNumber) {
|
|
401
|
+
let ms = intervalMs.doubleValue
|
|
402
|
+
let seconds = (ms.isFinite ? max(ms, 33) : 150) / 1000
|
|
403
|
+
Task { @MainActor in CrowdPlayRNCore.shared.startVoiceActivity(interval: seconds) }
|
|
404
|
+
}
|
|
405
|
+
|
|
250
406
|
@objc public func stopVoiceActivityUpdates() {
|
|
251
407
|
Task { @MainActor in CrowdPlayRNCore.shared.stopVoiceActivity() }
|
|
252
408
|
}
|
|
253
409
|
|
|
410
|
+
/// Starts the agent audio tap; resolves `{ tap }`, the id every frame of
|
|
411
|
+
/// this start carries. The JS layer validated the options already; the
|
|
412
|
+
/// engine validates again and a refusal leaves a running tap as it was.
|
|
413
|
+
@objc public func startAgentAudio(_ options: NSDictionary,
|
|
414
|
+
resolver: @escaping RCTPromiseResolveBlock,
|
|
415
|
+
rejecter: @escaping RCTPromiseRejectBlock) {
|
|
416
|
+
let frameSize = options["frameSize"] == nil ? 1024 : options["frameSize"] as? Int
|
|
417
|
+
let sampleRate = options["sampleRate"] == nil ? 16000 : options["sampleRate"] as? Int
|
|
418
|
+
guard let frameSize, let sampleRate else {
|
|
419
|
+
rejecter("agent_audio_invalid", "agent audio tap: frameSize and sampleRate must be integers", nil)
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
Task { @MainActor in
|
|
423
|
+
do {
|
|
424
|
+
let tap = try CrowdPlayRNCore.shared.startAgentAudio(
|
|
425
|
+
frameSize: frameSize, sampleRate: sampleRate, owner: self)
|
|
426
|
+
resolver(["tap": tap])
|
|
427
|
+
} catch {
|
|
428
|
+
rejecter("agent_audio_invalid", error.localizedDescription, error)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
@objc public func stopAgentAudio(_ resolve: @escaping RCTPromiseResolveBlock,
|
|
434
|
+
rejecter _: @escaping RCTPromiseRejectBlock) {
|
|
435
|
+
Task { @MainActor in
|
|
436
|
+
CrowdPlayRNCore.shared.stopAgentAudio()
|
|
437
|
+
resolve(nil)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
254
441
|
public override func startObserving() {
|
|
442
|
+
CrowdPlayRNAgentAudioEmitter.shared.attach(self)
|
|
255
443
|
Task { @MainActor in CrowdPlayRNCore.shared.bind(self) }
|
|
256
444
|
}
|
|
257
445
|
|
|
446
|
+
/// Never stops the agent audio tap: the listener count can touch 0 for
|
|
447
|
+
/// a moment during a remount. The tap's frames just stop being sent.
|
|
258
448
|
public override func stopObserving() {
|
|
449
|
+
CrowdPlayRNAgentAudioEmitter.shared.detach(self)
|
|
259
450
|
Task { @MainActor in CrowdPlayRNCore.shared.unbind() }
|
|
260
451
|
}
|
|
261
452
|
|
|
@@ -355,6 +546,7 @@ public final class CrowdPlayRNModule: RCTEventEmitter {
|
|
|
355
546
|
consent: consent,
|
|
356
547
|
agent: agentOptions)
|
|
357
548
|
if case let .failed(message) = core.engine.phase {
|
|
549
|
+
core.agentAudioAfterFailedJoin()
|
|
358
550
|
rejecter("join_failed", message, nil)
|
|
359
551
|
} else {
|
|
360
552
|
resolver(nil)
|
|
@@ -365,6 +557,7 @@ public final class CrowdPlayRNModule: RCTEventEmitter {
|
|
|
365
557
|
@objc public func leave(_ resolve: @escaping RCTPromiseResolveBlock,
|
|
366
558
|
rejecter _: @escaping RCTPromiseRejectBlock) {
|
|
367
559
|
Task { @MainActor in
|
|
560
|
+
CrowdPlayRNCore.shared.agentAudioBeforeLeave()
|
|
368
561
|
await CrowdPlayRNCore.shared.engine.leave()
|
|
369
562
|
resolve(nil)
|
|
370
563
|
}
|
|
@@ -32,7 +32,13 @@ RCT_EXTERN_METHOD(retryRecording)
|
|
|
32
32
|
RCT_EXTERN_METHOD(retryUploads)
|
|
33
33
|
RCT_EXTERN_METHOD(warmUp)
|
|
34
34
|
RCT_EXTERN_METHOD(startVoiceActivityUpdates)
|
|
35
|
+
RCT_EXTERN_METHOD(startVoiceActivityUpdatesWithInterval : (nonnull NSNumber *)intervalMs)
|
|
35
36
|
RCT_EXTERN_METHOD(stopVoiceActivityUpdates)
|
|
37
|
+
RCT_EXTERN_METHOD(startAgentAudio : (NSDictionary *)options
|
|
38
|
+
resolver : (RCTPromiseResolveBlock)resolve
|
|
39
|
+
rejecter : (RCTPromiseRejectBlock)reject)
|
|
40
|
+
RCT_EXTERN_METHOD(stopAgentAudio : (RCTPromiseResolveBlock)resolve
|
|
41
|
+
rejecter : (RCTPromiseRejectBlock)reject)
|
|
36
42
|
RCT_EXTERN_METHOD(snapshot : (RCTPromiseResolveBlock)resolve
|
|
37
43
|
rejecter : (RCTPromiseRejectBlock)reject)
|
|
38
44
|
RCT_EXTERN_METHOD(doctor : (RCTPromiseResolveBlock)resolve
|
package/ios/wire.rb
CHANGED
|
@@ -32,7 +32,8 @@ SDK_URL = 'https://github.com/symbiateam/crowdplaysdk'
|
|
|
32
32
|
# this still at 0.4.0, which broke every install (D-121). publish_rn.sh now
|
|
33
33
|
# compiles the bridge against exactly this release before publishing.
|
|
34
34
|
# 0.5.4: the storage guard and engine.storageWarning (D-148).
|
|
35
|
-
|
|
35
|
+
# 0.6.0: the agent audio tap (startAgentAudioTap), which the bridge calls.
|
|
36
|
+
SDK_VERSION = '0.6.0'
|
|
36
37
|
BRIDGE_FILES = ['CrowdPlayRNModule.swift', 'CrowdPlayRNVideoView.swift', 'CrowdPlayReactNative.m'].freeze
|
|
37
38
|
|
|
38
39
|
project_name = ARGV[0] or abort 'usage: ruby wire.rb <YourProjectName>'
|
package/lib/VoiceView.d.ts
CHANGED
|
@@ -27,9 +27,15 @@ export interface VoiceActivityEvent {
|
|
|
27
27
|
localSpeaking: boolean;
|
|
28
28
|
connected: boolean;
|
|
29
29
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
export interface VoiceActivityOptions {
|
|
31
|
+
/** How often the native stream sends 'voiceActivity', in ms (floor 33).
|
|
32
|
+
* Default 150. With several hooks mounted, the stream runs at the
|
|
33
|
+
* smallest interval any of them asks for. */
|
|
34
|
+
intervalMs?: number;
|
|
35
|
+
}
|
|
36
|
+
/** Live phase + energy from the call engine. The first mounted hook starts
|
|
37
|
+
* the native fast stream; the last unmount stops it. */
|
|
38
|
+
export declare function useVoiceActivity(options?: VoiceActivityOptions): VoiceActivity;
|
|
33
39
|
export interface VoiceCaption {
|
|
34
40
|
role: 'agent' | 'user';
|
|
35
41
|
text: string;
|
package/lib/VoiceView.js
CHANGED
|
@@ -51,11 +51,51 @@ exports.VoiceReactive = VoiceReactive;
|
|
|
51
51
|
exports.CrowdPlayVoiceView = CrowdPlayVoiceView;
|
|
52
52
|
const react_1 = __importStar(require("react"));
|
|
53
53
|
const react_native_1 = require("react-native");
|
|
54
|
-
/**
|
|
55
|
-
*
|
|
56
|
-
|
|
54
|
+
/** Every mounted useVoiceActivity() shares ONE native stream: each hook
|
|
55
|
+
* registers its requested interval (undefined = the default) here, the
|
|
56
|
+
* stream runs at the smallest, and only the last unmount stops it. */
|
|
57
|
+
const voiceRequests = new Map();
|
|
58
|
+
/** What the native stream was last told: 'stopped', 'default' (the
|
|
59
|
+
* zero-argument start, 150 ms, exactly as before intervals existed), or
|
|
60
|
+
* an interval in ms. */
|
|
61
|
+
let voiceStream = 'stopped';
|
|
62
|
+
function voiceIntervalOf(intervalMs) {
|
|
63
|
+
if (typeof intervalMs !== 'number' || !Number.isFinite(intervalMs) || intervalMs <= 0)
|
|
64
|
+
return undefined;
|
|
65
|
+
return Math.max(33, Math.round(intervalMs));
|
|
66
|
+
}
|
|
67
|
+
function applyVoiceRequests(module) {
|
|
68
|
+
if (voiceRequests.size === 0) {
|
|
69
|
+
if (voiceStream !== 'stopped')
|
|
70
|
+
module.stopVoiceActivityUpdates?.();
|
|
71
|
+
voiceStream = 'stopped';
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
let smallest;
|
|
75
|
+
for (const interval of voiceRequests.values()) {
|
|
76
|
+
if (interval !== undefined && (smallest === undefined || interval < smallest))
|
|
77
|
+
smallest = interval;
|
|
78
|
+
}
|
|
79
|
+
const want = smallest ?? 'default';
|
|
80
|
+
if (want === voiceStream)
|
|
81
|
+
return;
|
|
82
|
+
if (want !== 'default' && typeof module.startVoiceActivityUpdatesWithInterval === 'function') {
|
|
83
|
+
module.startVoiceActivityUpdatesWithInterval(want);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
// The default, or a bridge wired by an older package (fixed 150 ms).
|
|
87
|
+
module.startVoiceActivityUpdates?.();
|
|
88
|
+
}
|
|
89
|
+
voiceStream = want;
|
|
90
|
+
}
|
|
91
|
+
/** Live phase + energy from the call engine. The first mounted hook starts
|
|
92
|
+
* the native fast stream; the last unmount stops it. */
|
|
93
|
+
function useVoiceActivity(options) {
|
|
57
94
|
const [activity, setActivity] = (0, react_1.useState)({ phase: 'connecting', energy: 0 });
|
|
58
95
|
const energyRef = (0, react_1.useRef)(0);
|
|
96
|
+
const token = (0, react_1.useRef)({}).current;
|
|
97
|
+
const intervalMs = voiceIntervalOf(options?.intervalMs);
|
|
98
|
+
const intervalRef = (0, react_1.useRef)(intervalMs);
|
|
59
99
|
(0, react_1.useEffect)(() => {
|
|
60
100
|
const module = react_native_1.NativeModules.CrowdPlayReactNative;
|
|
61
101
|
if (!module)
|
|
@@ -86,12 +126,24 @@ function useVoiceActivity() {
|
|
|
86
126
|
energyRef.current += (target - energyRef.current) * rate;
|
|
87
127
|
setActivity({ phase, energy: energyRef.current });
|
|
88
128
|
});
|
|
89
|
-
|
|
129
|
+
voiceRequests.set(token, intervalRef.current);
|
|
130
|
+
applyVoiceRequests(module);
|
|
90
131
|
return () => {
|
|
91
132
|
sub.remove();
|
|
92
|
-
|
|
133
|
+
voiceRequests.delete(token);
|
|
134
|
+
applyVoiceRequests(module);
|
|
93
135
|
};
|
|
94
|
-
}, []);
|
|
136
|
+
}, [token]);
|
|
137
|
+
// A changed interval updates this hook's request in place, so the shared
|
|
138
|
+
// stream never stops and restarts for it.
|
|
139
|
+
(0, react_1.useEffect)(() => {
|
|
140
|
+
intervalRef.current = intervalMs;
|
|
141
|
+
const module = react_native_1.NativeModules.CrowdPlayReactNative;
|
|
142
|
+
if (!module || !voiceRequests.has(token))
|
|
143
|
+
return;
|
|
144
|
+
voiceRequests.set(token, intervalMs);
|
|
145
|
+
applyVoiceRequests(module);
|
|
146
|
+
}, [token, intervalMs]);
|
|
95
147
|
return activity;
|
|
96
148
|
}
|
|
97
149
|
/** The latest live transcript line (speech bubbles, subtitles). Captions
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent audio tap, JavaScript side (engine 0.6.0+): the voice AI's call
|
|
3
|
+
* audio as it plays on this phone, in fixed frames of 16-bit mono PCM, for
|
|
4
|
+
* lip sync, visemes and meters. This is CALL audio (Path A), already
|
|
5
|
+
* compressed and decoded by the call; it never touches the recording.
|
|
6
|
+
*
|
|
7
|
+
* Pure TypeScript with no react-native import: option rules, the base64
|
|
8
|
+
* decoder, and the controller that orders start/stop and filters frames.
|
|
9
|
+
* index.ts wires the controller to the native module.
|
|
10
|
+
*/
|
|
11
|
+
/** Options for CrowdPlay.startAgentAudio() and useAgentAudio(). */
|
|
12
|
+
export interface AgentAudioOptions {
|
|
13
|
+
/** Samples per frame, an integer of at least 1. Default 1024 (64 ms at
|
|
14
|
+
* 16 kHz). frameSize / sampleRate must be 10 ms to 1 s. */
|
|
15
|
+
frameSize?: number;
|
|
16
|
+
/** Output rate in Hz, an integer in 8000...48000. Default 16000. The
|
|
17
|
+
* call audio is resampled to it and downmixed to mono. */
|
|
18
|
+
sampleRate?: number;
|
|
19
|
+
}
|
|
20
|
+
/** Payload of the 'agentAudio' event: one frame of the voice AI's call
|
|
21
|
+
* audio, pulled for playout. */
|
|
22
|
+
export interface AgentAudioFrame {
|
|
23
|
+
/** Base64 of Int16 little-endian mono PCM, exactly `frameSize` samples.
|
|
24
|
+
* decodeAgentAudio(frame.pcm) gives the samples. */
|
|
25
|
+
pcm: string;
|
|
26
|
+
sampleRate: number;
|
|
27
|
+
frameSize: number;
|
|
28
|
+
/** 0, 1, 2... per start, contiguous across delivered frames. */
|
|
29
|
+
seq: number;
|
|
30
|
+
/** True when this frame does not directly continue the previous frame
|
|
31
|
+
* this listener received: its first frame, a track switch or reconnect,
|
|
32
|
+
* a format change, or lost audio. Reset any smoothing state. */
|
|
33
|
+
discontinuity: boolean;
|
|
34
|
+
/** Which start this frame belongs to (frames of a stopped tap are
|
|
35
|
+
* dropped before they reach the app). */
|
|
36
|
+
tap: number;
|
|
37
|
+
/** When the frame's first sample reaches the listener, in Unix ms on this
|
|
38
|
+
* phone's clock (compare with Date.now()): its playout pull plus
|
|
39
|
+
* `outputLatencyMs`. Show the frame's mouth shape at this moment. It is
|
|
40
|
+
* usually already past when the frame arrives on the speaker (a frame is
|
|
41
|
+
* sent once its last sample is pulled, 64 ms at the defaults) and 100 to
|
|
42
|
+
* 300 ms ahead on Bluetooth. */
|
|
43
|
+
heardAtMs: number;
|
|
44
|
+
/** The audio route's delay from playout to the listener, in ms: one IO
|
|
45
|
+
* buffer plus what iOS reports for the route (a few ms on the speaker
|
|
46
|
+
* and wired headphones, 100 to 300 ms on Bluetooth). Re-read at most
|
|
47
|
+
* every 0.5 s, so a route change shows within half a second. */
|
|
48
|
+
outputLatencyMs: number;
|
|
49
|
+
}
|
|
50
|
+
export declare const AGENT_AUDIO_DEFAULT_FRAME_SIZE = 1024;
|
|
51
|
+
export declare const AGENT_AUDIO_DEFAULT_SAMPLE_RATE = 16000;
|
|
52
|
+
/** The engine's rules (AgentAudioTap.validate), in the engine's order, so
|
|
53
|
+
* a bad option fails here with the same wording instead of in native code.
|
|
54
|
+
* Doubles divide identically in JS and Swift, so the 10 ms and 1 s edges
|
|
55
|
+
* agree exactly. */
|
|
56
|
+
export declare function normalizeAgentAudioOptions(options?: AgentAudioOptions | null): {
|
|
57
|
+
frameSize: number;
|
|
58
|
+
sampleRate: number;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Decodes an agentAudio frame's `pcm` (base64 of Int16 little-endian mono)
|
|
62
|
+
* into samples, -32768...32767. Standard base64, padded or not; no atob
|
|
63
|
+
* needed. A trailing odd byte (never sent by the SDK) is ignored. Throws a
|
|
64
|
+
* TypeError on anything that is not base64.
|
|
65
|
+
*/
|
|
66
|
+
export declare function decodeAgentAudio(pcm: string): Int16Array;
|
|
67
|
+
/** The native methods the controller needs; optional because a bridge
|
|
68
|
+
* wired by an older crowdplaysdk does not have them. */
|
|
69
|
+
export interface AgentAudioNative {
|
|
70
|
+
startAgentAudio?(options: {
|
|
71
|
+
frameSize: number;
|
|
72
|
+
sampleRate: number;
|
|
73
|
+
}): Promise<{
|
|
74
|
+
tap: number;
|
|
75
|
+
} | null>;
|
|
76
|
+
stopAgentAudio?(): Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
export declare const STALE_BRIDGE_MESSAGE: string;
|
|
79
|
+
export interface AgentAudioController {
|
|
80
|
+
start(options?: AgentAudioOptions | null): Promise<void>;
|
|
81
|
+
stop(): Promise<void>;
|
|
82
|
+
/** Wraps an app listener: drops frames of any tap but the active one and
|
|
83
|
+
* marks the first frame it passes, and any frame that does not follow
|
|
84
|
+
* the previous one it passed, as a discontinuity. */
|
|
85
|
+
filter(listener: (frame: AgentAudioFrame) => void): (frame: AgentAudioFrame) => void;
|
|
86
|
+
/** The tap whose frames are delivered, or null (tests and diagnostics). */
|
|
87
|
+
readonly activeTap: number | null;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Orders every start and stop through one promise chain (separate native
|
|
91
|
+
* calls are not guaranteed to run in call order), and tracks which tap's
|
|
92
|
+
* frames may reach the app. A stop takes effect for the app the moment it
|
|
93
|
+
* is CALLED: frames still in flight from the bridge are dropped, so none
|
|
94
|
+
* arrive after stopAgentAudio() is called, let alone after it resolves.
|
|
95
|
+
*/
|
|
96
|
+
export declare function createAgentAudio(getNative: () => AgentAudioNative): AgentAudioController;
|