@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.
- package/NOTICE +20 -0
- package/README.md +412 -0
- package/THIRD_PARTY +7 -0
- package/android/build.gradle +64 -0
- package/android/src/main/AndroidManifest.xml +5 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +80 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +846 -0
- package/android/src/main/java/com/mentra/acsmeeting/RemoteRoster.kt +123 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +90 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/AudioPolicyApplier.kt +176 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/IncomingAudioPump.kt +112 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/IncomingRateProbe.kt +65 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +108 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/PcmResampler.kt +159 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/PhoneMicCapturer.kt +115 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +302 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +230 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/BitmapFont.kt +99 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +578 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/FirstFrameGate.kt +45 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +101 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/MediaListeners.kt +57 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticFrameFactory.kt +205 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticI420Source.kt +214 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +31 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +41 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/ChromaProbe.kt +78 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +281 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +51 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/ProcessCpu.kt +15 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +33 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +475 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsTimestamp.kt +26 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/FrameGeometry.kt +22 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +90 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/I420Packer.kt +72 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/Nv12FormatSpec.kt +36 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/Nv12Packer.kt +51 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/SendGate.kt +27 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +57 -0
- package/android/src/test/java/com/mentra/acsmeeting/RemoteRosterTest.kt +40 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/AcsAudioPolicyTest.kt +204 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/AudioPolicyApplierTest.kt +223 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +12 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/IncomingAudioPumpTest.kt +97 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/IncomingRateProbeTest.kt +78 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/PcmResamplerTest.kt +125 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +201 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +166 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +26 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/BitmapFontTest.kt +22 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/FirstFrameGateTest.kt +60 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +133 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/I420PlanesTest.kt +67 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticFrameFactoryTest.kt +193 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticI420SourceTest.kt +176 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +36 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/ChromaProbeTest.kt +61 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +256 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/ProcessCpuTest.kt +18 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/RingPercentileTest.kt +49 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +43 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/FrameGeometryTest.kt +33 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +77 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/I420PackerTest.kt +126 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/Nv12FormatSpecTest.kt +36 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/Nv12PackerTest.kt +58 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/SendGateTest.kt +79 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +68 -0
- package/build/AcsMeeting.types.d.ts +59 -0
- package/build/AcsMeeting.types.d.ts.map +1 -0
- package/build/AcsMeeting.types.js +2 -0
- package/build/AcsMeeting.types.js.map +1 -0
- package/build/AcsMeetingModule.d.ts +15 -0
- package/build/AcsMeetingModule.d.ts.map +1 -0
- package/build/AcsMeetingModule.js +3 -0
- package/build/AcsMeetingModule.js.map +1 -0
- package/build/AcsMeetingModule.web.d.ts +16 -0
- package/build/AcsMeetingModule.web.d.ts.map +1 -0
- package/build/AcsMeetingModule.web.js +31 -0
- package/build/AcsMeetingModule.web.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +3 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/AcsFrameSender.swift +47 -0
- package/ios/AcsMeeting.podspec +27 -0
- package/ios/AcsMeetingModule.swift +558 -0
- package/ios/PcmBridge.swift +117 -0
- package/ios/PhoneMicCapturer.swift +64 -0
- package/ios/PolicyKit/Package.swift +17 -0
- package/ios/PolicyKit/Sources/AcsAudioPolicy/AcsAudioPolicy.swift +102 -0
- package/ios/PolicyKit/Sources/AcsAudioPolicy/AudioPolicyApplier.swift +162 -0
- package/ios/PolicyKit/Sources/AcsAudioPolicy/GlassesMediaSource.swift +68 -0
- package/ios/PolicyKit/Tests/AcsAudioPolicyTests/AcsAudioPolicyTests.swift +273 -0
- package/ios/WhepVideoSource.swift +356 -0
- package/package.json +56 -0
- package/src/AcsMeeting.types.ts +66 -0
- package/src/AcsMeetingModule.ts +16 -0
- package/src/AcsMeetingModule.web.ts +33 -0
- 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
|
+
}
|