@mentra/acs-meeting 3.2.0-dev.222 → 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.
- package/README.md +14 -3
- package/android/build.gradle +1 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +10 -10
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +37 -37
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +4 -4
- package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +3 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +12 -10
- package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +2 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/Nv12Packer.kt +2 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/{AcsInvestigationTest.kt → MediaDiagnosticsTest.kt} +10 -10
- package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +3 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +2 -2
- package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +2 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +2 -2
- package/build/AcsMeeting.types.d.ts +10 -1
- package/build/AcsMeeting.types.d.ts.map +1 -1
- package/build/AcsMeeting.types.js.map +1 -1
- package/build/AcsMeetingModule.d.ts +2 -2
- package/build/AcsMeetingModule.d.ts.map +1 -1
- package/build/AcsMeetingModule.js.map +1 -1
- package/ios/AcsMeeting.podspec +1 -1
- package/ios/AcsMeetingModule.swift +284 -148
- package/ios/PolicyKit/Package.swift +2 -1
- package/ios/PolicyKit/Sources/AcsAudioPolicy/CallbackOperation.swift +60 -0
- package/ios/PolicyKit/Sources/AcsAudioPolicy/CapturePolicy.swift +5 -0
- package/ios/PolicyKit/Tests/AcsAudioPolicyTests/AcsAudioPolicyTests.swift +1 -0
- package/ios/PolicyKit/Tests/AcsAudioPolicyTests/CallAgentRetirementTests.swift +113 -0
- package/package.json +3 -2
- package/src/AcsMeeting.types.ts +4 -1
- package/src/AcsMeetingModule.ts +2 -2
- package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +0 -291
- package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +0 -501
- package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +0 -51
- package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +0 -81
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +0 -355
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +0 -109
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +0 -160
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +0 -103
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +0 -53
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +0 -127
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +0 -573
- package/android/src/main/java/com/mentra/acsmeeting/source/BitmapFont.kt +0 -99
- package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +0 -487
- package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +0 -147
- package/android/src/main/java/com/mentra/acsmeeting/source/FirstFrameGate.kt +0 -45
- package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +0 -143
- package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +0 -772
- package/android/src/main/java/com/mentra/acsmeeting/source/MediaListeners.kt +0 -57
- package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +0 -17
- package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +0 -139
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +0 -144
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +0 -46
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +0 -160
- package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticFrameFactory.kt +0 -205
- package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticI420Source.kt +0 -214
- package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +0 -33
- package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +0 -110
- package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +0 -207
- package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +0 -356
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +0 -155
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/ChromaProbe.kt +0 -78
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +0 -372
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +0 -79
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/ProcessCpu.kt +0 -15
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +0 -48
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +0 -178
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +0 -145
- package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +0 -143
- package/android/src/main/java/com/mentra/acsmeeting/video/FrameGeometry.kt +0 -22
- package/android/src/main/java/com/mentra/acsmeeting/video/I420Packer.kt +0 -72
- package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +0 -102
- package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +0 -148
- package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +0 -59
- package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +0 -59
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +0 -35
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +0 -165
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +0 -96
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +0 -187
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +0 -172
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +0 -141
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +0 -69
- package/android/src/test/java/com/mentra/acsmeeting/source/BitmapFontTest.kt +0 -22
- package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +0 -41
- package/android/src/test/java/com/mentra/acsmeeting/source/FirstFrameGateTest.kt +0 -60
- package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +0 -151
- package/android/src/test/java/com/mentra/acsmeeting/source/I420PlanesTest.kt +0 -67
- package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +0 -165
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +0 -148
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +0 -76
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +0 -275
- package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticFrameFactoryTest.kt +0 -193
- package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticI420SourceTest.kt +0 -176
- package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +0 -45
- package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +0 -356
- package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +0 -411
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +0 -83
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/ChromaProbeTest.kt +0 -61
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +0 -322
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/ProcessCpuTest.kt +0 -18
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/RingPercentileTest.kt +0 -49
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +0 -149
- package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +0 -97
- package/android/src/test/java/com/mentra/acsmeeting/video/FrameGeometryTest.kt +0 -33
- package/android/src/test/java/com/mentra/acsmeeting/video/I420PackerTest.kt +0 -126
- package/ios/PolicyKit/Sources/AcsAudioPolicy/GlassesMediaSource.swift +0 -68
- 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
|
-
}
|