@mentra/acs-meeting 3.2.0-dev.235 → 3.2.0-dev.245
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/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +51 -41
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +400 -5
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/CallDiagnostics.kt +315 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/WireEpisodeTracker.kt +151 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +10 -1
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/CallDiagnosticsTest.kt +330 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/WireEpisodeTrackerTest.kt +180 -0
- package/package.json +2 -2
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.telemetry
|
|
2
|
+
|
|
3
|
+
import com.mentra.glassesmedia.telemetry.PipelineStats
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The field sets behind the ACS call diagnostics, separated from the session that emits them.
|
|
7
|
+
*
|
|
8
|
+
* TEMPORARY DIAGNOSTIC — these feed `scripts/acs-quality-compare.mjs` and go away with it. They
|
|
9
|
+
* live here because the session cannot be constructed in a JVM test (Android, plus the ACS SDK),
|
|
10
|
+
* and a trace whose field names drift from the analyzer that parses them is worse than no trace:
|
|
11
|
+
* the analyzer reports a call with no data rather than an error.
|
|
12
|
+
*/
|
|
13
|
+
object CallDiagnostics {
|
|
14
|
+
|
|
15
|
+
/** Dense sampling while a call is still settling. */
|
|
16
|
+
const val DENSE_INTERVAL_MS = 2_000L
|
|
17
|
+
|
|
18
|
+
/** And sparse once it has, so a 20-minute call does not bury the interesting first minute. */
|
|
19
|
+
const val SPARSE_INTERVAL_MS = 10_000L
|
|
20
|
+
|
|
21
|
+
/** How long after `connected` the dense rate lasts. */
|
|
22
|
+
const val DENSE_WINDOW_MS = 90_000L
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* "Visibly bad" and "gone", in bits per second. Same numbers as the analyzer.
|
|
26
|
+
*
|
|
27
|
+
* 500k at 540p15 is soft and blocky but legible; 250k is the mush the wearer calls potato. They
|
|
28
|
+
* are duplicated in `scripts/acs-quality-compare.mjs` on purpose — native decides when to look
|
|
29
|
+
* harder, the analyzer decides how to score, and neither should silently inherit the other's
|
|
30
|
+
* threshold. If one moves, move both.
|
|
31
|
+
*/
|
|
32
|
+
const val LOW_BITRATE_BPS = 500_000L
|
|
33
|
+
const val VERY_LOW_BITRATE_BPS = 250_000L
|
|
34
|
+
|
|
35
|
+
/** Above this the picture is back. Below it a call is still degraded, however far it has climbed. */
|
|
36
|
+
const val RECOVERED_BITRATE_BPS = 1_000_000L
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* How long a dip or a downscale holds the dense cadence after it ends.
|
|
40
|
+
*
|
|
41
|
+
* The climb back has been measured at ~170 s against a 90 s dip, so the recovery ramp is the
|
|
42
|
+
* larger half of what the wearer sits through and the part a sparse sampler renders as three
|
|
43
|
+
* points. Held open by time rather than by rate so the ramp is sampled all the way up.
|
|
44
|
+
*/
|
|
45
|
+
const val RECOVERY_WATCH_MS = 30_000L
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What the ACS hop is doing, which decides how often it is worth looking.
|
|
49
|
+
*
|
|
50
|
+
* The old cadence was dense for 90 s after `connected` and sparse forever after, on the
|
|
51
|
+
* assumption that a call which settles low does so early. An 8-minute capture disproved it: the
|
|
52
|
+
* collapse began at t=150 s, so the interesting 90 s was sampled every 10 s — nine points for
|
|
53
|
+
* the event the whole investigation was about — while the uneventful first minute got forty-five.
|
|
54
|
+
*
|
|
55
|
+
* `SILENT` is deliberately dense and deliberately not `LOW`. ACS publishing a report with unset
|
|
56
|
+
* fields is a fact about ACS, not a bitrate of zero, and the Start path has been observed doing
|
|
57
|
+
* it for most of a seven-minute call; that blindness is worth sampling closely for what the
|
|
58
|
+
* other columns still say (sent fps, subscriber count, glasses hop) even though the rate is
|
|
59
|
+
* unknown.
|
|
60
|
+
*/
|
|
61
|
+
enum class WireHealth { SETTLING, SILENT, LOW, RECOVERING, HEALTHY }
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param sinceConnectedMs negative before `connected`
|
|
65
|
+
* @param wireBitrateBps null or non-positive when ACS has not published a filled report
|
|
66
|
+
* @param msSinceLow time since the rate was last under [LOW_BITRATE_BPS], or -1 if never
|
|
67
|
+
* @param msSinceAdaptation time since the wire size last changed, or -1 if never
|
|
68
|
+
*/
|
|
69
|
+
fun wireHealth(
|
|
70
|
+
sinceConnectedMs: Long,
|
|
71
|
+
wireBitrateBps: Long?,
|
|
72
|
+
msSinceLow: Long,
|
|
73
|
+
msSinceAdaptation: Long,
|
|
74
|
+
): WireHealth {
|
|
75
|
+
if (sinceConnectedMs < 0 || sinceConnectedMs <= DENSE_WINDOW_MS) return WireHealth.SETTLING
|
|
76
|
+
if (wireBitrateBps == null || wireBitrateBps <= 0) return WireHealth.SILENT
|
|
77
|
+
if (wireBitrateBps < LOW_BITRATE_BPS) return WireHealth.LOW
|
|
78
|
+
val watching = (msSinceLow in 0..RECOVERY_WATCH_MS) || (msSinceAdaptation in 0..RECOVERY_WATCH_MS)
|
|
79
|
+
if (wireBitrateBps < RECOVERED_BITRATE_BPS || watching) return WireHealth.RECOVERING
|
|
80
|
+
return WireHealth.HEALTHY
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Sparse only when there is nothing happening; everything else is worth 2 s. */
|
|
84
|
+
fun sampleIntervalMs(health: WireHealth): Long =
|
|
85
|
+
if (health == WireHealth.HEALTHY) SPARSE_INTERVAL_MS else DENSE_INTERVAL_MS
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* One sample of both hops at one instant.
|
|
89
|
+
*
|
|
90
|
+
* Every rate is nullable or negative-for-unknown rather than defaulted to zero: a call with no
|
|
91
|
+
* MEDIA_STATISTICS report yet and a call sending nothing look identical once both print 0.
|
|
92
|
+
*/
|
|
93
|
+
data class BweSample(
|
|
94
|
+
val state: String,
|
|
95
|
+
val sinceJoinMs: Long,
|
|
96
|
+
val sinceConnectedMs: Long,
|
|
97
|
+
val lobbyDwellMs: Long,
|
|
98
|
+
val sendQuality: String,
|
|
99
|
+
val wireBitrateBps: Long?,
|
|
100
|
+
val wireWidth: Int?,
|
|
101
|
+
val wireHeight: Int?,
|
|
102
|
+
val sentFps: Double,
|
|
103
|
+
val wireFps: Double?,
|
|
104
|
+
/**
|
|
105
|
+
* The glasses hop's rate at this instant.
|
|
106
|
+
*
|
|
107
|
+
* On the same line as the ACS rate rather than left to `whip_ingest_sample`, because the
|
|
108
|
+
* comparison between the two is the finding and pairing them by log timestamp was guesswork.
|
|
109
|
+
* A collapsed wire next to a full-rate source is ACS's rate controller; both low is a starved
|
|
110
|
+
* pipeline. Those need different fixes and the analyzer should not have to infer which it is.
|
|
111
|
+
*/
|
|
112
|
+
val inboundBitrateBps: Long?,
|
|
113
|
+
val inboundFps: Double?,
|
|
114
|
+
val decodedFps: Double?,
|
|
115
|
+
val framesGated: Int,
|
|
116
|
+
val pacerDrops: Int,
|
|
117
|
+
val budgetBps: Int,
|
|
118
|
+
val rateArm: String,
|
|
119
|
+
/** How many MEDIA_STATISTICS reports this call has received so far. Zero with a live `sentFps` is "ACS is silent", not "we are sending nothing". */
|
|
120
|
+
val mediaStatsReports: Int = 0,
|
|
121
|
+
val mediaStatsAttached: Boolean = false,
|
|
122
|
+
/** `none` / ACS `VideoStreamState` name. Independent of whether MEDIA_STATISTICS ever reports. */
|
|
123
|
+
val videoOut: String = "none",
|
|
124
|
+
val packetsPerSecond: Double? = null,
|
|
125
|
+
val subCount: Int = 0,
|
|
126
|
+
val sinkCount: Int = 0,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `origin` and `callId` on the front of every diagnostic.
|
|
131
|
+
*
|
|
132
|
+
* Without `origin` the Start-versus-Join comparison cannot be made from the log at all; without
|
|
133
|
+
* `callId` two calls in one capture merge into one timeline whenever the trace id is reused.
|
|
134
|
+
*/
|
|
135
|
+
fun stamp(origin: String, callId: String, vararg fields: Pair<String, Any?>): Array<Pair<String, Any?>> =
|
|
136
|
+
arrayOf("origin" to origin.ifEmpty { "unknown" }, "callId" to callId.ifEmpty { "none" }, *fields)
|
|
137
|
+
|
|
138
|
+
fun bweFields(origin: String, callId: String, sample: BweSample): Array<Pair<String, Any?>> = stamp(
|
|
139
|
+
origin,
|
|
140
|
+
callId,
|
|
141
|
+
"state" to sample.state,
|
|
142
|
+
"sinceJoinMs" to sample.sinceJoinMs,
|
|
143
|
+
"sinceConnectedMs" to sample.sinceConnectedMs,
|
|
144
|
+
"lobbyDwellMs" to sample.lobbyDwellMs,
|
|
145
|
+
"sendQuality" to sample.sendQuality.ifBlank { "na" },
|
|
146
|
+
// The ACS hop: what MEDIA_STATISTICS says actually left this phone, which is what the far end
|
|
147
|
+
// sees and what the comparison ranks calls by.
|
|
148
|
+
"wireBitrateBps" to (sample.wireBitrateBps ?: -1L),
|
|
149
|
+
"wireWidth" to (sample.wireWidth ?: -1),
|
|
150
|
+
"wireHeight" to (sample.wireHeight ?: -1),
|
|
151
|
+
"sentFps" to PipelineStats.formatRate(sample.sentFps),
|
|
152
|
+
"wireFps" to rate(sample.wireFps),
|
|
153
|
+
// The glasses hop at the same instant: a phone sending little because it is receiving little
|
|
154
|
+
// is a different fault from one throttling its own uplink from plenty.
|
|
155
|
+
"inboundBitrateBps" to (sample.inboundBitrateBps ?: -1L),
|
|
156
|
+
"inboundFps" to rate(sample.inboundFps),
|
|
157
|
+
"decodedFps" to rate(sample.decodedFps),
|
|
158
|
+
"framesGated" to sample.framesGated,
|
|
159
|
+
"pacerDrops" to sample.pacerDrops,
|
|
160
|
+
"budgetBps" to sample.budgetBps,
|
|
161
|
+
"p7RateBound" to sample.rateArm,
|
|
162
|
+
// Present even when `wireBitrateBps` stays -1, so a Start that never gets MEDIA_STATISTICS
|
|
163
|
+
// still has a row that can be compared to Join on "is the phone feeding frames" and "did ACS
|
|
164
|
+
// attach / report".
|
|
165
|
+
"mediaStatsReports" to sample.mediaStatsReports,
|
|
166
|
+
"mediaStatsAttached" to sample.mediaStatsAttached,
|
|
167
|
+
"videoOut" to sample.videoOut.ifBlank { "none" },
|
|
168
|
+
"packetsPerSecond" to rate(sample.packetsPerSecond),
|
|
169
|
+
"subCount" to sample.subCount,
|
|
170
|
+
"sinkCount" to sample.sinkCount,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
fun mediaStatsAttachFields(
|
|
174
|
+
origin: String,
|
|
175
|
+
callId: String,
|
|
176
|
+
ok: Boolean,
|
|
177
|
+
error: String = "",
|
|
178
|
+
): Array<Pair<String, Any?>> = stamp(
|
|
179
|
+
origin,
|
|
180
|
+
callId,
|
|
181
|
+
"ok" to ok,
|
|
182
|
+
"error" to error.ifBlank { "none" },
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
fun mediaStatsIntervalFields(
|
|
186
|
+
origin: String,
|
|
187
|
+
callId: String,
|
|
188
|
+
attempt: Int,
|
|
189
|
+
ok: Boolean,
|
|
190
|
+
seconds: Int,
|
|
191
|
+
error: String = "",
|
|
192
|
+
): Array<Pair<String, Any?>> = stamp(
|
|
193
|
+
origin,
|
|
194
|
+
callId,
|
|
195
|
+
"attempt" to attempt,
|
|
196
|
+
"ok" to ok,
|
|
197
|
+
"seconds" to seconds,
|
|
198
|
+
"error" to error.ifBlank { "none" },
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
fun mediaStatsReportFields(
|
|
202
|
+
origin: String,
|
|
203
|
+
callId: String,
|
|
204
|
+
n: Int,
|
|
205
|
+
videos: Int,
|
|
206
|
+
audios: Int,
|
|
207
|
+
wireBitrateBps: Long?,
|
|
208
|
+
width: Int?,
|
|
209
|
+
height: Int?,
|
|
210
|
+
fps: Double?,
|
|
211
|
+
codec: String,
|
|
212
|
+
packetCount: Int?,
|
|
213
|
+
): Array<Pair<String, Any?>> = stamp(
|
|
214
|
+
origin,
|
|
215
|
+
callId,
|
|
216
|
+
"n" to n,
|
|
217
|
+
"videos" to videos,
|
|
218
|
+
"audios" to audios,
|
|
219
|
+
"wireBitrateBps" to (wireBitrateBps ?: -1L),
|
|
220
|
+
"width" to (width ?: -1),
|
|
221
|
+
"height" to (height ?: -1),
|
|
222
|
+
"fps" to rate(fps),
|
|
223
|
+
"codec" to codec.ifBlank { "na" }.replace(' ', '_'),
|
|
224
|
+
"packetCount" to (packetCount ?: -1),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The wire size ACS actually chose, logged on change.
|
|
229
|
+
*
|
|
230
|
+
* A permanent downscale is the single most misleading thing in these captures: ACS trades
|
|
231
|
+
* resolution for frames inside its budget, so 320x180 at a steady 15 fps prints as a healthy
|
|
232
|
+
* ladder line and reads like a working call. Until this event existed the drop was a `Log.w` the
|
|
233
|
+
* analyzer could not see, which is how a call that spent 90 s at 320x180 was scored as fine.
|
|
234
|
+
*
|
|
235
|
+
* `ceilingBps` rides along because the whole point of the ceiling A/B is to ask whether a
|
|
236
|
+
* smaller grant downscales less, and an event that cannot name its own arm cannot answer that.
|
|
237
|
+
*/
|
|
238
|
+
fun wireAdaptationFields(
|
|
239
|
+
origin: String,
|
|
240
|
+
callId: String,
|
|
241
|
+
width: Int,
|
|
242
|
+
height: Int,
|
|
243
|
+
direction: String,
|
|
244
|
+
sinceConnectedMs: Long,
|
|
245
|
+
askedWidth: Int,
|
|
246
|
+
askedHeight: Int,
|
|
247
|
+
wireBitrateBps: Long?,
|
|
248
|
+
ceilingBps: Int,
|
|
249
|
+
fps: Double?,
|
|
250
|
+
): Array<Pair<String, Any?>> = stamp(
|
|
251
|
+
origin,
|
|
252
|
+
callId,
|
|
253
|
+
"width" to width,
|
|
254
|
+
"height" to height,
|
|
255
|
+
"direction" to direction,
|
|
256
|
+
"sinceConnectedMs" to sinceConnectedMs,
|
|
257
|
+
"asked" to "${askedWidth}x$askedHeight",
|
|
258
|
+
"percentOfAsked" to percentOf(width, height, askedWidth, askedHeight),
|
|
259
|
+
"wireBitrateBps" to (wireBitrateBps ?: -1L),
|
|
260
|
+
"ceilingBps" to ceilingBps,
|
|
261
|
+
"fps" to rate(fps),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* One low-bitrate episode, as it begins, as it ends, and once it is paid for.
|
|
266
|
+
*
|
|
267
|
+
* Emitted as well as being derivable from the samples because the two answer different
|
|
268
|
+
* questions. The analyzer reconstructs episodes from whatever it can see, and says so when that
|
|
269
|
+
* is thin; this event is the session's own account, so an episode is on the record even when
|
|
270
|
+
* MEDIA_STATISTICS went quiet in the middle of it and the reconstruction has a hole.
|
|
271
|
+
*
|
|
272
|
+
* `inboundBitrateBps` is the glasses hop measured during the same window. It is the field that
|
|
273
|
+
* assigns blame, and it belongs on the episode rather than only on the periodic sample: a full
|
|
274
|
+
* source rate alongside a collapsed wire rate is ACS's rate controller and nothing upstream.
|
|
275
|
+
*/
|
|
276
|
+
fun episodeFields(
|
|
277
|
+
origin: String,
|
|
278
|
+
callId: String,
|
|
279
|
+
episode: String,
|
|
280
|
+
startedAtMs: Long,
|
|
281
|
+
durationMs: Long,
|
|
282
|
+
minBitrateBps: Long,
|
|
283
|
+
minResolution: String,
|
|
284
|
+
recoveryMs: Long,
|
|
285
|
+
inboundBitrateBps: Double?,
|
|
286
|
+
sentFps: Double,
|
|
287
|
+
ceilingBps: Int,
|
|
288
|
+
): Array<Pair<String, Any?>> = stamp(
|
|
289
|
+
origin,
|
|
290
|
+
callId,
|
|
291
|
+
"episode" to episode,
|
|
292
|
+
"startedAtMs" to startedAtMs,
|
|
293
|
+
"durationMs" to durationMs,
|
|
294
|
+
"minBitrateBps" to minBitrateBps,
|
|
295
|
+
"minResolution" to minResolution.ifBlank { "na" },
|
|
296
|
+
// -1 rather than 0: "has not climbed back yet" and "climbed back instantly" are different
|
|
297
|
+
// findings, and on the capture that started this the difference was 170 seconds.
|
|
298
|
+
"recoveryMs" to recoveryMs,
|
|
299
|
+
"inboundBitrateBps" to rate(inboundBitrateBps),
|
|
300
|
+
"sentFps" to PipelineStats.formatRate(sentFps),
|
|
301
|
+
"ceilingBps" to ceilingBps,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
private fun percentOf(width: Int, height: Int, askedWidth: Int, askedHeight: Int): Int {
|
|
305
|
+
val asked = askedWidth.toLong() * askedHeight
|
|
306
|
+
if (asked <= 0) return -1
|
|
307
|
+
return (width.toLong() * height * 100 / asked).toInt()
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Dense until [DENSE_WINDOW_MS] past `connected`; -1 for "not connected yet" keeps it dense. */
|
|
311
|
+
fun sampleIntervalMs(sinceConnectedMs: Long): Long =
|
|
312
|
+
if (sinceConnectedMs in 0..DENSE_WINDOW_MS || sinceConnectedMs < 0) DENSE_INTERVAL_MS else SPARSE_INTERVAL_MS
|
|
313
|
+
|
|
314
|
+
private fun rate(value: Double?): String = value?.let { PipelineStats.formatRate(it) } ?: "na"
|
|
315
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.telemetry
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Watches the ACS uplink rate and calls out each stretch where the picture went bad.
|
|
5
|
+
*
|
|
6
|
+
* TEMPORARY DIAGNOSTIC — pairs with `scripts/acs-quality-compare.mjs` and goes away with it.
|
|
7
|
+
*
|
|
8
|
+
* This exists because the failure that matters is an interval, not a number. The capture that
|
|
9
|
+
* motivated it ran eight minutes: the wire sat at ~1.3 Mbps and 960x540, fell to 33 kbps and
|
|
10
|
+
* 320x180 at t=150 s, stayed there about 90 s, then took another ~170 s to climb back — while the
|
|
11
|
+
* glasses hop held ~2 Mbps at 14.5 fps throughout and the phone kept handing ACS 15 fps. No single
|
|
12
|
+
* sample says that. A mean over the call says the opposite.
|
|
13
|
+
*
|
|
14
|
+
* It is a separate, dependency-free class so the state machine can be tested without Android or
|
|
15
|
+
* the ACS SDK, which the session cannot be.
|
|
16
|
+
*
|
|
17
|
+
* The one rule that is easy to get wrong: **a missing rate is not a low rate.** ACS publishes
|
|
18
|
+
* MEDIA_STATISTICS reports with the fields unset — 32 empty against 12 filled across one
|
|
19
|
+
* seven-minute call — and treating those as zero would invent an episode for every silence and
|
|
20
|
+
* bury the real ones. Silence leaves the state machine exactly where it was.
|
|
21
|
+
*/
|
|
22
|
+
class WireEpisodeTracker(
|
|
23
|
+
private val lowBps: Long = CallDiagnostics.LOW_BITRATE_BPS,
|
|
24
|
+
private val recoveredBps: Long = CallDiagnostics.RECOVERED_BITRATE_BPS,
|
|
25
|
+
) {
|
|
26
|
+
|
|
27
|
+
/** What just happened to the episode, if anything. */
|
|
28
|
+
enum class Phase {
|
|
29
|
+
/** The rate just fell below the low threshold. */
|
|
30
|
+
BEGIN,
|
|
31
|
+
|
|
32
|
+
/** It just came back above it. The dip is over; the climb is not. */
|
|
33
|
+
END,
|
|
34
|
+
|
|
35
|
+
/** It reached [recoveredBps]. This is when the wearer has their picture back. */
|
|
36
|
+
RECOVERED,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param durationMs time under the threshold; 0 on [Phase.BEGIN], final on [Phase.END]
|
|
41
|
+
* @param recoveryMs time from the end of the dip to [recoveredBps]; -1 until [Phase.RECOVERED]
|
|
42
|
+
* @param inboundBitrateBps the glasses hop during the episode — the field that assigns blame
|
|
43
|
+
*/
|
|
44
|
+
data class Event(
|
|
45
|
+
val phase: Phase,
|
|
46
|
+
val startedAtMs: Long,
|
|
47
|
+
val durationMs: Long,
|
|
48
|
+
val minBitrateBps: Long,
|
|
49
|
+
val minResolution: String,
|
|
50
|
+
val recoveryMs: Long,
|
|
51
|
+
val inboundBitrateBps: Double?,
|
|
52
|
+
val sentFps: Double,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
private var startedAtMs = -1L
|
|
56
|
+
private var endedAtMs = -1L
|
|
57
|
+
private var minBitrateBps = Long.MAX_VALUE
|
|
58
|
+
private var minPixels = Long.MAX_VALUE
|
|
59
|
+
private var minResolution = ""
|
|
60
|
+
private var inboundSum = 0.0
|
|
61
|
+
private var inboundCount = 0
|
|
62
|
+
private var inEpisode = false
|
|
63
|
+
private var awaitingRecovery = false
|
|
64
|
+
|
|
65
|
+
/** True while the wire is under the threshold, so the caller can hold its dense cadence. */
|
|
66
|
+
fun isInEpisode(): Boolean = inEpisode
|
|
67
|
+
|
|
68
|
+
/** True after a dip has ended but before the rate has climbed back to [recoveredBps]. */
|
|
69
|
+
fun isAwaitingRecovery(): Boolean = awaitingRecovery
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Feed one observation. Returns the transition it caused, or null for "carry on".
|
|
73
|
+
*
|
|
74
|
+
* At most one transition per observation: a rate that jumps from 33 kbps straight past 1 Mbps
|
|
75
|
+
* reports [Phase.END] now and [Phase.RECOVERED] on the next observation, so no caller has to
|
|
76
|
+
* unpack two events from one return.
|
|
77
|
+
*/
|
|
78
|
+
fun observe(
|
|
79
|
+
atMs: Long,
|
|
80
|
+
bitrateBps: Long?,
|
|
81
|
+
width: Int?,
|
|
82
|
+
height: Int?,
|
|
83
|
+
inboundBitrateBps: Double?,
|
|
84
|
+
sentFps: Double,
|
|
85
|
+
): Event? {
|
|
86
|
+
// Silence is not evidence. See the class comment: this is the branch that keeps ACS's empty
|
|
87
|
+
// reports from manufacturing an episode per gap.
|
|
88
|
+
if (bitrateBps == null || bitrateBps <= 0) return null
|
|
89
|
+
|
|
90
|
+
if (inEpisode) {
|
|
91
|
+
if (bitrateBps < lowBps) {
|
|
92
|
+
accumulate(atMs, bitrateBps, width, height, inboundBitrateBps)
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
inEpisode = false
|
|
96
|
+
awaitingRecovery = true
|
|
97
|
+
endedAtMs = atMs
|
|
98
|
+
return event(Phase.END, atMs, recoveryMs = -1L, sentFps = sentFps)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (bitrateBps < lowBps) {
|
|
102
|
+
// A dip that returns before recovering is a new episode rather than a continuation: the
|
|
103
|
+
// wearer saw the picture come back and go again, and averaging the two would hide that.
|
|
104
|
+
startedAtMs = atMs
|
|
105
|
+
endedAtMs = -1L
|
|
106
|
+
minBitrateBps = Long.MAX_VALUE
|
|
107
|
+
minPixels = Long.MAX_VALUE
|
|
108
|
+
minResolution = ""
|
|
109
|
+
inboundSum = 0.0
|
|
110
|
+
inboundCount = 0
|
|
111
|
+
inEpisode = true
|
|
112
|
+
awaitingRecovery = false
|
|
113
|
+
accumulate(atMs, bitrateBps, width, height, inboundBitrateBps)
|
|
114
|
+
return event(Phase.BEGIN, atMs, recoveryMs = -1L, sentFps = sentFps)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (awaitingRecovery && bitrateBps >= recoveredBps) {
|
|
118
|
+
awaitingRecovery = false
|
|
119
|
+
return event(Phase.RECOVERED, endedAtMs, recoveryMs = atMs - endedAtMs, sentFps = sentFps)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private fun accumulate(atMs: Long, bitrateBps: Long, width: Int?, height: Int?, inboundBitrateBps: Double?) {
|
|
126
|
+
endedAtMs = atMs
|
|
127
|
+
if (bitrateBps < minBitrateBps) minBitrateBps = bitrateBps
|
|
128
|
+
if (width != null && height != null && width > 0 && height > 0) {
|
|
129
|
+
val pixels = width.toLong() * height
|
|
130
|
+
if (pixels < minPixels) {
|
|
131
|
+
minPixels = pixels
|
|
132
|
+
minResolution = "${width}x$height"
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (inboundBitrateBps != null && inboundBitrateBps > 0) {
|
|
136
|
+
inboundSum += inboundBitrateBps
|
|
137
|
+
inboundCount += 1
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private fun event(phase: Phase, atMs: Long, recoveryMs: Long, sentFps: Double): Event = Event(
|
|
142
|
+
phase = phase,
|
|
143
|
+
startedAtMs = startedAtMs,
|
|
144
|
+
durationMs = if (phase == Phase.BEGIN) 0L else (atMs - startedAtMs).coerceAtLeast(0L),
|
|
145
|
+
minBitrateBps = if (minBitrateBps == Long.MAX_VALUE) -1L else minBitrateBps,
|
|
146
|
+
minResolution = minResolution,
|
|
147
|
+
recoveryMs = recoveryMs,
|
|
148
|
+
inboundBitrateBps = if (inboundCount > 0) inboundSum / inboundCount else null,
|
|
149
|
+
sentFps = sentFps,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
@@ -59,14 +59,21 @@ class AcsFrameSender(
|
|
|
59
59
|
}
|
|
60
60
|
// Set on the session thread, read from ACS state/format listener threads.
|
|
61
61
|
@Volatile private var onFormat: ((TargetSize) -> Unit)? = null
|
|
62
|
+
@Volatile private var onNegotiatedFormat: ((VideoStreamFormat) -> Unit)? = null
|
|
62
63
|
private var attachedStream: VirtualOutgoingVideoStream? = null
|
|
63
64
|
private var stateListener: VideoStreamStateChangedListener? = null
|
|
64
65
|
private var formatListener: VideoStreamFormatChangedListener? = null
|
|
65
66
|
|
|
66
|
-
fun attach(
|
|
67
|
+
fun attach(
|
|
68
|
+
outgoing: VirtualOutgoingVideoStream,
|
|
69
|
+
onFormat: ((TargetSize) -> Unit)? = null,
|
|
70
|
+
/** The whole negotiated format, for callers that log it. [onFormat] only carries the size. */
|
|
71
|
+
onNegotiatedFormat: ((VideoStreamFormat) -> Unit)? = null,
|
|
72
|
+
) {
|
|
67
73
|
detach()
|
|
68
74
|
pacer.reset()
|
|
69
75
|
this.onFormat = onFormat
|
|
76
|
+
this.onNegotiatedFormat = onNegotiatedFormat
|
|
70
77
|
stream.set(outgoing)
|
|
71
78
|
attachedStream = outgoing
|
|
72
79
|
val onState = VideoStreamStateChangedListener {
|
|
@@ -365,6 +372,7 @@ class AcsFrameSender(
|
|
|
365
372
|
pacer.reset()
|
|
366
373
|
pool.clear()
|
|
367
374
|
onFormat = null
|
|
375
|
+
onNegotiatedFormat = null
|
|
368
376
|
}
|
|
369
377
|
|
|
370
378
|
private fun pushTarget(fmt: VideoStreamFormat?) {
|
|
@@ -393,6 +401,7 @@ class AcsFrameSender(
|
|
|
393
401
|
|
|
394
402
|
private fun logFormat(fmt: VideoStreamFormat?) {
|
|
395
403
|
if (fmt == null) return
|
|
404
|
+
onNegotiatedFormat?.invoke(fmt)
|
|
396
405
|
Log.i(
|
|
397
406
|
TAG,
|
|
398
407
|
"P5 negotiated format pixel=${fmt.pixelFormat} ${fmt.width}x${fmt.height} fps=${fmt.framesPerSecond} " +
|