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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,205 +0,0 @@
1
- package com.mentra.acsmeeting.source
2
-
3
- import com.mentra.acsmeeting.video.I420Packer
4
- import java.nio.ByteBuffer
5
- import kotlin.math.max
6
-
7
- /**
8
- * Packed I420 generator. No Android / ACS / WebRTC types so JVM tests stay cheap.
9
- *
10
- * CHEAP paints a flat field, a moving bar, and a Recall-style stamp (6-digit
11
- * frame id + 1s FPS). Low motion: enough change that ACS cannot skip, still
12
- * trivially compressible.
13
- *
14
- * MOTION pans a tiled scene across the frame. That is the glasses-turn proxy:
15
- * temporal prediction dies (almost every pixel changes) but spatial structure
16
- * stays, so an I-frame still compresses. Use this to ask whether ACS holds
17
- * 15 fps under high motion.
18
- *
19
- * NOISE rewrites the whole Y plane from an LCG. That is not camera motion.
20
- * Neighbors are uncorrelated, so even I-frames stay huge. Measured on ACS at
21
- * 720p it pinned ~1.9 Mbps and collapsed the wire rate to 1.4 fps while we
22
- * submitted 15. Use it to stress the encoder, never to model glasses movement.
23
- *
24
- * Chroma stays at 128 in every mode.
25
- */
26
- class SyntheticFrameFactory(
27
- private val entropy: SyntheticEntropy = SyntheticEntropy.CHEAP,
28
- ) {
29
- private var lastDest: ByteBuffer? = null
30
- private var lastWidth = 0
31
- private var lastHeight = 0
32
- private var background: ByteArray? = null
33
- private var scene: ByteArray? = null
34
- private var sceneWidth = 0
35
- private var sceneHeight = 0
36
-
37
- fun write(dest: ByteBuffer, width: Int, height: Int, frameIndex: Int, emitFps: Double? = null) {
38
- require(width > 0 && height > 0) { "size must be positive" }
39
- val needed = I420Packer.packedSize(width, height)
40
- require(dest.capacity() >= needed) { "dest capacity ${dest.capacity()} < $needed" }
41
- when (entropy) {
42
- SyntheticEntropy.NOISE -> writeNoise(dest, width, height, frameIndex)
43
- SyntheticEntropy.MOTION -> writeMotion(dest, width, height, frameIndex)
44
- SyntheticEntropy.CHEAP -> writeCheap(dest, width, height, frameIndex)
45
- }
46
- writeStamp(dest, width, height, frameIndex, emitFps)
47
- dest.position(0)
48
- dest.limit(needed)
49
- }
50
-
51
- private fun writeCheap(dest: ByteBuffer, width: Int, height: Int, frameIndex: Int) {
52
- // Bulk-restore the field instead of a per-pixel loop: this runs on the emit
53
- // thread every frame, and 921600 single-byte puts is exactly the Kotlin
54
- // per-pixel cost the rest of this module exists to avoid.
55
- val ySize = width * height
56
- val template = background.takeIf { it != null && it.size == ySize }
57
- ?: ByteArray(ySize) { BG_Y }.also { background = it }
58
- dest.clear()
59
- dest.put(template, 0, ySize)
60
- if (dest !== lastDest || width != lastWidth || height != lastHeight) {
61
- fillChroma(dest, width, height)
62
- lastDest = dest
63
- lastWidth = width
64
- lastHeight = height
65
- }
66
- val barWidth = max(8, width / 80)
67
- val travel = max(1, width - barWidth)
68
- val barX = ((frameIndex.toLong() * barWidth) % travel).toInt()
69
- fillYRect(dest, width, height, barX, 0, barWidth, height, BAR_Y)
70
- }
71
-
72
- /**
73
- * Copy a pre-painted tiled scene with a wrap-safe offset. One bulk put per
74
- * row, not one put per pixel — the scene is the glasses-turn stand-in, and
75
- * generating it in Kotlin every frame would be the cost we are trying to
76
- * isolate out of this measurement.
77
- */
78
- private fun writeMotion(dest: ByteBuffer, width: Int, height: Int, frameIndex: Int) {
79
- val field = ensureScene(width, height)
80
- val ox = ((frameIndex.toLong() * PAN_X) % TILE).toInt()
81
- val oy = ((frameIndex.toLong() * PAN_Y) % TILE).toInt()
82
- for (row in 0 until height) {
83
- dest.position(row * width)
84
- dest.put(field, (row + oy) * sceneWidth + ox, width)
85
- }
86
- if (dest !== lastDest || width != lastWidth || height != lastHeight) {
87
- fillChroma(dest, width, height)
88
- lastDest = dest
89
- lastWidth = width
90
- lastHeight = height
91
- }
92
- }
93
-
94
- private fun ensureScene(width: Int, height: Int): ByteArray {
95
- val sw = width + TILE
96
- val sh = height + TILE
97
- val existing = scene
98
- if (existing != null && sceneWidth == sw && sceneHeight == sh) return existing
99
- val field = ByteArray(sw * sh)
100
- for (y in 0 until sh) {
101
- val rowOff = y * sw
102
- val tileY = y / TILE
103
- val gy = y % TILE
104
- for (x in 0 until sw) {
105
- val tile = (x / TILE + tileY) and 1
106
- val gx = x % TILE
107
- val base = if (tile == 0) DARK_TILE else LIGHT_TILE
108
- field[rowOff + x] = (base + gx - TILE / 2 + gy / 4).toByte()
109
- }
110
- }
111
- scene = field
112
- sceneWidth = sw
113
- sceneHeight = sh
114
- return field
115
- }
116
-
117
- private fun writeNoise(dest: ByteBuffer, width: Int, height: Int, frameIndex: Int) {
118
- writeNoiseY(dest, width, height, frameIndex)
119
- fillChroma(dest, width, height)
120
- lastDest = dest
121
- lastWidth = width
122
- lastHeight = height
123
- }
124
-
125
- private fun writeNoiseY(dest: ByteBuffer, width: Int, height: Int, frameIndex: Int) {
126
- val ySize = width * height
127
- var seed = (frameIndex + 1) * LCG_SEED
128
- for (i in 0 until ySize) {
129
- seed = seed * LCG_MUL + LCG_ADD
130
- dest.put(i, ((seed ushr 24) and 0xFF).toByte())
131
- }
132
- }
133
-
134
- /**
135
- * Recall burns `F 000123` plus live rates into the captured page. Same idea here:
136
- * a 6-digit unique-frame id and the last 1s emit rate, readable on the Teams tile.
137
- */
138
- private fun writeStamp(
139
- dest: ByteBuffer,
140
- width: Int,
141
- height: Int,
142
- frameIndex: Int,
143
- emitFps: Double?,
144
- ) {
145
- val scale = (height / 60).coerceIn(8, 16)
146
- val line1 = "F ${frameIndex.coerceAtLeast(0).toString().padStart(6, '0')}"
147
- val fpsLabel = emitFps?.let { ((it * 10).toInt() / 10.0).toString() } ?: "--"
148
- val line2 = "FPS $fpsLabel"
149
- val pad = scale * 2
150
- val lineH = BitmapFont.textHeight(scale)
151
- val gap = scale
152
- val boxW = max(BitmapFont.textWidth(line1.length, scale), BitmapFont.textWidth(line2.length, scale)) + pad * 2
153
- val boxH = lineH * 2 + gap + pad * 2
154
- val boxX = (width / 16).coerceAtLeast(16)
155
- val boxY = (height / 2 - boxH / 2).coerceAtLeast(0)
156
- fillYRect(dest, width, height, boxX, boxY, boxW, boxH, OFF_Y)
157
- BitmapFont.draw(dest, width, height, line1, boxX + pad, boxY + pad, scale, BAR_Y)
158
- BitmapFont.draw(dest, width, height, line2, boxX + pad, boxY + pad + lineH + gap, scale, BAR_Y)
159
- }
160
-
161
- private fun fillChroma(dest: ByteBuffer, width: Int, height: Int) {
162
- val ySize = width * height
163
- val uvSize = I420Packer.chromaStride(width) * I420Packer.chromaStride(height)
164
- val end = ySize + 2 * uvSize
165
- for (i in ySize until end) dest.put(i, CHROMA)
166
- }
167
-
168
- private fun fillYRect(
169
- dest: ByteBuffer,
170
- width: Int,
171
- height: Int,
172
- x0: Int,
173
- y0: Int,
174
- rectW: Int,
175
- rectH: Int,
176
- value: Byte,
177
- ) {
178
- val xStart = x0.coerceIn(0, width)
179
- val yStart = y0.coerceIn(0, height)
180
- val xEnd = (x0 + rectW).coerceIn(0, width)
181
- val yEnd = (y0 + rectH).coerceIn(0, height)
182
- if (xStart >= xEnd || yStart >= yEnd) return
183
- for (row in yStart until yEnd) {
184
- val rowOff = row * width
185
- for (col in xStart until xEnd) {
186
- dest.put(rowOff + col, value)
187
- }
188
- }
189
- }
190
-
191
- companion object {
192
- const val BG_Y: Byte = 40
193
- const val OFF_Y: Byte = 16
194
- const val BAR_Y: Byte = 235.toByte()
195
- const val CHROMA: Byte = 128.toByte()
196
- const val TILE = 32
197
- const val PAN_X = 8
198
- const val PAN_Y = 4
199
- private const val DARK_TILE = 70
200
- private const val LIGHT_TILE = 170
201
- private const val LCG_SEED = -1640531527
202
- private const val LCG_MUL = 1664525
203
- private const val LCG_ADD = 1013904223
204
- }
205
- }
@@ -1,214 +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.I420Packer
6
- import java.nio.ByteBuffer
7
- import java.util.concurrent.Executors
8
- import java.util.concurrent.ScheduledExecutorService
9
- import java.util.concurrent.TimeUnit
10
- import java.util.concurrent.atomic.AtomicBoolean
11
- import java.util.concurrent.atomic.AtomicInteger
12
- import java.util.concurrent.atomic.AtomicLong
13
- import java.util.concurrent.atomic.AtomicReference
14
-
15
- /**
16
- * Fixed-rate packed-I420 source. Bypasses glasses, Cloudflare, WHEP and decode.
17
- * Ticks that fail [isReady] or lack a target size are withheld and uncounted.
18
- */
19
- class SyntheticI420Source(
20
- private val video: VideoFrameListener,
21
- private val stats: PipelineStats,
22
- private val isReady: () -> Boolean,
23
- private val config: SyntheticConfig = SyntheticConfig(),
24
- ) : GlassesMediaSource {
25
- private val factory = SyntheticFrameFactory(config.entropy)
26
- private val targetSize = AtomicReference<TargetSize?>(null)
27
- private val pcmEnabled = AtomicBoolean(false)
28
- private val frameIndex = AtomicInteger(0)
29
- private val withheld = AtomicInteger(0)
30
- private val withheldThisGap = AtomicInteger(0)
31
- private val emitted = AtomicInteger(0)
32
- private val emitting = AtomicBoolean(false)
33
- private val firstLogged = AtomicBoolean(false)
34
- private val fpsWindowStartNs = AtomicLong(0)
35
- private val fpsWindowCount = AtomicInteger(0)
36
- @Volatile private var stampFps: Double? = null
37
- @Volatile override var state: SourceState = SourceState.IDLE
38
- private set
39
- @Volatile private var dest: ByteBuffer? = null
40
- @Volatile private var destWidth = 0
41
- @Volatile private var destHeight = 0
42
- @Volatile private var destY: ByteBuffer? = null
43
- @Volatile private var destU: ByteBuffer? = null
44
- @Volatile private var destV: ByteBuffer? = null
45
- private var scheduler: ScheduledExecutorService? = null
46
-
47
- /** Test-visible gate transitions. Production also writes them to ACS-SPIKE. */
48
- internal val events = mutableListOf<String>()
49
-
50
- /** Puts the source in LIVE without starting the scheduler. For emitOnce tests. */
51
- internal fun becomeLive() {
52
- state = SourceState.LIVE
53
- }
54
-
55
- override fun start(config: SourceConfig) {
56
- stop()
57
- becomeLive()
58
- val fps = this.config.fps.coerceAtLeast(1)
59
- val periodUs = 1_000_000L / fps
60
- val exec = Executors.newSingleThreadScheduledExecutor { runnable ->
61
- Thread(runnable, "acs-synthetic-i420").apply { isDaemon = true }
62
- }
63
- scheduler = exec
64
- exec.scheduleAtFixedRate({ emitOnce() }, 0, periodUs, TimeUnit.MICROSECONDS)
65
- }
66
-
67
- override fun restart(config: SourceConfig) {
68
- stop()
69
- start(config)
70
- }
71
-
72
- override fun stop() {
73
- scheduler?.shutdownNow()
74
- scheduler = null
75
- frameIndex.set(0)
76
- withheld.set(0)
77
- withheldThisGap.set(0)
78
- emitted.set(0)
79
- emitting.set(false)
80
- firstLogged.set(false)
81
- fpsWindowStartNs.set(0)
82
- fpsWindowCount.set(0)
83
- stampFps = null
84
- dest = null
85
- destWidth = 0
86
- destHeight = 0
87
- destY = null
88
- destU = null
89
- destV = null
90
- state = SourceState.IDLE
91
- }
92
-
93
- override fun setPcmDeliveryEnabled(enabled: Boolean) {
94
- pcmEnabled.set(enabled)
95
- }
96
-
97
- override fun setTargetSize(size: TargetSize?) {
98
- targetSize.set(size)
99
- }
100
-
101
- internal fun pcmDeliveryEnabled(): Boolean = pcmEnabled.get()
102
-
103
- /**
104
- * Generate-and-emit one frame if the sender is ready and a target size exists.
105
- * Returns true only when a frame was handed to [video].
106
- */
107
- internal fun emitOnce(): Boolean {
108
- if (state != SourceState.LIVE) return false
109
- val size = targetSize.get()
110
- val ready = isReady() && size != null
111
- if (!ready) {
112
- withheld.incrementAndGet()
113
- withheldThisGap.incrementAndGet()
114
- if (emitting.compareAndSet(true, false)) {
115
- logEvent("P6 synthetic paused reason=not-ready afterFrames=${emitted.get()}")
116
- }
117
- return false
118
- }
119
- val gap = withheldThisGap.getAndSet(0)
120
- if (!emitting.get() && emitted.get() > 0) {
121
- logEvent("P6 synthetic resumed withheld=$gap")
122
- }
123
- emitting.set(true)
124
-
125
- val w = size!!.width
126
- val h = size.height
127
- val buffer = ensureDest(w, h)
128
- val index = frameIndex.getAndIncrement()
129
- stampFps = sampleStampFps()
130
- factory.write(buffer, w, h, index, stampFps)
131
- stats.onSink()
132
- stats.recordGap()
133
- stats.setSize(w, h)
134
- val chroma = I420Packer.chromaStride(w)
135
- video.onVideoFrame(
136
- I420Planes(
137
- y = destY!!,
138
- strideY = w,
139
- u = destU!!,
140
- strideU = chroma,
141
- v = destV!!,
142
- strideV = chroma,
143
- width = w,
144
- height = h,
145
- timestampNs = System.nanoTime(),
146
- ),
147
- )
148
- val count = emitted.incrementAndGet()
149
- if (firstLogged.compareAndSet(false, true)) {
150
- logEvent(
151
- "P6 synthetic first-frame withheld=${withheld.get()} size=${w}x${h} " +
152
- "fps=${config.fps} entropy=${config.entropy}",
153
- )
154
- }
155
- return count > 0
156
- }
157
-
158
- /** 1-second emit rate, same window Recall uses for the P column on the stamp. */
159
- private fun sampleStampFps(): Double? {
160
- val now = System.nanoTime()
161
- val started = fpsWindowStartNs.get()
162
- if (started == 0L) {
163
- fpsWindowStartNs.set(now)
164
- fpsWindowCount.set(1)
165
- return stampFps
166
- }
167
- fpsWindowCount.incrementAndGet()
168
- val elapsedNs = now - started
169
- if (elapsedNs >= 1_000_000_000L) {
170
- val rate = fpsWindowCount.get() * 1_000_000_000.0 / elapsedNs
171
- fpsWindowCount.set(0)
172
- fpsWindowStartNs.set(now)
173
- return rate
174
- }
175
- return stampFps
176
- }
177
-
178
- private fun ensureDest(width: Int, height: Int): ByteBuffer {
179
- val needed = I420Packer.packedSize(width, height)
180
- val existing = dest
181
- if (existing != null && destWidth == width && destHeight == height && existing.capacity() >= needed) {
182
- existing.clear()
183
- return existing
184
- }
185
- val next = ByteBuffer.allocateDirect(needed)
186
- dest = next
187
- destWidth = width
188
- destHeight = height
189
- slicePlanes(next, width, height)
190
- return next
191
- }
192
-
193
- private fun slicePlanes(packed: ByteBuffer, width: Int, height: Int) {
194
- val ySize = width * height
195
- val uvSize = I420Packer.chromaStride(width) * I420Packer.chromaStride(height)
196
- val view = packed.duplicate()
197
- view.clear()
198
- destY = view.duplicate().apply { position(0); limit(ySize) }.slice()
199
- destU = view.duplicate().apply { position(ySize); limit(ySize + uvSize) }.slice()
200
- destV = view.duplicate().apply { position(ySize + uvSize); limit(ySize + 2 * uvSize) }.slice()
201
- }
202
-
203
- private fun logEvent(line: String) {
204
- synchronized(events) { events.add(line) }
205
- try {
206
- Log.i(TAG, line)
207
- } catch (_: Throwable) {
208
- }
209
- }
210
-
211
- companion object {
212
- private const val TAG = "ACS-SPIKE"
213
- }
214
- }
@@ -1,33 +0,0 @@
1
- package com.mentra.acsmeeting.source
2
-
3
- /**
4
- * Dedupe WebRTC track attachments by native track id.
5
- * Three observer callbacks can deliver distinct Java wrappers of one track;
6
- * object identity cannot be used. [reset] is load-bearing: [CloudflareWhepSource.start]
7
- * calls stop first, and a stale claim set makes reconnect never attach a sink. SoftAP
8
- * WHIP renegotiate reuses the same track id (`video0`); without a reset the new peer
9
- * decodes frames that never reach the ACS sink.
10
- */
11
- class TrackRegistry {
12
- private val ids = mutableSetOf<String>()
13
- private var skipped = 0
14
-
15
- @Synchronized
16
- fun claim(id: String): Boolean {
17
- if (ids.add(id)) return true
18
- skipped += 1
19
- return false
20
- }
21
-
22
- @Synchronized
23
- fun reset() {
24
- ids.clear()
25
- skipped = 0
26
- }
27
-
28
- @Synchronized
29
- fun size(): Int = ids.size
30
-
31
- @Synchronized
32
- fun skipped(): Int = skipped
33
- }
@@ -1,110 +0,0 @@
1
- package com.mentra.acsmeeting.source
2
-
3
- enum class VideoSourceArm { WHEP, SYNTHETIC }
4
-
5
- enum class SyntheticEntropy { CHEAP, MOTION, NOISE }
6
-
7
- enum class DecoderMode { TEXTURE, BYTE_BUFFER }
8
-
9
- enum class PixelFormatArm { I420, NV12 }
10
-
11
- enum class OutgoingRateArm { ADVERTISE_REQUESTED, CLAMP_TO_SOFTWARE_CEILING }
12
-
13
- /**
14
- * Investigation arm. SYNTHETIC bypasses glasses, Cloudflare, WHEP and decode.
15
- * Ships as WHEP. Flip locally to run the experiment; do not commit SYNTHETIC.
16
- *
17
- * [decoderMode] TEXTURE is the production default (shared EGL, MediaCodec to
18
- * Surface). BYTE_BUFFER is the A/B: no shared context, CPU planes, no
19
- * glReadPixels. 720p BYTE_BUFFER on SM_S948U decoded on hardware with
20
- * i420P95=0, but wire/codec never attached and busy drops climbed. Do not
21
- * commit BYTE_BUFFER until a same-phone 540p A/B clears the campaign gates.
22
- *
23
- * [zeroCopy] hands WebRTC I420 planes straight to ACS when they are tight and
24
- * retainable. Ships off. Do not commit true.
25
- *
26
- * [pixelFormat] I420 is the production default. NV12 is the encoder-flip A/B:
27
- * advertise and send biplanar NV12 in case ACS picks a hardware H.264
28
- * encoder. Revert unless `codecName` leaves `h264 sw`. Do not commit NV12
29
- * on a failed flip.
30
- */
31
- object AcsInvestigation {
32
- val videoArm = VideoSourceArm.WHEP
33
- val syntheticFps = 15
34
- val syntheticEntropy = SyntheticEntropy.MOTION
35
- val decoderMode = DecoderMode.TEXTURE
36
- val zeroCopy = false
37
- val pixelFormat = PixelFormatArm.I420
38
-
39
- /**
40
- * The rate we declare to ACS.
41
- *
42
- * CLAMP_TO_SOFTWARE_CEILING pins it to [VideoProfile.SOFTWARE_ENCODER_FPS]
43
- * whenever the profile asks for more. It was the default on the strength of
44
- * soaks that reported `codecName: "h264 sw"` alongside wire ≈ 8.8 fps, read as
45
- * an encoder ceiling. Two things in the device logs say it was never the
46
- * encoder.
47
- *
48
- * 720p15 and 540p15 both reported ~8.8 fps. 540p is 52% of the pixels of 720p,
49
- * so a CPU-bound encoder would have been close to twice as fast at the smaller
50
- * size; a limit that does not move with pixel count is not a pixel cost. And
51
- * once [FramePacer] delivered a steady cadence, `wire` tracked what we handed
52
- * ACS — 6.9 against 7.1 admitted — which is an encoder keeping up, not one
53
- * saturating. Both of those soaks ran through a send path that could not hold a
54
- * rate, so what they measured was our delivery.
55
- *
56
- * ADVERTISE_REQUESTED runs the configuration that was never actually tried:
57
- * declare the profile's own fps and let the corrected pacer deliver it. Read
58
- * the `P7 rate` verdict for what binds; it distinguishes an encoder that cannot
59
- * keep up from a rate controller starving one that can, and those two want
60
- * opposite fixes.
61
- */
62
- val outgoingRate = OutgoingRateArm.ADVERTISE_REQUESTED
63
-
64
- /**
65
- * libwebrtc's own `LS_INFO` stream during a SoftAP call.
66
- *
67
- * The one place that says which interfaces `BasicNetworkManager` kept, what adapter type it gave
68
- * each one, and every `BindSocketToNetwork` result — the evidence that separated a hotspot the
69
- * kernel had from one libwebrtc had erased. It is also loud, and it is the native stack's whole
70
- * log, not ours.
71
- *
72
- * Flip to false once the SoftAP path is green on device. Keep the `NetworkFacts` stages either
73
- * way: they are ours, bounded, and the thing that makes the next regression readable.
74
- */
75
- const val LIBWEBRTC_VERBOSE = true
76
-
77
- /**
78
- * How long to hold outgoing microphone PCM before the resampler, to align the wearer's voice
79
- * with their video at the far end.
80
- *
81
- * Ships at 0, and 0 is not a placeholder: the audio and the video reach ACS over different
82
- * transports (BLE LC3 versus SoftAP WebRTC) with different latencies, but *how* different is a
83
- * receiver-side measurement, not something either side can infer. Deriving it from first-frame
84
- * arrival times measures startup skew and would be wrong by however long the camera took to boot.
85
- *
86
- * Calibrate the ingest path with a clap in frame and read `AVSYNC clap audioLeadMs`. That is
87
- * the offset the delay line can cancel. ACS/Teams jitter after ingest still needs a Teams
88
- * recording. It is logged as `configuredDelayMs` and deliberately never reported as a measured
89
- * offset.
90
- */
91
- const val acsAudioDelayMs = 0
92
-
93
- /**
94
- * Whether outgoing audio buffers carry a presentation timestamp.
95
- *
96
- * Whether ACS honours these for lip-sync on *raw* audio is unanswered — the SDK exposes the
97
- * setter and documents nothing about the receiver. The only way to find out is to record the
98
- * Teams receiver with this on and with it off and compare, so it has to be switchable; a constant
99
- * true would leave two of the four measurement configurations unreachable.
100
- *
101
- * Defaults to true because the fallback is worse for an unrelated reason: a run of zero
102
- * timestamps is what makes the Teams jitter buffer hold.
103
- */
104
- const val acsAudioTimestamps = true
105
- }
106
-
107
- data class SyntheticConfig(
108
- val fps: Int = AcsInvestigation.syntheticFps,
109
- val entropy: SyntheticEntropy = AcsInvestigation.syntheticEntropy,
110
- )