@mentra/acs-meeting 3.2.0-dev.125 → 3.2.0-dev.136
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 +12 -0
- package/app.plugin.js +35 -0
- package/ios/AcsFrameSender.swift +43 -16
- package/ios/AcsMeetingModule.swift +70 -54
- package/ios/PolicyKit/Sources/AcsAudioPolicy/CallbackOperation.swift +51 -0
- package/ios/PolicyKit/Sources/AcsAudioPolicy/VideoSendGate.swift +32 -0
- package/ios/PolicyKit/Tests/AcsAudioPolicyTests/CallbackOperationTests.swift +53 -0
- package/ios/PolicyKit/Tests/AcsAudioPolicyTests/VideoSendGateTests.swift +40 -0
- package/ios/WhepVideoSource.swift +1 -1
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -381,6 +381,18 @@ rollback; it takes effect on the next join.
|
|
|
381
381
|
`scripts/acs-ladder.ts` in the Mentra-Call repo parses these lines and prints pass/fail
|
|
382
382
|
over a trailing 10-second window, including the `recv`-vs-`dec` attribution.
|
|
383
383
|
|
|
384
|
+
## iOS host setup
|
|
385
|
+
|
|
386
|
+
Add `"@mentra/acs-meeting"` to the host's Expo `plugins` list, then run
|
|
387
|
+
`expo prebuild --platform ios` and `pod install`. The Mentra App and example
|
|
388
|
+
OEM host already include it.
|
|
389
|
+
|
|
390
|
+
The plugin builds only `AzureCommunicationCommon` as a dynamic framework with
|
|
391
|
+
`BUILD_LIBRARY_FOR_DISTRIBUTION=YES`. Calling's binary requires that framework at
|
|
392
|
+
runtime and imports its generated Swift header and stable module interface at
|
|
393
|
+
build time. CocoaPods' default static-library layout cannot satisfy this contract.
|
|
394
|
+
The host's other pods retain their configured linkage.
|
|
395
|
+
|
|
384
396
|
## Tests
|
|
385
397
|
|
|
386
398
|
```bash
|
package/app.plugin.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const {withPodfile} = require("expo/config-plugins")
|
|
2
|
+
const {mergeContents} = require("@expo/config-plugins/build/utils/generateCode")
|
|
3
|
+
|
|
4
|
+
// Calling is a vendored dynamic framework: its umbrella header and LC_LOAD_DYLIB
|
|
5
|
+
// both require Common.framework. Expo otherwise builds Common as a static library.
|
|
6
|
+
const commonFramework = ` installer.pod_targets.each do |pod|
|
|
7
|
+
next unless pod.name == 'AzureCommunicationCommon'
|
|
8
|
+
def pod.build_type
|
|
9
|
+
Pod::BuildType.dynamic_framework
|
|
10
|
+
end
|
|
11
|
+
# Calling's Swift interface must import Common through a stable interface,
|
|
12
|
+
# including when the two SDKs have different minimum deployment targets.
|
|
13
|
+
pod.root_spec.pod_target_xcconfig = (pod.root_spec.attributes_hash['pod_target_xcconfig'] || {}).merge(
|
|
14
|
+
'BUILD_LIBRARY_FOR_DISTRIBUTION' => 'YES'
|
|
15
|
+
)
|
|
16
|
+
end`
|
|
17
|
+
|
|
18
|
+
module.exports = function withAcsMeeting(config) {
|
|
19
|
+
return withPodfile(config, (config) => {
|
|
20
|
+
let contents = config.modResults.contents
|
|
21
|
+
const hook = /^\s*pre_install do \|installer\|\s*$/m
|
|
22
|
+
if (!hook.test(contents)) {
|
|
23
|
+
contents += "\npre_install do |installer|\nend\n"
|
|
24
|
+
}
|
|
25
|
+
config.modResults.contents = mergeContents({
|
|
26
|
+
src: contents,
|
|
27
|
+
newSrc: commonFramework,
|
|
28
|
+
tag: "acs-common-framework",
|
|
29
|
+
anchor: hook,
|
|
30
|
+
offset: 1,
|
|
31
|
+
comment: "#",
|
|
32
|
+
}).contents
|
|
33
|
+
return config
|
|
34
|
+
})
|
|
35
|
+
}
|
package/ios/AcsFrameSender.swift
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
20
|
-
|
|
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) {
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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:
|
|
42
|
-
func
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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 =
|
|
175
|
-
outAudioFormat.sampleRate = .
|
|
176
|
-
outAudioFormat.channelMode = .
|
|
177
|
-
outAudioFormat.
|
|
177
|
+
let outAudioFormat = RawOutgoingAudioStreamProperties()
|
|
178
|
+
outAudioFormat.sampleRate = .hz48000
|
|
179
|
+
outAudioFormat.channelMode = .mono
|
|
180
|
+
outAudioFormat.format = .pcm16Bit
|
|
178
181
|
|
|
179
182
|
let outAudioOptions = RawOutgoingAudioStreamOptions()
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
outgoing
|
|
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 =
|
|
198
|
-
inAudioFormat.sampleRate = .
|
|
199
|
-
inAudioFormat.channelMode = .
|
|
200
|
-
inAudioFormat.
|
|
208
|
+
let inAudioFormat = RawIncomingAudioStreamProperties()
|
|
209
|
+
inAudioFormat.sampleRate = .hz16000
|
|
210
|
+
inAudioFormat.channelMode = .mono
|
|
211
|
+
inAudioFormat.format = .pcm16Bit
|
|
201
212
|
let inAudioOptions = RawIncomingAudioStreamOptions()
|
|
202
|
-
inAudioOptions.
|
|
203
|
-
let incoming = RawIncomingAudioStream(
|
|
204
|
-
incoming.
|
|
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
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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()?.
|
|
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
|
|
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
|
|
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
|
-
|
|
476
|
-
return .failure(CallMissingError())
|
|
477
|
-
}
|
|
506
|
+
let stream = call.activeOutgoingAudioStream
|
|
478
507
|
do {
|
|
479
|
-
try
|
|
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(_
|
|
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
|
|
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.
|
|
3
|
+
"version": "3.2.0-dev.136",
|
|
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",
|