crowdplaysdk 0.6.2 → 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 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 identical to a
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
 
@@ -97,7 +99,7 @@ Bare React Native (no Expo):
97
99
  npm install crowdplaysdk
98
100
  cd ios && pod install
99
101
  gem install xcodeproj --user-install # one time
100
- ruby node_modules/crowdplaysdk/ios/wire.rb <YourProjectName>
102
+ ruby ../node_modules/crowdplaysdk/ios/wire.rb <YourProjectName> # from ios/
101
103
  ```
102
104
 
103
105
  `wire.rb` is idempotent. It adds the compiled CrowdPlaySDK Swift package,
@@ -107,6 +109,18 @@ keeps explicit Swift modules on for the app target (React Native 0.8x turns
107
109
  them off project-wide on Xcode 26, which breaks importing the compiled
108
110
  engine). There is no CocoaPod of our own, so your Podfile stays untouched.
109
111
 
112
+ **Updating.** `npm install crowdplaysdk@latest`, then wire again so the
113
+ engine moves with the bridge: Expo, `npx expo prebuild` (or a build that
114
+ runs prebuild; an `ios/` folder committed to git is not re-wired by
115
+ `eas build`, so run prebuild yourself and commit the result); bare React
116
+ Native, re-run `wire.rb`. Since 0.6.3 that also re-pins the engine to the
117
+ version this package ships with. 0.6.2 and older only added the engine
118
+ the first time, so upgrading in place kept the old engine. Before the
119
+ next local build, clean (Xcode: Product > Clean Build Folder): an
120
+ incremental build can reuse Xcode's cached copy of the previous engine's
121
+ interface and fail on new symbols such as `storageWarning`. EAS Build
122
+ starts clean every time.
123
+
110
124
  **2. Declare permissions in Info.plist** (bare React Native only; the Expo
111
125
  plugin does this for you).
112
126
 
@@ -168,6 +182,7 @@ CrowdPlay.addListener('warning', …) // micSilent / clipping / crossTalk:
168
182
  CrowdPlay.addListener('storageWarning', …) // optional: message set = storage low (audio only or stopped)
169
183
  CrowdPlay.addListener('recording', …) // error means NOT capturing; offer retryRecording()
170
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)
171
186
  CrowdPlay.setAudioOutput('speaker') // or 'automatic' (the connected device)
172
187
  CrowdPlay.snapshot() // poll at 1 Hz for the REC timer and input level
173
188
  ```
@@ -198,8 +213,65 @@ the AI speaks:
198
213
  ```
199
214
 
200
215
  Restyle it with props, wrap your own artwork in `<VoiceReactive>`, or drive
201
- any visual from the `useVoiceActivity()` hook. Show live subtitles with
202
- `useLatestCaption()`, and identify the AI participant by name `agent`.
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.
203
275
 
204
276
  You can also override the voice, personality and context for a single call,
205
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
- /// Fast (0.15 s) voice-activity stream for the animated voice UI ,
160
- /// runs only while some JS component is subscribed, so the bridge
161
- /// stays quiet for apps that never render it.
162
- func startVoiceActivity() {
163
- guard voiceTimer == nil else { return }
164
- voiceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: true) { _ in
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
@@ -6,7 +6,8 @@
6
6
  # ruby node_modules/crowdplaysdk/ios/wire.rb <YourProjectName>
7
7
  #
8
8
  # What it does (idempotent; safe to re-run):
9
- # 1. adds the CrowdPlaySDK Swift package (compiled engine) to the project
9
+ # 1. adds the CrowdPlaySDK Swift package (compiled engine) to the project,
10
+ # or re-pins an existing one to SDK_VERSION (an upgrade)
10
11
  # 2. copies the three bridge files into your app group + target
11
12
  # 3. creates/points the Swift bridging header at the React headers, raises
12
13
  # the app target to iOS 17 and keeps explicit Swift modules ON for it
@@ -31,7 +32,8 @@ SDK_URL = 'https://github.com/symbiateam/crowdplaysdk'
31
32
  # this still at 0.4.0, which broke every install (D-121). publish_rn.sh now
32
33
  # compiles the bridge against exactly this release before publishing.
33
34
  # 0.5.4: the storage guard and engine.storageWarning (D-148).
34
- SDK_VERSION = '0.5.4'
35
+ # 0.6.0: the agent audio tap (startAgentAudioTap), which the bridge calls.
36
+ SDK_VERSION = '0.6.0'
35
37
  BRIDGE_FILES = ['CrowdPlayRNModule.swift', 'CrowdPlayRNVideoView.swift', 'CrowdPlayReactNative.m'].freeze
36
38
 
37
39
  project_name = ARGV[0] or abort 'usage: ruby wire.rb <YourProjectName>'
@@ -44,17 +46,30 @@ target = project.targets.find { |t| t.name == project_name } ||
44
46
  project.targets.find { |t| t.product_type == 'com.apple.product-type.application' }
45
47
  abort 'no application target found' unless target
46
48
 
47
- # 1. CrowdPlaySDK Swift package (skip when already present).
48
- unless project.root_object.package_references.any? { |r| r.respond_to?(:repositoryURL) && r.repositoryURL == SDK_URL }
49
+ # 1. CrowdPlaySDK Swift package, pinned to exactly SDK_VERSION. An existing
50
+ # reference is re-pinned, never skipped: step 2 always replaces the bridge
51
+ # files, and a new bridge against the old engine does not compile (0.6.2
52
+ # upgraded in place kept engine 0.5.3 and failed on storageWarning, D-151).
53
+ requirement = { 'kind' => 'exactVersion', 'version' => SDK_VERSION }
54
+ existing = project.root_object.package_references.select { |r| r.respond_to?(:repositoryURL) && r.repositoryURL == SDK_URL }
55
+ if existing.empty?
49
56
  pkg = project.new(Xcodeproj::Project::Object::XCRemoteSwiftPackageReference)
50
57
  pkg.repositoryURL = SDK_URL
51
- pkg.requirement = { 'kind' => 'exactVersion', 'version' => SDK_VERSION }
58
+ pkg.requirement = requirement
52
59
  project.root_object.package_references << pkg
53
60
  dep = project.new(Xcodeproj::Project::Object::XCSwiftPackageProductDependency)
54
61
  dep.package = pkg
55
62
  dep.product_name = 'CrowdPlaySDK'
56
63
  target.package_product_dependencies << dep
57
64
  puts "+ CrowdPlaySDK package #{SDK_VERSION}"
65
+ else
66
+ existing.each do |pkg|
67
+ next if pkg.requirement == requirement
68
+
69
+ was = (pkg.requirement || {})['version'] || (pkg.requirement || {})['minimumVersion'] || 'unpinned'
70
+ pkg.requirement = requirement
71
+ puts "+ CrowdPlaySDK package #{was} -> #{SDK_VERSION}"
72
+ end
58
73
  end
59
74
 
60
75
  # 2. Bridge files into the app group + target.
@@ -27,9 +27,15 @@ export interface VoiceActivityEvent {
27
27
  localSpeaking: boolean;
28
28
  connected: boolean;
29
29
  }
30
- /** Live phase + energy from the call engine. Subscribing starts the native
31
- * fast stream; the last unmount stops it. */
32
- export declare function useVoiceActivity(): VoiceActivity;
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
- /** Live phase + energy from the call engine. Subscribing starts the native
55
- * fast stream; the last unmount stops it. */
56
- function useVoiceActivity() {
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
- module.startVoiceActivityUpdates?.();
129
+ voiceRequests.set(token, intervalRef.current);
130
+ applyVoiceRequests(module);
90
131
  return () => {
91
132
  sub.remove();
92
- module.stopVoiceActivityUpdates?.();
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;