@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.
- package/NOTICE +20 -0
- package/README.md +412 -0
- package/THIRD_PARTY +7 -0
- package/android/build.gradle +64 -0
- package/android/src/main/AndroidManifest.xml +5 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +80 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +846 -0
- package/android/src/main/java/com/mentra/acsmeeting/RemoteRoster.kt +123 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +90 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/AudioPolicyApplier.kt +176 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/IncomingAudioPump.kt +112 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/IncomingRateProbe.kt +65 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +108 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/PcmResampler.kt +159 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/PhoneMicCapturer.kt +115 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +302 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +230 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/BitmapFont.kt +99 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +578 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/FirstFrameGate.kt +45 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +101 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/MediaListeners.kt +57 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticFrameFactory.kt +205 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticI420Source.kt +214 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +31 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +41 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/ChromaProbe.kt +78 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +281 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +51 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/ProcessCpu.kt +15 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +33 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +475 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsTimestamp.kt +26 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/FrameGeometry.kt +22 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +90 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/I420Packer.kt +72 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/Nv12FormatSpec.kt +36 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/Nv12Packer.kt +51 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/SendGate.kt +27 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +57 -0
- package/android/src/test/java/com/mentra/acsmeeting/RemoteRosterTest.kt +40 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/AcsAudioPolicyTest.kt +204 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/AudioPolicyApplierTest.kt +223 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +12 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/IncomingAudioPumpTest.kt +97 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/IncomingRateProbeTest.kt +78 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/PcmResamplerTest.kt +125 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +201 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +166 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +26 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/BitmapFontTest.kt +22 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/FirstFrameGateTest.kt +60 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +133 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/I420PlanesTest.kt +67 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticFrameFactoryTest.kt +193 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticI420SourceTest.kt +176 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +36 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/ChromaProbeTest.kt +61 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +256 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/ProcessCpuTest.kt +18 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/RingPercentileTest.kt +49 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +43 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/FrameGeometryTest.kt +33 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +77 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/I420PackerTest.kt +126 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/Nv12FormatSpecTest.kt +36 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/Nv12PackerTest.kt +58 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/SendGateTest.kt +79 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +68 -0
- package/build/AcsMeeting.types.d.ts +59 -0
- package/build/AcsMeeting.types.d.ts.map +1 -0
- package/build/AcsMeeting.types.js +2 -0
- package/build/AcsMeeting.types.js.map +1 -0
- package/build/AcsMeetingModule.d.ts +15 -0
- package/build/AcsMeetingModule.d.ts.map +1 -0
- package/build/AcsMeetingModule.js +3 -0
- package/build/AcsMeetingModule.js.map +1 -0
- package/build/AcsMeetingModule.web.d.ts +16 -0
- package/build/AcsMeetingModule.web.d.ts.map +1 -0
- package/build/AcsMeetingModule.web.js +31 -0
- package/build/AcsMeetingModule.web.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +3 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/AcsFrameSender.swift +47 -0
- package/ios/AcsMeeting.podspec +27 -0
- package/ios/AcsMeetingModule.swift +558 -0
- package/ios/PcmBridge.swift +117 -0
- package/ios/PhoneMicCapturer.swift +64 -0
- package/ios/PolicyKit/Package.swift +17 -0
- package/ios/PolicyKit/Sources/AcsAudioPolicy/AcsAudioPolicy.swift +102 -0
- package/ios/PolicyKit/Sources/AcsAudioPolicy/AudioPolicyApplier.swift +162 -0
- package/ios/PolicyKit/Sources/AcsAudioPolicy/GlassesMediaSource.swift +68 -0
- package/ios/PolicyKit/Tests/AcsAudioPolicyTests/AcsAudioPolicyTests.swift +273 -0
- package/ios/WhepVideoSource.swift +356 -0
- package/package.json +56 -0
- package/src/AcsMeeting.types.ts +66 -0
- package/src/AcsMeetingModule.ts +16 -0
- package/src/AcsMeetingModule.web.ts +33 -0
- 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
|
+
}
|