@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,330 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.telemetry
|
|
2
|
+
|
|
3
|
+
import com.mentra.glassesmedia.trace.SoftApTrace
|
|
4
|
+
import org.junit.Assert.assertEquals
|
|
5
|
+
import org.junit.Assert.assertTrue
|
|
6
|
+
import org.junit.Test
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* These exist because the analyzer parses these exact field names. A rename here and no rename
|
|
10
|
+
* there produces a comparison that silently reports nothing rather than failing, which is the one
|
|
11
|
+
* outcome that wastes a device session.
|
|
12
|
+
*/
|
|
13
|
+
class CallDiagnosticsTest {
|
|
14
|
+
|
|
15
|
+
private fun sample(
|
|
16
|
+
state: String = "connected",
|
|
17
|
+
sinceConnectedMs: Long = 4_000,
|
|
18
|
+
wireBitrateBps: Long? = 1_800_000,
|
|
19
|
+
) = CallDiagnostics.BweSample(
|
|
20
|
+
state = state,
|
|
21
|
+
sinceJoinMs = 12_000,
|
|
22
|
+
sinceConnectedMs = sinceConnectedMs,
|
|
23
|
+
lobbyDwellMs = 7_500,
|
|
24
|
+
sendQuality = "GOOD",
|
|
25
|
+
wireBitrateBps = wireBitrateBps,
|
|
26
|
+
wireWidth = 960,
|
|
27
|
+
wireHeight = 540,
|
|
28
|
+
sentFps = 14.6,
|
|
29
|
+
wireFps = 14.2,
|
|
30
|
+
inboundBitrateBps = 2_100_000,
|
|
31
|
+
inboundFps = 15.0,
|
|
32
|
+
decodedFps = 15.0,
|
|
33
|
+
framesGated = 3,
|
|
34
|
+
pacerDrops = 1,
|
|
35
|
+
budgetBps = 2_500_000,
|
|
36
|
+
rateArm = "advertise_requested",
|
|
37
|
+
mediaStatsReports = 4,
|
|
38
|
+
mediaStatsAttached = true,
|
|
39
|
+
videoOut = "started",
|
|
40
|
+
packetsPerSecond = 42.5,
|
|
41
|
+
subCount = 180,
|
|
42
|
+
sinkCount = 182,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
private fun render(fields: Array<Pair<String, Any?>>): String =
|
|
46
|
+
SoftApTrace.format("abc123", "acs_bwe_sample", 1_000, *fields)
|
|
47
|
+
|
|
48
|
+
@Test
|
|
49
|
+
fun `a sample carries the origin it was taken under`() {
|
|
50
|
+
val line = render(CallDiagnostics.bweFields("joined", "call-1", sample()))
|
|
51
|
+
|
|
52
|
+
assertTrue(line, line.contains(" origin=joined"))
|
|
53
|
+
assertTrue(line, line.contains(" callId=call-1"))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** An unstamped call still has to group somewhere, and "unknown" is a third bucket, not a guess. */
|
|
57
|
+
@Test
|
|
58
|
+
fun `a caller that sent no origin is recorded as unknown rather than either path`() {
|
|
59
|
+
val line = render(CallDiagnostics.bweFields("", "", sample()))
|
|
60
|
+
|
|
61
|
+
assertTrue(line, line.contains(" origin=unknown"))
|
|
62
|
+
assertTrue(line, line.contains(" callId=none"))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@Test
|
|
66
|
+
fun `the sample names both hops and what was asked for`() {
|
|
67
|
+
val line = render(CallDiagnostics.bweFields("created", "call-2", sample()))
|
|
68
|
+
|
|
69
|
+
for (key in listOf(
|
|
70
|
+
"state=connected",
|
|
71
|
+
"sinceJoinMs=12000",
|
|
72
|
+
"sinceConnectedMs=4000",
|
|
73
|
+
"lobbyDwellMs=7500",
|
|
74
|
+
"sendQuality=GOOD",
|
|
75
|
+
"wireBitrateBps=1800000",
|
|
76
|
+
"sentFps=14.6",
|
|
77
|
+
// Both hops on one line is what makes the comparison exact instead of aligned by timestamp.
|
|
78
|
+
"inboundBitrateBps=2100000",
|
|
79
|
+
"inboundFps=15.0",
|
|
80
|
+
"framesGated=3",
|
|
81
|
+
"pacerDrops=1",
|
|
82
|
+
"budgetBps=2500000",
|
|
83
|
+
"p7RateBound=advertise_requested",
|
|
84
|
+
"mediaStatsReports=4",
|
|
85
|
+
"mediaStatsAttached=true",
|
|
86
|
+
"videoOut=started",
|
|
87
|
+
"packetsPerSecond=42.5",
|
|
88
|
+
"subCount=180",
|
|
89
|
+
"sinkCount=182",
|
|
90
|
+
)) {
|
|
91
|
+
assertTrue("$key missing from $line", line.contains(" $key"))
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@Test
|
|
96
|
+
fun `media-stats attach and report names stay stable for the analyzer`() {
|
|
97
|
+
val attach = render(CallDiagnostics.mediaStatsAttachFields("created", "call-4", ok = true))
|
|
98
|
+
assertTrue(attach, attach.contains(" origin=created"))
|
|
99
|
+
assertTrue(attach, attach.contains(" ok=true"))
|
|
100
|
+
assertTrue(attach, attach.contains(" error=none"))
|
|
101
|
+
|
|
102
|
+
val report = render(
|
|
103
|
+
CallDiagnostics.mediaStatsReportFields(
|
|
104
|
+
origin = "joined",
|
|
105
|
+
callId = "call-5",
|
|
106
|
+
n = 3,
|
|
107
|
+
videos = 1,
|
|
108
|
+
audios = 1,
|
|
109
|
+
wireBitrateBps = 1_320_000,
|
|
110
|
+
width = 960,
|
|
111
|
+
height = 540,
|
|
112
|
+
fps = 15.0,
|
|
113
|
+
codec = "H264 HW",
|
|
114
|
+
packetCount = 800,
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
for (key in listOf(
|
|
118
|
+
"origin=joined",
|
|
119
|
+
"n=3",
|
|
120
|
+
"videos=1",
|
|
121
|
+
"wireBitrateBps=1320000",
|
|
122
|
+
"width=960",
|
|
123
|
+
"height=540",
|
|
124
|
+
"fps=15.0",
|
|
125
|
+
"codec=H264_HW",
|
|
126
|
+
"packetCount=800",
|
|
127
|
+
)) {
|
|
128
|
+
assertTrue("$key missing from $report", report.contains(" $key"))
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Zero would read as "sending nothing", which is the opposite of "nobody has told us yet". */
|
|
133
|
+
@Test
|
|
134
|
+
fun `an unreported bitrate is negative rather than zero`() {
|
|
135
|
+
val line = render(CallDiagnostics.bweFields("created", "call-3", sample(wireBitrateBps = null)))
|
|
136
|
+
|
|
137
|
+
assertTrue(line, line.contains(" wireBitrateBps=-1"))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
@Test
|
|
141
|
+
fun `sampling stays dense through the settling window and relaxes after it`() {
|
|
142
|
+
// Before connected: still settling, so still dense.
|
|
143
|
+
assertEquals(CallDiagnostics.DENSE_INTERVAL_MS, CallDiagnostics.sampleIntervalMs(-1))
|
|
144
|
+
assertEquals(CallDiagnostics.DENSE_INTERVAL_MS, CallDiagnostics.sampleIntervalMs(0))
|
|
145
|
+
assertEquals(CallDiagnostics.DENSE_INTERVAL_MS, CallDiagnostics.sampleIntervalMs(CallDiagnostics.DENSE_WINDOW_MS))
|
|
146
|
+
assertEquals(
|
|
147
|
+
CallDiagnostics.SPARSE_INTERVAL_MS,
|
|
148
|
+
CallDiagnostics.sampleIntervalMs(CallDiagnostics.DENSE_WINDOW_MS + 1),
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private fun health(
|
|
153
|
+
sinceConnectedMs: Long,
|
|
154
|
+
wireBitrateBps: Long?,
|
|
155
|
+
msSinceLow: Long = -1,
|
|
156
|
+
msSinceAdaptation: Long = -1,
|
|
157
|
+
) = CallDiagnostics.wireHealth(sinceConnectedMs, wireBitrateBps, msSinceLow, msSinceAdaptation)
|
|
158
|
+
|
|
159
|
+
@Test
|
|
160
|
+
fun `an established call at full rate is the only thing sampled sparsely`() {
|
|
161
|
+
val settled = health(sinceConnectedMs = 200_000, wireBitrateBps = 1_300_000)
|
|
162
|
+
|
|
163
|
+
assertEquals(CallDiagnostics.WireHealth.HEALTHY, settled)
|
|
164
|
+
assertEquals(CallDiagnostics.SPARSE_INTERVAL_MS, CallDiagnostics.sampleIntervalMs(settled))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The bug this replaced: the old cadence went sparse at 90 s and the collapse began at 150 s, so
|
|
169
|
+
* the event the whole investigation was about was sampled every 10 seconds.
|
|
170
|
+
*/
|
|
171
|
+
@Test
|
|
172
|
+
fun `a collapse long after the settling window pulls the cadence back to dense`() {
|
|
173
|
+
val low = health(sinceConnectedMs = 150_000, wireBitrateBps = 33_000)
|
|
174
|
+
|
|
175
|
+
assertEquals(CallDiagnostics.WireHealth.LOW, low)
|
|
176
|
+
assertEquals(CallDiagnostics.DENSE_INTERVAL_MS, CallDiagnostics.sampleIntervalMs(low))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The climb back was the longer half of the outage, so it is sampled at the same rate. */
|
|
180
|
+
@Test
|
|
181
|
+
fun `the recovery ramp stays dense while it is still degraded`() {
|
|
182
|
+
val climbing = health(sinceConnectedMs = 250_000, wireBitrateBps = 700_000)
|
|
183
|
+
|
|
184
|
+
assertEquals(CallDiagnostics.WireHealth.RECOVERING, climbing)
|
|
185
|
+
assertEquals(CallDiagnostics.DENSE_INTERVAL_MS, CallDiagnostics.sampleIntervalMs(climbing))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
@Test
|
|
189
|
+
fun `a call that just came back is watched a while before being trusted`() {
|
|
190
|
+
val justBack = health(sinceConnectedMs = 250_000, wireBitrateBps = 1_300_000, msSinceLow = 5_000)
|
|
191
|
+
assertEquals(CallDiagnostics.WireHealth.RECOVERING, justBack)
|
|
192
|
+
|
|
193
|
+
val longBack =
|
|
194
|
+
health(
|
|
195
|
+
sinceConnectedMs = 400_000,
|
|
196
|
+
wireBitrateBps = 1_300_000,
|
|
197
|
+
msSinceLow = CallDiagnostics.RECOVERY_WATCH_MS + 1,
|
|
198
|
+
)
|
|
199
|
+
assertEquals(CallDiagnostics.WireHealth.HEALTHY, longBack)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
@Test
|
|
203
|
+
fun `a recent downscale is watched even at a healthy rate`() {
|
|
204
|
+
val adapted = health(sinceConnectedMs = 300_000, wireBitrateBps = 1_300_000, msSinceAdaptation = 4_000)
|
|
205
|
+
|
|
206
|
+
assertEquals(CallDiagnostics.WireHealth.RECOVERING, adapted)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* ACS publishing an empty report is a fact about ACS, not a bitrate of zero, so it is its own
|
|
211
|
+
* state — and a dense one, because the other columns (sent fps, subscriber count, glasses hop)
|
|
212
|
+
* are what characterise the blindness.
|
|
213
|
+
*/
|
|
214
|
+
@Test
|
|
215
|
+
fun `a silent wire is distinguished from a low one and sampled densely`() {
|
|
216
|
+
assertEquals(CallDiagnostics.WireHealth.SILENT, health(sinceConnectedMs = 300_000, wireBitrateBps = null))
|
|
217
|
+
assertEquals(CallDiagnostics.WireHealth.SILENT, health(sinceConnectedMs = 300_000, wireBitrateBps = -1))
|
|
218
|
+
assertEquals(
|
|
219
|
+
CallDiagnostics.DENSE_INTERVAL_MS,
|
|
220
|
+
CallDiagnostics.sampleIntervalMs(CallDiagnostics.WireHealth.SILENT),
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
@Test
|
|
225
|
+
fun `everything inside the settling window is settling whatever the rate`() {
|
|
226
|
+
assertEquals(CallDiagnostics.WireHealth.SETTLING, health(sinceConnectedMs = -1, wireBitrateBps = null))
|
|
227
|
+
assertEquals(CallDiagnostics.WireHealth.SETTLING, health(sinceConnectedMs = 10_000, wireBitrateBps = 33_000))
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
@Test
|
|
231
|
+
fun `a downscale names what was asked for as well as what arrived`() {
|
|
232
|
+
val line = SoftApTrace.format(
|
|
233
|
+
"abc123",
|
|
234
|
+
"acs_wire_adaptation",
|
|
235
|
+
2_000,
|
|
236
|
+
*CallDiagnostics.wireAdaptationFields(
|
|
237
|
+
origin = "joined",
|
|
238
|
+
callId = "call-6",
|
|
239
|
+
width = 320,
|
|
240
|
+
height = 180,
|
|
241
|
+
direction = "down",
|
|
242
|
+
sinceConnectedMs = 150_000,
|
|
243
|
+
askedWidth = 960,
|
|
244
|
+
askedHeight = 540,
|
|
245
|
+
wireBitrateBps = 33_000,
|
|
246
|
+
ceilingBps = 3_000_000,
|
|
247
|
+
fps = 14.4,
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
for (key in listOf(
|
|
252
|
+
"origin=joined",
|
|
253
|
+
"width=320",
|
|
254
|
+
"height=180",
|
|
255
|
+
"direction=down",
|
|
256
|
+
"sinceConnectedMs=150000",
|
|
257
|
+
"asked=960x540",
|
|
258
|
+
// 11% of what was negotiated. The percentage is the part that reads as a fault rather than
|
|
259
|
+
// as a number, which is why it is computed here instead of left to the reader.
|
|
260
|
+
"percentOfAsked=11",
|
|
261
|
+
"wireBitrateBps=33000",
|
|
262
|
+
"ceilingBps=3000000",
|
|
263
|
+
)) {
|
|
264
|
+
assertTrue("$key missing from $line", line.contains(" $key"))
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
@Test
|
|
269
|
+
fun `an episode carries its floor, its cost and the hop that stayed healthy`() {
|
|
270
|
+
val line = SoftApTrace.format(
|
|
271
|
+
"abc123",
|
|
272
|
+
"acs_wire_episode",
|
|
273
|
+
3_000,
|
|
274
|
+
*CallDiagnostics.episodeFields(
|
|
275
|
+
origin = "joined",
|
|
276
|
+
callId = "call-7",
|
|
277
|
+
episode = "recovered",
|
|
278
|
+
startedAtMs = 150_000,
|
|
279
|
+
durationMs = 90_000,
|
|
280
|
+
minBitrateBps = 33_000,
|
|
281
|
+
minResolution = "320x180",
|
|
282
|
+
recoveryMs = 170_000,
|
|
283
|
+
inboundBitrateBps = 2_100_000.0,
|
|
284
|
+
sentFps = 14.5,
|
|
285
|
+
ceilingBps = 3_000_000,
|
|
286
|
+
),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
for (key in listOf(
|
|
290
|
+
"episode=recovered",
|
|
291
|
+
"startedAtMs=150000",
|
|
292
|
+
"durationMs=90000",
|
|
293
|
+
"minBitrateBps=33000",
|
|
294
|
+
"minResolution=320x180",
|
|
295
|
+
"recoveryMs=170000",
|
|
296
|
+
"inboundBitrateBps=2100000.0",
|
|
297
|
+
"sentFps=14.5",
|
|
298
|
+
"ceilingBps=3000000",
|
|
299
|
+
)) {
|
|
300
|
+
assertTrue("$key missing from $line", line.contains(" $key"))
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** "Not back yet" and "back instantly" differ by 170 seconds on the capture that started this. */
|
|
305
|
+
@Test
|
|
306
|
+
fun `an unrecovered episode reports negative rather than zero recovery`() {
|
|
307
|
+
val line = SoftApTrace.format(
|
|
308
|
+
"abc123",
|
|
309
|
+
"acs_wire_episode",
|
|
310
|
+
3_000,
|
|
311
|
+
*CallDiagnostics.episodeFields(
|
|
312
|
+
origin = "created",
|
|
313
|
+
callId = "call-8",
|
|
314
|
+
episode = "begin",
|
|
315
|
+
startedAtMs = 150_000,
|
|
316
|
+
durationMs = 0,
|
|
317
|
+
minBitrateBps = 33_000,
|
|
318
|
+
minResolution = "",
|
|
319
|
+
recoveryMs = -1,
|
|
320
|
+
inboundBitrateBps = null,
|
|
321
|
+
sentFps = 15.0,
|
|
322
|
+
ceilingBps = 1_500_000,
|
|
323
|
+
),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
assertTrue(line, line.contains(" recoveryMs=-1"))
|
|
327
|
+
assertTrue(line, line.contains(" inboundBitrateBps=na"))
|
|
328
|
+
assertTrue(line, line.contains(" minResolution=na"))
|
|
329
|
+
}
|
|
330
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.telemetry
|
|
2
|
+
|
|
3
|
+
import org.junit.Assert.assertEquals
|
|
4
|
+
import org.junit.Assert.assertFalse
|
|
5
|
+
import org.junit.Assert.assertNotNull
|
|
6
|
+
import org.junit.Assert.assertNull
|
|
7
|
+
import org.junit.Assert.assertTrue
|
|
8
|
+
import org.junit.Test
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The state machine that decides an episode happened. Tested hard because it is the thing that
|
|
12
|
+
* turns a device session into a verdict, and its two failure modes are both silent: inventing
|
|
13
|
+
* episodes out of ACS's empty reports, or missing a real one and pronouncing a bad call healthy.
|
|
14
|
+
*/
|
|
15
|
+
class WireEpisodeTrackerTest {
|
|
16
|
+
|
|
17
|
+
private fun tracker() = WireEpisodeTracker()
|
|
18
|
+
|
|
19
|
+
private fun WireEpisodeTracker.feed(
|
|
20
|
+
atMs: Long,
|
|
21
|
+
bitrateBps: Long?,
|
|
22
|
+
width: Int? = 960,
|
|
23
|
+
height: Int? = 540,
|
|
24
|
+
inbound: Double? = 2_000_000.0,
|
|
25
|
+
sentFps: Double = 15.0,
|
|
26
|
+
) = observe(atMs, bitrateBps, width, height, inbound, sentFps)
|
|
27
|
+
|
|
28
|
+
@Test
|
|
29
|
+
fun `a healthy wire produces no events at all`() {
|
|
30
|
+
val tracker = tracker()
|
|
31
|
+
|
|
32
|
+
for (t in 0..10) {
|
|
33
|
+
assertNull(tracker.feed(t * 2_000L, 1_300_000))
|
|
34
|
+
}
|
|
35
|
+
assertFalse(tracker.isInEpisode())
|
|
36
|
+
assertFalse(tracker.isAwaitingRecovery())
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The capture that motivated the class: a dip that ends long before the picture comes back.
|
|
41
|
+
* Reporting only the dip would understate what the wearer sat through by more than half.
|
|
42
|
+
*/
|
|
43
|
+
@Test
|
|
44
|
+
fun `a dip and its climb are reported as separate costs`() {
|
|
45
|
+
val tracker = tracker()
|
|
46
|
+
|
|
47
|
+
assertNull(tracker.feed(0, 1_300_000))
|
|
48
|
+
val begin = tracker.feed(150_000, 33_000, width = 320, height = 180)
|
|
49
|
+
assertNotNull(begin)
|
|
50
|
+
assertEquals(WireEpisodeTracker.Phase.BEGIN, begin!!.phase)
|
|
51
|
+
assertEquals(150_000L, begin.startedAtMs)
|
|
52
|
+
assertEquals(0L, begin.durationMs)
|
|
53
|
+
// Not yet recovered, and -1 says so rather than claiming an instant recovery.
|
|
54
|
+
assertEquals(-1L, begin.recoveryMs)
|
|
55
|
+
assertTrue(tracker.isInEpisode())
|
|
56
|
+
|
|
57
|
+
assertNull(tracker.feed(200_000, 40_000, width = 320, height = 180))
|
|
58
|
+
|
|
59
|
+
val end = tracker.feed(240_000, 700_000, width = 640, height = 360)
|
|
60
|
+
assertNotNull(end)
|
|
61
|
+
assertEquals(WireEpisodeTracker.Phase.END, end!!.phase)
|
|
62
|
+
assertEquals(90_000L, end.durationMs)
|
|
63
|
+
assertEquals(33_000L, end.minBitrateBps)
|
|
64
|
+
assertEquals("320x180", end.minResolution)
|
|
65
|
+
assertEquals(-1L, end.recoveryMs)
|
|
66
|
+
assertFalse(tracker.isInEpisode())
|
|
67
|
+
assertTrue(tracker.isAwaitingRecovery())
|
|
68
|
+
|
|
69
|
+
// Still under 1 Mbps: degraded, so not recovered.
|
|
70
|
+
assertNull(tracker.feed(300_000, 900_000))
|
|
71
|
+
|
|
72
|
+
val recovered = tracker.feed(410_000, 1_300_000)
|
|
73
|
+
assertNotNull(recovered)
|
|
74
|
+
assertEquals(WireEpisodeTracker.Phase.RECOVERED, recovered!!.phase)
|
|
75
|
+
assertEquals(90_000L, recovered.durationMs)
|
|
76
|
+
assertEquals(170_000L, recovered.recoveryMs)
|
|
77
|
+
assertFalse(tracker.isAwaitingRecovery())
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The rule that keeps the whole thing honest. ACS has been observed publishing 32 empty
|
|
82
|
+
* MEDIA_STATISTICS reports against 12 filled ones on one seven-minute call; if an unset field
|
|
83
|
+
* counted as zero, that call would report 32 episodes and the real ones would be unfindable.
|
|
84
|
+
*/
|
|
85
|
+
@Test
|
|
86
|
+
fun `an unreported rate is not an episode`() {
|
|
87
|
+
val tracker = tracker()
|
|
88
|
+
|
|
89
|
+
assertNull(tracker.feed(0, 1_300_000))
|
|
90
|
+
assertNull(tracker.feed(2_000, null))
|
|
91
|
+
assertNull(tracker.feed(4_000, -1))
|
|
92
|
+
assertNull(tracker.feed(6_000, 0))
|
|
93
|
+
|
|
94
|
+
assertFalse(tracker.isInEpisode())
|
|
95
|
+
// And the healthy state survived the silence rather than being reset by it.
|
|
96
|
+
assertNull(tracker.feed(8_000, 1_300_000))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Silence in the middle of an episode must not end it either. */
|
|
100
|
+
@Test
|
|
101
|
+
fun `silence during an episode leaves it open`() {
|
|
102
|
+
val tracker = tracker()
|
|
103
|
+
|
|
104
|
+
assertNotNull(tracker.feed(0, 40_000))
|
|
105
|
+
assertNull(tracker.feed(2_000, null))
|
|
106
|
+
assertTrue(tracker.isInEpisode())
|
|
107
|
+
|
|
108
|
+
val end = tracker.feed(10_000, 1_300_000)
|
|
109
|
+
assertEquals(WireEpisodeTracker.Phase.END, end!!.phase)
|
|
110
|
+
assertEquals(10_000L, end.durationMs)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Two dips with a brief good patch between them are two episodes. Merging them would hide the
|
|
115
|
+
* flapping, which is a different fault from one long collapse and points somewhere else.
|
|
116
|
+
*/
|
|
117
|
+
@Test
|
|
118
|
+
fun `a second dip before recovery starts a new episode`() {
|
|
119
|
+
val tracker = tracker()
|
|
120
|
+
|
|
121
|
+
assertNotNull(tracker.feed(0, 40_000))
|
|
122
|
+
val firstEnd = tracker.feed(10_000, 600_000)
|
|
123
|
+
assertEquals(WireEpisodeTracker.Phase.END, firstEnd!!.phase)
|
|
124
|
+
|
|
125
|
+
val secondBegin = tracker.feed(20_000, 35_000)
|
|
126
|
+
assertNotNull(secondBegin)
|
|
127
|
+
assertEquals(WireEpisodeTracker.Phase.BEGIN, secondBegin!!.phase)
|
|
128
|
+
assertEquals(20_000L, secondBegin.startedAtMs)
|
|
129
|
+
// The new episode starts clean rather than inheriting the first one's floor.
|
|
130
|
+
assertEquals(35_000L, secondBegin.minBitrateBps)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@Test
|
|
134
|
+
fun `the glasses hop during the episode is averaged and carried`() {
|
|
135
|
+
val tracker = tracker()
|
|
136
|
+
|
|
137
|
+
tracker.feed(0, 40_000, inbound = 2_000_000.0)
|
|
138
|
+
tracker.feed(2_000, 35_000, inbound = 2_200_000.0)
|
|
139
|
+
val end = tracker.feed(4_000, 1_300_000, inbound = 2_100_000.0)
|
|
140
|
+
|
|
141
|
+
// ~2.1 Mbps from the source while the wire sat at 35 kbps. This is the number that says the
|
|
142
|
+
// collapse was ACS's choice and not a starved pipeline.
|
|
143
|
+
assertEquals(2_100_000.0, end!!.inboundBitrateBps!!, 1.0)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@Test
|
|
147
|
+
fun `an episode with no glasses reading reports none rather than zero`() {
|
|
148
|
+
val tracker = tracker()
|
|
149
|
+
|
|
150
|
+
tracker.feed(0, 40_000, inbound = null)
|
|
151
|
+
val end = tracker.feed(2_000, 1_300_000, inbound = null)
|
|
152
|
+
|
|
153
|
+
assertNull(end!!.inboundBitrateBps)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
@Test
|
|
157
|
+
fun `an episode with no size reading still reports its floor`() {
|
|
158
|
+
val tracker = tracker()
|
|
159
|
+
|
|
160
|
+
tracker.feed(0, 40_000, width = null, height = null)
|
|
161
|
+
val end = tracker.feed(2_000, 1_300_000)
|
|
162
|
+
|
|
163
|
+
assertEquals(40_000L, end!!.minBitrateBps)
|
|
164
|
+
assertEquals("", end.minResolution)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A jump straight from the floor past the recovery line reports one event, then the other. */
|
|
168
|
+
@Test
|
|
169
|
+
fun `a straight jump to full rate reports end then recovered`() {
|
|
170
|
+
val tracker = tracker()
|
|
171
|
+
|
|
172
|
+
tracker.feed(0, 40_000)
|
|
173
|
+
val end = tracker.feed(2_000, 1_300_000)
|
|
174
|
+
assertEquals(WireEpisodeTracker.Phase.END, end!!.phase)
|
|
175
|
+
|
|
176
|
+
val recovered = tracker.feed(4_000, 1_300_000)
|
|
177
|
+
assertEquals(WireEpisodeTracker.Phase.RECOVERED, recovered!!.phase)
|
|
178
|
+
assertEquals(2_000L, recovered.recoveryMs)
|
|
179
|
+
}
|
|
180
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mentra/acs-meeting",
|
|
3
|
-
"version": "3.2.0-dev.
|
|
3
|
+
"version": "3.2.0-dev.245",
|
|
4
4
|
"description": "MentraOS native ACS Teams meeting module (WHEP decode → ACS raw media)",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"react-native": "src/index.ts",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@expo/config-plugins": "~55.0.10",
|
|
35
|
-
"@mentra/glasses-media": "3.2.0-dev.
|
|
35
|
+
"@mentra/glasses-media": "3.2.0-dev.245"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"expo-module-scripts": "^55.0.2",
|