@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.
Files changed (102) hide show
  1. package/NOTICE +20 -0
  2. package/README.md +412 -0
  3. package/THIRD_PARTY +7 -0
  4. package/android/build.gradle +64 -0
  5. package/android/src/main/AndroidManifest.xml +5 -0
  6. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +80 -0
  7. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +846 -0
  8. package/android/src/main/java/com/mentra/acsmeeting/RemoteRoster.kt +123 -0
  9. package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +90 -0
  10. package/android/src/main/java/com/mentra/acsmeeting/audio/AudioPolicyApplier.kt +176 -0
  11. package/android/src/main/java/com/mentra/acsmeeting/audio/IncomingAudioPump.kt +112 -0
  12. package/android/src/main/java/com/mentra/acsmeeting/audio/IncomingRateProbe.kt +65 -0
  13. package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +108 -0
  14. package/android/src/main/java/com/mentra/acsmeeting/audio/PcmResampler.kt +159 -0
  15. package/android/src/main/java/com/mentra/acsmeeting/audio/PhoneMicCapturer.kt +115 -0
  16. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +302 -0
  17. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +230 -0
  18. package/android/src/main/java/com/mentra/acsmeeting/source/BitmapFont.kt +99 -0
  19. package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +578 -0
  20. package/android/src/main/java/com/mentra/acsmeeting/source/FirstFrameGate.kt +45 -0
  21. package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +101 -0
  22. package/android/src/main/java/com/mentra/acsmeeting/source/MediaListeners.kt +57 -0
  23. package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticFrameFactory.kt +205 -0
  24. package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticI420Source.kt +214 -0
  25. package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +31 -0
  26. package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +41 -0
  27. package/android/src/main/java/com/mentra/acsmeeting/telemetry/ChromaProbe.kt +78 -0
  28. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +281 -0
  29. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +51 -0
  30. package/android/src/main/java/com/mentra/acsmeeting/telemetry/ProcessCpu.kt +15 -0
  31. package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +33 -0
  32. package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +475 -0
  33. package/android/src/main/java/com/mentra/acsmeeting/video/AcsTimestamp.kt +26 -0
  34. package/android/src/main/java/com/mentra/acsmeeting/video/FrameGeometry.kt +22 -0
  35. package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +90 -0
  36. package/android/src/main/java/com/mentra/acsmeeting/video/I420Packer.kt +72 -0
  37. package/android/src/main/java/com/mentra/acsmeeting/video/Nv12FormatSpec.kt +36 -0
  38. package/android/src/main/java/com/mentra/acsmeeting/video/Nv12Packer.kt +51 -0
  39. package/android/src/main/java/com/mentra/acsmeeting/video/SendGate.kt +27 -0
  40. package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +57 -0
  41. package/android/src/test/java/com/mentra/acsmeeting/RemoteRosterTest.kt +40 -0
  42. package/android/src/test/java/com/mentra/acsmeeting/audio/AcsAudioPolicyTest.kt +204 -0
  43. package/android/src/test/java/com/mentra/acsmeeting/audio/AudioPolicyApplierTest.kt +223 -0
  44. package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +12 -0
  45. package/android/src/test/java/com/mentra/acsmeeting/audio/IncomingAudioPumpTest.kt +97 -0
  46. package/android/src/test/java/com/mentra/acsmeeting/audio/IncomingRateProbeTest.kt +78 -0
  47. package/android/src/test/java/com/mentra/acsmeeting/audio/PcmResamplerTest.kt +125 -0
  48. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +201 -0
  49. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +166 -0
  50. package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +26 -0
  51. package/android/src/test/java/com/mentra/acsmeeting/source/BitmapFontTest.kt +22 -0
  52. package/android/src/test/java/com/mentra/acsmeeting/source/FirstFrameGateTest.kt +60 -0
  53. package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +133 -0
  54. package/android/src/test/java/com/mentra/acsmeeting/source/I420PlanesTest.kt +67 -0
  55. package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticFrameFactoryTest.kt +193 -0
  56. package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticI420SourceTest.kt +176 -0
  57. package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +36 -0
  58. package/android/src/test/java/com/mentra/acsmeeting/telemetry/ChromaProbeTest.kt +61 -0
  59. package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +256 -0
  60. package/android/src/test/java/com/mentra/acsmeeting/telemetry/ProcessCpuTest.kt +18 -0
  61. package/android/src/test/java/com/mentra/acsmeeting/telemetry/RingPercentileTest.kt +49 -0
  62. package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +43 -0
  63. package/android/src/test/java/com/mentra/acsmeeting/video/FrameGeometryTest.kt +33 -0
  64. package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +77 -0
  65. package/android/src/test/java/com/mentra/acsmeeting/video/I420PackerTest.kt +126 -0
  66. package/android/src/test/java/com/mentra/acsmeeting/video/Nv12FormatSpecTest.kt +36 -0
  67. package/android/src/test/java/com/mentra/acsmeeting/video/Nv12PackerTest.kt +58 -0
  68. package/android/src/test/java/com/mentra/acsmeeting/video/SendGateTest.kt +79 -0
  69. package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +68 -0
  70. package/build/AcsMeeting.types.d.ts +59 -0
  71. package/build/AcsMeeting.types.d.ts.map +1 -0
  72. package/build/AcsMeeting.types.js +2 -0
  73. package/build/AcsMeeting.types.js.map +1 -0
  74. package/build/AcsMeetingModule.d.ts +15 -0
  75. package/build/AcsMeetingModule.d.ts.map +1 -0
  76. package/build/AcsMeetingModule.js +3 -0
  77. package/build/AcsMeetingModule.js.map +1 -0
  78. package/build/AcsMeetingModule.web.d.ts +16 -0
  79. package/build/AcsMeetingModule.web.d.ts.map +1 -0
  80. package/build/AcsMeetingModule.web.js +31 -0
  81. package/build/AcsMeetingModule.web.js.map +1 -0
  82. package/build/index.d.ts +3 -0
  83. package/build/index.d.ts.map +1 -0
  84. package/build/index.js +3 -0
  85. package/build/index.js.map +1 -0
  86. package/expo-module.config.json +9 -0
  87. package/ios/AcsFrameSender.swift +47 -0
  88. package/ios/AcsMeeting.podspec +27 -0
  89. package/ios/AcsMeetingModule.swift +558 -0
  90. package/ios/PcmBridge.swift +117 -0
  91. package/ios/PhoneMicCapturer.swift +64 -0
  92. package/ios/PolicyKit/Package.swift +17 -0
  93. package/ios/PolicyKit/Sources/AcsAudioPolicy/AcsAudioPolicy.swift +102 -0
  94. package/ios/PolicyKit/Sources/AcsAudioPolicy/AudioPolicyApplier.swift +162 -0
  95. package/ios/PolicyKit/Sources/AcsAudioPolicy/GlassesMediaSource.swift +68 -0
  96. package/ios/PolicyKit/Tests/AcsAudioPolicyTests/AcsAudioPolicyTests.swift +273 -0
  97. package/ios/WhepVideoSource.swift +356 -0
  98. package/package.json +56 -0
  99. package/src/AcsMeeting.types.ts +66 -0
  100. package/src/AcsMeetingModule.ts +16 -0
  101. package/src/AcsMeetingModule.web.ts +33 -0
  102. 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
+ }