@mentra/acs-meeting 3.2.0-dev.120

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/NOTICE +20 -0
  2. package/README.md +412 -0
  3. package/THIRD_PARTY +7 -0
  4. package/android/build.gradle +64 -0
  5. package/android/src/main/AndroidManifest.xml +5 -0
  6. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +80 -0
  7. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +846 -0
  8. package/android/src/main/java/com/mentra/acsmeeting/RemoteRoster.kt +123 -0
  9. package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +90 -0
  10. package/android/src/main/java/com/mentra/acsmeeting/audio/AudioPolicyApplier.kt +176 -0
  11. package/android/src/main/java/com/mentra/acsmeeting/audio/IncomingAudioPump.kt +112 -0
  12. package/android/src/main/java/com/mentra/acsmeeting/audio/IncomingRateProbe.kt +65 -0
  13. package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +108 -0
  14. package/android/src/main/java/com/mentra/acsmeeting/audio/PcmResampler.kt +159 -0
  15. package/android/src/main/java/com/mentra/acsmeeting/audio/PhoneMicCapturer.kt +115 -0
  16. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +302 -0
  17. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +230 -0
  18. package/android/src/main/java/com/mentra/acsmeeting/source/BitmapFont.kt +99 -0
  19. package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +578 -0
  20. package/android/src/main/java/com/mentra/acsmeeting/source/FirstFrameGate.kt +45 -0
  21. package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +101 -0
  22. package/android/src/main/java/com/mentra/acsmeeting/source/MediaListeners.kt +57 -0
  23. package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticFrameFactory.kt +205 -0
  24. package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticI420Source.kt +214 -0
  25. package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +31 -0
  26. package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +41 -0
  27. package/android/src/main/java/com/mentra/acsmeeting/telemetry/ChromaProbe.kt +78 -0
  28. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +281 -0
  29. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +51 -0
  30. package/android/src/main/java/com/mentra/acsmeeting/telemetry/ProcessCpu.kt +15 -0
  31. package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +33 -0
  32. package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +475 -0
  33. package/android/src/main/java/com/mentra/acsmeeting/video/AcsTimestamp.kt +26 -0
  34. package/android/src/main/java/com/mentra/acsmeeting/video/FrameGeometry.kt +22 -0
  35. package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +90 -0
  36. package/android/src/main/java/com/mentra/acsmeeting/video/I420Packer.kt +72 -0
  37. package/android/src/main/java/com/mentra/acsmeeting/video/Nv12FormatSpec.kt +36 -0
  38. package/android/src/main/java/com/mentra/acsmeeting/video/Nv12Packer.kt +51 -0
  39. package/android/src/main/java/com/mentra/acsmeeting/video/SendGate.kt +27 -0
  40. package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +57 -0
  41. package/android/src/test/java/com/mentra/acsmeeting/RemoteRosterTest.kt +40 -0
  42. package/android/src/test/java/com/mentra/acsmeeting/audio/AcsAudioPolicyTest.kt +204 -0
  43. package/android/src/test/java/com/mentra/acsmeeting/audio/AudioPolicyApplierTest.kt +223 -0
  44. package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +12 -0
  45. package/android/src/test/java/com/mentra/acsmeeting/audio/IncomingAudioPumpTest.kt +97 -0
  46. package/android/src/test/java/com/mentra/acsmeeting/audio/IncomingRateProbeTest.kt +78 -0
  47. package/android/src/test/java/com/mentra/acsmeeting/audio/PcmResamplerTest.kt +125 -0
  48. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +201 -0
  49. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +166 -0
  50. package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +26 -0
  51. package/android/src/test/java/com/mentra/acsmeeting/source/BitmapFontTest.kt +22 -0
  52. package/android/src/test/java/com/mentra/acsmeeting/source/FirstFrameGateTest.kt +60 -0
  53. package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +133 -0
  54. package/android/src/test/java/com/mentra/acsmeeting/source/I420PlanesTest.kt +67 -0
  55. package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticFrameFactoryTest.kt +193 -0
  56. package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticI420SourceTest.kt +176 -0
  57. package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +36 -0
  58. package/android/src/test/java/com/mentra/acsmeeting/telemetry/ChromaProbeTest.kt +61 -0
  59. package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +256 -0
  60. package/android/src/test/java/com/mentra/acsmeeting/telemetry/ProcessCpuTest.kt +18 -0
  61. package/android/src/test/java/com/mentra/acsmeeting/telemetry/RingPercentileTest.kt +49 -0
  62. package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +43 -0
  63. package/android/src/test/java/com/mentra/acsmeeting/video/FrameGeometryTest.kt +33 -0
  64. package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +77 -0
  65. package/android/src/test/java/com/mentra/acsmeeting/video/I420PackerTest.kt +126 -0
  66. package/android/src/test/java/com/mentra/acsmeeting/video/Nv12FormatSpecTest.kt +36 -0
  67. package/android/src/test/java/com/mentra/acsmeeting/video/Nv12PackerTest.kt +58 -0
  68. package/android/src/test/java/com/mentra/acsmeeting/video/SendGateTest.kt +79 -0
  69. package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +68 -0
  70. package/build/AcsMeeting.types.d.ts +59 -0
  71. package/build/AcsMeeting.types.d.ts.map +1 -0
  72. package/build/AcsMeeting.types.js +2 -0
  73. package/build/AcsMeeting.types.js.map +1 -0
  74. package/build/AcsMeetingModule.d.ts +15 -0
  75. package/build/AcsMeetingModule.d.ts.map +1 -0
  76. package/build/AcsMeetingModule.js +3 -0
  77. package/build/AcsMeetingModule.js.map +1 -0
  78. package/build/AcsMeetingModule.web.d.ts +16 -0
  79. package/build/AcsMeetingModule.web.d.ts.map +1 -0
  80. package/build/AcsMeetingModule.web.js +31 -0
  81. package/build/AcsMeetingModule.web.js.map +1 -0
  82. package/build/index.d.ts +3 -0
  83. package/build/index.d.ts.map +1 -0
  84. package/build/index.js +3 -0
  85. package/build/index.js.map +1 -0
  86. package/expo-module.config.json +9 -0
  87. package/ios/AcsFrameSender.swift +47 -0
  88. package/ios/AcsMeeting.podspec +27 -0
  89. package/ios/AcsMeetingModule.swift +558 -0
  90. package/ios/PcmBridge.swift +117 -0
  91. package/ios/PhoneMicCapturer.swift +64 -0
  92. package/ios/PolicyKit/Package.swift +17 -0
  93. package/ios/PolicyKit/Sources/AcsAudioPolicy/AcsAudioPolicy.swift +102 -0
  94. package/ios/PolicyKit/Sources/AcsAudioPolicy/AudioPolicyApplier.swift +162 -0
  95. package/ios/PolicyKit/Sources/AcsAudioPolicy/GlassesMediaSource.swift +68 -0
  96. package/ios/PolicyKit/Tests/AcsAudioPolicyTests/AcsAudioPolicyTests.swift +273 -0
  97. package/ios/WhepVideoSource.swift +356 -0
  98. package/package.json +56 -0
  99. package/src/AcsMeeting.types.ts +66 -0
  100. package/src/AcsMeetingModule.ts +16 -0
  101. package/src/AcsMeetingModule.web.ts +33 -0
  102. package/src/index.ts +2 -0
@@ -0,0 +1,578 @@
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.FrameGeometry
8
+ import com.mentra.acsmeeting.video.I420Packer
9
+ import okhttp3.MediaType.Companion.toMediaType
10
+ import okhttp3.OkHttpClient
11
+ import okhttp3.Request
12
+ import okhttp3.RequestBody.Companion.toRequestBody
13
+ import org.webrtc.AudioTrack
14
+ import org.webrtc.AudioTrackSink
15
+ import org.webrtc.DefaultVideoDecoderFactory
16
+ import org.webrtc.DefaultVideoEncoderFactory
17
+ import org.webrtc.EglBase
18
+ import org.webrtc.IceCandidate
19
+ import org.webrtc.MediaConstraints
20
+ import org.webrtc.MediaStream
21
+ import org.webrtc.PeerConnection
22
+ import org.webrtc.PeerConnectionFactory
23
+ import org.webrtc.RtpTransceiver
24
+ import org.webrtc.SdpObserver
25
+ import org.webrtc.SessionDescription
26
+ import org.webrtc.VideoFrame
27
+ import org.webrtc.VideoSink
28
+ import org.webrtc.VideoTrack
29
+ import org.webrtc.audio.JavaAudioDeviceModule
30
+ import java.nio.ByteBuffer
31
+ import java.util.concurrent.CopyOnWriteArrayList
32
+ import java.util.concurrent.TimeUnit
33
+ import java.util.concurrent.atomic.AtomicBoolean
34
+ import java.util.concurrent.atomic.AtomicReference
35
+ import java.util.concurrent.atomic.AtomicLong
36
+
37
+ /**
38
+ * Recvonly WHEP subscriber. Decoded I420 planes are handed to ACS (no RGB
39
+ * convert, no pack-then-split). Scale uses libyuv cropAndScale in buffer
40
+ * coordinates. Remote AudioTrackSink PCM is the P4 hard gate.
41
+ */
42
+ class CloudflareWhepSource(
43
+ private val context: Context,
44
+ private val videoListener: VideoFrameListener,
45
+ private val pcmListener: PcmListener,
46
+ private val stats: PipelineStats = PipelineStats(),
47
+ ) : GlassesMediaSource {
48
+ private val http = OkHttpClient.Builder().callTimeout(20, TimeUnit.SECONDS).build()
49
+ private var factory: PeerConnectionFactory? = null
50
+ private var egl: EglBase? = null
51
+ private var pc: PeerConnection? = null
52
+ private var currentUrl: String? = null
53
+ @Volatile private var offerPosted = false
54
+ // Fail closed: the audio policy turns delivery on once the ACS virtual
55
+ // stream is live. Survives restart() so a WHEP rebuild cannot silently
56
+ // re-open the uplink (or the local playout) against the current decision.
57
+ @Volatile private var pcmEnabled = false
58
+ @Volatile override var state: SourceState = SourceState.IDLE
59
+ private set
60
+ @Volatile private var stateListener: SourceStateListener? = null
61
+ // Every generation of the peer gets its own id so a callback from a disposed
62
+ // PeerConnection (ICE FAILED racing a rebuild) cannot mark the new one failed.
63
+ @Volatile private var generation = 0
64
+ // attachAudio runs on the WebRTC signaling thread, setPcmDeliveryEnabled on the RN
65
+ // bridge thread, stop() on the session thread: a plain list threw CME on mute toggles.
66
+ private val audioTracks = CopyOnWriteArrayList<AudioTrack>()
67
+ private val videoIds = TrackRegistry()
68
+ private val audioIds = TrackRegistry()
69
+ @Volatile private var attachedVideo: VideoTrack? = null
70
+ private val targetSize = AtomicReference<TargetSize?>(null)
71
+ private val rotationLogged = AtomicBoolean(false)
72
+ private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
73
+ private var pendingOfferPost: Runnable? = null
74
+ // LIVE means "ACS is being handed frames", not "the WHEP endpoint answered".
75
+ private val firstFrame = FirstFrameGate()
76
+ @Volatile private var firstFrameDeadline: Runnable? = null
77
+ private var lastDecodedFrames = -1L
78
+ private var lastDecodedAtMs = 0L
79
+ private var lastReceivedFrames = -1L
80
+ private val statsPoll = object : Runnable {
81
+ override fun run() {
82
+ pollDecodedStats()
83
+ mainHandler.postDelayed(this, STATS_POLL_MS)
84
+ }
85
+ }
86
+
87
+ override fun start(config: SourceConfig) {
88
+ stop()
89
+ currentUrl = config.url
90
+ offerPosted = false
91
+ val gen = ++generation
92
+ transition(SourceState.CONNECTING, "start")
93
+ // Timer-driven, not sink-driven: a frame stall must still be sampled, or dec
94
+ // freezes at a stale value during exactly the freezes we are chasing.
95
+ mainHandler.removeCallbacks(statsPoll)
96
+ mainHandler.postDelayed(statsPoll, STATS_POLL_MS)
97
+ ensureFactory()
98
+ val peer = factory!!.createPeerConnection(iceServers(), observerFor(gen)) ?: run {
99
+ transition(SourceState.FAILED, "peer_create_failed")
100
+ error("PeerConnection create failed")
101
+ }
102
+ pc = peer
103
+ peer.addTransceiver(org.webrtc.MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO, recvOnly())
104
+ peer.addTransceiver(org.webrtc.MediaStreamTrack.MediaType.MEDIA_TYPE_AUDIO, recvOnly())
105
+ peer.createOffer(
106
+ object : SdpAdapter() {
107
+ override fun onCreateSuccess(sdp: SessionDescription) {
108
+ peer.setLocalDescription(object : SdpAdapter() {
109
+ override fun onSetSuccess() {
110
+ val runnable = Runnable { postOfferIfNeeded() }
111
+ pendingOfferPost = runnable
112
+ mainHandler.postDelayed(runnable, 2000)
113
+ }
114
+ }, sdp)
115
+ }
116
+ },
117
+ MediaConstraints(),
118
+ )
119
+ }
120
+
121
+ override fun restart(config: SourceConfig) {
122
+ // Reuse the existing subscriber only when the URL is unchanged AND the peer is
123
+ // still healthy. After ICE DISCONNECTED/FAILED (e.g. a Wi-Fi drop that
124
+ // reuses the same Cloudflare WHEP URL) we must rebuild, or glasses video and
125
+ // mic never recover. See canReuseSource for why CONNECTING is reusable.
126
+ if (canReuseSource(currentUrl, state, config)) return
127
+ start(config)
128
+ }
129
+
130
+ override fun forceRestart() {
131
+ val url = currentUrl ?: return
132
+ Log.i(TAG, "WHEP forced rebuild state=$state")
133
+ start(SourceConfig(url))
134
+ }
135
+
136
+ override fun setStateListener(listener: SourceStateListener?) {
137
+ stateListener = listener
138
+ }
139
+
140
+ private fun transition(next: SourceState, reason: String) {
141
+ val previous = state
142
+ state = next
143
+ if (previous == next) return
144
+ Log.i(TAG, "WHEP source $previous -> $next ($reason)")
145
+ try {
146
+ stateListener?.onSourceState(next, reason)
147
+ } catch (error: Exception) {
148
+ Log.w(TAG, "source state listener threw", error)
149
+ }
150
+ }
151
+
152
+ override fun setPcmDeliveryEnabled(enabled: Boolean) {
153
+ pcmEnabled = enabled
154
+ Log.i(TAG, "WHEP audio PCM delivery enabled=$enabled")
155
+ }
156
+
157
+ override fun setTargetSize(size: TargetSize?) {
158
+ targetSize.set(size)
159
+ }
160
+
161
+ override fun stop() {
162
+ currentUrl = null
163
+ offerPosted = false
164
+ pendingOfferPost?.let { mainHandler.removeCallbacks(it) }
165
+ pendingOfferPost = null
166
+ // Disarmed before removeSink, so a sink callback already in flight cannot
167
+ // promote the source we are tearing down.
168
+ cancelFirstFrameDeadline()
169
+ firstFrame.reset()
170
+ mainHandler.removeCallbacks(statsPoll)
171
+ try {
172
+ attachedVideo?.removeSink(videoSink)
173
+ } catch (_: Exception) {
174
+ }
175
+ attachedVideo = null
176
+ videoIds.reset()
177
+ audioIds.reset()
178
+ audioTracks.clear()
179
+ // Invalidate callbacks from the peer being disposed below before they can
180
+ // observe the IDLE we are about to set.
181
+ generation++
182
+ transition(SourceState.IDLE, "stop")
183
+ lastDecodedFrames = -1L
184
+ lastDecodedAtMs = 0L
185
+ lastReceivedFrames = -1L
186
+ rotationLogged.set(false)
187
+ // close() stops media; dispose() is what frees the native peer. Without it every
188
+ // WHEP restart leaks a PeerConnection. dispose() also blocks until observer and
189
+ // sink callbacks quiesce, so it must run after removeSink and before dest is reused.
190
+ val peer = pc
191
+ pc = null
192
+ try {
193
+ peer?.close()
194
+ peer?.dispose()
195
+ } catch (error: Exception) {
196
+ Log.w(TAG, "WHEP peer dispose failed", error)
197
+ }
198
+ }
199
+
200
+ private fun ensureFactory() {
201
+ if (factory != null) return
202
+ PeerConnectionFactory.initialize(
203
+ PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions(),
204
+ )
205
+ val mode = AcsInvestigation.decoderMode
206
+ val shared = if (mode == DecoderMode.TEXTURE) {
207
+ EglBase.create().also { egl = it }.eglBaseContext
208
+ } else {
209
+ null
210
+ }
211
+ // libwebrtc renders every received audio track through its device module.
212
+ // That is the wearer's own mic coming back out of the phone (or, over
213
+ // A2DP, the glasses) — the feedback loop. The mixer must still run so the
214
+ // AudioTrackSink gets PCM, so we keep playout but make it inert: media
215
+ // attributes (never a voice-call route) and per-track volume 0 below.
216
+ val adm = JavaAudioDeviceModule.builder(context)
217
+ .setAudioAttributes(
218
+ AudioAttributes.Builder()
219
+ .setUsage(AudioAttributes.USAGE_MEDIA)
220
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
221
+ .build(),
222
+ )
223
+ .setUseHardwareAcousticEchoCanceler(false)
224
+ .setUseHardwareNoiseSuppressor(false)
225
+ .setAudioTrackStateCallback(object : JavaAudioDeviceModule.AudioTrackStateCallback {
226
+ override fun onWebRtcAudioTrackStart() {
227
+ Log.i(TAG, "WHEP ADM playout started (silenced; sink-only)")
228
+ }
229
+
230
+ override fun onWebRtcAudioTrackStop() {
231
+ Log.i(TAG, "WHEP ADM playout stopped")
232
+ }
233
+ })
234
+ .setAudioRecordStateCallback(object : JavaAudioDeviceModule.AudioRecordStateCallback {
235
+ override fun onWebRtcAudioRecordStart() {
236
+ // recv-only transceivers must never open the phone microphone.
237
+ Log.e(TAG, "WHEP ADM started RECORDING — unexpected phone mic capture")
238
+ }
239
+
240
+ override fun onWebRtcAudioRecordStop() {
241
+ Log.i(TAG, "WHEP ADM recording stopped")
242
+ }
243
+ })
244
+ .createAudioDeviceModule()
245
+ // Belt and braces with the per-track volume: the device module zeroes
246
+ // its playout buffer, and recording is disabled outright.
247
+ adm.setSpeakerMute(true)
248
+ adm.setAudioRecordEnabled(false)
249
+ factory = PeerConnectionFactory.builder()
250
+ .setAudioDeviceModule(adm)
251
+ .setVideoEncoderFactory(DefaultVideoEncoderFactory(shared, true, true))
252
+ .setVideoDecoderFactory(DefaultVideoDecoderFactory(shared))
253
+ .createPeerConnectionFactory()
254
+ adm.release()
255
+ Log.i(TAG, "P3 factory decoderMode=$mode egl=${shared != null} adm=media-silent")
256
+ }
257
+
258
+ @Synchronized
259
+ private fun postOfferIfNeeded() {
260
+ if (offerPosted) return
261
+ val url = currentUrl ?: return
262
+ val peer = pc ?: return
263
+ val offer = peer.localDescription?.description ?: return
264
+ offerPosted = true
265
+ postOffer(url, offer, peer, generation)
266
+ }
267
+
268
+ private fun postOffer(url: String, offer: String, peer: PeerConnection, gen: Int) {
269
+ val request = Request.Builder()
270
+ .url(url)
271
+ .header("Content-Type", "application/sdp")
272
+ .post(offer.toRequestBody("application/sdp".toMediaType()))
273
+ .build()
274
+ http.newCall(request).enqueue(object : okhttp3.Callback {
275
+ override fun onFailure(call: okhttp3.Call, e: java.io.IOException) {
276
+ if (gen != generation) return
277
+ Log.e(TAG, "WHEP POST failed", e)
278
+ transition(SourceState.FAILED, "whep_post_failed:${e.javaClass.simpleName}")
279
+ }
280
+
281
+ override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) {
282
+ response.use {
283
+ val answer = it.body?.string().orEmpty()
284
+ if (gen != generation) return
285
+ if (!it.isSuccessful) {
286
+ Log.e(TAG, "WHEP ${it.code}: $answer")
287
+ transition(SourceState.FAILED, "whep_http_${it.code}")
288
+ return
289
+ }
290
+ Log.i(TAG, "P3 WHEP ${it.code} answer bytes=${answer.length}")
291
+ // Stay CONNECTING: setRemoteDescription, ICE and the decoder still have
292
+ // to run, and on device that is 3-6 s before ACS submits a frame.
293
+ armFirstFrame(gen)
294
+ peer.setRemoteDescription(
295
+ SdpAdapter(),
296
+ SessionDescription(SessionDescription.Type.ANSWER, answer),
297
+ )
298
+ }
299
+ }
300
+ })
301
+ }
302
+
303
+ private fun observerFor(gen: Int) = object : PeerConnection.Observer {
304
+ override fun onSignalingChange(state: PeerConnection.SignalingState) {}
305
+ override fun onIceConnectionChange(state: PeerConnection.IceConnectionState) {
306
+ Log.i(TAG, "ICE $state")
307
+ if (gen != generation) return
308
+ if (state == PeerConnection.IceConnectionState.FAILED ||
309
+ state == PeerConnection.IceConnectionState.DISCONNECTED
310
+ ) {
311
+ transition(SourceState.FAILED, "ice_${state.name.lowercase()}")
312
+ } else if (state == PeerConnection.IceConnectionState.CONNECTED ||
313
+ state == PeerConnection.IceConnectionState.COMPLETED
314
+ ) {
315
+ // ICE can bounce DISCONNECTED → CONNECTED on its own (consent freshness
316
+ // hiccup); only act if the answer already arrived. LIVE now means a frame
317
+ // reached the sink and a recovered candidate pair is only a promise of
318
+ // one, so go back to CONNECTING and let the next frame promote. Frames
319
+ // that were already flowing promote immediately; if none arrive the
320
+ // rearmed deadline fails us again and the session's rebuild backoff runs.
321
+ if (this@CloudflareWhepSource.state == SourceState.FAILED && offerPosted) {
322
+ transition(SourceState.CONNECTING, "ice_recovered")
323
+ armFirstFrame(gen)
324
+ }
325
+ }
326
+ }
327
+ override fun onIceConnectionReceivingChange(receiving: Boolean) {}
328
+ override fun onIceGatheringChange(state: PeerConnection.IceGatheringState) {
329
+ Log.i(TAG, "ICE gathering $state")
330
+ if (gen != generation) return
331
+ if (state == PeerConnection.IceGatheringState.COMPLETE) postOfferIfNeeded()
332
+ }
333
+ override fun onIceCandidate(candidate: IceCandidate) {}
334
+ override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>) {}
335
+ // Unified Plan + recv-only transceivers: onTrack is the only attach path.
336
+ // onAddStream / onAddTrack still fire for the same track and used to triple-addSink.
337
+ override fun onAddStream(stream: MediaStream) {}
338
+ override fun onRemoveStream(stream: MediaStream) {}
339
+ override fun onDataChannel(channel: org.webrtc.DataChannel) {}
340
+ override fun onRenegotiationNeeded() {}
341
+ override fun onAddTrack(receiver: org.webrtc.RtpReceiver, streams: Array<out MediaStream>) {}
342
+ override fun onTrack(transceiver: RtpTransceiver) {
343
+ if (gen != generation) return
344
+ when (val track = transceiver.receiver.track()) {
345
+ is VideoTrack -> attachVideo(track)
346
+ is AudioTrack -> attachAudio(track)
347
+ }
348
+ }
349
+ }
350
+
351
+ private fun attachVideo(track: VideoTrack) {
352
+ val id = track.id()
353
+ if (!videoIds.claim(id)) {
354
+ stats.onDup()
355
+ Log.i(TAG, "P3 attach kind=video id=$id attached=${videoIds.size()} skipped=${videoIds.skipped()}")
356
+ return
357
+ }
358
+ attachedVideo = track
359
+ track.addSink(videoSink)
360
+ Log.i(TAG, "P3 attach kind=video id=$id attached=${videoIds.size()} skipped=${videoIds.skipped()}")
361
+ }
362
+
363
+ /**
364
+ * Arms the first-frame gate for [gen] and bounds the wait.
365
+ *
366
+ * An answered WHEP that never delivers a frame used to read LIVE forever
367
+ * behind a frozen image, with nothing above this layer able to see it.
368
+ * Expiring into FAILED hands it to [SourceStateListener], whose rebuild
369
+ * backoff is the only thing that can repair it.
370
+ */
371
+ private fun armFirstFrame(gen: Int) {
372
+ firstFrame.arm(gen)
373
+ cancelFirstFrameDeadline()
374
+ val task = Runnable {
375
+ firstFrameDeadline = null
376
+ if (gen != generation || !firstFrame.expired(gen)) return@Runnable
377
+ Log.w(TAG, "WHEP answered but delivered no frame in ${FIRST_FRAME_TIMEOUT_MS}ms")
378
+ transition(SourceState.FAILED, "no_first_frame")
379
+ }
380
+ firstFrameDeadline = task
381
+ mainHandler.postDelayed(task, FIRST_FRAME_TIMEOUT_MS)
382
+ }
383
+
384
+ private fun cancelFirstFrameDeadline() {
385
+ firstFrameDeadline?.let { mainHandler.removeCallbacks(it) }
386
+ firstFrameDeadline = null
387
+ }
388
+
389
+ /** The one honest LIVE: a decoded frame is on its way to ACS. */
390
+ private fun notePromotableFrame() {
391
+ if (!firstFrame.onFrame(generation)) return
392
+ cancelFirstFrameDeadline()
393
+ transition(SourceState.LIVE, "first_frame")
394
+ }
395
+
396
+ private val videoSink = VideoSink { frame ->
397
+ val sinkStart = System.nanoTime()
398
+ notePromotableFrame()
399
+ stats.onSink()
400
+ stats.recordGap()
401
+ val buffer = frame.buffer
402
+ stats.onFrameBuffer(classifyBuffer(buffer))
403
+ val geometry = FrameGeometry.packSize(buffer.width, buffer.height, frame.rotation)
404
+ if (geometry.rotationNonZero) {
405
+ stats.onRotation()
406
+ if (rotationLogged.compareAndSet(false, true)) {
407
+ Log.w(
408
+ TAG,
409
+ "P3 rotation=${frame.rotation} using buffer ${geometry.width}x${geometry.height} " +
410
+ "(rotatedWidth=${frame.rotatedWidth}x${frame.rotatedHeight} would overrun I420 planes)",
411
+ )
412
+ }
413
+ }
414
+ val target = targetSize.get()
415
+ var scaled: VideoFrame.Buffer? = null
416
+ val source = if (target != null && (target.width != geometry.width || target.height != geometry.height)) {
417
+ val scaleStart = System.nanoTime()
418
+ scaled = buffer.cropAndScale(0, 0, geometry.width, geometry.height, target.width, target.height)
419
+ stats.scale.record(System.nanoTime() - scaleStart)
420
+ scaled
421
+ } else {
422
+ buffer
423
+ }
424
+ val i420Start = System.nanoTime()
425
+ val i420 = source.toI420()
426
+ stats.toI420.record(System.nanoTime() - i420Start)
427
+ if (i420 == null) {
428
+ stats.onDropNullI420()
429
+ scaled?.release()
430
+ stats.sinkCb.record(System.nanoTime() - sinkStart)
431
+ return@VideoSink
432
+ }
433
+ try {
434
+ val w = i420.width
435
+ val h = i420.height
436
+ stats.onStrides(i420.strideY, i420.strideU, i420.strideV, w)
437
+ stats.setSize(w, h)
438
+ videoListener.onVideoFrame(
439
+ I420Planes(
440
+ y = i420.dataY,
441
+ strideY = i420.strideY,
442
+ u = i420.dataU,
443
+ strideU = i420.strideU,
444
+ v = i420.dataV,
445
+ strideV = i420.strideV,
446
+ width = w,
447
+ height = h,
448
+ timestampNs = frame.timestampNs,
449
+ retain = { i420.retain() },
450
+ release = { i420.release() },
451
+ ),
452
+ )
453
+ } finally {
454
+ i420.release()
455
+ scaled?.release()
456
+ stats.sinkCb.record(System.nanoTime() - sinkStart)
457
+ }
458
+ }
459
+
460
+ private fun classifyBuffer(buffer: VideoFrame.Buffer): String = when (buffer) {
461
+ is VideoFrame.TextureBuffer -> "tex"
462
+ is VideoFrame.I420Buffer -> "i420"
463
+ else -> "other"
464
+ }
465
+
466
+ private fun attachAudio(track: AudioTrack) {
467
+ val id = track.id()
468
+ if (!audioIds.claim(id)) {
469
+ stats.onDup()
470
+ Log.i(TAG, "P3 attach kind=audio id=$id attached=${audioIds.size()} skipped=${audioIds.skipped()}")
471
+ return
472
+ }
473
+ audioTracks.add(track)
474
+ // Output gain is applied after the raw sink callback in ChannelReceive, so
475
+ // volume 0 silences local playout while the sink still gets full-scale PCM.
476
+ track.setVolume(0.0)
477
+ track.setEnabled(true)
478
+ track.addSink(audioSink)
479
+ Log.i(TAG, "P3 attach kind=audio id=$id attached=${audioIds.size()} skipped=${audioIds.skipped()} playoutVolume=0")
480
+ }
481
+
482
+ private val audioSink = AudioTrackSink { audioData, bitsPerSample, sampleRate, numberOfChannels, numberOfFrames, _ ->
483
+ if (!pcmEnabled || bitsPerSample != 16) return@AudioTrackSink
484
+ val bytes = ByteArray(audioData.remaining())
485
+ audioData.get(bytes)
486
+ pcmListener.onPcm(bytes, sampleRate, numberOfChannels)
487
+ }
488
+
489
+ private fun pollDecodedStats() {
490
+ val peer = pc ?: return
491
+ peer.getStats { report ->
492
+ for (statsReport in report.statsMap.values) {
493
+ if (statsReport.type != "inbound-rtp") continue
494
+ if (statsReport.members["kind"]?.toString() != "video") continue
495
+ val members = statsReport.members
496
+ val decoded = (members["framesDecoded"] as? Number)?.toLong() ?: continue
497
+ val received = (members["framesReceived"] as? Number)?.toLong() ?: -1L
498
+ val now = System.currentTimeMillis()
499
+ if (lastDecodedFrames >= 0 && lastDecodedAtMs > 0) {
500
+ val dt = now - lastDecodedAtMs
501
+ if (dt > 0) {
502
+ stats.decodedFps = (decoded - lastDecodedFrames) * 1000.0 / dt
503
+ if (received >= 0 && lastReceivedFrames >= 0) {
504
+ stats.recvFps = (received - lastReceivedFrames) * 1000.0 / dt
505
+ }
506
+ }
507
+ }
508
+ lastDecodedFrames = decoded
509
+ lastReceivedFrames = received
510
+ lastDecodedAtMs = now
511
+ // Receive-side disposition. Separates "the network never delivered it"
512
+ // (packetsLost/nack/pli, recv < 15) from "we got it and stalled"
513
+ // (recv ~15 but dropped/freeze climbing).
514
+ stats.setRecvHealth(
515
+ PipelineStats.RecvHealth(
516
+ assembled = (members["framesAssembledFromMultiplePackets"] as? Number)?.toLong() ?: -1L,
517
+ dropped = (members["framesDropped"] as? Number)?.toLong() ?: -1L,
518
+ packetsLost = (members["packetsLost"] as? Number)?.toLong() ?: -1L,
519
+ nack = (members["nackCount"] as? Number)?.toLong() ?: -1L,
520
+ pli = (members["pliCount"] as? Number)?.toLong() ?: -1L,
521
+ freezes = (members["freezeCount"] as? Number)?.toLong() ?: -1L,
522
+ freezeSec = (members["totalFreezesDuration"] as? Number)?.toDouble() ?: -1.0,
523
+ jitter = (members["jitter"] as? Number)?.toDouble() ?: -1.0,
524
+ decodeSec = (members["totalDecodeTime"] as? Number)?.toDouble() ?: -1.0,
525
+ jitterBufferSec = (members["jitterBufferDelay"] as? Number)?.toDouble() ?: -1.0,
526
+ jitterBufferEmits = (members["jitterBufferEmittedCount"] as? Number)?.toLong() ?: -1L,
527
+ decImpl = members["decoderImplementation"]?.toString().orEmpty(),
528
+ ),
529
+ )
530
+ return@getStats
531
+ }
532
+ }
533
+ }
534
+
535
+ private fun recvOnly() = RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.RECV_ONLY)
536
+
537
+ private fun iceServers() = PeerConnection.RTCConfiguration(
538
+ listOf(PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()),
539
+ )
540
+
541
+ companion object {
542
+ private const val TAG = "ACS-SPIKE"
543
+ private const val STATS_POLL_MS = 1000L
544
+
545
+ /**
546
+ * How long a WHEP answer has to produce its first frame. Measured on an S22
547
+ * reconnect the gap is ~3.2 s (answer 15:56:47.16 → first frame 15:56:50.36);
548
+ * a cold subscribe on a bad network is slower, so this is deliberately loose.
549
+ * Too tight rebuilds a subscriber that was about to work.
550
+ */
551
+ private const val FIRST_FRAME_TIMEOUT_MS = 9_000L
552
+
553
+ fun i420PackedSize(width: Int, height: Int) = I420Packer.packedSize(width, height)
554
+
555
+ fun packI420(
556
+ dataY: ByteBuffer,
557
+ strideY: Int,
558
+ dataU: ByteBuffer,
559
+ strideU: Int,
560
+ dataV: ByteBuffer,
561
+ strideV: Int,
562
+ width: Int,
563
+ height: Int,
564
+ dest: ByteBuffer,
565
+ ) = I420Packer.pack(dataY, strideY, dataU, strideU, dataV, strideV, width, height, dest)
566
+
567
+ fun percentileMs(samplesNs: LongArray, quantile: Double) = I420Packer.percentileMs(samplesNs, quantile)
568
+ }
569
+ }
570
+
571
+ typealias WhepVideoSource = CloudflareWhepSource
572
+
573
+ private open class SdpAdapter : SdpObserver {
574
+ override fun onCreateSuccess(sdp: SessionDescription) {}
575
+ override fun onSetSuccess() {}
576
+ override fun onCreateFailure(error: String) {}
577
+ override fun onSetFailure(error: String) {}
578
+ }
@@ -0,0 +1,45 @@
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
+ }