@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,78 @@
1
+ package com.mentra.acsmeeting.telemetry
2
+
3
+ import com.mentra.acsmeeting.video.I420Packer
4
+ import java.nio.ByteBuffer
5
+ import kotlin.math.max
6
+
7
+ /** 64-sample plane averages. Neutral content sits near u≈v≈128. */
8
+ object ChromaProbe {
9
+ const val SAMPLES = 64
10
+
11
+ data class Sample(val y: Int, val u: Int, val v: Int)
12
+
13
+ fun sampleNv12(y: ByteBuffer, uv: ByteBuffer): Sample {
14
+ val yView = y.duplicate()
15
+ val uvView = uv.duplicate()
16
+ val origin = uvView.position()
17
+ val size = uvView.remaining()
18
+ return Sample(
19
+ y = average(yView, yView.position(), yView.remaining()),
20
+ u = averageEveryOther(uvView, origin, size, even = true),
21
+ v = averageEveryOther(uvView, origin, size, even = false),
22
+ )
23
+ }
24
+
25
+ fun samplePlanes(y: ByteBuffer, u: ByteBuffer, v: ByteBuffer): Sample {
26
+ val yView = y.duplicate()
27
+ val uView = u.duplicate()
28
+ val vView = v.duplicate()
29
+ return Sample(
30
+ y = average(yView, yView.position(), yView.remaining()),
31
+ u = average(uView, uView.position(), uView.remaining()),
32
+ v = average(vView, vView.position(), vView.remaining()),
33
+ )
34
+ }
35
+
36
+ fun samplePacked(src: ByteBuffer, width: Int, height: Int): Sample {
37
+ val ySize = width * height
38
+ val uvSize = I420Packer.chromaStride(width) * I420Packer.chromaStride(height)
39
+ val view = src.duplicate()
40
+ return Sample(
41
+ y = average(view, 0, ySize),
42
+ u = average(view, ySize, uvSize),
43
+ v = average(view, ySize + uvSize, uvSize),
44
+ )
45
+ }
46
+
47
+ fun average(buffer: ByteBuffer, offset: Int, size: Int): Int {
48
+ if (size <= 0) return 0
49
+ val step = max(1, size / SAMPLES)
50
+ var sum = 0L
51
+ var count = 0
52
+ var i = offset
53
+ val end = offset + size
54
+ while (i < end && count < SAMPLES) {
55
+ sum += buffer.get(i).toInt() and 0xFF
56
+ i += step
57
+ count += 1
58
+ }
59
+ return if (count == 0) 0 else (sum / count).toInt()
60
+ }
61
+
62
+ private fun averageEveryOther(buffer: ByteBuffer, offset: Int, size: Int, even: Boolean): Int {
63
+ if (size <= 1) return 0
64
+ val start = offset + if (even) 0 else 1
65
+ val raw = max(2, size / SAMPLES)
66
+ val step = raw + (raw and 1)
67
+ var sum = 0L
68
+ var count = 0
69
+ var i = start
70
+ val end = offset + size
71
+ while (i < end && count < SAMPLES) {
72
+ sum += buffer.get(i).toInt() and 0xFF
73
+ i += step
74
+ count += 1
75
+ }
76
+ return if (count == 0) 0 else (sum / count).toInt()
77
+ }
78
+ }
@@ -0,0 +1,281 @@
1
+ package com.mentra.acsmeeting.telemetry
2
+
3
+ import com.mentra.acsmeeting.video.I420Packer
4
+ import java.util.concurrent.atomic.AtomicInteger
5
+ import java.util.concurrent.atomic.AtomicLong
6
+ import kotlin.math.roundToInt
7
+
8
+ /**
9
+ * Monotonic pipeline counters. The 1 Hz [tick] prints rates for eyes; [cum]
10
+ * is what pass/fail uses.
11
+ *
12
+ * inFlight is derived (sink - sub - drop), never tracked. A tracked counter
13
+ * cannot be read atomically alongside the others, so onSub's increment-then-
14
+ * decrement window printed CONSERVE_FAIL on a healthy pipeline. Deriving it
15
+ * makes conservation an identity; reading sink last keeps it non-negative.
16
+ */
17
+ class PipelineStats(
18
+ private val nowMs: () -> Long = { System.currentTimeMillis() },
19
+ ) {
20
+ private val sink = AtomicInteger(0)
21
+ private val sub = AtomicInteger(0)
22
+ private val dropSize = AtomicInteger(0)
23
+ private val dropBusy = AtomicInteger(0)
24
+ private val dropNotStarted = AtomicInteger(0)
25
+ private val dropFail = AtomicInteger(0)
26
+ private val dropNullI420 = AtomicInteger(0)
27
+ private val abandoned = AtomicInteger(0)
28
+ private val dup = AtomicInteger(0)
29
+ private val rot = AtomicInteger(0)
30
+
31
+ private val destAlloc = AtomicInteger(0)
32
+ private val planeAlloc = AtomicInteger(0)
33
+
34
+ private val lastTickSink = AtomicInteger(0)
35
+ private val lastTickSub = AtomicInteger(0)
36
+ private val lastTickMs = AtomicLong(0)
37
+
38
+ private val lastArrivalNs = AtomicLong(0)
39
+
40
+ val gap = RingPercentile()
41
+ val pack = RingPercentile()
42
+ val scale = RingPercentile()
43
+ val toI420 = RingPercentile()
44
+ val sinkCb = RingPercentile()
45
+ val split = RingPercentile()
46
+ val copy = RingPercentile()
47
+ val send = RingPercentile()
48
+
49
+ /**
50
+ * WebRTC inbound-rtp disposition. Cumulative; the ladder prints deltas so a
51
+ * climbing counter is visible without reading absolute values.
52
+ */
53
+ data class RecvHealth(
54
+ val assembled: Long = -1L,
55
+ val dropped: Long = -1L,
56
+ val packetsLost: Long = -1L,
57
+ val nack: Long = -1L,
58
+ val pli: Long = -1L,
59
+ val freezes: Long = -1L,
60
+ val freezeSec: Double = -1.0,
61
+ val jitter: Double = -1.0,
62
+ val decodeSec: Double = -1.0,
63
+ val jitterBufferSec: Double = -1.0,
64
+ val jitterBufferEmits: Long = -1L,
65
+ val decImpl: String = "",
66
+ )
67
+
68
+ @Volatile var arm: String = "whep"
69
+ /** texture | bytebuf — which decoder factory we built. */
70
+ @Volatile var pathMode: String = "texture"
71
+ /** packsplit | planes | zerocopy | nv12 — how pixels reach ACS. */
72
+ @Volatile var pathCopy: String = "packsplit"
73
+ /** i420 | nv12 — pixel format advertised to ACS. */
74
+ @Volatile var pix: String = "i420"
75
+ /** MEDIA_STATISTICS codecName, underscored. Empty until the first report. */
76
+ @Volatile var codecName: String = ""
77
+ @Volatile var decodedFps: Double? = null
78
+ @Volatile var recvFps: Double? = null
79
+ @Volatile var wireFps: Double? = null
80
+ @Volatile var wireWidth: Int? = null
81
+ @Volatile var wireHeight: Int? = null
82
+ @Volatile var wireBitrateBps: Long? = null
83
+ @Volatile private var recv: RecvHealth? = null
84
+ @Volatile private var recvPrev: RecvHealth? = null
85
+ @Volatile var width: Int = 0
86
+ @Volatile var height: Int = 0
87
+ @Volatile var chromaY: Int = 0
88
+ @Volatile var chromaU: Int = 0
89
+ @Volatile var chromaV: Int = 0
90
+ @Volatile var strideY: Int = 0
91
+ @Volatile var strideU: Int = 0
92
+ @Volatile var strideV: Int = 0
93
+ @Volatile var zcOn: Int = 0
94
+
95
+ private val bufTex = AtomicInteger(0)
96
+ private val bufI420 = AtomicInteger(0)
97
+ private val bufOther = AtomicInteger(0)
98
+ private val strideTight = AtomicInteger(0)
99
+ private val stridePadded = AtomicInteger(0)
100
+ private val zcUsed = AtomicInteger(0)
101
+ private val zcFell = AtomicInteger(0)
102
+ private val zcPadded = AtomicInteger(0)
103
+ private val zcHeldMax = AtomicInteger(0)
104
+ private val zcTimeout = AtomicInteger(0)
105
+
106
+ fun onSink() {
107
+ sink.incrementAndGet()
108
+ }
109
+
110
+ fun recordGap(nowNs: Long = System.nanoTime()) {
111
+ val prev = lastArrivalNs.getAndSet(nowNs)
112
+ if (prev != 0L) gap.record(nowNs - prev)
113
+ }
114
+
115
+ /** Kept as the explicit hand-off point; inFlight is derived, so this records nothing. */
116
+ fun onQueued() = Unit
117
+
118
+ fun onSub() {
119
+ sub.incrementAndGet()
120
+ }
121
+
122
+ fun onDropSize() = dropSize.incrementAndGet()
123
+ fun onDropBusy() = dropBusy.incrementAndGet()
124
+ fun onDropNotStarted() = dropNotStarted.incrementAndGet()
125
+ fun onDropFail() = dropFail.incrementAndGet()
126
+ fun onDropMalformed() = dropFail.incrementAndGet()
127
+
128
+ /**
129
+ * A send that timed out. The future is not cancelled, so ACS may still read those
130
+ * direct buffers; they are never recycled. Non-zero here means the pool is leaking.
131
+ */
132
+ fun onAbandoned() = abandoned.incrementAndGet()
133
+
134
+ fun abandonedCount(): Int = abandoned.get()
135
+ fun onDropNullI420() = dropNullI420.incrementAndGet()
136
+ fun onDup() = dup.incrementAndGet()
137
+ fun onRotation() = rot.incrementAndGet()
138
+ fun onDestAlloc() = destAlloc.incrementAndGet()
139
+ fun onPlaneAlloc() = planeAlloc.incrementAndGet()
140
+ fun destAllocCount(): Int = destAlloc.get()
141
+ fun planeAllocCount(): Int = planeAlloc.get()
142
+
143
+ /**
144
+ * Classify the WebRTC [VideoFrame.Buffer] without importing WebRTC here.
145
+ * [kind] is "tex", "i420", or anything else ("other").
146
+ */
147
+ fun onFrameBuffer(kind: String) {
148
+ when (kind) {
149
+ "tex" -> bufTex.incrementAndGet()
150
+ "i420" -> bufI420.incrementAndGet()
151
+ else -> bufOther.incrementAndGet()
152
+ }
153
+ }
154
+
155
+ fun onStrides(strideY: Int, strideU: Int, strideV: Int, width: Int) {
156
+ this.strideY = strideY
157
+ this.strideU = strideU
158
+ this.strideV = strideV
159
+ val chroma = I420Packer.chromaStride(width)
160
+ if (strideY == width && strideU == chroma && strideV == chroma) {
161
+ strideTight.incrementAndGet()
162
+ } else {
163
+ stridePadded.incrementAndGet()
164
+ }
165
+ }
166
+
167
+ fun onZcUsed(heldNow: Int) {
168
+ zcUsed.incrementAndGet()
169
+ zcHeldMax.updateAndGet { current -> if (heldNow > current) heldNow else current }
170
+ }
171
+
172
+ fun onZcFell() = zcFell.incrementAndGet()
173
+ fun onZcPadded() = zcPadded.incrementAndGet()
174
+ fun onZcTimeout() = zcTimeout.incrementAndGet()
175
+
176
+ fun zcUsedCount(): Int = zcUsed.get()
177
+ fun zcFellCount(): Int = zcFell.get()
178
+ fun zcTimeoutCount(): Int = zcTimeout.get()
179
+ fun zcHeldMax(): Int = zcHeldMax.get()
180
+ fun strideTightCount(): Int = strideTight.get()
181
+ fun stridePaddedCount(): Int = stridePadded.get()
182
+ fun bufTexCount(): Int = bufTex.get()
183
+ fun bufI420Count(): Int = bufI420.get()
184
+ fun bufOtherCount(): Int = bufOther.get()
185
+
186
+ fun setSize(w: Int, h: Int) {
187
+ width = w
188
+ height = h
189
+ }
190
+
191
+ fun setRecvHealth(next: RecvHealth) {
192
+ recvPrev = recv
193
+ recv = next
194
+ }
195
+
196
+ /** Per-tick deltas plus the two absolutes that only mean something cumulatively. */
197
+ fun recvLabel(): String {
198
+ val now = recv ?: return "recv{na}"
199
+ val was = recvPrev
200
+ fun d(pick: (RecvHealth) -> Long): String {
201
+ val current = pick(now)
202
+ if (current < 0) return "na"
203
+ val previous = was?.let(pick) ?: return current.toString()
204
+ return if (previous < 0) current.toString() else (current - previous).toString()
205
+ }
206
+ val decodeMs = if (now.decodeSec < 0 || now.assembled <= 0) "na"
207
+ else format(now.decodeSec * 1000.0 / now.assembled)
208
+ val jbMs = if (now.jitterBufferSec < 0 || now.jitterBufferEmits <= 0) "na"
209
+ else format(now.jitterBufferSec * 1000.0 / now.jitterBufferEmits)
210
+ val impl = now.decImpl.ifBlank { "na" }.replace(' ', '_')
211
+ return "recv{drop=${d { it.dropped }} lost=${d { it.packetsLost }} nack=${d { it.nack }} " +
212
+ "pli=${d { it.pli }} freeze=${d { it.freezes }} freezeSec=${format(now.freezeSec)} " +
213
+ "jit=${format(now.jitter * 1000.0)} decMs=$decodeMs jbMs=$jbMs decImpl=$impl}"
214
+ }
215
+
216
+ fun setChroma(sample: ChromaProbe.Sample) {
217
+ chromaY = sample.y
218
+ chromaU = sample.u
219
+ chromaV = sample.v
220
+ }
221
+
222
+ fun sinkCount(): Int = sink.get()
223
+ fun subCount(): Int = sub.get()
224
+ fun dropCount(): Int = dropSize.get() + dropBusy.get() + dropNotStarted.get() + dropFail.get() + dropNullI420.get()
225
+
226
+ /** Read sink last: every frame hits sink before sub or drop, so this cannot go negative. */
227
+ fun inFlightCount(): Int {
228
+ val settled = sub.get() + dropCount()
229
+ return sink.get() - settled
230
+ }
231
+
232
+ fun dupCount(): Int = dup.get()
233
+ fun rotCount(): Int = rot.get()
234
+
235
+ fun conserved(): Boolean = inFlightCount() >= 0
236
+
237
+ fun tick(): String {
238
+ val now = nowMs()
239
+ val previous = lastTickMs.get()
240
+ val dt = if (previous == 0L) 1000L else (now - previous).coerceAtLeast(1L)
241
+ lastTickMs.set(now)
242
+ val sinkNow = sink.get()
243
+ val subNow = sub.get()
244
+ val sinkDelta = sinkNow - lastTickSink.getAndSet(sinkNow)
245
+ val subDelta = subNow - lastTickSub.getAndSet(subNow)
246
+ val sinkRate = rate(sinkDelta, dt)
247
+ val subRate = rate(subDelta, dt)
248
+ val dec = decodedFps?.let { formatRate(it) } ?: "na"
249
+ val rcv = recvFps?.let { formatRate(it) } ?: "na"
250
+ val wireFpsLabel = wireFps?.let { formatRate(it) } ?: "na"
251
+ val wireW = wireWidth ?: 0
252
+ val wireH = wireHeight ?: 0
253
+ val wireKbps = wireBitrateBps?.let { (it / 1000).toString() } ?: "na"
254
+ val codec = codecName.ifBlank { "na" }.replace(' ', '_')
255
+ val wire = "${wireW}x${wireH}@$wireFpsLabel kbps=$wireKbps codec=$codec"
256
+ val sizeLabel = if (width > 0 && height > 0) "${width}x${height}" else "0x0"
257
+ val inFlightNow = inFlightCount()
258
+ val conserve = if (inFlightNow >= 0) "" else " CONSERVE_FAIL"
259
+ return "P6 ladder arm=$arm $sizeLabel recv=$rcv dec=$dec sink=$sinkRate dup=${dup.get()} sub=$subRate wire=$wire rot=${rot.get()} " +
260
+ "drop{size=${dropSize.get()} busy=${dropBusy.get()} notStarted=${dropNotStarted.get()} fail=${dropFail.get()} nullI420=${dropNullI420.get()} abandoned=${abandoned.get()}} " +
261
+ "${recvLabel()} " +
262
+ "path{mode=$pathMode copy=$pathCopy pix=$pix} " +
263
+ "buf{tex=${bufTex.get()} i420=${bufI420.get()} other=${bufOther.get()}} " +
264
+ "stride{y=$strideY u=$strideU v=$strideV tight=${strideTight.get()} padded=${stridePadded.get()}} " +
265
+ "zc{on=$zcOn used=${zcUsed.get()} fell=${zcFell.get()} padded=${zcPadded.get()} heldMax=${zcHeldMax.get()} timeout=${zcTimeout.get()}} " +
266
+ "ms{gapP50=${gap.p50()} gapP95=${gap.p95()} i420P95=${toI420.p95()} packP95=${pack.p95()} scaleP95=${scale.p95()} sinkCbP95=${sinkCb.p95()} splitP95=${split.p95()} copyP95=${copy.p95()} sendP95=${send.p95()}} " +
267
+ "alloc{dest=${destAlloc.get()} plane=${planeAlloc.get()}} " +
268
+ "chroma{y=$chromaY u=$chromaU v=$chromaV} " +
269
+ "cum{sink=$sinkNow sub=$subNow drop=${dropCount()} inFlight=$inFlightNow}" +
270
+ conserve
271
+ }
272
+
273
+ private fun rate(delta: Int, dtMs: Long): String = formatRate(delta * 1000.0 / dtMs)
274
+
275
+ companion object {
276
+ fun formatRate(value: Double): String = ((value * 10).roundToInt() / 10.0).toString()
277
+
278
+ fun format(value: Double): String =
279
+ if (value < 0) "na" else ((value * 10).roundToInt() / 10.0).toString()
280
+ }
281
+ }
@@ -0,0 +1,51 @@
1
+ package com.mentra.acsmeeting.telemetry
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import android.os.Process
6
+
7
+ /** Timer-driven 1 Hz emit so a stall prints sink=0 instead of going silent. */
8
+ class PipelineTicker(
9
+ private val stats: PipelineStats,
10
+ looper: Looper = Looper.getMainLooper(),
11
+ private val elapsedCpuMs: () -> Long = { Process.getElapsedCpuTime() },
12
+ private val cores: Int = Runtime.getRuntime().availableProcessors(),
13
+ private val emit: (String) -> Unit,
14
+ ) {
15
+ private val handler = Handler(looper)
16
+ private var lastCpuMs = -1L
17
+ private var lastWallMs = 0L
18
+ private val tick = object : Runnable {
19
+ override fun run() {
20
+ emit("${stats.tick()} ${cpuLabel()}")
21
+ handler.postDelayed(this, INTERVAL_MS)
22
+ }
23
+ }
24
+
25
+ fun start() {
26
+ lastCpuMs = -1L
27
+ lastWallMs = 0L
28
+ handler.removeCallbacks(tick)
29
+ handler.post(tick)
30
+ }
31
+
32
+ fun stop() {
33
+ handler.removeCallbacks(tick)
34
+ }
35
+
36
+ private fun cpuLabel(): String {
37
+ val now = System.currentTimeMillis()
38
+ val cpu = elapsedCpuMs()
39
+ val prevCpu = lastCpuMs
40
+ val prevWall = lastWallMs
41
+ lastCpuMs = cpu
42
+ lastWallMs = now
43
+ val percent = if (prevCpu < 0 || prevWall <= 0) null
44
+ else ProcessCpu.percent(cpu - prevCpu, now - prevWall)
45
+ return ProcessCpu.label(percent, cores)
46
+ }
47
+
48
+ companion object {
49
+ const val INTERVAL_MS = 1000L
50
+ }
51
+ }
@@ -0,0 +1,15 @@
1
+ package com.mentra.acsmeeting.telemetry
2
+
3
+ /**
4
+ * Process CPU percent the same way `top` reports it: CPU-ms / wall-ms * 100.
5
+ * Can exceed 100 on multi-core. Pure so JVM tests stay cheap.
6
+ */
7
+ object ProcessCpu {
8
+ fun percent(cpuDeltaMs: Long, wallDeltaMs: Long): Double =
9
+ cpuDeltaMs * 100.0 / wallDeltaMs.coerceAtLeast(1L)
10
+
11
+ fun label(percent: Double?, cores: Int): String {
12
+ val proc = percent?.let { PipelineStats.format(it) } ?: "na"
13
+ return "cpu{proc=$proc cores=$cores}"
14
+ }
15
+ }
@@ -0,0 +1,33 @@
1
+ package com.mentra.acsmeeting.telemetry
2
+
3
+ import kotlin.math.roundToInt
4
+
5
+ /** Thread-safe ring of nanosecond samples. p50/p95 for the 1 Hz ladder. */
6
+ class RingPercentile(private val capacity: Int = 32) {
7
+ private val samples = LongArray(capacity)
8
+ private var count = 0
9
+ private var index = 0
10
+
11
+ @Synchronized
12
+ fun record(ns: Long) {
13
+ samples[index] = ns
14
+ index = (index + 1) % capacity
15
+ if (count < capacity) count += 1
16
+ }
17
+
18
+ fun p50(): String = percentile(0.50)
19
+
20
+ fun p95(): String = percentile(0.95)
21
+
22
+ @Synchronized
23
+ fun percentile(quantile: Double): String {
24
+ if (count == 0) return "na"
25
+ val copy = samples.copyOf(count)
26
+ copy.sort()
27
+ val idx = ((copy.size - 1) * quantile).roundToInt().coerceIn(0, copy.lastIndex)
28
+ return ((copy[idx] / 1_000_000.0) * 10).roundToInt().div(10.0).toString()
29
+ }
30
+
31
+ @Synchronized
32
+ fun size(): Int = count
33
+ }