@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.
- 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
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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.
|