@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,36 @@
1
+ package com.mentra.acsmeeting.video
2
+
3
+ /**
4
+ * The NV12 format we advertise to ACS, as plain values.
5
+ *
6
+ * Same split as [I420FormatSpec]: VideoStreamFormat is native-backed and
7
+ * cannot initialize under a JVM unit test. Stride2 is the interleaved UV
8
+ * row (`chromaW * 2`), not a third plane.
9
+ */
10
+ data class Nv12FormatSpec(
11
+ val width: Int,
12
+ val height: Int,
13
+ val fps: Float,
14
+ val strideY: Int,
15
+ val strideUv: Int,
16
+ ) {
17
+ companion object {
18
+ fun of(width: Int = 1280, height: Int = 720, fps: Float = 15f): Nv12FormatSpec =
19
+ Nv12FormatSpec(
20
+ width = width,
21
+ height = height,
22
+ fps = fps,
23
+ strideY = width,
24
+ strideUv = Nv12Packer.strideUv(width),
25
+ )
26
+ }
27
+
28
+ fun withinAcsBounds(): Boolean {
29
+ val probe = I420FormatSpec.of(width, height, fps)
30
+ return probe.withinAcsBounds()
31
+ }
32
+
33
+ fun namedResolution(): AcsNamedResolution? = I420FormatSpec.of(width, height, fps).namedResolution()
34
+
35
+ fun packedSize(): Int = strideY * height + strideUv * Nv12Packer.chromaHeight(height)
36
+ }
@@ -0,0 +1,51 @@
1
+ package com.mentra.acsmeeting.video
2
+
3
+ import java.nio.ByteBuffer
4
+
5
+ /**
6
+ * I420 → NV12. ACS NV12 is two planes: tight Y, then interleaved UV with
7
+ * [strideUv] = chromaW * 2. Pure so JVM tests stay cheap.
8
+ */
9
+ object Nv12Packer {
10
+ fun chromaHeight(height: Int): Int = I420Packer.chromaStride(height)
11
+
12
+ fun strideUv(width: Int): Int = I420Packer.chromaStride(width) * 2
13
+
14
+ fun uvSize(width: Int, height: Int): Int = strideUv(width) * chromaHeight(height)
15
+
16
+ fun packedSize(width: Int, height: Int): Int = width * height + uvSize(width, height)
17
+
18
+ fun copyY(src: ByteBuffer, strideY: Int, width: Int, height: Int, dest: ByteBuffer) {
19
+ I420Packer.copyPlane(src, strideY, width, height, dest)
20
+ }
21
+
22
+ /**
23
+ * Writes UVUV… rows into [dest]. Last visible chroma column is [chromaW];
24
+ * [strideUv] must be `chromaW * 2`.
25
+ */
26
+ fun interleaveUv(
27
+ u: ByteBuffer,
28
+ strideU: Int,
29
+ v: ByteBuffer,
30
+ strideV: Int,
31
+ chromaW: Int,
32
+ chromaH: Int,
33
+ dest: ByteBuffer,
34
+ ) {
35
+ val uView = u.duplicate()
36
+ val vView = v.duplicate()
37
+ val uOrigin = uView.position()
38
+ val vOrigin = vView.position()
39
+ val row = ByteArray(chromaW * 2)
40
+ for (r in 0 until chromaH) {
41
+ var di = 0
42
+ val uRow = uOrigin + r * strideU
43
+ val vRow = vOrigin + r * strideV
44
+ for (c in 0 until chromaW) {
45
+ row[di++] = uView.get(uRow + c)
46
+ row[di++] = vView.get(vRow + c)
47
+ }
48
+ dest.put(row)
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,27 @@
1
+ package com.mentra.acsmeeting.video
2
+
3
+ import java.util.concurrent.atomic.AtomicBoolean
4
+ import java.util.concurrent.atomic.AtomicInteger
5
+
6
+ /**
7
+ * Single in-flight send. No time gate: every unique frame reaches ACS unless
8
+ * the previous send is still running. [busyCount] is the only backpressure signal.
9
+ */
10
+ class SendGate {
11
+ private val sending = AtomicBoolean(false)
12
+ private val busy = AtomicInteger(0)
13
+
14
+ fun tryAcquire(): Boolean {
15
+ if (sending.compareAndSet(false, true)) return true
16
+ busy.incrementAndGet()
17
+ return false
18
+ }
19
+
20
+ fun release() {
21
+ sending.set(false)
22
+ }
23
+
24
+ fun busyCount(): Int = busy.get()
25
+
26
+ fun isHeld(): Boolean = sending.get()
27
+ }
@@ -0,0 +1,57 @@
1
+ package com.mentra.acsmeeting.video
2
+
3
+ /**
4
+ * The one place that decides what we send ACS: geometry, rate, and bitrate
5
+ * ceiling. The declared format, the outgoing constraints, and the source target
6
+ * size all read from here so they cannot drift apart.
7
+ *
8
+ * Drift is not cosmetic. ACS runs a rate controller over a fixed bitrate
9
+ * budget, and its only levers are quality and dropped frames. If we advertise
10
+ * 30 fps and send 15, it reserves budget for frames that never arrive; if we
11
+ * advertise a rate we cannot sustain, it does the same. Either way the wire
12
+ * rate collapses while our own counters look healthy.
13
+ */
14
+ data class VideoProfile(
15
+ val width: Int,
16
+ val height: Int,
17
+ val fps: Int,
18
+ val maxBitrateBps: Int,
19
+ ) {
20
+ fun spec(): I420FormatSpec = I420FormatSpec.of(width, height, fps.toFloat())
21
+
22
+ /** Bits available per frame at the ceiling. Below ~20 kbit the encoder starts dropping. */
23
+ fun bitsPerFrame(): Int = maxBitrateBps / fps
24
+
25
+ companion object {
26
+ /**
27
+ * 720p15. ACS encodes this in software on mid-range hardware (the media
28
+ * statistics report `codecName: "h264 sw"`), so the pixel count is a real
29
+ * CPU cost, not just a bitrate cost.
30
+ */
31
+ val HD = VideoProfile(width = 1280, height = 720, fps = 15, maxBitrateBps = 2_500_000)
32
+
33
+ /** Quarter the pixels of [HD]. Use when the software encoder cannot hold 15 fps at 720p. */
34
+ val SD = VideoProfile(width = 640, height = 360, fps = 15, maxBitrateBps = 1_000_000)
35
+
36
+ /** Investigation flip. Ships as HD. SD is 360p — a quarter of the encode work. */
37
+ val DEFAULT = HD
38
+
39
+ /**
40
+ * ACS P540. VirtualOutgoingVideoStream documents 960×540, not 540×960.
41
+ * 540p A/B uses this size on both WHIP and ACS.
42
+ */
43
+ val P540 = VideoProfile(width = 960, height = 540, fps = 30, maxBitrateBps = 1_500_000)
44
+
45
+ /**
46
+ * Same P540 geometry at 15 fps. Selectable from Mentra Call; not the default.
47
+ * Isolates pixel-count savings without doubling frame work versus [P540].
48
+ */
49
+ val P540_15 = VideoProfile(width = 960, height = 540, fps = 15, maxBitrateBps = 1_500_000)
50
+
51
+ fun parse(width: Int, height: Int, fps: Int, maxBitrateBps: Int): VideoProfile? {
52
+ if (maxBitrateBps <= 0) return null
53
+ I420FormatSpec.parseOrNull(width, height, fps.toFloat()) ?: return null
54
+ return VideoProfile(width, height, fps, maxBitrateBps)
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,40 @@
1
+ package com.mentra.acsmeeting
2
+
3
+ import com.azure.android.communication.calling.AudioStreamSampleRate
4
+ import com.azure.android.communication.calling.ParticipantState
5
+ import org.assertj.core.api.Assertions.assertThat
6
+ import org.junit.Test
7
+
8
+ class RemoteRosterTest {
9
+ @Test
10
+ fun participantStatesMapToHostVocabulary() {
11
+ assertThat(RemoteRoster.stateName(ParticipantState.CONNECTED)).isEqualTo("connected")
12
+ assertThat(RemoteRoster.stateName(ParticipantState.IN_LOBBY)).isEqualTo("lobby")
13
+ assertThat(RemoteRoster.stateName(ParticipantState.CONNECTING)).isEqualTo("connecting")
14
+ assertThat(RemoteRoster.stateName(ParticipantState.RINGING)).isEqualTo("connecting")
15
+ assertThat(RemoteRoster.stateName(ParticipantState.EARLY_MEDIA)).isEqualTo("connecting")
16
+ assertThat(RemoteRoster.stateName(ParticipantState.HOLD)).isEqualTo("hold")
17
+ assertThat(RemoteRoster.stateName(ParticipantState.DISCONNECTED)).isEqualTo("disconnected")
18
+ assertThat(RemoteRoster.stateName(ParticipantState.IDLE)).isEqualTo("idle")
19
+ assertThat(RemoteRoster.stateName(null)).isEqualTo("idle")
20
+ }
21
+
22
+ @Test
23
+ fun emptyRosterSnapshotsToEmptyList() {
24
+ val roster = RemoteRoster {}
25
+ assertThat(roster.snapshot()).isEmpty()
26
+ roster.detach()
27
+ assertThat(roster.snapshot()).isEmpty()
28
+ }
29
+
30
+ @Test
31
+ fun acsSampleRatesMapToHz() {
32
+ assertThat(AcsMeetingSession.sampleRateHz(AudioStreamSampleRate.HZ_16000)).isEqualTo(16000)
33
+ assertThat(AcsMeetingSession.sampleRateHz(AudioStreamSampleRate.HZ_22050)).isEqualTo(22050)
34
+ assertThat(AcsMeetingSession.sampleRateHz(AudioStreamSampleRate.HZ_24000)).isEqualTo(24000)
35
+ assertThat(AcsMeetingSession.sampleRateHz(AudioStreamSampleRate.HZ_32000)).isEqualTo(32000)
36
+ assertThat(AcsMeetingSession.sampleRateHz(AudioStreamSampleRate.HZ_44100)).isEqualTo(44100)
37
+ assertThat(AcsMeetingSession.sampleRateHz(AudioStreamSampleRate.HZ_48000)).isEqualTo(48000)
38
+ assertThat(AcsMeetingSession.sampleRateHz(null)).isNull()
39
+ }
40
+ }
@@ -0,0 +1,204 @@
1
+ package com.mentra.acsmeeting.audio
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+ import org.junit.runner.RunWith
6
+ import org.junit.runners.Parameterized
7
+
8
+ class AcsAudioPolicyTest {
9
+ @Test
10
+ fun glassesVirtualUnmuted_sendsPcmAndLeavesPhysicalAlone() {
11
+ assertRow(AudioSourceKind.GLASSES, ActiveStreamKind.VIRTUAL, false, true, false, PhysicalMuteAction.LEAVE_ALONE)
12
+ }
13
+
14
+ @Test
15
+ fun glassesVirtualMuted_gatesPcmAndLeavesPhysicalAlone() {
16
+ assertRow(AudioSourceKind.GLASSES, ActiveStreamKind.VIRTUAL, true, false, false, PhysicalMuteAction.LEAVE_ALONE)
17
+ }
18
+
19
+ @Test
20
+ fun glassesLocalUnmuted_mutesHandsetAndGatesPcm() {
21
+ assertRow(AudioSourceKind.GLASSES, ActiveStreamKind.LOCAL, false, false, false, PhysicalMuteAction.MUTE)
22
+ }
23
+
24
+ @Test
25
+ fun glassesLocalMuted_mutesHandsetAndGatesPcm() {
26
+ assertRow(AudioSourceKind.GLASSES, ActiveStreamKind.LOCAL, true, false, false, PhysicalMuteAction.MUTE)
27
+ }
28
+
29
+ @Test
30
+ fun glassesNoneUnmuted_holdsSilence() {
31
+ assertRow(AudioSourceKind.GLASSES, ActiveStreamKind.NONE, false, false, false, PhysicalMuteAction.LEAVE_ALONE)
32
+ }
33
+
34
+ @Test
35
+ fun glassesNoneMuted_holdsSilence() {
36
+ assertRow(AudioSourceKind.GLASSES, ActiveStreamKind.NONE, true, false, false, PhysicalMuteAction.LEAVE_ALONE)
37
+ }
38
+
39
+ @Test
40
+ fun phoneLocalUnmuted_unmutesHandsetAndGatesPhonePcm() {
41
+ assertRow(AudioSourceKind.PHONE, ActiveStreamKind.LOCAL, false, false, false, PhysicalMuteAction.UNMUTE)
42
+ }
43
+
44
+ @Test
45
+ fun phoneLocalMuted_mutesHandsetAndGatesPhonePcm() {
46
+ assertRow(AudioSourceKind.PHONE, ActiveStreamKind.LOCAL, true, false, false, PhysicalMuteAction.MUTE)
47
+ }
48
+
49
+ @Test
50
+ fun phoneVirtualUnmuted_sendsPhonePcmAndLeavesPhysicalAlone() {
51
+ assertRow(AudioSourceKind.PHONE, ActiveStreamKind.VIRTUAL, false, false, true, PhysicalMuteAction.LEAVE_ALONE)
52
+ }
53
+
54
+ @Test
55
+ fun phoneVirtualMuted_gatesPhonePcmAndLeavesPhysicalAlone() {
56
+ assertRow(AudioSourceKind.PHONE, ActiveStreamKind.VIRTUAL, true, false, false, PhysicalMuteAction.LEAVE_ALONE)
57
+ }
58
+
59
+ @Test
60
+ fun phoneNoneUnmuted_holdsSilence() {
61
+ assertRow(AudioSourceKind.PHONE, ActiveStreamKind.NONE, false, false, false, PhysicalMuteAction.LEAVE_ALONE)
62
+ }
63
+
64
+ @Test
65
+ fun phoneNoneMuted_holdsSilence() {
66
+ assertRow(AudioSourceKind.PHONE, ActiveStreamKind.NONE, true, false, false, PhysicalMuteAction.LEAVE_ALONE)
67
+ }
68
+
69
+ @Test
70
+ fun cartesianProduct_neverLeaksWrongMicrophone() {
71
+ for (desired in AudioSourceKind.entries) {
72
+ for (active in ActiveStreamKind.entries) {
73
+ for (userMuted in listOf(false, true)) {
74
+ val decision = AcsAudioPolicy.decide(desired, active, userMuted)
75
+ if (decision.glassesPcmEnabled) {
76
+ assertThat(active).isEqualTo(ActiveStreamKind.VIRTUAL)
77
+ assertThat(desired).isEqualTo(AudioSourceKind.GLASSES)
78
+ assertThat(userMuted).isFalse()
79
+ }
80
+ if (decision.phonePcmEnabled) {
81
+ assertThat(active).isEqualTo(ActiveStreamKind.VIRTUAL)
82
+ assertThat(desired).isEqualTo(AudioSourceKind.PHONE)
83
+ assertThat(userMuted).isFalse()
84
+ }
85
+ if (decision.physicalMute == PhysicalMuteAction.UNMUTE) {
86
+ assertThat(active).isEqualTo(ActiveStreamKind.LOCAL)
87
+ assertThat(desired).isEqualTo(AudioSourceKind.PHONE)
88
+ assertThat(userMuted).isFalse()
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ @Test
96
+ fun planJoin_glassesRequiresUnmutedTransport_ignoresUserMute() {
97
+ val muted = AcsAudioPolicy.planJoin(AudioSourceKind.GLASSES, userMuted = true, glassesRequiresUnmutedTransport = true)
98
+ val unmuted = AcsAudioPolicy.planJoin(AudioSourceKind.GLASSES, userMuted = false, glassesRequiresUnmutedTransport = true)
99
+ assertThat(muted.armVirtual).isTrue()
100
+ assertThat(muted.transportMuted).isFalse()
101
+ assertThat(unmuted.armVirtual).isTrue()
102
+ assertThat(unmuted.transportMuted).isFalse()
103
+ }
104
+
105
+ @Test
106
+ fun planJoin_glassesWithoutUnmutedRequirement_followsUserMute() {
107
+ val muted = AcsAudioPolicy.planJoin(AudioSourceKind.GLASSES, userMuted = true, glassesRequiresUnmutedTransport = false)
108
+ val unmuted = AcsAudioPolicy.planJoin(AudioSourceKind.GLASSES, userMuted = false, glassesRequiresUnmutedTransport = false)
109
+ assertThat(muted.transportMuted).isTrue()
110
+ assertThat(unmuted.transportMuted).isFalse()
111
+ assertThat(muted.armVirtual).isTrue()
112
+ }
113
+
114
+ @Test
115
+ fun planJoin_phoneAlsoArmsVirtualAndKeepsTransportLive() {
116
+ for (glassesFlag in listOf(true, false)) {
117
+ val muted = AcsAudioPolicy.planJoin(AudioSourceKind.PHONE, userMuted = true, glassesRequiresUnmutedTransport = glassesFlag)
118
+ val unmuted = AcsAudioPolicy.planJoin(AudioSourceKind.PHONE, userMuted = false, glassesRequiresUnmutedTransport = glassesFlag)
119
+ assertThat(muted.armVirtual).isTrue()
120
+ assertThat(unmuted.armVirtual).isTrue()
121
+ assertThat(muted.transportMuted).isFalse()
122
+ assertThat(unmuted.transportMuted).isFalse()
123
+ }
124
+ }
125
+
126
+ @Test
127
+ fun expectedStream_isAlwaysVirtual() {
128
+ assertThat(AcsAudioPolicy.expectedStream(AudioSourceKind.GLASSES)).isEqualTo(ActiveStreamKind.VIRTUAL)
129
+ assertThat(AcsAudioPolicy.expectedStream(AudioSourceKind.PHONE)).isEqualTo(ActiveStreamKind.VIRTUAL)
130
+ }
131
+
132
+ @Test
133
+ fun parseSource_allowlistOnly() {
134
+ assertThat(AcsAudioPolicy.parseSource("glasses")).isEqualTo(AudioSourceKind.GLASSES)
135
+ assertThat(AcsAudioPolicy.parseSource("phone")).isEqualTo(AudioSourceKind.PHONE)
136
+ assertThat(AcsAudioPolicy.parseSource("bluetooth")).isNull()
137
+ assertThat(AcsAudioPolicy.parseSource("auto")).isNull()
138
+ assertThat(AcsAudioPolicy.parseSource(null)).isNull()
139
+ }
140
+
141
+ @Test
142
+ fun callGuard_nullIsFailureNotSuccess() {
143
+ val missing: Any? = null
144
+ val result = CallGuard.require(missing)
145
+ assertThat(result.isFailure).isTrue()
146
+ assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java)
147
+ assertThat(result.exceptionOrNull()?.message).isEqualTo("no call")
148
+ }
149
+
150
+ @Test
151
+ fun callGuard_presentIsSuccess() {
152
+ assertThat(CallGuard.require("call").getOrThrow()).isEqualTo("call")
153
+ }
154
+
155
+ private fun assertRow(
156
+ desired: AudioSourceKind,
157
+ active: ActiveStreamKind,
158
+ userMuted: Boolean,
159
+ glassesPcm: Boolean,
160
+ phonePcm: Boolean,
161
+ physicalMute: PhysicalMuteAction,
162
+ ) {
163
+ val decision = AcsAudioPolicy.decide(desired, active, userMuted)
164
+ assertThat(decision.glassesPcmEnabled).isEqualTo(glassesPcm)
165
+ assertThat(decision.phonePcmEnabled).isEqualTo(phonePcm)
166
+ assertThat(decision.physicalMute).isEqualTo(physicalMute)
167
+ }
168
+ }
169
+
170
+ @RunWith(Parameterized::class)
171
+ class AcsAudioPolicyJoinMatrixTest(
172
+ private val desired: AudioSourceKind,
173
+ private val userMuted: Boolean,
174
+ private val glassesRequiresUnmutedTransport: Boolean,
175
+ ) {
176
+ @Test
177
+ fun armVirtualMatchesDesired() {
178
+ val plan = AcsAudioPolicy.planJoin(desired, userMuted, glassesRequiresUnmutedTransport)
179
+ assertThat(plan.armVirtual).isTrue()
180
+ if (desired == AudioSourceKind.PHONE) {
181
+ assertThat(plan.transportMuted).isFalse()
182
+ } else if (glassesRequiresUnmutedTransport) {
183
+ assertThat(plan.transportMuted).isFalse()
184
+ } else {
185
+ assertThat(plan.transportMuted).isEqualTo(userMuted)
186
+ }
187
+ }
188
+
189
+ companion object {
190
+ @JvmStatic
191
+ @Parameterized.Parameters(name = "{0} muted={1} glassesUnmutedTransport={2}")
192
+ fun data(): List<Array<Any>> {
193
+ val rows = mutableListOf<Array<Any>>()
194
+ for (desired in AudioSourceKind.entries) {
195
+ for (muted in listOf(false, true)) {
196
+ for (flag in listOf(false, true)) {
197
+ rows.add(arrayOf(desired, muted, flag))
198
+ }
199
+ }
200
+ }
201
+ return rows
202
+ }
203
+ }
204
+ }
@@ -0,0 +1,223 @@
1
+ package com.mentra.acsmeeting.audio
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+
6
+ class FakeScheduler : PolicyScheduler {
7
+ data class Job(val atMs: Long, val task: () -> Unit)
8
+
9
+ var nowMs = 0L
10
+ private val jobs = mutableListOf<Job>()
11
+
12
+ override fun schedule(delayMs: Long, task: () -> Unit) {
13
+ jobs.add(Job(nowMs + delayMs, task))
14
+ }
15
+
16
+ override fun cancelPending() {
17
+ jobs.clear()
18
+ }
19
+
20
+ fun pendingCount(): Int = jobs.size
21
+
22
+ fun advanceTo(targetMs: Long) {
23
+ while (true) {
24
+ val next = jobs.filter { it.atMs <= targetMs }.minByOrNull { it.atMs } ?: break
25
+ jobs.remove(next)
26
+ nowMs = next.atMs
27
+ next.task()
28
+ }
29
+ nowMs = targetMs
30
+ }
31
+ }
32
+
33
+ class FakeAudioStreamController : AudioStreamController {
34
+ var active: ActiveStreamKind = ActiveStreamKind.NONE
35
+ var physicallyMuted: Boolean? = true
36
+ var pcmEnabled: Boolean = false
37
+ var phonePcm: Boolean = false
38
+ var muteResult: Result<Unit> = Result.success(Unit)
39
+ var unmuteResult: Result<Unit> = Result.success(Unit)
40
+ var stopResult: Result<Unit> = Result.success(Unit)
41
+ var muteSetsFlag: Boolean = true
42
+ val pcmCalls = mutableListOf<Boolean>()
43
+ val phonePcmCalls = mutableListOf<Boolean>()
44
+ var muteCalls = 0
45
+ var unmuteCalls = 0
46
+ var stopCalls = 0
47
+
48
+ override fun readActive(): ActiveStreamKind = active
49
+
50
+ override fun isPhysicallyMuted(): Boolean? = physicallyMuted
51
+
52
+ override fun setGlassesPcmEnabled(enabled: Boolean) {
53
+ pcmEnabled = enabled
54
+ pcmCalls.add(enabled)
55
+ }
56
+
57
+ override fun setPhonePcmEnabled(enabled: Boolean) {
58
+ phonePcm = enabled
59
+ phonePcmCalls.add(enabled)
60
+ }
61
+
62
+ override fun mutePhysical(): Result<Unit> {
63
+ muteCalls += 1
64
+ if (muteResult.isSuccess && muteSetsFlag) physicallyMuted = true
65
+ return muteResult
66
+ }
67
+
68
+ override fun unmutePhysical(): Result<Unit> {
69
+ unmuteCalls += 1
70
+ if (unmuteResult.isSuccess) physicallyMuted = false
71
+ return unmuteResult
72
+ }
73
+
74
+ override fun stopActive(): Result<Unit> {
75
+ stopCalls += 1
76
+ if (stopResult.isSuccess) active = ActiveStreamKind.NONE
77
+ return stopResult
78
+ }
79
+ }
80
+
81
+ class AudioPolicyApplierTest {
82
+ private val controller = FakeAudioStreamController()
83
+ private val scheduler = FakeScheduler()
84
+ private val logs = mutableListOf<String>()
85
+ private val applier = AudioPolicyApplier(controller, scheduler) { logs.add(it) }
86
+
87
+ @Test
88
+ fun noneNoneVirtual_opensGateOnThirdObservation() {
89
+ controller.active = ActiveStreamKind.NONE
90
+ applier.apply(AudioSourceKind.GLASSES, userMuted = false, reason = "join")
91
+ assertThat(controller.pcmEnabled).isFalse()
92
+ assertThat(controller.pcmCalls).containsExactly(false)
93
+
94
+ scheduler.advanceTo(50)
95
+ assertThat(controller.pcmEnabled).isFalse()
96
+ assertThat(controller.pcmCalls).hasSize(2)
97
+
98
+ controller.active = ActiveStreamKind.VIRTUAL
99
+ scheduler.advanceTo(100)
100
+ assertThat(controller.pcmEnabled).isTrue()
101
+ assertThat(applier.lastSafety()).isEqualTo(AudioSafety.SAFE)
102
+ assertThat(controller.unmuteCalls).isEqualTo(0)
103
+ }
104
+
105
+ @Test
106
+ fun noneNoneVirtual_opensPhonePcmOnThirdObservation() {
107
+ controller.active = ActiveStreamKind.NONE
108
+ applier.apply(AudioSourceKind.PHONE, userMuted = false, reason = "join")
109
+ assertThat(controller.phonePcm).isFalse()
110
+ assertThat(controller.phonePcmCalls).containsExactly(false)
111
+ assertThat(controller.unmuteCalls).isEqualTo(0)
112
+
113
+ scheduler.advanceTo(50)
114
+ assertThat(controller.phonePcm).isFalse()
115
+ assertThat(controller.phonePcmCalls).hasSize(2)
116
+
117
+ controller.active = ActiveStreamKind.VIRTUAL
118
+ scheduler.advanceTo(100)
119
+ assertThat(controller.phonePcm).isTrue()
120
+ assertThat(controller.pcmEnabled).isFalse()
121
+ assertThat(controller.unmuteCalls).isEqualTo(0)
122
+ assertThat(applier.lastSafety()).isEqualTo(AudioSafety.SAFE)
123
+ }
124
+
125
+ @Test
126
+ fun convergenceTimeout_holdsSilenceAndReportsDegraded() {
127
+ controller.active = ActiveStreamKind.NONE
128
+ applier.apply(AudioSourceKind.GLASSES, userMuted = false, reason = "join")
129
+ scheduler.advanceTo(1600)
130
+ assertThat(controller.pcmEnabled).isFalse()
131
+ assertThat(applier.lastSafety()).isEqualTo(AudioSafety.DEGRADED)
132
+ assertThat(logs.any { it.contains("convergence timed out") }).isTrue()
133
+ }
134
+
135
+ @Test
136
+ fun muteFailureEscalatesToStop_safeWhenStopSucceeds() {
137
+ controller.active = ActiveStreamKind.LOCAL
138
+ controller.physicallyMuted = false
139
+ controller.muteResult = Result.failure(IllegalStateException("no call"))
140
+ val safety = applier.apply(AudioSourceKind.PHONE, userMuted = true, reason = "set-muted")
141
+ assertThat(controller.muteCalls).isEqualTo(1)
142
+ assertThat(controller.stopCalls).isEqualTo(1)
143
+ assertThat(controller.active).isEqualTo(ActiveStreamKind.NONE)
144
+ assertThat(safety).isEqualTo(AudioSafety.SAFE)
145
+ }
146
+
147
+ @Test
148
+ fun muteAndStopFailure_isUnsafe() {
149
+ controller.active = ActiveStreamKind.LOCAL
150
+ controller.physicallyMuted = false
151
+ controller.muteResult = Result.failure(IllegalStateException("mute failed"))
152
+ controller.stopResult = Result.failure(IllegalStateException("stop failed"))
153
+ val safety = applier.apply(AudioSourceKind.PHONE, userMuted = true, reason = "set-muted")
154
+ assertThat(controller.stopCalls).isEqualTo(1)
155
+ assertThat(controller.active).isEqualTo(ActiveStreamKind.LOCAL)
156
+ assertThat(safety).isEqualTo(AudioSafety.UNSAFE)
157
+ }
158
+
159
+ @Test
160
+ fun successfulMuteWithDisagreeingReadback_stillEscalates() {
161
+ controller.active = ActiveStreamKind.LOCAL
162
+ controller.physicallyMuted = false
163
+ controller.muteSetsFlag = false
164
+ controller.muteResult = Result.success(Unit)
165
+ applier.apply(AudioSourceKind.PHONE, userMuted = true, reason = "set-muted")
166
+ assertThat(controller.muteCalls).isEqualTo(1)
167
+ assertThat(controller.stopCalls).isEqualTo(1)
168
+ assertThat(controller.active).isEqualTo(ActiveStreamKind.NONE)
169
+ }
170
+
171
+ @Test
172
+ fun unmuteFailure_isDegradedAndNeverStops() {
173
+ controller.active = ActiveStreamKind.LOCAL
174
+ controller.physicallyMuted = true
175
+ controller.unmuteResult = Result.failure(IllegalStateException("unmute failed"))
176
+ val safety = applier.apply(AudioSourceKind.PHONE, userMuted = false, reason = "set-muted")
177
+ assertThat(controller.unmuteCalls).isEqualTo(1)
178
+ assertThat(controller.stopCalls).isEqualTo(0)
179
+ assertThat(safety).isEqualTo(AudioSafety.DEGRADED)
180
+ }
181
+
182
+ @Test
183
+ fun applyTwice_doesNotRepeatAcsMute() {
184
+ controller.active = ActiveStreamKind.LOCAL
185
+ controller.physicallyMuted = true
186
+ applier.apply(AudioSourceKind.PHONE, userMuted = false, reason = "join")
187
+ assertThat(controller.unmuteCalls).isEqualTo(1)
188
+ applier.apply(AudioSourceKind.PHONE, userMuted = false, reason = "call-connected")
189
+ assertThat(controller.unmuteCalls).isEqualTo(1)
190
+ }
191
+
192
+ @Test
193
+ fun applyGlassesVirtualTwice_neverUnmutesPhysical() {
194
+ controller.active = ActiveStreamKind.VIRTUAL
195
+ applier.apply(AudioSourceKind.GLASSES, userMuted = false, reason = "stream-started")
196
+ applier.apply(AudioSourceKind.GLASSES, userMuted = false, reason = "call-connected")
197
+ assertThat(controller.unmuteCalls).isEqualTo(0)
198
+ assertThat(controller.muteCalls).isEqualTo(0)
199
+ assertThat(controller.pcmEnabled).isTrue()
200
+ }
201
+
202
+ @Test
203
+ fun staleConvergenceCannotOverwriteNewerDesiredState() {
204
+ controller.active = ActiveStreamKind.NONE
205
+ applier.apply(AudioSourceKind.GLASSES, userMuted = false, reason = "join")
206
+ assertThat(scheduler.pendingCount()).isGreaterThan(0)
207
+ applier.apply(AudioSourceKind.PHONE, userMuted = false, reason = "set-source")
208
+ controller.active = ActiveStreamKind.VIRTUAL
209
+ scheduler.advanceTo(100)
210
+ assertThat(controller.pcmEnabled).isFalse()
211
+ assertThat(controller.phonePcm).isTrue()
212
+ assertThat(controller.unmuteCalls).isEqualTo(0)
213
+ }
214
+
215
+ @Test
216
+ fun neverUnmutesWhileVirtualIsActive() {
217
+ controller.active = ActiveStreamKind.VIRTUAL
218
+ applier.apply(AudioSourceKind.PHONE, userMuted = false, reason = "mismatch")
219
+ assertThat(controller.unmuteCalls).isEqualTo(0)
220
+ assertThat(controller.pcmEnabled).isFalse()
221
+ assertThat(controller.phonePcm).isTrue()
222
+ }
223
+ }
@@ -0,0 +1,12 @@
1
+ package com.mentra.acsmeeting.audio
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+
6
+ class CapturePolicyTest {
7
+ @Test
8
+ fun glassesCapturesMicAndPhoneDoesNot() {
9
+ assertThat(CapturePolicy.captureGlassesMic(AudioSourceKind.GLASSES)).isTrue()
10
+ assertThat(CapturePolicy.captureGlassesMic(AudioSourceKind.PHONE)).isFalse()
11
+ }
12
+ }