@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,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
|
+
}
|