@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
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+
6
+ class Nv12FormatSpecTest {
7
+ @Test
8
+ fun hdOffersTightBiplanarStrides() {
9
+ val spec = Nv12FormatSpec.of()
10
+ assertThat(spec.width).isEqualTo(1280)
11
+ assertThat(spec.height).isEqualTo(720)
12
+ assertThat(spec.strideY).isEqualTo(1280)
13
+ assertThat(spec.strideUv).isEqualTo(1280)
14
+ assertThat(spec.namedResolution()).isEqualTo(AcsNamedResolution.P720)
15
+ assertThat(spec.packedSize()).isEqualTo(Nv12Packer.packedSize(1280, 720))
16
+ }
17
+
18
+ @Test
19
+ fun oddWidthRoundsUvStrideUp() {
20
+ val spec = Nv12FormatSpec.of(width = 3, height = 3, fps = 15f)
21
+ assertThat(spec.strideY).isEqualTo(3)
22
+ assertThat(spec.strideUv).isEqualTo(4)
23
+ assertThat(spec.packedSize()).isEqualTo(3 * 3 + 4 * 2)
24
+ assertThat(spec.withinAcsBounds()).isFalse()
25
+ }
26
+
27
+ @Test
28
+ fun advertisedSizeMatchesPacker() {
29
+ for ((w, h) in listOf(1280 to 720, 960 to 540, 640 to 360, 3 to 3, 641 to 361)) {
30
+ val spec = Nv12FormatSpec.of(width = w, height = h)
31
+ assertThat(spec.packedSize())
32
+ .describedAs("%dx%d", w, h)
33
+ .isEqualTo(Nv12Packer.packedSize(w, h))
34
+ }
35
+ }
36
+ }
@@ -0,0 +1,58 @@
1
+ package com.mentra.acsmeeting.video
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+ import java.nio.ByteBuffer
6
+
7
+ class Nv12PackerTest {
8
+ @Test
9
+ fun strideUvIsTwiceChromaWidth() {
10
+ assertThat(Nv12Packer.strideUv(1280)).isEqualTo(1280)
11
+ assertThat(Nv12Packer.strideUv(3)).isEqualTo(4)
12
+ assertThat(Nv12Packer.uvSize(1280, 720)).isEqualTo(1280 * 360)
13
+ assertThat(Nv12Packer.packedSize(1280, 720)).isEqualTo(1280 * 720 * 3 / 2)
14
+ }
15
+
16
+ @Test
17
+ fun interleaveWritesUvPairsFromPaddedPlanes() {
18
+ val width = 4
19
+ val height = 4
20
+ val chromaW = 2
21
+ val chromaH = 2
22
+ val u = ByteBuffer.allocate(4 * 2)
23
+ val v = ByteBuffer.allocate(4 * 2)
24
+ // visible U: 10 11 / 12 13 V: 20 21 / 22 23, stride 4
25
+ u.put(0, 10); u.put(1, 11); u.put(4, 12); u.put(5, 13)
26
+ v.put(0, 20); v.put(1, 21); v.put(4, 22); v.put(5, 23)
27
+ val dest = ByteBuffer.allocate(Nv12Packer.uvSize(width, height))
28
+ Nv12Packer.interleaveUv(u, 4, v, 4, chromaW, chromaH, dest)
29
+ dest.flip()
30
+ val out = ByteArray(dest.remaining())
31
+ dest.get(out)
32
+ assertThat(out).containsExactly(10, 20, 11, 21, 12, 22, 13, 23)
33
+ }
34
+
35
+ @Test
36
+ fun copyYThenInterleaveMatchesPackedNv12() {
37
+ val width = 4
38
+ val height = 2
39
+ val y = ByteBuffer.allocate(width * height)
40
+ repeat(width * height) { y.put(it, (40 + it).toByte()) }
41
+ val u = ByteBuffer.allocate(2)
42
+ val v = ByteBuffer.allocate(2)
43
+ u.put(0, 80); u.put(1, 81)
44
+ v.put(0, 90); v.put(1, 91)
45
+ val destY = ByteBuffer.allocate(width * height)
46
+ val destUv = ByteBuffer.allocate(Nv12Packer.uvSize(width, height))
47
+ Nv12Packer.copyY(y, width, width, height, destY)
48
+ Nv12Packer.interleaveUv(u, 2, v, 2, 2, 1, destUv)
49
+ destY.flip()
50
+ destUv.flip()
51
+ val yOut = ByteArray(destY.remaining())
52
+ val uvOut = ByteArray(destUv.remaining())
53
+ destY.get(yOut)
54
+ destUv.get(uvOut)
55
+ assertThat(yOut).containsExactly(40, 41, 42, 43, 44, 45, 46, 47)
56
+ assertThat(uvOut).containsExactly(80, 90, 81, 91)
57
+ }
58
+ }
@@ -0,0 +1,79 @@
1
+ package com.mentra.acsmeeting.video
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+
6
+ class SendGateTest {
7
+ @Test
8
+ fun burstyTenFpsAllSubmitWhenGateIsFree() {
9
+ val intervals = burstyTenFps()
10
+ assertThat(oldPacerDrops(intervals, minIntervalMs = 67)).isGreaterThan((intervals.size * 0.3).toInt())
11
+ val gate = SendGate()
12
+ assertThat(submitAll(intervals, gate)).isEqualTo(intervals.size)
13
+ assertThat(gate.busyCount()).isEqualTo(0)
14
+ }
15
+
16
+ @Test
17
+ fun decoderBatchingAtFifteenFpsAverageDropsNone() {
18
+ val intervals = longArrayOf(20, 110, 25, 111, 20, 110, 25, 111)
19
+ assertThat(oldPacerDrops(intervals, minIntervalMs = 33)).isGreaterThan(0)
20
+ val gate = SendGate()
21
+ assertThat(submitAll(intervals, gate)).isEqualTo(intervals.size)
22
+ assertThat(gate.busyCount()).isEqualTo(0)
23
+ }
24
+
25
+ @Test
26
+ fun threeFramesOneMsApartAllSubmitWhenFree() {
27
+ val gate = SendGate()
28
+ assertThat(submitAll(longArrayOf(1, 1, 1), gate)).isEqualTo(3)
29
+ assertThat(gate.busyCount()).isEqualTo(0)
30
+ }
31
+
32
+ @Test
33
+ fun heldGateIncrementsBusyAndDropsContendedFrames() {
34
+ val gate = SendGate()
35
+ assertThat(gate.tryAcquire()).isTrue()
36
+ assertThat(gate.tryAcquire()).isFalse()
37
+ assertThat(gate.tryAcquire()).isFalse()
38
+ assertThat(gate.busyCount()).isEqualTo(2)
39
+ gate.release()
40
+ assertThat(gate.tryAcquire()).isTrue()
41
+ }
42
+
43
+ private fun submitAll(intervalsMs: LongArray, gate: SendGate): Int {
44
+ var submitted = 0
45
+ for (ignored in intervalsMs) {
46
+ if (gate.tryAcquire()) {
47
+ submitted += 1
48
+ gate.release()
49
+ }
50
+ }
51
+ return submitted
52
+ }
53
+
54
+ /** Observed WHEP pattern: unique ~10 fps delivered as 40 ms pairs then a 160 ms gap. */
55
+ private fun burstyTenFps(): LongArray {
56
+ val out = ArrayList<Long>(20)
57
+ repeat(10) {
58
+ out.add(40)
59
+ out.add(160)
60
+ }
61
+ return out.toLongArray()
62
+ }
63
+
64
+ /** Old 1x / 2x min-interval gate, kept only so a reintroduced pacer fails these cases. */
65
+ private fun oldPacerDrops(intervalsMs: LongArray, minIntervalMs: Long): Int {
66
+ var last = Long.MIN_VALUE
67
+ var t = 0L
68
+ var drops = 0
69
+ for (dt in intervalsMs) {
70
+ t += dt
71
+ if (last != Long.MIN_VALUE && t - last < minIntervalMs) {
72
+ drops += 1
73
+ } else {
74
+ last = t
75
+ }
76
+ }
77
+ return drops
78
+ }
79
+ }
@@ -0,0 +1,68 @@
1
+ package com.mentra.acsmeeting.video
2
+
3
+ import com.mentra.acsmeeting.source.AcsInvestigation
4
+ import org.assertj.core.api.Assertions.assertThat
5
+ import org.junit.Test
6
+
7
+ class VideoProfileTest {
8
+ @Test
9
+ fun defaultRemainsHd720p15() {
10
+ assertThat(VideoProfile.DEFAULT).isEqualTo(VideoProfile.HD)
11
+ assertThat(VideoProfile.DEFAULT.width).isEqualTo(1280)
12
+ assertThat(VideoProfile.DEFAULT.height).isEqualTo(720)
13
+ assertThat(VideoProfile.DEFAULT.fps).isEqualTo(15)
14
+ }
15
+
16
+ @Test
17
+ fun defaultProfileIsWithinAcsFormatBounds() {
18
+ val spec = VideoProfile.DEFAULT.spec()
19
+ assertThat(spec.withinAcsBounds()).isTrue()
20
+ assertThat(spec.fps).isEqualTo(VideoProfile.DEFAULT.fps.toFloat())
21
+ }
22
+
23
+ @Test
24
+ fun everyProfileIsWithinAcsFormatBounds() {
25
+ for (profile in listOf(VideoProfile.HD, VideoProfile.SD, VideoProfile.P540, VideoProfile.P540_15)) {
26
+ assertThat(profile.spec().withinAcsBounds()).isTrue()
27
+ }
28
+ }
29
+
30
+ /**
31
+ * The declared rate must equal the rate we actually emit. Advertising a rate
32
+ * we do not sustain makes the ACS rate controller reserve budget for frames
33
+ * that never arrive, which costs wire fps.
34
+ */
35
+ @Test
36
+ fun declaredRateMatchesTheSyntheticEmitRate() {
37
+ assertThat(VideoProfile.DEFAULT.fps).isEqualTo(AcsInvestigation.syntheticFps)
38
+ }
39
+
40
+ /**
41
+ * Regression for the 1.4 fps collapse: 720p noise needed ~1.4 Mbit per frame
42
+ * against a ~1.9 Mbps budget. Keep enough headroom per frame that ordinary
43
+ * content never puts the rate controller in that position.
44
+ */
45
+ @Test
46
+ fun eachProfileLeavesUsableBitsPerFrame() {
47
+ for (profile in listOf(VideoProfile.HD, VideoProfile.SD, VideoProfile.P540_15)) {
48
+ assertThat(profile.bitsPerFrame()).isGreaterThan(20_000)
49
+ }
50
+ }
51
+
52
+ @Test
53
+ 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)
57
+ assertThat(VideoProfile.parse(540, 960, 30, 1_500_000)).isNull()
58
+ assertThat(VideoProfile.parse(854, 480, 15, 1_500_000)).isNull()
59
+ }
60
+
61
+ /** The fallback only earns its place if it is a real cut in encoder work. */
62
+ @Test
63
+ fun sdIsAQuarterOfTheHdPixelCount() {
64
+ val hd = VideoProfile.HD.width * VideoProfile.HD.height
65
+ val sd = VideoProfile.SD.width * VideoProfile.SD.height
66
+ assertThat(sd).isEqualTo(hd / 4)
67
+ }
68
+ }
@@ -0,0 +1,59 @@
1
+ export type MeetingPhase = "idle" | "connecting" | "lobby" | "connected" | "disconnected" | "error";
2
+ export type AcsAudioSource = "glasses" | "phone";
3
+ export type AcsActiveStream = "none" | "virtual" | "local";
4
+ export type AcsAudioSafety = "safe" | "degraded" | "unsafe";
5
+ /**
6
+ * Health of the glasses WHEP subscription feeding the call. `failed` = ICE dropped or
7
+ * the WHEP endpoint went away while the ACS call may still be `connected`. Native
8
+ * rebuilds it on its own with backoff; the host can force one via `restartVideoSource`.
9
+ */
10
+ export type AcsMediaSourceState = "idle" | "connecting" | "live" | "failed";
11
+ export type AcsMeetingParticipantState = "idle" | "connecting" | "connected" | "lobby" | "hold" | "disconnected";
12
+ export type AcsMeetingParticipant = {
13
+ id: string;
14
+ displayName: string | null;
15
+ state: AcsMeetingParticipantState;
16
+ isMuted: boolean;
17
+ isSpeaking: boolean;
18
+ };
19
+ export type AcsMeetingState = {
20
+ state: MeetingPhase;
21
+ muted: boolean;
22
+ error?: string;
23
+ meetingUrl?: string;
24
+ provider?: "acs-teams";
25
+ audioSource?: AcsAudioSource;
26
+ activeStream?: AcsActiveStream;
27
+ audioSafety?: AcsAudioSafety;
28
+ mediaSource?: AcsMediaSourceState;
29
+ /** Remote roster (Android emits this; iOS does not yet). */
30
+ participants?: AcsMeetingParticipant[];
31
+ };
32
+ export type AcsOutgoingVideo = {
33
+ width: number;
34
+ height: number;
35
+ fps: number;
36
+ maxBitrateBps: number;
37
+ };
38
+ export type AcsMeetingJoinOptions = {
39
+ meetingUrl: string;
40
+ token: string;
41
+ whepUrl: string;
42
+ displayName?: string;
43
+ /** "glasses" sends WHEP PCM. "phone" uses the ACS local mic (handset or BT). */
44
+ audioSource?: AcsAudioSource;
45
+ /** Dump WHEP PCM to a WAV in cache for P4 verification. */
46
+ dumpPcmWav?: boolean;
47
+ /** When omitted, native keeps 1280×720@15 / 2.5 Mbps. */
48
+ video?: AcsOutgoingVideo;
49
+ };
50
+ export type AcsIncomingPcmEvent = {
51
+ base64: string;
52
+ sampleRate: number;
53
+ channels: number;
54
+ };
55
+ export type AcsMeetingModuleEvents = {
56
+ onState: (state: AcsMeetingState) => void;
57
+ onIncomingPcm: (pcm: AcsIncomingPcmEvent) => void;
58
+ };
59
+ //# sourceMappingURL=AcsMeeting.types.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=AcsMeeting.types.js.map
@@ -0,0 +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"]}
@@ -0,0 +1,15 @@
1
+ import { NativeModule } from "expo";
2
+ import type { AcsMeetingJoinOptions, AcsMeetingModuleEvents, AcsMeetingState } from "./AcsMeeting.types";
3
+ declare class AcsMeetingNativeModule extends NativeModule<AcsMeetingModuleEvents> {
4
+ join(options: AcsMeetingJoinOptions): Promise<AcsMeetingState>;
5
+ leave(): Promise<void>;
6
+ setMuted(muted: boolean): Promise<AcsMeetingState>;
7
+ setAudioSource(source: "glasses" | "phone"): Promise<AcsMeetingState>;
8
+ updateVideoSource(whepUrl: string): Promise<void>;
9
+ /** Force a WHEP rebuild on the current URL (phone changed networks). */
10
+ restartVideoSource(): Promise<void>;
11
+ getState(): Promise<AcsMeetingState>;
12
+ }
13
+ declare const _default: AcsMeetingNativeModule;
14
+ export default _default;
15
+ //# sourceMappingURL=AcsMeetingModule.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AcsMeetingModule.d.ts","sourceRoot":"","sources":["../src/AcsMeetingModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,YAAY,EAAsB,MAAM,MAAM,CAAA;AAEtD,OAAO,KAAK,EAAC,qBAAqB,EAAE,sBAAsB,EAAE,eAAe,EAAC,MAAM,oBAAoB,CAAA;AAEtG,OAAO,OAAO,sBAAuB,SAAQ,YAAY,CAAC,sBAAsB,CAAC;IAC/E,IAAI,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC;IAC9D,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC;IAClD,cAAc,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC;IACrE,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IACjD,wEAAwE;IACxE,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC;IACnC,QAAQ,IAAI,OAAO,CAAC,eAAe,CAAC;CACrC;;AAED,wBAA8E"}
@@ -0,0 +1,3 @@
1
+ import { requireNativeModule } from "expo";
2
+ export default requireNativeModule("MentraAcsMeeting");
3
+ //# sourceMappingURL=AcsMeetingModule.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AcsMeetingModule.js","sourceRoot":"","sources":["../src/AcsMeetingModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,mBAAmB,EAAC,MAAM,MAAM,CAAA;AAetD,eAAe,mBAAmB,CAAyB,kBAAkB,CAAC,CAAA","sourcesContent":["import {NativeModule, requireNativeModule} from \"expo\"\n\nimport type {AcsMeetingJoinOptions, AcsMeetingModuleEvents, AcsMeetingState} from \"./AcsMeeting.types\"\n\ndeclare class AcsMeetingNativeModule extends NativeModule<AcsMeetingModuleEvents> {\n join(options: AcsMeetingJoinOptions): Promise<AcsMeetingState>\n leave(): Promise<void>\n setMuted(muted: boolean): Promise<AcsMeetingState>\n setAudioSource(source: \"glasses\" | \"phone\"): Promise<AcsMeetingState>\n updateVideoSource(whepUrl: string): Promise<void>\n /** Force a WHEP rebuild on the current URL (phone changed networks). */\n restartVideoSource(): Promise<void>\n getState(): Promise<AcsMeetingState>\n}\n\nexport default requireNativeModule<AcsMeetingNativeModule>(\"MentraAcsMeeting\")\n"]}
@@ -0,0 +1,16 @@
1
+ import type { AcsMeetingJoinOptions, AcsMeetingState } from "./AcsMeeting.types";
2
+ declare const _default: {
3
+ join(_options: AcsMeetingJoinOptions): Promise<AcsMeetingState>;
4
+ leave(): Promise<void>;
5
+ setMuted(_muted: boolean): Promise<AcsMeetingState>;
6
+ setAudioSource(_source: "glasses" | "phone"): Promise<AcsMeetingState>;
7
+ updateVideoSource(_whepUrl: string): Promise<void>;
8
+ restartVideoSource(): Promise<void>;
9
+ getState(): Promise<AcsMeetingState>;
10
+ addListener(): {
11
+ remove(): void;
12
+ };
13
+ removeListeners(): void;
14
+ };
15
+ export default _default;
16
+ //# sourceMappingURL=AcsMeetingModule.web.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AcsMeetingModule.web.d.ts","sourceRoot":"","sources":["../src/AcsMeetingModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,qBAAqB,EAAE,eAAe,EAAC,MAAM,oBAAoB,CAAA;;mBAOvD,qBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC;aAGtD,OAAO,CAAC,IAAI,CAAC;qBAGL,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC;4BAG3B,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC;gCAG1C,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;0BAG5B,OAAO,CAAC,IAAI,CAAC;gBAGvB,OAAO,CAAC,eAAe,CAAC;;;;;;AAnB5C,wBA0BC"}
@@ -0,0 +1,31 @@
1
+ function unavailable() {
2
+ throw new Error("ACS meeting is not available on web");
3
+ }
4
+ export default {
5
+ async join(_options) {
6
+ unavailable();
7
+ },
8
+ async leave() {
9
+ unavailable();
10
+ },
11
+ async setMuted(_muted) {
12
+ unavailable();
13
+ },
14
+ async setAudioSource(_source) {
15
+ unavailable();
16
+ },
17
+ async updateVideoSource(_whepUrl) {
18
+ unavailable();
19
+ },
20
+ async restartVideoSource() {
21
+ unavailable();
22
+ },
23
+ async getState() {
24
+ return { state: "idle", muted: false };
25
+ },
26
+ addListener() {
27
+ return { remove() { } };
28
+ },
29
+ removeListeners() { },
30
+ };
31
+ //# sourceMappingURL=AcsMeetingModule.web.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AcsMeetingModule.web.js","sourceRoot":"","sources":["../src/AcsMeetingModule.web.ts"],"names":[],"mappings":"AAEA,SAAS,WAAW;IAClB,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAA;AACxD,CAAC;AAED,eAAe;IACb,KAAK,CAAC,IAAI,CAAC,QAA+B;QACxC,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,KAAK;QACT,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,QAAQ,CAAC,MAAe;QAC5B,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,cAAc,CAAC,OAA4B;QAC/C,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,iBAAiB,CAAC,QAAgB;QACtC,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,kBAAkB;QACtB,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,QAAQ;QACZ,OAAO,EAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAC,CAAA;IACtC,CAAC;IACD,WAAW;QACT,OAAO,EAAC,MAAM,KAAI,CAAC,EAAC,CAAA;IACtB,CAAC;IACD,eAAe,KAAI,CAAC;CACrB,CAAA","sourcesContent":["import type {AcsMeetingJoinOptions, AcsMeetingState} from \"./AcsMeeting.types\"\n\nfunction unavailable(): never {\n throw new Error(\"ACS meeting is not available on web\")\n}\n\nexport default {\n async join(_options: AcsMeetingJoinOptions): Promise<AcsMeetingState> {\n unavailable()\n },\n async leave(): Promise<void> {\n unavailable()\n },\n async setMuted(_muted: boolean): Promise<AcsMeetingState> {\n unavailable()\n },\n async setAudioSource(_source: \"glasses\" | \"phone\"): Promise<AcsMeetingState> {\n unavailable()\n },\n async updateVideoSource(_whepUrl: string): Promise<void> {\n unavailable()\n },\n async restartVideoSource(): Promise<void> {\n unavailable()\n },\n async getState(): Promise<AcsMeetingState> {\n return {state: \"idle\", muted: false}\n },\n addListener() {\n return {remove() {}}\n },\n removeListeners() {},\n}\n"]}
@@ -0,0 +1,3 @@
1
+ export { default } from "./AcsMeetingModule";
2
+ export * from "./AcsMeeting.types";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,OAAO,EAAC,MAAM,oBAAoB,CAAA;AAC1C,cAAc,oBAAoB,CAAA"}
package/build/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { default } from "./AcsMeetingModule";
2
+ export * from "./AcsMeeting.types";
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,OAAO,EAAC,MAAM,oBAAoB,CAAA;AAC1C,cAAc,oBAAoB,CAAA","sourcesContent":["export {default} from \"./AcsMeetingModule\"\nexport * from \"./AcsMeeting.types\"\n"]}
@@ -0,0 +1,9 @@
1
+ {
2
+ "platforms": ["apple", "android"],
3
+ "apple": {
4
+ "modules": ["AcsMeetingModule"]
5
+ },
6
+ "android": {
7
+ "modules": ["com.mentra.acsmeeting.AcsMeetingModule"]
8
+ }
9
+ }
@@ -0,0 +1,47 @@
1
+ import AzureCommunicationCalling
2
+ import CoreVideo
3
+ import Foundation
4
+
5
+ /// Fresh Swift sender on ACS iOS RawOutgoingVideoStream + CVPixelBuffer.
6
+ /// No RealWear code (they have no iOS equivalent).
7
+ final class AcsFrameSender {
8
+ private var stream: RawOutgoingVideoStream?
9
+ private var running = false
10
+ private var lastSent: CFTimeInterval = 0
11
+
12
+ func attach(_ stream: VirtualOutgoingVideoStream) {
13
+ detach()
14
+ self.stream = stream
15
+ stream.delegate = self
16
+ }
17
+
18
+ func send(_ pixelBuffer: CVPixelBuffer) {
19
+ guard running, let stream else { return }
20
+ let fps = stream.format?.framesPerSecond ?? 15
21
+ let now = CFAbsoluteTimeGetCurrent()
22
+ if lastSent > 0, now - lastSent < 1.0 / max(fps, 1) { return }
23
+ lastSent = now
24
+ do {
25
+ let frame = RawVideoFrameBuffer(pixelBuffer, streamFormat: stream.format)
26
+ try stream.send(frame)
27
+ } catch {
28
+ NSLog("ACS-SPIKE send frame failed: \(error)")
29
+ }
30
+ }
31
+
32
+ func detach() {
33
+ running = false
34
+ // Drop the delegate on the previous stream so a late STOPPED from a prior
35
+ // call cannot freeze outgoing video for the current meeting.
36
+ stream?.delegate = nil
37
+ stream = nil
38
+ }
39
+ }
40
+
41
+ extension AcsFrameSender: RawOutgoingVideoStreamDelegate {
42
+ func rawOutgoingVideoStream(_ rawOutgoingVideoStream: RawOutgoingVideoStream, didChangeState args: VideoStreamStateChangedEventArgs) {
43
+ guard rawOutgoingVideoStream === stream else { return }
44
+ running = rawOutgoingVideoStream.state == .started
45
+ NSLog("ACS-SPIKE iOS raw video state=\(rawOutgoingVideoStream.state)")
46
+ }
47
+ }
@@ -0,0 +1,27 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'AcsMeeting'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.description = package['description']
10
+ s.license = package['license']
11
+ s.author = package['author']
12
+ s.homepage = package['homepage']
13
+ s.platforms = { :ios => '15.1' }
14
+ s.swift_version = '5.9'
15
+ s.source = { git: 'https://github.com/Mentra-Community/MentraOS.git' }
16
+ s.static_framework = true
17
+ s.dependency 'ExpoModulesCore'
18
+ s.dependency 'AzureCommunicationCalling', '~> 2.15'
19
+ s.dependency 'AzureCommunicationCommon', '~> 1.1'
20
+ # Pin to the same WebRTC-SDK the Mentra App already links via LiveKit.
21
+ s.dependency 'WebRTC-SDK', '137.7151.09'
22
+ s.frameworks = 'AVFoundation', 'AudioToolbox', 'CoreMedia', 'CoreVideo', 'UIKit'
23
+ s.pod_target_xcconfig = {
24
+ 'DEFINES_MODULE' => 'YES',
25
+ }
26
+ s.source_files = '*.{h,m,mm,swift}', 'PolicyKit/Sources/AcsAudioPolicy/*.swift'
27
+ end