@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,846 @@
1
+ package com.mentra.acsmeeting
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import com.azure.android.communication.calling.AudioStreamBufferDuration
6
+ import com.azure.android.communication.calling.AudioStreamChannelMode
7
+ import com.azure.android.communication.calling.AudioStreamFormat
8
+ import com.azure.android.communication.calling.AudioStreamSampleRate
9
+ import com.azure.android.communication.calling.AudioStreamState
10
+ import com.azure.android.communication.calling.AudioStreamType
11
+ import com.azure.android.communication.calling.Call
12
+ import com.azure.android.communication.calling.CallAgent
13
+ import com.azure.android.communication.calling.CallAgentOptions
14
+ import com.azure.android.communication.calling.CallClient
15
+ import com.azure.android.communication.calling.CallState
16
+ import com.azure.android.communication.calling.Features
17
+ import com.azure.android.communication.calling.DiagnosticFlagChangedListener
18
+ import com.azure.android.communication.calling.DiagnosticQualityChangedListener
19
+ import com.azure.android.communication.calling.LocalUserDiagnosticsCallFeature
20
+ import com.azure.android.communication.calling.MediaStatisticsCallFeature
21
+ import com.azure.android.communication.calling.MediaStatisticsReportReceivedListener
22
+ import com.azure.android.communication.calling.NetworkDiagnostics
23
+ import com.azure.android.communication.calling.IncomingAudioOptions
24
+ import com.azure.android.communication.calling.IncomingMixedAudioEvent
25
+ import com.azure.android.communication.calling.JoinCallOptions
26
+ import com.azure.android.communication.calling.LocalOutgoingAudioStream
27
+ import com.azure.android.communication.calling.OutgoingAudioOptions
28
+ import com.azure.android.communication.calling.OutgoingVideoConstraints
29
+ import com.azure.android.communication.calling.OutgoingVideoOptions
30
+ import com.azure.android.communication.calling.RawIncomingAudioStream
31
+ import com.azure.android.communication.calling.RawIncomingAudioStreamOptions
32
+ import com.azure.android.communication.calling.RawIncomingAudioStreamProperties
33
+ import com.azure.android.communication.calling.RawOutgoingAudioStream
34
+ import com.azure.android.communication.calling.RawOutgoingAudioStreamOptions
35
+ import com.azure.android.communication.calling.RawOutgoingAudioStreamProperties
36
+ import com.azure.android.communication.calling.RawOutgoingVideoStreamOptions
37
+ import com.azure.android.communication.calling.TeamsMeetingLinkLocator
38
+ import com.azure.android.communication.calling.VirtualOutgoingVideoStream
39
+ import com.azure.android.communication.common.CommunicationTokenCredential
40
+ import com.mentra.acsmeeting.audio.AcsAudioPolicy
41
+ import com.mentra.acsmeeting.audio.ActiveStreamKind
42
+ import com.mentra.acsmeeting.audio.JoinAudioPlan
43
+ import com.mentra.acsmeeting.audio.AudioPolicyApplier
44
+ import com.mentra.acsmeeting.audio.AudioSafety
45
+ import com.mentra.acsmeeting.audio.AudioSourceKind
46
+ import com.mentra.acsmeeting.audio.AudioStreamController
47
+ import com.mentra.acsmeeting.audio.CallGuard
48
+ import com.mentra.acsmeeting.audio.ExecutorPolicyScheduler
49
+ import com.mentra.acsmeeting.audio.AcsUplinkTransport
50
+ import com.mentra.acsmeeting.audio.IncomingAudioPump
51
+ import com.mentra.acsmeeting.audio.IncomingRateProbe
52
+ import com.mentra.acsmeeting.audio.PcmBridge
53
+ import com.mentra.acsmeeting.audio.PhoneMicCapturer
54
+ import com.mentra.acsmeeting.audio.UplinkPacer
55
+ import com.mentra.acsmeeting.audio.UplinkSender
56
+ import com.mentra.acsmeeting.source.AcsInvestigation
57
+ import com.mentra.acsmeeting.source.CloudflareWhepSource
58
+ import com.mentra.acsmeeting.source.DecoderMode
59
+ import com.mentra.acsmeeting.source.PixelFormatArm
60
+ import com.mentra.acsmeeting.source.GlassesMediaController
61
+ import com.mentra.acsmeeting.source.GlassesMediaSourceFactory
62
+ import com.mentra.acsmeeting.source.SourceConfig
63
+ import com.mentra.acsmeeting.source.SourceKind
64
+ import com.mentra.acsmeeting.source.SourceState
65
+ import com.mentra.acsmeeting.source.SyntheticI420Source
66
+ import com.mentra.acsmeeting.source.TargetSize
67
+ import com.mentra.acsmeeting.source.VideoSourceArm
68
+ import com.mentra.acsmeeting.telemetry.PipelineStats
69
+ import com.mentra.acsmeeting.telemetry.PipelineTicker
70
+ import com.mentra.acsmeeting.video.AcsFrameSender
71
+ import com.mentra.acsmeeting.video.VideoProfile
72
+ import java.util.concurrent.Executors
73
+ import java.util.concurrent.ScheduledExecutorService
74
+ import java.util.concurrent.ScheduledFuture
75
+ import java.util.concurrent.TimeUnit
76
+ import java.util.concurrent.atomic.AtomicBoolean
77
+ import java.util.concurrent.atomic.AtomicInteger
78
+ import kotlin.math.roundToInt
79
+
80
+ class AcsMeetingSession(
81
+ private val context: Context,
82
+ private val onState: (Map<String, Any>) -> Unit,
83
+ private val onIncomingPcm: (String, Int, Int) -> Unit,
84
+ mediaSourceFactory: GlassesMediaSourceFactory? = null,
85
+ ) {
86
+ internal val stats = PipelineStats()
87
+ private val ticker = PipelineTicker(stats) {
88
+ Log.i(TAG, it)
89
+ }
90
+ private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
91
+ private val scheduler = ExecutorPolicyScheduler(executor)
92
+ private val outgoingReady = AtomicBoolean(false)
93
+ private val muted = AtomicBoolean(false)
94
+ private val frameSender = AcsFrameSender(stats)
95
+ private var profile = VideoProfile.DEFAULT
96
+ private val resolvedFactory = mediaSourceFactory ?: GlassesMediaSourceFactory { video, pcm ->
97
+ when (AcsInvestigation.videoArm) {
98
+ VideoSourceArm.SYNTHETIC -> SyntheticI420Source(video, stats, frameSender::isReady)
99
+ VideoSourceArm.WHEP -> CloudflareWhepSource(context, video, pcm, stats)
100
+ }
101
+ }
102
+ @Volatile private var lastGatedLogMs = 0L
103
+ private var pcmBridge: PcmBridge? = null
104
+ private val incomingProbe = IncomingRateProbe()
105
+ // Clock-domain adapter: the WebRTC audio thread only ever fills the pacer,
106
+ // and a dedicated monotonic-deadline thread drains it into ACS.
107
+ private val pacer = UplinkPacer()
108
+ @Volatile private var uplinkSender: UplinkSender? = null
109
+ private val phoneMic = PhoneMicCapturer { pcm, rate, channels -> feedOutgoingPcm(pcm, rate, channels) }
110
+ // Emits already-normalized 16 kHz mono; the host opens its PCM player with
111
+ // exactly that format, so whatever ACS actually delivers cannot change pitch.
112
+ private val incomingPump = IncomingAudioPump { chunk ->
113
+ onIncomingPcm(PcmBridge.encodeBase64(chunk), IncomingAudioPump.OUT_RATE, IncomingAudioPump.OUT_CHANNELS)
114
+ }
115
+ // isSpeaking flips several times a second per participant; coalesce so the
116
+ // host and miniapp see one roster snapshot per burst instead of a storm.
117
+ private val rosterPushPending = AtomicBoolean(false)
118
+ private val roster = RemoteRoster {
119
+ if (rosterPushPending.compareAndSet(false, true)) {
120
+ executor.schedule({
121
+ rosterPushPending.set(false)
122
+ if (call != null) onState(snapshot())
123
+ }, ROSTER_COALESCE_MS, TimeUnit.MILLISECONDS)
124
+ }
125
+ }
126
+ private val media = GlassesMediaController(resolvedFactory)
127
+ private var mediaStatsListener: MediaStatisticsReportReceivedListener? = null
128
+ private var mediaStatsFeature: MediaStatisticsCallFeature? = null
129
+ private val mediaStatsReports = AtomicInteger(0)
130
+ private var netDiagnostics: NetworkDiagnostics? = null
131
+ private var sendQualityListener: DiagnosticQualityChangedListener? = null
132
+ private var reconnectListener: DiagnosticQualityChangedListener? = null
133
+ private var noNetworkListener: DiagnosticFlagChangedListener? = null
134
+ private var relaysListener: DiagnosticFlagChangedListener? = null
135
+ private var callClient: CallClient? = null
136
+ private var callAgent: CallAgent? = null
137
+ private var call: Call? = null
138
+ private var audioOut: RawOutgoingAudioStream? = null
139
+ private var localOut: LocalOutgoingAudioStream? = null
140
+ private var audioIn: RawIncomingAudioStream? = null
141
+ private var videoOut: VirtualOutgoingVideoStream? = null
142
+ @Volatile private var meetingUrl: String? = null
143
+ @Volatile private var phase = "idle"
144
+ @Volatile private var lastError: String? = null
145
+ @Volatile private var audioSource = "glasses"
146
+ @Volatile private var lastSafety = AudioSafety.DEGRADED
147
+ // Health of the glasses WHEP feed, reported alongside the ACS phase so the host
148
+ // can tell "call is up, glasses video is dead" from a healthy call.
149
+ @Volatile private var mediaSource = SourceState.IDLE
150
+ private var mediaRestartAttempts = 0
151
+ private var mediaRestartTask: ScheduledFuture<*>? = null
152
+ private val controller = SessionAudioController()
153
+ private val applier = AudioPolicyApplier(controller, scheduler) { Log.i(TAG, it) }
154
+
155
+ fun snapshot(): Map<String, Any> {
156
+ val result = mutableMapOf<String, Any>(
157
+ "state" to phase,
158
+ "muted" to muted.get(),
159
+ "provider" to "acs-teams",
160
+ "audioSource" to audioSource,
161
+ "activeStream" to controller.readActive().name.lowercase(),
162
+ "audioSafety" to lastSafety.name.lowercase(),
163
+ "mediaSource" to mediaSource.name.lowercase(),
164
+ "participants" to roster.snapshot(),
165
+ )
166
+ meetingUrl?.let { result["meetingUrl"] = it }
167
+ lastError?.let { result["error"] = it }
168
+ describeEndReason(call).forEach { (key, value) ->
169
+ if (value != null) result["endReason_$key"] = value
170
+ }
171
+ return result
172
+ }
173
+
174
+ fun join(
175
+ token: String,
176
+ teamsUrl: String,
177
+ whepUrl: String,
178
+ displayName: String?,
179
+ dumpWav: Boolean,
180
+ audioSource: String = "glasses",
181
+ video: VideoProfile = VideoProfile.DEFAULT,
182
+ ): Map<String, Any> {
183
+ // Both glasses and phone feed RawOutgoingAudioStream so ACS never owns
184
+ // the phone audio route (no MODE_IN_COMMUNICATION, no forced speaker).
185
+ // Phone PCM comes from AudioRecord; glasses PCM still arrives via WHEP.
186
+ val parsed = AcsAudioPolicy.parseSource(audioSource) ?: AudioSourceKind.GLASSES
187
+ if (parsed == AudioSourceKind.PHONE) {
188
+ Log.i(TAG, "audioSource=phone: AudioRecord → virtual outgoing; communication mode off")
189
+ }
190
+ this.audioSource = if (parsed == AudioSourceKind.PHONE) "phone" else "glasses"
191
+ // The ACS work below is queued, so callers must not receive the pre-join
192
+ // phase. Reflect the intent synchronously so the resolved snapshot is
193
+ // "connecting" and cannot overwrite a fresher onState with a stale idle.
194
+ phase = "connecting"
195
+ lastError = null
196
+ meetingUrl = teamsUrl
197
+ executor.execute {
198
+ try {
199
+ // Tear down any previous call without announcing idle: the caller already
200
+ // holds a "connecting" snapshot, and an idle event landing after it made
201
+ // the host and miniapp flash out of "joining" on every join.
202
+ leaveLocked(emitIdle = false)
203
+ this.profile = video
204
+ this.audioSource = if (parsed == AudioSourceKind.PHONE) "phone" else "glasses"
205
+ meetingUrl = teamsUrl
206
+ lastError = null
207
+ emit("connecting")
208
+ pcmBridge = PcmBridge(context.cacheDir, dumpWav)
209
+ val credential = CommunicationTokenCredential(token)
210
+ callClient = CallClient()
211
+ val agentOptions = CallAgentOptions()
212
+ agentOptions.displayName = displayName ?: "Mentra Call"
213
+ callAgent = callClient!!.createCallAgent(context, credential, agentOptions).get()
214
+
215
+ val videoOptions = RawOutgoingVideoStreamOptions()
216
+ videoOptions.formats = listOf(AcsFrameSender.outgoingFormat(profile))
217
+ val videoStream = VirtualOutgoingVideoStream(videoOptions)
218
+ videoOut = videoStream
219
+ frameSender.attach(videoStream) { size -> media.setTargetSize(size) }
220
+
221
+ val audioProperties = RawOutgoingAudioStreamProperties()
222
+ .setFormat(AudioStreamFormat.PCM16_BIT)
223
+ .setSampleRate(AudioStreamSampleRate.HZ_48000)
224
+ .setChannelMode(AudioStreamChannelMode.MONO)
225
+ .setBufferDuration(AudioStreamBufferDuration.MS20)
226
+ val outAudioOptions = RawOutgoingAudioStreamOptions().setProperties(audioProperties)
227
+ val outgoing = RawOutgoingAudioStream(outAudioOptions)
228
+ audioOut = outgoing
229
+ outgoing.addOnStateChangedListener {
230
+ val ready = outgoing.state.toString().contains("STARTED", ignoreCase = true)
231
+ outgoingReady.set(ready)
232
+ Log.i(TAG, "raw outgoing audio state=${outgoing.state}")
233
+ if (ready) startUplink(outgoing) else stopUplink()
234
+ applyAudioPolicy("virtual-stream-state")
235
+ }
236
+
237
+ val synthetic = AcsInvestigation.videoArm == VideoSourceArm.SYNTHETIC
238
+ if (synthetic) muted.set(true)
239
+ val desired = desiredKind()
240
+ val plan = if (synthetic) {
241
+ JoinAudioPlan(armVirtual = true, transportMuted = true)
242
+ } else {
243
+ AcsAudioPolicy.planJoin(desired, muted.get(), GLASSES_REQUIRES_UNMUTED_TRANSPORT)
244
+ }
245
+
246
+ // Virtual outgoing stays armed for phone and glasses. A LocalOutgoing
247
+ // stream would make ACS own the phone route and open an echo loop.
248
+ val local = if (plan.armVirtual) null else LocalOutgoingAudioStream()
249
+ localOut = local
250
+ local?.addOnStateChangedListener {
251
+ Log.i(TAG, "local outgoing audio state=${local.state}")
252
+ applyAudioPolicy("local-stream-state")
253
+ }
254
+
255
+ val incomingProperties = RawIncomingAudioStreamProperties()
256
+ .setFormat(AudioStreamFormat.PCM16_BIT)
257
+ .setSampleRate(AudioStreamSampleRate.HZ_16000)
258
+ .setChannelMode(AudioStreamChannelMode.MONO)
259
+ val inAudioOptions = RawIncomingAudioStreamOptions().setProperties(incomingProperties)
260
+ val incoming = RawIncomingAudioStream(inAudioOptions)
261
+ audioIn = incoming
262
+ incomingPump.reset()
263
+ incomingProbe.reset()
264
+ incoming.addOnStateChangedListener {
265
+ Log.i(TAG, "raw incoming audio state=${incoming.state}")
266
+ }
267
+ incoming.addOnMixedAudioBufferReceivedListener { event: IncomingMixedAudioEvent ->
268
+ if (AcsInvestigation.videoArm == VideoSourceArm.SYNTHETIC) return@addOnMixedAudioBufferReceivedListener
269
+ try {
270
+ val data = event.audioBuffer?.buffer ?: return@addOnMixedAudioBufferReceivedListener
271
+ val bytes = ByteArray(data.remaining())
272
+ data.get(bytes)
273
+ // Trust the event's format over what we asked for.
274
+ val props = event.streamProperties
275
+ val rate = sampleRateHz(props?.sampleRate) ?: 16000
276
+ val channels = if (props?.channelMode == AudioStreamChannelMode.STEREO) 2 else 1
277
+ incomingPump.push(bytes, rate, channels)
278
+ logIncomingRate(rate, channels, bytes.size)
279
+ } catch (error: Exception) {
280
+ Log.w(TAG, "incoming PCM callback failed", error)
281
+ }
282
+ }
283
+ val joinOptions = JoinCallOptions()
284
+ val constraints = OutgoingVideoConstraints()
285
+ .setMaxWidth(profile.width)
286
+ .setMaxHeight(profile.height)
287
+ .setMaxFrameRate(profile.fps)
288
+ .setMaxBitrateInBps(profile.maxBitrateBps)
289
+ val ov = OutgoingVideoOptions()
290
+ .setOutgoingVideoStreams(listOf(videoStream))
291
+ .setConstraints(constraints)
292
+ joinOptions.setOutgoingVideoOptions(ov)
293
+ // Virtual outgoing and MODE_IN_COMMUNICATION off for glasses and phone.
294
+ // Communication mode makes the ACS SDK call setMode(3) on connect and
295
+ // request the phone speaker, which yanks A2DP off the glasses.
296
+ val oa = OutgoingAudioOptions()
297
+ .setStream(if (plan.armVirtual) outgoing else requireNotNull(local))
298
+ .setMuted(plan.transportMuted)
299
+ .setCommunicationAudioModeEnabled(!plan.armVirtual)
300
+ joinOptions.setOutgoingAudioOptions(oa)
301
+ // Raw incoming replaces SDK playout: buffers come to us, the SDK plays
302
+ // nothing. Do not start it "speaker muted" — that flag gates the very
303
+ // stream we read from.
304
+ val ia = IncomingAudioOptions()
305
+ .setStream(incoming)
306
+ .setMuted(false)
307
+ joinOptions.setIncomingAudioOptions(ia)
308
+
309
+ val locator = TeamsMeetingLinkLocator(teamsUrl)
310
+ val joined = callAgent!!.join(context, locator, joinOptions)
311
+ call = joined
312
+ roster.attach(joined)
313
+ joined.addOnStateChangedListener { pushCallState(joined.state) }
314
+ joined.addOnOutgoingAudioStateChangedListener {
315
+ Log.i(TAG, "outgoing audio state changed muted=${joined.isOutgoingAudioMuted}")
316
+ applyAudioPolicy("outgoing-audio-state")
317
+ }
318
+ pushCallState(joined.state)
319
+
320
+ stats.arm = if (synthetic) "synthetic" else "whep"
321
+ stats.pathMode = if (AcsInvestigation.decoderMode == DecoderMode.BYTE_BUFFER) "bytebuf" else "texture"
322
+ stats.pathCopy = when {
323
+ AcsInvestigation.pixelFormat == PixelFormatArm.NV12 -> "nv12"
324
+ AcsInvestigation.zeroCopy -> "zerocopy"
325
+ else -> "planes"
326
+ }
327
+ stats.pix = AcsInvestigation.pixelFormat.name.lowercase()
328
+ stats.zcOn = if (AcsInvestigation.zeroCopy) 1 else 0
329
+ mediaRestartAttempts = 0
330
+ media.setStateListener { state, reason ->
331
+ // Fired from WebRTC/OkHttp threads; hop to the session executor so it
332
+ // serializes with join/leave/policy like everything else.
333
+ executor.execute { onMediaSourceState(state, reason) }
334
+ }
335
+ media.attach(
336
+ video = { planes ->
337
+ frameSender.sendPlanes(planes)
338
+ },
339
+ pcm = { pcm, rate, channels -> feedOutgoingPcm(pcm, rate, channels) },
340
+ config = SourceConfig(
341
+ url = whepUrl,
342
+ kind = if (synthetic) SourceKind.DIRECT else SourceKind.WHEP,
343
+ ),
344
+ )
345
+ media.setTargetSize(TargetSize(profile.width, profile.height))
346
+ ticker.start()
347
+ Log.i(
348
+ TAG,
349
+ "CPU probe: 1 Hz P6 ladder includes path{mode copy}, buf{tex i420}, stride{tight padded}, " +
350
+ "copyP95, zc{}, cpu{proc}. i420P95 is toI420; copyP95 is the single plane copy. " +
351
+ "BYTE_BUFFER should drop i420P95 and set buf{tex=0}. zerocopy should drop copyP95.",
352
+ )
353
+ applyAudioPolicy("join")
354
+ Log.i(
355
+ TAG,
356
+ "ACS join started arm=${AcsInvestigation.videoArm.name.lowercase()} " +
357
+ "profile=${profile.width}x${profile.height}@${profile.fps} " +
358
+ "maxBitrate=${profile.maxBitrateBps} bitsPerFrame=${profile.bitsPerFrame()} " +
359
+ "syntheticFps=${AcsInvestigation.syntheticFps} entropy=${AcsInvestigation.syntheticEntropy} " +
360
+ "decoderMode=${AcsInvestigation.decoderMode} zeroCopy=${AcsInvestigation.zeroCopy} " +
361
+ "pixelFormat=${AcsInvestigation.pixelFormat} " +
362
+ "source=${this.audioSource} audio=${if (synthetic) "off" else "on"} " +
363
+ "armVirtual=${plan.armVirtual} transportMuted=${plan.transportMuted}",
364
+ )
365
+ } catch (error: Exception) {
366
+ val message = formatAcsError(error)
367
+ Log.e(TAG, "join failed $message", error)
368
+ // A step after a successful ACS join (e.g. WHEP start) can throw. Record
369
+ // the failure before tearing the call down: lastError makes pushCallState
370
+ // ignore the hang-up's async disconnected callbacks, and emitIdle=false
371
+ // keeps the terminal state as error instead of resetting to idle. Either
372
+ // would otherwise let Mentra Call treat the failed join as a clean end.
373
+ lastError = message
374
+ leaveLocked(emitIdle = false)
375
+ emit("error")
376
+ }
377
+ }
378
+ return snapshot()
379
+ }
380
+
381
+ fun updateVideoSource(whepUrl: String) {
382
+ executor.execute {
383
+ // The host has a fresher opinion about where the glasses publish; drop
384
+ // any automatic retry against the old URL.
385
+ cancelMediaRestart()
386
+ media.restart(SourceConfig(whepUrl))
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Rebuild the WHEP subscription on the current URL even when it looks healthy.
392
+ * The host calls this when the phone changed networks: ICE may not have noticed
393
+ * yet, but the old candidate pair is dead.
394
+ */
395
+ fun restartVideoSource() {
396
+ executor.execute {
397
+ cancelMediaRestart()
398
+ media.forceRestart()
399
+ }
400
+ }
401
+
402
+ private fun onMediaSourceState(state: SourceState, reason: String?) {
403
+ val previous = mediaSource
404
+ mediaSource = state
405
+ if (state == SourceState.LIVE) mediaRestartAttempts = 0
406
+ if (state == SourceState.FAILED) scheduleMediaRestart(reason)
407
+ // start() emits IDLE then CONNECTING back to back; one snapshot per real change.
408
+ if (previous != state && call != null && phase != "idle") onState(snapshot())
409
+ }
410
+
411
+ /**
412
+ * Native owns first-line recovery: nothing above this layer can see ICE fail, and
413
+ * a Teams call with a frozen last frame looks healthy from every other angle.
414
+ * Exponential backoff capped at MEDIA_RESTART_MAX_MS; runs as long as the call is
415
+ * alive. The host resets it whenever it hands us a new URL.
416
+ */
417
+ private fun scheduleMediaRestart(reason: String?) {
418
+ if (call == null || phase == "idle" || phase == "disconnected" || phase == "error") return
419
+ if (mediaRestartTask?.isDone == false) return
420
+ val attempt = mediaRestartAttempts++
421
+ val delayMs = minOf(MEDIA_RESTART_BASE_MS shl minOf(attempt, 4), MEDIA_RESTART_MAX_MS)
422
+ Log.w(TAG, "glasses media source failed ($reason); WHEP rebuild #${attempt + 1} in ${delayMs}ms")
423
+ mediaRestartTask = executor.schedule({
424
+ mediaRestartTask = null
425
+ if (call == null || mediaSource != SourceState.FAILED) return@schedule
426
+ try {
427
+ media.forceRestart()
428
+ } catch (error: Exception) {
429
+ Log.w(TAG, "WHEP rebuild failed", error)
430
+ scheduleMediaRestart("rebuild_threw")
431
+ }
432
+ }, delayMs, TimeUnit.MILLISECONDS)
433
+ }
434
+
435
+ private fun cancelMediaRestart() {
436
+ mediaRestartTask?.cancel(false)
437
+ mediaRestartTask = null
438
+ mediaRestartAttempts = 0
439
+ }
440
+
441
+ fun setMuted(next: Boolean): Map<String, Any> {
442
+ if (AcsInvestigation.videoArm == VideoSourceArm.SYNTHETIC) {
443
+ muted.set(true)
444
+ return snapshot()
445
+ }
446
+ muted.set(next)
447
+ executor.execute { applyAudioPolicy("set-muted") }
448
+ val snap = snapshot()
449
+ onState(snap)
450
+ return snap
451
+ }
452
+
453
+ fun setAudioSource(source: String): Map<String, Any> {
454
+ val parsed = AcsAudioPolicy.parseSource(source)
455
+ if (parsed == null) {
456
+ Log.w(TAG, "unknown audioSource=$source ignored; source is locked for this call")
457
+ return snapshot()
458
+ }
459
+ Log.i(TAG, "setAudioSource=$source ignored; audio source is locked for this call at ${audioSource}")
460
+ return snapshot()
461
+ }
462
+
463
+ fun leave() {
464
+ executor.execute { leaveLocked() }
465
+ }
466
+
467
+ fun getState(): Map<String, Any> = snapshot()
468
+
469
+ private fun desiredKind(): AudioSourceKind =
470
+ if (audioSource == "phone") AudioSourceKind.PHONE else AudioSourceKind.GLASSES
471
+
472
+ /** Queues onto [executor] so ACS callbacks cannot race the applier. */
473
+ private fun applyAudioPolicy(reason: String) {
474
+ executor.execute { applyAudioPolicyOnExecutor(reason) }
475
+ }
476
+
477
+ private fun applyAudioPolicyOnExecutor(reason: String) {
478
+ lastSafety = applier.apply(desiredKind(), muted.get(), reason)
479
+ if (lastSafety == AudioSafety.UNSAFE) {
480
+ Log.e(TAG, "audioSafety=unsafe — mute and stopAudio both failed; unintended mic may be live")
481
+ }
482
+ onState(snapshot())
483
+ }
484
+
485
+ private fun logIncomingRate(rate: Int, channels: Int, bytes: Int) {
486
+ val reading = incomingProbe.record(System.nanoTime(), bytes, rate, channels) ?: return
487
+ Log.i(
488
+ TAG,
489
+ "P8 audio-in declaredRate=${reading.declaredRate} ch=${reading.channels} " +
490
+ "samplesPerCallback=${reading.samplesPerCallback} " +
491
+ "callbackHz=${"%.2f".format(reading.callbackHz)} " +
492
+ "measuredRate=${reading.measuredRate.roundToInt()} " +
493
+ "prerollMs=${IncomingAudioPump.DEFAULT_PREROLL_MS} events=${incomingPump.eventsIn} " +
494
+ "in=${incomingPump.bytesIn} out16k=${incomingPump.bytesOut} " +
495
+ "formatChanges=${incomingPump.formatChanges}",
496
+ )
497
+ }
498
+
499
+ private fun feedOutgoingPcm(pcm: ByteArray, sampleRate: Int, channels: Int) {
500
+ if (muted.get()) {
501
+ val now = System.currentTimeMillis()
502
+ if (now - lastGatedLogMs >= 1000) {
503
+ lastGatedLogMs = now
504
+ Log.i(TAG, "outgoing PCM gated bytes=${pcm.size} (user muted)")
505
+ }
506
+ return
507
+ }
508
+ if (!outgoingReady.get()) return
509
+ // Resample here, but do not touch ACS: sending straight from this thread
510
+ // hands ACS the glasses' audio clock in bursts. The pacer decides when.
511
+ val frames = pcmBridge?.ingest(pcm, sampleRate, channels) ?: return
512
+ for (frame in frames) pacer.push(frame)
513
+ }
514
+
515
+ @Synchronized
516
+ private fun startUplink(stream: RawOutgoingAudioStream) {
517
+ if (uplinkSender != null) return
518
+ pacer.reset()
519
+ val sender = UplinkSender(pacer, AcsUplinkTransport(stream))
520
+ uplinkSender = sender
521
+ sender.start()
522
+ }
523
+
524
+ @Synchronized
525
+ private fun stopUplink() {
526
+ uplinkSender?.stop()
527
+ uplinkSender = null
528
+ pacer.reset()
529
+ }
530
+
531
+ private fun pushCallState(state: CallState) {
532
+ // A failed join has already reported a terminal error and torn the call
533
+ // down; ignore any late ACS state callback so it cannot overwrite error.
534
+ if (lastError != null) return
535
+ val previous = phase
536
+ phase = when (state) {
537
+ CallState.CONNECTING -> "connecting"
538
+ CallState.IN_LOBBY -> "lobby"
539
+ CallState.CONNECTED -> "connected"
540
+ CallState.DISCONNECTING -> "disconnected"
541
+ CallState.DISCONNECTED -> "disconnected"
542
+ else -> phase
543
+ }
544
+ val end = describeEndReason(call)
545
+ Log.i(TAG, "ACS call state=$state phase=$phase previous=$previous end=$end")
546
+ if (phase == "connected" && previous != "connected") {
547
+ call?.let {
548
+ attachMediaStats(it)
549
+ attachDiagnostics(it)
550
+ }
551
+ applyAudioPolicy("call-connected")
552
+ } else {
553
+ onState(snapshot())
554
+ }
555
+ }
556
+
557
+ private fun emit(next: String) {
558
+ phase = next
559
+ onState(snapshot())
560
+ }
561
+
562
+ private fun attachMediaStats(joined: Call) {
563
+ detachMediaStats()
564
+ mediaStatsReports.set(0)
565
+ try {
566
+ val feature = joined.feature(Features.MEDIA_STATISTICS)
567
+ val listener = MediaStatisticsReportReceivedListener { event ->
568
+ val outgoing = event.report?.outgoingStatistics
569
+ val videos = outgoing?.videoStatistics
570
+ val n = mediaStatsReports.incrementAndGet()
571
+ val video = videos?.firstOrNull()
572
+ if (n <= 8 || video == null) {
573
+ Log.i(
574
+ TAG,
575
+ "P6 wire report #$n videos=${videos?.size ?: 0} " +
576
+ "audios=${outgoing?.audioStatistics?.size ?: 0} " +
577
+ "codec=${video?.codecName ?: "na"} fps=${video?.frameRate ?: "na"}",
578
+ )
579
+ }
580
+ stats.wireFps = video?.frameRate?.toDouble()
581
+ stats.wireWidth = video?.frameWidth
582
+ stats.wireHeight = video?.frameHeight
583
+ stats.wireBitrateBps = video?.bitrateInBps?.toLong()
584
+ val codec = video?.codecName.orEmpty()
585
+ if (codec.isNotBlank() && codec != stats.codecName) {
586
+ Log.i(TAG, "P6 wire codec=$codec ${video?.frameWidth}x${video?.frameHeight} fps=${video?.frameRate}")
587
+ }
588
+ stats.codecName = codec
589
+ val width = video?.frameWidth
590
+ val height = video?.frameHeight
591
+ if (width != null && height != null && width > 0 && height > 0) {
592
+ stats.setSize(width, height)
593
+ }
594
+ }
595
+ feature.addOnReportReceivedListener(listener)
596
+ mediaStatsListener = listener
597
+ mediaStatsFeature = feature
598
+ // First attempt often throws while ACS is still spinning up the media
599
+ // stack (S26 Ultra never emitted a default-interval report). Retry after
600
+ // CONNECTED so codecName is not stuck at na.
601
+ scheduleMediaStatsInterval(feature, 0)
602
+ Log.i(TAG, "P6 wire hop attached")
603
+ } catch (error: Exception) {
604
+ Log.w(TAG, "MEDIA_STATISTICS attach failed", error)
605
+ }
606
+ }
607
+
608
+ private fun scheduleMediaStatsInterval(feature: MediaStatisticsCallFeature, attempt: Int) {
609
+ executor.schedule({
610
+ if (mediaStatsFeature !== feature) return@schedule
611
+ try {
612
+ feature.updateReportIntervalInSeconds(1)
613
+ Log.i(TAG, "P6 wire interval=1s attempt=$attempt")
614
+ } catch (error: Exception) {
615
+ Log.w(
616
+ TAG,
617
+ "P6 wire interval attempt=$attempt failed ${error.javaClass.simpleName}: ${error.message}",
618
+ )
619
+ if (attempt < 5) {
620
+ scheduleMediaStatsInterval(feature, attempt + 1)
621
+ }
622
+ }
623
+ }, if (attempt == 0) 0L else 2L, TimeUnit.SECONDS)
624
+ }
625
+
626
+ /**
627
+ * OutgoingVideoStatistics reports frameRate/bitrate/packetCount and nothing
628
+ * about loss or RTT, so `wire` cannot see a bad uplink. Microsoft's own
629
+ * "sender's video is frozen" guidance points at these diagnostics instead:
630
+ * they are the only send-side network signal the SDK exposes.
631
+ */
632
+ private fun attachDiagnostics(joined: Call) {
633
+ detachDiagnostics()
634
+ try {
635
+ val feature = joined.feature(Features.LOCAL_USER_DIAGNOSTICS) as LocalUserDiagnosticsCallFeature
636
+ val network = feature.networkDiagnostics
637
+ val onSend = DiagnosticQualityChangedListener { args ->
638
+ logDiagnostic("networkSendQuality", args.value?.name ?: "null")
639
+ }
640
+ val onReconnect = DiagnosticQualityChangedListener { args ->
641
+ logDiagnostic("networkReconnectionQuality", args.value?.name ?: "null")
642
+ }
643
+ val onNoNetwork = DiagnosticFlagChangedListener { args ->
644
+ logDiagnostic("networkUnavailable", args.value.toString())
645
+ }
646
+ val onRelays = DiagnosticFlagChangedListener { args ->
647
+ logDiagnostic("networkRelaysUnreachable", args.value.toString())
648
+ }
649
+ network.addOnNetworkSendQualityChangedListener(onSend)
650
+ network.addOnNetworkReconnectionQualityChangedListener(onReconnect)
651
+ network.addOnIsNetworkUnavailableChangedListener(onNoNetwork)
652
+ network.addOnIsNetworkRelaysUnreachableChangedListener(onRelays)
653
+ netDiagnostics = network
654
+ sendQualityListener = onSend
655
+ reconnectListener = onReconnect
656
+ noNetworkListener = onNoNetwork
657
+ relaysListener = onRelays
658
+ Log.i(TAG, "P7 diagnostics attached")
659
+ } catch (error: Exception) {
660
+ Log.w(TAG, "LOCAL_USER_DIAGNOSTICS attach failed", error)
661
+ }
662
+ }
663
+
664
+ private fun logDiagnostic(name: String, value: String) {
665
+ Log.i(TAG, "P7 diag $name=$value")
666
+ }
667
+
668
+ private fun detachDiagnostics() {
669
+ val network = netDiagnostics ?: return
670
+ try {
671
+ sendQualityListener?.let { network.removeOnNetworkSendQualityChangedListener(it) }
672
+ reconnectListener?.let { network.removeOnNetworkReconnectionQualityChangedListener(it) }
673
+ noNetworkListener?.let { network.removeOnIsNetworkUnavailableChangedListener(it) }
674
+ relaysListener?.let { network.removeOnIsNetworkRelaysUnreachableChangedListener(it) }
675
+ } catch (_: Exception) {
676
+ }
677
+ netDiagnostics = null
678
+ sendQualityListener = null
679
+ reconnectListener = null
680
+ noNetworkListener = null
681
+ relaysListener = null
682
+ }
683
+
684
+ private fun detachMediaStats() {
685
+ val listener = mediaStatsListener ?: return
686
+ val feature = mediaStatsFeature
687
+ mediaStatsListener = null
688
+ mediaStatsFeature = null
689
+ try {
690
+ // Must be the same feature instance we added to: call.feature() can hand back a
691
+ // fresh wrapper, and removing from that leaves the listener live on a leaving call.
692
+ feature?.removeOnReportReceivedListener(listener)
693
+ } catch (_: Exception) {
694
+ }
695
+ }
696
+
697
+ private fun leaveLocked(emitIdle: Boolean = true) {
698
+ try {
699
+ ticker.stop()
700
+ detachDiagnostics()
701
+ detachMediaStats()
702
+ roster.detach()
703
+ phoneMic.setEnabled(false)
704
+ stopUplink()
705
+ incomingPump.reset()
706
+ incomingProbe.reset()
707
+ applier.reset()
708
+ scheduler.cancelPending()
709
+ pcmBridge?.finishDump()
710
+ // Detach before stop so the teardown's own IDLE transition does not emit a
711
+ // snapshot (or schedule a rebuild) for a call that is going away.
712
+ cancelMediaRestart()
713
+ media.setStateListener(null)
714
+ mediaSource = SourceState.IDLE
715
+ media.stop()
716
+ frameSender.detach()
717
+ } catch (error: Exception) {
718
+ Log.w(TAG, "leave cleanup failed", error)
719
+ }
720
+ // Hang up and dispose must be independent: a failed hang-up must not skip
721
+ // dispose, or the ACS agent leaks and the guest stays in the Teams roster.
722
+ try {
723
+ call?.hangUp()?.get()
724
+ } catch (error: Exception) {
725
+ Log.w(TAG, "leave hangUp failed", error)
726
+ }
727
+ try {
728
+ callAgent?.dispose()
729
+ } catch (error: Exception) {
730
+ Log.w(TAG, "leave dispose failed", error)
731
+ }
732
+ callClient = null
733
+ media.stop()
734
+ call = null
735
+ callAgent = null
736
+ audioOut = null
737
+ localOut = null
738
+ audioIn = null
739
+ videoOut = null
740
+ outgoingReady.set(false)
741
+ muted.set(false)
742
+ audioSource = "glasses"
743
+ lastSafety = AudioSafety.DEGRADED
744
+ meetingUrl = null
745
+ // Clearing lastError is scoped to the clean idle reset. A failed join tears
746
+ // down with emitIdle=false and relies on lastError staying set so emit("error")
747
+ // still carries it and pushCallState keeps ignoring late disconnected callbacks.
748
+ if (emitIdle) {
749
+ lastError = null
750
+ emit("idle")
751
+ }
752
+ }
753
+
754
+ private inner class SessionAudioController : AudioStreamController {
755
+ override fun readActive(): ActiveStreamKind {
756
+ val stream = call?.activeOutgoingAudioStream ?: return ActiveStreamKind.NONE
757
+ if (stream.state != AudioStreamState.STARTED) return ActiveStreamKind.NONE
758
+ return when (stream.type) {
759
+ AudioStreamType.VIRTUAL_OUTGOING -> ActiveStreamKind.VIRTUAL
760
+ AudioStreamType.LOCAL_OUTGOING -> ActiveStreamKind.LOCAL
761
+ else -> ActiveStreamKind.NONE
762
+ }
763
+ }
764
+
765
+ override fun isPhysicallyMuted(): Boolean? = call?.isOutgoingAudioMuted
766
+
767
+ override fun setGlassesPcmEnabled(enabled: Boolean) {
768
+ media.setPcmDeliveryEnabled(enabled)
769
+ }
770
+
771
+ override fun setPhonePcmEnabled(enabled: Boolean) {
772
+ phoneMic.setEnabled(enabled)
773
+ }
774
+
775
+ override fun mutePhysical(): Result<Unit> {
776
+ val c = CallGuard.require(call).getOrElse { return Result.failure(it) }
777
+ return runCatching { c.muteOutgoingAudio(context).get() }
778
+ }
779
+
780
+ override fun unmutePhysical(): Result<Unit> {
781
+ val c = CallGuard.require(call).getOrElse { return Result.failure(it) }
782
+ return runCatching { c.unmuteOutgoingAudio(context).get() }
783
+ }
784
+
785
+ override fun stopActive(): Result<Unit> {
786
+ val c = CallGuard.require(call).getOrElse { return Result.failure(it) }
787
+ val stream = c.activeOutgoingAudioStream
788
+ ?: return Result.failure(IllegalStateException("no active stream"))
789
+ return runCatching { c.stopAudio(context, stream).get() }
790
+ }
791
+ }
792
+
793
+ companion object {
794
+ private const val TAG = "ACS-SPIKE"
795
+ const val GLASSES_REQUIRES_UNMUTED_TRANSPORT = true
796
+ private const val ROSTER_COALESCE_MS = 150L
797
+ private const val MEDIA_RESTART_BASE_MS = 1_000L
798
+ private const val MEDIA_RESTART_MAX_MS = 10_000L
799
+
800
+ fun sampleRateHz(rate: AudioStreamSampleRate?): Int? = when (rate) {
801
+ AudioStreamSampleRate.HZ_16000 -> 16000
802
+ AudioStreamSampleRate.HZ_22050 -> 22050
803
+ AudioStreamSampleRate.HZ_24000 -> 24000
804
+ AudioStreamSampleRate.HZ_32000 -> 32000
805
+ AudioStreamSampleRate.HZ_44100 -> 44100
806
+ AudioStreamSampleRate.HZ_48000 -> 48000
807
+ null -> null
808
+ }
809
+
810
+ private fun describeEndReason(call: Call?): Map<String, Any?> {
811
+ if (call == null) return mapOf("hasCall" to false)
812
+ return try {
813
+ val getter = call.javaClass.methods.firstOrNull {
814
+ it.name == "getCallEndReason" || it.name == "getEndReason"
815
+ }
816
+ val reason = getter?.invoke(call) ?: return mapOf("hasCall" to true, "endReason" to "null")
817
+ val methods = reason.javaClass.methods
818
+ fun pick(vararg names: String): Any? =
819
+ names.firstNotNullOfOrNull { name -> methods.firstOrNull { it.name == name && it.parameterCount == 0 }?.invoke(reason) }
820
+ val message = pick("getMessage")?.toString()?.take(120)
821
+ mapOf(
822
+ "hasCall" to true,
823
+ "code" to pick("getCode"),
824
+ "subcode" to pick("getSubcode", "getSubCode"),
825
+ "message" to message,
826
+ )
827
+ } catch (error: Throwable) {
828
+ mapOf("hasCall" to true, "endReasonError" to "${error.javaClass.simpleName}:${error.message ?: ""}")
829
+ }
830
+ }
831
+
832
+ private fun formatAcsError(error: Throwable): String {
833
+ val parts = linkedSetOf<String>()
834
+ var current: Throwable? = error
835
+ var depth = 0
836
+ while (current != null && depth < 4) {
837
+ val message = current.message?.trim().orEmpty()
838
+ val piece = if (message.isNotEmpty()) "${current.javaClass.simpleName}: $message" else current.javaClass.simpleName
839
+ parts.add(piece)
840
+ current = current.cause
841
+ depth += 1
842
+ }
843
+ return parts.joinToString(" | ").ifBlank { "ACS join failed" }
844
+ }
845
+ }
846
+ }