@mentra/acs-meeting 3.2.0-dev.221 → 3.2.0-dev.222

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.
Files changed (100) hide show
  1. package/README.md +4 -2
  2. package/android/build.gradle +23 -0
  3. package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +291 -0
  4. package/android/src/main/AndroidManifest.xml +2 -0
  5. package/android/src/main/java/com/mentra/acsmeeting/AbandonedCallAgent.kt +36 -0
  6. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +392 -22
  7. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +909 -56
  8. package/android/src/main/java/com/mentra/acsmeeting/CallCapabilities.kt +33 -0
  9. package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +21 -0
  10. package/android/src/main/java/com/mentra/acsmeeting/audio/AudioUplinkChain.kt +126 -0
  11. package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +27 -4
  12. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +45 -10
  13. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +110 -5
  14. package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +501 -0
  15. package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +51 -0
  16. package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +81 -0
  17. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +355 -0
  18. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +109 -0
  19. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +160 -0
  20. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +103 -0
  21. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +53 -0
  22. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +127 -0
  23. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +573 -0
  24. package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +10 -101
  25. package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +147 -0
  26. package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +47 -5
  27. package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +772 -0
  28. package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +73 -0
  29. package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +17 -0
  30. package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +139 -0
  31. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +144 -0
  32. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +46 -0
  33. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +160 -0
  34. package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +3 -1
  35. package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +69 -0
  36. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +207 -0
  37. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +356 -0
  38. package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +155 -0
  39. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +94 -3
  40. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +33 -5
  41. package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +15 -0
  42. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +178 -0
  43. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +145 -0
  44. package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +143 -0
  45. package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +23 -4
  46. package/android/src/main/java/com/mentra/acsmeeting/video/FramePacer.kt +70 -0
  47. package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +1 -1
  48. package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +36 -3
  49. package/android/src/test/java/com/mentra/acsmeeting/AbandonedCallAgentTest.kt +74 -0
  50. package/android/src/test/java/com/mentra/acsmeeting/CallCapabilitiesTest.kt +47 -0
  51. package/android/src/test/java/com/mentra/acsmeeting/audio/AudioUplinkChainTest.kt +250 -0
  52. package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +44 -0
  53. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +34 -4
  54. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +96 -0
  55. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +102 -0
  56. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +148 -0
  57. package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +59 -0
  58. package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +59 -0
  59. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +35 -0
  60. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +165 -0
  61. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +96 -0
  62. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +187 -0
  63. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +172 -0
  64. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +141 -0
  65. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +69 -0
  66. package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +40 -0
  67. package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +41 -0
  68. package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +26 -8
  69. package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +109 -0
  70. package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +165 -0
  71. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +148 -0
  72. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +76 -0
  73. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +275 -0
  74. package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +9 -0
  75. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +356 -0
  76. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +411 -0
  77. package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +83 -0
  78. package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +69 -3
  79. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +182 -0
  80. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +149 -0
  81. package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +97 -0
  82. package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +34 -0
  83. package/android/src/test/java/com/mentra/acsmeeting/video/FramePacerTest.kt +105 -0
  84. package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +5 -0
  85. package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +38 -3
  86. package/build/AcsMeeting.types.d.ts +5 -0
  87. package/build/AcsMeeting.types.d.ts.map +1 -1
  88. package/build/AcsMeeting.types.js.map +1 -1
  89. package/build/AcsMeetingModule.d.ts +23 -0
  90. package/build/AcsMeetingModule.d.ts.map +1 -1
  91. package/build/AcsMeetingModule.js.map +1 -1
  92. package/build/AcsMeetingModule.web.d.ts +15 -0
  93. package/build/AcsMeetingModule.web.d.ts.map +1 -1
  94. package/build/AcsMeetingModule.web.js +13 -0
  95. package/build/AcsMeetingModule.web.js.map +1 -1
  96. package/ios/AcsMeetingModule.swift +213 -2
  97. package/package.json +1 -1
  98. package/src/AcsMeeting.types.ts +5 -0
  99. package/src/AcsMeetingModule.ts +13 -0
  100. 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 depthAboveEmergencyCapDropsOldestDownToTarget() {
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
- assertThat(pacer.controlDepthMs()).isEqualTo(UplinkPacer.TARGET_MS)
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).isEqualTo(480L)
122
- assertThat(stats.driftCorrections).isEqualTo(0L)
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
+ }