@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.
@@ -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(outgoing: VirtualOutgoingVideoStream, onFormat: ((TargetSize) -> Unit)? = null) {
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} " +