@mentra/acs-meeting 3.2.0-dev.221 → 3.2.0-dev.223
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/README.md +4 -2
- package/android/build.gradle +23 -0
- package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +291 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/mentra/acsmeeting/AbandonedCallAgent.kt +36 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +392 -22
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +909 -56
- package/android/src/main/java/com/mentra/acsmeeting/CallCapabilities.kt +33 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +21 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/AudioUplinkChain.kt +126 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +27 -4
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +45 -10
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +110 -5
- package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +501 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +51 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +81 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +355 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +109 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +160 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +103 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +53 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +127 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +573 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +10 -101
- package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +147 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +47 -5
- package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +772 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +73 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +17 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +139 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +144 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +46 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +160 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +3 -1
- package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +69 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +207 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +356 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +155 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +94 -3
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +33 -5
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +15 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +178 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +145 -0
- package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +143 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +23 -4
- package/android/src/main/java/com/mentra/acsmeeting/video/FramePacer.kt +70 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +1 -1
- package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +36 -3
- package/android/src/test/java/com/mentra/acsmeeting/AbandonedCallAgentTest.kt +74 -0
- package/android/src/test/java/com/mentra/acsmeeting/CallCapabilitiesTest.kt +47 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/AudioUplinkChainTest.kt +250 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +44 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +34 -4
- package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +96 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +102 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +148 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +59 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +59 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +35 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +165 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +96 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +187 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +172 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +141 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +69 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +40 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +41 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +26 -8
- package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +109 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +165 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +148 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +76 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +275 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +9 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +356 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +411 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +83 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +69 -3
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +182 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +149 -0
- package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +97 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +34 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/FramePacerTest.kt +105 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +5 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +38 -3
- package/build/AcsMeeting.types.d.ts +5 -0
- package/build/AcsMeeting.types.d.ts.map +1 -1
- package/build/AcsMeeting.types.js.map +1 -1
- package/build/AcsMeetingModule.d.ts +23 -0
- package/build/AcsMeetingModule.d.ts.map +1 -1
- package/build/AcsMeetingModule.js.map +1 -1
- package/build/AcsMeetingModule.web.d.ts +15 -0
- package/build/AcsMeetingModule.web.d.ts.map +1 -1
- package/build/AcsMeetingModule.web.js +13 -0
- package/build/AcsMeetingModule.web.js.map +1 -1
- package/ios/AcsMeetingModule.swift +213 -2
- package/package.json +1 -1
- package/src/AcsMeeting.types.ts +5 -0
- package/src/AcsMeetingModule.ts +13 -0
- package/src/AcsMeetingModule.web.ts +13 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.telemetry
|
|
2
|
+
|
|
3
|
+
import com.mentra.acsmeeting.telemetry.VideoQuality.Band
|
|
4
|
+
import com.mentra.acsmeeting.video.VideoProfile
|
|
5
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
6
|
+
import org.assertj.core.api.Assertions.within
|
|
7
|
+
import org.junit.Test
|
|
8
|
+
|
|
9
|
+
class VideoQualityTest {
|
|
10
|
+
@Test
|
|
11
|
+
fun bitsPerPixelIsBitrateOverPixelsPerSecond() {
|
|
12
|
+
// 960x540 at 15 fps on 1.5 Mbps: 1_500_000 / (960*540*15).
|
|
13
|
+
val bpp = VideoQuality.bitsPerPixel(1_500_000, 960, 540, 15.0)!!
|
|
14
|
+
assertThat(bpp).isCloseTo(0.193, within(0.001))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@Test
|
|
18
|
+
fun missingOrZeroInputsGiveNoNumberRatherThanZero() {
|
|
19
|
+
assertThat(VideoQuality.bitsPerPixel(null, 960, 540, 15.0)).isNull()
|
|
20
|
+
assertThat(VideoQuality.bitsPerPixel(1_500_000, 960, 540, null)).isNull()
|
|
21
|
+
assertThat(VideoQuality.bitsPerPixel(1_500_000, 0, 540, 15.0)).isNull()
|
|
22
|
+
assertThat(VideoQuality.bitsPerPixel(1_500_000, 960, 540, 0.0)).isNull()
|
|
23
|
+
assertThat(VideoQuality.band(null)).isEqualTo(Band.UNKNOWN)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@Test
|
|
27
|
+
fun bandsFollowTheKushGaugeMotionMultiples() {
|
|
28
|
+
assertThat(VideoQuality.band(0.03)).isEqualTo(Band.BLOCKY)
|
|
29
|
+
assertThat(VideoQuality.band(0.10)).isEqualTo(Band.SOFT)
|
|
30
|
+
assertThat(VideoQuality.band(0.20)).isEqualTo(Band.OK)
|
|
31
|
+
assertThat(VideoQuality.band(0.30)).isEqualTo(Band.GOOD)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@Test
|
|
35
|
+
fun bandBoundariesAreTheConstantsThemselves() {
|
|
36
|
+
assertThat(VideoQuality.band(VideoQuality.STATIC_BPP)).isEqualTo(Band.SOFT)
|
|
37
|
+
assertThat(VideoQuality.band(VideoQuality.MODERATE_BPP)).isEqualTo(Band.OK)
|
|
38
|
+
assertThat(VideoQuality.band(VideoQuality.HIGH_MOTION_BPP)).isEqualTo(Band.GOOD)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The measured device state: `wire=960x540@14.4 kbps=1276`. Every rate counter
|
|
43
|
+
* read healthy and the picture was reported pixelated, which is the whole
|
|
44
|
+
* reason this file exists.
|
|
45
|
+
*/
|
|
46
|
+
@Test
|
|
47
|
+
fun theMeasuredDeviceStateLandsShortOfHighMotion() {
|
|
48
|
+
val bpp = VideoQuality.bitsPerPixel(1_276_000, 960, 540, 14.4)!!
|
|
49
|
+
assertThat(bpp).isCloseTo(0.171, within(0.001))
|
|
50
|
+
assertThat(VideoQuality.band(bpp)).isEqualTo(Band.OK)
|
|
51
|
+
assertThat(bpp).isLessThan(VideoQuality.HIGH_MOTION_BPP)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The old 1.5 Mbps grant could not reach high motion at 540p15 no matter what the
|
|
56
|
+
* encoder did, which is why the ceiling was raised. This pins the new one to the
|
|
57
|
+
* requirement rather than to a round number, so shrinking it back below what the
|
|
58
|
+
* geometry needs fails here instead of on someone's screen.
|
|
59
|
+
*/
|
|
60
|
+
@Test
|
|
61
|
+
fun theShipped540pBudgetCanNowReachHighMotion() {
|
|
62
|
+
val p540 = VideoProfile.P540_15
|
|
63
|
+
val needed = VideoQuality.neededBps(p540.width, p540.height, p540.fps.toDouble(), VideoQuality.HIGH_MOTION_BPP)
|
|
64
|
+
assertThat(needed).isCloseTo(2_177_280L, within(1_000L))
|
|
65
|
+
assertThat(p540.maxBitrateBps.toLong()).isGreaterThanOrEqualTo(needed)
|
|
66
|
+
assertThat(
|
|
67
|
+
VideoQuality.budgetCapsQuality(p540.width, p540.height, p540.fps.toDouble(), p540.maxBitrateBps),
|
|
68
|
+
).isFalse()
|
|
69
|
+
// The grant it replaced, kept as the regression it was.
|
|
70
|
+
assertThat(VideoQuality.budgetCapsQuality(p540.width, p540.height, p540.fps.toDouble(), 1_500_000)).isTrue()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 360p15 fits inside its 1 Mbps grant, so the smaller profile is not budget-capped. */
|
|
74
|
+
@Test
|
|
75
|
+
fun theSdProfileIsNotBudgetCapped() {
|
|
76
|
+
val sd = VideoProfile.SD
|
|
77
|
+
assertThat(
|
|
78
|
+
VideoQuality.budgetCapsQuality(sd.width, sd.height, sd.fps.toDouble(), sd.maxBitrateBps),
|
|
79
|
+
).isFalse()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@Test
|
|
83
|
+
fun aBudgetOfZeroIsNotTreatedAsACap() {
|
|
84
|
+
assertThat(VideoQuality.budgetCapsQuality(960, 540, 15.0, 0)).isFalse()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@Test
|
|
88
|
+
fun everyBandHasANonEmptyHint() {
|
|
89
|
+
for (band in Band.entries) {
|
|
90
|
+
for (capped in listOf(true, false)) {
|
|
91
|
+
for (spending in listOf(true, false)) {
|
|
92
|
+
for (busy in listOf(true, false)) {
|
|
93
|
+
assertThat(VideoQuality.hint(band, capped, spending, busy)).isNotBlank()
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Underspending with a nearly full core and underspending with an idle one look
|
|
102
|
+
* the same on screen and want opposite fixes, so they must not share a hint.
|
|
103
|
+
*/
|
|
104
|
+
@Test
|
|
105
|
+
fun underspendSplitsOnCpuRatherThanCollapsingToOneRemedy() {
|
|
106
|
+
val busy = VideoQuality.hint(Band.OK, budgetCapped = true, spendingBudget = false, encoderBusy = true)
|
|
107
|
+
val idle = VideoQuality.hint(Band.OK, budgetCapped = true, spendingBudget = false, encoderBusy = false)
|
|
108
|
+
assertThat(busy).contains("cut pixels")
|
|
109
|
+
assertThat(idle).contains("congestion control")
|
|
110
|
+
assertThat(idle).doesNotContain("cut pixels")
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@Test
|
|
114
|
+
fun spendingIsMeasuredAgainstTheGrantedCeiling() {
|
|
115
|
+
assertThat(VideoQuality.spendingBudget(1_275_000, 1_500_000)).isTrue()
|
|
116
|
+
assertThat(VideoQuality.spendingBudget(1_200_000, 1_500_000)).isFalse()
|
|
117
|
+
assertThat(VideoQuality.spendingBudget(null, 1_500_000)).isFalse()
|
|
118
|
+
assertThat(VideoQuality.spendingBudget(1_200_000, 0)).isFalse()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Measured on device during the bitrate slide: `kbps=1210 budgetKbps=1500` with
|
|
123
|
+
* process CPU at 69–88% of one core on an 8-core phone. The encoder had headroom
|
|
124
|
+
* and the rate still fell, so this must not be reported as an encoder problem.
|
|
125
|
+
*/
|
|
126
|
+
@Test
|
|
127
|
+
fun theMeasuredSlideIsBlamedOnCongestionNotTheEncoder() {
|
|
128
|
+
val line = VideoQuality.line(960, 540, 14.2, 1_210_000, 1_500_000, 140.0, 77.8, "GOOD")
|
|
129
|
+
assertThat(line).contains("used=81%")
|
|
130
|
+
assertThat(line).contains("cpu=77.8")
|
|
131
|
+
assertThat(line).contains("sendQuality=GOOD")
|
|
132
|
+
assertThat(line).contains("congestion control")
|
|
133
|
+
assertThat(line).doesNotContain("cut pixels")
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A picture that is already sharp is never told to cut anything. */
|
|
137
|
+
@Test
|
|
138
|
+
fun aGoodPictureIsNotGivenARemedy() {
|
|
139
|
+
val hint = VideoQuality.hint(
|
|
140
|
+
Band.GOOD,
|
|
141
|
+
budgetCapped = true,
|
|
142
|
+
spendingBudget = false,
|
|
143
|
+
encoderBusy = true,
|
|
144
|
+
)
|
|
145
|
+
assertThat(hint).isEqualTo("enough for high motion")
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
@Test
|
|
149
|
+
fun theLinePrintsTheShortfallAndNotJustTheNumber() {
|
|
150
|
+
val line = VideoQuality.line(
|
|
151
|
+
wireWidth = 960,
|
|
152
|
+
wireHeight = 540,
|
|
153
|
+
wireFps = 14.4,
|
|
154
|
+
wireBitrateBps = 1_276_000,
|
|
155
|
+
budgetBps = 1_500_000,
|
|
156
|
+
packetsPerSecond = 143.0,
|
|
157
|
+
cpuPercent = 74.4,
|
|
158
|
+
sendQuality = "GOOD",
|
|
159
|
+
)
|
|
160
|
+
assertThat(line).startsWith("P9 quality band=OK")
|
|
161
|
+
assertThat(line).contains("bpp=0.171")
|
|
162
|
+
assertThat(line).contains("size=960x540@14.4")
|
|
163
|
+
assertThat(line).contains("kbps=1276")
|
|
164
|
+
assertThat(line).contains("budgetKbps=1500")
|
|
165
|
+
assertThat(line).contains("used=85%")
|
|
166
|
+
// What it would take to look sharp on a head-mounted camera, scored against the
|
|
167
|
+
// rate actually on the wire (960*540*14.4*0.28), not the rate we asked for.
|
|
168
|
+
assertThat(line).contains("goodNeedsKbps=2090")
|
|
169
|
+
assertThat(line).contains("budgetCapped=true")
|
|
170
|
+
assertThat(line).contains("pps=143.0")
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
@Test
|
|
174
|
+
fun theLineSurvivesAMissingWireReport() {
|
|
175
|
+
val line = VideoQuality.line(null, null, null, null, 1_500_000, null, null, "")
|
|
176
|
+
assertThat(line).contains("band=UNKNOWN")
|
|
177
|
+
assertThat(line).contains("bpp=na")
|
|
178
|
+
assertThat(line).contains("pps=na")
|
|
179
|
+
assertThat(line).contains("cpu=na")
|
|
180
|
+
assertThat(line).contains("sendQuality=na")
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.telemetry
|
|
2
|
+
|
|
3
|
+
import com.mentra.acsmeeting.telemetry.VideoRateVerdict.Bound
|
|
4
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
5
|
+
import org.junit.Test
|
|
6
|
+
|
|
7
|
+
class VideoRateVerdictTest {
|
|
8
|
+
/** 540p15 against a starved 1.5 Mbps budget — the grant Mentra Call used to send. */
|
|
9
|
+
private fun verdict(
|
|
10
|
+
advertisedFps: Double = 15.0,
|
|
11
|
+
sinkFps: Double = 15.0,
|
|
12
|
+
admittedFps: Double = 15.0,
|
|
13
|
+
wireFps: Double? = 15.0,
|
|
14
|
+
wireBitrateBps: Long? = 1_400_000,
|
|
15
|
+
budgetBps: Int = 1_500_000,
|
|
16
|
+
cpuPercent: Double? = 55.0,
|
|
17
|
+
) = VideoRateVerdict.of(
|
|
18
|
+
advertisedFps = advertisedFps,
|
|
19
|
+
sinkFps = sinkFps,
|
|
20
|
+
admittedFps = admittedFps,
|
|
21
|
+
wireFps = wireFps,
|
|
22
|
+
wireBitrateBps = wireBitrateBps,
|
|
23
|
+
budgetBps = budgetBps,
|
|
24
|
+
cpuPercent = cpuPercent,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
@Test
|
|
28
|
+
fun aWireHoldingTheAdvertisedRateIsOk() {
|
|
29
|
+
assertThat(verdict()).isEqualTo(Bound.OK)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The verdict that decides whether "the call quality is low" is answered by raising the grant.
|
|
34
|
+
* Every frame is on the wire, so nothing here is short — but the bits are pressed against the
|
|
35
|
+
* ceiling, which is the one condition under which a bigger number changes the picture. Without
|
|
36
|
+
* this the same tick reads OK and the ceiling never enters the conversation.
|
|
37
|
+
*/
|
|
38
|
+
@Test
|
|
39
|
+
fun aFullRateRidingTheCeilingIsNamedAsCapBound() {
|
|
40
|
+
assertThat(verdict(wireBitrateBps = 1_480_000)).isEqualTo(Bound.CAP_BOUND)
|
|
41
|
+
// The controller routinely overshoots the grant; that is still the ceiling binding.
|
|
42
|
+
assertThat(verdict(wireBitrateBps = 1_623_000)).isEqualTo(Bound.CAP_BOUND)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Headroom under the grant at a full rate is a healthy stream, not a ceiling problem. */
|
|
46
|
+
@Test
|
|
47
|
+
fun aFullRateWithBitsToSpareIsStillPlainOk() {
|
|
48
|
+
assertThat(verdict(wireBitrateBps = 1_100_000)).isEqualTo(Bound.OK)
|
|
49
|
+
assertThat(verdict(wireBitrateBps = null)).isEqualTo(Bound.OK)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Cap-bound is about the ceiling, and a short frame rate means something else is wrong first.
|
|
54
|
+
* Raising the grant for a stream that cannot fill the one it has buys nothing.
|
|
55
|
+
*/
|
|
56
|
+
@Test
|
|
57
|
+
fun aShortRateIsDiagnosedBeforeTheCeilingIsConsidered() {
|
|
58
|
+
assertThat(verdict(wireFps = 8.0, wireBitrateBps = 1_480_000, cpuPercent = 92.0))
|
|
59
|
+
.isEqualTo(Bound.ENCODER_SHORT)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The exact shape of the greedy-gate bug: the glasses supplied 14.6 fps, the
|
|
64
|
+
* pacer admitted 7.3 against an advertised 8, and every counter downstream
|
|
65
|
+
* looked like an encoder problem. It was ours.
|
|
66
|
+
*/
|
|
67
|
+
@Test
|
|
68
|
+
fun aGateThatDropsFramesTheSourceSuppliedIsBlamedOnThePacer() {
|
|
69
|
+
assertThat(verdict(advertisedFps = 8.0, sinkFps = 14.6, admittedFps = 7.3, wireFps = 7.0))
|
|
70
|
+
.isEqualTo(Bound.PACER_SHORT)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@Test
|
|
74
|
+
fun aShortSourceIsNotBlamedOnAnythingDownstream() {
|
|
75
|
+
assertThat(verdict(sinkFps = 6.0, admittedFps = 6.0, wireFps = 6.0))
|
|
76
|
+
.isEqualTo(Bound.SOURCE_SHORT)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A source a little under the advertise is not the pacer's fault. Scoring the
|
|
81
|
+
* gate against the advertise alone would read every slightly slow glasses leg
|
|
82
|
+
* as our own bug and send the next fix to the wrong place.
|
|
83
|
+
*/
|
|
84
|
+
@Test
|
|
85
|
+
fun thePacerIsNotBlamedForFramesTheSourceNeverSent() {
|
|
86
|
+
assertThat(verdict(sinkFps = 13.0, admittedFps = 13.0, wireFps = 13.0))
|
|
87
|
+
.isEqualTo(Bound.OK)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The two verdicts that matter, because they want opposite fixes. Same short
|
|
92
|
+
* wire rate, same delivered rate; only CPU and the bitrate headroom differ.
|
|
93
|
+
*/
|
|
94
|
+
@Test
|
|
95
|
+
fun aBusyCpuMeansTheEncoderIsTheCeiling() {
|
|
96
|
+
assertThat(verdict(wireFps = 8.0, cpuPercent = 92.0)).isEqualTo(Bound.ENCODER_SHORT)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@Test
|
|
100
|
+
fun sparecpuAndACollapsedBitrateMeansTheRateControllerIsThrottling() {
|
|
101
|
+
assertThat(verdict(wireFps = 8.0, wireBitrateBps = 496_000, cpuPercent = 50.0))
|
|
102
|
+
.isEqualTo(Bound.BITRATE_STARVED)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Spare CPU but the budget is being spent: the encoder really is the limit. */
|
|
106
|
+
@Test
|
|
107
|
+
fun sparecpuWithTheBudgetSpentIsStillTheEncoder() {
|
|
108
|
+
assertThat(verdict(wireFps = 8.0, wireBitrateBps = 1_450_000, cpuPercent = 50.0))
|
|
109
|
+
.isEqualTo(Bound.ENCODER_SHORT)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@Test
|
|
113
|
+
fun aMissingWireReportIsNotAVerdict() {
|
|
114
|
+
assertThat(verdict(wireFps = null)).isEqualTo(Bound.UNKNOWN)
|
|
115
|
+
assertThat(verdict(advertisedFps = 0.0)).isEqualTo(Bound.UNKNOWN)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Every bound carries an action, or the line makes the reader open this file. */
|
|
119
|
+
@Test
|
|
120
|
+
fun everyBoundHasANonEmptyHint() {
|
|
121
|
+
for (bound in Bound.entries) {
|
|
122
|
+
assertThat(VideoRateVerdict.hint(bound)).isNotBlank()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@Test
|
|
127
|
+
fun theLinePrintsTheInputsBesideTheVerdict() {
|
|
128
|
+
val line = VideoRateVerdict.line(
|
|
129
|
+
advertisedFps = 15.0,
|
|
130
|
+
sinkFps = 14.6,
|
|
131
|
+
admittedFps = 7.3,
|
|
132
|
+
wireFps = 7.0,
|
|
133
|
+
wireBitrateBps = 496_000,
|
|
134
|
+
budgetBps = 1_500_000,
|
|
135
|
+
cpuPercent = 61.3,
|
|
136
|
+
codecName = "h264 sw",
|
|
137
|
+
)
|
|
138
|
+
assertThat(line).startsWith("P7 rate bound=PACER_SHORT")
|
|
139
|
+
assertThat(line).contains("advertised=15.0")
|
|
140
|
+
assertThat(line).contains("sink=14.6")
|
|
141
|
+
assertThat(line).contains("admitted=7.3")
|
|
142
|
+
assertThat(line).contains("wire=7.0")
|
|
143
|
+
assertThat(line).contains("kbps=496")
|
|
144
|
+
assertThat(line).contains("budgetKbps=1500")
|
|
145
|
+
assertThat(line).contains("cpu=61.3")
|
|
146
|
+
assertThat(line).contains("codec=h264_sw")
|
|
147
|
+
assertThat(line).contains(VideoRateVerdict.hint(Bound.PACER_SHORT))
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.trace
|
|
2
|
+
|
|
3
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
4
|
+
import org.junit.Test
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Covers the pure formatter. The `Log.i` wrapper is a one-liner and is exercised on device.
|
|
8
|
+
*/
|
|
9
|
+
class SoftApTraceTest {
|
|
10
|
+
|
|
11
|
+
@Test
|
|
12
|
+
fun `format carries the grep marker`() {
|
|
13
|
+
assertThat(SoftApTrace.format("t1", "scoped_network_available", 12))
|
|
14
|
+
.contains("SOFTAP_TRACE")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@Test
|
|
18
|
+
fun `format includes traceId stage and elapsed`() {
|
|
19
|
+
assertThat(SoftApTrace.format("abc123", "ice_selected_pair", 450))
|
|
20
|
+
.isEqualTo("[SOFTAP_TRACE] traceId=abc123 stage=ice_selected_pair elapsedMs=450")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@Test
|
|
24
|
+
fun `format omits traceId when absent`() {
|
|
25
|
+
assertThat(SoftApTrace.format("", "boot", 0))
|
|
26
|
+
.isEqualTo("[SOFTAP_TRACE] stage=boot elapsedMs=0")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Test
|
|
30
|
+
fun `format appends fields in order`() {
|
|
31
|
+
assertThat(SoftApTrace.format("t", "whip_request", 7, "method" to "POST", "status" to 201))
|
|
32
|
+
.endsWith("method=POST status=201")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@Test
|
|
36
|
+
fun `sanitize redacts the hotspot password`() {
|
|
37
|
+
assertThat(SoftApTrace.sanitize("password", "hunter2")).isEqualTo("<redacted>")
|
|
38
|
+
assertThat(SoftApTrace.sanitize("hotspotPassword", "hunter2")).isEqualTo("<redacted>")
|
|
39
|
+
assertThat(SoftApTrace.sanitize("psk", "hunter2")).isEqualTo("<redacted>")
|
|
40
|
+
assertThat(SoftApTrace.sanitize("passphrase", "hunter2")).isEqualTo("<redacted>")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@Test
|
|
44
|
+
fun `sanitize redacts tokens and credentials`() {
|
|
45
|
+
assertThat(SoftApTrace.sanitize("token", "eyJhbGciOi")).isEqualTo("<redacted>")
|
|
46
|
+
assertThat(SoftApTrace.sanitize("acsToken", "eyJhbGciOi")).isEqualTo("<redacted>")
|
|
47
|
+
assertThat(SoftApTrace.sanitize("Authorization", "Bearer x")).isEqualTo("<redacted>")
|
|
48
|
+
assertThat(SoftApTrace.sanitize("meetingUrl", "https://teams.microsoft.com/l/x"))
|
|
49
|
+
.isEqualTo("<redacted>")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@Test
|
|
53
|
+
fun `format never leaks a secret value into the line`() {
|
|
54
|
+
val line = SoftApTrace.format(
|
|
55
|
+
"t", "hotspot_started", 5,
|
|
56
|
+
"ssid" to "MentraLive", "password" to "hunter2",
|
|
57
|
+
)
|
|
58
|
+
assertThat(line).doesNotContain("hunter2")
|
|
59
|
+
assertThat(line).contains("ssid=MentraLive")
|
|
60
|
+
assertThat(line).contains("password=<redacted>")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@Test
|
|
64
|
+
fun `sanitize keeps the local whip url which is diagnostic`() {
|
|
65
|
+
assertThat(SoftApTrace.sanitize("whipUrl", "http://192.168.43.20:8790/whip/abc"))
|
|
66
|
+
.isEqualTo("http://192.168.43.20:8790/whip/abc")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@Test
|
|
70
|
+
fun `sanitize strips query strings that may carry tokens`() {
|
|
71
|
+
assertThat(SoftApTrace.sanitize("playbackUrl", "https://example.com/live?token=secret123"))
|
|
72
|
+
.isEqualTo("https://example.com/live?<redacted>")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@Test
|
|
76
|
+
fun `sanitize strips userinfo from urls`() {
|
|
77
|
+
val result = SoftApTrace.sanitize("endpoint", "https://user:pw@example.com/x")
|
|
78
|
+
assertThat(result).doesNotContain("pw@")
|
|
79
|
+
assertThat(result).contains("<redacted>@example.com/x")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@Test
|
|
83
|
+
fun `sanitize quotes values containing spaces`() {
|
|
84
|
+
assertThat(SoftApTrace.sanitize("reason", "two words")).isEqualTo("\"two words\"")
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@Test
|
|
88
|
+
fun `sanitize renders null and empty`() {
|
|
89
|
+
assertThat(SoftApTrace.sanitize("reason", null)).isEqualTo("null")
|
|
90
|
+
assertThat(SoftApTrace.sanitize("reason", "")).isEqualTo("\"\"")
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@Test
|
|
94
|
+
fun `newTraceId produces distinct ids`() {
|
|
95
|
+
assertThat(SoftApTrace.newTraceId()).isNotEqualTo(SoftApTrace.newTraceId())
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -40,4 +40,38 @@ class AcsTimestampTest {
|
|
|
40
40
|
val ticks = AcsTimestamp.resolve(streamTicks = 0, captureNs = 100, lastTicks = 9_000)
|
|
41
41
|
assertThat(ticks).isEqualTo(9_001)
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Outgoing audio's shape: `RawOutgoingAudioStream` in 2.16.0 exposes no stream clock, so the
|
|
46
|
+
* capture time is always the source and `streamTicks` is always 0.
|
|
47
|
+
*
|
|
48
|
+
* The audio and the video come from different places — one over BLE, one over the SoftAP peer —
|
|
49
|
+
* so a shared `System.nanoTime()` base is the only thing that lets a receiver relate them at all.
|
|
50
|
+
* These assertions are the precondition for the A/V question, not the answer to it: whether ACS
|
|
51
|
+
* honours the ticks for raw audio is a receiver measurement.
|
|
52
|
+
*/
|
|
53
|
+
@Test
|
|
54
|
+
fun audioFramesStampedFromCaptureTimeShareTheVideoTickBase() {
|
|
55
|
+
val captureNs = 1_234_500_000L
|
|
56
|
+
val audio = AcsTimestamp.resolve(streamTicks = 0, captureNs = captureNs, lastTicks = 0)
|
|
57
|
+
val videoFromTheSameInstant = AcsTimestamp.resolve(streamTicks = 0, captureNs = captureNs, lastTicks = 0)
|
|
58
|
+
|
|
59
|
+
assertThat(audio).isEqualTo(videoFromTheSameInstant)
|
|
60
|
+
assertThat(audio).isEqualTo(captureNs / AcsTimestamp.NS_PER_TICK)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@Test
|
|
64
|
+
fun aStreamOfAudioFramesIsStrictlyIncreasingAndNeverZero() {
|
|
65
|
+
// 20 ms periods on a clock coarse enough that two frames could collide.
|
|
66
|
+
var last = 0L
|
|
67
|
+
val ticks = (0 until 50).map { period ->
|
|
68
|
+
val resolved = AcsTimestamp.resolve(streamTicks = 0, captureNs = period * 20_000_000L, lastTicks = last)
|
|
69
|
+
last = resolved
|
|
70
|
+
resolved
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
assertThat(ticks).doesNotContain(0L)
|
|
74
|
+
assertThat(ticks).isSorted()
|
|
75
|
+
assertThat(ticks.toSet()).hasSameSizeAs(ticks)
|
|
76
|
+
}
|
|
43
77
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.video
|
|
2
|
+
|
|
3
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
4
|
+
import org.assertj.core.api.Assertions.within
|
|
5
|
+
import org.junit.Test
|
|
6
|
+
|
|
7
|
+
class FramePacerTest {
|
|
8
|
+
@Test
|
|
9
|
+
fun firstFrameAlwaysAdmits() {
|
|
10
|
+
val pacer = FramePacer()
|
|
11
|
+
assertThat(pacer.tryAdmit(FramePacer.intervalNs(8f), nowNs = 1_000L)).isTrue()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
@Test
|
|
15
|
+
fun dropsAFrameThatArrivesInsideTheAdvertisedInterval() {
|
|
16
|
+
val pacer = FramePacer()
|
|
17
|
+
val interval = FramePacer.intervalNs(8f)
|
|
18
|
+
assertThat(pacer.tryAdmit(interval, nowNs = 0L)).isTrue()
|
|
19
|
+
assertThat(pacer.tryAdmit(interval, nowNs = interval - 1)).isFalse()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@Test
|
|
23
|
+
fun admitsOnceTheAdvertisedIntervalHasElapsed() {
|
|
24
|
+
val pacer = FramePacer()
|
|
25
|
+
val interval = FramePacer.intervalNs(8f)
|
|
26
|
+
assertThat(pacer.tryAdmit(interval, nowNs = 0L)).isTrue()
|
|
27
|
+
assertThat(pacer.tryAdmit(interval, nowNs = interval)).isTrue()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Fifteen frames a second against an 8 fps advertise is the production SoftAP
|
|
32
|
+
* case, and the only assertion that catches the failure is the sustained rate.
|
|
33
|
+
*
|
|
34
|
+
* Counting admits inside one 15-frame window cannot: 8 of 15 is also what a
|
|
35
|
+
* 7.5 fps stream looks like, because both land on i=0,2,4..14. That is how a
|
|
36
|
+
* greedy "interval elapsed since the last admit" gate passed this file while
|
|
37
|
+
* the device ran `sub=7.0 wire=960x540@7.0` against an advertised 8.
|
|
38
|
+
*/
|
|
39
|
+
@Test
|
|
40
|
+
fun sustainsTheAdvertisedRateFromAnyFasterSource() {
|
|
41
|
+
// 14.49 and 14.6 are arrival rates measured on device (gapP50 68-69ms), not
|
|
42
|
+
// round numbers. The greedy gate delivered 7.25 and 7.30 for them.
|
|
43
|
+
for (sourceFps in listOf(14.49f, 14.6f, 15f, 30f)) {
|
|
44
|
+
assertThat(sustainedFps(advertisedFps = 8f, sourceFps = sourceFps))
|
|
45
|
+
.`as`("source %s fps", sourceFps)
|
|
46
|
+
.isCloseTo(8.0, within(0.05))
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A source slower than the advertise passes through untouched, not padded. */
|
|
51
|
+
@Test
|
|
52
|
+
fun neverInventsFramesTheSourceDidNotSend() {
|
|
53
|
+
assertThat(sustainedFps(advertisedFps = 8f, sourceFps = 5f)).isCloseTo(5.0, within(0.05))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Catch-up is bounded. A source that goes away must not buy its debt back as a
|
|
58
|
+
* burst of back-to-back admits: that is over-delivery against the same
|
|
59
|
+
* advertised rate, which is the way the ungated path starved the encoder.
|
|
60
|
+
*/
|
|
61
|
+
@Test
|
|
62
|
+
fun aStallDoesNotEarnABurstOfCatchUpAdmits() {
|
|
63
|
+
val pacer = FramePacer()
|
|
64
|
+
val interval = FramePacer.intervalNs(8f)
|
|
65
|
+
assertThat(pacer.tryAdmit(interval, nowNs = 0L)).isTrue()
|
|
66
|
+
val afterStall = 10 * interval
|
|
67
|
+
assertThat(pacer.tryAdmit(interval, nowNs = afterStall)).isTrue()
|
|
68
|
+
// The resync admit starts a fresh interval; nothing is due until it elapses.
|
|
69
|
+
assertThat(pacer.tryAdmit(interval, nowNs = afterStall + 1)).isFalse()
|
|
70
|
+
assertThat(pacer.tryAdmit(interval, nowNs = afterStall + interval)).isTrue()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private fun sustainedFps(advertisedFps: Float, sourceFps: Float, frames: Int = 3000): Double {
|
|
74
|
+
val pacer = FramePacer()
|
|
75
|
+
val interval = FramePacer.intervalNs(advertisedFps)
|
|
76
|
+
val arrival = FramePacer.intervalNs(sourceFps)
|
|
77
|
+
var admitted = 0
|
|
78
|
+
for (i in 0 until frames) {
|
|
79
|
+
if (pacer.tryAdmit(interval, nowNs = i * arrival)) admitted += 1
|
|
80
|
+
}
|
|
81
|
+
return admitted / ((frames - 1).toDouble() * arrival / 1_000_000_000.0)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@Test
|
|
85
|
+
fun resetForgetsTheLastAdmit() {
|
|
86
|
+
val pacer = FramePacer()
|
|
87
|
+
val interval = FramePacer.intervalNs(8f)
|
|
88
|
+
assertThat(pacer.tryAdmit(interval, nowNs = 0L)).isTrue()
|
|
89
|
+
pacer.reset()
|
|
90
|
+
assertThat(pacer.tryAdmit(interval, nowNs = 1L)).isTrue()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@Test
|
|
94
|
+
fun zeroIntervalNeverPaces() {
|
|
95
|
+
val pacer = FramePacer()
|
|
96
|
+
assertThat(FramePacer.intervalNs(0f)).isEqualTo(0L)
|
|
97
|
+
assertThat(pacer.tryAdmit(0L, nowNs = 0L)).isTrue()
|
|
98
|
+
assertThat(pacer.tryAdmit(0L, nowNs = 1L)).isTrue()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@Test
|
|
102
|
+
fun eightFpsIntervalIsOneHundredTwentyFiveMilliseconds() {
|
|
103
|
+
assertThat(FramePacer.intervalNs(8f)).isEqualTo(125_000_000L)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -54,6 +54,11 @@ class AcsFrameSenderFormatTest {
|
|
|
54
54
|
fun parseOrNullAllowsDocumentedVirtualCameraSizesOnly() {
|
|
55
55
|
assertThat(I420FormatSpec.parseOrNull(1280, 720, 15f)).isNotNull()
|
|
56
56
|
assertThat(I420FormatSpec.parseOrNull(960, 540, 30f)).isNotNull()
|
|
57
|
+
for (fps in listOf(15f, 24f, 30f)) {
|
|
58
|
+
val spec = I420FormatSpec.parseOrNull(858, 480, fps)!!
|
|
59
|
+
assertThat(spec.namedResolution()).isEqualTo(AcsNamedResolution.P480)
|
|
60
|
+
assertThat(spec.packedSize()).isEqualTo(I420Packer.packedSize(858, 480))
|
|
61
|
+
}
|
|
57
62
|
assertThat(I420FormatSpec.parseOrNull(540, 960, 30f)).isNull()
|
|
58
63
|
assertThat(I420FormatSpec.parseOrNull(854, 480, 15f)).isNull()
|
|
59
64
|
}
|
|
@@ -49,15 +49,50 @@ class VideoProfileTest {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
@Test
|
|
53
|
+
fun acsCeilingsSitAboveTheGlassesPhoneLink() {
|
|
54
|
+
val glassesPhoneBps = 2_500_000
|
|
55
|
+
for (profile in listOf(VideoProfile.HD, VideoProfile.P540, VideoProfile.P540_15)) {
|
|
56
|
+
assertThat(profile.maxBitrateBps).isGreaterThan(glassesPhoneBps)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
52
60
|
@Test
|
|
53
61
|
fun parseAcceptsDocumentedVirtualCameraSizesAndRejectsPortraitAnd480p() {
|
|
54
|
-
assertThat(VideoProfile.parse(1280, 720, 15,
|
|
55
|
-
assertThat(VideoProfile.parse(960, 540, 30,
|
|
56
|
-
assertThat(VideoProfile.parse(960, 540, 15,
|
|
62
|
+
assertThat(VideoProfile.parse(1280, 720, 15, 3_000_000)).isEqualTo(VideoProfile.HD)
|
|
63
|
+
assertThat(VideoProfile.parse(960, 540, 30, 3_000_000)).isEqualTo(VideoProfile.P540)
|
|
64
|
+
assertThat(VideoProfile.parse(960, 540, 15, 3_000_000)).isEqualTo(VideoProfile.P540_15)
|
|
57
65
|
assertThat(VideoProfile.parse(540, 960, 30, 1_500_000)).isNull()
|
|
58
66
|
assertThat(VideoProfile.parse(854, 480, 15, 1_500_000)).isNull()
|
|
59
67
|
}
|
|
60
68
|
|
|
69
|
+
@Test
|
|
70
|
+
fun softwareEncoderClampDropsOnlyTheRate() {
|
|
71
|
+
val requested = VideoProfile.P540_15
|
|
72
|
+
val clamped = requested.forSoftwareEncoder()
|
|
73
|
+
assertThat(clamped.width).isEqualTo(requested.width)
|
|
74
|
+
assertThat(clamped.height).isEqualTo(requested.height)
|
|
75
|
+
assertThat(clamped.maxBitrateBps).isEqualTo(requested.maxBitrateBps)
|
|
76
|
+
assertThat(clamped.fps).isEqualTo(VideoProfile.SOFTWARE_ENCODER_FPS)
|
|
77
|
+
assertThat(clamped.spec().withinAcsBounds()).isTrue()
|
|
78
|
+
assertThat(clamped.bitsPerFrame()).isEqualTo(requested.maxBitrateBps / VideoProfile.SOFTWARE_ENCODER_FPS)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@Test
|
|
82
|
+
fun softwareEncoderClampIsIdempotentAtOrBelowTheSustainRate() {
|
|
83
|
+
val alreadyHeld = VideoProfile.P540_15.copy(fps = VideoProfile.SOFTWARE_ENCODER_FPS)
|
|
84
|
+
assertThat(alreadyHeld.forSoftwareEncoder()).isSameAs(alreadyHeld)
|
|
85
|
+
val slower = VideoProfile.P540_15.copy(fps = 5)
|
|
86
|
+
assertThat(slower.forSoftwareEncoder()).isSameAs(slower)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@Test
|
|
90
|
+
fun softwareEncoderClampHitsEveryShippedProfile() {
|
|
91
|
+
for (profile in listOf(VideoProfile.HD, VideoProfile.SD, VideoProfile.P540, VideoProfile.P540_15)) {
|
|
92
|
+
assertThat(profile.forSoftwareEncoder().fps).isEqualTo(VideoProfile.SOFTWARE_ENCODER_FPS)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
61
96
|
/** The fallback only earns its place if it is a real cut in encoder work. */
|
|
62
97
|
@Test
|
|
63
98
|
fun sdIsAQuarterOfTheHdPixelCount() {
|
|
@@ -42,6 +42,11 @@ export type AcsMeetingJoinOptions = {
|
|
|
42
42
|
displayName?: string;
|
|
43
43
|
/** "glasses" sends WHEP PCM. "phone" uses the ACS local mic (handset or BT). */
|
|
44
44
|
audioSource?: AcsAudioSource;
|
|
45
|
+
/**
|
|
46
|
+
* Hold outgoing PCM this many ms before ACS. SoftAP+LC3 sets this in the host
|
|
47
|
+
* because BLE audio reaches the phone before SoftAP video.
|
|
48
|
+
*/
|
|
49
|
+
audioDelayMs?: number;
|
|
45
50
|
/** Dump WHEP PCM to a WAV in cache for P4 verification. */
|
|
46
51
|
dumpPcmWav?: boolean;
|
|
47
52
|
/** When omitted, native keeps 1280×720@15 / 2.5 Mbps. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AcsMeeting.types.d.ts","sourceRoot":"","sources":["../src/AcsMeeting.types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,YAAY,GAAG,OAAO,GAAG,WAAW,GAAG,cAAc,GAAG,OAAO,CAAA;AAEnG,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,OAAO,CAAA;AAChD,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,CAAA;AAC1D,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,UAAU,GAAG,QAAQ,CAAA;AAC3D;;;;GAIG;AACH,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,YAAY,GAAG,MAAM,GAAG,QAAQ,CAAA;AAE3E,MAAM,MAAM,0BAA0B,GAAG,MAAM,GAAG,YAAY,GAAG,WAAW,GAAG,OAAO,GAAG,MAAM,GAAG,cAAc,CAAA;AAEhH,MAAM,MAAM,qBAAqB,GAAG;IAClC,EAAE,EAAE,MAAM,CAAA;IACV,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,KAAK,EAAE,0BAA0B,CAAA;IACjC,OAAO,EAAE,OAAO,CAAA;IAChB,UAAU,EAAE,OAAO,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,KAAK,EAAE,YAAY,CAAA;IACnB,KAAK,EAAE,OAAO,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,EAAE,WAAW,CAAA;IACtB,WAAW,CAAC,EAAE,cAAc,CAAA;IAC5B,YAAY,CAAC,EAAE,eAAe,CAAA;IAC9B,WAAW,CAAC,EAAE,cAAc,CAAA;IAC5B,WAAW,CAAC,EAAE,mBAAmB,CAAA;IACjC,4DAA4D;IAC5D,YAAY,CAAC,EAAE,qBAAqB,EAAE,CAAA;CACvC,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,GAAG,EAAE,MAAM,CAAA;IACX,aAAa,EAAE,MAAM,CAAA;CACtB,CAAA;AAED,MAAM,MAAM,qBAAqB,GAAG;IAClC,UAAU,EAAE,MAAM,CAAA;IAClB,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,gFAAgF;IAChF,WAAW,CAAC,EAAE,cAAc,CAAA;IAC5B,2DAA2D;IAC3D,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,yDAAyD;IACzD,KAAK,CAAC,EAAE,gBAAgB,CAAA;CACzB,CAAA;AAED,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,MAAM,CAAA;IACd,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,MAAM,MAAM,sBAAsB,GAAG;IACnC,OAAO,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAA;IACzC,aAAa,EAAE,CAAC,GAAG,EAAE,mBAAmB,KAAK,IAAI,CAAA;CAClD,CAAA"}
|
|
1
|
+
{"version":3,"file":"AcsMeeting.types.d.ts","sourceRoot":"","sources":["../src/AcsMeeting.types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,YAAY,GAAG,OAAO,GAAG,WAAW,GAAG,cAAc,GAAG,OAAO,CAAA;AAEnG,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,OAAO,CAAA;AAChD,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,CAAA;AAC1D,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,UAAU,GAAG,QAAQ,CAAA;AAC3D;;;;GAIG;AACH,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,YAAY,GAAG,MAAM,GAAG,QAAQ,CAAA;AAE3E,MAAM,MAAM,0BAA0B,GAAG,MAAM,GAAG,YAAY,GAAG,WAAW,GAAG,OAAO,GAAG,MAAM,GAAG,cAAc,CAAA;AAEhH,MAAM,MAAM,qBAAqB,GAAG;IAClC,EAAE,EAAE,MAAM,CAAA;IACV,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,KAAK,EAAE,0BAA0B,CAAA;IACjC,OAAO,EAAE,OAAO,CAAA;IAChB,UAAU,EAAE,OAAO,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,KAAK,EAAE,YAAY,CAAA;IACnB,KAAK,EAAE,OAAO,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,EAAE,WAAW,CAAA;IACtB,WAAW,CAAC,EAAE,cAAc,CAAA;IAC5B,YAAY,CAAC,EAAE,eAAe,CAAA;IAC9B,WAAW,CAAC,EAAE,cAAc,CAAA;IAC5B,WAAW,CAAC,EAAE,mBAAmB,CAAA;IACjC,4DAA4D;IAC5D,YAAY,CAAC,EAAE,qBAAqB,EAAE,CAAA;CACvC,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,GAAG,EAAE,MAAM,CAAA;IACX,aAAa,EAAE,MAAM,CAAA;CACtB,CAAA;AAED,MAAM,MAAM,qBAAqB,GAAG;IAClC,UAAU,EAAE,MAAM,CAAA;IAClB,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,gFAAgF;IAChF,WAAW,CAAC,EAAE,cAAc,CAAA;IAC5B;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,2DAA2D;IAC3D,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,yDAAyD;IACzD,KAAK,CAAC,EAAE,gBAAgB,CAAA;CACzB,CAAA;AAED,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,MAAM,CAAA;IACd,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,MAAM,MAAM,sBAAsB,GAAG;IACnC,OAAO,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAA;IACzC,aAAa,EAAE,CAAC,GAAG,EAAE,mBAAmB,KAAK,IAAI,CAAA;CAClD,CAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AcsMeeting.types.js","sourceRoot":"","sources":["../src/AcsMeeting.types.ts"],"names":[],"mappings":"","sourcesContent":["export type MeetingPhase = \"idle\" | \"connecting\" | \"lobby\" | \"connected\" | \"disconnected\" | \"error\"\n\nexport type AcsAudioSource = \"glasses\" | \"phone\"\nexport type AcsActiveStream = \"none\" | \"virtual\" | \"local\"\nexport type AcsAudioSafety = \"safe\" | \"degraded\" | \"unsafe\"\n/**\n * Health of the glasses WHEP subscription feeding the call. `failed` = ICE dropped or\n * the WHEP endpoint went away while the ACS call may still be `connected`. Native\n * rebuilds it on its own with backoff; the host can force one via `restartVideoSource`.\n */\nexport type AcsMediaSourceState = \"idle\" | \"connecting\" | \"live\" | \"failed\"\n\nexport type AcsMeetingParticipantState = \"idle\" | \"connecting\" | \"connected\" | \"lobby\" | \"hold\" | \"disconnected\"\n\nexport type AcsMeetingParticipant = {\n id: string\n displayName: string | null\n state: AcsMeetingParticipantState\n isMuted: boolean\n isSpeaking: boolean\n}\n\nexport type AcsMeetingState = {\n state: MeetingPhase\n muted: boolean\n error?: string\n meetingUrl?: string\n provider?: \"acs-teams\"\n audioSource?: AcsAudioSource\n activeStream?: AcsActiveStream\n audioSafety?: AcsAudioSafety\n mediaSource?: AcsMediaSourceState\n /** Remote roster (Android emits this; iOS does not yet). */\n participants?: AcsMeetingParticipant[]\n}\n\nexport type AcsOutgoingVideo = {\n width: number\n height: number\n fps: number\n maxBitrateBps: number\n}\n\nexport type AcsMeetingJoinOptions = {\n meetingUrl: string\n token: string\n whepUrl: string\n displayName?: string\n /** \"glasses\" sends WHEP PCM. \"phone\" uses the ACS local mic (handset or BT). */\n audioSource?: AcsAudioSource\n /** Dump WHEP PCM to a WAV in cache for P4 verification. */\n dumpPcmWav?: boolean\n /** When omitted, native keeps 1280×720@15 / 2.5 Mbps. */\n video?: AcsOutgoingVideo\n}\n\nexport type AcsIncomingPcmEvent = {\n base64: string\n sampleRate: number\n channels: number\n}\n\nexport type AcsMeetingModuleEvents = {\n onState: (state: AcsMeetingState) => void\n onIncomingPcm: (pcm: AcsIncomingPcmEvent) => void\n}\n"]}
|
|
1
|
+
{"version":3,"file":"AcsMeeting.types.js","sourceRoot":"","sources":["../src/AcsMeeting.types.ts"],"names":[],"mappings":"","sourcesContent":["export type MeetingPhase = \"idle\" | \"connecting\" | \"lobby\" | \"connected\" | \"disconnected\" | \"error\"\n\nexport type AcsAudioSource = \"glasses\" | \"phone\"\nexport type AcsActiveStream = \"none\" | \"virtual\" | \"local\"\nexport type AcsAudioSafety = \"safe\" | \"degraded\" | \"unsafe\"\n/**\n * Health of the glasses WHEP subscription feeding the call. `failed` = ICE dropped or\n * the WHEP endpoint went away while the ACS call may still be `connected`. Native\n * rebuilds it on its own with backoff; the host can force one via `restartVideoSource`.\n */\nexport type AcsMediaSourceState = \"idle\" | \"connecting\" | \"live\" | \"failed\"\n\nexport type AcsMeetingParticipantState = \"idle\" | \"connecting\" | \"connected\" | \"lobby\" | \"hold\" | \"disconnected\"\n\nexport type AcsMeetingParticipant = {\n id: string\n displayName: string | null\n state: AcsMeetingParticipantState\n isMuted: boolean\n isSpeaking: boolean\n}\n\nexport type AcsMeetingState = {\n state: MeetingPhase\n muted: boolean\n error?: string\n meetingUrl?: string\n provider?: \"acs-teams\"\n audioSource?: AcsAudioSource\n activeStream?: AcsActiveStream\n audioSafety?: AcsAudioSafety\n mediaSource?: AcsMediaSourceState\n /** Remote roster (Android emits this; iOS does not yet). */\n participants?: AcsMeetingParticipant[]\n}\n\nexport type AcsOutgoingVideo = {\n width: number\n height: number\n fps: number\n maxBitrateBps: number\n}\n\nexport type AcsMeetingJoinOptions = {\n meetingUrl: string\n token: string\n whepUrl: string\n displayName?: string\n /** \"glasses\" sends WHEP PCM. \"phone\" uses the ACS local mic (handset or BT). */\n audioSource?: AcsAudioSource\n /**\n * Hold outgoing PCM this many ms before ACS. SoftAP+LC3 sets this in the host\n * because BLE audio reaches the phone before SoftAP video.\n */\n audioDelayMs?: number\n /** Dump WHEP PCM to a WAV in cache for P4 verification. */\n dumpPcmWav?: boolean\n /** When omitted, native keeps 1280×720@15 / 2.5 Mbps. */\n video?: AcsOutgoingVideo\n}\n\nexport type AcsIncomingPcmEvent = {\n base64: string\n sampleRate: number\n channels: number\n}\n\nexport type AcsMeetingModuleEvents = {\n onState: (state: AcsMeetingState) => void\n onIncomingPcm: (pcm: AcsIncomingPcmEvent) => void\n}\n"]}
|