@mentra/acs-meeting 3.2.0-dev.127 → 3.2.0-dev.137

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
@@ -4,6 +4,14 @@ Phone-native Azure Communication Services client that puts a MentraOS wearer int
4
4
  Microsoft Teams meeting as a guest. The glasses provide the camera and the microphone;
5
5
  the phone does all the WebRTC and ACS work.
6
6
 
7
+ ## Host setup
8
+
9
+ Add `"@mentra/acs-meeting"` to the host's Expo `plugins` list, including when
10
+ this module is installed through `@mentra/engine`, then run Expo prebuild for
11
+ the target platform. The plugin enables Android core library desugaring and
12
+ builds `AzureCommunicationCommon` as the framework required by ACS on iOS.
13
+ These requirements apply even when the host does not use Crust or Mapbox.
14
+
7
15
  The phone is a **relay, not a capture device**. It subscribes to whatever the glasses
8
16
  already published to Cloudflare, decodes it, and re-publishes it into ACS. No frame
9
17
  crosses the JavaScript bridge. Production never generates pixels on the phone.
@@ -381,6 +389,18 @@ rollback; it takes effect on the next join.
381
389
  `scripts/acs-ladder.ts` in the Mentra-Call repo parses these lines and prints pass/fail
382
390
  over a trailing 10-second window, including the `recv`-vs-`dec` attribution.
383
391
 
392
+ ## iOS host setup
393
+
394
+ Add `"@mentra/acs-meeting"` to the host's Expo `plugins` list, then run
395
+ `expo prebuild --platform ios` and `pod install`. The Mentra App and example
396
+ OEM host already include it.
397
+
398
+ The plugin builds only `AzureCommunicationCommon` as a dynamic framework with
399
+ `BUILD_LIBRARY_FOR_DISTRIBUTION=YES`. Calling's binary requires that framework at
400
+ runtime and imports its generated Swift header and stable module interface at
401
+ build time. CocoaPods' default static-library layout cannot satisfy this contract.
402
+ The host's other pods retain their configured linkage.
403
+
384
404
  ## Tests
385
405
 
386
406
  ```bash
package/app.plugin.js ADDED
@@ -0,0 +1,64 @@
1
+ const {withAppBuildGradle, withPodfile} = require("expo/config-plugins")
2
+ const {
3
+ createGeneratedHeaderComment,
4
+ mergeContents,
5
+ removeGeneratedContents,
6
+ } = require("@expo/config-plugins/build/utils/generateCode")
7
+
8
+ // Calling is a vendored dynamic framework: its umbrella header and LC_LOAD_DYLIB
9
+ // both require Common.framework. Expo otherwise builds Common as a static library.
10
+ const commonFramework = ` installer.pod_targets.each do |pod|
11
+ next unless pod.name == 'AzureCommunicationCommon'
12
+ def pod.build_type
13
+ Pod::BuildType.dynamic_framework
14
+ end
15
+ # Calling's Swift interface must import Common through a stable interface,
16
+ # including when the two SDKs have different minimum deployment targets.
17
+ pod.root_spec.pod_target_xcconfig = (pod.root_spec.attributes_hash['pod_target_xcconfig'] || {}).merge(
18
+ 'BUILD_LIBRARY_FOR_DISTRIBUTION' => 'YES'
19
+ )
20
+ end`
21
+
22
+ module.exports = function withAcsMeeting(config) {
23
+ config = withAppBuildGradle(config, (config) => {
24
+ if (config.modResults.language !== "groovy") {
25
+ throw new Error("@mentra/acs-meeting requires a Groovy app/build.gradle")
26
+ }
27
+ // A library cannot enable desugaring for its consuming app. Keep this
28
+ // requirement with ACS, including hosts that do not use the Crust plugin.
29
+ const tag = "acs-core-library-desugaring"
30
+ const contents = removeGeneratedContents(config.modResults.contents, tag) ?? config.modResults.contents
31
+ const desugaring = `android {
32
+ compileOptions {
33
+ coreLibraryDesugaringEnabled true
34
+ }
35
+ }
36
+ dependencies {
37
+ coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
38
+ }`
39
+ config.modResults.contents = [
40
+ contents.trimEnd(),
41
+ createGeneratedHeaderComment(desugaring, tag, "//"),
42
+ desugaring,
43
+ `// @generated end ${tag}`,
44
+ "",
45
+ ].join("\n")
46
+ return config
47
+ })
48
+ return withPodfile(config, (config) => {
49
+ let contents = config.modResults.contents
50
+ const hook = /^\s*pre_install do \|installer\|\s*$/m
51
+ if (!hook.test(contents)) {
52
+ contents += "\npre_install do |installer|\nend\n"
53
+ }
54
+ config.modResults.contents = mergeContents({
55
+ src: contents,
56
+ newSrc: commonFramework,
57
+ tag: "acs-common-framework",
58
+ anchor: hook,
59
+ offset: 1,
60
+ comment: "#",
61
+ }).contents
62
+ return config
63
+ })
64
+ }
@@ -4,44 +4,71 @@ import Foundation
4
4
 
5
5
  /// Fresh Swift sender on ACS iOS RawOutgoingVideoStream + CVPixelBuffer.
6
6
  /// No RealWear code (they have no iOS equivalent).
7
- final class AcsFrameSender {
8
- private var stream: RawOutgoingVideoStream?
7
+ final class AcsFrameSender: NSObject {
8
+ private var stream: VirtualOutgoingVideoStream?
9
9
  private var running = false
10
10
  private var lastSent: CFTimeInterval = 0
11
+ private let sendGate = VideoSendGate()
12
+ private let stateLock = NSLock()
11
13
 
12
14
  func attach(_ stream: VirtualOutgoingVideoStream) {
13
15
  detach()
16
+ stateLock.lock()
14
17
  self.stream = stream
18
+ lastSent = 0
19
+ stateLock.unlock()
15
20
  stream.delegate = self
16
21
  }
17
22
 
18
23
  func send(_ pixelBuffer: CVPixelBuffer) {
19
- guard running, let stream else { return }
20
- let fps = stream.format?.framesPerSecond ?? 15
24
+ stateLock.lock()
25
+ guard running, let stream else {
26
+ stateLock.unlock()
27
+ return
28
+ }
29
+ let fps = stream.format.framesPerSecond
21
30
  let now = CFAbsoluteTimeGetCurrent()
22
- if lastSent > 0, now - lastSent < 1.0 / max(fps, 1) { return }
31
+ if lastSent > 0, now - lastSent < 1.0 / Double(max(fps, 1)) {
32
+ stateLock.unlock()
33
+ return
34
+ }
35
+ guard let token = sendGate.acquire() else {
36
+ stateLock.unlock()
37
+ return
38
+ }
23
39
  lastSent = now
24
- do {
25
- let frame = RawVideoFrameBuffer(pixelBuffer, streamFormat: stream.format)
26
- try stream.send(frame)
27
- } catch {
28
- NSLog("ACS-SPIKE send frame failed: \(error)")
40
+ stateLock.unlock()
41
+ let frame = RawVideoFrameBuffer()
42
+ frame.buffer = pixelBuffer
43
+ frame.streamFormat = stream.format
44
+ frame.timestampInTicks = stream.timestampInTicks
45
+ stream.send(frame: frame) { [sendGate] error in
46
+ // Keep the source pixels alive until ACS finishes reading the frame.
47
+ withExtendedLifetime(pixelBuffer) { frame.dispose() }
48
+ sendGate.release(token)
49
+ if let error { NSLog("ACS-SPIKE send frame failed: \(error)") }
29
50
  }
30
51
  }
31
52
 
32
53
  func detach() {
54
+ stateLock.lock()
33
55
  running = false
34
56
  // Drop the delegate on the previous stream so a late STOPPED from a prior
35
57
  // call cannot freeze outgoing video for the current meeting.
36
- stream?.delegate = nil
58
+ let previous = stream
37
59
  stream = nil
60
+ sendGate.reset()
61
+ stateLock.unlock()
62
+ previous?.delegate = nil
38
63
  }
39
64
  }
40
65
 
41
- extension AcsFrameSender: RawOutgoingVideoStreamDelegate {
42
- func rawOutgoingVideoStream(_ rawOutgoingVideoStream: RawOutgoingVideoStream, didChangeState args: VideoStreamStateChangedEventArgs) {
43
- guard rawOutgoingVideoStream === stream else { return }
44
- running = rawOutgoingVideoStream.state == .started
45
- NSLog("ACS-SPIKE iOS raw video state=\(rawOutgoingVideoStream.state)")
66
+ extension AcsFrameSender: VirtualOutgoingVideoStreamDelegate {
67
+ func virtualOutgoingVideoStream(_ virtualOutgoingVideoStream: VirtualOutgoingVideoStream, didChangeState _: VideoStreamStateChangedEventArgs) {
68
+ stateLock.lock()
69
+ defer { stateLock.unlock() }
70
+ guard virtualOutgoingVideoStream === stream else { return }
71
+ running = virtualOutgoingVideoStream.state == .started
72
+ NSLog("ACS-SPIKE iOS raw video state=\(virtualOutgoingVideoStream.state)")
46
73
  }
47
74
  }
@@ -1,3 +1,4 @@
1
+ import AVFoundation
1
2
  import AzureCommunicationCalling
2
3
  import AzureCommunicationCommon
3
4
  import ExpoModulesCore
@@ -80,7 +81,7 @@ final class QueuePolicyScheduler: PolicyScheduler {
80
81
  }
81
82
  }
82
83
 
83
- final class AcsMeetingSession {
84
+ final class AcsMeetingSession: NSObject {
84
85
  private static let glassesRequiresUnmutedTransport = true
85
86
  private let onState: ([String: Any]) -> Void
86
87
  private let onIncomingPcm: (String, Int, Int) -> Void
@@ -158,7 +159,9 @@ final class AcsMeetingSession {
158
159
  self.callClient = client
159
160
  let options = CallAgentOptions()
160
161
  options.displayName = displayName ?? "Mentra Call"
161
- let agent = try client.createCallAgent(userCredential: credential, options: options).get()
162
+ let agent = try CallbackOperation<CallAgent>().wait(onLateSuccess: { $0.dispose() }) { completion in
163
+ client.createCallAgent(userCredential: credential, options: options, completionHandler: completion)
164
+ }
162
165
  self.callAgent = agent
163
166
 
164
167
  let videoFormat = VideoStreamFormat()
@@ -171,15 +174,23 @@ final class AcsMeetingSession {
171
174
  let videoStream = VirtualOutgoingVideoStream(videoStreamOptions: videoOptions)
172
175
  self.frameSender.attach(videoStream)
173
176
 
174
- let outAudioFormat = AudioStreamFormat()
175
- outAudioFormat.sampleRate = .sampleRate48000
176
- outAudioFormat.channelMode = .channelModeMono
177
- outAudioFormat.encodedAudioFormat = .pcm
177
+ let outAudioFormat = RawOutgoingAudioStreamProperties()
178
+ outAudioFormat.sampleRate = .hz48000
179
+ outAudioFormat.channelMode = .mono
180
+ outAudioFormat.format = .pcm16Bit
178
181
 
179
182
  let outAudioOptions = RawOutgoingAudioStreamOptions()
180
- outAudioOptions.format = outAudioFormat
181
- let outgoing = RawOutgoingAudioStream(audioStreamOptions: outAudioOptions)
182
- outgoing.delegate = self
183
+ outAudioFormat.bufferDuration = .ms20
184
+ outAudioOptions.properties = outAudioFormat
185
+ let outgoing = RawOutgoingAudioStream(options: outAudioOptions)
186
+ outgoing.events.onStateChanged = { [weak self, weak outgoing] _ in
187
+ guard let self, let outgoing else { return }
188
+ self.queue.async {
189
+ guard self.audioOut === outgoing else { return }
190
+ self.outgoingReady = outgoing.state == .started
191
+ self.applyAudioPolicyOnQueue("virtual-stream-state")
192
+ }
193
+ }
183
194
  self.audioOut = outgoing
184
195
 
185
196
  let desired: AudioSourceKind = self.audioSource == "phone" ? .phone : .glasses
@@ -194,14 +205,19 @@ final class AcsMeetingSession {
194
205
  let local: LocalOutgoingAudioStream? = plan.armVirtual ? nil : LocalOutgoingAudioStream()
195
206
  self.localOut = local
196
207
 
197
- let inAudioFormat = AudioStreamFormat()
198
- inAudioFormat.sampleRate = .sampleRate16000
199
- inAudioFormat.channelMode = .channelModeMono
200
- inAudioFormat.encodedAudioFormat = .pcm
208
+ let inAudioFormat = RawIncomingAudioStreamProperties()
209
+ inAudioFormat.sampleRate = .hz16000
210
+ inAudioFormat.channelMode = .mono
211
+ inAudioFormat.format = .pcm16Bit
201
212
  let inAudioOptions = RawIncomingAudioStreamOptions()
202
- inAudioOptions.format = inAudioFormat
203
- let incoming = RawIncomingAudioStream(audioStreamOptions: inAudioOptions)
204
- incoming.delegate = self
213
+ inAudioOptions.properties = inAudioFormat
214
+ let incoming = RawIncomingAudioStream(options: inAudioOptions)
215
+ incoming.events.onMixedAudioBufferReceived = { [weak self] args in
216
+ guard let pcm = args.audioBuffer.buffer as? AVAudioPCMBuffer,
217
+ let samples = pcm.int16ChannelData else { return }
218
+ let data = Data(bytes: samples[0], count: Int(pcm.frameLength) * Int(pcm.format.channelCount) * 2)
219
+ self?.onIncomingPcm(data.base64EncodedString(), Int(pcm.format.sampleRate), Int(pcm.format.channelCount))
220
+ }
205
221
 
206
222
  let joinOptions = JoinCallOptions()
207
223
  let outgoingVideo = OutgoingVideoOptions()
@@ -219,7 +235,11 @@ final class AcsMeetingSession {
219
235
  joinOptions.incomingAudioOptions = incomingAudio
220
236
 
221
237
  let locator = TeamsMeetingLinkLocator(meetingLink: meetingUrl)
222
- let call = try agent.join(with: locator, joinCallOptions: joinOptions)
238
+ let call = try CallbackOperation<Call>().wait(onLateSuccess: { lateCall in
239
+ lateCall.hangUp(options: nil) { _ in }
240
+ }) { completion in
241
+ agent.join(with: locator, joinCallOptions: joinOptions, completionHandler: completion)
242
+ }
223
243
  self.call = call
224
244
  call.delegate = self
225
245
 
@@ -350,13 +370,16 @@ final class AcsMeetingSession {
350
370
  private func feedOutgoingPcm(_ pcm: Data, sampleRate: Int, channels: Int) {
351
371
  guard !muted, outgoingReady, let stream = audioOut else { return }
352
372
  for frame in pcmBridge?.ingest(pcm16Le: pcm, sampleRate: sampleRate, channels: channels) ?? [] {
353
- do {
354
- let buffer = RawAudioBuffer()
355
- buffer.data = frame
356
- try stream.send(buffer)
357
- } catch {
358
- NSLog("ACS-SPIKE sendRawAudioBuffer failed: \(error)")
359
- break
373
+ guard let format = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 48000, channels: 1, interleaved: true),
374
+ let pcm = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(frame.count / 2)),
375
+ let samples = pcm.int16ChannelData else { continue }
376
+ pcm.frameLength = pcm.frameCapacity
377
+ frame.copyBytes(to: UnsafeMutableRawBufferPointer(start: samples[0], count: frame.count))
378
+ let buffer = RawAudioBuffer()
379
+ buffer.buffer = pcm
380
+ stream.send(buffer: buffer) { error in
381
+ buffer.dispose()
382
+ if let error { NSLog("ACS-SPIKE sendRawAudioBuffer failed: \(error)") }
360
383
  }
361
384
  }
362
385
  }
@@ -380,7 +403,11 @@ final class AcsMeetingSession {
380
403
  whep?.stop()
381
404
  frameSender.detach()
382
405
  do {
383
- try call?.hangUp().get()
406
+ if let call {
407
+ try CallbackOperation<Void>().wait { completion in
408
+ call.hangUp(options: nil) { completion((), $0) }
409
+ }
410
+ }
384
411
  } catch {
385
412
  NSLog("ACS-SPIKE leave hangUp failed: \(error)")
386
413
  }
@@ -422,16 +449,16 @@ final class SessionAudioController: AudioStreamController {
422
449
 
423
450
  func readActive() -> ActiveStreamKind {
424
451
  guard let stream = session?.currentCall()?.activeOutgoingAudioStream else { return .none }
425
- let started = String(describing: stream.state).localizedCaseInsensitiveContains("started")
426
- guard started else { return .none }
427
- let type = String(describing: stream.type).lowercased()
428
- if type.contains("virtual") { return .virtual }
429
- if type.contains("local") { return .local }
430
- return .none
452
+ guard stream.state == .started else { return .none }
453
+ switch stream.type {
454
+ case .virtualOutgoing: return .virtual
455
+ case .localOutgoing: return .local
456
+ default: return .none
457
+ }
431
458
  }
432
459
 
433
460
  func isPhysicallyMuted() -> Bool? {
434
- session?.currentCall()?.isMuted
461
+ session?.currentCall()?.isOutgoingAudioMuted
435
462
  }
436
463
 
437
464
  func setGlassesPcmEnabled(_ enabled: Bool) {
@@ -447,7 +474,9 @@ final class SessionAudioController: AudioStreamController {
447
474
  case .failure(let error): return .failure(error)
448
475
  case .success(let call):
449
476
  do {
450
- try call.mute().get()
477
+ try CallbackOperation<Void>().wait { completion in
478
+ call.muteOutgoingAudio { completion((), $0) }
479
+ }
451
480
  return .success(())
452
481
  } catch {
453
482
  return .failure(error)
@@ -460,7 +489,9 @@ final class SessionAudioController: AudioStreamController {
460
489
  case .failure(let error): return .failure(error)
461
490
  case .success(let call):
462
491
  do {
463
- try call.unmute().get()
492
+ try CallbackOperation<Void>().wait { completion in
493
+ call.unmuteOutgoingAudio { completion((), $0) }
494
+ }
464
495
  return .success(())
465
496
  } catch {
466
497
  return .failure(error)
@@ -472,11 +503,11 @@ final class SessionAudioController: AudioStreamController {
472
503
  switch CallGuard.require(session?.currentCall()) {
473
504
  case .failure(let error): return .failure(error)
474
505
  case .success(let call):
475
- guard let stream = call.activeOutgoingAudioStream else {
476
- return .failure(CallMissingError())
477
- }
506
+ let stream = call.activeOutgoingAudioStream
478
507
  do {
479
- try call.stopAudio(stream: stream).get()
508
+ try CallbackOperation<Void>().wait { completion in
509
+ call.stopAudio(stream: stream) { completion((), $0) }
510
+ }
480
511
  return .success(())
481
512
  } catch {
482
513
  return .failure(error)
@@ -501,26 +532,11 @@ extension AcsMeetingSession: CallDelegate {
501
532
  }
502
533
  }
503
534
 
504
- func call(_ call: Call, didChangeMuteState args: PropertyChangedEventArgs) {
535
+ func call(_: Call, didUpdateOutgoingAudioState _: PropertyChangedEventArgs) {
505
536
  applyAudioPolicy("outgoing-audio-state")
506
537
  }
507
538
  }
508
539
 
509
- extension AcsMeetingSession: RawOutgoingAudioStreamDelegate {
510
- func rawOutgoingAudioStream(_ rawOutgoingAudioStream: RawOutgoingAudioStream, didChangeState args: AudioStreamStateChangedEventArgs) {
511
- outgoingReady = String(describing: rawOutgoingAudioStream.state).localizedCaseInsensitiveContains("started")
512
- NSLog("ACS-SPIKE iOS raw outgoing audio state=\(rawOutgoingAudioStream.state)")
513
- applyAudioPolicy("virtual-stream-state")
514
- }
515
- }
516
-
517
- extension AcsMeetingSession: RawIncomingAudioStreamDelegate {
518
- func rawIncomingAudioStream(_ rawIncomingAudioStream: RawIncomingAudioStream, didReceiveRawAudioBuffer args: IncomingAudioStreamRawBufferReceivedEventArgs) {
519
- guard let data = args.audioBuffer?.data else { return }
520
- onIncomingPcm(data.base64EncodedString(), 16000, 1)
521
- }
522
- }
523
-
524
540
  struct AcsOutgoingVideo {
525
541
  let width: Int
526
542
  let height: Int
@@ -0,0 +1,51 @@
1
+ import Foundation
2
+
3
+ /// Adapts ACS completion handlers to the session's serial control queue. Never
4
+ /// used on the main thread or the media path. Policy decisions must observe the
5
+ /// completed mute/stop result before deciding whether the microphone is safe.
6
+ public final class CallbackOperation<Value> {
7
+ private let condition = NSCondition()
8
+ private var result: Result<Value, Error>?
9
+ private var expired = false
10
+
11
+ public init() {}
12
+
13
+ public func wait(
14
+ timeout: TimeInterval = 30,
15
+ onLateSuccess: @escaping (Value) -> Void = { _ in },
16
+ start: (@escaping (Value?, Error?) -> Void) -> Void
17
+ ) throws -> Value {
18
+ precondition(!Thread.isMainThread, "ACS control operations must run on the session queue")
19
+ let deadline = Date().addingTimeInterval(timeout)
20
+ start { value, error in
21
+ let outcome: Result<Value, Error> = if let error { .failure(error) }
22
+ else if let value { .success(value) }
23
+ else { .failure(CallbackOperationError.missingResult) }
24
+
25
+ self.condition.lock()
26
+ if self.expired {
27
+ self.condition.unlock()
28
+ if case let .success(value) = outcome { onLateSuccess(value) }
29
+ return
30
+ }
31
+ if self.result == nil { self.result = outcome }
32
+ self.condition.broadcast()
33
+ self.condition.unlock()
34
+ }
35
+
36
+ condition.lock()
37
+ defer { condition.unlock() }
38
+ while result == nil {
39
+ if !condition.wait(until: deadline), result == nil {
40
+ expired = true
41
+ throw CallbackOperationError.timedOut
42
+ }
43
+ }
44
+ return try result!.get()
45
+ }
46
+ }
47
+
48
+ public enum CallbackOperationError: Error {
49
+ case missingResult
50
+ case timedOut
51
+ }
@@ -0,0 +1,32 @@
1
+ import Foundation
2
+
3
+ /// At most one retained video frame per stream generation, even if the SDK
4
+ /// stops completing sends. Old completions cannot release a new stream's frame.
5
+ public final class VideoSendGate {
6
+ private let lock = NSLock()
7
+ private var nextToken: UInt64 = 0
8
+ private var pending: UInt64?
9
+
10
+ public init() {}
11
+
12
+ public func acquire() -> UInt64? {
13
+ lock.lock()
14
+ defer { lock.unlock() }
15
+ guard pending == nil else { return nil }
16
+ nextToken &+= 1
17
+ pending = nextToken
18
+ return nextToken
19
+ }
20
+
21
+ public func release(_ token: UInt64) {
22
+ lock.lock()
23
+ defer { lock.unlock() }
24
+ if token == pending { pending = nil }
25
+ }
26
+
27
+ public func reset() {
28
+ lock.lock()
29
+ defer { lock.unlock() }
30
+ pending = nil
31
+ }
32
+ }
@@ -0,0 +1,53 @@
1
+ @testable import AcsAudioPolicy
2
+ import Foundation
3
+ import XCTest
4
+
5
+ final class CallbackOperationTests: XCTestCase {
6
+ private enum TestError: Error { case sdkFailure }
7
+
8
+ private func onWorker(_ body: @escaping () throws -> Void) {
9
+ let finished = expectation(description: "control queue operation")
10
+ DispatchQueue.global().async {
11
+ do { try body() }
12
+ catch { XCTFail("Unexpected error: \(error)") }
13
+ finished.fulfill()
14
+ }
15
+ wait(for: [finished], timeout: 2)
16
+ }
17
+
18
+ func testInlineAndDelayedSuccess() {
19
+ onWorker {
20
+ XCTAssertEqual(try CallbackOperation<Int>().wait { $0(7, nil) }, 7)
21
+ let result = try CallbackOperation<Int>().wait { completion in
22
+ DispatchQueue.global().asyncAfter(deadline: .now() + 0.01) { completion(9, nil) }
23
+ }
24
+ XCTAssertEqual(result, 9)
25
+ }
26
+ }
27
+
28
+ func testPropagatesSdkFailureAndRejectsMissingResult() {
29
+ onWorker {
30
+ XCTAssertThrowsError(try CallbackOperation<Int>().wait { $0(nil, TestError.sdkFailure) }) {
31
+ XCTAssertTrue($0 is TestError)
32
+ }
33
+ XCTAssertThrowsError(try CallbackOperation<Int>().wait { $0(nil, nil) }) {
34
+ guard case CallbackOperationError.missingResult = $0 else { return XCTFail("Wrong error: \($0)") }
35
+ }
36
+ }
37
+ }
38
+
39
+ func testTimeoutDisposesAResourceReturnedAfterTheWaitEnds() {
40
+ let disposed = expectation(description: "late agent or call cleaned up")
41
+ onWorker {
42
+ var callback: ((Int?, Error?) -> Void)?
43
+ XCTAssertThrowsError(try CallbackOperation<Int>().wait(timeout: 0.01, onLateSuccess: { value in
44
+ XCTAssertEqual(value, 42)
45
+ disposed.fulfill()
46
+ }) { callback = $0 }) {
47
+ guard case CallbackOperationError.timedOut = $0 else { return XCTFail("Wrong error: \($0)") }
48
+ }
49
+ callback?(42, nil)
50
+ }
51
+ wait(for: [disposed], timeout: 1)
52
+ }
53
+ }
@@ -0,0 +1,40 @@
1
+ @testable import AcsAudioPolicy
2
+ import Foundation
3
+ import XCTest
4
+
5
+ final class VideoSendGateTests: XCTestCase {
6
+ func testDelayedCompletionBoundsOutstandingFrames() throws {
7
+ let gate = VideoSendGate()
8
+ let token = try XCTUnwrap(gate.acquire())
9
+ for _ in 0 ..< 1000 {
10
+ XCTAssertNil(gate.acquire())
11
+ }
12
+ gate.release(token)
13
+ XCTAssertNotNil(gate.acquire())
14
+ }
15
+
16
+ func testOldCompletionDoesNotReleaseNewStreamFrame() throws {
17
+ let gate = VideoSendGate()
18
+ let old = try XCTUnwrap(gate.acquire())
19
+ gate.reset()
20
+ let current = try XCTUnwrap(gate.acquire())
21
+ gate.release(old)
22
+ XCTAssertNil(gate.acquire())
23
+ gate.release(current)
24
+ XCTAssertNotNil(gate.acquire())
25
+ }
26
+
27
+ func testConcurrentFramesAdmitExactlyOneSubmission() {
28
+ let gate = VideoSendGate()
29
+ let lock = NSLock()
30
+ var admitted = 0
31
+ DispatchQueue.concurrentPerform(iterations: 100) { _ in
32
+ if gate.acquire() != nil {
33
+ lock.lock()
34
+ admitted += 1
35
+ lock.unlock()
36
+ }
37
+ }
38
+ XCTAssertEqual(admitted, 1)
39
+ }
40
+ }
@@ -305,7 +305,7 @@ extension WhepVideoSource: RTCVideoRenderer {
305
305
  }
306
306
 
307
307
  extension WhepVideoSource: RTCAudioRenderer {
308
- func renderPCMBuffer(_ pcmBuffer: AVAudioPCMBuffer) {
308
+ func render(pcmBuffer: AVAudioPCMBuffer) {
309
309
  guard pcmEnabled else { return }
310
310
  let channels = Int(pcmBuffer.format.channelCount)
311
311
  let rate = Int(pcmBuffer.format.sampleRate)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/acs-meeting",
3
- "version": "3.2.0-dev.127",
3
+ "version": "3.2.0-dev.137",
4
4
  "description": "MentraOS native ACS Teams meeting module (WHEP decode → ACS raw media)",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -30,6 +30,9 @@
30
30
  "publishConfig": {
31
31
  "access": "public"
32
32
  },
33
+ "dependencies": {
34
+ "@expo/config-plugins": "~55.0.10"
35
+ },
33
36
  "devDependencies": {
34
37
  "expo-module-scripts": "^55.0.2",
35
38
  "typescript": "~5.9.2"
@@ -40,6 +43,7 @@
40
43
  "react-native": "*"
41
44
  },
42
45
  "files": [
46
+ "app.plugin.js",
43
47
  "android",
44
48
  "!android/.gradle",
45
49
  "!android/build",