@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,487 +0,0 @@
1
- package com.mentra.acsmeeting.source
2
-
3
- import android.content.Context
4
- import android.media.AudioAttributes
5
- import android.util.Log
6
- import com.mentra.acsmeeting.telemetry.PipelineStats
7
- import com.mentra.acsmeeting.video.I420Packer
8
- import okhttp3.MediaType.Companion.toMediaType
9
- import okhttp3.OkHttpClient
10
- import okhttp3.Request
11
- import okhttp3.RequestBody.Companion.toRequestBody
12
- import org.webrtc.AudioTrack
13
- import org.webrtc.DefaultVideoDecoderFactory
14
- import org.webrtc.DefaultVideoEncoderFactory
15
- import org.webrtc.EglBase
16
- import org.webrtc.IceCandidate
17
- import org.webrtc.MediaConstraints
18
- import org.webrtc.MediaStream
19
- import org.webrtc.PeerConnection
20
- import org.webrtc.PeerConnectionFactory
21
- import org.webrtc.RtpTransceiver
22
- import org.webrtc.SdpObserver
23
- import org.webrtc.SessionDescription
24
- import org.webrtc.VideoTrack
25
- import org.webrtc.audio.JavaAudioDeviceModule
26
- import java.nio.ByteBuffer
27
- import java.util.concurrent.CopyOnWriteArrayList
28
- import java.util.concurrent.TimeUnit
29
- import java.util.concurrent.atomic.AtomicLong
30
-
31
- /**
32
- * Recvonly WHEP subscriber. Decoded I420 planes are handed to ACS (no RGB
33
- * convert, no pack-then-split). Scale uses libyuv cropAndScale in buffer
34
- * coordinates. Remote AudioTrackSink PCM is the P4 hard gate.
35
- */
36
- class CloudflareWhepSource(
37
- private val context: Context,
38
- private val videoListener: VideoFrameListener,
39
- private val pcmListener: PcmListener,
40
- private val stats: PipelineStats = PipelineStats(),
41
- ) : GlassesMediaSource {
42
- private val http = OkHttpClient.Builder().callTimeout(20, TimeUnit.SECONDS).build()
43
- private var factory: PeerConnectionFactory? = null
44
- private var egl: EglBase? = null
45
- private var pc: PeerConnection? = null
46
- private var currentUrl: String? = null
47
- @Volatile private var offerPosted = false
48
- @Volatile override var state: SourceState = SourceState.IDLE
49
- private set
50
- @Volatile private var stateListener: SourceStateListener? = null
51
- // Every generation of the peer gets its own id so a callback from a disposed
52
- // PeerConnection (ICE FAILED racing a rebuild) cannot mark the new one failed.
53
- @Volatile private var generation = 0
54
- // attachAudio runs on the WebRTC signaling thread, setPcmDeliveryEnabled on the RN
55
- // bridge thread, stop() on the session thread: a plain list threw CME on mute toggles.
56
- private val audioTracks = CopyOnWriteArrayList<AudioTrack>()
57
- private val videoIds = TrackRegistry()
58
- private val audioIds = TrackRegistry()
59
- @Volatile private var attachedVideo: VideoTrack? = null
60
- // Shared with the SoftAP path: past the decoder, a Cloudflare hop and a hotspot hop are the
61
- // same I420 planes going to the same ACS sender.
62
- private val relay = DecodedTrackRelay(videoListener, pcmListener, stats) { notePromotableFrame() }
63
- private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
64
- private var pendingOfferPost: Runnable? = null
65
- // LIVE means "ACS is being handed frames", not "the WHEP endpoint answered".
66
- private val firstFrame = FirstFrameGate()
67
- @Volatile private var firstFrameDeadline: Runnable? = null
68
- private var lastDecodedFrames = -1L
69
- private var lastDecodedAtMs = 0L
70
- private var lastReceivedFrames = -1L
71
- private val statsPoll = object : Runnable {
72
- override fun run() {
73
- pollDecodedStats()
74
- mainHandler.postDelayed(this, STATS_POLL_MS)
75
- }
76
- }
77
-
78
- override fun start(config: SourceConfig) {
79
- stop()
80
- currentUrl = config.url
81
- offerPosted = false
82
- val gen = ++generation
83
- transition(SourceState.CONNECTING, "start")
84
- // Timer-driven, not sink-driven: a frame stall must still be sampled, or dec
85
- // freezes at a stale value during exactly the freezes we are chasing.
86
- mainHandler.removeCallbacks(statsPoll)
87
- mainHandler.postDelayed(statsPoll, STATS_POLL_MS)
88
- ensureFactory()
89
- val peer = factory!!.createPeerConnection(iceServers(), observerFor(gen)) ?: run {
90
- transition(SourceState.FAILED, "peer_create_failed")
91
- error("PeerConnection create failed")
92
- }
93
- pc = peer
94
- peer.addTransceiver(org.webrtc.MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO, recvOnly())
95
- peer.addTransceiver(org.webrtc.MediaStreamTrack.MediaType.MEDIA_TYPE_AUDIO, recvOnly())
96
- peer.createOffer(
97
- object : SdpAdapter() {
98
- override fun onCreateSuccess(sdp: SessionDescription) {
99
- peer.setLocalDescription(object : SdpAdapter() {
100
- override fun onSetSuccess() {
101
- val runnable = Runnable { postOfferIfNeeded() }
102
- pendingOfferPost = runnable
103
- mainHandler.postDelayed(runnable, 2000)
104
- }
105
- }, sdp)
106
- }
107
- },
108
- MediaConstraints(),
109
- )
110
- }
111
-
112
- override fun restart(config: SourceConfig) {
113
- // Reuse the existing subscriber only when the URL is unchanged AND the peer is
114
- // still healthy. After ICE DISCONNECTED/FAILED (e.g. a Wi-Fi drop that
115
- // reuses the same Cloudflare WHEP URL) we must rebuild, or glasses video and
116
- // mic never recover. See SourceReusePolicy for why CONNECTING is reusable.
117
- if (SourceReusePolicy.canReuse(currentUrl, state, config)) return
118
- start(config)
119
- }
120
-
121
- override fun forceRestart() {
122
- val url = currentUrl ?: return
123
- Log.i(TAG, "WHEP forced rebuild state=$state")
124
- start(SourceConfig(url))
125
- }
126
-
127
- override fun setStateListener(listener: SourceStateListener?) {
128
- stateListener = listener
129
- }
130
-
131
- private fun transition(next: SourceState, reason: String) {
132
- val previous = state
133
- state = next
134
- if (previous == next) return
135
- Log.i(TAG, "WHEP source $previous -> $next ($reason)")
136
- try {
137
- stateListener?.onSourceState(next, reason)
138
- } catch (error: Exception) {
139
- Log.w(TAG, "source state listener threw", error)
140
- }
141
- }
142
-
143
- override fun setPcmDeliveryEnabled(enabled: Boolean) {
144
- relay.setPcmDeliveryEnabled(enabled)
145
- Log.i(TAG, "WHEP audio PCM delivery enabled=$enabled")
146
- }
147
-
148
- override fun setTargetSize(size: TargetSize?) {
149
- relay.setTargetSize(size)
150
- }
151
-
152
- override fun stop() {
153
- currentUrl = null
154
- offerPosted = false
155
- pendingOfferPost?.let { mainHandler.removeCallbacks(it) }
156
- pendingOfferPost = null
157
- // Disarmed before removeSink, so a sink callback already in flight cannot
158
- // promote the source we are tearing down.
159
- cancelFirstFrameDeadline()
160
- firstFrame.reset()
161
- mainHandler.removeCallbacks(statsPoll)
162
- try {
163
- attachedVideo?.removeSink(videoSink)
164
- } catch (_: Exception) {
165
- }
166
- attachedVideo = null
167
- videoIds.reset()
168
- audioIds.reset()
169
- audioTracks.clear()
170
- // Invalidate callbacks from the peer being disposed below before they can
171
- // observe the IDLE we are about to set.
172
- generation++
173
- transition(SourceState.IDLE, "stop")
174
- lastDecodedFrames = -1L
175
- lastDecodedAtMs = 0L
176
- lastReceivedFrames = -1L
177
- relay.resetRotationLog()
178
- // close() stops media; dispose() is what frees the native peer. Without it every
179
- // WHEP restart leaks a PeerConnection. dispose() also blocks until observer and
180
- // sink callbacks quiesce, so it must run after removeSink and before dest is reused.
181
- val peer = pc
182
- pc = null
183
- try {
184
- peer?.close()
185
- peer?.dispose()
186
- } catch (error: Exception) {
187
- Log.w(TAG, "WHEP peer dispose failed", error)
188
- }
189
- }
190
-
191
- private fun ensureFactory() {
192
- if (factory != null) return
193
- PeerConnectionFactory.initialize(
194
- PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions(),
195
- )
196
- val mode = AcsInvestigation.decoderMode
197
- val shared = if (mode == DecoderMode.TEXTURE) {
198
- EglBase.create().also { egl = it }.eglBaseContext
199
- } else {
200
- null
201
- }
202
- // libwebrtc renders every received audio track through its device module.
203
- // That is the wearer's own mic coming back out of the phone (or, over
204
- // A2DP, the glasses) — the feedback loop. The mixer must still run so the
205
- // AudioTrackSink gets PCM, so we keep playout but make it inert: media
206
- // attributes (never a voice-call route) and per-track volume 0 below.
207
- val adm = JavaAudioDeviceModule.builder(context)
208
- .setAudioAttributes(
209
- AudioAttributes.Builder()
210
- .setUsage(AudioAttributes.USAGE_MEDIA)
211
- .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
212
- .build(),
213
- )
214
- .setUseHardwareAcousticEchoCanceler(false)
215
- .setUseHardwareNoiseSuppressor(false)
216
- .setAudioTrackStateCallback(object : JavaAudioDeviceModule.AudioTrackStateCallback {
217
- override fun onWebRtcAudioTrackStart() {
218
- Log.i(TAG, "WHEP ADM playout started (silenced; sink-only)")
219
- }
220
-
221
- override fun onWebRtcAudioTrackStop() {
222
- Log.i(TAG, "WHEP ADM playout stopped")
223
- }
224
- })
225
- .setAudioRecordStateCallback(object : JavaAudioDeviceModule.AudioRecordStateCallback {
226
- override fun onWebRtcAudioRecordStart() {
227
- // recv-only transceivers must never open the phone microphone.
228
- Log.e(TAG, "WHEP ADM started RECORDING — unexpected phone mic capture")
229
- }
230
-
231
- override fun onWebRtcAudioRecordStop() {
232
- Log.i(TAG, "WHEP ADM recording stopped")
233
- }
234
- })
235
- .createAudioDeviceModule()
236
- // Belt and braces with the per-track volume: the device module zeroes
237
- // its playout buffer, and recording is disabled outright.
238
- adm.setSpeakerMute(true)
239
- adm.setAudioRecordEnabled(false)
240
- factory = PeerConnectionFactory.builder()
241
- .setAudioDeviceModule(adm)
242
- .setVideoEncoderFactory(DefaultVideoEncoderFactory(shared, true, true))
243
- .setVideoDecoderFactory(DefaultVideoDecoderFactory(shared))
244
- .createPeerConnectionFactory()
245
- adm.release()
246
- Log.i(TAG, "P3 factory decoderMode=$mode egl=${shared != null} adm=media-silent")
247
- }
248
-
249
- @Synchronized
250
- private fun postOfferIfNeeded() {
251
- if (offerPosted) return
252
- val url = currentUrl ?: return
253
- val peer = pc ?: return
254
- val offer = peer.localDescription?.description ?: return
255
- offerPosted = true
256
- postOffer(url, offer, peer, generation)
257
- }
258
-
259
- private fun postOffer(url: String, offer: String, peer: PeerConnection, gen: Int) {
260
- val request = Request.Builder()
261
- .url(url)
262
- .header("Content-Type", "application/sdp")
263
- .post(offer.toRequestBody("application/sdp".toMediaType()))
264
- .build()
265
- http.newCall(request).enqueue(object : okhttp3.Callback {
266
- override fun onFailure(call: okhttp3.Call, e: java.io.IOException) {
267
- if (gen != generation) return
268
- Log.e(TAG, "WHEP POST failed", e)
269
- transition(SourceState.FAILED, "whep_post_failed:${e.javaClass.simpleName}")
270
- }
271
-
272
- override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) {
273
- response.use {
274
- val answer = it.body?.string().orEmpty()
275
- if (gen != generation) return
276
- if (!it.isSuccessful) {
277
- Log.e(TAG, "WHEP ${it.code}: $answer")
278
- transition(SourceState.FAILED, "whep_http_${it.code}")
279
- return
280
- }
281
- Log.i(TAG, "P3 WHEP ${it.code} answer bytes=${answer.length}")
282
- // Stay CONNECTING: setRemoteDescription, ICE and the decoder still have
283
- // to run, and on device that is 3-6 s before ACS submits a frame.
284
- armFirstFrame(gen)
285
- peer.setRemoteDescription(
286
- SdpAdapter(),
287
- SessionDescription(SessionDescription.Type.ANSWER, answer),
288
- )
289
- }
290
- }
291
- })
292
- }
293
-
294
- private fun observerFor(gen: Int) = object : PeerConnection.Observer {
295
- override fun onSignalingChange(state: PeerConnection.SignalingState) {}
296
- override fun onIceConnectionChange(state: PeerConnection.IceConnectionState) {
297
- Log.i(TAG, "ICE $state")
298
- if (gen != generation) return
299
- if (state == PeerConnection.IceConnectionState.FAILED ||
300
- state == PeerConnection.IceConnectionState.DISCONNECTED
301
- ) {
302
- transition(SourceState.FAILED, "ice_${state.name.lowercase()}")
303
- } else if (state == PeerConnection.IceConnectionState.CONNECTED ||
304
- state == PeerConnection.IceConnectionState.COMPLETED
305
- ) {
306
- // ICE can bounce DISCONNECTED → CONNECTED on its own (consent freshness
307
- // hiccup); only act if the answer already arrived. LIVE now means a frame
308
- // reached the sink and a recovered candidate pair is only a promise of
309
- // one, so go back to CONNECTING and let the next frame promote. Frames
310
- // that were already flowing promote immediately; if none arrive the
311
- // rearmed deadline fails us again and the session's rebuild backoff runs.
312
- if (this@CloudflareWhepSource.state == SourceState.FAILED && offerPosted) {
313
- transition(SourceState.CONNECTING, "ice_recovered")
314
- armFirstFrame(gen)
315
- }
316
- }
317
- }
318
- override fun onIceConnectionReceivingChange(receiving: Boolean) {}
319
- override fun onIceGatheringChange(state: PeerConnection.IceGatheringState) {
320
- Log.i(TAG, "ICE gathering $state")
321
- if (gen != generation) return
322
- if (state == PeerConnection.IceGatheringState.COMPLETE) postOfferIfNeeded()
323
- }
324
- override fun onIceCandidate(candidate: IceCandidate) {}
325
- override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>) {}
326
- // Unified Plan + recv-only transceivers: onTrack is the only attach path.
327
- // onAddStream / onAddTrack still fire for the same track and used to triple-addSink.
328
- override fun onAddStream(stream: MediaStream) {}
329
- override fun onRemoveStream(stream: MediaStream) {}
330
- override fun onDataChannel(channel: org.webrtc.DataChannel) {}
331
- override fun onRenegotiationNeeded() {}
332
- override fun onAddTrack(receiver: org.webrtc.RtpReceiver, streams: Array<out MediaStream>) {}
333
- override fun onTrack(transceiver: RtpTransceiver) {
334
- if (gen != generation) return
335
- when (val track = transceiver.receiver.track()) {
336
- is VideoTrack -> attachVideo(track)
337
- is AudioTrack -> attachAudio(track)
338
- }
339
- }
340
- }
341
-
342
- private fun attachVideo(track: VideoTrack) {
343
- val id = track.id()
344
- if (!videoIds.claim(id)) {
345
- stats.onDup()
346
- Log.i(TAG, "P3 attach kind=video id=$id attached=${videoIds.size()} skipped=${videoIds.skipped()}")
347
- return
348
- }
349
- attachedVideo = track
350
- track.addSink(videoSink)
351
- Log.i(TAG, "P3 attach kind=video id=$id attached=${videoIds.size()} skipped=${videoIds.skipped()}")
352
- }
353
-
354
- /**
355
- * Arms the first-frame gate for [gen] and bounds the wait.
356
- *
357
- * An answered WHEP that never delivers a frame used to read LIVE forever
358
- * behind a frozen image, with nothing above this layer able to see it.
359
- * Expiring into FAILED hands it to [SourceStateListener], whose rebuild
360
- * backoff is the only thing that can repair it.
361
- */
362
- private fun armFirstFrame(gen: Int) {
363
- firstFrame.arm(gen)
364
- cancelFirstFrameDeadline()
365
- val task = Runnable {
366
- firstFrameDeadline = null
367
- if (gen != generation || !firstFrame.expired(gen)) return@Runnable
368
- Log.w(TAG, "WHEP answered but delivered no frame in ${FIRST_FRAME_TIMEOUT_MS}ms")
369
- transition(SourceState.FAILED, "no_first_frame")
370
- }
371
- firstFrameDeadline = task
372
- mainHandler.postDelayed(task, FIRST_FRAME_TIMEOUT_MS)
373
- }
374
-
375
- private fun cancelFirstFrameDeadline() {
376
- firstFrameDeadline?.let { mainHandler.removeCallbacks(it) }
377
- firstFrameDeadline = null
378
- }
379
-
380
- /** The one honest LIVE: a decoded frame is on its way to ACS. */
381
- private fun notePromotableFrame() {
382
- if (!firstFrame.onFrame(generation)) return
383
- cancelFirstFrameDeadline()
384
- transition(SourceState.LIVE, "first_frame")
385
- }
386
-
387
- private val videoSink get() = relay.videoSink
388
-
389
- private fun attachAudio(track: AudioTrack) {
390
- val id = track.id()
391
- if (!audioIds.claim(id)) {
392
- stats.onDup()
393
- Log.i(TAG, "P3 attach kind=audio id=$id attached=${audioIds.size()} skipped=${audioIds.skipped()}")
394
- return
395
- }
396
- audioTracks.add(track)
397
- // Output gain is applied after the raw sink callback in ChannelReceive, so
398
- // volume 0 silences local playout while the sink still gets full-scale PCM.
399
- track.setVolume(0.0)
400
- track.setEnabled(true)
401
- track.addSink(relay.audioSink)
402
- Log.i(TAG, "P3 attach kind=audio id=$id attached=${audioIds.size()} skipped=${audioIds.skipped()} playoutVolume=0")
403
- }
404
-
405
- private fun pollDecodedStats() {
406
- val peer = pc ?: return
407
- peer.getStats { report ->
408
- for (statsReport in report.statsMap.values) {
409
- if (statsReport.type != "inbound-rtp") continue
410
- if (statsReport.members["kind"]?.toString() != "video") continue
411
- val members = statsReport.members
412
- val decoded = (members["framesDecoded"] as? Number)?.toLong() ?: continue
413
- val received = (members["framesReceived"] as? Number)?.toLong() ?: -1L
414
- val now = System.currentTimeMillis()
415
- if (lastDecodedFrames >= 0 && lastDecodedAtMs > 0) {
416
- val dt = now - lastDecodedAtMs
417
- if (dt > 0) {
418
- stats.decodedFps = (decoded - lastDecodedFrames) * 1000.0 / dt
419
- if (received >= 0 && lastReceivedFrames >= 0) {
420
- stats.recvFps = (received - lastReceivedFrames) * 1000.0 / dt
421
- }
422
- }
423
- }
424
- lastDecodedFrames = decoded
425
- lastReceivedFrames = received
426
- lastDecodedAtMs = now
427
- // Receive-side disposition. Separates "the network never delivered it"
428
- // (packetsLost/nack/pli, recv < 15) from "we got it and stalled"
429
- // (recv ~15 but dropped/freeze climbing).
430
- stats.setRecvHealth(
431
- PipelineStats.RecvHealth(
432
- assembled = (members["framesAssembledFromMultiplePackets"] as? Number)?.toLong() ?: -1L,
433
- dropped = (members["framesDropped"] as? Number)?.toLong() ?: -1L,
434
- packetsLost = (members["packetsLost"] as? Number)?.toLong() ?: -1L,
435
- nack = (members["nackCount"] as? Number)?.toLong() ?: -1L,
436
- pli = (members["pliCount"] as? Number)?.toLong() ?: -1L,
437
- freezes = (members["freezeCount"] as? Number)?.toLong() ?: -1L,
438
- freezeSec = (members["totalFreezesDuration"] as? Number)?.toDouble() ?: -1.0,
439
- jitter = (members["jitter"] as? Number)?.toDouble() ?: -1.0,
440
- decodeSec = (members["totalDecodeTime"] as? Number)?.toDouble() ?: -1.0,
441
- jitterBufferSec = (members["jitterBufferDelay"] as? Number)?.toDouble() ?: -1.0,
442
- jitterBufferEmits = (members["jitterBufferEmittedCount"] as? Number)?.toLong() ?: -1L,
443
- decImpl = members["decoderImplementation"]?.toString().orEmpty(),
444
- ),
445
- )
446
- return@getStats
447
- }
448
- }
449
- }
450
-
451
- private fun recvOnly() = RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.RECV_ONLY)
452
-
453
- private fun iceServers() = PeerConnection.RTCConfiguration(
454
- listOf(PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()),
455
- )
456
-
457
- companion object {
458
- private const val TAG = "ACS-SPIKE"
459
- private const val STATS_POLL_MS = 1000L
460
-
461
- /**
462
- * How long a WHEP answer has to produce its first frame. Measured on an S22
463
- * reconnect the gap is ~3.2 s (answer 15:56:47.16 → first frame 15:56:50.36);
464
- * a cold subscribe on a bad network is slower, so this is deliberately loose.
465
- * Too tight rebuilds a subscriber that was about to work.
466
- */
467
- private const val FIRST_FRAME_TIMEOUT_MS = 9_000L
468
-
469
- fun i420PackedSize(width: Int, height: Int) = I420Packer.packedSize(width, height)
470
-
471
- fun packI420(
472
- dataY: ByteBuffer,
473
- strideY: Int,
474
- dataU: ByteBuffer,
475
- strideU: Int,
476
- dataV: ByteBuffer,
477
- strideV: Int,
478
- width: Int,
479
- height: Int,
480
- dest: ByteBuffer,
481
- ) = I420Packer.pack(dataY, strideY, dataU, strideU, dataV, strideV, width, height, dest)
482
-
483
- fun percentileMs(samplesNs: LongArray, quantile: Double) = I420Packer.percentileMs(samplesNs, quantile)
484
- }
485
- }
486
-
487
- typealias WhepVideoSource = CloudflareWhepSource
@@ -1,147 +0,0 @@
1
- package com.mentra.acsmeeting.source
2
-
3
- import android.util.Log
4
- import com.mentra.acsmeeting.telemetry.PipelineStats
5
- import com.mentra.acsmeeting.video.FrameGeometry
6
- import org.webrtc.AudioTrackSink
7
- import org.webrtc.VideoFrame
8
- import org.webrtc.VideoSink
9
- import java.util.concurrent.atomic.AtomicBoolean
10
- import java.util.concurrent.atomic.AtomicReference
11
-
12
- /**
13
- * Hands decoded WebRTC tracks to ACS.
14
- *
15
- * Extracted from [CloudflareWhepSource] so the SoftAP path shares it rather than copying it. The
16
- * scaling and geometry handling here is subtle enough that two copies would drift: buffer
17
- * coordinates rather than display coordinates, crop-and-scale before `toI420` so libyuv does the
18
- * work, and a retain/release pair the ACS sender owns. Whether the bitstream arrived from Cloudflare
19
- * or straight off the hotspot makes no difference past the decoder, so neither should the code.
20
- *
21
- * Thread confinement: [videoSink] and [audioSink] are called on libwebrtc's decode and audio
22
- * threads. Nothing here blocks, and the mutable settings are atomics because they are written from
23
- * the session thread.
24
- */
25
- class DecodedTrackRelay(
26
- private val videoListener: VideoFrameListener,
27
- private val pcmListener: PcmListener,
28
- private val stats: PipelineStats,
29
- /** Called on the first frame of each generation so the owner can promote itself to LIVE. */
30
- private val onPromotableFrame: () -> Unit,
31
- ) {
32
- private val targetSize = AtomicReference<TargetSize?>(null)
33
- private val rotationLogged = AtomicBoolean(false)
34
-
35
- /**
36
- * Fail closed. The audio policy turns delivery on once the ACS virtual stream is live, and this
37
- * survives a transport rebuild so a reconnect cannot silently re-open the uplink against a
38
- * decision that has since changed.
39
- */
40
- @Volatile
41
- private var pcmEnabled = false
42
-
43
- fun setTargetSize(size: TargetSize?) = targetSize.set(size)
44
-
45
- fun setPcmDeliveryEnabled(enabled: Boolean) {
46
- pcmEnabled = enabled
47
- }
48
-
49
- fun resetRotationLog() = rotationLogged.set(false)
50
-
51
- val videoSink = VideoSink { frame ->
52
- val sinkStart = System.nanoTime()
53
- onPromotableFrame()
54
- stats.onSink()
55
- stats.recordGap()
56
- // Guarded because a synthetic or malformed frame can carry 0, and an "age" measured from the
57
- // epoch would swamp the percentile ring with one absurd sample.
58
- if (frame.timestampNs > 0) {
59
- stats.age.record(sinkStart - frame.timestampNs)
60
- }
61
- val buffer = frame.buffer
62
- stats.onFrameBuffer(classifyBuffer(buffer))
63
- val geometry = FrameGeometry.packSize(buffer.width, buffer.height, frame.rotation)
64
- if (geometry.rotationNonZero) {
65
- stats.onRotation()
66
- if (rotationLogged.compareAndSet(false, true)) {
67
- Log.w(
68
- TAG,
69
- "P3 rotation=${frame.rotation} using buffer ${geometry.width}x${geometry.height} " +
70
- "(rotatedWidth=${frame.rotatedWidth}x${frame.rotatedHeight} would overrun I420 planes)",
71
- )
72
- }
73
- }
74
- val target = targetSize.get()
75
- var scaled: VideoFrame.Buffer? = null
76
- val source = if (needsScale(target, geometry.width, geometry.height)) {
77
- val scaleStart = System.nanoTime()
78
- scaled = buffer.cropAndScale(0, 0, geometry.width, geometry.height, target!!.width, target.height)
79
- stats.scale.record(System.nanoTime() - scaleStart)
80
- scaled
81
- } else {
82
- buffer
83
- }
84
- val i420Start = System.nanoTime()
85
- val i420 = source.toI420()
86
- stats.toI420.record(System.nanoTime() - i420Start)
87
- if (i420 == null) {
88
- stats.onDropNullI420()
89
- scaled?.release()
90
- stats.sinkCb.record(System.nanoTime() - sinkStart)
91
- return@VideoSink
92
- }
93
- try {
94
- val w = i420.width
95
- val h = i420.height
96
- stats.onStrides(i420.strideY, i420.strideU, i420.strideV, w)
97
- stats.setSize(w, h)
98
- videoListener.onVideoFrame(
99
- I420Planes(
100
- y = i420.dataY,
101
- strideY = i420.strideY,
102
- u = i420.dataU,
103
- strideU = i420.strideU,
104
- v = i420.dataV,
105
- strideV = i420.strideV,
106
- width = w,
107
- height = h,
108
- timestampNs = frame.timestampNs,
109
- retain = { i420.retain() },
110
- release = { i420.release() },
111
- ),
112
- )
113
- } finally {
114
- i420.release()
115
- scaled?.release()
116
- stats.sinkCb.record(System.nanoTime() - sinkStart)
117
- }
118
- }
119
-
120
- val audioSink = AudioTrackSink { audioData, bitsPerSample, sampleRate, numberOfChannels, _, _ ->
121
- if (!pcmEnabled || bitsPerSample != 16) return@AudioTrackSink
122
- val bytes = ByteArray(audioData.remaining())
123
- audioData.get(bytes)
124
- pcmListener.onPcm(bytes, sampleRate, numberOfChannels)
125
- }
126
-
127
- companion object {
128
- private const val TAG = "ACS-SPIKE"
129
-
130
- /**
131
- * Whether a frame needs resizing before ACS sees it.
132
- *
133
- * Split out and made pure because it decides whether libyuv runs on every single frame, and
134
- * because getting it backwards is invisible: an unnecessary scale is a silent CPU cost, and a
135
- * skipped one is a plane-size mismatch at the sender. A null target means ACS has not
136
- * negotiated a size yet, so the buffer passes through untouched.
137
- */
138
- fun needsScale(target: TargetSize?, width: Int, height: Int): Boolean =
139
- target != null && (target.width != width || target.height != height)
140
-
141
- fun classifyBuffer(buffer: VideoFrame.Buffer): String = when (buffer) {
142
- is VideoFrame.TextureBuffer -> "tex"
143
- is VideoFrame.I420Buffer -> "i420"
144
- else -> "other"
145
- }
146
- }
147
- }
@@ -1,45 +0,0 @@
1
- package com.mentra.acsmeeting.source
2
-
3
- /**
4
- * Guards the promotion to [SourceState.LIVE] on the first video frame.
5
- *
6
- * `LIVE` used to fire on the WHEP answer, which is three hops and several
7
- * seconds before ACS is handed a frame — long enough that Mentra Call told the
8
- * wearer video was restored 7.5 s before Teams got one. It now means a frame
9
- * reached the sink, and an answer that never produces one expires instead of
10
- * reading healthy forever behind a frozen last frame.
11
- *
12
- * Armed per peer generation so a frame — or a deadline — belonging to a peer
13
- * being torn down can neither promote nor fail its replacement.
14
- */
15
- internal class FirstFrameGate {
16
- private var armedGeneration = -1
17
- // Read on every sink callback; the slow path takes the lock.
18
- @Volatile private var promoted = false
19
-
20
- /** The answer landed (or ICE came back): frames are promised, not delivered. */
21
- @Synchronized fun arm(generation: Int) {
22
- armedGeneration = generation
23
- promoted = false
24
- }
25
-
26
- /** True exactly once per [arm]: this frame is the one that makes the source live. */
27
- fun onFrame(generation: Int): Boolean {
28
- if (promoted) return false
29
- return promote(generation)
30
- }
31
-
32
- /** True while [generation] is armed and still frameless, so it must be rebuilt. */
33
- @Synchronized fun expired(generation: Int): Boolean = generation == armedGeneration && !promoted
34
-
35
- @Synchronized fun reset() {
36
- armedGeneration = -1
37
- promoted = false
38
- }
39
-
40
- @Synchronized private fun promote(generation: Int): Boolean {
41
- if (promoted || generation != armedGeneration) return false
42
- promoted = true
43
- return true
44
- }
45
- }