@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.
- package/README.md +14 -3
- package/android/build.gradle +1 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +10 -10
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +37 -37
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +4 -4
- package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +3 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +12 -10
- package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +2 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/Nv12Packer.kt +2 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/{AcsInvestigationTest.kt → MediaDiagnosticsTest.kt} +10 -10
- package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +3 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +2 -2
- package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +2 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +2 -2
- package/build/AcsMeeting.types.d.ts +10 -1
- package/build/AcsMeeting.types.d.ts.map +1 -1
- package/build/AcsMeeting.types.js.map +1 -1
- package/build/AcsMeetingModule.d.ts +2 -2
- package/build/AcsMeetingModule.d.ts.map +1 -1
- package/build/AcsMeetingModule.js.map +1 -1
- package/ios/AcsMeeting.podspec +1 -1
- package/ios/AcsMeetingModule.swift +284 -148
- package/ios/PolicyKit/Package.swift +2 -1
- package/ios/PolicyKit/Sources/AcsAudioPolicy/CallbackOperation.swift +60 -0
- package/ios/PolicyKit/Sources/AcsAudioPolicy/CapturePolicy.swift +5 -0
- package/ios/PolicyKit/Tests/AcsAudioPolicyTests/AcsAudioPolicyTests.swift +1 -0
- package/ios/PolicyKit/Tests/AcsAudioPolicyTests/CallAgentRetirementTests.swift +113 -0
- package/package.json +3 -2
- package/src/AcsMeeting.types.ts +4 -1
- package/src/AcsMeetingModule.ts +2 -2
- package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +0 -291
- package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +0 -501
- package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +0 -51
- package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +0 -81
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +0 -355
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +0 -109
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +0 -160
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +0 -103
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +0 -53
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +0 -127
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +0 -573
- package/android/src/main/java/com/mentra/acsmeeting/source/BitmapFont.kt +0 -99
- package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +0 -487
- package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +0 -147
- package/android/src/main/java/com/mentra/acsmeeting/source/FirstFrameGate.kt +0 -45
- package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +0 -143
- package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +0 -772
- package/android/src/main/java/com/mentra/acsmeeting/source/MediaListeners.kt +0 -57
- package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +0 -17
- package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +0 -139
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +0 -144
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +0 -46
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +0 -160
- package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticFrameFactory.kt +0 -205
- package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticI420Source.kt +0 -214
- package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +0 -33
- package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +0 -110
- package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +0 -207
- package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +0 -356
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +0 -155
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/ChromaProbe.kt +0 -78
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +0 -372
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +0 -79
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/ProcessCpu.kt +0 -15
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +0 -48
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +0 -178
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +0 -145
- package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +0 -143
- package/android/src/main/java/com/mentra/acsmeeting/video/FrameGeometry.kt +0 -22
- package/android/src/main/java/com/mentra/acsmeeting/video/I420Packer.kt +0 -72
- package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +0 -102
- package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +0 -148
- package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +0 -59
- package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +0 -59
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +0 -35
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +0 -165
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +0 -96
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +0 -187
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +0 -172
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +0 -141
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +0 -69
- package/android/src/test/java/com/mentra/acsmeeting/source/BitmapFontTest.kt +0 -22
- package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +0 -41
- package/android/src/test/java/com/mentra/acsmeeting/source/FirstFrameGateTest.kt +0 -60
- package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +0 -151
- package/android/src/test/java/com/mentra/acsmeeting/source/I420PlanesTest.kt +0 -67
- package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +0 -165
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +0 -148
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +0 -76
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +0 -275
- package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticFrameFactoryTest.kt +0 -193
- package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticI420SourceTest.kt +0 -176
- package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +0 -45
- package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +0 -356
- package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +0 -411
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +0 -83
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/ChromaProbeTest.kt +0 -61
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +0 -322
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/ProcessCpuTest.kt +0 -18
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/RingPercentileTest.kt +0 -49
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +0 -149
- package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +0 -97
- package/android/src/test/java/com/mentra/acsmeeting/video/FrameGeometryTest.kt +0 -33
- package/android/src/test/java/com/mentra/acsmeeting/video/I420PackerTest.kt +0 -126
- package/ios/PolicyKit/Sources/AcsAudioPolicy/GlassesMediaSource.swift +0 -68
- 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
|
-
}
|