@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,250 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.audio
|
|
2
|
+
|
|
3
|
+
import java.util.concurrent.CountDownLatch
|
|
4
|
+
import java.util.concurrent.TimeUnit
|
|
5
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
6
|
+
import org.junit.Test
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The mute contract, stage by stage.
|
|
10
|
+
*
|
|
11
|
+
* These are not tests that mute sets a flag. They populate every stage that can hold the wearer's
|
|
12
|
+
* voice and then assert that nothing which was in them ever reaches the pacer — which is the only
|
|
13
|
+
* form of the guarantee that means anything to someone who pressed Mute mid-sentence.
|
|
14
|
+
*/
|
|
15
|
+
class AudioUplinkChainTest {
|
|
16
|
+
private val rate = 16_000
|
|
17
|
+
private val nativeRate = UplinkPacer.RATE
|
|
18
|
+
private val bytesPerMs = rate * 2 / 1000
|
|
19
|
+
private val nativeBytesPerMs = UplinkPacer.BYTES_PER_MS
|
|
20
|
+
|
|
21
|
+
/** 16 kHz mono PCM16 at a constant non-zero level, so silence is distinguishable from voice. */
|
|
22
|
+
private fun voice(ms: Int): ByteArray = ByteArray(ms * bytesPerMs) { 0x20 }
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The same at ACS's own 48 kHz mono, which the bridge passes through untouched.
|
|
26
|
+
*
|
|
27
|
+
* The stage tests below assert on exact frame boundaries, and the 16 kHz interpolator cannot give
|
|
28
|
+
* them: it drops its last partial sample pair per call, so a 20 ms buffer resamples to slightly
|
|
29
|
+
* under a 20 ms frame. Feeding the native rate keeps the byte arithmetic exact so an assertion
|
|
30
|
+
* that fails means a stage held audio, not that the resampler rounded.
|
|
31
|
+
*/
|
|
32
|
+
private fun nativeVoice(ms: Int): ByteArray = ByteArray(ms * nativeBytesPerMs) { 0x20 }
|
|
33
|
+
|
|
34
|
+
private fun bridge() = PcmBridge(null, false)
|
|
35
|
+
|
|
36
|
+
private class FakeClock(var nanos: Long = 0L) : () -> Long {
|
|
37
|
+
override fun invoke(): Long = nanos
|
|
38
|
+
|
|
39
|
+
fun advanceMs(ms: Long) {
|
|
40
|
+
nanos += ms * 1_000_000L
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@Test
|
|
45
|
+
fun resamplesSixteenKilohertzMonoPreservingDuration() {
|
|
46
|
+
val pacer = UplinkPacer()
|
|
47
|
+
val chain = AudioUplinkChain(bridge(), pacer)
|
|
48
|
+
|
|
49
|
+
// A second of 16 kHz input is a second of 48 kHz output. The interpolator's tail leaves at most
|
|
50
|
+
// one partial 20 ms frame in the bridge, so the pacer holds a whole second bar that frame.
|
|
51
|
+
repeat(100) { chain.ingest(voice(10), rate, 1) }
|
|
52
|
+
|
|
53
|
+
assertThat(pacer.depthMs()).isBetween(980, 1_000)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@Test
|
|
57
|
+
fun mutedIngestNeverEntersAnyStage() {
|
|
58
|
+
val pacer = UplinkPacer()
|
|
59
|
+
val chain = AudioUplinkChain(bridge(), pacer)
|
|
60
|
+
|
|
61
|
+
chain.mute()
|
|
62
|
+
repeat(100) { chain.ingest(voice(10), rate, 1) }
|
|
63
|
+
|
|
64
|
+
assertThat(pacer.depthMs()).isEqualTo(0)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The failure this whole class exists for. Gating new input is not mute: at the moment the button
|
|
69
|
+
* is pressed the delay ring holds up to its full window, the resampler holds a partial 20 ms
|
|
70
|
+
* frame, and the pacer holds its target headroom. All three keep draining unless mute clears them.
|
|
71
|
+
*/
|
|
72
|
+
@Test
|
|
73
|
+
fun muteClearsTheDelayRingTheBridgeRemainderAndThePacer() {
|
|
74
|
+
val clock = FakeClock()
|
|
75
|
+
val pacer = UplinkPacer()
|
|
76
|
+
val chain = AudioUplinkChain(bridge(), pacer, delayMs = 200, clock = clock)
|
|
77
|
+
|
|
78
|
+
// 400 ms of input against a 200 ms hold: 200 ms has reached the pacer, 200 ms is still held.
|
|
79
|
+
repeat(40) {
|
|
80
|
+
chain.ingest(nativeVoice(10), nativeRate, 1)
|
|
81
|
+
clock.advanceMs(10)
|
|
82
|
+
}
|
|
83
|
+
// One more releases a 21st 10 ms chunk, so the bridge is left holding half of a 20 ms frame.
|
|
84
|
+
chain.ingest(nativeVoice(10), nativeRate, 1)
|
|
85
|
+
assertThat(pacer.depthMs()).isGreaterThan(UplinkPacer.TARGET_MS)
|
|
86
|
+
assertThat(chain.delayedBytes()).isGreaterThan(0)
|
|
87
|
+
|
|
88
|
+
chain.mute()
|
|
89
|
+
|
|
90
|
+
assertThat(pacer.depthMs()).isEqualTo(0)
|
|
91
|
+
assertThat(chain.delayedBytes()).isEqualTo(0)
|
|
92
|
+
|
|
93
|
+
// And the bridge remainder is gone too. After unmute, the first 10 ms to clear the delay ring
|
|
94
|
+
// must not complete a 20 ms frame — it could only do so by pairing with the half-frame the
|
|
95
|
+
// wearer spoke before pressing Mute. It takes two fresh chunks to make the first whole frame.
|
|
96
|
+
chain.unmute()
|
|
97
|
+
chain.ingest(nativeVoice(10), nativeRate, 1)
|
|
98
|
+
clock.advanceMs(200)
|
|
99
|
+
chain.ingest(nativeVoice(10), nativeRate, 1)
|
|
100
|
+
assertThat(pacer.depthMs()).isEqualTo(0)
|
|
101
|
+
clock.advanceMs(200)
|
|
102
|
+
chain.ingest(nativeVoice(10), nativeRate, 1)
|
|
103
|
+
assertThat(pacer.depthMs()).isEqualTo(20)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@Test
|
|
107
|
+
fun unmuteAcceptsAudioAgain() {
|
|
108
|
+
val pacer = UplinkPacer()
|
|
109
|
+
val chain = AudioUplinkChain(bridge(), pacer)
|
|
110
|
+
|
|
111
|
+
chain.mute()
|
|
112
|
+
chain.ingest(nativeVoice(20), nativeRate, 1)
|
|
113
|
+
chain.unmute()
|
|
114
|
+
chain.ingest(nativeVoice(20), nativeRate, 1)
|
|
115
|
+
|
|
116
|
+
assertThat(chain.isMuted()).isFalse()
|
|
117
|
+
// Exactly one frame: the buffer ingested while muted was dropped, not deferred.
|
|
118
|
+
assertThat(pacer.depthMs()).isEqualTo(20)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@Test
|
|
122
|
+
fun resetDropsBufferedAudioWithoutMuting() {
|
|
123
|
+
val pacer = UplinkPacer()
|
|
124
|
+
val chain = AudioUplinkChain(bridge(), pacer)
|
|
125
|
+
chain.ingest(nativeVoice(100), nativeRate, 1)
|
|
126
|
+
|
|
127
|
+
chain.reset()
|
|
128
|
+
|
|
129
|
+
assertThat(pacer.depthMs()).isEqualTo(0)
|
|
130
|
+
assertThat(chain.isMuted()).isFalse()
|
|
131
|
+
chain.ingest(nativeVoice(20), nativeRate, 1)
|
|
132
|
+
assertThat(pacer.depthMs()).isEqualTo(20)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `ingest` runs on the BLE/JS thread and `mute` on the caller's. Without one lock across both,
|
|
137
|
+
* mute can clear the pacer while an ingest is midway through pushing into it, and the frames that
|
|
138
|
+
* land after the clear are voice from before the mute.
|
|
139
|
+
*/
|
|
140
|
+
@Test
|
|
141
|
+
fun aMuteRacingIngestNeverLeavesVoiceBehind() {
|
|
142
|
+
repeat(200) {
|
|
143
|
+
val pacer = UplinkPacer()
|
|
144
|
+
val chain = AudioUplinkChain(bridge(), pacer)
|
|
145
|
+
val ready = CountDownLatch(2)
|
|
146
|
+
val go = CountDownLatch(1)
|
|
147
|
+
|
|
148
|
+
val producer = Thread {
|
|
149
|
+
ready.countDown()
|
|
150
|
+
go.await()
|
|
151
|
+
repeat(20) { chain.ingest(voice(20), rate, 1) }
|
|
152
|
+
}
|
|
153
|
+
val muter = Thread {
|
|
154
|
+
ready.countDown()
|
|
155
|
+
go.await()
|
|
156
|
+
chain.mute()
|
|
157
|
+
}
|
|
158
|
+
producer.start()
|
|
159
|
+
muter.start()
|
|
160
|
+
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue()
|
|
161
|
+
go.countDown()
|
|
162
|
+
producer.join(5_000)
|
|
163
|
+
muter.join(5_000)
|
|
164
|
+
|
|
165
|
+
assertThat(chain.isMuted()).isTrue()
|
|
166
|
+
// Whatever interleaving happened, the last thing to run under the lock was either the mute
|
|
167
|
+
// (which clears) or an ingest that saw `muted` and dropped. Neither leaves audio buffered.
|
|
168
|
+
assertThat(pacer.depthMs()).isEqualTo(0)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
@Test
|
|
173
|
+
fun aZeroDelayIsPassthrough() {
|
|
174
|
+
val clock = FakeClock()
|
|
175
|
+
val pacer = UplinkPacer()
|
|
176
|
+
val chain = AudioUplinkChain(bridge(), pacer, delayMs = 0, clock = clock)
|
|
177
|
+
|
|
178
|
+
chain.ingest(nativeVoice(20), nativeRate, 1)
|
|
179
|
+
|
|
180
|
+
assertThat(chain.delayedBytes()).isEqualTo(0)
|
|
181
|
+
assertThat(pacer.depthMs()).isEqualTo(20)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
@Test
|
|
185
|
+
fun aConfiguredDelayHoldsInputUntilItIsOldEnough() {
|
|
186
|
+
val clock = FakeClock()
|
|
187
|
+
val pacer = UplinkPacer()
|
|
188
|
+
val chain = AudioUplinkChain(bridge(), pacer, delayMs = 120, clock = clock)
|
|
189
|
+
|
|
190
|
+
// 100 ms of input, none of it yet 120 ms old.
|
|
191
|
+
repeat(10) {
|
|
192
|
+
chain.ingest(nativeVoice(10), nativeRate, 1)
|
|
193
|
+
clock.advanceMs(10)
|
|
194
|
+
}
|
|
195
|
+
assertThat(pacer.depthMs()).isEqualTo(0)
|
|
196
|
+
assertThat(chain.delayedBytes()).isEqualTo(100 * nativeBytesPerMs)
|
|
197
|
+
|
|
198
|
+
// Once the two oldest chunks are 120 ms old they come out together, in arrival order.
|
|
199
|
+
clock.advanceMs(30)
|
|
200
|
+
chain.ingest(nativeVoice(10), nativeRate, 1)
|
|
201
|
+
assertThat(pacer.depthMs()).isEqualTo(20)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
@Test
|
|
205
|
+
fun aDelayBeyondTheCeilingIsClamped() {
|
|
206
|
+
val clock = FakeClock()
|
|
207
|
+
val pacer = UplinkPacer()
|
|
208
|
+
val chain = AudioUplinkChain(bridge(), pacer, delayMs = 5_000, clock = clock)
|
|
209
|
+
|
|
210
|
+
repeat(60) {
|
|
211
|
+
chain.ingest(voice(10), rate, 1)
|
|
212
|
+
clock.advanceMs(10)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Clamped to 500 ms, so by 600 ms of input the first 100 ms have already been released.
|
|
216
|
+
assertThat(pacer.depthMs()).isGreaterThan(0)
|
|
217
|
+
assertThat(chain.delayedBytes()).isLessThanOrEqualTo(AudioUplinkChain.MAX_DELAY_MS * bytesPerMs)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** A clock that does not advance must not turn a bounded hold into an unbounded one. */
|
|
221
|
+
@Test
|
|
222
|
+
fun aStoppedClockDoesNotGrowTheDelayRingWithoutBound() {
|
|
223
|
+
val pacer = UplinkPacer()
|
|
224
|
+
val chain = AudioUplinkChain(bridge(), pacer, delayMs = 100, clock = { 0L })
|
|
225
|
+
|
|
226
|
+
repeat(500) { chain.ingest(voice(10), rate, 1) }
|
|
227
|
+
|
|
228
|
+
assertThat(chain.delayedBytes()).isLessThanOrEqualTo(100 * AudioUplinkChain.MAX_BYTES_PER_MS)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
@Test
|
|
232
|
+
fun ingestNotifiesBeforeTheDelayLineHolds() {
|
|
233
|
+
val clock = FakeClock(nanos = 40_000_000L)
|
|
234
|
+
val pacer = UplinkPacer()
|
|
235
|
+
val seen = mutableListOf<Pair<Int, Long>>()
|
|
236
|
+
val input = nativeVoice(20)
|
|
237
|
+
val chain = AudioUplinkChain(
|
|
238
|
+
bridge(),
|
|
239
|
+
pacer,
|
|
240
|
+
delayMs = 120,
|
|
241
|
+
clock = clock,
|
|
242
|
+
onIngest = { pcm, nowNs -> seen.add(pcm.size to nowNs) },
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
chain.ingest(input, nativeRate, 1)
|
|
246
|
+
|
|
247
|
+
assertThat(pacer.depthMs()).isEqualTo(0)
|
|
248
|
+
assertThat(seen).containsExactly(input.size to 40_000_000L)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -10,3 +10,47 @@ class CapturePolicyTest {
|
|
|
10
10
|
assertThat(CapturePolicy.captureGlassesMic(AudioSourceKind.PHONE)).isFalse()
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
+
|
|
14
|
+
class GlassesPcmRoutingTest {
|
|
15
|
+
/**
|
|
16
|
+
* The double-audio case. Both sources carry the same room; enabling both is heard as an echo at
|
|
17
|
+
* the far end that no participant can mute, because neither copy is anyone's microphone.
|
|
18
|
+
*/
|
|
19
|
+
@Test
|
|
20
|
+
fun exactlyOneSourceOfTheWearersVoiceIsEverOn() {
|
|
21
|
+
for (softap in listOf(true, false)) {
|
|
22
|
+
for (enabled in listOf(true, false)) {
|
|
23
|
+
val routing = GlassesPcmRouting.decide(softap, enabled)
|
|
24
|
+
assertThat(routing.relayPcm && routing.externalPcm).isFalse()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* SoftAP publishes video only, so the wearer's voice can come from exactly one place: the BLE
|
|
31
|
+
* LC3 PCM the host pushes. Taking the relay here would be taking a track that carries no audio.
|
|
32
|
+
*/
|
|
33
|
+
@Test
|
|
34
|
+
fun aSoftApCallTakesTheWearerFromTheHostsBleLc3Pcm() {
|
|
35
|
+
assertThat(GlassesPcmRouting.decide(softap = true, enabled = true))
|
|
36
|
+
.isEqualTo(GlassesPcmRouting(relayPcm = false, externalPcm = true))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@Test
|
|
40
|
+
fun aWhepCallKeepsTakingTheWearerFromTheRelayTrack() {
|
|
41
|
+
assertThat(GlassesPcmRouting.decide(softap = false, enabled = true))
|
|
42
|
+
.isEqualTo(GlassesPcmRouting(relayPcm = true, externalPcm = false))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Disabling has to close *both* gates whichever transport this call uses. A push that survives a
|
|
47
|
+
* mute or a source switch is the wearer heard when they asked not to be.
|
|
48
|
+
*/
|
|
49
|
+
@Test
|
|
50
|
+
fun disablingClosesBothGatesOnEitherTransport() {
|
|
51
|
+
assertThat(GlassesPcmRouting.decide(softap = true, enabled = false))
|
|
52
|
+
.isEqualTo(GlassesPcmRouting(relayPcm = false, externalPcm = false))
|
|
53
|
+
assertThat(GlassesPcmRouting.decide(softap = false, enabled = false))
|
|
54
|
+
.isEqualTo(GlassesPcmRouting(relayPcm = false, externalPcm = false))
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -4,6 +4,21 @@ import org.assertj.core.api.Assertions.assertThat
|
|
|
4
4
|
import org.junit.Test
|
|
5
5
|
|
|
6
6
|
class UplinkPacerTest {
|
|
7
|
+
@Test
|
|
8
|
+
fun lc3HeadroomAbsorbsRepeated140msDeliveryBursts() {
|
|
9
|
+
val pacer = UplinkPacer()
|
|
10
|
+
pacer.configureTarget(UplinkPacer.LC3_TARGET_MS)
|
|
11
|
+
pacer.push(tone(UplinkPacer.LC3_TARGET_MS))
|
|
12
|
+
repeat(1500) { tick ->
|
|
13
|
+
if (tick > 0 && tick % 7 == 0) pacer.push(tone(140))
|
|
14
|
+
assertThat(pacer.tick(tick * 20_000_000L).silence)
|
|
15
|
+
.describedAs("tick %d of bursty LC3", tick).isFalse()
|
|
16
|
+
}
|
|
17
|
+
assertThat(pacer.snapshot(30_000_000_000L).overflowDroppedMs).isZero()
|
|
18
|
+
pacer.configureTarget(UplinkPacer.TARGET_MS)
|
|
19
|
+
assertThat(pacer.depthMs()).isZero()
|
|
20
|
+
assertThat(pacer.state()).isEqualTo(UplinkPacer.State.PREROLLING)
|
|
21
|
+
}
|
|
7
22
|
private val frameBytes = UplinkPacer.FRAME_BYTES
|
|
8
23
|
private val tickNanos = UplinkPacer.FRAME_MS * 1_000_000L
|
|
9
24
|
|
|
@@ -107,7 +122,7 @@ class UplinkPacerTest {
|
|
|
107
122
|
}
|
|
108
123
|
|
|
109
124
|
@Test
|
|
110
|
-
fun
|
|
125
|
+
fun depthAboveEmergencyCapShearsOnlyThePeak() {
|
|
111
126
|
val pacer = UplinkPacer()
|
|
112
127
|
var now = 0L
|
|
113
128
|
pacer.push(tone(UplinkPacer.TARGET_MS))
|
|
@@ -116,10 +131,25 @@ class UplinkPacerTest {
|
|
|
116
131
|
|
|
117
132
|
pacer.push(tone(500))
|
|
118
133
|
assertThat(pacer.tick(now).silence).isFalse()
|
|
119
|
-
|
|
134
|
+
// Shears to the cap, not back to the 60 ms target — that hole is what the wearer heard.
|
|
135
|
+
assertThat(pacer.controlDepthMs()).isBetween(UplinkPacer.EMERGENCY_CAP_MS - 20, UplinkPacer.EMERGENCY_CAP_MS)
|
|
136
|
+
assertThat(pacer.controlDepthMs()).isGreaterThan(UplinkPacer.TARGET_MS)
|
|
120
137
|
val stats = pacer.snapshot(now)
|
|
121
|
-
assertThat(stats.overflowDroppedMs).
|
|
122
|
-
|
|
138
|
+
assertThat(stats.overflowDroppedMs).isBetween(130L, 160L)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@Test
|
|
142
|
+
fun aDelaySizedBurstStaysInTheRing() {
|
|
143
|
+
val pacer = UplinkPacer()
|
|
144
|
+
var now = 0L
|
|
145
|
+
pacer.push(tone(UplinkPacer.TARGET_MS))
|
|
146
|
+
pacer.tick(now)
|
|
147
|
+
now += tickNanos
|
|
148
|
+
|
|
149
|
+
pacer.push(tone(170))
|
|
150
|
+
assertThat(pacer.tick(now).silence).isFalse()
|
|
151
|
+
assertThat(pacer.controlDepthMs()).isGreaterThan(150)
|
|
152
|
+
assertThat(pacer.snapshot(now).overflowDroppedMs).isEqualTo(0L)
|
|
123
153
|
}
|
|
124
154
|
|
|
125
155
|
@Test
|
|
@@ -145,6 +145,102 @@ class UplinkSenderTest {
|
|
|
145
145
|
.contains("skippedTicks=0")
|
|
146
146
|
.contains("sendFailures=0")
|
|
147
147
|
.contains("backpressureDrops=0")
|
|
148
|
+
.contains("pcmMeanAbs=-1")
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The gate exists for the gap between `pacer.tick` and `transport.send`. A mute that lands in it
|
|
153
|
+
* would otherwise put the wearer's last word on the call after they asked not to be heard, and no
|
|
154
|
+
* amount of clearing upstream buffers can catch a frame that has already been popped.
|
|
155
|
+
*/
|
|
156
|
+
@Test
|
|
157
|
+
fun aMuteBetweenTickAndSendIsStillHonoured() {
|
|
158
|
+
val pacer = UplinkPacer()
|
|
159
|
+
val transport = FakeTransport()
|
|
160
|
+
var muted = false
|
|
161
|
+
val sender = UplinkSender(pacer, transport, clock = { 0L }, muted = { muted }, log = {})
|
|
162
|
+
pacer.push(tone(UplinkPacer.TARGET_MS * 2))
|
|
163
|
+
|
|
164
|
+
sender.pumpOnce(0)
|
|
165
|
+
assertThat(transport.sent.last().any { it != 0.toByte() }).isTrue()
|
|
166
|
+
|
|
167
|
+
muted = true
|
|
168
|
+
sender.pumpOnce(UplinkSender.PERIOD_NANOS)
|
|
169
|
+
|
|
170
|
+
// Silence, not a gap: the cadence ACS reads as a healthy stream has to continue.
|
|
171
|
+
assertThat(transport.sent).hasSize(2)
|
|
172
|
+
assertThat(transport.sent.last()).hasSize(UplinkPacer.FRAME_BYTES)
|
|
173
|
+
assertThat(transport.sent.last().all { it == 0.toByte() }).isTrue()
|
|
174
|
+
assertThat(sender.stats().mutedFrames).isEqualTo(1L)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Silence the pacer produced itself is not the mute gate's work, and counting it as such would
|
|
179
|
+
* make `mutedFrames` read as suppressed speech during every preroll.
|
|
180
|
+
*/
|
|
181
|
+
@Test
|
|
182
|
+
fun pacerSilenceIsNotCountedAsMutedVoice() {
|
|
183
|
+
val pacer = UplinkPacer()
|
|
184
|
+
val sender = UplinkSender(pacer, FakeTransport(), clock = { 0L }, muted = { true }, log = {})
|
|
185
|
+
|
|
186
|
+
// Nothing pushed, so the pacer is still prerolling and emits its own silence.
|
|
187
|
+
sender.pumpOnce(0)
|
|
188
|
+
|
|
189
|
+
assertThat(sender.stats().mutedFrames).isEqualTo(0L)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Mute is instantaneous at the gate and is not instantaneous at the far end: `sendRawAudioBuffer`
|
|
194
|
+
* cannot be recalled, so whatever ACS already accepted still plays out. That drain is the tail the
|
|
195
|
+
* wearer perceives, so it is measured rather than assumed.
|
|
196
|
+
*/
|
|
197
|
+
@Test
|
|
198
|
+
fun muteTailIsMeasuredFromTheDrainOfAlreadySubmittedBuffers() {
|
|
199
|
+
val pacer = UplinkPacer()
|
|
200
|
+
val transport = FakeTransport().apply { autoComplete = false }
|
|
201
|
+
var muted = false
|
|
202
|
+
val logs = mutableListOf<String>()
|
|
203
|
+
val sender = UplinkSender(pacer, transport, clock = { 0L }, muted = { muted }, log = { logs.add(it) })
|
|
204
|
+
pacer.push(tone(UplinkPacer.TARGET_MS))
|
|
205
|
+
|
|
206
|
+
// Five frames ACS has accepted and not completed.
|
|
207
|
+
pump(sender, pacer, 5)
|
|
208
|
+
assertThat(sender.stats().inFlight).isEqualTo(5)
|
|
209
|
+
assertThat(sender.stats().muteTailMs).isNull()
|
|
210
|
+
|
|
211
|
+
muted = true
|
|
212
|
+
sender.pumpOnce(5 * UplinkSender.PERIOD_NANOS)
|
|
213
|
+
// Still in flight, so the tail is not over and must not be reported as zero.
|
|
214
|
+
assertThat(sender.stats().muteTailMs).isNull()
|
|
215
|
+
|
|
216
|
+
transport.completeAll()
|
|
217
|
+
sender.pumpOnce(8 * UplinkSender.PERIOD_NANOS)
|
|
218
|
+
|
|
219
|
+
assertThat(sender.stats().muteTailMs).isEqualTo(60L)
|
|
220
|
+
assertThat(sender.stats().muteTailMs!!).isLessThanOrEqualTo(UplinkSender.MAX_IN_FLIGHT * UplinkPacer.FRAME_MS.toLong())
|
|
221
|
+
assertThat(logs).anySatisfy { assertThat(it).contains("muteTailMs=60") }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
@Test
|
|
225
|
+
fun unmutingArmsTheNextMuteTailMeasurement() {
|
|
226
|
+
val pacer = UplinkPacer()
|
|
227
|
+
val transport = FakeTransport()
|
|
228
|
+
var muted = false
|
|
229
|
+
val sender = UplinkSender(pacer, transport, clock = { 0L }, muted = { muted }, log = {})
|
|
230
|
+
pacer.push(tone(UplinkPacer.TARGET_MS))
|
|
231
|
+
|
|
232
|
+
muted = true
|
|
233
|
+
pump(sender, pacer, 2)
|
|
234
|
+
assertThat(sender.stats().muteTailMs).isEqualTo(20L)
|
|
235
|
+
|
|
236
|
+
muted = false
|
|
237
|
+
pump(sender, pacer, 2, from = 2)
|
|
238
|
+
muted = true
|
|
239
|
+
sender.pumpOnce(4 * UplinkSender.PERIOD_NANOS)
|
|
240
|
+
sender.pumpOnce(9 * UplinkSender.PERIOD_NANOS)
|
|
241
|
+
|
|
242
|
+
// Measured from the second mute, not carried over from the first.
|
|
243
|
+
assertThat(sender.stats().muteTailMs).isEqualTo(100L)
|
|
148
244
|
}
|
|
149
245
|
|
|
150
246
|
@Test
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.network
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.net.ConnectivityManager
|
|
5
|
+
import android.net.Network
|
|
6
|
+
import android.net.NetworkCapabilities
|
|
7
|
+
import android.net.NetworkRequest
|
|
8
|
+
import java.util.concurrent.CountDownLatch
|
|
9
|
+
import java.util.concurrent.TimeUnit
|
|
10
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
11
|
+
import org.junit.Assert.*
|
|
12
|
+
import org.junit.Test
|
|
13
|
+
import org.mockito.ArgumentMatchers.*
|
|
14
|
+
import org.mockito.Mockito.*
|
|
15
|
+
|
|
16
|
+
/** Real request/close lifecycle with the Android service mocked; no radio or network required. */
|
|
17
|
+
class InternetHoldLifecycleTest {
|
|
18
|
+
private class Fixture {
|
|
19
|
+
val context = mock(Context::class.java)
|
|
20
|
+
val manager = mock(ConnectivityManager::class.java)
|
|
21
|
+
val network = mock(Network::class.java)
|
|
22
|
+
val capabilities = mock(NetworkCapabilities::class.java)
|
|
23
|
+
val requested = CountDownLatch(1)
|
|
24
|
+
lateinit var callback: ConnectivityManager.NetworkCallback
|
|
25
|
+
val hold: InternetHold
|
|
26
|
+
|
|
27
|
+
init {
|
|
28
|
+
`when`(context.applicationContext).thenReturn(context)
|
|
29
|
+
`when`(context.getSystemService(ConnectivityManager::class.java)).thenReturn(manager)
|
|
30
|
+
`when`(manager.allNetworks).thenReturn(arrayOf(network))
|
|
31
|
+
`when`(manager.getNetworkCapabilities(network)).thenReturn(capabilities)
|
|
32
|
+
`when`(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)).thenReturn(true)
|
|
33
|
+
`when`(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)).thenReturn(true)
|
|
34
|
+
`when`(capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)).thenReturn(true)
|
|
35
|
+
`when`(manager.bindProcessToNetwork(network)).thenReturn(true)
|
|
36
|
+
doAnswer {
|
|
37
|
+
callback = it.getArgument(1)
|
|
38
|
+
requested.countDown()
|
|
39
|
+
null
|
|
40
|
+
}.`when`(manager).requestNetwork(nullable(NetworkRequest::class.java), any(ConnectivityManager.NetworkCallback::class.java))
|
|
41
|
+
hold = InternetHold(context)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The plain JVM android.jar has no implementation of Builder's fluent methods.
|
|
45
|
+
// Scope this stub to the caller thread, including the validation-wait worker test.
|
|
46
|
+
fun awaitCellular(timeoutMs: Long): InternetHold.CellularHold =
|
|
47
|
+
mockConstruction(NetworkRequest.Builder::class.java, withSettings().defaultAnswer(RETURNS_SELF)) { builder, _ ->
|
|
48
|
+
`when`(builder.build()).thenReturn(mock(NetworkRequest::class.java))
|
|
49
|
+
}.use { hold.awaitValidatedCellular(timeoutMs) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@Test
|
|
53
|
+
fun `destroy with no default network drops pin and invalidates old callbacks`() {
|
|
54
|
+
val f = Fixture()
|
|
55
|
+
assertTrue(f.awaitCellular(0).validated)
|
|
56
|
+
assertFalse(f.hold.defaultNetwork().usable)
|
|
57
|
+
clearInvocations(f.manager)
|
|
58
|
+
f.hold.close()
|
|
59
|
+
verify(f.manager).unregisterNetworkCallback(f.callback)
|
|
60
|
+
verify(f.manager).bindProcessToNetwork(null)
|
|
61
|
+
clearInvocations(f.manager)
|
|
62
|
+
f.callback.onCapabilitiesChanged(mock(Network::class.java), f.capabilities)
|
|
63
|
+
f.callback.onLost(f.network)
|
|
64
|
+
f.hold.close()
|
|
65
|
+
assertFalse(f.hold.bindProcessToCellular())
|
|
66
|
+
assertFalse(f.awaitCellular(0).held)
|
|
67
|
+
verify(f.manager, never()).bindProcessToNetwork(nullable(Network::class.java))
|
|
68
|
+
verify(f.manager, never()).requestNetwork(nullable(NetworkRequest::class.java), any(ConnectivityManager.NetworkCallback::class.java))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@Test
|
|
72
|
+
fun `late validation cannot resurrect a hold destroyed during its wait`() {
|
|
73
|
+
val f = Fixture()
|
|
74
|
+
val result = AtomicReference<InternetHold.CellularHold>()
|
|
75
|
+
val worker = Thread { result.set(f.awaitCellular(2_000)) }
|
|
76
|
+
worker.start()
|
|
77
|
+
try {
|
|
78
|
+
assertTrue(f.requested.await(1, TimeUnit.SECONDS))
|
|
79
|
+
f.hold.close()
|
|
80
|
+
clearInvocations(f.manager)
|
|
81
|
+
f.callback.onCapabilitiesChanged(f.network, f.capabilities)
|
|
82
|
+
worker.join(1_000)
|
|
83
|
+
assertFalse(worker.isAlive)
|
|
84
|
+
assertFalse(result.get().held)
|
|
85
|
+
assertFalse(result.get().validated)
|
|
86
|
+
verify(f.manager, never()).bindProcessToNetwork(nullable(Network::class.java))
|
|
87
|
+
} finally {
|
|
88
|
+
worker.interrupt()
|
|
89
|
+
worker.join(2_000)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@Test
|
|
94
|
+
fun `ordinary release still permits reuse while close is permanent`() {
|
|
95
|
+
val f = Fixture()
|
|
96
|
+
assertTrue(f.awaitCellular(0).held)
|
|
97
|
+
f.hold.release()
|
|
98
|
+
assertTrue(f.awaitCellular(0).validated)
|
|
99
|
+
f.hold.close()
|
|
100
|
+
assertFalse(f.awaitCellular(0).held)
|
|
101
|
+
}
|
|
102
|
+
}
|