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

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 (100) hide show
  1. package/README.md +4 -2
  2. package/android/build.gradle +23 -0
  3. package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +291 -0
  4. package/android/src/main/AndroidManifest.xml +2 -0
  5. package/android/src/main/java/com/mentra/acsmeeting/AbandonedCallAgent.kt +36 -0
  6. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +392 -22
  7. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +909 -56
  8. package/android/src/main/java/com/mentra/acsmeeting/CallCapabilities.kt +33 -0
  9. package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +21 -0
  10. package/android/src/main/java/com/mentra/acsmeeting/audio/AudioUplinkChain.kt +126 -0
  11. package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +27 -4
  12. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +45 -10
  13. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +110 -5
  14. package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +501 -0
  15. package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +51 -0
  16. package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +81 -0
  17. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +355 -0
  18. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +109 -0
  19. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +160 -0
  20. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +103 -0
  21. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +53 -0
  22. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +127 -0
  23. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +573 -0
  24. package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +10 -101
  25. package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +147 -0
  26. package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +47 -5
  27. package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +772 -0
  28. package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +73 -0
  29. package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +17 -0
  30. package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +139 -0
  31. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +144 -0
  32. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +46 -0
  33. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +160 -0
  34. package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +3 -1
  35. package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +69 -0
  36. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +207 -0
  37. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +356 -0
  38. package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +155 -0
  39. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +94 -3
  40. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +33 -5
  41. package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +15 -0
  42. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +178 -0
  43. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +145 -0
  44. package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +143 -0
  45. package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +23 -4
  46. package/android/src/main/java/com/mentra/acsmeeting/video/FramePacer.kt +70 -0
  47. package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +1 -1
  48. package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +36 -3
  49. package/android/src/test/java/com/mentra/acsmeeting/AbandonedCallAgentTest.kt +74 -0
  50. package/android/src/test/java/com/mentra/acsmeeting/CallCapabilitiesTest.kt +47 -0
  51. package/android/src/test/java/com/mentra/acsmeeting/audio/AudioUplinkChainTest.kt +250 -0
  52. package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +44 -0
  53. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +34 -4
  54. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +96 -0
  55. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +102 -0
  56. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +148 -0
  57. package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +59 -0
  58. package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +59 -0
  59. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +35 -0
  60. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +165 -0
  61. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +96 -0
  62. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +187 -0
  63. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +172 -0
  64. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +141 -0
  65. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +69 -0
  66. package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +40 -0
  67. package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +41 -0
  68. package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +26 -8
  69. package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +109 -0
  70. package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +165 -0
  71. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +148 -0
  72. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +76 -0
  73. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +275 -0
  74. package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +9 -0
  75. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +356 -0
  76. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +411 -0
  77. package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +83 -0
  78. package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +69 -3
  79. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +182 -0
  80. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +149 -0
  81. package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +97 -0
  82. package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +34 -0
  83. package/android/src/test/java/com/mentra/acsmeeting/video/FramePacerTest.kt +105 -0
  84. package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +5 -0
  85. package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +38 -3
  86. package/build/AcsMeeting.types.d.ts +5 -0
  87. package/build/AcsMeeting.types.d.ts.map +1 -1
  88. package/build/AcsMeeting.types.js.map +1 -1
  89. package/build/AcsMeetingModule.d.ts +23 -0
  90. package/build/AcsMeetingModule.d.ts.map +1 -1
  91. package/build/AcsMeetingModule.js.map +1 -1
  92. package/build/AcsMeetingModule.web.d.ts +15 -0
  93. package/build/AcsMeetingModule.web.d.ts.map +1 -1
  94. package/build/AcsMeetingModule.web.js +13 -0
  95. package/build/AcsMeetingModule.web.js.map +1 -1
  96. package/ios/AcsMeetingModule.swift +213 -2
  97. package/package.json +1 -1
  98. package/src/AcsMeeting.types.ts +5 -0
  99. package/src/AcsMeetingModule.ts +13 -0
  100. package/src/AcsMeetingModule.web.ts +13 -0
@@ -4,14 +4,12 @@ import android.content.Context
4
4
  import android.media.AudioAttributes
5
5
  import android.util.Log
6
6
  import com.mentra.acsmeeting.telemetry.PipelineStats
7
- import com.mentra.acsmeeting.video.FrameGeometry
8
7
  import com.mentra.acsmeeting.video.I420Packer
9
8
  import okhttp3.MediaType.Companion.toMediaType
10
9
  import okhttp3.OkHttpClient
11
10
  import okhttp3.Request
12
11
  import okhttp3.RequestBody.Companion.toRequestBody
13
12
  import org.webrtc.AudioTrack
14
- import org.webrtc.AudioTrackSink
15
13
  import org.webrtc.DefaultVideoDecoderFactory
16
14
  import org.webrtc.DefaultVideoEncoderFactory
17
15
  import org.webrtc.EglBase
@@ -23,15 +21,11 @@ import org.webrtc.PeerConnectionFactory
23
21
  import org.webrtc.RtpTransceiver
24
22
  import org.webrtc.SdpObserver
25
23
  import org.webrtc.SessionDescription
26
- import org.webrtc.VideoFrame
27
- import org.webrtc.VideoSink
28
24
  import org.webrtc.VideoTrack
29
25
  import org.webrtc.audio.JavaAudioDeviceModule
30
26
  import java.nio.ByteBuffer
31
27
  import java.util.concurrent.CopyOnWriteArrayList
32
28
  import java.util.concurrent.TimeUnit
33
- import java.util.concurrent.atomic.AtomicBoolean
34
- import java.util.concurrent.atomic.AtomicReference
35
29
  import java.util.concurrent.atomic.AtomicLong
36
30
 
37
31
  /**
@@ -51,10 +45,6 @@ class CloudflareWhepSource(
51
45
  private var pc: PeerConnection? = null
52
46
  private var currentUrl: String? = null
53
47
  @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
48
  @Volatile override var state: SourceState = SourceState.IDLE
59
49
  private set
60
50
  @Volatile private var stateListener: SourceStateListener? = null
@@ -67,8 +57,9 @@ class CloudflareWhepSource(
67
57
  private val videoIds = TrackRegistry()
68
58
  private val audioIds = TrackRegistry()
69
59
  @Volatile private var attachedVideo: VideoTrack? = null
70
- private val targetSize = AtomicReference<TargetSize?>(null)
71
- private val rotationLogged = AtomicBoolean(false)
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() }
72
63
  private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
73
64
  private var pendingOfferPost: Runnable? = null
74
65
  // LIVE means "ACS is being handed frames", not "the WHEP endpoint answered".
@@ -122,8 +113,8 @@ class CloudflareWhepSource(
122
113
  // Reuse the existing subscriber only when the URL is unchanged AND the peer is
123
114
  // still healthy. After ICE DISCONNECTED/FAILED (e.g. a Wi-Fi drop that
124
115
  // 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
116
+ // mic never recover. See SourceReusePolicy for why CONNECTING is reusable.
117
+ if (SourceReusePolicy.canReuse(currentUrl, state, config)) return
127
118
  start(config)
128
119
  }
129
120
 
@@ -150,12 +141,12 @@ class CloudflareWhepSource(
150
141
  }
151
142
 
152
143
  override fun setPcmDeliveryEnabled(enabled: Boolean) {
153
- pcmEnabled = enabled
144
+ relay.setPcmDeliveryEnabled(enabled)
154
145
  Log.i(TAG, "WHEP audio PCM delivery enabled=$enabled")
155
146
  }
156
147
 
157
148
  override fun setTargetSize(size: TargetSize?) {
158
- targetSize.set(size)
149
+ relay.setTargetSize(size)
159
150
  }
160
151
 
161
152
  override fun stop() {
@@ -183,7 +174,7 @@ class CloudflareWhepSource(
183
174
  lastDecodedFrames = -1L
184
175
  lastDecodedAtMs = 0L
185
176
  lastReceivedFrames = -1L
186
- rotationLogged.set(false)
177
+ relay.resetRotationLog()
187
178
  // close() stops media; dispose() is what frees the native peer. Without it every
188
179
  // WHEP restart leaks a PeerConnection. dispose() also blocks until observer and
189
180
  // sink callbacks quiesce, so it must run after removeSink and before dest is reused.
@@ -393,75 +384,7 @@ class CloudflareWhepSource(
393
384
  transition(SourceState.LIVE, "first_frame")
394
385
  }
395
386
 
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
- }
387
+ private val videoSink get() = relay.videoSink
465
388
 
466
389
  private fun attachAudio(track: AudioTrack) {
467
390
  val id = track.id()
@@ -475,17 +398,10 @@ class CloudflareWhepSource(
475
398
  // volume 0 silences local playout while the sink still gets full-scale PCM.
476
399
  track.setVolume(0.0)
477
400
  track.setEnabled(true)
478
- track.addSink(audioSink)
401
+ track.addSink(relay.audioSink)
479
402
  Log.i(TAG, "P3 attach kind=audio id=$id attached=${audioIds.size()} skipped=${audioIds.skipped()} playoutVolume=0")
480
403
  }
481
404
 
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
405
  private fun pollDecodedStats() {
490
406
  val peer = pc ?: return
491
407
  peer.getStats { report ->
@@ -569,10 +485,3 @@ class CloudflareWhepSource(
569
485
  }
570
486
 
571
487
  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,147 @@
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,12 +1,29 @@
1
1
  package com.mentra.acsmeeting.source
2
2
 
3
- enum class SourceKind { WHEP, DIRECT }
3
+ enum class SourceKind {
4
+ /** Subscribe to a Cloudflare WHEP endpoint. The phone is the offerer. */
5
+ WHEP,
6
+ DIRECT,
7
+
8
+ /**
9
+ * Serve a WHIP endpoint on the glasses hotspot and let the glasses publish into it. The phone is
10
+ * the answerer, and no traffic leaves the local link. See [LocalWhipIngestSource].
11
+ */
12
+ SOFTAP,
13
+ }
4
14
 
5
15
  enum class SourceState { IDLE, CONNECTING, LIVE, FAILED }
6
16
 
7
17
  data class SourceConfig(
18
+ /**
19
+ * WHEP: the endpoint to subscribe to. SOFTAP: unused, because the URL is not known until the
20
+ * listener has bound a port — the source reports it back through
21
+ * [GlassesMediaSource.ingestUrl] instead.
22
+ */
8
23
  val url: String,
9
24
  val kind: SourceKind = SourceKind.WHEP,
25
+ /** SOFTAP only: the phone's own IPv4 address on the hotspot, to bind the listener to. */
26
+ val bindAddress: String? = null,
10
27
  )
11
28
 
12
29
  /** ACS-negotiated output size. One object so a frame cannot observe a torn width/height. */
@@ -22,9 +39,18 @@ data class TargetSize(val width: Int, val height: Int)
22
39
  * spans the answer → first frame window too. It cannot strand a caller, because
23
40
  * every `CONNECTING` is bounded: the offer post fails, or the answer's
24
41
  * first-frame deadline expires into `FAILED` and this returns false.
42
+ *
43
+ * SoftAP never reuses: the listener, port and peer belong to one publish attempt,
44
+ * and the ingest URL is an output of binding, not an input that could stay valid.
45
+ *
46
+ * Owned by an object rather than a file-level function so Kotlin incremental
47
+ * compile cannot see two [GlassesMediaSourceKt] facades and report a phantom
48
+ * overload of the same signature.
25
49
  */
26
- fun canReuseSource(currentUrl: String?, state: SourceState, next: SourceConfig): Boolean =
27
- currentUrl == next.url && next.kind == SourceKind.WHEP && state != SourceState.FAILED
50
+ object SourceReusePolicy {
51
+ fun canReuse(currentUrl: String?, state: SourceState, next: SourceConfig): Boolean =
52
+ currentUrl == next.url && next.kind == SourceKind.WHEP && state != SourceState.FAILED
53
+ }
28
54
 
29
55
  /**
30
56
  * Observes source health transitions. `FAILED` is the one that matters: ICE
@@ -47,13 +73,25 @@ interface GlassesMediaSource {
47
73
  fun forceRestart() {}
48
74
  fun stop()
49
75
  val state: SourceState
76
+
77
+ /**
78
+ * SOFTAP only: the WHIP URL the glasses must POST their offer to, known only after [start] has
79
+ * bound a listener. Null for every other kind, where the URL is an input rather than an output.
80
+ */
81
+ val ingestUrl: String? get() = null
82
+
50
83
  fun setPcmDeliveryEnabled(enabled: Boolean)
51
84
  fun setTargetSize(size: TargetSize?) {}
52
85
  fun setStateListener(listener: SourceStateListener?) {}
53
86
  }
54
87
 
88
+ /**
89
+ * Builds the transport for one attempt. Takes [SourceConfig] because the kind decides the class:
90
+ * a Cloudflare subscription and a SoftAP listener are different objects, not one object with a
91
+ * mode flag, and the session must not have to know which.
92
+ */
55
93
  fun interface GlassesMediaSourceFactory {
56
- fun create(video: VideoFrameListener, pcm: PcmListener): GlassesMediaSource
94
+ fun create(video: VideoFrameListener, pcm: PcmListener, config: SourceConfig): GlassesMediaSource
57
95
  }
58
96
 
59
97
  class GlassesMediaController(
@@ -65,9 +103,13 @@ class GlassesMediaController(
65
103
  val state: SourceState
66
104
  get() = source?.state ?: SourceState.IDLE
67
105
 
106
+ /** SOFTAP only: the URL the glasses must publish to, once a listener has bound. */
107
+ val ingestUrl: String?
108
+ get() = source?.ingestUrl
109
+
68
110
  fun attach(video: VideoFrameListener, pcm: PcmListener, config: SourceConfig) {
69
111
  source?.stop()
70
- source = factory.create(video, pcm).also {
112
+ source = factory.create(video, pcm, config).also {
71
113
  it.setStateListener(stateListener)
72
114
  it.start(config)
73
115
  }