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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +4 -2
  2. package/android/build.gradle +23 -0
  3. package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +291 -0
  4. package/android/src/main/AndroidManifest.xml +2 -0
  5. package/android/src/main/java/com/mentra/acsmeeting/AbandonedCallAgent.kt +36 -0
  6. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +392 -22
  7. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +909 -56
  8. package/android/src/main/java/com/mentra/acsmeeting/CallCapabilities.kt +33 -0
  9. package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +21 -0
  10. package/android/src/main/java/com/mentra/acsmeeting/audio/AudioUplinkChain.kt +126 -0
  11. package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +27 -4
  12. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +45 -10
  13. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +110 -5
  14. package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +501 -0
  15. package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +51 -0
  16. package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +81 -0
  17. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +355 -0
  18. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +109 -0
  19. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +160 -0
  20. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +103 -0
  21. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +53 -0
  22. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +127 -0
  23. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +573 -0
  24. package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +10 -101
  25. package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +147 -0
  26. package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +47 -5
  27. package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +772 -0
  28. package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +73 -0
  29. package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +17 -0
  30. package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +139 -0
  31. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +144 -0
  32. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +46 -0
  33. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +160 -0
  34. package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +3 -1
  35. package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +69 -0
  36. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +207 -0
  37. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +356 -0
  38. package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +155 -0
  39. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +94 -3
  40. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +33 -5
  41. package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +15 -0
  42. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +178 -0
  43. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +145 -0
  44. package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +143 -0
  45. package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +23 -4
  46. package/android/src/main/java/com/mentra/acsmeeting/video/FramePacer.kt +70 -0
  47. package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +1 -1
  48. package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +36 -3
  49. package/android/src/test/java/com/mentra/acsmeeting/AbandonedCallAgentTest.kt +74 -0
  50. package/android/src/test/java/com/mentra/acsmeeting/CallCapabilitiesTest.kt +47 -0
  51. package/android/src/test/java/com/mentra/acsmeeting/audio/AudioUplinkChainTest.kt +250 -0
  52. package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +44 -0
  53. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +34 -4
  54. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +96 -0
  55. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +102 -0
  56. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +148 -0
  57. package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +59 -0
  58. package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +59 -0
  59. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +35 -0
  60. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +165 -0
  61. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +96 -0
  62. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +187 -0
  63. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +172 -0
  64. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +141 -0
  65. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +69 -0
  66. package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +40 -0
  67. package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +41 -0
  68. package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +26 -8
  69. package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +109 -0
  70. package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +165 -0
  71. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +148 -0
  72. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +76 -0
  73. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +275 -0
  74. package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +9 -0
  75. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +356 -0
  76. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +411 -0
  77. package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +83 -0
  78. package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +69 -3
  79. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +182 -0
  80. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +149 -0
  81. package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +97 -0
  82. package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +34 -0
  83. package/android/src/test/java/com/mentra/acsmeeting/video/FramePacerTest.kt +105 -0
  84. package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +5 -0
  85. package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +38 -3
  86. package/build/AcsMeeting.types.d.ts +5 -0
  87. package/build/AcsMeeting.types.d.ts.map +1 -1
  88. package/build/AcsMeeting.types.js.map +1 -1
  89. package/build/AcsMeetingModule.d.ts +23 -0
  90. package/build/AcsMeetingModule.d.ts.map +1 -1
  91. package/build/AcsMeetingModule.js.map +1 -1
  92. package/build/AcsMeetingModule.web.d.ts +15 -0
  93. package/build/AcsMeetingModule.web.d.ts.map +1 -1
  94. package/build/AcsMeetingModule.web.js +13 -0
  95. package/build/AcsMeetingModule.web.js.map +1 -1
  96. package/ios/AcsMeetingModule.swift +213 -2
  97. package/package.json +1 -1
  98. package/src/AcsMeeting.types.ts +5 -0
  99. package/src/AcsMeetingModule.ts +13 -0
  100. package/src/AcsMeetingModule.web.ts +13 -0
@@ -0,0 +1,182 @@
1
+ package com.mentra.acsmeeting.telemetry
2
+
3
+ import com.mentra.acsmeeting.telemetry.VideoQuality.Band
4
+ import com.mentra.acsmeeting.video.VideoProfile
5
+ import org.assertj.core.api.Assertions.assertThat
6
+ import org.assertj.core.api.Assertions.within
7
+ import org.junit.Test
8
+
9
+ class VideoQualityTest {
10
+ @Test
11
+ fun bitsPerPixelIsBitrateOverPixelsPerSecond() {
12
+ // 960x540 at 15 fps on 1.5 Mbps: 1_500_000 / (960*540*15).
13
+ val bpp = VideoQuality.bitsPerPixel(1_500_000, 960, 540, 15.0)!!
14
+ assertThat(bpp).isCloseTo(0.193, within(0.001))
15
+ }
16
+
17
+ @Test
18
+ fun missingOrZeroInputsGiveNoNumberRatherThanZero() {
19
+ assertThat(VideoQuality.bitsPerPixel(null, 960, 540, 15.0)).isNull()
20
+ assertThat(VideoQuality.bitsPerPixel(1_500_000, 960, 540, null)).isNull()
21
+ assertThat(VideoQuality.bitsPerPixel(1_500_000, 0, 540, 15.0)).isNull()
22
+ assertThat(VideoQuality.bitsPerPixel(1_500_000, 960, 540, 0.0)).isNull()
23
+ assertThat(VideoQuality.band(null)).isEqualTo(Band.UNKNOWN)
24
+ }
25
+
26
+ @Test
27
+ fun bandsFollowTheKushGaugeMotionMultiples() {
28
+ assertThat(VideoQuality.band(0.03)).isEqualTo(Band.BLOCKY)
29
+ assertThat(VideoQuality.band(0.10)).isEqualTo(Band.SOFT)
30
+ assertThat(VideoQuality.band(0.20)).isEqualTo(Band.OK)
31
+ assertThat(VideoQuality.band(0.30)).isEqualTo(Band.GOOD)
32
+ }
33
+
34
+ @Test
35
+ fun bandBoundariesAreTheConstantsThemselves() {
36
+ assertThat(VideoQuality.band(VideoQuality.STATIC_BPP)).isEqualTo(Band.SOFT)
37
+ assertThat(VideoQuality.band(VideoQuality.MODERATE_BPP)).isEqualTo(Band.OK)
38
+ assertThat(VideoQuality.band(VideoQuality.HIGH_MOTION_BPP)).isEqualTo(Band.GOOD)
39
+ }
40
+
41
+ /**
42
+ * The measured device state: `wire=960x540@14.4 kbps=1276`. Every rate counter
43
+ * read healthy and the picture was reported pixelated, which is the whole
44
+ * reason this file exists.
45
+ */
46
+ @Test
47
+ fun theMeasuredDeviceStateLandsShortOfHighMotion() {
48
+ val bpp = VideoQuality.bitsPerPixel(1_276_000, 960, 540, 14.4)!!
49
+ assertThat(bpp).isCloseTo(0.171, within(0.001))
50
+ assertThat(VideoQuality.band(bpp)).isEqualTo(Band.OK)
51
+ assertThat(bpp).isLessThan(VideoQuality.HIGH_MOTION_BPP)
52
+ }
53
+
54
+ /**
55
+ * The old 1.5 Mbps grant could not reach high motion at 540p15 no matter what the
56
+ * encoder did, which is why the ceiling was raised. This pins the new one to the
57
+ * requirement rather than to a round number, so shrinking it back below what the
58
+ * geometry needs fails here instead of on someone's screen.
59
+ */
60
+ @Test
61
+ fun theShipped540pBudgetCanNowReachHighMotion() {
62
+ val p540 = VideoProfile.P540_15
63
+ val needed = VideoQuality.neededBps(p540.width, p540.height, p540.fps.toDouble(), VideoQuality.HIGH_MOTION_BPP)
64
+ assertThat(needed).isCloseTo(2_177_280L, within(1_000L))
65
+ assertThat(p540.maxBitrateBps.toLong()).isGreaterThanOrEqualTo(needed)
66
+ assertThat(
67
+ VideoQuality.budgetCapsQuality(p540.width, p540.height, p540.fps.toDouble(), p540.maxBitrateBps),
68
+ ).isFalse()
69
+ // The grant it replaced, kept as the regression it was.
70
+ assertThat(VideoQuality.budgetCapsQuality(p540.width, p540.height, p540.fps.toDouble(), 1_500_000)).isTrue()
71
+ }
72
+
73
+ /** 360p15 fits inside its 1 Mbps grant, so the smaller profile is not budget-capped. */
74
+ @Test
75
+ fun theSdProfileIsNotBudgetCapped() {
76
+ val sd = VideoProfile.SD
77
+ assertThat(
78
+ VideoQuality.budgetCapsQuality(sd.width, sd.height, sd.fps.toDouble(), sd.maxBitrateBps),
79
+ ).isFalse()
80
+ }
81
+
82
+ @Test
83
+ fun aBudgetOfZeroIsNotTreatedAsACap() {
84
+ assertThat(VideoQuality.budgetCapsQuality(960, 540, 15.0, 0)).isFalse()
85
+ }
86
+
87
+ @Test
88
+ fun everyBandHasANonEmptyHint() {
89
+ for (band in Band.entries) {
90
+ for (capped in listOf(true, false)) {
91
+ for (spending in listOf(true, false)) {
92
+ for (busy in listOf(true, false)) {
93
+ assertThat(VideoQuality.hint(band, capped, spending, busy)).isNotBlank()
94
+ }
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Underspending with a nearly full core and underspending with an idle one look
102
+ * the same on screen and want opposite fixes, so they must not share a hint.
103
+ */
104
+ @Test
105
+ fun underspendSplitsOnCpuRatherThanCollapsingToOneRemedy() {
106
+ val busy = VideoQuality.hint(Band.OK, budgetCapped = true, spendingBudget = false, encoderBusy = true)
107
+ val idle = VideoQuality.hint(Band.OK, budgetCapped = true, spendingBudget = false, encoderBusy = false)
108
+ assertThat(busy).contains("cut pixels")
109
+ assertThat(idle).contains("congestion control")
110
+ assertThat(idle).doesNotContain("cut pixels")
111
+ }
112
+
113
+ @Test
114
+ fun spendingIsMeasuredAgainstTheGrantedCeiling() {
115
+ assertThat(VideoQuality.spendingBudget(1_275_000, 1_500_000)).isTrue()
116
+ assertThat(VideoQuality.spendingBudget(1_200_000, 1_500_000)).isFalse()
117
+ assertThat(VideoQuality.spendingBudget(null, 1_500_000)).isFalse()
118
+ assertThat(VideoQuality.spendingBudget(1_200_000, 0)).isFalse()
119
+ }
120
+
121
+ /**
122
+ * Measured on device during the bitrate slide: `kbps=1210 budgetKbps=1500` with
123
+ * process CPU at 69–88% of one core on an 8-core phone. The encoder had headroom
124
+ * and the rate still fell, so this must not be reported as an encoder problem.
125
+ */
126
+ @Test
127
+ fun theMeasuredSlideIsBlamedOnCongestionNotTheEncoder() {
128
+ val line = VideoQuality.line(960, 540, 14.2, 1_210_000, 1_500_000, 140.0, 77.8, "GOOD")
129
+ assertThat(line).contains("used=81%")
130
+ assertThat(line).contains("cpu=77.8")
131
+ assertThat(line).contains("sendQuality=GOOD")
132
+ assertThat(line).contains("congestion control")
133
+ assertThat(line).doesNotContain("cut pixels")
134
+ }
135
+
136
+ /** A picture that is already sharp is never told to cut anything. */
137
+ @Test
138
+ fun aGoodPictureIsNotGivenARemedy() {
139
+ val hint = VideoQuality.hint(
140
+ Band.GOOD,
141
+ budgetCapped = true,
142
+ spendingBudget = false,
143
+ encoderBusy = true,
144
+ )
145
+ assertThat(hint).isEqualTo("enough for high motion")
146
+ }
147
+
148
+ @Test
149
+ fun theLinePrintsTheShortfallAndNotJustTheNumber() {
150
+ val line = VideoQuality.line(
151
+ wireWidth = 960,
152
+ wireHeight = 540,
153
+ wireFps = 14.4,
154
+ wireBitrateBps = 1_276_000,
155
+ budgetBps = 1_500_000,
156
+ packetsPerSecond = 143.0,
157
+ cpuPercent = 74.4,
158
+ sendQuality = "GOOD",
159
+ )
160
+ assertThat(line).startsWith("P9 quality band=OK")
161
+ assertThat(line).contains("bpp=0.171")
162
+ assertThat(line).contains("size=960x540@14.4")
163
+ assertThat(line).contains("kbps=1276")
164
+ assertThat(line).contains("budgetKbps=1500")
165
+ assertThat(line).contains("used=85%")
166
+ // What it would take to look sharp on a head-mounted camera, scored against the
167
+ // rate actually on the wire (960*540*14.4*0.28), not the rate we asked for.
168
+ assertThat(line).contains("goodNeedsKbps=2090")
169
+ assertThat(line).contains("budgetCapped=true")
170
+ assertThat(line).contains("pps=143.0")
171
+ }
172
+
173
+ @Test
174
+ fun theLineSurvivesAMissingWireReport() {
175
+ val line = VideoQuality.line(null, null, null, null, 1_500_000, null, null, "")
176
+ assertThat(line).contains("band=UNKNOWN")
177
+ assertThat(line).contains("bpp=na")
178
+ assertThat(line).contains("pps=na")
179
+ assertThat(line).contains("cpu=na")
180
+ assertThat(line).contains("sendQuality=na")
181
+ }
182
+ }
@@ -0,0 +1,149 @@
1
+ package com.mentra.acsmeeting.telemetry
2
+
3
+ import com.mentra.acsmeeting.telemetry.VideoRateVerdict.Bound
4
+ import org.assertj.core.api.Assertions.assertThat
5
+ import org.junit.Test
6
+
7
+ class VideoRateVerdictTest {
8
+ /** 540p15 against a starved 1.5 Mbps budget — the grant Mentra Call used to send. */
9
+ private fun verdict(
10
+ advertisedFps: Double = 15.0,
11
+ sinkFps: Double = 15.0,
12
+ admittedFps: Double = 15.0,
13
+ wireFps: Double? = 15.0,
14
+ wireBitrateBps: Long? = 1_400_000,
15
+ budgetBps: Int = 1_500_000,
16
+ cpuPercent: Double? = 55.0,
17
+ ) = VideoRateVerdict.of(
18
+ advertisedFps = advertisedFps,
19
+ sinkFps = sinkFps,
20
+ admittedFps = admittedFps,
21
+ wireFps = wireFps,
22
+ wireBitrateBps = wireBitrateBps,
23
+ budgetBps = budgetBps,
24
+ cpuPercent = cpuPercent,
25
+ )
26
+
27
+ @Test
28
+ fun aWireHoldingTheAdvertisedRateIsOk() {
29
+ assertThat(verdict()).isEqualTo(Bound.OK)
30
+ }
31
+
32
+ /**
33
+ * The verdict that decides whether "the call quality is low" is answered by raising the grant.
34
+ * Every frame is on the wire, so nothing here is short — but the bits are pressed against the
35
+ * ceiling, which is the one condition under which a bigger number changes the picture. Without
36
+ * this the same tick reads OK and the ceiling never enters the conversation.
37
+ */
38
+ @Test
39
+ fun aFullRateRidingTheCeilingIsNamedAsCapBound() {
40
+ assertThat(verdict(wireBitrateBps = 1_480_000)).isEqualTo(Bound.CAP_BOUND)
41
+ // The controller routinely overshoots the grant; that is still the ceiling binding.
42
+ assertThat(verdict(wireBitrateBps = 1_623_000)).isEqualTo(Bound.CAP_BOUND)
43
+ }
44
+
45
+ /** Headroom under the grant at a full rate is a healthy stream, not a ceiling problem. */
46
+ @Test
47
+ fun aFullRateWithBitsToSpareIsStillPlainOk() {
48
+ assertThat(verdict(wireBitrateBps = 1_100_000)).isEqualTo(Bound.OK)
49
+ assertThat(verdict(wireBitrateBps = null)).isEqualTo(Bound.OK)
50
+ }
51
+
52
+ /**
53
+ * Cap-bound is about the ceiling, and a short frame rate means something else is wrong first.
54
+ * Raising the grant for a stream that cannot fill the one it has buys nothing.
55
+ */
56
+ @Test
57
+ fun aShortRateIsDiagnosedBeforeTheCeilingIsConsidered() {
58
+ assertThat(verdict(wireFps = 8.0, wireBitrateBps = 1_480_000, cpuPercent = 92.0))
59
+ .isEqualTo(Bound.ENCODER_SHORT)
60
+ }
61
+
62
+ /**
63
+ * The exact shape of the greedy-gate bug: the glasses supplied 14.6 fps, the
64
+ * pacer admitted 7.3 against an advertised 8, and every counter downstream
65
+ * looked like an encoder problem. It was ours.
66
+ */
67
+ @Test
68
+ fun aGateThatDropsFramesTheSourceSuppliedIsBlamedOnThePacer() {
69
+ assertThat(verdict(advertisedFps = 8.0, sinkFps = 14.6, admittedFps = 7.3, wireFps = 7.0))
70
+ .isEqualTo(Bound.PACER_SHORT)
71
+ }
72
+
73
+ @Test
74
+ fun aShortSourceIsNotBlamedOnAnythingDownstream() {
75
+ assertThat(verdict(sinkFps = 6.0, admittedFps = 6.0, wireFps = 6.0))
76
+ .isEqualTo(Bound.SOURCE_SHORT)
77
+ }
78
+
79
+ /**
80
+ * A source a little under the advertise is not the pacer's fault. Scoring the
81
+ * gate against the advertise alone would read every slightly slow glasses leg
82
+ * as our own bug and send the next fix to the wrong place.
83
+ */
84
+ @Test
85
+ fun thePacerIsNotBlamedForFramesTheSourceNeverSent() {
86
+ assertThat(verdict(sinkFps = 13.0, admittedFps = 13.0, wireFps = 13.0))
87
+ .isEqualTo(Bound.OK)
88
+ }
89
+
90
+ /**
91
+ * The two verdicts that matter, because they want opposite fixes. Same short
92
+ * wire rate, same delivered rate; only CPU and the bitrate headroom differ.
93
+ */
94
+ @Test
95
+ fun aBusyCpuMeansTheEncoderIsTheCeiling() {
96
+ assertThat(verdict(wireFps = 8.0, cpuPercent = 92.0)).isEqualTo(Bound.ENCODER_SHORT)
97
+ }
98
+
99
+ @Test
100
+ fun sparecpuAndACollapsedBitrateMeansTheRateControllerIsThrottling() {
101
+ assertThat(verdict(wireFps = 8.0, wireBitrateBps = 496_000, cpuPercent = 50.0))
102
+ .isEqualTo(Bound.BITRATE_STARVED)
103
+ }
104
+
105
+ /** Spare CPU but the budget is being spent: the encoder really is the limit. */
106
+ @Test
107
+ fun sparecpuWithTheBudgetSpentIsStillTheEncoder() {
108
+ assertThat(verdict(wireFps = 8.0, wireBitrateBps = 1_450_000, cpuPercent = 50.0))
109
+ .isEqualTo(Bound.ENCODER_SHORT)
110
+ }
111
+
112
+ @Test
113
+ fun aMissingWireReportIsNotAVerdict() {
114
+ assertThat(verdict(wireFps = null)).isEqualTo(Bound.UNKNOWN)
115
+ assertThat(verdict(advertisedFps = 0.0)).isEqualTo(Bound.UNKNOWN)
116
+ }
117
+
118
+ /** Every bound carries an action, or the line makes the reader open this file. */
119
+ @Test
120
+ fun everyBoundHasANonEmptyHint() {
121
+ for (bound in Bound.entries) {
122
+ assertThat(VideoRateVerdict.hint(bound)).isNotBlank()
123
+ }
124
+ }
125
+
126
+ @Test
127
+ fun theLinePrintsTheInputsBesideTheVerdict() {
128
+ val line = VideoRateVerdict.line(
129
+ advertisedFps = 15.0,
130
+ sinkFps = 14.6,
131
+ admittedFps = 7.3,
132
+ wireFps = 7.0,
133
+ wireBitrateBps = 496_000,
134
+ budgetBps = 1_500_000,
135
+ cpuPercent = 61.3,
136
+ codecName = "h264 sw",
137
+ )
138
+ assertThat(line).startsWith("P7 rate bound=PACER_SHORT")
139
+ assertThat(line).contains("advertised=15.0")
140
+ assertThat(line).contains("sink=14.6")
141
+ assertThat(line).contains("admitted=7.3")
142
+ assertThat(line).contains("wire=7.0")
143
+ assertThat(line).contains("kbps=496")
144
+ assertThat(line).contains("budgetKbps=1500")
145
+ assertThat(line).contains("cpu=61.3")
146
+ assertThat(line).contains("codec=h264_sw")
147
+ assertThat(line).contains(VideoRateVerdict.hint(Bound.PACER_SHORT))
148
+ }
149
+ }
@@ -0,0 +1,97 @@
1
+ package com.mentra.acsmeeting.trace
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+
6
+ /**
7
+ * Covers the pure formatter. The `Log.i` wrapper is a one-liner and is exercised on device.
8
+ */
9
+ class SoftApTraceTest {
10
+
11
+ @Test
12
+ fun `format carries the grep marker`() {
13
+ assertThat(SoftApTrace.format("t1", "scoped_network_available", 12))
14
+ .contains("SOFTAP_TRACE")
15
+ }
16
+
17
+ @Test
18
+ fun `format includes traceId stage and elapsed`() {
19
+ assertThat(SoftApTrace.format("abc123", "ice_selected_pair", 450))
20
+ .isEqualTo("[SOFTAP_TRACE] traceId=abc123 stage=ice_selected_pair elapsedMs=450")
21
+ }
22
+
23
+ @Test
24
+ fun `format omits traceId when absent`() {
25
+ assertThat(SoftApTrace.format("", "boot", 0))
26
+ .isEqualTo("[SOFTAP_TRACE] stage=boot elapsedMs=0")
27
+ }
28
+
29
+ @Test
30
+ fun `format appends fields in order`() {
31
+ assertThat(SoftApTrace.format("t", "whip_request", 7, "method" to "POST", "status" to 201))
32
+ .endsWith("method=POST status=201")
33
+ }
34
+
35
+ @Test
36
+ fun `sanitize redacts the hotspot password`() {
37
+ assertThat(SoftApTrace.sanitize("password", "hunter2")).isEqualTo("<redacted>")
38
+ assertThat(SoftApTrace.sanitize("hotspotPassword", "hunter2")).isEqualTo("<redacted>")
39
+ assertThat(SoftApTrace.sanitize("psk", "hunter2")).isEqualTo("<redacted>")
40
+ assertThat(SoftApTrace.sanitize("passphrase", "hunter2")).isEqualTo("<redacted>")
41
+ }
42
+
43
+ @Test
44
+ fun `sanitize redacts tokens and credentials`() {
45
+ assertThat(SoftApTrace.sanitize("token", "eyJhbGciOi")).isEqualTo("<redacted>")
46
+ assertThat(SoftApTrace.sanitize("acsToken", "eyJhbGciOi")).isEqualTo("<redacted>")
47
+ assertThat(SoftApTrace.sanitize("Authorization", "Bearer x")).isEqualTo("<redacted>")
48
+ assertThat(SoftApTrace.sanitize("meetingUrl", "https://teams.microsoft.com/l/x"))
49
+ .isEqualTo("<redacted>")
50
+ }
51
+
52
+ @Test
53
+ fun `format never leaks a secret value into the line`() {
54
+ val line = SoftApTrace.format(
55
+ "t", "hotspot_started", 5,
56
+ "ssid" to "MentraLive", "password" to "hunter2",
57
+ )
58
+ assertThat(line).doesNotContain("hunter2")
59
+ assertThat(line).contains("ssid=MentraLive")
60
+ assertThat(line).contains("password=<redacted>")
61
+ }
62
+
63
+ @Test
64
+ fun `sanitize keeps the local whip url which is diagnostic`() {
65
+ assertThat(SoftApTrace.sanitize("whipUrl", "http://192.168.43.20:8790/whip/abc"))
66
+ .isEqualTo("http://192.168.43.20:8790/whip/abc")
67
+ }
68
+
69
+ @Test
70
+ fun `sanitize strips query strings that may carry tokens`() {
71
+ assertThat(SoftApTrace.sanitize("playbackUrl", "https://example.com/live?token=secret123"))
72
+ .isEqualTo("https://example.com/live?<redacted>")
73
+ }
74
+
75
+ @Test
76
+ fun `sanitize strips userinfo from urls`() {
77
+ val result = SoftApTrace.sanitize("endpoint", "https://user:pw@example.com/x")
78
+ assertThat(result).doesNotContain("pw@")
79
+ assertThat(result).contains("<redacted>@example.com/x")
80
+ }
81
+
82
+ @Test
83
+ fun `sanitize quotes values containing spaces`() {
84
+ assertThat(SoftApTrace.sanitize("reason", "two words")).isEqualTo("\"two words\"")
85
+ }
86
+
87
+ @Test
88
+ fun `sanitize renders null and empty`() {
89
+ assertThat(SoftApTrace.sanitize("reason", null)).isEqualTo("null")
90
+ assertThat(SoftApTrace.sanitize("reason", "")).isEqualTo("\"\"")
91
+ }
92
+
93
+ @Test
94
+ fun `newTraceId produces distinct ids`() {
95
+ assertThat(SoftApTrace.newTraceId()).isNotEqualTo(SoftApTrace.newTraceId())
96
+ }
97
+ }
@@ -40,4 +40,38 @@ class AcsTimestampTest {
40
40
  val ticks = AcsTimestamp.resolve(streamTicks = 0, captureNs = 100, lastTicks = 9_000)
41
41
  assertThat(ticks).isEqualTo(9_001)
42
42
  }
43
+
44
+ /**
45
+ * Outgoing audio's shape: `RawOutgoingAudioStream` in 2.16.0 exposes no stream clock, so the
46
+ * capture time is always the source and `streamTicks` is always 0.
47
+ *
48
+ * The audio and the video come from different places — one over BLE, one over the SoftAP peer —
49
+ * so a shared `System.nanoTime()` base is the only thing that lets a receiver relate them at all.
50
+ * These assertions are the precondition for the A/V question, not the answer to it: whether ACS
51
+ * honours the ticks for raw audio is a receiver measurement.
52
+ */
53
+ @Test
54
+ fun audioFramesStampedFromCaptureTimeShareTheVideoTickBase() {
55
+ val captureNs = 1_234_500_000L
56
+ val audio = AcsTimestamp.resolve(streamTicks = 0, captureNs = captureNs, lastTicks = 0)
57
+ val videoFromTheSameInstant = AcsTimestamp.resolve(streamTicks = 0, captureNs = captureNs, lastTicks = 0)
58
+
59
+ assertThat(audio).isEqualTo(videoFromTheSameInstant)
60
+ assertThat(audio).isEqualTo(captureNs / AcsTimestamp.NS_PER_TICK)
61
+ }
62
+
63
+ @Test
64
+ fun aStreamOfAudioFramesIsStrictlyIncreasingAndNeverZero() {
65
+ // 20 ms periods on a clock coarse enough that two frames could collide.
66
+ var last = 0L
67
+ val ticks = (0 until 50).map { period ->
68
+ val resolved = AcsTimestamp.resolve(streamTicks = 0, captureNs = period * 20_000_000L, lastTicks = last)
69
+ last = resolved
70
+ resolved
71
+ }
72
+
73
+ assertThat(ticks).doesNotContain(0L)
74
+ assertThat(ticks).isSorted()
75
+ assertThat(ticks.toSet()).hasSameSizeAs(ticks)
76
+ }
43
77
  }
@@ -0,0 +1,105 @@
1
+ package com.mentra.acsmeeting.video
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.assertj.core.api.Assertions.within
5
+ import org.junit.Test
6
+
7
+ class FramePacerTest {
8
+ @Test
9
+ fun firstFrameAlwaysAdmits() {
10
+ val pacer = FramePacer()
11
+ assertThat(pacer.tryAdmit(FramePacer.intervalNs(8f), nowNs = 1_000L)).isTrue()
12
+ }
13
+
14
+ @Test
15
+ fun dropsAFrameThatArrivesInsideTheAdvertisedInterval() {
16
+ val pacer = FramePacer()
17
+ val interval = FramePacer.intervalNs(8f)
18
+ assertThat(pacer.tryAdmit(interval, nowNs = 0L)).isTrue()
19
+ assertThat(pacer.tryAdmit(interval, nowNs = interval - 1)).isFalse()
20
+ }
21
+
22
+ @Test
23
+ fun admitsOnceTheAdvertisedIntervalHasElapsed() {
24
+ val pacer = FramePacer()
25
+ val interval = FramePacer.intervalNs(8f)
26
+ assertThat(pacer.tryAdmit(interval, nowNs = 0L)).isTrue()
27
+ assertThat(pacer.tryAdmit(interval, nowNs = interval)).isTrue()
28
+ }
29
+
30
+ /**
31
+ * Fifteen frames a second against an 8 fps advertise is the production SoftAP
32
+ * case, and the only assertion that catches the failure is the sustained rate.
33
+ *
34
+ * Counting admits inside one 15-frame window cannot: 8 of 15 is also what a
35
+ * 7.5 fps stream looks like, because both land on i=0,2,4..14. That is how a
36
+ * greedy "interval elapsed since the last admit" gate passed this file while
37
+ * the device ran `sub=7.0 wire=960x540@7.0` against an advertised 8.
38
+ */
39
+ @Test
40
+ fun sustainsTheAdvertisedRateFromAnyFasterSource() {
41
+ // 14.49 and 14.6 are arrival rates measured on device (gapP50 68-69ms), not
42
+ // round numbers. The greedy gate delivered 7.25 and 7.30 for them.
43
+ for (sourceFps in listOf(14.49f, 14.6f, 15f, 30f)) {
44
+ assertThat(sustainedFps(advertisedFps = 8f, sourceFps = sourceFps))
45
+ .`as`("source %s fps", sourceFps)
46
+ .isCloseTo(8.0, within(0.05))
47
+ }
48
+ }
49
+
50
+ /** A source slower than the advertise passes through untouched, not padded. */
51
+ @Test
52
+ fun neverInventsFramesTheSourceDidNotSend() {
53
+ assertThat(sustainedFps(advertisedFps = 8f, sourceFps = 5f)).isCloseTo(5.0, within(0.05))
54
+ }
55
+
56
+ /**
57
+ * Catch-up is bounded. A source that goes away must not buy its debt back as a
58
+ * burst of back-to-back admits: that is over-delivery against the same
59
+ * advertised rate, which is the way the ungated path starved the encoder.
60
+ */
61
+ @Test
62
+ fun aStallDoesNotEarnABurstOfCatchUpAdmits() {
63
+ val pacer = FramePacer()
64
+ val interval = FramePacer.intervalNs(8f)
65
+ assertThat(pacer.tryAdmit(interval, nowNs = 0L)).isTrue()
66
+ val afterStall = 10 * interval
67
+ assertThat(pacer.tryAdmit(interval, nowNs = afterStall)).isTrue()
68
+ // The resync admit starts a fresh interval; nothing is due until it elapses.
69
+ assertThat(pacer.tryAdmit(interval, nowNs = afterStall + 1)).isFalse()
70
+ assertThat(pacer.tryAdmit(interval, nowNs = afterStall + interval)).isTrue()
71
+ }
72
+
73
+ private fun sustainedFps(advertisedFps: Float, sourceFps: Float, frames: Int = 3000): Double {
74
+ val pacer = FramePacer()
75
+ val interval = FramePacer.intervalNs(advertisedFps)
76
+ val arrival = FramePacer.intervalNs(sourceFps)
77
+ var admitted = 0
78
+ for (i in 0 until frames) {
79
+ if (pacer.tryAdmit(interval, nowNs = i * arrival)) admitted += 1
80
+ }
81
+ return admitted / ((frames - 1).toDouble() * arrival / 1_000_000_000.0)
82
+ }
83
+
84
+ @Test
85
+ fun resetForgetsTheLastAdmit() {
86
+ val pacer = FramePacer()
87
+ val interval = FramePacer.intervalNs(8f)
88
+ assertThat(pacer.tryAdmit(interval, nowNs = 0L)).isTrue()
89
+ pacer.reset()
90
+ assertThat(pacer.tryAdmit(interval, nowNs = 1L)).isTrue()
91
+ }
92
+
93
+ @Test
94
+ fun zeroIntervalNeverPaces() {
95
+ val pacer = FramePacer()
96
+ assertThat(FramePacer.intervalNs(0f)).isEqualTo(0L)
97
+ assertThat(pacer.tryAdmit(0L, nowNs = 0L)).isTrue()
98
+ assertThat(pacer.tryAdmit(0L, nowNs = 1L)).isTrue()
99
+ }
100
+
101
+ @Test
102
+ fun eightFpsIntervalIsOneHundredTwentyFiveMilliseconds() {
103
+ assertThat(FramePacer.intervalNs(8f)).isEqualTo(125_000_000L)
104
+ }
105
+ }
@@ -54,6 +54,11 @@ class AcsFrameSenderFormatTest {
54
54
  fun parseOrNullAllowsDocumentedVirtualCameraSizesOnly() {
55
55
  assertThat(I420FormatSpec.parseOrNull(1280, 720, 15f)).isNotNull()
56
56
  assertThat(I420FormatSpec.parseOrNull(960, 540, 30f)).isNotNull()
57
+ for (fps in listOf(15f, 24f, 30f)) {
58
+ val spec = I420FormatSpec.parseOrNull(858, 480, fps)!!
59
+ assertThat(spec.namedResolution()).isEqualTo(AcsNamedResolution.P480)
60
+ assertThat(spec.packedSize()).isEqualTo(I420Packer.packedSize(858, 480))
61
+ }
57
62
  assertThat(I420FormatSpec.parseOrNull(540, 960, 30f)).isNull()
58
63
  assertThat(I420FormatSpec.parseOrNull(854, 480, 15f)).isNull()
59
64
  }
@@ -49,15 +49,50 @@ class VideoProfileTest {
49
49
  }
50
50
  }
51
51
 
52
+ @Test
53
+ fun acsCeilingsSitAboveTheGlassesPhoneLink() {
54
+ val glassesPhoneBps = 2_500_000
55
+ for (profile in listOf(VideoProfile.HD, VideoProfile.P540, VideoProfile.P540_15)) {
56
+ assertThat(profile.maxBitrateBps).isGreaterThan(glassesPhoneBps)
57
+ }
58
+ }
59
+
52
60
  @Test
53
61
  fun parseAcceptsDocumentedVirtualCameraSizesAndRejectsPortraitAnd480p() {
54
- assertThat(VideoProfile.parse(1280, 720, 15, 2_500_000)).isEqualTo(VideoProfile.HD)
55
- assertThat(VideoProfile.parse(960, 540, 30, 1_500_000)).isEqualTo(VideoProfile.P540)
56
- assertThat(VideoProfile.parse(960, 540, 15, 1_500_000)).isEqualTo(VideoProfile.P540_15)
62
+ assertThat(VideoProfile.parse(1280, 720, 15, 3_000_000)).isEqualTo(VideoProfile.HD)
63
+ assertThat(VideoProfile.parse(960, 540, 30, 3_000_000)).isEqualTo(VideoProfile.P540)
64
+ assertThat(VideoProfile.parse(960, 540, 15, 3_000_000)).isEqualTo(VideoProfile.P540_15)
57
65
  assertThat(VideoProfile.parse(540, 960, 30, 1_500_000)).isNull()
58
66
  assertThat(VideoProfile.parse(854, 480, 15, 1_500_000)).isNull()
59
67
  }
60
68
 
69
+ @Test
70
+ fun softwareEncoderClampDropsOnlyTheRate() {
71
+ val requested = VideoProfile.P540_15
72
+ val clamped = requested.forSoftwareEncoder()
73
+ assertThat(clamped.width).isEqualTo(requested.width)
74
+ assertThat(clamped.height).isEqualTo(requested.height)
75
+ assertThat(clamped.maxBitrateBps).isEqualTo(requested.maxBitrateBps)
76
+ assertThat(clamped.fps).isEqualTo(VideoProfile.SOFTWARE_ENCODER_FPS)
77
+ assertThat(clamped.spec().withinAcsBounds()).isTrue()
78
+ assertThat(clamped.bitsPerFrame()).isEqualTo(requested.maxBitrateBps / VideoProfile.SOFTWARE_ENCODER_FPS)
79
+ }
80
+
81
+ @Test
82
+ fun softwareEncoderClampIsIdempotentAtOrBelowTheSustainRate() {
83
+ val alreadyHeld = VideoProfile.P540_15.copy(fps = VideoProfile.SOFTWARE_ENCODER_FPS)
84
+ assertThat(alreadyHeld.forSoftwareEncoder()).isSameAs(alreadyHeld)
85
+ val slower = VideoProfile.P540_15.copy(fps = 5)
86
+ assertThat(slower.forSoftwareEncoder()).isSameAs(slower)
87
+ }
88
+
89
+ @Test
90
+ fun softwareEncoderClampHitsEveryShippedProfile() {
91
+ for (profile in listOf(VideoProfile.HD, VideoProfile.SD, VideoProfile.P540, VideoProfile.P540_15)) {
92
+ assertThat(profile.forSoftwareEncoder().fps).isEqualTo(VideoProfile.SOFTWARE_ENCODER_FPS)
93
+ }
94
+ }
95
+
61
96
  /** The fallback only earns its place if it is a real cut in encoder work. */
62
97
  @Test
63
98
  fun sdIsAQuarterOfTheHdPixelCount() {
@@ -42,6 +42,11 @@ export type AcsMeetingJoinOptions = {
42
42
  displayName?: string;
43
43
  /** "glasses" sends WHEP PCM. "phone" uses the ACS local mic (handset or BT). */
44
44
  audioSource?: AcsAudioSource;
45
+ /**
46
+ * Hold outgoing PCM this many ms before ACS. SoftAP+LC3 sets this in the host
47
+ * because BLE audio reaches the phone before SoftAP video.
48
+ */
49
+ audioDelayMs?: number;
45
50
  /** Dump WHEP PCM to a WAV in cache for P4 verification. */
46
51
  dumpPcmWav?: boolean;
47
52
  /** When omitted, native keeps 1280×720@15 / 2.5 Mbps. */
@@ -1 +1 @@
1
- {"version":3,"file":"AcsMeeting.types.d.ts","sourceRoot":"","sources":["../src/AcsMeeting.types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,YAAY,GAAG,OAAO,GAAG,WAAW,GAAG,cAAc,GAAG,OAAO,CAAA;AAEnG,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,OAAO,CAAA;AAChD,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,CAAA;AAC1D,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,UAAU,GAAG,QAAQ,CAAA;AAC3D;;;;GAIG;AACH,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,YAAY,GAAG,MAAM,GAAG,QAAQ,CAAA;AAE3E,MAAM,MAAM,0BAA0B,GAAG,MAAM,GAAG,YAAY,GAAG,WAAW,GAAG,OAAO,GAAG,MAAM,GAAG,cAAc,CAAA;AAEhH,MAAM,MAAM,qBAAqB,GAAG;IAClC,EAAE,EAAE,MAAM,CAAA;IACV,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,KAAK,EAAE,0BAA0B,CAAA;IACjC,OAAO,EAAE,OAAO,CAAA;IAChB,UAAU,EAAE,OAAO,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,KAAK,EAAE,YAAY,CAAA;IACnB,KAAK,EAAE,OAAO,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,EAAE,WAAW,CAAA;IACtB,WAAW,CAAC,EAAE,cAAc,CAAA;IAC5B,YAAY,CAAC,EAAE,eAAe,CAAA;IAC9B,WAAW,CAAC,EAAE,cAAc,CAAA;IAC5B,WAAW,CAAC,EAAE,mBAAmB,CAAA;IACjC,4DAA4D;IAC5D,YAAY,CAAC,EAAE,qBAAqB,EAAE,CAAA;CACvC,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,GAAG,EAAE,MAAM,CAAA;IACX,aAAa,EAAE,MAAM,CAAA;CACtB,CAAA;AAED,MAAM,MAAM,qBAAqB,GAAG;IAClC,UAAU,EAAE,MAAM,CAAA;IAClB,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,gFAAgF;IAChF,WAAW,CAAC,EAAE,cAAc,CAAA;IAC5B,2DAA2D;IAC3D,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,yDAAyD;IACzD,KAAK,CAAC,EAAE,gBAAgB,CAAA;CACzB,CAAA;AAED,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,MAAM,CAAA;IACd,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,MAAM,MAAM,sBAAsB,GAAG;IACnC,OAAO,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAA;IACzC,aAAa,EAAE,CAAC,GAAG,EAAE,mBAAmB,KAAK,IAAI,CAAA;CAClD,CAAA"}
1
+ {"version":3,"file":"AcsMeeting.types.d.ts","sourceRoot":"","sources":["../src/AcsMeeting.types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,YAAY,GAAG,OAAO,GAAG,WAAW,GAAG,cAAc,GAAG,OAAO,CAAA;AAEnG,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,OAAO,CAAA;AAChD,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,CAAA;AAC1D,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,UAAU,GAAG,QAAQ,CAAA;AAC3D;;;;GAIG;AACH,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,YAAY,GAAG,MAAM,GAAG,QAAQ,CAAA;AAE3E,MAAM,MAAM,0BAA0B,GAAG,MAAM,GAAG,YAAY,GAAG,WAAW,GAAG,OAAO,GAAG,MAAM,GAAG,cAAc,CAAA;AAEhH,MAAM,MAAM,qBAAqB,GAAG;IAClC,EAAE,EAAE,MAAM,CAAA;IACV,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,KAAK,EAAE,0BAA0B,CAAA;IACjC,OAAO,EAAE,OAAO,CAAA;IAChB,UAAU,EAAE,OAAO,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,KAAK,EAAE,YAAY,CAAA;IACnB,KAAK,EAAE,OAAO,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,EAAE,WAAW,CAAA;IACtB,WAAW,CAAC,EAAE,cAAc,CAAA;IAC5B,YAAY,CAAC,EAAE,eAAe,CAAA;IAC9B,WAAW,CAAC,EAAE,cAAc,CAAA;IAC5B,WAAW,CAAC,EAAE,mBAAmB,CAAA;IACjC,4DAA4D;IAC5D,YAAY,CAAC,EAAE,qBAAqB,EAAE,CAAA;CACvC,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,GAAG,EAAE,MAAM,CAAA;IACX,aAAa,EAAE,MAAM,CAAA;CACtB,CAAA;AAED,MAAM,MAAM,qBAAqB,GAAG;IAClC,UAAU,EAAE,MAAM,CAAA;IAClB,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,gFAAgF;IAChF,WAAW,CAAC,EAAE,cAAc,CAAA;IAC5B;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,2DAA2D;IAC3D,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,yDAAyD;IACzD,KAAK,CAAC,EAAE,gBAAgB,CAAA;CACzB,CAAA;AAED,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,MAAM,CAAA;IACd,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,MAAM,MAAM,sBAAsB,GAAG;IACnC,OAAO,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAA;IACzC,aAAa,EAAE,CAAC,GAAG,EAAE,mBAAmB,KAAK,IAAI,CAAA;CAClD,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"AcsMeeting.types.js","sourceRoot":"","sources":["../src/AcsMeeting.types.ts"],"names":[],"mappings":"","sourcesContent":["export type MeetingPhase = \"idle\" | \"connecting\" | \"lobby\" | \"connected\" | \"disconnected\" | \"error\"\n\nexport type AcsAudioSource = \"glasses\" | \"phone\"\nexport type AcsActiveStream = \"none\" | \"virtual\" | \"local\"\nexport type AcsAudioSafety = \"safe\" | \"degraded\" | \"unsafe\"\n/**\n * Health of the glasses WHEP subscription feeding the call. `failed` = ICE dropped or\n * the WHEP endpoint went away while the ACS call may still be `connected`. Native\n * rebuilds it on its own with backoff; the host can force one via `restartVideoSource`.\n */\nexport type AcsMediaSourceState = \"idle\" | \"connecting\" | \"live\" | \"failed\"\n\nexport type AcsMeetingParticipantState = \"idle\" | \"connecting\" | \"connected\" | \"lobby\" | \"hold\" | \"disconnected\"\n\nexport type AcsMeetingParticipant = {\n id: string\n displayName: string | null\n state: AcsMeetingParticipantState\n isMuted: boolean\n isSpeaking: boolean\n}\n\nexport type AcsMeetingState = {\n state: MeetingPhase\n muted: boolean\n error?: string\n meetingUrl?: string\n provider?: \"acs-teams\"\n audioSource?: AcsAudioSource\n activeStream?: AcsActiveStream\n audioSafety?: AcsAudioSafety\n mediaSource?: AcsMediaSourceState\n /** Remote roster (Android emits this; iOS does not yet). */\n participants?: AcsMeetingParticipant[]\n}\n\nexport type AcsOutgoingVideo = {\n width: number\n height: number\n fps: number\n maxBitrateBps: number\n}\n\nexport type AcsMeetingJoinOptions = {\n meetingUrl: string\n token: string\n whepUrl: string\n displayName?: string\n /** \"glasses\" sends WHEP PCM. \"phone\" uses the ACS local mic (handset or BT). */\n audioSource?: AcsAudioSource\n /** Dump WHEP PCM to a WAV in cache for P4 verification. */\n dumpPcmWav?: boolean\n /** When omitted, native keeps 1280×720@15 / 2.5 Mbps. */\n video?: AcsOutgoingVideo\n}\n\nexport type AcsIncomingPcmEvent = {\n base64: string\n sampleRate: number\n channels: number\n}\n\nexport type AcsMeetingModuleEvents = {\n onState: (state: AcsMeetingState) => void\n onIncomingPcm: (pcm: AcsIncomingPcmEvent) => void\n}\n"]}
1
+ {"version":3,"file":"AcsMeeting.types.js","sourceRoot":"","sources":["../src/AcsMeeting.types.ts"],"names":[],"mappings":"","sourcesContent":["export type MeetingPhase = \"idle\" | \"connecting\" | \"lobby\" | \"connected\" | \"disconnected\" | \"error\"\n\nexport type AcsAudioSource = \"glasses\" | \"phone\"\nexport type AcsActiveStream = \"none\" | \"virtual\" | \"local\"\nexport type AcsAudioSafety = \"safe\" | \"degraded\" | \"unsafe\"\n/**\n * Health of the glasses WHEP subscription feeding the call. `failed` = ICE dropped or\n * the WHEP endpoint went away while the ACS call may still be `connected`. Native\n * rebuilds it on its own with backoff; the host can force one via `restartVideoSource`.\n */\nexport type AcsMediaSourceState = \"idle\" | \"connecting\" | \"live\" | \"failed\"\n\nexport type AcsMeetingParticipantState = \"idle\" | \"connecting\" | \"connected\" | \"lobby\" | \"hold\" | \"disconnected\"\n\nexport type AcsMeetingParticipant = {\n id: string\n displayName: string | null\n state: AcsMeetingParticipantState\n isMuted: boolean\n isSpeaking: boolean\n}\n\nexport type AcsMeetingState = {\n state: MeetingPhase\n muted: boolean\n error?: string\n meetingUrl?: string\n provider?: \"acs-teams\"\n audioSource?: AcsAudioSource\n activeStream?: AcsActiveStream\n audioSafety?: AcsAudioSafety\n mediaSource?: AcsMediaSourceState\n /** Remote roster (Android emits this; iOS does not yet). */\n participants?: AcsMeetingParticipant[]\n}\n\nexport type AcsOutgoingVideo = {\n width: number\n height: number\n fps: number\n maxBitrateBps: number\n}\n\nexport type AcsMeetingJoinOptions = {\n meetingUrl: string\n token: string\n whepUrl: string\n displayName?: string\n /** \"glasses\" sends WHEP PCM. \"phone\" uses the ACS local mic (handset or BT). */\n audioSource?: AcsAudioSource\n /**\n * Hold outgoing PCM this many ms before ACS. SoftAP+LC3 sets this in the host\n * because BLE audio reaches the phone before SoftAP video.\n */\n audioDelayMs?: number\n /** Dump WHEP PCM to a WAV in cache for P4 verification. */\n dumpPcmWav?: boolean\n /** When omitted, native keeps 1280×720@15 / 2.5 Mbps. */\n video?: AcsOutgoingVideo\n}\n\nexport type AcsIncomingPcmEvent = {\n base64: string\n sampleRate: number\n channels: number\n}\n\nexport type AcsMeetingModuleEvents = {\n onState: (state: AcsMeetingState) => void\n onIncomingPcm: (pcm: AcsIncomingPcmEvent) => void\n}\n"]}