@mentra/acs-meeting 3.2.0-dev.120

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.
Files changed (102) hide show
  1. package/NOTICE +20 -0
  2. package/README.md +412 -0
  3. package/THIRD_PARTY +7 -0
  4. package/android/build.gradle +64 -0
  5. package/android/src/main/AndroidManifest.xml +5 -0
  6. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +80 -0
  7. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +846 -0
  8. package/android/src/main/java/com/mentra/acsmeeting/RemoteRoster.kt +123 -0
  9. package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +90 -0
  10. package/android/src/main/java/com/mentra/acsmeeting/audio/AudioPolicyApplier.kt +176 -0
  11. package/android/src/main/java/com/mentra/acsmeeting/audio/IncomingAudioPump.kt +112 -0
  12. package/android/src/main/java/com/mentra/acsmeeting/audio/IncomingRateProbe.kt +65 -0
  13. package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +108 -0
  14. package/android/src/main/java/com/mentra/acsmeeting/audio/PcmResampler.kt +159 -0
  15. package/android/src/main/java/com/mentra/acsmeeting/audio/PhoneMicCapturer.kt +115 -0
  16. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +302 -0
  17. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +230 -0
  18. package/android/src/main/java/com/mentra/acsmeeting/source/BitmapFont.kt +99 -0
  19. package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +578 -0
  20. package/android/src/main/java/com/mentra/acsmeeting/source/FirstFrameGate.kt +45 -0
  21. package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +101 -0
  22. package/android/src/main/java/com/mentra/acsmeeting/source/MediaListeners.kt +57 -0
  23. package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticFrameFactory.kt +205 -0
  24. package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticI420Source.kt +214 -0
  25. package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +31 -0
  26. package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +41 -0
  27. package/android/src/main/java/com/mentra/acsmeeting/telemetry/ChromaProbe.kt +78 -0
  28. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +281 -0
  29. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +51 -0
  30. package/android/src/main/java/com/mentra/acsmeeting/telemetry/ProcessCpu.kt +15 -0
  31. package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +33 -0
  32. package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +475 -0
  33. package/android/src/main/java/com/mentra/acsmeeting/video/AcsTimestamp.kt +26 -0
  34. package/android/src/main/java/com/mentra/acsmeeting/video/FrameGeometry.kt +22 -0
  35. package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +90 -0
  36. package/android/src/main/java/com/mentra/acsmeeting/video/I420Packer.kt +72 -0
  37. package/android/src/main/java/com/mentra/acsmeeting/video/Nv12FormatSpec.kt +36 -0
  38. package/android/src/main/java/com/mentra/acsmeeting/video/Nv12Packer.kt +51 -0
  39. package/android/src/main/java/com/mentra/acsmeeting/video/SendGate.kt +27 -0
  40. package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +57 -0
  41. package/android/src/test/java/com/mentra/acsmeeting/RemoteRosterTest.kt +40 -0
  42. package/android/src/test/java/com/mentra/acsmeeting/audio/AcsAudioPolicyTest.kt +204 -0
  43. package/android/src/test/java/com/mentra/acsmeeting/audio/AudioPolicyApplierTest.kt +223 -0
  44. package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +12 -0
  45. package/android/src/test/java/com/mentra/acsmeeting/audio/IncomingAudioPumpTest.kt +97 -0
  46. package/android/src/test/java/com/mentra/acsmeeting/audio/IncomingRateProbeTest.kt +78 -0
  47. package/android/src/test/java/com/mentra/acsmeeting/audio/PcmResamplerTest.kt +125 -0
  48. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +201 -0
  49. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +166 -0
  50. package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +26 -0
  51. package/android/src/test/java/com/mentra/acsmeeting/source/BitmapFontTest.kt +22 -0
  52. package/android/src/test/java/com/mentra/acsmeeting/source/FirstFrameGateTest.kt +60 -0
  53. package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +133 -0
  54. package/android/src/test/java/com/mentra/acsmeeting/source/I420PlanesTest.kt +67 -0
  55. package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticFrameFactoryTest.kt +193 -0
  56. package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticI420SourceTest.kt +176 -0
  57. package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +36 -0
  58. package/android/src/test/java/com/mentra/acsmeeting/telemetry/ChromaProbeTest.kt +61 -0
  59. package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +256 -0
  60. package/android/src/test/java/com/mentra/acsmeeting/telemetry/ProcessCpuTest.kt +18 -0
  61. package/android/src/test/java/com/mentra/acsmeeting/telemetry/RingPercentileTest.kt +49 -0
  62. package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +43 -0
  63. package/android/src/test/java/com/mentra/acsmeeting/video/FrameGeometryTest.kt +33 -0
  64. package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +77 -0
  65. package/android/src/test/java/com/mentra/acsmeeting/video/I420PackerTest.kt +126 -0
  66. package/android/src/test/java/com/mentra/acsmeeting/video/Nv12FormatSpecTest.kt +36 -0
  67. package/android/src/test/java/com/mentra/acsmeeting/video/Nv12PackerTest.kt +58 -0
  68. package/android/src/test/java/com/mentra/acsmeeting/video/SendGateTest.kt +79 -0
  69. package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +68 -0
  70. package/build/AcsMeeting.types.d.ts +59 -0
  71. package/build/AcsMeeting.types.d.ts.map +1 -0
  72. package/build/AcsMeeting.types.js +2 -0
  73. package/build/AcsMeeting.types.js.map +1 -0
  74. package/build/AcsMeetingModule.d.ts +15 -0
  75. package/build/AcsMeetingModule.d.ts.map +1 -0
  76. package/build/AcsMeetingModule.js +3 -0
  77. package/build/AcsMeetingModule.js.map +1 -0
  78. package/build/AcsMeetingModule.web.d.ts +16 -0
  79. package/build/AcsMeetingModule.web.d.ts.map +1 -0
  80. package/build/AcsMeetingModule.web.js +31 -0
  81. package/build/AcsMeetingModule.web.js.map +1 -0
  82. package/build/index.d.ts +3 -0
  83. package/build/index.d.ts.map +1 -0
  84. package/build/index.js +3 -0
  85. package/build/index.js.map +1 -0
  86. package/expo-module.config.json +9 -0
  87. package/ios/AcsFrameSender.swift +47 -0
  88. package/ios/AcsMeeting.podspec +27 -0
  89. package/ios/AcsMeetingModule.swift +558 -0
  90. package/ios/PcmBridge.swift +117 -0
  91. package/ios/PhoneMicCapturer.swift +64 -0
  92. package/ios/PolicyKit/Package.swift +17 -0
  93. package/ios/PolicyKit/Sources/AcsAudioPolicy/AcsAudioPolicy.swift +102 -0
  94. package/ios/PolicyKit/Sources/AcsAudioPolicy/AudioPolicyApplier.swift +162 -0
  95. package/ios/PolicyKit/Sources/AcsAudioPolicy/GlassesMediaSource.swift +68 -0
  96. package/ios/PolicyKit/Tests/AcsAudioPolicyTests/AcsAudioPolicyTests.swift +273 -0
  97. package/ios/WhepVideoSource.swift +356 -0
  98. package/package.json +56 -0
  99. package/src/AcsMeeting.types.ts +66 -0
  100. package/src/AcsMeetingModule.ts +16 -0
  101. package/src/AcsMeetingModule.web.ts +33 -0
  102. package/src/index.ts +2 -0
@@ -0,0 +1,64 @@
1
+ import AVFoundation
2
+ import Foundation
3
+
4
+ /// Phone-mic uplink for ACS virtual outgoing. Taps the hardware input at
5
+ /// whatever rate the session is already using and never switches the session
6
+ /// into voice-chat / speaker-default, so A2DP playback can stay on the glasses.
7
+ final class PhoneMicCapturer {
8
+ private let engine = AVAudioEngine()
9
+ private var running = false
10
+ var onPcm: ((Data, Int, Int) -> Void)?
11
+
12
+ func setEnabled(_ enabled: Bool) {
13
+ if enabled { start() } else { stop() }
14
+ }
15
+
16
+ private func start() {
17
+ guard !running else { return }
18
+ do {
19
+ let session = AVAudioSession.sharedInstance()
20
+ try session.setCategory(.playAndRecord, mode: .default, options: [.mixWithOthers, .allowBluetoothA2DP])
21
+ try session.setPreferredSampleRate(48_000)
22
+ try session.setActive(true, options: [])
23
+ let input = engine.inputNode
24
+ let hwFormat = input.inputFormat(forBus: 0)
25
+ input.removeTap(onBus: 0)
26
+ input.installTap(onBus: 0, bufferSize: 960, format: hwFormat) { [weak self] buffer, _ in
27
+ guard let self, let data = Self.pcm16(from: buffer) else { return }
28
+ self.onPcm?(data, Int(buffer.format.sampleRate), Int(buffer.format.channelCount))
29
+ }
30
+ try engine.start()
31
+ running = true
32
+ NSLog("ACS-SPIKE PhoneMicCapturer started")
33
+ } catch {
34
+ NSLog("ACS-SPIKE PhoneMicCapturer start failed: \(error)")
35
+ stop()
36
+ }
37
+ }
38
+
39
+ private func stop() {
40
+ engine.inputNode.removeTap(onBus: 0)
41
+ if engine.isRunning { engine.stop() }
42
+ running = false
43
+ NSLog("ACS-SPIKE PhoneMicCapturer stopped")
44
+ }
45
+
46
+ private static func pcm16(from buffer: AVAudioPCMBuffer) -> Data? {
47
+ let frames = Int(buffer.frameLength)
48
+ guard frames > 0 else { return nil }
49
+ let channels = Int(buffer.format.channelCount)
50
+ if let int16 = buffer.int16ChannelData {
51
+ return Data(bytes: int16[0], count: frames * channels * 2)
52
+ }
53
+ guard let floats = buffer.floatChannelData else { return nil }
54
+ var out = [Int16](repeating: 0, count: frames * channels)
55
+ for ch in 0..<channels {
56
+ let src = floats[ch]
57
+ for i in 0..<frames {
58
+ let clipped = max(-1.0, min(1.0, src[i]))
59
+ out[i * channels + ch] = Int16(clipped * Float(Int16.max))
60
+ }
61
+ }
62
+ return out.withUnsafeBytes { Data($0) }
63
+ }
64
+ }
@@ -0,0 +1,17 @@
1
+ // swift-tools-version: 5.9
2
+ import PackageDescription
3
+
4
+ let package = Package(
5
+ name: "AcsAudioPolicyKit",
6
+ platforms: [
7
+ .macOS(.v13),
8
+ .iOS(.v15),
9
+ ],
10
+ products: [
11
+ .library(name: "AcsAudioPolicy", targets: ["AcsAudioPolicy"]),
12
+ ],
13
+ targets: [
14
+ .target(name: "AcsAudioPolicy"),
15
+ .testTarget(name: "AcsAudioPolicyTests", dependencies: ["AcsAudioPolicy"]),
16
+ ]
17
+ )
@@ -0,0 +1,102 @@
1
+ public enum AudioSourceKind: String, CaseIterable, Sendable {
2
+ case glasses
3
+ case phone
4
+ }
5
+
6
+ /// Mirrors (AudioStreamType, AudioStreamState). Attached-but-STOPPED reads as none.
7
+ public enum ActiveStreamKind: String, CaseIterable, Sendable {
8
+ case none
9
+ case virtual
10
+ case local
11
+ }
12
+
13
+ public enum PhysicalMuteAction: String, Sendable {
14
+ case mute
15
+ case unmute
16
+ case leaveAlone
17
+ }
18
+
19
+ public enum AudioSafety: String, Sendable {
20
+ case safe
21
+ case degraded
22
+ case unsafe
23
+ }
24
+
25
+ public struct AudioPolicyDecision: Equatable, Sendable {
26
+ public var glassesPcmEnabled: Bool
27
+ public var phonePcmEnabled: Bool
28
+ public var physicalMute: PhysicalMuteAction
29
+ }
30
+
31
+ public struct JoinAudioPlan: Equatable, Sendable {
32
+ public var armVirtual: Bool
33
+ public var transportMuted: Bool
34
+ }
35
+
36
+ public enum AcsAudioPolicy {
37
+ public static func decide(
38
+ desired: AudioSourceKind,
39
+ active: ActiveStreamKind,
40
+ userMuted: Bool
41
+ ) -> AudioPolicyDecision {
42
+ let glassesPcmEnabled = active == .virtual && desired == .glasses && !userMuted
43
+ let phonePcmEnabled = active == .virtual && desired == .phone && !userMuted
44
+ let physicalMute: PhysicalMuteAction
45
+ switch active {
46
+ case .virtual, .none:
47
+ physicalMute = .leaveAlone
48
+ case .local:
49
+ physicalMute = (desired == .phone && !userMuted) ? .unmute : .mute
50
+ }
51
+ return AudioPolicyDecision(
52
+ glassesPcmEnabled: glassesPcmEnabled,
53
+ phonePcmEnabled: phonePcmEnabled,
54
+ physicalMute: physicalMute
55
+ )
56
+ }
57
+
58
+ public static func planJoin(
59
+ desired: AudioSourceKind,
60
+ userMuted: Bool,
61
+ glassesRequiresUnmutedTransport: Bool
62
+ ) -> JoinAudioPlan {
63
+ // Phone and glasses both feed RawOutgoingAudioStream. Mute is PCM/capturer
64
+ // gating, never a LocalOutgoingAudioStream.
65
+ let armVirtual = true
66
+ let transportMuted: Bool
67
+ if desired == .glasses && glassesRequiresUnmutedTransport {
68
+ transportMuted = false
69
+ } else if desired == .phone {
70
+ transportMuted = false
71
+ } else {
72
+ transportMuted = userMuted
73
+ }
74
+ return JoinAudioPlan(armVirtual: armVirtual, transportMuted: transportMuted)
75
+ }
76
+
77
+ public static func expectedStream(_: AudioSourceKind) -> ActiveStreamKind {
78
+ .virtual
79
+ }
80
+
81
+ public static func parseSource(_ raw: String?) -> AudioSourceKind? {
82
+ switch raw {
83
+ case "glasses": return .glasses
84
+ case "phone": return .phone
85
+ default: return nil
86
+ }
87
+ }
88
+ }
89
+
90
+ public enum CallGuard {
91
+ public static func require<T>(_ value: T?) -> Result<T, Error> {
92
+ if let value {
93
+ return .success(value)
94
+ }
95
+ return .failure(CallMissingError())
96
+ }
97
+ }
98
+
99
+ public struct CallMissingError: Error, Equatable {
100
+ public init() {}
101
+ public var localizedDescription: String { "no call" }
102
+ }
@@ -0,0 +1,162 @@
1
+ public protocol AudioStreamController {
2
+ func readActive() -> ActiveStreamKind
3
+ func isPhysicallyMuted() -> Bool?
4
+ func setGlassesPcmEnabled(_ enabled: Bool)
5
+ func setPhonePcmEnabled(_ enabled: Bool)
6
+ func mutePhysical() -> Result<Void, Error>
7
+ func unmutePhysical() -> Result<Void, Error>
8
+ func stopActive() -> Result<Void, Error>
9
+ }
10
+
11
+ public protocol PolicyScheduler: AnyObject {
12
+ func schedule(delayMs: Int, task: @escaping () -> Void)
13
+ func cancelPending()
14
+ }
15
+
16
+ public final class AudioPolicyApplier {
17
+ public static let convergeDelaysMs = [50, 100, 200, 400, 800, 1600]
18
+
19
+ private let controller: AudioStreamController
20
+ private let scheduler: PolicyScheduler
21
+ private let log: (String) -> Void
22
+ private var generation: UInt64 = 0
23
+ private var lastDecision: AudioPolicyDecision?
24
+ private var lastSafetyValue: AudioSafety = .degraded
25
+
26
+ public init(controller: AudioStreamController, scheduler: PolicyScheduler, log: @escaping (String) -> Void = { _ in }) {
27
+ self.controller = controller
28
+ self.scheduler = scheduler
29
+ self.log = log
30
+ }
31
+
32
+ public func lastSafety() -> AudioSafety { lastSafetyValue }
33
+
34
+ @discardableResult
35
+ public func apply(desired: AudioSourceKind, userMuted: Bool, reason: String) -> AudioSafety {
36
+ generation += 1
37
+ scheduler.cancelPending()
38
+ return runApply(desired: desired, userMuted: userMuted, reason: reason, gen: generation, force: false, armConvergence: true)
39
+ }
40
+
41
+ public func reset() {
42
+ generation += 1
43
+ scheduler.cancelPending()
44
+ lastDecision = nil
45
+ lastSafetyValue = .degraded
46
+ }
47
+
48
+ @discardableResult
49
+ private func runApply(
50
+ desired: AudioSourceKind,
51
+ userMuted: Bool,
52
+ reason: String,
53
+ gen: UInt64,
54
+ force: Bool,
55
+ armConvergence: Bool
56
+ ) -> AudioSafety {
57
+ guard gen == generation else { return lastSafetyValue }
58
+ let active = controller.readActive()
59
+ let decision = AcsAudioPolicy.decide(desired: desired, active: active, userMuted: userMuted)
60
+ let outcome = applyDecision(decision, force: force)
61
+ guard gen == generation else { return lastSafetyValue }
62
+
63
+ let after = controller.readActive()
64
+ let expected = AcsAudioPolicy.expectedStream(desired)
65
+ let safety = safetyOf(desired: desired, active: after, outcome: outcome)
66
+ lastDecision = decision
67
+ lastSafetyValue = safety
68
+ log(
69
+ "audio policy reason=\(reason) source=\(desired) userMuted=\(userMuted) active=\(after) expected=\(expected) glassesPcm=\(decision.glassesPcmEnabled) phonePcm=\(decision.phonePcmEnabled) mute=\(decision.physicalMute) safety=\(safety)"
70
+ )
71
+
72
+ if after == expected {
73
+ scheduler.cancelPending()
74
+ } else if armConvergence && gen == generation {
75
+ self.armConvergence(desired: desired, userMuted: userMuted, gen: gen, expected: expected)
76
+ }
77
+ return lastSafetyValue
78
+ }
79
+
80
+ private func armConvergence(desired: AudioSourceKind, userMuted: Bool, gen: UInt64, expected: ActiveStreamKind) {
81
+ for (index, delayMs) in Self.convergeDelaysMs.enumerated() {
82
+ scheduler.schedule(delayMs: delayMs) { [weak self] in
83
+ guard let self, gen == self.generation else { return }
84
+ let isLast = index == Self.convergeDelaysMs.count - 1
85
+ _ = self.runApply(
86
+ desired: desired,
87
+ userMuted: userMuted,
88
+ reason: "converge-\(delayMs)ms",
89
+ gen: gen,
90
+ force: true,
91
+ armConvergence: false
92
+ )
93
+ if isLast && gen == self.generation && self.controller.readActive() != expected {
94
+ self.log("audio policy convergence timed out source=\(desired) expected=\(expected) holding silence")
95
+ self.lastSafetyValue = .degraded
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ private func applyDecision(_ decision: AudioPolicyDecision, force: Bool) -> EffectOutcome {
102
+ let previous = lastDecision
103
+ let skipGlassesPcm = !force && previous?.glassesPcmEnabled == decision.glassesPcmEnabled
104
+ let skipPhonePcm = !force && previous?.phonePcmEnabled == decision.phonePcmEnabled
105
+ let skipMute = !force && previous?.physicalMute == decision.physicalMute
106
+ if !skipGlassesPcm {
107
+ controller.setGlassesPcmEnabled(decision.glassesPcmEnabled)
108
+ }
109
+ if !skipPhonePcm {
110
+ controller.setPhonePcmEnabled(decision.phonePcmEnabled)
111
+ }
112
+ if skipMute { return .none }
113
+
114
+ switch decision.physicalMute {
115
+ case .leaveAlone:
116
+ return .none
117
+ case .unmute:
118
+ let result = controller.unmutePhysical()
119
+ let unmuted = controller.isPhysicallyMuted()
120
+ let failed = result.isFailure || unmuted == true
121
+ return EffectOutcome(unmuteAttempted: true, unmuteFailed: failed)
122
+ case .mute:
123
+ let result = controller.mutePhysical()
124
+ let muted = controller.isPhysicallyMuted()
125
+ let muteFailed = result.isFailure || muted == false
126
+ if !muteFailed { return EffectOutcome(muteAttempted: true) }
127
+ let stop = controller.stopActive()
128
+ let stopped = controller.readActive() == .none
129
+ return EffectOutcome(
130
+ muteAttempted: true,
131
+ muteFailed: true,
132
+ stopAttempted: true,
133
+ stopFailed: stop.isFailure || !stopped
134
+ )
135
+ }
136
+ }
137
+
138
+ private func safetyOf(desired: AudioSourceKind, active: ActiveStreamKind, outcome: EffectOutcome) -> AudioSafety {
139
+ if outcome.stopFailed { return .unsafe }
140
+ if outcome.stopAttempted { return .safe }
141
+ if outcome.unmuteFailed { return .degraded }
142
+ if active != AcsAudioPolicy.expectedStream(desired) { return .degraded }
143
+ return .safe
144
+ }
145
+ }
146
+
147
+ private struct EffectOutcome {
148
+ var muteAttempted = false
149
+ var muteFailed = false
150
+ var unmuteAttempted = false
151
+ var unmuteFailed = false
152
+ var stopAttempted = false
153
+ var stopFailed = false
154
+ static let none = EffectOutcome()
155
+ }
156
+
157
+ private extension Result {
158
+ var isFailure: Bool {
159
+ if case .failure = self { return true }
160
+ return false
161
+ }
162
+ }
@@ -0,0 +1,68 @@
1
+ import Foundation
2
+
3
+ public enum SourceKind: String, Sendable {
4
+ case whep
5
+ case direct
6
+ }
7
+
8
+ public enum SourceState: String, Sendable {
9
+ case idle
10
+ case connecting
11
+ case live
12
+ case failed
13
+ }
14
+
15
+ public struct SourceConfig: Equatable, Sendable {
16
+ public var url: String
17
+ public var kind: SourceKind
18
+
19
+ public init(url: String, kind: SourceKind = .whep) {
20
+ self.url = url
21
+ self.kind = kind
22
+ }
23
+ }
24
+
25
+ public protocol GlassesMediaSource: AnyObject {
26
+ var state: SourceState { get }
27
+ func start(config: SourceConfig)
28
+ func restart(config: SourceConfig)
29
+ func stop()
30
+ func setPcmDeliveryEnabled(_ enabled: Bool)
31
+ }
32
+
33
+ public final class GlassesMediaController {
34
+ private let factory: () -> GlassesMediaSource
35
+ private var source: GlassesMediaSource?
36
+
37
+ public init(factory: @escaping () -> GlassesMediaSource) {
38
+ self.factory = factory
39
+ }
40
+
41
+ public var state: SourceState { source?.state ?? .idle }
42
+
43
+ public func attach(config: SourceConfig) {
44
+ source?.stop()
45
+ let next = factory()
46
+ source = next
47
+ next.start(config: config)
48
+ }
49
+
50
+ public func restart(config: SourceConfig) {
51
+ source?.restart(config: config)
52
+ }
53
+
54
+ public func stop() {
55
+ source?.stop()
56
+ source = nil
57
+ }
58
+
59
+ public func setPcmDeliveryEnabled(_ enabled: Bool) {
60
+ source?.setPcmDeliveryEnabled(enabled)
61
+ }
62
+ }
63
+
64
+ public enum CapturePolicy {
65
+ public static func captureGlassesMic(_ source: AudioSourceKind) -> Bool {
66
+ source == .glasses
67
+ }
68
+ }
@@ -0,0 +1,273 @@
1
+ import XCTest
2
+ @testable import AcsAudioPolicy
3
+
4
+ final class AcsAudioPolicyTests: XCTestCase {
5
+ func testTwelveStates() {
6
+ let rows: [(AudioSourceKind, ActiveStreamKind, Bool, Bool, Bool, PhysicalMuteAction)] = [
7
+ (.glasses, .virtual, false, true, false, .leaveAlone),
8
+ (.glasses, .virtual, true, false, false, .leaveAlone),
9
+ (.glasses, .local, false, false, false, .mute),
10
+ (.glasses, .local, true, false, false, .mute),
11
+ (.glasses, .none, false, false, false, .leaveAlone),
12
+ (.glasses, .none, true, false, false, .leaveAlone),
13
+ (.phone, .local, false, false, false, .unmute),
14
+ (.phone, .local, true, false, false, .mute),
15
+ (.phone, .virtual, false, false, true, .leaveAlone),
16
+ (.phone, .virtual, true, false, false, .leaveAlone),
17
+ (.phone, .none, false, false, false, .leaveAlone),
18
+ (.phone, .none, true, false, false, .leaveAlone),
19
+ ]
20
+ XCTAssertEqual(rows.count, 12)
21
+ for row in rows {
22
+ let decision = AcsAudioPolicy.decide(desired: row.0, active: row.1, userMuted: row.2)
23
+ XCTAssertEqual(decision.glassesPcmEnabled, row.3, "\(row)")
24
+ XCTAssertEqual(decision.phonePcmEnabled, row.4, "\(row)")
25
+ XCTAssertEqual(decision.physicalMute, row.5, "\(row)")
26
+ }
27
+ }
28
+
29
+ func testCartesianInvariants() {
30
+ for desired in AudioSourceKind.allCases {
31
+ for active in ActiveStreamKind.allCases {
32
+ for userMuted in [false, true] {
33
+ let decision = AcsAudioPolicy.decide(desired: desired, active: active, userMuted: userMuted)
34
+ if decision.glassesPcmEnabled {
35
+ XCTAssertEqual(active, .virtual)
36
+ XCTAssertEqual(desired, .glasses)
37
+ XCTAssertFalse(userMuted)
38
+ }
39
+ if decision.phonePcmEnabled {
40
+ XCTAssertEqual(active, .virtual)
41
+ XCTAssertEqual(desired, .phone)
42
+ XCTAssertFalse(userMuted)
43
+ }
44
+ if decision.physicalMute == .unmute {
45
+ XCTAssertEqual(active, .local)
46
+ XCTAssertEqual(desired, .phone)
47
+ XCTAssertFalse(userMuted)
48
+ }
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ func testPlanJoinMatrix() {
55
+ for desired in AudioSourceKind.allCases {
56
+ for userMuted in [false, true] {
57
+ for flag in [false, true] {
58
+ let plan = AcsAudioPolicy.planJoin(
59
+ desired: desired,
60
+ userMuted: userMuted,
61
+ glassesRequiresUnmutedTransport: flag
62
+ )
63
+ XCTAssertTrue(plan.armVirtual)
64
+ if desired == .phone {
65
+ XCTAssertFalse(plan.transportMuted)
66
+ } else if flag {
67
+ XCTAssertFalse(plan.transportMuted)
68
+ } else {
69
+ XCTAssertEqual(plan.transportMuted, userMuted)
70
+ }
71
+ }
72
+ }
73
+ }
74
+ }
75
+
76
+ func testParseSourceAllowlist() {
77
+ XCTAssertEqual(AcsAudioPolicy.parseSource("glasses"), .glasses)
78
+ XCTAssertEqual(AcsAudioPolicy.parseSource("phone"), .phone)
79
+ XCTAssertNil(AcsAudioPolicy.parseSource("bluetooth"))
80
+ XCTAssertNil(AcsAudioPolicy.parseSource(nil))
81
+ }
82
+
83
+ func testCallGuardNullIsFailure() {
84
+ let missing: String? = nil
85
+ switch CallGuard.require(missing) {
86
+ case .success:
87
+ XCTFail("null call must not succeed")
88
+ case .failure:
89
+ break
90
+ }
91
+ }
92
+ }
93
+
94
+ final class FakeScheduler: PolicyScheduler {
95
+ struct Job {
96
+ var atMs: Int
97
+ var task: () -> Void
98
+ }
99
+
100
+ var nowMs = 0
101
+ private var jobs: [Job] = []
102
+
103
+ func schedule(delayMs: Int, task: @escaping () -> Void) {
104
+ jobs.append(Job(atMs: nowMs + delayMs, task: task))
105
+ }
106
+
107
+ func cancelPending() {
108
+ jobs.removeAll()
109
+ }
110
+
111
+ func advanceTo(_ targetMs: Int) {
112
+ while let nextIndex = jobs.enumerated().filter({ $0.element.atMs <= targetMs }).min(by: { $0.element.atMs < $1.element.atMs })?.offset {
113
+ let next = jobs.remove(at: nextIndex)
114
+ nowMs = next.atMs
115
+ next.task()
116
+ }
117
+ nowMs = targetMs
118
+ }
119
+ }
120
+
121
+ final class FakeController: AudioStreamController {
122
+ var active: ActiveStreamKind = .none
123
+ var physicallyMuted: Bool? = true
124
+ var glassesPcmEnabled = false
125
+ var phonePcmEnabled = false
126
+ var muteResult: Result<Void, Error> = .success(())
127
+ var unmuteResult: Result<Void, Error> = .success(())
128
+ var stopResult: Result<Void, Error> = .success(())
129
+ var muteSetsFlag = true
130
+ var muteCalls = 0
131
+ var unmuteCalls = 0
132
+ var stopCalls = 0
133
+
134
+ func readActive() -> ActiveStreamKind { active }
135
+ func isPhysicallyMuted() -> Bool? { physicallyMuted }
136
+ func setGlassesPcmEnabled(_ enabled: Bool) { glassesPcmEnabled = enabled }
137
+ func setPhonePcmEnabled(_ enabled: Bool) { phonePcmEnabled = enabled }
138
+ func mutePhysical() -> Result<Void, Error> {
139
+ muteCalls += 1
140
+ if case .success = muteResult, muteSetsFlag { physicallyMuted = true }
141
+ return muteResult
142
+ }
143
+ func unmutePhysical() -> Result<Void, Error> {
144
+ unmuteCalls += 1
145
+ if case .success = unmuteResult { physicallyMuted = false }
146
+ return unmuteResult
147
+ }
148
+ func stopActive() -> Result<Void, Error> {
149
+ stopCalls += 1
150
+ if case .success = stopResult { active = .none }
151
+ return stopResult
152
+ }
153
+ }
154
+
155
+ final class AudioPolicyApplierTests: XCTestCase {
156
+ func testNoneNoneVirtualOpensOnThirdObservation() {
157
+ let controller = FakeController()
158
+ let scheduler = FakeScheduler()
159
+ let applier = AudioPolicyApplier(controller: controller, scheduler: scheduler)
160
+ controller.active = .none
161
+ _ = applier.apply(desired: .glasses, userMuted: false, reason: "join")
162
+ XCTAssertFalse(controller.glassesPcmEnabled)
163
+ scheduler.advanceTo(50)
164
+ XCTAssertFalse(controller.glassesPcmEnabled)
165
+ controller.active = .virtual
166
+ scheduler.advanceTo(100)
167
+ XCTAssertTrue(controller.glassesPcmEnabled)
168
+ XCTAssertFalse(controller.phonePcmEnabled)
169
+ XCTAssertEqual(applier.lastSafety(), .safe)
170
+ XCTAssertEqual(controller.unmuteCalls, 0)
171
+ }
172
+
173
+ func testNoneNoneVirtualOpensPhonePcmOnThirdObservation() {
174
+ let controller = FakeController()
175
+ let scheduler = FakeScheduler()
176
+ let applier = AudioPolicyApplier(controller: controller, scheduler: scheduler)
177
+ controller.active = .none
178
+ _ = applier.apply(desired: .phone, userMuted: false, reason: "join")
179
+ XCTAssertFalse(controller.phonePcmEnabled)
180
+ scheduler.advanceTo(50)
181
+ XCTAssertFalse(controller.phonePcmEnabled)
182
+ controller.active = .virtual
183
+ scheduler.advanceTo(100)
184
+ XCTAssertTrue(controller.phonePcmEnabled)
185
+ XCTAssertFalse(controller.glassesPcmEnabled)
186
+ XCTAssertEqual(applier.lastSafety(), .safe)
187
+ XCTAssertEqual(controller.unmuteCalls, 0)
188
+ }
189
+
190
+ func testMuteFailureEscalatesToStop() {
191
+ let controller = FakeController()
192
+ let scheduler = FakeScheduler()
193
+ let applier = AudioPolicyApplier(controller: controller, scheduler: scheduler)
194
+ controller.active = .local
195
+ controller.physicallyMuted = false
196
+ controller.muteResult = .failure(CallMissingError())
197
+ let safety = applier.apply(desired: .phone, userMuted: true, reason: "set-muted")
198
+ XCTAssertEqual(controller.stopCalls, 1)
199
+ XCTAssertEqual(controller.active, .none)
200
+ XCTAssertEqual(safety, .safe)
201
+ }
202
+
203
+ func testApplyTwiceDoesNotRepeatUnmute() {
204
+ let controller = FakeController()
205
+ let scheduler = FakeScheduler()
206
+ let applier = AudioPolicyApplier(controller: controller, scheduler: scheduler)
207
+ controller.active = .local
208
+ controller.physicallyMuted = true
209
+ _ = applier.apply(desired: .phone, userMuted: false, reason: "join")
210
+ _ = applier.apply(desired: .phone, userMuted: false, reason: "call-connected")
211
+ XCTAssertEqual(controller.unmuteCalls, 1)
212
+ }
213
+ }
214
+
215
+ final class FakeGlassesMediaSource: GlassesMediaSource {
216
+ var started: [SourceConfig] = []
217
+ var restarted: [SourceConfig] = []
218
+ var stopped = 0
219
+ var pcmDeliveryEnabled = true
220
+ var pcmHandler: ((Data, Int, Int) -> Void)?
221
+ private(set) var state: SourceState = .idle
222
+
223
+ func start(config: SourceConfig) {
224
+ started.append(config)
225
+ state = .live
226
+ }
227
+
228
+ func restart(config: SourceConfig) {
229
+ restarted.append(config)
230
+ start(config: config)
231
+ }
232
+
233
+ func stop() {
234
+ stopped += 1
235
+ state = .idle
236
+ }
237
+
238
+ func setPcmDeliveryEnabled(_ enabled: Bool) {
239
+ pcmDeliveryEnabled = enabled
240
+ }
241
+
242
+ func emitPcm(_ data: Data) {
243
+ guard pcmDeliveryEnabled, state == .live else { return }
244
+ pcmHandler?(data, 16_000, 1)
245
+ }
246
+ }
247
+
248
+ final class GlassesMediaSourceTests: XCTestCase {
249
+ func testControllerStartRestartStopAndPcmGate() {
250
+ let source = FakeGlassesMediaSource()
251
+ var pcm: [Data] = []
252
+ source.pcmHandler = { data, _, _ in pcm.append(data) }
253
+ let controller = GlassesMediaController { source }
254
+ XCTAssertEqual(controller.state, .idle)
255
+ controller.attach(config: SourceConfig(url: "https://example.com/whep"))
256
+ XCTAssertEqual(source.started.map(\.url), ["https://example.com/whep"])
257
+ XCTAssertEqual(controller.state, .live)
258
+ source.emitPcm(Data([1, 2, 3, 4]))
259
+ XCTAssertEqual(pcm.count, 1)
260
+ controller.setPcmDeliveryEnabled(false)
261
+ source.emitPcm(Data([9, 9]))
262
+ XCTAssertEqual(pcm.count, 1)
263
+ controller.restart(config: SourceConfig(url: "https://example.com/whep-2"))
264
+ XCTAssertEqual(source.restarted.map(\.url), ["https://example.com/whep-2"])
265
+ controller.stop()
266
+ XCTAssertEqual(controller.state, .idle)
267
+ }
268
+
269
+ func testCapturePolicyMatchesAcsSource() {
270
+ XCTAssertTrue(CapturePolicy.captureGlassesMic(.glasses))
271
+ XCTAssertFalse(CapturePolicy.captureGlassesMic(.phone))
272
+ }
273
+ }