@mentra/acs-meeting 3.2.0-dev.233 → 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.
@@ -61,6 +61,8 @@ import com.mentra.acsmeeting.audio.PcmBridge
61
61
  import com.mentra.acsmeeting.audio.PhoneMicCapturer
62
62
  import com.mentra.acsmeeting.audio.UplinkPacer
63
63
  import com.mentra.acsmeeting.audio.UplinkSender
64
+ import com.mentra.acsmeeting.telemetry.CallDiagnostics
65
+ import com.mentra.acsmeeting.telemetry.WireEpisodeTracker
64
66
  import com.mentra.glassesmedia.source.MediaDiagnostics
65
67
  import com.mentra.glassesmedia.source.CloudflareWhepSource
66
68
  import com.mentra.glassesmedia.source.DecoderMode
@@ -108,7 +110,7 @@ class AcsMeetingSession(
108
110
  ) {
109
111
  internal val stats = PipelineStats()
110
112
  private val avSync = AvSyncProbe()
111
- private val ticker = PipelineTicker(stats, avSync) {
113
+ private val ticker = PipelineTicker(stats, avSync, onTick = { sampleBwe() }) {
112
114
  Log.i(TAG, it)
113
115
  }
114
116
  private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
@@ -218,6 +220,37 @@ class AcsMeetingSession(
218
220
  private var mediaRestartAttempts = 0
219
221
  private var mediaRestartTask: ScheduledFuture<*>? = null
220
222
 
223
+ /**
224
+ * Which path the wearer took into this call: `created` (Start) or `joined` (Join).
225
+ *
226
+ * Stamped on every diagnostic below, because the open question these traces exist to answer —
227
+ * whether a joined call runs at a lower bitrate than a created one — cannot be asked of a log
228
+ * that does not say which kind of call produced each line. `unknown` means a caller that
229
+ * predates the field, and is kept distinct from either answer rather than guessed at.
230
+ */
231
+ @Volatile private var callOrigin = "unknown"
232
+
233
+ /** ACS call id once the SDK has one; the only key that ties these lines to a Teams-side record. */
234
+ @Volatile private var callId = ""
235
+ @Volatile private var joinStartedAtMs = 0L
236
+ @Volatile private var lobbyEnteredAtMs = 0L
237
+ @Volatile private var connectedAtMs = 0L
238
+ @Volatile private var lastBweSampleAtMs = 0L
239
+
240
+ /**
241
+ * Calls out each stretch where the ACS uplink went bad, and what it cost to come back.
242
+ *
243
+ * Kept here rather than left to the analyzer because the two are not redundant: the analyzer can
244
+ * only reconstruct episodes from readings that arrived, and on a Start call barely a quarter of
245
+ * them do. This is the session's own account, so an episode is on the record even when
246
+ * MEDIA_STATISTICS went quiet in the middle of it.
247
+ */
248
+ @Volatile private var wireEpisodes = WireEpisodeTracker()
249
+ /** When the wire size last changed, so the recovery ramp after a downscale is sampled densely. */
250
+ @Volatile private var lastAdaptationAtMs = 0L
251
+ /** When the wire was last under the low threshold, for the same reason. */
252
+ @Volatile private var lastLowWireAtMs = 0L
253
+
221
254
  /**
222
255
  * Bumped by every join and every leave, so a bounded ACS operation that completes late cannot
223
256
  * attach its result to a session that has already moved on.
@@ -340,6 +373,8 @@ class AcsMeetingSession(
340
373
  audioSource: String = "glasses",
341
374
  video: VideoProfile = VideoProfile.DEFAULT,
342
375
  audioDelayMs: Int? = null,
376
+ /** `created` (Start) or `joined` (Join); see [callOrigin]. Diagnostic only. */
377
+ origin: String = "unknown",
343
378
  /**
344
379
  * Runs the WHIP listener bind, and exists so the caller can lift a process-wide network pin
345
380
  * across exactly that call. Takes the block rather than being a pair of before/after hooks so
@@ -361,6 +396,17 @@ class AcsMeetingSession(
361
396
  phase = "connecting"
362
397
  lastError = null
363
398
  meetingUrl = teamsUrl
399
+ callOrigin = origin
400
+ callId = ""
401
+ joinStartedAtMs = SystemClock.elapsedRealtime()
402
+ lobbyEnteredAtMs = 0L
403
+ connectedAtMs = 0L
404
+ lastBweSampleAtMs = 0L
405
+ // Fresh per call. A tracker carried over would open this call's first episode against the
406
+ // previous call's floor, and an A/B run is back-to-back calls on different ceilings.
407
+ wireEpisodes = WireEpisodeTracker()
408
+ lastAdaptationAtMs = 0L
409
+ lastLowWireAtMs = 0L
364
410
  // SoftAP needs the WHIP listener bound before this method returns: the JS
365
411
  // orchestrator reads ingestUrl off the join result and tears the scoped
366
412
  // network down if it is missing. ACS join itself stays on the executor.
@@ -440,7 +486,29 @@ class AcsMeetingSession(
440
486
  videoOptions.formats = listOf(AcsFrameSender.outgoingFormat(profile))
441
487
  val videoStream = VirtualOutgoingVideoStream(videoOptions)
442
488
  videoOut = videoStream
443
- frameSender.attach(videoStream) { size -> media.setTargetSize(size) }
489
+ frameSender.attach(
490
+ videoStream,
491
+ onFormat = { size -> media.setTargetSize(size) },
492
+ // Fires at stream start and again on every renegotiation. ACS trades resolution inside
493
+ // the budget it was given, so "what we asked for" and "what is being sent" diverge
494
+ // silently; only this says when.
495
+ onNegotiatedFormat = { format ->
496
+ SoftApTrace.stage(
497
+ "acs_outgoing_format",
498
+ *diag(
499
+ "width" to format.width,
500
+ "height" to format.height,
501
+ "fps" to format.framesPerSecond,
502
+ "pixelFormat" to format.pixelFormat,
503
+ "askedWidth" to profile.width,
504
+ "askedHeight" to profile.height,
505
+ "askedFps" to profile.fps,
506
+ "maxBitrateBps" to profile.maxBitrateBps,
507
+ "rateArm" to MediaDiagnostics.outgoingRate.name.lowercase(),
508
+ ),
509
+ )
510
+ },
511
+ )
444
512
 
445
513
  val audioProperties = RawOutgoingAudioStreamProperties()
446
514
  .setFormat(AudioStreamFormat.PCM16_BIT)
@@ -838,6 +906,18 @@ class AcsMeetingSession(
838
906
  return true
839
907
  }
840
908
 
909
+ /**
910
+ * Wait for the SoftAP WHIP listener to release its port, and say whether it did.
911
+ *
912
+ * Deliberately not folded into [leaveAndAwait]: the ACS teardown and the listener's tombstone
913
+ * run on their own clocks, and collapsing them into one answer would hide which of the two the
914
+ * next call is actually waiting on. `false` means the port is still held.
915
+ */
916
+ fun awaitIngestClosed(timeoutMs: Long): Boolean = media.awaitIngestClosed(timeoutMs)
917
+
918
+ /** Drop the retiring WHIP listener now. The barrier's forced path, after [awaitIngestClosed]. */
919
+ fun forceCloseIngest() = media.forceCloseIngest()
920
+
841
921
  /**
842
922
  * End the Teams group call for everyone, then tear this device down.
843
923
  *
@@ -1021,6 +1101,25 @@ class AcsMeetingSession(
1021
1101
  "(code=${end["code"]}, subcode=${end["subcode"]})"
1022
1102
  }
1023
1103
  Log.i(TAG, "ACS call state=$state phase=$phase previous=$previous end=$end")
1104
+ if (callId.isEmpty()) callId = readCallId()
1105
+ if (phase == "lobby" && lobbyEnteredAtMs == 0L) lobbyEnteredAtMs = SystemClock.elapsedRealtime()
1106
+ if (phase == "connected" && connectedAtMs == 0L) connectedAtMs = SystemClock.elapsedRealtime()
1107
+ SoftApTrace.stage(
1108
+ "acs_call_state",
1109
+ *diag(
1110
+ "state" to state.toString().lowercase(),
1111
+ // Not `phase`: the trace parser treats a bare `phase=` as a stage name, because that is
1112
+ // how the host's own lines are shaped. A field called `phase` here is silently dropped.
1113
+ "callPhase" to phase,
1114
+ "previous" to previous,
1115
+ "sinceJoinMs" to sinceJoinMs(),
1116
+ // Reported on every transition, not only on admission, so a call that is still waiting
1117
+ // shows a dwell that grows instead of a field that appears once at the end.
1118
+ "sinceLobbyMs" to lobbyDwellMs(),
1119
+ "endCode" to (end["code"] ?: -1),
1120
+ "endSubcode" to (end["subcode"] ?: -1),
1121
+ ),
1122
+ )
1024
1123
  if (phase == "connected" && previous != "connected") {
1025
1124
  call?.let {
1026
1125
  attachMediaStats(it)
@@ -1038,6 +1137,14 @@ class AcsMeetingSession(
1038
1137
  onState(snapshot())
1039
1138
  }
1040
1139
 
1140
+ /** The ACS call id, or empty if the SDK has not minted one yet. Never throws into a trace. */
1141
+ private fun readCallId(): String = try {
1142
+ call?.id.orEmpty()
1143
+ } catch (error: Exception) {
1144
+ Log.w(TAG, "call id unavailable", error)
1145
+ ""
1146
+ }
1147
+
1041
1148
  private fun attachMediaStats(joined: Call) {
1042
1149
  detachMediaStats()
1043
1150
  mediaStatsReports.set(0)
@@ -1073,6 +1180,24 @@ class AcsMeetingSession(
1073
1180
  if (width != null && height != null && width > 0 && height > 0) {
1074
1181
  stats.setSize(width, height)
1075
1182
  }
1183
+ // Every report, not just the first eight: Start has been observed to attach and then
1184
+ // never print a P6 line the analyzer can see. SoftApTrace is what the comparison reads.
1185
+ SoftApTrace.stage(
1186
+ "acs_media_stats",
1187
+ *CallDiagnostics.mediaStatsReportFields(
1188
+ origin = callOrigin,
1189
+ callId = callId,
1190
+ n = n,
1191
+ videos = videos?.size ?: 0,
1192
+ audios = outgoing?.audioStatistics?.size ?: 0,
1193
+ wireBitrateBps = video?.bitrateInBps?.toLong(),
1194
+ width = video?.frameWidth,
1195
+ height = video?.frameHeight,
1196
+ fps = video?.frameRate?.toDouble(),
1197
+ codec = codec,
1198
+ packetCount = video?.packetCount,
1199
+ ),
1200
+ )
1076
1201
  }
1077
1202
  feature.addOnReportReceivedListener(listener)
1078
1203
  mediaStatsListener = listener
@@ -1082,8 +1207,18 @@ class AcsMeetingSession(
1082
1207
  // CONNECTED so codecName is not stuck at na.
1083
1208
  scheduleMediaStatsInterval(feature, 0)
1084
1209
  Log.i(TAG, "P6 wire hop attached")
1210
+ SoftApTrace.stage("acs_media_stats_attach", *CallDiagnostics.mediaStatsAttachFields(callOrigin, callId, ok = true))
1085
1211
  } catch (error: Exception) {
1086
1212
  Log.w(TAG, "MEDIA_STATISTICS attach failed", error)
1213
+ SoftApTrace.stage(
1214
+ "acs_media_stats_attach",
1215
+ *CallDiagnostics.mediaStatsAttachFields(
1216
+ callOrigin,
1217
+ callId,
1218
+ ok = false,
1219
+ error = "${error.javaClass.simpleName}:${error.message.orEmpty()}",
1220
+ ),
1221
+ )
1087
1222
  }
1088
1223
  }
1089
1224
 
@@ -1095,14 +1230,45 @@ class AcsMeetingSession(
1095
1230
  * the adapted size once per second and a permanent downscale reads exactly like a healthy call —
1096
1231
  * the number is right there and nothing ever calls it out. Logged on transition only, because at
1097
1232
  * 1 Hz a warning per report is noise nobody reads.
1233
+ *
1234
+ * Traced as well as logged. A `Log.w` is invisible to `acs-quality-compare.mjs`, and while that
1235
+ * was the only record, a call that spent 90 s at 320x180 was scored as healthy: the analyzer had
1236
+ * no downscale to count, and 320x180 at a steady 15 fps looks like a working ladder. The trace
1237
+ * carries `ceilingBps` so a downscale can be attributed to its A/B arm.
1098
1238
  */
1099
1239
  private fun reportWireAdaptation(width: Int?, height: Int?, fps: Float?) {
1100
1240
  if (width == null || height == null || width <= 0 || height <= 0) return
1101
1241
  val key = "${width}x$height"
1102
- if (key == lastWireSizeKey) return
1242
+ val previous = lastWireSizeKey
1243
+ if (key == previous) return
1103
1244
  lastWireSizeKey = key
1104
1245
  val askedPixels = profile.width.toLong() * profile.height
1105
1246
  val gotPixels = width.toLong() * height
1247
+ // First report is the baseline, not an adaptation: direction is only meaningful against a
1248
+ // previous size, and calling the initial negotiated size a "downscale" would put a spurious
1249
+ // adaptation on every call in the A/B.
1250
+ val direction = when {
1251
+ previous == null -> "initial"
1252
+ gotPixels < pixelsOfKey(previous) -> "down"
1253
+ else -> "up"
1254
+ }
1255
+ lastAdaptationAtMs = SystemClock.elapsedRealtime()
1256
+ SoftApTrace.stage(
1257
+ "acs_wire_adaptation",
1258
+ *CallDiagnostics.wireAdaptationFields(
1259
+ origin = callOrigin,
1260
+ callId = callId,
1261
+ width = width,
1262
+ height = height,
1263
+ direction = direction,
1264
+ sinceConnectedMs = if (connectedAtMs == 0L) -1L else SystemClock.elapsedRealtime() - connectedAtMs,
1265
+ askedWidth = profile.width,
1266
+ askedHeight = profile.height,
1267
+ wireBitrateBps = stats.wireBitrateBps,
1268
+ ceilingBps = profile.maxBitrateBps,
1269
+ fps = fps?.toDouble(),
1270
+ ),
1271
+ )
1106
1272
  if (gotPixels < askedPixels) {
1107
1273
  val percent = (gotPixels * 100 / askedPixels).toInt()
1108
1274
  Log.w(
@@ -1116,22 +1282,80 @@ class AcsMeetingSession(
1116
1282
  }
1117
1283
  }
1118
1284
 
1285
+ private fun pixelsOfKey(key: String): Long {
1286
+ val parts = key.split("x")
1287
+ val width = parts.getOrNull(0)?.toLongOrNull() ?: return 0L
1288
+ val height = parts.getOrNull(1)?.toLongOrNull() ?: return 0L
1289
+ return width * height
1290
+ }
1291
+
1292
+ /**
1293
+ * Keep asking ACS for 1 Hz reports until it agrees, for as long as the call lasts.
1294
+ *
1295
+ * This used to give up after six attempts across about ten seconds, and the captures show it
1296
+ * never once succeeded: every call in `outputs/acs-quality` carries `interval=no`, and the Start
1297
+ * calls sit at 0-28% observed coverage as a direct result. The default interval is coarse enough
1298
+ * that a 90-second collapse can pass with a handful of readings, so losing this is what made the
1299
+ * whole investigation guess.
1300
+ *
1301
+ * Ten seconds was the wrong budget because it encodes the wrong theory. The evidence is that ACS
1302
+ * populates outgoing statistics only once a remote subscriber is actually pulling the stream —
1303
+ * which on a Start call is whenever the other person happens to join, not a fixed delay after
1304
+ * the local join. So the retry now backs off and keeps going for
1305
+ * [MEDIA_STATS_INTERVAL_MAX_ATTEMPTS], and stops early only on success or when the feature is
1306
+ * replaced.
1307
+ */
1119
1308
  private fun scheduleMediaStatsInterval(feature: MediaStatisticsCallFeature, attempt: Int) {
1309
+ val delaySeconds = when {
1310
+ attempt == 0 -> 0L
1311
+ attempt <= 5 -> 2L
1312
+ attempt <= 15 -> 5L
1313
+ else -> 15L
1314
+ }
1120
1315
  executor.schedule({
1121
1316
  if (mediaStatsFeature !== feature) return@schedule
1122
1317
  try {
1123
1318
  feature.updateReportIntervalInSeconds(1)
1124
1319
  Log.i(TAG, "P6 wire interval=1s attempt=$attempt")
1320
+ SoftApTrace.stage(
1321
+ "acs_media_stats_interval",
1322
+ *CallDiagnostics.mediaStatsIntervalFields(
1323
+ callOrigin,
1324
+ callId,
1325
+ attempt = attempt,
1326
+ ok = true,
1327
+ seconds = 1,
1328
+ ),
1329
+ )
1125
1330
  } catch (error: Exception) {
1126
1331
  Log.w(
1127
1332
  TAG,
1128
1333
  "P6 wire interval attempt=$attempt failed ${error.javaClass.simpleName}: ${error.message}",
1129
1334
  )
1130
- if (attempt < 5) {
1335
+ SoftApTrace.stage(
1336
+ "acs_media_stats_interval",
1337
+ *CallDiagnostics.mediaStatsIntervalFields(
1338
+ callOrigin,
1339
+ callId,
1340
+ attempt = attempt,
1341
+ ok = false,
1342
+ seconds = 1,
1343
+ error = "${error.javaClass.simpleName}:${error.message.orEmpty()}",
1344
+ ),
1345
+ )
1346
+ // Logged at info for the first handful and then only occasionally: the point of retrying
1347
+ // for minutes is defeated if it fills logcat with the same refusal every 15 seconds.
1348
+ if (attempt < MEDIA_STATS_INTERVAL_MAX_ATTEMPTS) {
1131
1349
  scheduleMediaStatsInterval(feature, attempt + 1)
1350
+ } else {
1351
+ Log.w(
1352
+ TAG,
1353
+ "P6 wire interval never accepted after $attempt attempts; " +
1354
+ "MEDIA_STATISTICS stays at its default cadence and captures will be sparse",
1355
+ )
1132
1356
  }
1133
1357
  }
1134
- }, if (attempt == 0) 0L else 2L, TimeUnit.SECONDS)
1358
+ }, delaySeconds, TimeUnit.SECONDS)
1135
1359
  }
1136
1360
 
1137
1361
  /**
@@ -1177,6 +1401,156 @@ class AcsMeetingSession(
1177
1401
 
1178
1402
  private fun logDiagnostic(name: String, value: String) {
1179
1403
  Log.i(TAG, "P7 diag $name=$value")
1404
+ SoftApTrace.stage("acs_network_diag", *diag("name" to name, "value" to value))
1405
+ }
1406
+
1407
+ /**
1408
+ * Stamp a diagnostic with the two facts that make it comparable across runs.
1409
+ *
1410
+ * Without `origin` the Start-versus-Join question cannot be asked of the log at all, and without
1411
+ * `callId` two calls in one capture merge into one timeline whenever the trace id is reused.
1412
+ */
1413
+ private fun diag(vararg fields: Pair<String, Any?>): Array<out Pair<String, Any?>> =
1414
+ CallDiagnostics.stamp(callOrigin, callId, *fields)
1415
+
1416
+ private fun sinceJoinMs(): Long =
1417
+ if (joinStartedAtMs == 0L) -1L else SystemClock.elapsedRealtime() - joinStartedAtMs
1418
+
1419
+ /**
1420
+ * How long this call has been in, or was held in, the Teams lobby.
1421
+ *
1422
+ * Frozen at admission rather than reset, so every sample after `connected` still carries the
1423
+ * dwell that preceded it. That is the correlation the comparison is looking for: a joined call
1424
+ * that settles low *and* waited in the lobby points at ACS rate control settling while there was
1425
+ * nowhere to send to.
1426
+ */
1427
+ private fun lobbyDwellMs(): Long = when {
1428
+ lobbyEnteredAtMs == 0L -> -1L
1429
+ connectedAtMs > 0L -> connectedAtMs - lobbyEnteredAtMs
1430
+ else -> SystemClock.elapsedRealtime() - lobbyEnteredAtMs
1431
+ }
1432
+
1433
+ /**
1434
+ * One `acs_bwe_sample` per cadence slot, driven by the 1 Hz ladder tick.
1435
+ *
1436
+ * The cadence follows the wire's health rather than the clock. It used to be dense (2 s) for the
1437
+ * first 90 s after `connected` and sparse (10 s) forever after, on the theory that a call which
1438
+ * settles low settles low early. An 8-minute capture killed that theory: the collapse started at
1439
+ * t=150 s, so the 90 seconds that mattered got nine samples while the uneventful first minute
1440
+ * got forty-five. Now anything other than an established full-rate call is worth 2 s — a dip,
1441
+ * the climb out of one, a fresh downscale, or ACS publishing empty reports.
1442
+ *
1443
+ * Before `connected` the dense rate applies too: the outgoing stream exists during the lobby,
1444
+ * and what it does there is half the hypothesis.
1445
+ */
1446
+ private fun sampleBwe() {
1447
+ val now = SystemClock.elapsedRealtime()
1448
+ val sinceConnectedMs = if (connectedAtMs == 0L) -1L else now - connectedAtMs
1449
+ val wireBitrateBps = stats.wireBitrateBps
1450
+ if (wireBitrateBps != null && wireBitrateBps in 1 until CallDiagnostics.LOW_BITRATE_BPS) {
1451
+ lastLowWireAtMs = now
1452
+ }
1453
+ val health = CallDiagnostics.wireHealth(
1454
+ sinceConnectedMs = sinceConnectedMs,
1455
+ wireBitrateBps = wireBitrateBps,
1456
+ msSinceLow = if (lastLowWireAtMs == 0L) -1L else now - lastLowWireAtMs,
1457
+ msSinceAdaptation = if (lastAdaptationAtMs == 0L) -1L else now - lastAdaptationAtMs,
1458
+ )
1459
+ // Episodes are tracked on every tick, not on every emitted sample: the record of a collapse
1460
+ // must not depend on the cadence that the collapse is what widens.
1461
+ trackWireEpisode(sinceConnectedMs, wireBitrateBps)
1462
+ val interval = CallDiagnostics.sampleIntervalMs(health)
1463
+ if (lastBweSampleAtMs != 0L && now - lastBweSampleAtMs < interval) return
1464
+ lastBweSampleAtMs = now
1465
+ SoftApTrace.stage(
1466
+ "acs_bwe_sample",
1467
+ *CallDiagnostics.bweFields(
1468
+ callOrigin,
1469
+ callId,
1470
+ CallDiagnostics.BweSample(
1471
+ state = phase,
1472
+ sinceJoinMs = sinceJoinMs(),
1473
+ sinceConnectedMs = sinceConnectedMs,
1474
+ lobbyDwellMs = lobbyDwellMs(),
1475
+ sendQuality = stats.sendQuality,
1476
+ wireBitrateBps = stats.wireBitrateBps,
1477
+ wireWidth = stats.wireWidth,
1478
+ wireHeight = stats.wireHeight,
1479
+ sentFps = stats.lastSubFps,
1480
+ wireFps = stats.wireFps,
1481
+ inboundBitrateBps = stats.inboundBitrateBps,
1482
+ inboundFps = stats.recvFps,
1483
+ decodedFps = stats.decodedFps,
1484
+ framesGated = stats.dropCount(),
1485
+ pacerDrops = stats.dropPacedCount(),
1486
+ budgetBps = stats.budgetBps,
1487
+ rateArm = MediaDiagnostics.outgoingRate.name.lowercase(),
1488
+ mediaStatsReports = mediaStatsReports.get(),
1489
+ mediaStatsAttached = mediaStatsFeature != null,
1490
+ videoOut = videoOutLabel(),
1491
+ packetsPerSecond = stats.lastPacketsPerSecond,
1492
+ subCount = stats.subCount(),
1493
+ sinkCount = stats.sinkCount(),
1494
+ ),
1495
+ ),
1496
+ )
1497
+ }
1498
+
1499
+ /**
1500
+ * Feed the episode tracker and emit the transitions it reports.
1501
+ *
1502
+ * Separate from the sample above because the two have different jobs. The sample answers "what
1503
+ * is it doing now" and is rate-limited; this answers "what did that outage cost" and must not
1504
+ * be, because the interesting transitions are exactly two per outage and dropping either one
1505
+ * loses the measurement. Only counted once connected — the lobby has nowhere to send to, so a
1506
+ * low rate there is not an outage.
1507
+ */
1508
+ private fun trackWireEpisode(sinceConnectedMs: Long, wireBitrateBps: Long?) {
1509
+ if (sinceConnectedMs < 0) return
1510
+ val event = wireEpisodes.observe(
1511
+ atMs = sinceConnectedMs,
1512
+ bitrateBps = wireBitrateBps,
1513
+ width = stats.wireWidth,
1514
+ height = stats.wireHeight,
1515
+ inboundBitrateBps = stats.inboundBitrateBps?.toDouble(),
1516
+ sentFps = stats.lastSubFps,
1517
+ ) ?: return
1518
+ val label = event.phase.name.lowercase()
1519
+ SoftApTrace.stage(
1520
+ "acs_wire_episode",
1521
+ *CallDiagnostics.episodeFields(
1522
+ origin = callOrigin,
1523
+ callId = callId,
1524
+ episode = label,
1525
+ startedAtMs = event.startedAtMs,
1526
+ durationMs = event.durationMs,
1527
+ minBitrateBps = event.minBitrateBps,
1528
+ minResolution = event.minResolution,
1529
+ recoveryMs = event.recoveryMs,
1530
+ inboundBitrateBps = event.inboundBitrateBps,
1531
+ sentFps = event.sentFps,
1532
+ ceilingBps = profile.maxBitrateBps,
1533
+ ),
1534
+ )
1535
+ // Warned rather than logged at info: a wire under 500 kbps while the glasses keep feeding full
1536
+ // rate is the fault itself, and it is the line a reader scanning logcat should trip over.
1537
+ Log.w(
1538
+ TAG,
1539
+ "P6 wire episode=$label since=${event.startedAtMs}ms dur=${event.durationMs}ms " +
1540
+ "floor=${event.minBitrateBps} minRes=${event.minResolution.ifBlank { "na" }} " +
1541
+ "recovery=${event.recoveryMs}ms glassesHop=${event.inboundBitrateBps?.toLong() ?: -1} " +
1542
+ "sentFps=${event.sentFps} ceiling=${profile.maxBitrateBps}",
1543
+ )
1544
+ }
1545
+
1546
+ /** ACS stream state, or `none` if we never built one. Independent of MEDIA_STATISTICS. */
1547
+ private fun videoOutLabel(): String {
1548
+ val stream = videoOut ?: return "none"
1549
+ return try {
1550
+ stream.state.toString().lowercase()
1551
+ } catch (_: Exception) {
1552
+ "unknown"
1553
+ }
1180
1554
  }
1181
1555
 
1182
1556
  /**
@@ -1542,6 +1916,15 @@ class AcsMeetingSession(
1542
1916
  audioSource = "glasses"
1543
1917
  lastSafety = AudioSafety.DEGRADED
1544
1918
  meetingUrl = null
1919
+ // Not `callOrigin`: a teardown trace still belongs to the call that is ending, and the next
1920
+ // join overwrites it before anything else is stamped.
1921
+ callId = ""
1922
+ lobbyEnteredAtMs = 0L
1923
+ connectedAtMs = 0L
1924
+ lastBweSampleAtMs = 0L
1925
+ wireEpisodes = WireEpisodeTracker()
1926
+ lastAdaptationAtMs = 0L
1927
+ lastLowWireAtMs = 0L
1545
1928
  // Clearing lastError is scoped to the clean idle reset. A failed join tears
1546
1929
  // down with emitIdle=false and relies on lastError staying set so emit("error")
1547
1930
  // still carries it and pushCallState keeps ignoring late disconnected callbacks.
@@ -1604,9 +1987,21 @@ class AcsMeetingSession(
1604
1987
  private const val ROSTER_COALESCE_MS = 150L
1605
1988
  private const val MEDIA_RESTART_BASE_MS = 1_000L
1606
1989
  private const val MEDIA_RESTART_MAX_MS = 10_000L
1990
+
1991
+ /**
1992
+ * How many times to ask ACS for 1 Hz MEDIA_STATISTICS before giving up.
1993
+ *
1994
+ * With the backoff in [scheduleMediaStatsInterval] — 2 s, then 5 s, then 15 s — this spans
1995
+ * roughly eleven minutes, which is deliberately longer than a short call. The old budget was
1996
+ * six attempts over ten seconds and it never succeeded in any capture, because the condition
1997
+ * being waited on is a remote subscriber pulling the stream rather than a fixed startup delay.
1998
+ */
1999
+ private const val MEDIA_STATS_INTERVAL_MAX_ATTEMPTS = 60
2000
+
1607
2001
  /** SoftAP join() blocks until the WHIP listener is bound and ACS join is queued. */
1608
2002
  private const val SOFTAP_JOIN_WAIT_MS = 45_000L
1609
2003
 
2004
+
1610
2005
  /**
1611
2006
  * How long End waits for ACS to accept the hang-up before reporting it unconfirmed. Local
1612
2007
  * teardown runs either way; this only bounds how long the wearer stares at a confirm sheet.