@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,48 +0,0 @@
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
- /**
32
- * p95 in milliseconds, or -1 when nothing has been recorded.
33
- *
34
- * Numeric rather than the formatted [p95] because the ladder ranks stages against each other to
35
- * name the slowest one, and comparing the rendered strings would order "9.0" above "10.0".
36
- */
37
- @Synchronized
38
- fun p95Ms(): Double {
39
- if (count == 0) return -1.0
40
- val copy = samples.copyOf(count)
41
- copy.sort()
42
- val idx = ((copy.size - 1) * 0.95).roundToInt().coerceIn(0, copy.lastIndex)
43
- return copy[idx] / 1_000_000.0
44
- }
45
-
46
- @Synchronized
47
- fun size(): Int = count
48
- }
@@ -1,178 +0,0 @@
1
- package com.mentra.acsmeeting.telemetry
2
-
3
- import kotlin.math.roundToInt
4
-
5
- /**
6
- * How good the picture is, as opposed to how many frames of it there are.
7
- *
8
- * Every rate number we log can be healthy while Teams shows mush, because
9
- * "pixelated" is bits per pixel and nothing in the ladder computed that.
10
- * `wire=960x540@14.4 kbps=1276` reads fine and is 0.17 bits per pixel, which is
11
- * under what a head-mounted camera needs. ACS 2.16.0's
12
- * `OutgoingVideoStatistics` exposes no QP and no quality-limitation reason —
13
- * only codec, bitrate, packet count, rate, and size — so bits per pixel is the
14
- * only quality signal available and it has to be derived here.
15
- *
16
- * Bands come from the Kush gauge, the standard H.264 estimate
17
- * `bitrate ≈ width · height · fps · 0.07 · motion`, which rearranges to
18
- * `bpp ≈ 0.07 · motion`. Motion is 1 for a near-static scene, 2 for moderate,
19
- * and 4 for high. Glasses are strapped to someone's head, so the honest target
20
- * is the high-motion one — which is why a number sitting mid-band can still
21
- * look bad and the log says so rather than printing a bare "OK".
22
- */
23
- object VideoQuality {
24
- enum class Band {
25
- /** Below what even a static scene needs. Visible blocking. */
26
- BLOCKY,
27
-
28
- /** Enough for a near-static scene only. Soft, smeared on movement. */
29
- SOFT,
30
-
31
- /** Enough for moderate motion. Still short for a head-mounted camera. */
32
- OK,
33
-
34
- /** Enough for high motion, which is what glasses actually produce. */
35
- GOOD,
36
-
37
- UNKNOWN,
38
- }
39
-
40
- /** bpp ≈ 0.07 · motion, for motion = 1, 2, and 4. */
41
- const val STATIC_BPP = 0.07
42
- const val MODERATE_BPP = 0.14
43
- const val HIGH_MOTION_BPP = 0.28
44
-
45
- fun bitsPerPixel(bitrateBps: Long?, width: Int?, height: Int?, fps: Double?): Double? {
46
- if (bitrateBps == null || width == null || height == null || fps == null) return null
47
- if (bitrateBps <= 0 || width <= 0 || height <= 0 || fps <= 0.0) return null
48
- return bitrateBps / (width.toDouble() * height * fps)
49
- }
50
-
51
- fun band(bpp: Double?): Band = when {
52
- bpp == null -> Band.UNKNOWN
53
- bpp < STATIC_BPP -> Band.BLOCKY
54
- bpp < MODERATE_BPP -> Band.SOFT
55
- bpp < HIGH_MOTION_BPP -> Band.OK
56
- else -> Band.GOOD
57
- }
58
-
59
- /** Bitrate this geometry needs to reach [bpp], so the log can name the shortfall. */
60
- fun neededBps(width: Int, height: Int, fps: Double, bpp: Double): Long {
61
- if (width <= 0 || height <= 0 || fps <= 0.0) return 0L
62
- return (width.toDouble() * height * fps * bpp).toLong()
63
- }
64
-
65
- /**
66
- * Whether the granted ceiling can even reach high motion at this geometry.
67
- *
68
- * This is the check that turns "the picture is bad" into a decision. 540p15
69
- * against a 1.5 Mbps budget tops out at 0.19 bpp even if ACS spends every bit,
70
- * so no encoder change and no rate fix can make that combination look sharp on
71
- * a moving camera — the budget is the ceiling, and it has to move.
72
- */
73
- fun budgetCapsQuality(width: Int, height: Int, fps: Double, budgetBps: Int): Boolean {
74
- if (budgetBps <= 0) return false
75
- return budgetBps < neededBps(width, height, fps, HIGH_MOTION_BPP)
76
- }
77
-
78
- /**
79
- * Whether ACS is actually spending what it was granted.
80
- *
81
- * The grant is a target, not a wall — a device run recorded `used=108%`, the wire
82
- * at 1623 kbps against a 1.5 Mbps grant — so falling short of it is a choice the
83
- * rate controller made rather than a limit it hit. That makes the shortfall
84
- * diagnostic: something upstream of the ceiling is holding the bitrate down, and
85
- * [hint] uses CPU to say which thing.
86
- */
87
- const val SPENDING_FRACTION = 0.85
88
-
89
- /**
90
- * Process CPU at which a software encoder is a plausible cap.
91
- *
92
- * The figure is a percentage of a *single* core, not of the device, so on an
93
- * 8-core phone 88% means one core nearly full while seven sit idle. That still
94
- * binds a single-threaded encoder, which is why the threshold sits near one core
95
- * rather than near the machine.
96
- */
97
- const val ENCODER_BUSY_PERCENT = 90.0
98
-
99
- fun spendingBudget(wireBitrateBps: Long?, budgetBps: Int): Boolean {
100
- if (wireBitrateBps == null || budgetBps <= 0) return false
101
- return wireBitrateBps >= budgetBps * SPENDING_FRACTION
102
- }
103
-
104
- fun hint(
105
- band: Band,
106
- budgetCapped: Boolean,
107
- spendingBudget: Boolean,
108
- encoderBusy: Boolean,
109
- ): String = when {
110
- band == Band.UNKNOWN -> "no wire report yet"
111
- band == Band.GOOD -> "enough for high motion"
112
- !spendingBudget && encoderBusy ->
113
- "bits unspent with a core nearly full; encoder is the cap -- cut pixels, not the ceiling"
114
- !spendingBudget ->
115
- "bits unspent while CPU is idle; congestion control is holding the rate down, " +
116
- "so neither a bigger ceiling nor fewer pixels addresses it -- check the network"
117
- budgetCapped ->
118
- "budget cannot reach high motion at this geometry; raise maxBitrateBps or drop resolution"
119
- band == Band.BLOCKY -> "far below any usable bitrate; check for a rate-controller collapse"
120
- band == Band.SOFT -> "only enough for a static scene; a head camera will smear"
121
- else -> "fine for moderate motion, still short for a head-mounted camera"
122
- }
123
-
124
- /**
125
- * One line per tick. Prints the derived figures and the bitrate that would be
126
- * needed, because "0.17 bpp" alone does not tell anyone what to change.
127
- */
128
- fun line(
129
- wireWidth: Int?,
130
- wireHeight: Int?,
131
- wireFps: Double?,
132
- wireBitrateBps: Long?,
133
- budgetBps: Int,
134
- packetsPerSecond: Double?,
135
- cpuPercent: Double?,
136
- sendQuality: String,
137
- ): String {
138
- val bpp = bitsPerPixel(wireBitrateBps, wireWidth, wireHeight, wireFps)
139
- val band = band(bpp)
140
- val capped = if (wireWidth != null && wireHeight != null && wireFps != null) {
141
- budgetCapsQuality(wireWidth, wireHeight, wireFps, budgetBps)
142
- } else {
143
- false
144
- }
145
- val bitsPerFrame = if (wireBitrateBps != null && wireFps != null && wireFps > 0) {
146
- (wireBitrateBps / wireFps).roundToInt().toString()
147
- } else {
148
- "na"
149
- }
150
- val goodKbps = if (wireWidth != null && wireHeight != null && wireFps != null) {
151
- (neededBps(wireWidth, wireHeight, wireFps, HIGH_MOTION_BPP) / 1000).toString()
152
- } else {
153
- "na"
154
- }
155
- val usedKbps = wireBitrateBps?.let { (it / 1000).toString() } ?: "na"
156
- val usedPercent = if (wireBitrateBps != null && budgetBps > 0) {
157
- ((wireBitrateBps * 100.0 / budgetBps).roundToInt()).toString()
158
- } else {
159
- "na"
160
- }
161
- return "P9 quality band=$band bpp=${bpp?.let { decimal(it) } ?: "na"} " +
162
- "size=${wireWidth ?: 0}x${wireHeight ?: 0}@${wireFps?.let { PipelineStats.formatRate(it) } ?: "na"} " +
163
- "bitsPerFrame=$bitsPerFrame kbps=$usedKbps budgetKbps=${budgetBps / 1000} used=$usedPercent% " +
164
- "goodNeedsKbps=$goodKbps budgetCapped=$capped " +
165
- "pps=${packetsPerSecond?.let { PipelineStats.formatRate(it) } ?: "na"} " +
166
- "cpu=${cpuPercent?.let { PipelineStats.formatRate(it) } ?: "na"} " +
167
- "sendQuality=${sendQuality.ifBlank { "na" }} -- " +
168
- hint(
169
- band = band,
170
- budgetCapped = capped,
171
- spendingBudget = spendingBudget(wireBitrateBps, budgetBps),
172
- encoderBusy = cpuPercent != null && cpuPercent >= ENCODER_BUSY_PERCENT,
173
- )
174
- }
175
-
176
- /** Three places, because bpp differences that matter show up in the third. */
177
- private fun decimal(value: Double): String = ((value * 1000).roundToInt() / 1000.0).toString()
178
- }
@@ -1,145 +0,0 @@
1
- package com.mentra.acsmeeting.telemetry
2
-
3
- /**
4
- * Names what is holding the outgoing frame rate down.
5
- *
6
- * The fps question kept being answered by inference: a soak put ~8.8 fps on the
7
- * wire, so the ACS software encoder "holds 8.8 fps". That does not follow. At
8
- * least four different things produce a low wire rate, they are trivial to tell
9
- * apart from numbers we already collect, and two of them want opposite fixes —
10
- * cutting resolution rescues an encoder that is genuinely CPU-bound and makes a
11
- * bitrate-starved stream strictly worse.
12
- *
13
- * So this prints the answer instead of leaving it to be guessed:
14
- *
15
- * - [Bound.SOURCE_SHORT] the glasses are not sending enough frames. Nothing
16
- * downstream of the decoder can raise the rate.
17
- * - [Bound.PACER_SHORT] our own gate is the limit — it admitted fewer frames
18
- * than we advertised while the source had plenty. This is what a greedy
19
- * `FramePacer` did: 14.6 fps in, 7.3 out, against an advertised 8.
20
- * - [Bound.ENCODER_SHORT] ACS took the frames and put fewer on the wire while
21
- * the CPU was busy. The real encoder ceiling. Cut pixels.
22
- * - [Bound.BITRATE_STARVED] ACS took the frames and put fewer on the wire with
23
- * CPU to spare and the bitrate far under the budget we granted. The rate
24
- * controller is throttling an encoder that could keep up. Cutting fps or
25
- * pixels here makes it worse; fix delivery consistency.
26
- * - [Bound.CAP_BOUND] every frame is on the wire and the bitrate is pressed
27
- * against the ceiling we granted. Nothing is broken; the grant is simply the
28
- * limit, so the picture cannot get sharper until the number does.
29
- */
30
- object VideoRateVerdict {
31
- enum class Bound {
32
- OK,
33
- SOURCE_SHORT,
34
- PACER_SHORT,
35
- ENCODER_SHORT,
36
- BITRATE_STARVED,
37
- CAP_BOUND,
38
- UNKNOWN,
39
- }
40
-
41
- /** Below this fraction of the target a rate counts as short rather than noise. */
42
- const val SHORT_FRACTION = 0.85
43
-
44
- /**
45
- * The pacer is held to a tighter tolerance than anything else, because it is
46
- * deterministic code we own rather than a jittery encoder or radio. The bug
47
- * this class was written for delivered 7.3 against an advertised 8 — a 9%
48
- * chronic shortfall, comfortably inside [SHORT_FRACTION] and still enough to
49
- * bias the ACS rate controller down on every hiccup.
50
- */
51
- const val PACER_SHORT_FRACTION = 0.95
52
-
53
- /** Process CPU at or above this is treated as the encoder being the cost. */
54
- const val CPU_BUSY_PERCENT = 80.0
55
-
56
- /** Wire bitrate below this fraction of the granted budget is a throttled stream. */
57
- const val STARVED_FRACTION = 0.6
58
-
59
- /**
60
- * Wire bitrate at or above this fraction of the grant counts as riding the ceiling.
61
- *
62
- * The grant is a target rather than a wall — a device run recorded the wire at 1623 kbps
63
- * against a 1.5 Mbps grant, 108% — so "pressed against it" has to mean near, not exactly at.
64
- * 0.95 is below the noise on a controller that routinely overshoots and well above the 0.85
65
- * that [VideoQuality.SPENDING_FRACTION] treats as merely spending the budget.
66
- */
67
- const val CAP_BOUND_FRACTION = 0.95
68
-
69
- fun of(
70
- advertisedFps: Double,
71
- sinkFps: Double,
72
- admittedFps: Double,
73
- wireFps: Double?,
74
- wireBitrateBps: Long?,
75
- budgetBps: Int,
76
- cpuPercent: Double?,
77
- ): Bound {
78
- if (advertisedFps <= 0.0) return Bound.UNKNOWN
79
- // Source first: when the glasses are short, every rate below them is short
80
- // too, and blaming the encoder for that sends the next fix to the wrong leg.
81
- if (sinkFps < advertisedFps * SHORT_FRACTION) return Bound.SOURCE_SHORT
82
- // Against what the source actually supplied, not against the advertise. The
83
- // pacer cannot admit frames that never arrived, and scoring it on the
84
- // advertise alone would read a merely slow source as our own bug.
85
- val deliverable = minOf(advertisedFps, sinkFps)
86
- if (admittedFps < deliverable * PACER_SHORT_FRACTION) return Bound.PACER_SHORT
87
- if (wireFps == null) return Bound.UNKNOWN
88
- if (wireFps >= admittedFps * SHORT_FRACTION) {
89
- // A full frame rate is not the same as a good picture, and this is the one case where the
90
- // fix is the ceiling itself. Reporting it as a plain OK is how "the quality is low" turns
91
- // into an argument about the encoder: every rate number looks healthy, so the grant — the
92
- // one thing actually holding the bits down — never comes up.
93
- val capBound =
94
- wireBitrateBps != null && budgetBps > 0 && wireBitrateBps >= budgetBps * CAP_BOUND_FRACTION
95
- return if (capBound) Bound.CAP_BOUND else Bound.OK
96
- }
97
- if (cpuPercent != null && cpuPercent >= CPU_BUSY_PERCENT) return Bound.ENCODER_SHORT
98
- val starved =
99
- wireBitrateBps != null && budgetBps > 0 && wireBitrateBps < budgetBps * STARVED_FRACTION
100
- return if (starved) Bound.BITRATE_STARVED else Bound.ENCODER_SHORT
101
- }
102
-
103
- /** What to do about it, so the log line does not need a reader who has this file open. */
104
- fun hint(bound: Bound): String = when (bound) {
105
- Bound.OK -> "wire is holding the advertised rate"
106
- Bound.SOURCE_SHORT -> "glasses/WHIP leg is short; raising ACS fps cannot help"
107
- Bound.PACER_SHORT -> "our gate drops frames the source supplied; check FramePacer interval"
108
- Bound.ENCODER_SHORT -> "ACS encoder is the ceiling; cut pixels (VideoProfile.SD) not fps"
109
- Bound.BITRATE_STARVED -> "rate controller is throttling, not CPU; do not cut fps or pixels"
110
- Bound.CAP_BOUND -> "wire is riding the granted ceiling; raising maxBitrateBps is the only lever"
111
- Bound.UNKNOWN -> "not enough evidence yet"
112
- }
113
-
114
- /**
115
- * One line per tick. Prints every input beside the verdict: a verdict whose
116
- * inputs are not visible is the same guess this class exists to replace.
117
- */
118
- fun line(
119
- advertisedFps: Double,
120
- sinkFps: Double,
121
- admittedFps: Double,
122
- wireFps: Double?,
123
- wireBitrateBps: Long?,
124
- budgetBps: Int,
125
- cpuPercent: Double?,
126
- codecName: String,
127
- ): String {
128
- val bound = of(
129
- advertisedFps = advertisedFps,
130
- sinkFps = sinkFps,
131
- admittedFps = admittedFps,
132
- wireFps = wireFps,
133
- wireBitrateBps = wireBitrateBps,
134
- budgetBps = budgetBps,
135
- cpuPercent = cpuPercent,
136
- )
137
- val wireLabel = wireFps?.let { PipelineStats.formatRate(it) } ?: "na"
138
- val wireKbps = wireBitrateBps?.let { (it / 1000).toString() } ?: "na"
139
- return "P7 rate bound=$bound advertised=${PipelineStats.formatRate(advertisedFps)} " +
140
- "sink=${PipelineStats.formatRate(sinkFps)} admitted=${PipelineStats.formatRate(admittedFps)} " +
141
- "wire=$wireLabel kbps=$wireKbps budgetKbps=${budgetBps / 1000} " +
142
- "cpu=${cpuPercent?.let { PipelineStats.formatRate(it) } ?: "na"} " +
143
- "codec=${codecName.ifBlank { "na" }.replace(' ', '_')} -- ${hint(bound)}"
144
- }
145
- }
@@ -1,143 +0,0 @@
1
- package com.mentra.acsmeeting.trace
2
-
3
- import android.os.SystemClock
4
- import android.util.Log
5
-
6
- /**
7
- * Correlated stage logging for the SoftAP calling pipeline (phone side).
8
- *
9
- * TEMPORARY DIAGNOSTIC. Every line carries the literal `SOFTAP_TRACE` marker so a single
10
- * `rg -n 'SOFTAP_TRACE'` finds all of them at cleanup time. Flip [ENABLED] to false to mute the
11
- * layer for a release without deleting call sites.
12
- *
13
- * The logcat tag is deliberately distinct from the module's `ACS-SPIKE` tag so that
14
- * `adb logcat -s SOFTAP-TRACE` isolates the whole SoftAP pipeline in one stream.
15
- *
16
- * The phone mints [traceId] when it starts the hotspot and passes it to the glasses in
17
- * `start_stream`. Both devices stamp the same id on every line, which is the only way to correlate
18
- * two logs whose clocks were never synchronised.
19
- */
20
- object SoftApTrace {
21
-
22
- /** Master switch. Flip to false to mute the trace without removing call sites. */
23
- const val ENABLED = true
24
-
25
- /** Grep marker. Never build this by concatenation — a single grep must be exhaustive. */
26
- const val MARKER = "SOFTAP_TRACE"
27
-
28
- const val TAG = "SOFTAP-TRACE"
29
-
30
- private const val REDACTED = "<redacted>"
31
-
32
- /** Keys whose values must never reach logcat. Matched case-insensitively as substrings. */
33
- private val SENSITIVE_KEYS = listOf(
34
- "password", "passwd", "passphrase", "psk", "token", "secret",
35
- "credential", "authorization", "bearer", "meetingurl",
36
- )
37
-
38
- @Volatile
39
- private var traceId: String = ""
40
-
41
- @Volatile
42
- private var originMs: Long = 0L
43
-
44
- @Volatile
45
- private var lastStage: String = ""
46
-
47
- /** Mint a new trace id and reset the elapsed-time origin. Called when the hotspot request starts. */
48
- fun begin(id: String = newTraceId()): String {
49
- traceId = id
50
- originMs = SystemClock.elapsedRealtime()
51
- lastStage = ""
52
- return id
53
- }
54
-
55
- fun reset() {
56
- traceId = ""
57
- originMs = 0L
58
- lastStage = ""
59
- }
60
-
61
- fun traceId(): String = traceId
62
-
63
- /** Last stage successfully logged; use as failure context when reporting an error. */
64
- fun lastStage(): String = lastStage
65
-
66
- /** Short, collision-resistant enough for correlating one call's logs. */
67
- fun newTraceId(): String = java.lang.Long.toHexString(System.nanoTime() and 0xFFFFFFFFL)
68
-
69
- /**
70
- * Log one pipeline stage transition.
71
- *
72
- * @param stage stable snake_case stage name, e.g. `scoped_network_available`
73
- */
74
- fun stage(stage: String, vararg fields: Pair<String, Any?>) {
75
- if (!ENABLED) return
76
- lastStage = stage
77
- val elapsedMs = if (originMs == 0L) 0L else SystemClock.elapsedRealtime() - originMs
78
- Log.i(TAG, format(traceId, stage, elapsedMs, *fields))
79
- }
80
-
81
- /** Error-level variant so genuine failures survive a logcat level filter. */
82
- fun failure(stage: String, vararg fields: Pair<String, Any?>) {
83
- if (!ENABLED) return
84
- val elapsedMs = if (originMs == 0L) 0L else SystemClock.elapsedRealtime() - originMs
85
- Log.e(TAG, format(traceId, stage, elapsedMs, *fields, "afterStage" to lastStage))
86
- }
87
-
88
- /**
89
- * Pure formatter. Split out from [stage] so it can be unit tested without the Android logging
90
- * framework, which is not available to plain JVM tests in this module.
91
- */
92
- fun format(
93
- traceId: String,
94
- stage: String,
95
- elapsedMs: Long,
96
- vararg fields: Pair<String, Any?>,
97
- ): String = buildString {
98
- append('[').append(MARKER).append(']')
99
- if (traceId.isNotEmpty()) append(" traceId=").append(traceId)
100
- append(" stage=").append(stage)
101
- append(" elapsedMs=").append(elapsedMs)
102
- for ((key, value) in fields) {
103
- append(' ').append(key).append('=').append(sanitize(key, value))
104
- }
105
- }
106
-
107
- /**
108
- * Redact secrets outright and strip query strings from URLs. The local WHIP URL is genuinely
109
- * useful in a trace, but a URL carrying a watch token is not, so query and userinfo go.
110
- */
111
- fun sanitize(key: String, value: Any?): String {
112
- if (isSensitive(key)) return REDACTED
113
- if (value == null) return "null"
114
-
115
- val text = value.toString()
116
- if (text.isEmpty()) return "\"\""
117
- val cleaned = stripUrlSecrets(text)
118
- return if (cleaned.contains(' ')) "\"$cleaned\"" else cleaned
119
- }
120
-
121
- private fun isSensitive(key: String): Boolean {
122
- val lower = key.lowercase()
123
- return SENSITIVE_KEYS.any { lower.contains(it) }
124
- }
125
-
126
- /** Drop `?query`, `#fragment`, and `user:pass@` from anything URL-shaped. */
127
- private fun stripUrlSecrets(text: String): String {
128
- val schemeIndex = text.indexOf("://")
129
- if (schemeIndex < 0) return text
130
-
131
- var result = text
132
- val query = result.indexOf('?')
133
- if (query >= 0) result = result.substring(0, query) + "?" + REDACTED
134
- val fragment = result.indexOf('#')
135
- if (fragment >= 0) result = result.substring(0, fragment)
136
-
137
- val at = result.indexOf('@', schemeIndex + 3)
138
- if (at >= 0) {
139
- result = result.substring(0, schemeIndex + 3) + REDACTED + "@" + result.substring(at + 1)
140
- }
141
- return result
142
- }
143
- }
@@ -1,22 +0,0 @@
1
- package com.mentra.acsmeeting.video
2
-
3
- /**
4
- * Pack I420 in the buffer's coordinate system, never [VideoFrame.rotatedWidth].
5
- * cropAndScale and toI420 planes are un-rotated; mixing them with display size
6
- * overruns the plane at rotation 90/270.
7
- */
8
- object FrameGeometry {
9
- data class PackSize(
10
- val width: Int,
11
- val height: Int,
12
- val rotationNonZero: Boolean,
13
- )
14
-
15
- fun packSize(bufferWidth: Int, bufferHeight: Int, rotationDegrees: Int): PackSize {
16
- return PackSize(
17
- width = bufferWidth,
18
- height = bufferHeight,
19
- rotationNonZero = rotationDegrees % 360 != 0,
20
- )
21
- }
22
- }
@@ -1,72 +0,0 @@
1
- package com.mentra.acsmeeting.video
2
-
3
- import java.nio.ByteBuffer
4
- import kotlin.math.roundToInt
5
-
6
- /** Tight I420 pack + stride math. No Android / ACS / WebRTC types so JVM tests stay cheap. */
7
- object I420Packer {
8
- fun packedSize(width: Int, height: Int): Int {
9
- val chromaW = (width + 1) / 2
10
- val chromaH = (height + 1) / 2
11
- return width * height + 2 * chromaW * chromaH
12
- }
13
-
14
- fun chromaStride(width: Int): Int = (width + 1) / 2
15
-
16
- /**
17
- * Bytes a reader must be able to see for a plane with [stride], starting at
18
- * the buffer's current position. Last row is only [width] bytes, not a full
19
- * stride, so this is `stride*(height-1)+width`.
20
- */
21
- fun planeMinBytes(stride: Int, width: Int, height: Int): Int {
22
- if (width <= 0 || height <= 0 || stride < width) return 0
23
- return stride * (height - 1) + width
24
- }
25
-
26
- /**
27
- * Packs padded WebRTC planes into tight I420 (strideY=width, strideU=strideV=(width+1)/2).
28
- */
29
- fun pack(
30
- dataY: ByteBuffer,
31
- strideY: Int,
32
- dataU: ByteBuffer,
33
- strideU: Int,
34
- dataV: ByteBuffer,
35
- strideV: Int,
36
- width: Int,
37
- height: Int,
38
- dest: ByteBuffer,
39
- ) {
40
- dest.clear()
41
- val chromaW = chromaStride(width)
42
- val chromaH = chromaStride(height)
43
- copyPlane(dataY, strideY, width, height, dest)
44
- copyPlane(dataU, strideU, chromaW, chromaH, dest)
45
- copyPlane(dataV, strideV, chromaW, chromaH, dest)
46
- dest.flip()
47
- }
48
-
49
- fun copyPlane(src: ByteBuffer, stride: Int, width: Int, height: Int, dest: ByteBuffer) {
50
- val plane = src.duplicate()
51
- val origin = plane.position()
52
- if (stride == width && plane.remaining() >= width * height) {
53
- plane.limit(origin + width * height)
54
- dest.put(plane)
55
- return
56
- }
57
- for (row in 0 until height) {
58
- val start = origin + row * stride
59
- plane.limit(start + width)
60
- plane.position(start)
61
- dest.put(plane)
62
- }
63
- }
64
-
65
- fun percentileMs(samplesNs: LongArray, quantile: Double): String {
66
- if (samplesNs.isEmpty()) return "na"
67
- val sorted = samplesNs.copyOf()
68
- sorted.sort()
69
- val index = ((sorted.size - 1) * quantile).roundToInt().coerceIn(0, sorted.lastIndex)
70
- return ((sorted[index] / 1_000_000.0) * 10).roundToInt().div(10.0).toString()
71
- }
72
- }