@mentra/acs-meeting 3.2.0-dev.223 → 3.2.0-dev.225

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 (106) hide show
  1. package/README.md +14 -3
  2. package/android/build.gradle +1 -0
  3. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +10 -10
  4. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +37 -37
  5. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +4 -4
  6. package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +3 -0
  7. package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +12 -10
  8. package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +2 -0
  9. package/android/src/main/java/com/mentra/acsmeeting/video/Nv12Packer.kt +2 -0
  10. package/android/src/test/java/com/mentra/acsmeeting/source/{AcsInvestigationTest.kt → MediaDiagnosticsTest.kt} +10 -10
  11. package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +3 -0
  12. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +2 -2
  13. package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +2 -0
  14. package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +2 -2
  15. package/build/AcsMeeting.types.d.ts +10 -1
  16. package/build/AcsMeeting.types.d.ts.map +1 -1
  17. package/build/AcsMeeting.types.js.map +1 -1
  18. package/build/AcsMeetingModule.d.ts +2 -2
  19. package/build/AcsMeetingModule.d.ts.map +1 -1
  20. package/build/AcsMeetingModule.js.map +1 -1
  21. package/ios/AcsMeeting.podspec +1 -1
  22. package/ios/AcsMeetingModule.swift +284 -148
  23. package/ios/PolicyKit/Package.swift +2 -1
  24. package/ios/PolicyKit/Sources/AcsAudioPolicy/CallbackOperation.swift +60 -0
  25. package/ios/PolicyKit/Sources/AcsAudioPolicy/CapturePolicy.swift +5 -0
  26. package/ios/PolicyKit/Tests/AcsAudioPolicyTests/AcsAudioPolicyTests.swift +1 -0
  27. package/ios/PolicyKit/Tests/AcsAudioPolicyTests/CallAgentRetirementTests.swift +113 -0
  28. package/package.json +3 -2
  29. package/src/AcsMeeting.types.ts +4 -1
  30. package/src/AcsMeetingModule.ts +2 -2
  31. package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +0 -291
  32. package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +0 -501
  33. package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +0 -51
  34. package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +0 -81
  35. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +0 -355
  36. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +0 -109
  37. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +0 -160
  38. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +0 -103
  39. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +0 -53
  40. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +0 -127
  41. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +0 -573
  42. package/android/src/main/java/com/mentra/acsmeeting/source/BitmapFont.kt +0 -99
  43. package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +0 -487
  44. package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +0 -147
  45. package/android/src/main/java/com/mentra/acsmeeting/source/FirstFrameGate.kt +0 -45
  46. package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +0 -143
  47. package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +0 -772
  48. package/android/src/main/java/com/mentra/acsmeeting/source/MediaListeners.kt +0 -57
  49. package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +0 -17
  50. package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +0 -139
  51. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +0 -144
  52. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +0 -46
  53. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +0 -160
  54. package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticFrameFactory.kt +0 -205
  55. package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticI420Source.kt +0 -214
  56. package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +0 -33
  57. package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +0 -110
  58. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +0 -207
  59. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +0 -356
  60. package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +0 -155
  61. package/android/src/main/java/com/mentra/acsmeeting/telemetry/ChromaProbe.kt +0 -78
  62. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +0 -372
  63. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +0 -79
  64. package/android/src/main/java/com/mentra/acsmeeting/telemetry/ProcessCpu.kt +0 -15
  65. package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +0 -48
  66. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +0 -178
  67. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +0 -145
  68. package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +0 -143
  69. package/android/src/main/java/com/mentra/acsmeeting/video/FrameGeometry.kt +0 -22
  70. package/android/src/main/java/com/mentra/acsmeeting/video/I420Packer.kt +0 -72
  71. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +0 -102
  72. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +0 -148
  73. package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +0 -59
  74. package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +0 -59
  75. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +0 -35
  76. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +0 -165
  77. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +0 -96
  78. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +0 -187
  79. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +0 -172
  80. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +0 -141
  81. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +0 -69
  82. package/android/src/test/java/com/mentra/acsmeeting/source/BitmapFontTest.kt +0 -22
  83. package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +0 -41
  84. package/android/src/test/java/com/mentra/acsmeeting/source/FirstFrameGateTest.kt +0 -60
  85. package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +0 -151
  86. package/android/src/test/java/com/mentra/acsmeeting/source/I420PlanesTest.kt +0 -67
  87. package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +0 -165
  88. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +0 -148
  89. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +0 -76
  90. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +0 -275
  91. package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticFrameFactoryTest.kt +0 -193
  92. package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticI420SourceTest.kt +0 -176
  93. package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +0 -45
  94. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +0 -356
  95. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +0 -411
  96. package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +0 -83
  97. package/android/src/test/java/com/mentra/acsmeeting/telemetry/ChromaProbeTest.kt +0 -61
  98. package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +0 -322
  99. package/android/src/test/java/com/mentra/acsmeeting/telemetry/ProcessCpuTest.kt +0 -18
  100. package/android/src/test/java/com/mentra/acsmeeting/telemetry/RingPercentileTest.kt +0 -49
  101. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +0 -149
  102. package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +0 -97
  103. package/android/src/test/java/com/mentra/acsmeeting/video/FrameGeometryTest.kt +0 -33
  104. package/android/src/test/java/com/mentra/acsmeeting/video/I420PackerTest.kt +0 -126
  105. package/ios/PolicyKit/Sources/AcsAudioPolicy/GlassesMediaSource.swift +0 -68
  106. package/ios/WhepVideoSource.swift +0 -356
@@ -1,126 +0,0 @@
1
- package com.mentra.acsmeeting.video
2
-
3
- import org.assertj.core.api.Assertions.assertThat
4
- import org.junit.Test
5
- import java.nio.ByteBuffer
6
-
7
- class I420PackerTest {
8
- @Test
9
- fun percentileMsHandlesEmptyAndOrder() {
10
- assertThat(I420Packer.percentileMs(LongArray(0), 0.50)).isEqualTo("na")
11
- assertThat(I420Packer.percentileMs(longArrayOf(10_000_000, 20_000_000, 40_000_000), 0.50)).isEqualTo("20.0")
12
- }
13
-
14
- @Test
15
- fun packedSizeUsesChromaRounding() {
16
- assertThat(I420Packer.packedSize(4, 4)).isEqualTo(24)
17
- assertThat(I420Packer.packedSize(3, 3)).isEqualTo(3 * 3 + 2 * 2 * 2)
18
- assertThat(I420Packer.packedSize(1280, 720)).isEqualTo(1280 * 720 * 3 / 2)
19
- }
20
-
21
- @Test
22
- fun planeMinBytesIsLastRowPlusFullStrides() {
23
- assertThat(I420Packer.planeMinBytes(8, 8, 4)).isEqualTo(32)
24
- assertThat(I420Packer.planeMinBytes(16, 8, 4)).isEqualTo(16 * 3 + 8)
25
- assertThat(I420Packer.planeMinBytes(4, 8, 4)).isEqualTo(0)
26
- }
27
-
28
- @Test
29
- fun copyPlaneFromPaddedStrideWritesTightDest() {
30
- val width = 4
31
- val height = 4
32
- val padded = buildPlanes(width, height, strideY = 8, strideUv = 4, seed = 7)
33
- val dest = ByteBuffer.allocate(width * height)
34
- I420Packer.copyPlane(padded.y, 8, width, height, dest)
35
- dest.flip()
36
- val out = ByteArray(dest.remaining())
37
- dest.get(out)
38
- assertThat(out).containsExactly(
39
- 7, 8, 9, 10,
40
- 11, 12, 13, 14,
41
- 15, 16, 17, 18,
42
- 19, 20, 21, 22,
43
- )
44
- }
45
-
46
- @Test
47
- fun chromaStrideMatchesAcsI420Offer() {
48
- assertThat(I420Packer.chromaStride(1280)).isEqualTo(640)
49
- assertThat(I420Packer.chromaStride(3)).isEqualTo(2)
50
- }
51
-
52
- @Test
53
- fun paddedStridesPackIdenticallyToTightStrides() {
54
- val width = 4
55
- val height = 4
56
- val tight = buildPlanes(width, height, strideY = 4, strideUv = 2, seed = 7)
57
- val padded = buildPlanes(width, height, strideY = 8, strideUv = 4, seed = 7)
58
- val tightOut = ByteBuffer.allocate(I420Packer.packedSize(width, height))
59
- val paddedOut = ByteBuffer.allocate(I420Packer.packedSize(width, height))
60
- I420Packer.pack(
61
- tight.y, 4, tight.u, 2, tight.v, 2, width, height, tightOut,
62
- )
63
- I420Packer.pack(
64
- padded.y, 8, padded.u, 4, padded.v, 4, width, height, paddedOut,
65
- )
66
- assertThat(bytes(paddedOut)).isEqualTo(bytes(tightOut))
67
- assertThat(tightOut.remaining()).isEqualTo(I420Packer.packedSize(width, height))
68
- }
69
-
70
- @Test
71
- fun oddDimensionsPackExactBytes() {
72
- val width = 3
73
- val height = 3
74
- val planes = buildPlanes(width, height, strideY = 5, strideUv = 3, seed = 11)
75
- val dest = ByteBuffer.allocate(I420Packer.packedSize(width, height))
76
- I420Packer.pack(planes.y, 5, planes.u, 3, planes.v, 3, width, height, dest)
77
- val packed = bytes(dest)
78
- assertThat(packed).hasSize(3 * 3 + 2 * 2 * 2)
79
- // Visible Y is seed + row*width + col; chroma is 2x2 after (3+1)/2 rounding.
80
- assertThat(packed.copyOfRange(0, 9)).containsExactly(
81
- 11, 12, 13,
82
- 14, 15, 16,
83
- 17, 18, 19,
84
- )
85
- assertThat(packed.copyOfRange(9, 13)).containsExactly(31, 32, 33, 34)
86
- assertThat(packed.copyOfRange(13, 17)).containsExactly(41, 42, 43, 44)
87
- }
88
-
89
- private fun buildPlanes(
90
- width: Int,
91
- height: Int,
92
- strideY: Int,
93
- strideUv: Int,
94
- seed: Int,
95
- ): Planes {
96
- val chromaW = (width + 1) / 2
97
- val chromaH = (height + 1) / 2
98
- val y = ByteBuffer.allocate(strideY * height)
99
- val u = ByteBuffer.allocate(strideUv * chromaH)
100
- val v = ByteBuffer.allocate(strideUv * chromaH)
101
- for (row in 0 until height) {
102
- for (col in 0 until width) {
103
- y.put(row * strideY + col, (seed + row * width + col).toByte())
104
- }
105
- }
106
- for (row in 0 until chromaH) {
107
- for (col in 0 until chromaW) {
108
- u.put(row * strideUv + col, (seed + 20 + row * chromaW + col).toByte())
109
- v.put(row * strideUv + col, (seed + 30 + row * chromaW + col).toByte())
110
- }
111
- }
112
- y.rewind()
113
- u.rewind()
114
- v.rewind()
115
- return Planes(y, u, v)
116
- }
117
-
118
- private fun bytes(buffer: ByteBuffer): ByteArray {
119
- val copy = buffer.duplicate()
120
- val out = ByteArray(copy.remaining())
121
- copy.get(out)
122
- return out
123
- }
124
-
125
- private data class Planes(val y: ByteBuffer, val u: ByteBuffer, val v: ByteBuffer)
126
- }
@@ -1,68 +0,0 @@
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
- }
@@ -1,356 +0,0 @@
1
- import AVFoundation
2
- import CoreVideo
3
- import Foundation
4
- import WebRTC
5
-
6
- /// Recvonly WHEP subscriber. Decoded frames become CVPixelBuffers for ACS.
7
- /// Remote PCM is the P4 hard gate (RTCAudioRenderer on LiveKit/WebRTC-SDK 137).
8
- final class WhepVideoSource: NSObject, GlassesMediaSource {
9
- var onFrame: ((CVPixelBuffer) -> Void)?
10
- var onPcm: ((Data, Int, Int) -> Void)?
11
- /// Fires on every health transition. `.failed` is the one nothing else can see:
12
- /// ICE dropped or the WHEP endpoint went away while the ACS call stays connected.
13
- var onStateChange: ((SourceState, String) -> Void)?
14
- private(set) var state: SourceState = .idle
15
-
16
- private var factory: RTCPeerConnectionFactory?
17
- private var pc: RTCPeerConnection?
18
- private var currentUrl: String?
19
- private var offerPosted = false
20
- private var pendingOfferPost: DispatchWorkItem?
21
- // Fail closed like Android: the audio policy turns delivery on once the ACS
22
- // virtual stream is live. Survives restart() so a WHEP rebuild cannot silently
23
- // re-open the uplink against the current mute decision.
24
- private var pcmEnabled = false
25
- private var audioTracks: [RTCAudioTrack] = []
26
- private var attachedVideo: RTCVideoTrack?
27
- // Every peer generation gets its own id so a callback from a peer being torn
28
- // down (ICE failed racing a rebuild) cannot mark the new one failed.
29
- private var generation = 0
30
- // `.live` means "a frame reached the renderer", not "the WHEP endpoint
31
- // answered" — the answer lands seconds before ACS submits anything, and an
32
- // answer that never produces a frame used to read healthy forever behind a
33
- // frozen image. Armed per generation so a frame from a peer being torn down
34
- // cannot promote its replacement.
35
- private var firstFrameGeneration = -1
36
- private var firstFramePromoted = false
37
- private var firstFrameDeadline: DispatchWorkItem?
38
- private var frameCount = 0
39
- private var lastFpsLog = Date()
40
- private lazy var http: URLSession = {
41
- let config = URLSessionConfiguration.ephemeral
42
- config.timeoutIntervalForRequest = 20
43
- config.timeoutIntervalForResource = 20
44
- return URLSession(configuration: config)
45
- }()
46
-
47
- func start(config: SourceConfig) {
48
- start(whepUrl: config.url)
49
- }
50
-
51
- func start(whepUrl: String) {
52
- stop()
53
- currentUrl = whepUrl
54
- offerPosted = false
55
- generation += 1
56
- let gen = generation
57
- transition(.connecting, reason: "start")
58
- RTCInitializeSSL()
59
- let encoder = RTCDefaultVideoEncoderFactory()
60
- let decoder = RTCDefaultVideoDecoderFactory()
61
- factory = RTCPeerConnectionFactory(encoderFactory: encoder, decoderFactory: decoder)
62
- let config = RTCConfiguration()
63
- config.iceServers = [RTCIceServer(urlStrings: ["stun:stun.l.google.com:19302"])]
64
- let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
65
- guard let factory, let peer = factory.peerConnection(with: config, constraints: constraints, delegate: self) else {
66
- transition(.failed, reason: "peer_create_failed")
67
- return
68
- }
69
- pc = peer
70
- peer.addTransceiver(of: .video, init: RTCRtpTransceiverInit.recvOnly())
71
- peer.addTransceiver(of: .audio, init: RTCRtpTransceiverInit.recvOnly())
72
- peer.offer(for: constraints) { [weak self] sdp, error in
73
- guard let self, self.generation == gen else { return }
74
- guard let sdp, error == nil else {
75
- self.transition(.failed, reason: "offer_failed")
76
- return
77
- }
78
- peer.setLocalDescription(sdp) { [weak self] _ in
79
- // Track the delayed post so a same-instance restart can cancel it and
80
- // avoid publishing a stale peer's SDP before ICE finishes.
81
- let work = DispatchWorkItem { [weak self] in self?.postOfferIfNeeded() }
82
- self?.pendingOfferPost = work
83
- DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: work)
84
- }
85
- }
86
- }
87
-
88
- func restart(config: SourceConfig) {
89
- // Only reuse the existing subscriber when the URL is unchanged AND the peer
90
- // is still healthy. After ICE failed/disconnected (e.g. a Wi-Fi drop reusing
91
- // the same WHEP URL) we must rebuild, or glasses video and mic never recover.
92
- // `.connecting` is reusable and now spans answer → first frame; rebuilding
93
- // there would only restart a wait that is bounded by the deadline below.
94
- if config.url == currentUrl, state != .failed { return }
95
- start(config: config)
96
- }
97
-
98
- /// Rebuild on the current URL even when the peer looks healthy (phone changed
99
- /// networks; ICE may not have noticed the dead candidate pair yet).
100
- func forceRestart() {
101
- guard let url = currentUrl else { return }
102
- NSLog("ACS-SPIKE WHEP forced rebuild state=\(state.rawValue)")
103
- start(whepUrl: url)
104
- }
105
-
106
- func updateUrl(_ whepUrl: String) {
107
- restart(config: SourceConfig(url: whepUrl))
108
- }
109
-
110
- func setPcmDeliveryEnabled(_ enabled: Bool) {
111
- setPcmEnabled(enabled)
112
- }
113
-
114
- func setPcmEnabled(_ enabled: Bool) {
115
- pcmEnabled = enabled
116
- audioTracks.forEach { $0.isEnabled = enabled }
117
- NSLog("ACS-SPIKE WHEP audio track enabled=\(enabled)")
118
- }
119
-
120
- func stop() {
121
- currentUrl = nil
122
- offerPosted = false
123
- pendingOfferPost?.cancel()
124
- pendingOfferPost = nil
125
- // Disarmed before the renderer is removed, so a frame already in flight
126
- // cannot promote the source we are tearing down.
127
- cancelFirstFrameDeadline()
128
- firstFrameGeneration = -1
129
- firstFramePromoted = false
130
- audioTracks.removeAll()
131
- attachedVideo?.remove(self as RTCVideoRenderer)
132
- attachedVideo = nil
133
- // Invalidate callbacks from the peer being closed below before they can
134
- // observe the idle we are about to set.
135
- generation += 1
136
- transition(.idle, reason: "stop")
137
- // Drop our reference first so delegate callbacks fired by close() fail isCurrent().
138
- let peer = pc
139
- pc = nil
140
- peer?.close()
141
- }
142
-
143
- private func transition(_ next: SourceState, reason: String) {
144
- let previous = state
145
- state = next
146
- guard previous != next else { return }
147
- NSLog("ACS-SPIKE WHEP source \(previous.rawValue) -> \(next.rawValue) (\(reason))")
148
- onStateChange?(next, reason)
149
- }
150
-
151
- private func attachVideo(_ track: RTCVideoTrack) {
152
- // Unified Plan delivers the same remote video via didAdd stream and
153
- // didStartReceivingOn; rendering it twice doubles the frames handed to ACS.
154
- if let attachedVideo, attachedVideo === track { return }
155
- attachedVideo?.remove(self as RTCVideoRenderer)
156
- attachedVideo = track
157
- track.add(self as RTCVideoRenderer)
158
- }
159
-
160
- private func attachAudio(_ track: RTCAudioTrack) {
161
- // Unified Plan fires didAdd stream, didStartReceivingOn and add-track for
162
- // the same remote audio; only wire the renderer once or Teams hears doubled
163
- // PCM.
164
- if audioTracks.contains(where: { $0 === track }) {
165
- track.isEnabled = pcmEnabled
166
- return
167
- }
168
- audioTracks.append(track)
169
- track.isEnabled = pcmEnabled
170
- track.add(self as RTCAudioRenderer)
171
- }
172
-
173
- private func postOfferIfNeeded() {
174
- guard !offerPosted, let url = currentUrl, let peer = pc, let sdp = peer.localDescription?.sdp else { return }
175
- offerPosted = true
176
- postOffer(url: url, sdp: sdp, peer: peer, gen: generation)
177
- }
178
-
179
- private func postOffer(url: String, sdp: String, peer: RTCPeerConnection, gen: Int) {
180
- guard let endpoint = URL(string: url) else {
181
- transition(.failed, reason: "whep_bad_url")
182
- return
183
- }
184
- var request = URLRequest(url: endpoint)
185
- request.httpMethod = "POST"
186
- request.setValue("application/sdp", forHTTPHeaderField: "Content-Type")
187
- request.httpBody = sdp.data(using: .utf8)
188
- http.dataTask(with: request) { [weak self] data, response, error in
189
- guard let self, self.generation == gen else { return }
190
- let code = (response as? HTTPURLResponse)?.statusCode ?? -1
191
- NSLog("ACS-SPIKE P3 WHEP \(code) answer bytes=\(data?.count ?? 0)")
192
- guard error == nil, (200 ..< 300).contains(code),
193
- let data, let answer = String(data: data, encoding: .utf8) else {
194
- NSLog("ACS-SPIKE WHEP answer rejected code=\(code) error=\(error?.localizedDescription ?? "none")")
195
- self.transition(.failed, reason: error != nil ? "whep_post_failed" : "whep_http_\(code)")
196
- return
197
- }
198
- // Stay `.connecting`: setRemoteDescription, ICE and the decoder still have
199
- // to run before ACS is handed anything.
200
- self.armFirstFrame(gen: gen)
201
- let desc = RTCSessionDescription(type: .answer, sdp: answer)
202
- peer.setRemoteDescription(desc) { _ in }
203
- }.resume()
204
- }
205
-
206
- /// Arms the first-frame gate for `gen` and bounds the wait. An answered WHEP
207
- /// that never delivers is invisible to every other layer — ACS keeps the call
208
- /// up on a frozen frame — so it has to expire into `.failed` and let the
209
- /// module's rebuild backoff repair it.
210
- private func armFirstFrame(gen: Int) {
211
- firstFrameGeneration = gen
212
- firstFramePromoted = false
213
- cancelFirstFrameDeadline()
214
- let work = DispatchWorkItem { [weak self] in
215
- guard let self else { return }
216
- self.firstFrameDeadline = nil
217
- guard self.generation == gen, self.firstFrameGeneration == gen, !self.firstFramePromoted else { return }
218
- NSLog("ACS-SPIKE WHEP answered but delivered no frame in \(Self.firstFrameTimeout)s")
219
- self.transition(.failed, reason: "no_first_frame")
220
- }
221
- firstFrameDeadline = work
222
- DispatchQueue.main.asyncAfter(deadline: .now() + Self.firstFrameTimeout, execute: work)
223
- }
224
-
225
- private func cancelFirstFrameDeadline() {
226
- firstFrameDeadline?.cancel()
227
- firstFrameDeadline = nil
228
- }
229
-
230
- /// The one honest `.live`: a decoded frame is on its way to ACS.
231
- private func notePromotableFrame() {
232
- guard !firstFramePromoted, firstFrameGeneration == generation else { return }
233
- firstFramePromoted = true
234
- cancelFirstFrameDeadline()
235
- transition(.live, reason: "first_frame")
236
- }
237
-
238
- private func isCurrent(_ peerConnection: RTCPeerConnection) -> Bool {
239
- pc === peerConnection
240
- }
241
-
242
- /// Matches Android's FIRST_FRAME_TIMEOUT_MS: a measured answer → first frame
243
- /// gap is ~3 s, and a cold subscribe on a bad network is slower.
244
- private static let firstFrameTimeout: TimeInterval = 9
245
- }
246
-
247
- extension WhepVideoSource: RTCPeerConnectionDelegate {
248
- func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) {}
249
- func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) {
250
- guard isCurrent(peerConnection) else { return }
251
- stream.videoTracks.first.map(attachVideo)
252
- stream.audioTracks.first.map(attachAudio)
253
- }
254
- func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) {}
255
- func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) {}
256
- func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {
257
- NSLog("ACS-SPIKE ICE \(newState.rawValue)")
258
- guard isCurrent(peerConnection) else { return }
259
- if newState == .failed || newState == .disconnected {
260
- transition(.failed, reason: newState == .failed ? "ice_failed" : "ice_disconnected")
261
- } else if newState == .connected || newState == .completed {
262
- // ICE can bounce disconnected → connected on its own; only act once the
263
- // answer has been applied. A recovered candidate pair is a promise of
264
- // frames, not a frame, so go back to `.connecting` and let the next one
265
- // promote — immediately if media was already flowing, and if nothing
266
- // arrives the rearmed deadline fails us again for the rebuild backoff.
267
- if state == .failed, offerPosted {
268
- transition(.connecting, reason: "ice_recovered")
269
- armFirstFrame(gen: generation)
270
- }
271
- }
272
- }
273
- func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) {
274
- guard isCurrent(peerConnection) else { return }
275
- if newState == .complete { postOfferIfNeeded() }
276
- }
277
- func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) {}
278
- func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) {}
279
- func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) {}
280
- func peerConnection(_ peerConnection: RTCPeerConnection, didStartReceivingOn transceiver: RTCRtpTransceiver) {
281
- guard isCurrent(peerConnection) else { return }
282
- if let video = transceiver.receiver.track as? RTCVideoTrack {
283
- attachVideo(video)
284
- }
285
- if let audio = transceiver.receiver.track as? RTCAudioTrack {
286
- attachAudio(audio)
287
- }
288
- }
289
- }
290
-
291
- extension WhepVideoSource: RTCVideoRenderer {
292
- func setSize(_ size: CGSize) {}
293
- func renderFrame(_ frame: RTCVideoFrame?) {
294
- guard let frame, let buffer = frame.buffer as? RTCCVPixelBuffer else { return }
295
- notePromotableFrame()
296
- frameCount += 1
297
- let now = Date()
298
- if now.timeIntervalSince(lastFpsLog) >= 1 {
299
- NSLog("ACS-SPIKE P3 video \(Int(frame.width))x\(Int(frame.height)) fps=\(frameCount)")
300
- frameCount = 0
301
- lastFpsLog = now
302
- }
303
- onFrame?(buffer.pixelBuffer)
304
- }
305
- }
306
-
307
- extension WhepVideoSource: RTCAudioRenderer {
308
- func render(pcmBuffer: AVAudioPCMBuffer) {
309
- guard pcmEnabled else { return }
310
- let channels = Int(pcmBuffer.format.channelCount)
311
- let rate = Int(pcmBuffer.format.sampleRate)
312
- guard let data = Self.pcm16(from: pcmBuffer) else { return }
313
- onPcm?(data, rate, channels)
314
- }
315
- }
316
-
317
- private extension WhepVideoSource {
318
- static func pcm16(from buffer: AVAudioPCMBuffer) -> Data? {
319
- let channels = Int(buffer.format.channelCount)
320
- let frames = Int(buffer.frameLength)
321
- if frames == 0 || channels == 0 { return nil }
322
- var data = Data(count: frames * channels * 2)
323
- if let int16 = buffer.int16ChannelData {
324
- data.withUnsafeMutableBytes { raw in
325
- let dest = raw.bindMemory(to: Int16.self)
326
- for frame in 0 ..< frames {
327
- for channel in 0 ..< channels {
328
- dest[frame * channels + channel] = int16[channel][frame]
329
- }
330
- }
331
- }
332
- return data
333
- }
334
- if let float = buffer.floatChannelData {
335
- data.withUnsafeMutableBytes { raw in
336
- let dest = raw.bindMemory(to: Int16.self)
337
- for frame in 0 ..< frames {
338
- for channel in 0 ..< channels {
339
- let sample = max(-1, min(1, float[channel][frame]))
340
- dest[frame * channels + channel] = Int16(sample * Float(Int16.max))
341
- }
342
- }
343
- }
344
- return data
345
- }
346
- return nil
347
- }
348
- }
349
-
350
- private extension RTCRtpTransceiverInit {
351
- static func recvOnly() -> RTCRtpTransceiverInit {
352
- let value = RTCRtpTransceiverInit()
353
- value.direction = .recvOnly
354
- return value
355
- }
356
- }