@mentra/acs-meeting 3.2.0-dev.120
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/NOTICE +20 -0
- package/README.md +412 -0
- package/THIRD_PARTY +7 -0
- package/android/build.gradle +64 -0
- package/android/src/main/AndroidManifest.xml +5 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +80 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +846 -0
- package/android/src/main/java/com/mentra/acsmeeting/RemoteRoster.kt +123 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +90 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/AudioPolicyApplier.kt +176 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/IncomingAudioPump.kt +112 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/IncomingRateProbe.kt +65 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +108 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/PcmResampler.kt +159 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/PhoneMicCapturer.kt +115 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +302 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +230 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/BitmapFont.kt +99 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +578 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/FirstFrameGate.kt +45 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +101 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/MediaListeners.kt +57 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticFrameFactory.kt +205 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticI420Source.kt +214 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +31 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +41 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/ChromaProbe.kt +78 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +281 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +51 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/ProcessCpu.kt +15 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +33 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +475 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsTimestamp.kt +26 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/FrameGeometry.kt +22 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +90 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/I420Packer.kt +72 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/Nv12FormatSpec.kt +36 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/Nv12Packer.kt +51 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/SendGate.kt +27 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +57 -0
- package/android/src/test/java/com/mentra/acsmeeting/RemoteRosterTest.kt +40 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/AcsAudioPolicyTest.kt +204 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/AudioPolicyApplierTest.kt +223 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +12 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/IncomingAudioPumpTest.kt +97 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/IncomingRateProbeTest.kt +78 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/PcmResamplerTest.kt +125 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +201 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +166 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +26 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/BitmapFontTest.kt +22 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/FirstFrameGateTest.kt +60 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +133 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/I420PlanesTest.kt +67 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticFrameFactoryTest.kt +193 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticI420SourceTest.kt +176 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +36 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/ChromaProbeTest.kt +61 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +256 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/ProcessCpuTest.kt +18 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/RingPercentileTest.kt +49 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +43 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/FrameGeometryTest.kt +33 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +77 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/I420PackerTest.kt +126 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/Nv12FormatSpecTest.kt +36 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/Nv12PackerTest.kt +58 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/SendGateTest.kt +79 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +68 -0
- package/build/AcsMeeting.types.d.ts +59 -0
- package/build/AcsMeeting.types.d.ts.map +1 -0
- package/build/AcsMeeting.types.js +2 -0
- package/build/AcsMeeting.types.js.map +1 -0
- package/build/AcsMeetingModule.d.ts +15 -0
- package/build/AcsMeetingModule.d.ts.map +1 -0
- package/build/AcsMeetingModule.js +3 -0
- package/build/AcsMeetingModule.js.map +1 -0
- package/build/AcsMeetingModule.web.d.ts +16 -0
- package/build/AcsMeetingModule.web.d.ts.map +1 -0
- package/build/AcsMeetingModule.web.js +31 -0
- package/build/AcsMeetingModule.web.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +3 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/AcsFrameSender.swift +47 -0
- package/ios/AcsMeeting.podspec +27 -0
- package/ios/AcsMeetingModule.swift +558 -0
- package/ios/PcmBridge.swift +117 -0
- package/ios/PhoneMicCapturer.swift +64 -0
- package/ios/PolicyKit/Package.swift +17 -0
- package/ios/PolicyKit/Sources/AcsAudioPolicy/AcsAudioPolicy.swift +102 -0
- package/ios/PolicyKit/Sources/AcsAudioPolicy/AudioPolicyApplier.swift +162 -0
- package/ios/PolicyKit/Sources/AcsAudioPolicy/GlassesMediaSource.swift +68 -0
- package/ios/PolicyKit/Tests/AcsAudioPolicyTests/AcsAudioPolicyTests.swift +273 -0
- package/ios/WhepVideoSource.swift +356 -0
- package/package.json +56 -0
- package/src/AcsMeeting.types.ts +66 -0
- package/src/AcsMeetingModule.ts +16 -0
- package/src/AcsMeetingModule.web.ts +33 -0
- package/src/index.ts +2 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.audio
|
|
2
|
+
|
|
3
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
4
|
+
import org.junit.Test
|
|
5
|
+
|
|
6
|
+
class IncomingAudioPumpTest {
|
|
7
|
+
private fun silence(ms: Int, rate: Int, channels: Int = 1) = ByteArray(rate * channels * 2 * ms / 1000)
|
|
8
|
+
|
|
9
|
+
@Test
|
|
10
|
+
fun bytesForMatches16kMono() {
|
|
11
|
+
assertThat(IncomingAudioPump.bytesFor(20)).isEqualTo(640)
|
|
12
|
+
assertThat(IncomingAudioPump.bytesFor(60)).isEqualTo(1920)
|
|
13
|
+
assertThat(IncomingAudioPump.bytesFor(120)).isEqualTo(3840)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@Test
|
|
17
|
+
fun defaultPrerollIsOneOfTheLadderSteps() {
|
|
18
|
+
// The downlink A/B walks this ladder on real hardware; whatever ships must
|
|
19
|
+
// be a step on it, not an arbitrary number.
|
|
20
|
+
assertThat(IncomingAudioPump.PREROLL_LADDER_MS).containsExactly(120, 160, 200)
|
|
21
|
+
assertThat(IncomingAudioPump.PREROLL_LADDER_MS).contains(IncomingAudioPump.DEFAULT_PREROLL_MS)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@Test
|
|
25
|
+
fun holdsDefaultPrerollBeforeFirstEmit() {
|
|
26
|
+
val emitted = mutableListOf<ByteArray>()
|
|
27
|
+
val pump = IncomingAudioPump { emitted.add(it) }
|
|
28
|
+
val callbacks = IncomingAudioPump.DEFAULT_PREROLL_MS / 20
|
|
29
|
+
|
|
30
|
+
repeat(callbacks - 1) { pump.push(silence(20, 16_000), 16_000, 1) }
|
|
31
|
+
assertThat(emitted).isEmpty()
|
|
32
|
+
|
|
33
|
+
pump.push(silence(20, 16_000), 16_000, 1)
|
|
34
|
+
assertThat(emitted).isNotEmpty()
|
|
35
|
+
assertThat(emitted).allSatisfy { assertThat(it).hasSize(IncomingAudioPump.bytesFor(IncomingAudioPump.DEFAULT_BATCH_MS)) }
|
|
36
|
+
// Whole batches only; the sub-batch remainder stays as headroom.
|
|
37
|
+
val total = emitted.sumOf { it.size }
|
|
38
|
+
assertThat(total)
|
|
39
|
+
.isGreaterThan(IncomingAudioPump.bytesFor(IncomingAudioPump.DEFAULT_PREROLL_MS - IncomingAudioPump.DEFAULT_BATCH_MS))
|
|
40
|
+
.isLessThanOrEqualTo(IncomingAudioPump.bytesFor(IncomingAudioPump.DEFAULT_PREROLL_MS))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@Test
|
|
44
|
+
fun holdsPrerollThenEmitsBatches() {
|
|
45
|
+
val emitted = mutableListOf<ByteArray>()
|
|
46
|
+
val pump = IncomingAudioPump(prerollMs = 120, batchMs = 60) { emitted.add(it) }
|
|
47
|
+
|
|
48
|
+
// Five 20 ms ACS callbacks = 100 ms: below the 120 ms preroll.
|
|
49
|
+
repeat(5) { pump.push(silence(20, 16_000), 16_000, 1) }
|
|
50
|
+
assertThat(emitted).isEmpty()
|
|
51
|
+
|
|
52
|
+
pump.push(silence(20, 16_000), 16_000, 1)
|
|
53
|
+
assertThat(emitted).hasSize(2)
|
|
54
|
+
assertThat(emitted.all { it.size == IncomingAudioPump.bytesFor(60) }).isTrue()
|
|
55
|
+
|
|
56
|
+
// Steady state: one 60 ms batch per three callbacks.
|
|
57
|
+
repeat(2) { pump.push(silence(20, 16_000), 16_000, 1) }
|
|
58
|
+
assertThat(emitted).hasSize(2)
|
|
59
|
+
pump.push(silence(20, 16_000), 16_000, 1)
|
|
60
|
+
assertThat(emitted).hasSize(3)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@Test
|
|
64
|
+
fun normalizes48kStereoTo16kMono() {
|
|
65
|
+
val emitted = mutableListOf<ByteArray>()
|
|
66
|
+
val pump = IncomingAudioPump(prerollMs = 0, batchMs = 20) { emitted.add(it) }
|
|
67
|
+
// 200 ms of 48 kHz stereo (as ACS would deliver if it ignored our 16 kHz request).
|
|
68
|
+
repeat(10) { pump.push(silence(20, 48_000, channels = 2), 48_000, 2) }
|
|
69
|
+
val total = emitted.sumOf { it.size }
|
|
70
|
+
// 200 ms at 16 kHz mono = 6400 bytes, minus FIR warm-up and one held batch.
|
|
71
|
+
assertThat(total).isBetween(IncomingAudioPump.bytesFor(140), IncomingAudioPump.bytesFor(200))
|
|
72
|
+
assertThat(pump.format).isEqualTo(IncomingAudioPump.Format(48_000, 2))
|
|
73
|
+
assertThat(pump.formatChanges).isEqualTo(0)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@Test
|
|
77
|
+
fun flushEmitsTailAndRearmsPreroll() {
|
|
78
|
+
val emitted = mutableListOf<ByteArray>()
|
|
79
|
+
val pump = IncomingAudioPump(prerollMs = 120, batchMs = 60) { emitted.add(it) }
|
|
80
|
+
pump.push(silence(20, 16_000), 16_000, 1)
|
|
81
|
+
pump.flush()
|
|
82
|
+
assertThat(emitted).hasSize(1)
|
|
83
|
+
assertThat(emitted[0].size).isEqualTo(IncomingAudioPump.bytesFor(20))
|
|
84
|
+
|
|
85
|
+
repeat(5) { pump.push(silence(20, 16_000), 16_000, 1) }
|
|
86
|
+
assertThat(emitted).hasSize(1)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@Test
|
|
90
|
+
fun countsFormatChanges() {
|
|
91
|
+
val pump = IncomingAudioPump(prerollMs = 0, batchMs = 20) {}
|
|
92
|
+
pump.push(silence(20, 16_000), 16_000, 1)
|
|
93
|
+
pump.push(silence(20, 48_000), 48_000, 1)
|
|
94
|
+
assertThat(pump.formatChanges).isEqualTo(1)
|
|
95
|
+
assertThat(pump.eventsIn).isEqualTo(2)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.audio
|
|
2
|
+
|
|
3
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
4
|
+
import org.junit.Test
|
|
5
|
+
|
|
6
|
+
class IncomingRateProbeTest {
|
|
7
|
+
@Test
|
|
8
|
+
fun measuresTrueRateFromElapsedTimeNotAssumedCadence() {
|
|
9
|
+
val probe = IncomingRateProbe()
|
|
10
|
+
var now = 0L
|
|
11
|
+
var reading: IncomingRateProbe.Reading? = null
|
|
12
|
+
|
|
13
|
+
// 16 kHz mono, 320 samples every 20 ms, for slightly over a second.
|
|
14
|
+
repeat(52) {
|
|
15
|
+
reading = probe.record(now, bytes = 640, sampleRate = 16_000, channels = 1) ?: reading
|
|
16
|
+
now += 20_000_000L
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
val result = requireNotNull(reading)
|
|
20
|
+
assertThat(result.declaredRate).isEqualTo(16_000)
|
|
21
|
+
assertThat(result.samplesPerCallback).isEqualTo(320)
|
|
22
|
+
assertThat(result.callbackHz).isBetween(49.5, 50.5)
|
|
23
|
+
assertThat(result.measuredRate).isBetween(15_900.0, 16_100.0)
|
|
24
|
+
assertThat(result.events).isEqualTo(50L)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@Test
|
|
28
|
+
fun mislabeledRateShowsAsMeasuredDisagreeingWithDeclared() {
|
|
29
|
+
val probe = IncomingRateProbe()
|
|
30
|
+
var now = 0L
|
|
31
|
+
var reading: IncomingRateProbe.Reading? = null
|
|
32
|
+
|
|
33
|
+
// ACS declares 16 kHz but hands over 48 kHz worth of samples per 20 ms.
|
|
34
|
+
repeat(52) {
|
|
35
|
+
reading = probe.record(now, bytes = 1920, sampleRate = 16_000, channels = 1) ?: reading
|
|
36
|
+
now += 20_000_000L
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
val result = requireNotNull(reading)
|
|
40
|
+
assertThat(result.declaredRate).isEqualTo(16_000)
|
|
41
|
+
assertThat(result.measuredRate).isBetween(47_500.0, 48_500.0)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@Test
|
|
45
|
+
fun halfRateCadenceShowsInCallbackHz() {
|
|
46
|
+
val probe = IncomingRateProbe()
|
|
47
|
+
var now = 0L
|
|
48
|
+
var reading: IncomingRateProbe.Reading? = null
|
|
49
|
+
|
|
50
|
+
// One 40 ms callback instead of two 20 ms ones: same rate, half the Hz.
|
|
51
|
+
repeat(27) {
|
|
52
|
+
reading = probe.record(now, bytes = 1280, sampleRate = 16_000, channels = 1) ?: reading
|
|
53
|
+
now += 40_000_000L
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
val result = requireNotNull(reading)
|
|
57
|
+
assertThat(result.samplesPerCallback).isEqualTo(640)
|
|
58
|
+
assertThat(result.callbackHz).isBetween(24.5, 25.5)
|
|
59
|
+
assertThat(result.measuredRate).isBetween(15_900.0, 16_100.0)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@Test
|
|
63
|
+
fun stereoCountsSamplesPerChannel() {
|
|
64
|
+
val probe = IncomingRateProbe()
|
|
65
|
+
probe.record(0, bytes = 1920, sampleRate = 48_000, channels = 2)
|
|
66
|
+
val reading = probe.record(1_000_000_000L, bytes = 1920, sampleRate = 48_000, channels = 2)
|
|
67
|
+
|
|
68
|
+
assertThat(requireNotNull(reading).samplesPerCallback).isEqualTo(480)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@Test
|
|
72
|
+
fun resetReopensTheWindow() {
|
|
73
|
+
val probe = IncomingRateProbe()
|
|
74
|
+
probe.record(0, bytes = 640, sampleRate = 16_000, channels = 1)
|
|
75
|
+
probe.reset()
|
|
76
|
+
assertThat(probe.record(2_000_000_000L, bytes = 640, sampleRate = 16_000, channels = 1)).isNull()
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.audio
|
|
2
|
+
|
|
3
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
4
|
+
import org.junit.Test
|
|
5
|
+
import java.nio.ByteBuffer
|
|
6
|
+
import java.nio.ByteOrder
|
|
7
|
+
import kotlin.math.PI
|
|
8
|
+
import kotlin.math.sin
|
|
9
|
+
import kotlin.math.sqrt
|
|
10
|
+
|
|
11
|
+
class PcmResamplerTest {
|
|
12
|
+
private fun tone(rate: Int, hz: Double, seconds: Double, amplitude: Double = 8000.0, channels: Int = 1): ByteArray {
|
|
13
|
+
val frames = (rate * seconds).toInt()
|
|
14
|
+
val out = ByteArray(frames * channels * 2)
|
|
15
|
+
val bb = ByteBuffer.wrap(out).order(ByteOrder.LITTLE_ENDIAN)
|
|
16
|
+
for (i in 0 until frames) {
|
|
17
|
+
val s = (amplitude * sin(2 * PI * hz * i / rate)).toInt().toShort()
|
|
18
|
+
for (c in 0 until channels) bb.putShort(s)
|
|
19
|
+
}
|
|
20
|
+
return out
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private fun shorts(pcm: ByteArray): ShortArray {
|
|
24
|
+
val s = ShortArray(pcm.size / 2)
|
|
25
|
+
ByteBuffer.wrap(pcm).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().get(s)
|
|
26
|
+
return s
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private fun rms(s: ShortArray, from: Int = 0): Double {
|
|
30
|
+
var acc = 0.0
|
|
31
|
+
for (i in from until s.size) acc += s[i].toDouble() * s[i]
|
|
32
|
+
return sqrt(acc / (s.size - from))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private fun zeroCrossings(s: ShortArray, from: Int = 0): Int {
|
|
36
|
+
var n = 0
|
|
37
|
+
for (i in from + 1 until s.size) if ((s[i - 1] < 0) != (s[i] < 0)) n++
|
|
38
|
+
return n
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@Test
|
|
42
|
+
fun passthroughAt16kMonoIsByteExact() {
|
|
43
|
+
val input = tone(16_000, 440.0, 0.1)
|
|
44
|
+
val out = PcmResampler(16_000).process(input, 16_000, 1)
|
|
45
|
+
assertThat(out).isEqualTo(input)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@Test
|
|
49
|
+
fun stereoDownmixAveragesChannels() {
|
|
50
|
+
val frames = 100
|
|
51
|
+
val input = ByteArray(frames * 4)
|
|
52
|
+
val bb = ByteBuffer.wrap(input).order(ByteOrder.LITTLE_ENDIAN)
|
|
53
|
+
for (i in 0 until frames) {
|
|
54
|
+
bb.putShort(1000)
|
|
55
|
+
bb.putShort(-1000)
|
|
56
|
+
}
|
|
57
|
+
val out = shorts(PcmResampler(16_000).process(input, 16_000, 2))
|
|
58
|
+
assertThat(out).hasSize(frames)
|
|
59
|
+
assertThat(out.all { it == 0.toShort() }).isTrue()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@Test
|
|
63
|
+
fun downsamples48kTo16kPreservingSpeechBandTone() {
|
|
64
|
+
val input = tone(48_000, 1000.0, 1.0)
|
|
65
|
+
val out = shorts(PcmResampler(16_000).process(input, 48_000, 1))
|
|
66
|
+
// 1 s at 16 kHz minus FIR warm-up (62 input samples ≈ 20 output samples).
|
|
67
|
+
assertThat(out.size).isBetween(16_000 - 30, 16_000)
|
|
68
|
+
val settle = 200
|
|
69
|
+
assertThat(rms(out, settle)).isBetween(8000 / sqrt(2.0) * 0.97, 8000 / sqrt(2.0) * 1.03)
|
|
70
|
+
// 1 kHz tone: 2000 zero crossings per second.
|
|
71
|
+
val seconds = (out.size - settle) / 16_000.0
|
|
72
|
+
assertThat(zeroCrossings(out, settle) / seconds).isBetween(1990.0, 2010.0)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@Test
|
|
76
|
+
fun downsamplingRejectsContentAboveOutputNyquist() {
|
|
77
|
+
// 20 kHz at 48 kHz would alias to 4 kHz at full amplitude with plain decimation.
|
|
78
|
+
val input = tone(48_000, 20_000.0, 1.0)
|
|
79
|
+
val out = shorts(PcmResampler(16_000).process(input, 48_000, 1))
|
|
80
|
+
assertThat(rms(out, 200)).isLessThan(8000 / sqrt(2.0) * 0.02)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@Test
|
|
84
|
+
fun chunkedInputMatchesContiguousInput() {
|
|
85
|
+
val input = tone(48_000, 700.0, 0.5)
|
|
86
|
+
val whole = shorts(PcmResampler(16_000).process(input, 48_000, 1))
|
|
87
|
+
|
|
88
|
+
val chunked = PcmResampler(16_000)
|
|
89
|
+
val pieces = ArrayList<Short>()
|
|
90
|
+
var offset = 0
|
|
91
|
+
val chunk = 960 // 10 ms at 48 kHz, WebRTC's callback size
|
|
92
|
+
while (offset < input.size) {
|
|
93
|
+
val end = minOf(input.size, offset + chunk)
|
|
94
|
+
pieces.addAll(shorts(chunked.process(input.copyOfRange(offset, end), 48_000, 1)).toList())
|
|
95
|
+
offset = end
|
|
96
|
+
}
|
|
97
|
+
assertThat(pieces.size).isEqualTo(whole.size)
|
|
98
|
+
for (i in whole.indices) {
|
|
99
|
+
assertThat(pieces[i].toInt()).isCloseTo(whole[i].toInt(), org.assertj.core.data.Offset.offset(1))
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@Test
|
|
104
|
+
fun upsamplesWithoutFilterAndKeepsLength() {
|
|
105
|
+
val input = tone(8_000, 400.0, 0.5)
|
|
106
|
+
val out = shorts(PcmResampler(16_000).process(input, 8_000, 1))
|
|
107
|
+
assertThat(out.size).isBetween(8_000 - 4, 8_000)
|
|
108
|
+
assertThat(rms(out, 50)).isBetween(8000 / sqrt(2.0) * 0.95, 8000 / sqrt(2.0) * 1.03)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@Test
|
|
112
|
+
fun formatChangeResetsStateAndKeepsGoing() {
|
|
113
|
+
val r = PcmResampler(16_000)
|
|
114
|
+
r.process(tone(48_000, 1000.0, 0.1), 48_000, 1)
|
|
115
|
+
val out = shorts(r.process(tone(16_000, 1000.0, 0.1), 16_000, 1))
|
|
116
|
+
assertThat(out.size).isEqualTo(1600)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
@Test
|
|
120
|
+
fun lowPassTapsAreUnityGainAtDc() {
|
|
121
|
+
val taps = PcmResampler.designLowPass(48_000, 16_000)
|
|
122
|
+
assertThat(taps.sum().toDouble()).isCloseTo(1.0, org.assertj.core.data.Offset.offset(1e-4))
|
|
123
|
+
assertThat(taps.size % 2).isEqualTo(1)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.audio
|
|
2
|
+
|
|
3
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
4
|
+
import org.junit.Test
|
|
5
|
+
|
|
6
|
+
class UplinkPacerTest {
|
|
7
|
+
private val frameBytes = UplinkPacer.FRAME_BYTES
|
|
8
|
+
private val tickNanos = UplinkPacer.FRAME_MS * 1_000_000L
|
|
9
|
+
|
|
10
|
+
/** Non-zero PCM so a manufactured silence frame is distinguishable. */
|
|
11
|
+
private fun tone(ms: Int): ByteArray = ByteArray(ms * UplinkPacer.BYTES_PER_MS) { 0x20 }
|
|
12
|
+
|
|
13
|
+
@Test
|
|
14
|
+
fun prerollGateHoldsUntilTargetDepth() {
|
|
15
|
+
val pacer = UplinkPacer()
|
|
16
|
+
var now = 0L
|
|
17
|
+
pacer.push(tone(40))
|
|
18
|
+
repeat(3) {
|
|
19
|
+
assertThat(pacer.tick(now).silence).isTrue()
|
|
20
|
+
now += tickNanos
|
|
21
|
+
}
|
|
22
|
+
assertThat(pacer.state()).isEqualTo(UplinkPacer.State.PREROLLING)
|
|
23
|
+
assertThat(pacer.depthMs()).isEqualTo(40)
|
|
24
|
+
|
|
25
|
+
pacer.push(tone(20))
|
|
26
|
+
assertThat(pacer.tick(now).silence).isFalse()
|
|
27
|
+
assertThat(pacer.state()).isEqualTo(UplinkPacer.State.RUNNING)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@Test
|
|
31
|
+
fun steadyStateSendsFiftyFramesPerSecondAtTargetDepth() {
|
|
32
|
+
val pacer = UplinkPacer()
|
|
33
|
+
var now = 0L
|
|
34
|
+
pacer.push(tone(UplinkPacer.TARGET_MS))
|
|
35
|
+
var audio = 0
|
|
36
|
+
repeat(500) {
|
|
37
|
+
if (!pacer.tick(now).silence) audio += 1
|
|
38
|
+
pacer.push(tone(UplinkPacer.FRAME_MS))
|
|
39
|
+
now += tickNanos
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
assertThat(audio).isEqualTo(500)
|
|
43
|
+
val stats = pacer.snapshot(now)
|
|
44
|
+
assertThat(stats.sentFps).isBetween(49.5, 50.5)
|
|
45
|
+
assertThat(stats.depthMs).isEqualTo(UplinkPacer.TARGET_MS)
|
|
46
|
+
assertThat(stats.silenceFrames).isEqualTo(0L)
|
|
47
|
+
assertThat(stats.driftCorrections).isEqualTo(0L)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@Test
|
|
51
|
+
fun burstIsSmoothedToOneFramePerTick() {
|
|
52
|
+
val pacer = UplinkPacer()
|
|
53
|
+
// One 100 ms delivery, as the WebRTC audio thread bursts it.
|
|
54
|
+
pacer.push(tone(100))
|
|
55
|
+
val frames = (0 until 5).map { pacer.tick(it * tickNanos) }
|
|
56
|
+
|
|
57
|
+
assertThat(frames.map { it.bytes.size }).containsOnly(frameBytes)
|
|
58
|
+
assertThat(frames.none { it.silence }).isTrue()
|
|
59
|
+
assertThat(pacer.depthMs()).isEqualTo(0)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@Test
|
|
63
|
+
fun underrunEmitsSilenceWithoutLeavingRunning() {
|
|
64
|
+
val pacer = UplinkPacer()
|
|
65
|
+
var now = 0L
|
|
66
|
+
pacer.push(tone(UplinkPacer.TARGET_MS))
|
|
67
|
+
repeat(3) {
|
|
68
|
+
pacer.tick(now)
|
|
69
|
+
now += tickNanos
|
|
70
|
+
}
|
|
71
|
+
assertThat(pacer.depthMs()).isEqualTo(0)
|
|
72
|
+
|
|
73
|
+
val frame = pacer.tick(now)
|
|
74
|
+
assertThat(frame.silence).isTrue()
|
|
75
|
+
assertThat(frame.bytes.size).isEqualTo(frameBytes)
|
|
76
|
+
assertThat(pacer.state()).isEqualTo(UplinkPacer.State.RUNNING)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@Test
|
|
80
|
+
fun prolongedOutageStarvesAndRebuildsHeadroomBeforeResuming() {
|
|
81
|
+
val pacer = UplinkPacer()
|
|
82
|
+
var now = 0L
|
|
83
|
+
pacer.push(tone(UplinkPacer.TARGET_MS))
|
|
84
|
+
repeat(3) {
|
|
85
|
+
pacer.tick(now)
|
|
86
|
+
now += tickNanos
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 1.5 s with no producer audio at all.
|
|
90
|
+
repeat(75) {
|
|
91
|
+
assertThat(pacer.tick(now).silence).isTrue()
|
|
92
|
+
now += tickNanos
|
|
93
|
+
}
|
|
94
|
+
assertThat(pacer.state()).isEqualTo(UplinkPacer.State.STARVED)
|
|
95
|
+
|
|
96
|
+
// Audio is back but below target: still silence, and no source audio is
|
|
97
|
+
// consumed, so the rebuild is not spent immediately.
|
|
98
|
+
pacer.push(tone(40))
|
|
99
|
+
assertThat(pacer.tick(now).silence).isTrue()
|
|
100
|
+
now += tickNanos
|
|
101
|
+
assertThat(pacer.state()).isEqualTo(UplinkPacer.State.STARVED)
|
|
102
|
+
assertThat(pacer.depthMs()).isEqualTo(40)
|
|
103
|
+
|
|
104
|
+
pacer.push(tone(20))
|
|
105
|
+
assertThat(pacer.tick(now).silence).isFalse()
|
|
106
|
+
assertThat(pacer.state()).isEqualTo(UplinkPacer.State.RUNNING)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
@Test
|
|
110
|
+
fun depthAboveEmergencyCapDropsOldestDownToTarget() {
|
|
111
|
+
val pacer = UplinkPacer()
|
|
112
|
+
var now = 0L
|
|
113
|
+
pacer.push(tone(UplinkPacer.TARGET_MS))
|
|
114
|
+
pacer.tick(now)
|
|
115
|
+
now += tickNanos
|
|
116
|
+
|
|
117
|
+
pacer.push(tone(500))
|
|
118
|
+
assertThat(pacer.tick(now).silence).isFalse()
|
|
119
|
+
assertThat(pacer.controlDepthMs()).isEqualTo(UplinkPacer.TARGET_MS)
|
|
120
|
+
val stats = pacer.snapshot(now)
|
|
121
|
+
assertThat(stats.overflowDroppedMs).isEqualTo(480L)
|
|
122
|
+
assertThat(stats.driftCorrections).isEqualTo(0L)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@Test
|
|
126
|
+
fun fastProducerClockStaysInsideEnvelopeForAnHour() {
|
|
127
|
+
val run = simulateDrift(ppm = 300.0, minutes = 60)
|
|
128
|
+
|
|
129
|
+
assertThat(run.minControlMs).isGreaterThanOrEqualTo(UplinkPacer.LOW_MS)
|
|
130
|
+
assertThat(run.maxControlMs).isLessThanOrEqualTo(UplinkPacer.HIGH_MS + UplinkPacer.CORRECTION_MS)
|
|
131
|
+
assertThat(run.stats.silenceFrames).isEqualTo(0L)
|
|
132
|
+
assertThat(run.stats.overflowDroppedMs).isEqualTo(0L)
|
|
133
|
+
assertThat(run.stats.driftInsertedMs).isEqualTo(0L)
|
|
134
|
+
// 300 ppm over an hour is ~1080 ms of excess audio to shed.
|
|
135
|
+
assertThat(run.stats.driftDroppedMs).isBetween(900L, 1300L)
|
|
136
|
+
assertThat(run.stats.driftCorrections).isLessThan(200L)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
@Test
|
|
140
|
+
fun slowProducerClockStaysInsideEnvelopeForAnHour() {
|
|
141
|
+
val run = simulateDrift(ppm = -300.0, minutes = 60)
|
|
142
|
+
|
|
143
|
+
assertThat(run.minControlMs).isGreaterThanOrEqualTo(UplinkPacer.LOW_MS - UplinkPacer.CORRECTION_MS)
|
|
144
|
+
assertThat(run.maxControlMs).isLessThanOrEqualTo(UplinkPacer.HIGH_MS)
|
|
145
|
+
assertThat(run.stats.silenceFrames).isEqualTo(0L)
|
|
146
|
+
assertThat(run.stats.overflowDroppedMs).isEqualTo(0L)
|
|
147
|
+
assertThat(run.stats.driftDroppedMs).isEqualTo(0L)
|
|
148
|
+
assertThat(run.stats.driftInsertedMs).isBetween(900L, 1300L)
|
|
149
|
+
assertThat(run.stats.driftCorrections).isLessThan(200L)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
@Test
|
|
153
|
+
fun resetRearmsPreroll() {
|
|
154
|
+
val pacer = UplinkPacer()
|
|
155
|
+
pacer.push(tone(UplinkPacer.TARGET_MS))
|
|
156
|
+
pacer.tick(0)
|
|
157
|
+
assertThat(pacer.state()).isEqualTo(UplinkPacer.State.RUNNING)
|
|
158
|
+
|
|
159
|
+
pacer.reset()
|
|
160
|
+
assertThat(pacer.state()).isEqualTo(UplinkPacer.State.PREROLLING)
|
|
161
|
+
assertThat(pacer.depthMs()).isEqualTo(0)
|
|
162
|
+
assertThat(pacer.snapshot(0).framesEmitted).isEqualTo(0L)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private class DriftRun(
|
|
166
|
+
val minControlMs: Int,
|
|
167
|
+
val maxControlMs: Int,
|
|
168
|
+
val stats: UplinkPacer.Stats,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Runs [minutes] of call time with a producer clock offset by [ppm], one
|
|
173
|
+
* simulated 20 ms period per iteration. The pacer's injected clock is what
|
|
174
|
+
* makes an hour cost milliseconds.
|
|
175
|
+
*/
|
|
176
|
+
private fun simulateDrift(ppm: Double, minutes: Int): DriftRun {
|
|
177
|
+
val pacer = UplinkPacer()
|
|
178
|
+
var now = 0L
|
|
179
|
+
var minMs = Int.MAX_VALUE
|
|
180
|
+
var maxMs = Int.MIN_VALUE
|
|
181
|
+
var owed = 0.0
|
|
182
|
+
val bytesPerTick = frameBytes * (1.0 + ppm / 1_000_000.0)
|
|
183
|
+
val ticks = minutes * 60 * 1000 / UplinkPacer.FRAME_MS
|
|
184
|
+
|
|
185
|
+
pacer.push(tone(UplinkPacer.TARGET_MS))
|
|
186
|
+
repeat(ticks) {
|
|
187
|
+
pacer.tick(now)
|
|
188
|
+
minMs = minOf(minMs, pacer.controlDepthMs())
|
|
189
|
+
maxMs = maxOf(maxMs, pacer.controlDepthMs())
|
|
190
|
+
owed += bytesPerTick
|
|
191
|
+
// Sample-aligned: PCM16 frames never split a sample.
|
|
192
|
+
val whole = (owed.toInt() / 2) * 2
|
|
193
|
+
if (whole > 0) {
|
|
194
|
+
pacer.push(ByteArray(whole) { 0x20 })
|
|
195
|
+
owed -= whole
|
|
196
|
+
}
|
|
197
|
+
now += tickNanos
|
|
198
|
+
}
|
|
199
|
+
return DriftRun(minMs, maxMs, pacer.snapshot(now))
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.audio
|
|
2
|
+
|
|
3
|
+
import java.util.Collections
|
|
4
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
5
|
+
import org.junit.Test
|
|
6
|
+
|
|
7
|
+
class UplinkSenderTest {
|
|
8
|
+
private fun tone(ms: Int): ByteArray = ByteArray(ms * UplinkPacer.BYTES_PER_MS) { 0x20 }
|
|
9
|
+
|
|
10
|
+
private class FakeTransport : UplinkTransport {
|
|
11
|
+
val sent = mutableListOf<ByteArray>()
|
|
12
|
+
private val pending = mutableListOf<(Throwable?) -> Unit>()
|
|
13
|
+
var autoComplete = true
|
|
14
|
+
var failWith: Throwable? = null
|
|
15
|
+
|
|
16
|
+
override fun send(frame: ByteArray, onComplete: (Throwable?) -> Unit) {
|
|
17
|
+
sent.add(frame)
|
|
18
|
+
if (autoComplete) onComplete(failWith) else pending.add(onComplete)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
fun completeAll(error: Throwable? = null) {
|
|
22
|
+
val drained = pending.toList()
|
|
23
|
+
pending.clear()
|
|
24
|
+
drained.forEach { it(error) }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Preroll, then one 20 ms production per period: steady 60 ms of headroom. */
|
|
29
|
+
private fun pump(sender: UplinkSender, pacer: UplinkPacer, periods: Int, from: Int = 0) {
|
|
30
|
+
repeat(periods) {
|
|
31
|
+
sender.pumpOnce((from + it) * UplinkSender.PERIOD_NANOS)
|
|
32
|
+
pacer.push(tone(UplinkPacer.FRAME_MS))
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@Test
|
|
37
|
+
fun aStallSendsOneFrameAndCountsSkippedTicks() {
|
|
38
|
+
val pacer = UplinkPacer()
|
|
39
|
+
val transport = FakeTransport()
|
|
40
|
+
val sender = UplinkSender(pacer, transport, clock = { 0L }, log = {})
|
|
41
|
+
pacer.push(tone(UplinkPacer.TARGET_MS))
|
|
42
|
+
|
|
43
|
+
sender.pumpOnce(0)
|
|
44
|
+
pacer.push(tone(UplinkPacer.FRAME_MS))
|
|
45
|
+
sender.pumpOnce(20_000_000L)
|
|
46
|
+
pacer.push(tone(UplinkPacer.FRAME_MS))
|
|
47
|
+
// 80 ms stall: this wake is 60 ms past its 40 ms deadline.
|
|
48
|
+
sender.pumpOnce(100_000_000L)
|
|
49
|
+
|
|
50
|
+
assertThat(transport.sent).hasSize(3)
|
|
51
|
+
val stats = sender.stats()
|
|
52
|
+
assertThat(stats.framesSubmitted).isEqualTo(3L)
|
|
53
|
+
assertThat(stats.skippedTicks).isEqualTo(3L)
|
|
54
|
+
assertThat(stats.tickLateMaxMs).isEqualTo(60L)
|
|
55
|
+
|
|
56
|
+
// The deadline is re-based on now, so the very next period is 20 ms out
|
|
57
|
+
// rather than three frames being replayed back to back.
|
|
58
|
+
pacer.push(tone(UplinkPacer.FRAME_MS))
|
|
59
|
+
sender.pumpOnce(120_000_000L)
|
|
60
|
+
assertThat(transport.sent).hasSize(4)
|
|
61
|
+
assertThat(sender.stats().skippedTicks).isEqualTo(3L)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@Test
|
|
65
|
+
fun everySubmissionGetsItsOwnStorageUntilCompletion() {
|
|
66
|
+
val pacer = UplinkPacer()
|
|
67
|
+
val transport = FakeTransport().apply { autoComplete = false }
|
|
68
|
+
val sender = UplinkSender(pacer, transport, clock = { 0L }, log = {})
|
|
69
|
+
pacer.push(tone(UplinkPacer.TARGET_MS))
|
|
70
|
+
|
|
71
|
+
pump(sender, pacer, 5)
|
|
72
|
+
|
|
73
|
+
assertThat(transport.sent).hasSize(5)
|
|
74
|
+
// ACS still holds all five, so no two may share an array.
|
|
75
|
+
val identities = transport.sent.mapTo(mutableSetOf()) { System.identityHashCode(it) }
|
|
76
|
+
assertThat(identities).hasSize(5)
|
|
77
|
+
assertThat(transport.sent).allSatisfy { frame ->
|
|
78
|
+
assertThat(frame).hasSize(UplinkPacer.FRAME_BYTES)
|
|
79
|
+
assertThat(frame.all { it == 0x20.toByte() }).isTrue()
|
|
80
|
+
}
|
|
81
|
+
assertThat(sender.stats().inFlight).isEqualTo(5)
|
|
82
|
+
|
|
83
|
+
transport.completeAll()
|
|
84
|
+
assertThat(sender.stats().inFlight).isEqualTo(0)
|
|
85
|
+
assertThat(sender.stats().sendFailures).isEqualTo(0L)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@Test
|
|
89
|
+
fun failedSendIsSurfacedNotSwallowed() {
|
|
90
|
+
val pacer = UplinkPacer()
|
|
91
|
+
val transport = FakeTransport().apply {
|
|
92
|
+
failWith = IllegalStateException("FAILED_TO_SEND_RAW_AUDIO_BUFFER")
|
|
93
|
+
}
|
|
94
|
+
val errors = mutableListOf<String>()
|
|
95
|
+
val sender = UplinkSender(
|
|
96
|
+
pacer,
|
|
97
|
+
transport,
|
|
98
|
+
clock = { 0L },
|
|
99
|
+
log = {},
|
|
100
|
+
logError = { message, _ -> errors.add(message) },
|
|
101
|
+
)
|
|
102
|
+
pacer.push(tone(UplinkPacer.TARGET_MS))
|
|
103
|
+
|
|
104
|
+
sender.pumpOnce(0)
|
|
105
|
+
|
|
106
|
+
assertThat(sender.stats().sendFailures).isEqualTo(1L)
|
|
107
|
+
assertThat(errors).hasSize(1)
|
|
108
|
+
assertThat(errors[0]).contains("sendRawAudioBuffer failed").contains("total=1")
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@Test
|
|
112
|
+
fun backpressureStopsFeedingWhileAcsIsBehind() {
|
|
113
|
+
val pacer = UplinkPacer()
|
|
114
|
+
val transport = FakeTransport().apply { autoComplete = false }
|
|
115
|
+
val sender = UplinkSender(pacer, transport, clock = { 0L }, log = {})
|
|
116
|
+
pacer.push(tone(UplinkPacer.TARGET_MS))
|
|
117
|
+
|
|
118
|
+
pump(sender, pacer, UplinkSender.MAX_IN_FLIGHT + 5)
|
|
119
|
+
|
|
120
|
+
assertThat(transport.sent).hasSize(UplinkSender.MAX_IN_FLIGHT)
|
|
121
|
+
assertThat(sender.stats().backpressureDrops).isEqualTo(5L)
|
|
122
|
+
|
|
123
|
+
transport.completeAll()
|
|
124
|
+
pump(sender, pacer, 1, from = UplinkSender.MAX_IN_FLIGHT + 5)
|
|
125
|
+
assertThat(transport.sent).hasSize(UplinkSender.MAX_IN_FLIGHT + 1)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@Test
|
|
129
|
+
fun logsOneP8LinePerSecond() {
|
|
130
|
+
val pacer = UplinkPacer()
|
|
131
|
+
val logs = mutableListOf<String>()
|
|
132
|
+
val sender = UplinkSender(pacer, FakeTransport(), clock = { 0L }, log = { logs.add(it) })
|
|
133
|
+
pacer.push(tone(UplinkPacer.TARGET_MS))
|
|
134
|
+
|
|
135
|
+
// 0 .. 1980 ms: one 1 Hz boundary crossed.
|
|
136
|
+
pump(sender, pacer, 100)
|
|
137
|
+
|
|
138
|
+
val lines = logs.filter { it.startsWith("P8 audio-up state=") }
|
|
139
|
+
assertThat(lines).hasSize(1)
|
|
140
|
+
assertThat(lines[0])
|
|
141
|
+
.contains("state=RUNNING")
|
|
142
|
+
.contains("depthMs=60")
|
|
143
|
+
.contains("targetMs=60")
|
|
144
|
+
.contains("silenceFrames=0")
|
|
145
|
+
.contains("skippedTicks=0")
|
|
146
|
+
.contains("sendFailures=0")
|
|
147
|
+
.contains("backpressureDrops=0")
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
@Test
|
|
151
|
+
fun secondStartIsANoOp() {
|
|
152
|
+
val logs = Collections.synchronizedList(mutableListOf<String>())
|
|
153
|
+
val sender = UplinkSender(UplinkPacer(), FakeTransport(), clock = { 0L }, log = { logs.add(it) })
|
|
154
|
+
|
|
155
|
+
sender.start()
|
|
156
|
+
sender.start()
|
|
157
|
+
assertThat(sender.isRunning()).isTrue()
|
|
158
|
+
sender.stop()
|
|
159
|
+
sender.stop()
|
|
160
|
+
|
|
161
|
+
assertThat(sender.isRunning()).isFalse()
|
|
162
|
+
assertThat(logs.count { it.contains("sender started") }).isEqualTo(1)
|
|
163
|
+
assertThat(logs.count { it.contains("already started") }).isEqualTo(1)
|
|
164
|
+
assertThat(logs.count { it.contains("sender stopped") }).isEqualTo(1)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.source
|
|
2
|
+
|
|
3
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
4
|
+
import org.junit.Test
|
|
5
|
+
|
|
6
|
+
class AcsInvestigationTest {
|
|
7
|
+
@Test
|
|
8
|
+
fun videoArmDefaultsToWhep() {
|
|
9
|
+
assertThat(AcsInvestigation.videoArm).isEqualTo(VideoSourceArm.WHEP)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
@Test
|
|
13
|
+
fun decoderModeDefaultsToTexture() {
|
|
14
|
+
assertThat(AcsInvestigation.decoderMode).isEqualTo(DecoderMode.TEXTURE)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@Test
|
|
18
|
+
fun zeroCopyDefaultsToOff() {
|
|
19
|
+
assertThat(AcsInvestigation.zeroCopy).isFalse()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@Test
|
|
23
|
+
fun pixelFormatDefaultsToI420() {
|
|
24
|
+
assertThat(AcsInvestigation.pixelFormat).isEqualTo(PixelFormatArm.I420)
|
|
25
|
+
}
|
|
26
|
+
}
|