@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,22 @@
1
+ package com.mentra.acsmeeting.source
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+ import java.nio.ByteBuffer
6
+
7
+ class BitmapFontTest {
8
+ @Test
9
+ fun drawsOnPixelsForDigitEight() {
10
+ val width = 80
11
+ val height = 40
12
+ val dest = ByteBuffer.allocate(width * height)
13
+ BitmapFont.draw(dest, width, height, "8", 0, 0, 2, 0xEB.toByte())
14
+ var on = 0
15
+ for (i in 0 until dest.capacity()) {
16
+ if (dest.get(i) == 0xEB.toByte()) on += 1
17
+ }
18
+ assertThat(on).isGreaterThan(0)
19
+ assertThat(BitmapFont.textWidth(1, 2)).isEqualTo(5 * 2)
20
+ assertThat(BitmapFont.textHeight(2)).isEqualTo(7 * 2)
21
+ }
22
+ }
@@ -0,0 +1,60 @@
1
+ package com.mentra.acsmeeting.source
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+
6
+ class FirstFrameGateTest {
7
+ @Test
8
+ fun answerAloneDoesNotPromote() {
9
+ val gate = FirstFrameGate()
10
+ gate.arm(1)
11
+ // The answer is armed, not live: nothing has reached the sink yet.
12
+ assertThat(gate.expired(1)).isTrue()
13
+ }
14
+
15
+ @Test
16
+ fun firstFrameForTheArmedGenerationPromotesExactlyOnce() {
17
+ val gate = FirstFrameGate()
18
+ gate.arm(7)
19
+ assertThat(gate.onFrame(7)).isTrue()
20
+ assertThat(gate.onFrame(7)).isFalse()
21
+ assertThat(gate.expired(7)).isFalse()
22
+ }
23
+
24
+ @Test
25
+ fun framesFromAPeerBeingTornDownNeverPromote() {
26
+ val gate = FirstFrameGate()
27
+ gate.arm(2)
28
+ assertThat(gate.onFrame(1)).isFalse()
29
+ assertThat(gate.expired(2)).isTrue()
30
+ }
31
+
32
+ @Test
33
+ fun deadlineForASupersededGenerationDoesNotFailTheNewPeer() {
34
+ val gate = FirstFrameGate()
35
+ gate.arm(3)
36
+ gate.arm(4)
37
+ assertThat(gate.expired(3)).isFalse()
38
+ assertThat(gate.expired(4)).isTrue()
39
+ }
40
+
41
+ @Test
42
+ fun iceRecoveryRearmsSoTheNextFramePromotesAgain() {
43
+ val gate = FirstFrameGate()
44
+ gate.arm(5)
45
+ assertThat(gate.onFrame(5)).isTrue()
46
+ // ICE bounced and came back on the same peer: LIVE has to be re-earned.
47
+ gate.arm(5)
48
+ assertThat(gate.expired(5)).isTrue()
49
+ assertThat(gate.onFrame(5)).isTrue()
50
+ }
51
+
52
+ @Test
53
+ fun resetClearsTheArmSoAStoppedSourceCannotBeFailedLater() {
54
+ val gate = FirstFrameGate()
55
+ gate.arm(6)
56
+ gate.reset()
57
+ assertThat(gate.expired(6)).isFalse()
58
+ assertThat(gate.onFrame(6)).isFalse()
59
+ }
60
+ }
@@ -0,0 +1,133 @@
1
+ package com.mentra.acsmeeting.source
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+
6
+ class FakeGlassesMediaSource : GlassesMediaSource {
7
+ val started = mutableListOf<SourceConfig>()
8
+ val restarted = mutableListOf<SourceConfig>()
9
+ var stopped = 0
10
+ var pcmEnabled = true
11
+ var pcmListener: PcmListener? = null
12
+ val targetSizes = mutableListOf<TargetSize?>()
13
+ override var state: SourceState = SourceState.IDLE
14
+ private set
15
+
16
+ override fun start(config: SourceConfig) {
17
+ started.add(config)
18
+ state = SourceState.CONNECTING
19
+ state = SourceState.LIVE
20
+ }
21
+
22
+ override fun restart(config: SourceConfig) {
23
+ restarted.add(config)
24
+ start(config)
25
+ }
26
+
27
+ override fun stop() {
28
+ stopped += 1
29
+ state = SourceState.IDLE
30
+ }
31
+
32
+ override fun setPcmDeliveryEnabled(enabled: Boolean) {
33
+ pcmEnabled = enabled
34
+ }
35
+
36
+ override fun setTargetSize(size: TargetSize?) {
37
+ targetSizes.add(size)
38
+ }
39
+
40
+ fun emitPcm(pcm: ByteArray, sampleRate: Int = 16_000, channels: Int = 1) {
41
+ if (pcmEnabled && state == SourceState.LIVE) {
42
+ pcmListener?.onPcm(pcm, sampleRate, channels)
43
+ }
44
+ }
45
+ }
46
+
47
+ class GlassesMediaSourceContractTest {
48
+ private fun contract(source: FakeGlassesMediaSource) {
49
+ val pcm = mutableListOf<ByteArray>()
50
+ source.pcmListener = PcmListener { bytes, _, _ -> pcm.add(bytes) }
51
+ val controller = GlassesMediaController { _, listener ->
52
+ source.pcmListener = listener
53
+ source
54
+ }
55
+
56
+ assertThat(controller.state).isEqualTo(SourceState.IDLE)
57
+ controller.attach(
58
+ video = VideoFrameListener { _ -> },
59
+ pcm = { bytes, _, _ -> pcm.add(bytes) },
60
+ config = SourceConfig("https://example.com/whep"),
61
+ )
62
+ assertThat(source.started).containsExactly(SourceConfig("https://example.com/whep"))
63
+ assertThat(controller.state).isEqualTo(SourceState.LIVE)
64
+
65
+ source.emitPcm(byteArrayOf(1, 2, 3, 4))
66
+ assertThat(pcm).hasSize(1)
67
+
68
+ controller.setPcmDeliveryEnabled(false)
69
+ source.emitPcm(byteArrayOf(9, 9))
70
+ assertThat(pcm).hasSize(1)
71
+
72
+ controller.restart(SourceConfig("https://example.com/whep-2"))
73
+ assertThat(source.restarted.map { it.url }).contains("https://example.com/whep-2")
74
+ assertThat(controller.state).isEqualTo(SourceState.LIVE)
75
+
76
+ controller.setTargetSize(TargetSize(1280, 720))
77
+ assertThat(source.targetSizes).contains(TargetSize(1280, 720))
78
+
79
+ controller.stop()
80
+ assertThat(source.stopped).isGreaterThanOrEqualTo(1)
81
+ assertThat(controller.state).isEqualTo(SourceState.IDLE)
82
+ }
83
+
84
+ @Test
85
+ fun fakeSourceHonorsStartRestartStopPcmAndState() {
86
+ contract(FakeGlassesMediaSource())
87
+ }
88
+
89
+ @Test
90
+ fun sameUrlIsReusedUntilTheSubscriberFails() {
91
+ val url = "https://example.com/whep"
92
+ val config = SourceConfig(url)
93
+ // A resumed glasses uplink re-sends the same URL. ACS never lost its
94
+ // subscription, so rebuilding would only restart the wait for a first frame.
95
+ assertThat(canReuseSource(url, SourceState.LIVE, config)).isTrue()
96
+ // Includes the answer → first frame window, which LIVE no longer covers.
97
+ assertThat(canReuseSource(url, SourceState.CONNECTING, config)).isTrue()
98
+ // ICE dropped, or the answer never produced a frame: rebuild.
99
+ assertThat(canReuseSource(url, SourceState.FAILED, config)).isFalse()
100
+ // A new stream means a new WHEP URL, and a stopped source has none.
101
+ assertThat(canReuseSource(url, SourceState.LIVE, SourceConfig("https://example.com/whep-2"))).isFalse()
102
+ assertThat(canReuseSource(null, SourceState.IDLE, config)).isFalse()
103
+ assertThat(canReuseSource(url, SourceState.LIVE, SourceConfig(url, SourceKind.DIRECT))).isFalse()
104
+ }
105
+
106
+ @Test
107
+ fun syntheticSourceHonorsStartRestartStopAndState() {
108
+ val source = SyntheticI420Source(
109
+ video = { _ -> },
110
+ stats = com.mentra.acsmeeting.telemetry.PipelineStats(),
111
+ isReady = { false },
112
+ )
113
+ val controller = GlassesMediaController { _, _ -> source }
114
+
115
+ assertThat(controller.state).isEqualTo(SourceState.IDLE)
116
+ controller.attach(
117
+ video = { _ -> },
118
+ pcm = { _, _, _ -> },
119
+ config = SourceConfig("https://example.com/whep", SourceKind.DIRECT),
120
+ )
121
+ assertThat(controller.state).isEqualTo(SourceState.LIVE)
122
+
123
+ controller.setPcmDeliveryEnabled(false)
124
+ assertThat(source.pcmDeliveryEnabled()).isFalse()
125
+
126
+ controller.restart(SourceConfig("https://example.com/whep-2", SourceKind.DIRECT))
127
+ assertThat(controller.state).isEqualTo(SourceState.LIVE)
128
+
129
+ controller.setTargetSize(TargetSize(1280, 720))
130
+ controller.stop()
131
+ assertThat(controller.state).isEqualTo(SourceState.IDLE)
132
+ }
133
+ }
@@ -0,0 +1,67 @@
1
+ package com.mentra.acsmeeting.source
2
+
3
+ import com.mentra.acsmeeting.video.I420Packer
4
+ import org.assertj.core.api.Assertions.assertThat
5
+ import org.junit.Test
6
+ import java.nio.ByteBuffer
7
+
8
+ class I420PlanesTest {
9
+ @Test
10
+ fun tightWhenStridesMatchWidth() {
11
+ val planes = planes(width = 8, height = 4, strideY = 8, strideUv = 4, direct = false)
12
+ assertThat(planes.isTight()).isTrue()
13
+ assertThat(planes.planesReadable()).isTrue()
14
+ }
15
+
16
+ @Test
17
+ fun paddedWhenStrideExceedsWidth() {
18
+ val planes = planes(width = 8, height = 4, strideY = 16, strideUv = 8, direct = false)
19
+ assertThat(planes.isTight()).isFalse()
20
+ assertThat(planes.planesReadable()).isTrue()
21
+ }
22
+
23
+ @Test
24
+ fun heapBuffersAreNotDirect() {
25
+ val planes = planes(width = 4, height = 4, strideY = 4, strideUv = 2, direct = false)
26
+ assertThat(planes.isDirect()).isFalse()
27
+ }
28
+
29
+ @Test
30
+ fun directBuffersReportDirect() {
31
+ val planes = planes(width = 4, height = 4, strideY = 4, strideUv = 2, direct = true)
32
+ assertThat(planes.isDirect()).isTrue()
33
+ }
34
+
35
+ @Test
36
+ fun shortPlaneIsNotReadable() {
37
+ val y = ByteBuffer.allocate(4)
38
+ val u = ByteBuffer.allocate(2)
39
+ val v = ByteBuffer.allocate(2)
40
+ val planes = I420Planes(y, 8, u, 4, v, 4, 8, 4, 0L)
41
+ assertThat(planes.planesReadable()).isFalse()
42
+ assertThat(I420Packer.planeMinBytes(8, 8, 4)).isEqualTo(32)
43
+ }
44
+
45
+ private fun planes(
46
+ width: Int,
47
+ height: Int,
48
+ strideY: Int,
49
+ strideUv: Int,
50
+ direct: Boolean,
51
+ ): I420Planes {
52
+ val chromaH = I420Packer.chromaStride(height)
53
+ fun alloc(bytes: Int): ByteBuffer =
54
+ if (direct) ByteBuffer.allocateDirect(bytes) else ByteBuffer.allocate(bytes)
55
+ return I420Planes(
56
+ y = alloc(strideY * height),
57
+ strideY = strideY,
58
+ u = alloc(strideUv * chromaH),
59
+ strideU = strideUv,
60
+ v = alloc(strideUv * chromaH),
61
+ strideV = strideUv,
62
+ width = width,
63
+ height = height,
64
+ timestampNs = 0L,
65
+ )
66
+ }
67
+ }
@@ -0,0 +1,193 @@
1
+ package com.mentra.acsmeeting.source
2
+
3
+ import com.mentra.acsmeeting.telemetry.ChromaProbe
4
+ import com.mentra.acsmeeting.video.I420Packer
5
+ import org.assertj.core.api.Assertions.assertThat
6
+ import org.junit.Test
7
+ import java.nio.ByteBuffer
8
+
9
+ class SyntheticFrameFactoryTest {
10
+ @Test
11
+ fun packedLengthMatchesI420() {
12
+ val dest = buffer(WIDTH, HEIGHT)
13
+ SyntheticFrameFactory().write(dest, WIDTH, HEIGHT, 0)
14
+ assertThat(dest.remaining()).isEqualTo(I420Packer.packedSize(WIDTH, HEIGHT))
15
+ }
16
+
17
+ /**
18
+ * Regression for the 1.4 fps wire collapse: a noisy CHEAP background is
19
+ * incompressible and starves the ACS encoder. CHEAP must stay flat.
20
+ */
21
+ @Test
22
+ fun cheapBackgroundStaysCompressible() {
23
+ val dest = buffer(WIDTH, HEIGHT)
24
+ SyntheticFrameFactory(SyntheticEntropy.CHEAP).write(dest, WIDTH, HEIGHT, 0)
25
+ val y = yBytes(dest)
26
+ var background = 0
27
+ for (row in HEIGHT - 40 until HEIGHT) {
28
+ val rowOff = row * WIDTH
29
+ for (col in 0 until WIDTH) {
30
+ if (y[rowOff + col] == SyntheticFrameFactory.BG_Y) background += 1
31
+ }
32
+ }
33
+ assertThat(background).isGreaterThan(30 * WIDTH)
34
+ }
35
+
36
+ @Test
37
+ fun consecutiveCheapFramesDifferInY() {
38
+ val a = buffer(WIDTH, HEIGHT)
39
+ val b = buffer(WIDTH, HEIGHT)
40
+ val factory = SyntheticFrameFactory(SyntheticEntropy.CHEAP)
41
+ factory.write(a, WIDTH, HEIGHT, 0)
42
+ factory.write(b, WIDTH, HEIGHT, 1)
43
+ assertThat(yBytes(a)).isNotEqualTo(yBytes(b))
44
+ }
45
+
46
+ @Test
47
+ fun cheapIsDeterministicForAGivenIndex() {
48
+ val a = buffer(WIDTH, HEIGHT)
49
+ val b = buffer(WIDTH, HEIGHT)
50
+ SyntheticFrameFactory(SyntheticEntropy.CHEAP).write(a, WIDTH, HEIGHT, 7)
51
+ SyntheticFrameFactory(SyntheticEntropy.CHEAP).write(b, WIDTH, HEIGHT, 7)
52
+ assertThat(copy(a)).isEqualTo(copy(b))
53
+ }
54
+
55
+ @Test
56
+ fun incrementalCheapMatchesFreshWrite() {
57
+ val stepped = buffer(WIDTH, HEIGHT)
58
+ val fresh = buffer(WIDTH, HEIGHT)
59
+ val factory = SyntheticFrameFactory(SyntheticEntropy.CHEAP)
60
+ factory.write(stepped, WIDTH, HEIGHT, 0)
61
+ factory.write(stepped, WIDTH, HEIGHT, 1)
62
+ SyntheticFrameFactory(SyntheticEntropy.CHEAP).write(fresh, WIDTH, HEIGHT, 1)
63
+ assertThat(copy(stepped)).isEqualTo(copy(fresh))
64
+ }
65
+
66
+ @Test
67
+ fun chromaAveragesLandAt128() {
68
+ for (entropy in SyntheticEntropy.entries) {
69
+ val dest = buffer(WIDTH, HEIGHT)
70
+ SyntheticFrameFactory(entropy).write(dest, WIDTH, HEIGHT, 3)
71
+ val sample = ChromaProbe.samplePacked(dest, WIDTH, HEIGHT)
72
+ assertThat(sample.u).isEqualTo(128)
73
+ assertThat(sample.v).isEqualTo(128)
74
+ }
75
+ }
76
+
77
+ @Test
78
+ fun motionPansAlmostEveryPixel() {
79
+ val a = buffer(WIDTH, HEIGHT)
80
+ val b = buffer(WIDTH, HEIGHT)
81
+ val factory = SyntheticFrameFactory(SyntheticEntropy.MOTION)
82
+ factory.write(a, WIDTH, HEIGHT, 0)
83
+ factory.write(b, WIDTH, HEIGHT, 1)
84
+ val ya = yBytes(a)
85
+ val yb = yBytes(b)
86
+ var differ = 0
87
+ for (i in ya.indices) if (ya[i] != yb[i]) differ += 1
88
+ assertThat(differ).isGreaterThan(ya.size / 2)
89
+ }
90
+
91
+ /**
92
+ * Glasses motion still has neighbors that look alike. A 32×32 patch in the
93
+ * corner must stay a gradient, not snow — that is what lets an I-frame fit
94
+ * the bitrate budget.
95
+ */
96
+ @Test
97
+ fun motionKeepsSpatialStructure() {
98
+ val dest = buffer(WIDTH, HEIGHT)
99
+ SyntheticFrameFactory(SyntheticEntropy.MOTION).write(dest, WIDTH, HEIGHT, 0)
100
+ val y = yBytes(dest)
101
+ val unique = mutableSetOf<Byte>()
102
+ for (row in HEIGHT - 80 until HEIGHT - 48) {
103
+ val rowOff = row * WIDTH
104
+ for (col in 0 until 32) unique.add(y[rowOff + col])
105
+ }
106
+ assertThat(unique.size).isLessThan(40)
107
+ }
108
+
109
+ @Test
110
+ fun motionIsDeterministicForAGivenIndex() {
111
+ val a = buffer(WIDTH, HEIGHT)
112
+ val b = buffer(WIDTH, HEIGHT)
113
+ SyntheticFrameFactory(SyntheticEntropy.MOTION).write(a, WIDTH, HEIGHT, 11)
114
+ SyntheticFrameFactory(SyntheticEntropy.MOTION).write(b, WIDTH, HEIGHT, 11)
115
+ assertThat(copy(a)).isEqualTo(copy(b))
116
+ }
117
+
118
+ @Test
119
+ fun noiseRewritesTheFullYPlane() {
120
+ val a = buffer(WIDTH, HEIGHT)
121
+ val b = buffer(WIDTH, HEIGHT)
122
+ val factory = SyntheticFrameFactory(SyntheticEntropy.NOISE)
123
+ factory.write(a, WIDTH, HEIGHT, 0)
124
+ factory.write(b, WIDTH, HEIGHT, 1)
125
+ val ya = yBytes(a)
126
+ val yb = yBytes(b)
127
+ var differ = 0
128
+ for (i in ya.indices) if (ya[i] != yb[i]) differ += 1
129
+ assertThat(differ).isGreaterThan(ya.size / 2)
130
+ }
131
+
132
+ @Test
133
+ fun stampPaintsFrameIdAndFpsOnADarkBox() {
134
+ val dest = buffer(WIDTH, HEIGHT)
135
+ SyntheticFrameFactory(SyntheticEntropy.CHEAP).write(dest, WIDTH, HEIGHT, 42, 15.0)
136
+ val y = yBytes(dest)
137
+ val boxX = WIDTH / 16
138
+ val boxY = HEIGHT / 2 - 80
139
+ var dark = 0
140
+ var bright = 0
141
+ for (row in boxY until boxY + 160) {
142
+ val rowOff = row * WIDTH
143
+ for (col in boxX until boxX + 400) {
144
+ val value = y[rowOff + col].toInt() and 0xFF
145
+ if (value <= 20) dark += 1
146
+ if (value >= 220) bright += 1
147
+ }
148
+ }
149
+ assertThat(dark).isGreaterThan(1_000)
150
+ assertThat(bright).isGreaterThan(200)
151
+ }
152
+
153
+ @Test
154
+ fun stampFrameIndexChangesTheOverlay() {
155
+ val a = buffer(WIDTH, HEIGHT)
156
+ val b = buffer(WIDTH, HEIGHT)
157
+ SyntheticFrameFactory(SyntheticEntropy.CHEAP).write(a, WIDTH, HEIGHT, 1, 15.0)
158
+ SyntheticFrameFactory(SyntheticEntropy.CHEAP).write(b, WIDTH, HEIGHT, 2, 15.0)
159
+ assertThat(copy(a)).isNotEqualTo(copy(b))
160
+ }
161
+
162
+ @Test
163
+ fun noiseIsDeterministicForAGivenIndex() {
164
+ val a = buffer(WIDTH, HEIGHT)
165
+ val b = buffer(WIDTH, HEIGHT)
166
+ SyntheticFrameFactory(SyntheticEntropy.NOISE).write(a, WIDTH, HEIGHT, 11)
167
+ SyntheticFrameFactory(SyntheticEntropy.NOISE).write(b, WIDTH, HEIGHT, 11)
168
+ assertThat(copy(a)).isEqualTo(copy(b))
169
+ }
170
+
171
+ private fun buffer(width: Int, height: Int): ByteBuffer =
172
+ ByteBuffer.allocate(I420Packer.packedSize(width, height))
173
+
174
+ private fun yBytes(src: ByteBuffer): ByteArray {
175
+ val view = src.duplicate()
176
+ view.position(0)
177
+ val y = ByteArray(WIDTH * HEIGHT)
178
+ view.get(y)
179
+ return y
180
+ }
181
+
182
+ private fun copy(src: ByteBuffer): ByteArray {
183
+ val view = src.duplicate()
184
+ val out = ByteArray(view.remaining())
185
+ view.get(out)
186
+ return out
187
+ }
188
+
189
+ companion object {
190
+ private const val WIDTH = 1280
191
+ private const val HEIGHT = 720
192
+ }
193
+ }
@@ -0,0 +1,176 @@
1
+ package com.mentra.acsmeeting.source
2
+
3
+ import com.mentra.acsmeeting.telemetry.PipelineStats
4
+ import com.mentra.acsmeeting.video.I420Packer
5
+ import org.assertj.core.api.Assertions.assertThat
6
+ import org.junit.Test
7
+ import java.util.concurrent.atomic.AtomicBoolean
8
+
9
+ class SyntheticI420SourceTest {
10
+ @Test
11
+ fun withheldWhileNotReadyThenEmitsOnceReady() {
12
+ val ready = AtomicBoolean(false)
13
+ val frames = ArrayList<Triple<Int, Int, Int>>()
14
+ val stats = PipelineStats()
15
+ val source = source(stats, ready) { planes ->
16
+ frames.add(Triple(I420Planes.remaining(planes.y) + I420Planes.remaining(planes.u) + I420Planes.remaining(planes.v), planes.width, planes.height))
17
+ }
18
+ source.becomeLive()
19
+ source.setTargetSize(TargetSize(1280, 720))
20
+
21
+ assertThat(source.emitOnce()).isFalse()
22
+ assertThat(frames).isEmpty()
23
+ assertThat(stats.sinkCount()).isEqualTo(0)
24
+
25
+ ready.set(true)
26
+ assertThat(source.emitOnce()).isTrue()
27
+ assertThat(frames).hasSize(1)
28
+ assertThat(frames[0].second).isEqualTo(1280)
29
+ assertThat(frames[0].third).isEqualTo(720)
30
+ assertThat(frames[0].first).isEqualTo(I420Packer.packedSize(1280, 720))
31
+ assertThat(stats.sinkCount()).isEqualTo(1)
32
+ assertThat(source.events.any { it.startsWith("P6 synthetic first-frame") }).isTrue()
33
+ }
34
+
35
+ @Test
36
+ fun withheldWhileTargetSizeIsNull() {
37
+ val frames = ArrayList<Int>()
38
+ val stats = PipelineStats()
39
+ val source = source(stats, AtomicBoolean(true)) { _ -> frames.add(1) }
40
+ source.becomeLive()
41
+
42
+ assertThat(source.emitOnce()).isFalse()
43
+ assertThat(frames).isEmpty()
44
+ assertThat(stats.sinkCount()).isEqualTo(0)
45
+
46
+ source.setTargetSize(TargetSize(640, 360))
47
+ assertThat(source.emitOnce()).isTrue()
48
+ assertThat(frames).hasSize(1)
49
+ }
50
+
51
+ @Test
52
+ fun nReadyCallsDeliverNFramesAndHonorResize() {
53
+ val frames = ArrayList<Pair<Int, Int>>()
54
+ val stats = PipelineStats()
55
+ val source = source(stats, AtomicBoolean(true)) { planes -> frames.add(planes.width to planes.height) }
56
+ source.becomeLive()
57
+ source.setTargetSize(TargetSize(1280, 720))
58
+ repeat(4) { source.emitOnce() }
59
+ source.setTargetSize(TargetSize(640, 360))
60
+ source.emitOnce()
61
+
62
+ assertThat(frames).containsExactly(
63
+ 1280 to 720,
64
+ 1280 to 720,
65
+ 1280 to 720,
66
+ 1280 to 720,
67
+ 640 to 360,
68
+ )
69
+ assertThat(stats.sinkCount()).isEqualTo(5)
70
+ }
71
+
72
+ @Test
73
+ fun withheldTicksTouchNoCounters() {
74
+ val stats = PipelineStats()
75
+ val source = source(stats, AtomicBoolean(false)) { _ -> }
76
+ source.becomeLive()
77
+ source.setTargetSize(TargetSize(1280, 720))
78
+ repeat(5) { source.emitOnce() }
79
+ assertThat(stats.sinkCount()).isEqualTo(0)
80
+ assertThat(stats.dropCount()).isEqualTo(0)
81
+ assertThat(stats.subCount()).isEqualTo(0)
82
+ assertThat(stats.pack.p95()).isEqualTo("na")
83
+ }
84
+
85
+ @Test
86
+ fun readinessFlapProducesOnePauseAndOneResume() {
87
+ val ready = AtomicBoolean(true)
88
+ val source = source(PipelineStats(), ready) { _ -> }
89
+ source.becomeLive()
90
+ source.setTargetSize(TargetSize(1280, 720))
91
+ source.emitOnce()
92
+ ready.set(false)
93
+ source.emitOnce()
94
+ ready.set(true)
95
+ source.emitOnce()
96
+
97
+ val pauses = source.events.filter { it.contains("paused") }
98
+ val resumes = source.events.filter { it.contains("resumed") }
99
+ assertThat(pauses).hasSize(1)
100
+ assertThat(resumes).hasSize(1)
101
+ }
102
+
103
+ @Test
104
+ fun stopEmitsNothingAndRestartResetsIndex() {
105
+ val indexes = ArrayList<Int>()
106
+ val ready = AtomicBoolean(true)
107
+ val source = source(PipelineStats(), ready) { planes ->
108
+ indexes.add(yCorner(planes))
109
+ }
110
+ source.becomeLive()
111
+ source.setTargetSize(TargetSize(64, 64))
112
+ source.emitOnce()
113
+ source.emitOnce()
114
+ source.stop()
115
+ assertThat(source.state).isEqualTo(SourceState.IDLE)
116
+ assertThat(source.emitOnce()).isFalse()
117
+
118
+ source.becomeLive()
119
+ source.setTargetSize(TargetSize(64, 64))
120
+ source.emitOnce()
121
+ assertThat(indexes).hasSize(3)
122
+ assertThat(indexes[0]).isEqualTo(indexes[2])
123
+ }
124
+
125
+ @Test
126
+ fun restartLeavesSourceLive() {
127
+ val source = source(PipelineStats(), AtomicBoolean(false)) { _ -> }
128
+ source.start(SourceConfig("ignored", SourceKind.DIRECT))
129
+ source.restart(SourceConfig("ignored-2", SourceKind.DIRECT))
130
+ assertThat(source.state).isEqualTo(SourceState.LIVE)
131
+ source.stop()
132
+ assertThat(source.state).isEqualTo(SourceState.IDLE)
133
+ }
134
+
135
+ @Test
136
+ fun pcmListenerIsNeverInvoked() {
137
+ val source = SyntheticI420Source(
138
+ video = { _ -> },
139
+ stats = PipelineStats(),
140
+ isReady = { true },
141
+ )
142
+ source.setPcmDeliveryEnabled(true)
143
+ assertThat(source.pcmDeliveryEnabled()).isTrue()
144
+ source.setPcmDeliveryEnabled(false)
145
+ assertThat(source.pcmDeliveryEnabled()).isFalse()
146
+ }
147
+
148
+ private fun source(
149
+ stats: PipelineStats,
150
+ ready: AtomicBoolean,
151
+ video: VideoFrameListener,
152
+ ): SyntheticI420Source = SyntheticI420Source(
153
+ video = video,
154
+ stats = stats,
155
+ isReady = { ready.get() },
156
+ )
157
+
158
+ @Test
159
+ fun emitsTightStridePlanesAliasingThePackedBuffer() {
160
+ val seen = ArrayList<I420Planes>()
161
+ val source = source(PipelineStats(), AtomicBoolean(true)) { seen.add(it) }
162
+ source.becomeLive()
163
+ source.setTargetSize(TargetSize(64, 64))
164
+ assertThat(source.emitOnce()).isTrue()
165
+ val planes = seen.single()
166
+ assertThat(planes.isTight()).isTrue()
167
+ assertThat(planes.retain == null).isTrue()
168
+ assertThat(planes.release == null).isTrue()
169
+ assertThat(I420Planes.remaining(planes.y)).isEqualTo(64 * 64)
170
+ assertThat(I420Planes.remaining(planes.u)).isEqualTo(32 * 32)
171
+ }
172
+
173
+ private fun yCorner(planes: I420Planes): Int {
174
+ return planes.y.duplicate().get(0).toInt() and 0xFF
175
+ }
176
+ }
@@ -0,0 +1,36 @@
1
+ package com.mentra.acsmeeting.source
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+
6
+ class TrackRegistryTest {
7
+ @Test
8
+ fun threeClaimsOfOneIdYieldTrueFalseFalse() {
9
+ val registry = TrackRegistry()
10
+ assertThat(registry.claim("track-a")).isTrue()
11
+ assertThat(registry.claim("track-a")).isFalse()
12
+ assertThat(registry.claim("track-a")).isFalse()
13
+ assertThat(registry.size()).isEqualTo(1)
14
+ assertThat(registry.skipped()).isEqualTo(2)
15
+ }
16
+
17
+ @Test
18
+ fun distinctIdsEachClaimOnce() {
19
+ val registry = TrackRegistry()
20
+ assertThat(registry.claim("video")).isTrue()
21
+ assertThat(registry.claim("audio")).isTrue()
22
+ assertThat(registry.size()).isEqualTo(2)
23
+ assertThat(registry.skipped()).isEqualTo(0)
24
+ }
25
+
26
+ @Test
27
+ fun resetMakesTheSameIdClaimableAgain() {
28
+ val registry = TrackRegistry()
29
+ assertThat(registry.claim("track-a")).isTrue()
30
+ registry.reset()
31
+ assertThat(registry.size()).isEqualTo(0)
32
+ assertThat(registry.skipped()).isEqualTo(0)
33
+ assertThat(registry.claim("track-a")).isTrue()
34
+ assertThat(registry.size()).isEqualTo(1)
35
+ }
36
+ }