@mentra/acs-meeting 3.2.0-dev.222 → 3.2.0-dev.225
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/README.md +14 -3
- package/android/build.gradle +1 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +10 -10
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +37 -37
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +4 -4
- package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +3 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +12 -10
- package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +2 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/Nv12Packer.kt +2 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/{AcsInvestigationTest.kt → MediaDiagnosticsTest.kt} +10 -10
- package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +3 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +2 -2
- package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +2 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +2 -2
- package/build/AcsMeeting.types.d.ts +10 -1
- package/build/AcsMeeting.types.d.ts.map +1 -1
- package/build/AcsMeeting.types.js.map +1 -1
- package/build/AcsMeetingModule.d.ts +2 -2
- package/build/AcsMeetingModule.d.ts.map +1 -1
- package/build/AcsMeetingModule.js.map +1 -1
- package/ios/AcsMeeting.podspec +1 -1
- package/ios/AcsMeetingModule.swift +284 -148
- package/ios/PolicyKit/Package.swift +2 -1
- package/ios/PolicyKit/Sources/AcsAudioPolicy/CallbackOperation.swift +60 -0
- package/ios/PolicyKit/Sources/AcsAudioPolicy/CapturePolicy.swift +5 -0
- package/ios/PolicyKit/Tests/AcsAudioPolicyTests/AcsAudioPolicyTests.swift +1 -0
- package/ios/PolicyKit/Tests/AcsAudioPolicyTests/CallAgentRetirementTests.swift +113 -0
- package/package.json +3 -2
- package/src/AcsMeeting.types.ts +4 -1
- package/src/AcsMeetingModule.ts +2 -2
- package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +0 -291
- package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +0 -501
- package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +0 -51
- package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +0 -81
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +0 -355
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +0 -109
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +0 -160
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +0 -103
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +0 -53
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +0 -127
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +0 -573
- package/android/src/main/java/com/mentra/acsmeeting/source/BitmapFont.kt +0 -99
- package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +0 -487
- package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +0 -147
- package/android/src/main/java/com/mentra/acsmeeting/source/FirstFrameGate.kt +0 -45
- package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +0 -143
- package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +0 -772
- package/android/src/main/java/com/mentra/acsmeeting/source/MediaListeners.kt +0 -57
- package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +0 -17
- package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +0 -139
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +0 -144
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +0 -46
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +0 -160
- package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticFrameFactory.kt +0 -205
- package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticI420Source.kt +0 -214
- package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +0 -33
- package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +0 -110
- package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +0 -207
- package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +0 -356
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +0 -155
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/ChromaProbe.kt +0 -78
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +0 -372
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +0 -79
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/ProcessCpu.kt +0 -15
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +0 -48
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +0 -178
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +0 -145
- package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +0 -143
- package/android/src/main/java/com/mentra/acsmeeting/video/FrameGeometry.kt +0 -22
- package/android/src/main/java/com/mentra/acsmeeting/video/I420Packer.kt +0 -72
- package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +0 -102
- package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +0 -148
- package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +0 -59
- package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +0 -59
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +0 -35
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +0 -165
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +0 -96
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +0 -187
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +0 -172
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +0 -141
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +0 -69
- package/android/src/test/java/com/mentra/acsmeeting/source/BitmapFontTest.kt +0 -22
- package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +0 -41
- package/android/src/test/java/com/mentra/acsmeeting/source/FirstFrameGateTest.kt +0 -60
- package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +0 -151
- package/android/src/test/java/com/mentra/acsmeeting/source/I420PlanesTest.kt +0 -67
- package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +0 -165
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +0 -148
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +0 -76
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +0 -275
- package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticFrameFactoryTest.kt +0 -193
- package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticI420SourceTest.kt +0 -176
- package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +0 -45
- package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +0 -356
- package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +0 -411
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +0 -83
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/ChromaProbeTest.kt +0 -61
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +0 -322
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/ProcessCpuTest.kt +0 -18
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/RingPercentileTest.kt +0 -49
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +0 -149
- package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +0 -97
- package/android/src/test/java/com/mentra/acsmeeting/video/FrameGeometryTest.kt +0 -33
- package/android/src/test/java/com/mentra/acsmeeting/video/I420PackerTest.kt +0 -126
- package/ios/PolicyKit/Sources/AcsAudioPolicy/GlassesMediaSource.swift +0 -68
- package/ios/WhepVideoSource.swift +0 -356
|
@@ -1,487 +0,0 @@
|
|
|
1
|
-
package com.mentra.acsmeeting.source
|
|
2
|
-
|
|
3
|
-
import android.content.Context
|
|
4
|
-
import android.media.AudioAttributes
|
|
5
|
-
import android.util.Log
|
|
6
|
-
import com.mentra.acsmeeting.telemetry.PipelineStats
|
|
7
|
-
import com.mentra.acsmeeting.video.I420Packer
|
|
8
|
-
import okhttp3.MediaType.Companion.toMediaType
|
|
9
|
-
import okhttp3.OkHttpClient
|
|
10
|
-
import okhttp3.Request
|
|
11
|
-
import okhttp3.RequestBody.Companion.toRequestBody
|
|
12
|
-
import org.webrtc.AudioTrack
|
|
13
|
-
import org.webrtc.DefaultVideoDecoderFactory
|
|
14
|
-
import org.webrtc.DefaultVideoEncoderFactory
|
|
15
|
-
import org.webrtc.EglBase
|
|
16
|
-
import org.webrtc.IceCandidate
|
|
17
|
-
import org.webrtc.MediaConstraints
|
|
18
|
-
import org.webrtc.MediaStream
|
|
19
|
-
import org.webrtc.PeerConnection
|
|
20
|
-
import org.webrtc.PeerConnectionFactory
|
|
21
|
-
import org.webrtc.RtpTransceiver
|
|
22
|
-
import org.webrtc.SdpObserver
|
|
23
|
-
import org.webrtc.SessionDescription
|
|
24
|
-
import org.webrtc.VideoTrack
|
|
25
|
-
import org.webrtc.audio.JavaAudioDeviceModule
|
|
26
|
-
import java.nio.ByteBuffer
|
|
27
|
-
import java.util.concurrent.CopyOnWriteArrayList
|
|
28
|
-
import java.util.concurrent.TimeUnit
|
|
29
|
-
import java.util.concurrent.atomic.AtomicLong
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Recvonly WHEP subscriber. Decoded I420 planes are handed to ACS (no RGB
|
|
33
|
-
* convert, no pack-then-split). Scale uses libyuv cropAndScale in buffer
|
|
34
|
-
* coordinates. Remote AudioTrackSink PCM is the P4 hard gate.
|
|
35
|
-
*/
|
|
36
|
-
class CloudflareWhepSource(
|
|
37
|
-
private val context: Context,
|
|
38
|
-
private val videoListener: VideoFrameListener,
|
|
39
|
-
private val pcmListener: PcmListener,
|
|
40
|
-
private val stats: PipelineStats = PipelineStats(),
|
|
41
|
-
) : GlassesMediaSource {
|
|
42
|
-
private val http = OkHttpClient.Builder().callTimeout(20, TimeUnit.SECONDS).build()
|
|
43
|
-
private var factory: PeerConnectionFactory? = null
|
|
44
|
-
private var egl: EglBase? = null
|
|
45
|
-
private var pc: PeerConnection? = null
|
|
46
|
-
private var currentUrl: String? = null
|
|
47
|
-
@Volatile private var offerPosted = false
|
|
48
|
-
@Volatile override var state: SourceState = SourceState.IDLE
|
|
49
|
-
private set
|
|
50
|
-
@Volatile private var stateListener: SourceStateListener? = null
|
|
51
|
-
// Every generation of the peer gets its own id so a callback from a disposed
|
|
52
|
-
// PeerConnection (ICE FAILED racing a rebuild) cannot mark the new one failed.
|
|
53
|
-
@Volatile private var generation = 0
|
|
54
|
-
// attachAudio runs on the WebRTC signaling thread, setPcmDeliveryEnabled on the RN
|
|
55
|
-
// bridge thread, stop() on the session thread: a plain list threw CME on mute toggles.
|
|
56
|
-
private val audioTracks = CopyOnWriteArrayList<AudioTrack>()
|
|
57
|
-
private val videoIds = TrackRegistry()
|
|
58
|
-
private val audioIds = TrackRegistry()
|
|
59
|
-
@Volatile private var attachedVideo: VideoTrack? = null
|
|
60
|
-
// Shared with the SoftAP path: past the decoder, a Cloudflare hop and a hotspot hop are the
|
|
61
|
-
// same I420 planes going to the same ACS sender.
|
|
62
|
-
private val relay = DecodedTrackRelay(videoListener, pcmListener, stats) { notePromotableFrame() }
|
|
63
|
-
private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
|
64
|
-
private var pendingOfferPost: Runnable? = null
|
|
65
|
-
// LIVE means "ACS is being handed frames", not "the WHEP endpoint answered".
|
|
66
|
-
private val firstFrame = FirstFrameGate()
|
|
67
|
-
@Volatile private var firstFrameDeadline: Runnable? = null
|
|
68
|
-
private var lastDecodedFrames = -1L
|
|
69
|
-
private var lastDecodedAtMs = 0L
|
|
70
|
-
private var lastReceivedFrames = -1L
|
|
71
|
-
private val statsPoll = object : Runnable {
|
|
72
|
-
override fun run() {
|
|
73
|
-
pollDecodedStats()
|
|
74
|
-
mainHandler.postDelayed(this, STATS_POLL_MS)
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
override fun start(config: SourceConfig) {
|
|
79
|
-
stop()
|
|
80
|
-
currentUrl = config.url
|
|
81
|
-
offerPosted = false
|
|
82
|
-
val gen = ++generation
|
|
83
|
-
transition(SourceState.CONNECTING, "start")
|
|
84
|
-
// Timer-driven, not sink-driven: a frame stall must still be sampled, or dec
|
|
85
|
-
// freezes at a stale value during exactly the freezes we are chasing.
|
|
86
|
-
mainHandler.removeCallbacks(statsPoll)
|
|
87
|
-
mainHandler.postDelayed(statsPoll, STATS_POLL_MS)
|
|
88
|
-
ensureFactory()
|
|
89
|
-
val peer = factory!!.createPeerConnection(iceServers(), observerFor(gen)) ?: run {
|
|
90
|
-
transition(SourceState.FAILED, "peer_create_failed")
|
|
91
|
-
error("PeerConnection create failed")
|
|
92
|
-
}
|
|
93
|
-
pc = peer
|
|
94
|
-
peer.addTransceiver(org.webrtc.MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO, recvOnly())
|
|
95
|
-
peer.addTransceiver(org.webrtc.MediaStreamTrack.MediaType.MEDIA_TYPE_AUDIO, recvOnly())
|
|
96
|
-
peer.createOffer(
|
|
97
|
-
object : SdpAdapter() {
|
|
98
|
-
override fun onCreateSuccess(sdp: SessionDescription) {
|
|
99
|
-
peer.setLocalDescription(object : SdpAdapter() {
|
|
100
|
-
override fun onSetSuccess() {
|
|
101
|
-
val runnable = Runnable { postOfferIfNeeded() }
|
|
102
|
-
pendingOfferPost = runnable
|
|
103
|
-
mainHandler.postDelayed(runnable, 2000)
|
|
104
|
-
}
|
|
105
|
-
}, sdp)
|
|
106
|
-
}
|
|
107
|
-
},
|
|
108
|
-
MediaConstraints(),
|
|
109
|
-
)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
override fun restart(config: SourceConfig) {
|
|
113
|
-
// Reuse the existing subscriber only when the URL is unchanged AND the peer is
|
|
114
|
-
// still healthy. After ICE DISCONNECTED/FAILED (e.g. a Wi-Fi drop that
|
|
115
|
-
// reuses the same Cloudflare WHEP URL) we must rebuild, or glasses video and
|
|
116
|
-
// mic never recover. See SourceReusePolicy for why CONNECTING is reusable.
|
|
117
|
-
if (SourceReusePolicy.canReuse(currentUrl, state, config)) return
|
|
118
|
-
start(config)
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
override fun forceRestart() {
|
|
122
|
-
val url = currentUrl ?: return
|
|
123
|
-
Log.i(TAG, "WHEP forced rebuild state=$state")
|
|
124
|
-
start(SourceConfig(url))
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
override fun setStateListener(listener: SourceStateListener?) {
|
|
128
|
-
stateListener = listener
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
private fun transition(next: SourceState, reason: String) {
|
|
132
|
-
val previous = state
|
|
133
|
-
state = next
|
|
134
|
-
if (previous == next) return
|
|
135
|
-
Log.i(TAG, "WHEP source $previous -> $next ($reason)")
|
|
136
|
-
try {
|
|
137
|
-
stateListener?.onSourceState(next, reason)
|
|
138
|
-
} catch (error: Exception) {
|
|
139
|
-
Log.w(TAG, "source state listener threw", error)
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
override fun setPcmDeliveryEnabled(enabled: Boolean) {
|
|
144
|
-
relay.setPcmDeliveryEnabled(enabled)
|
|
145
|
-
Log.i(TAG, "WHEP audio PCM delivery enabled=$enabled")
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
override fun setTargetSize(size: TargetSize?) {
|
|
149
|
-
relay.setTargetSize(size)
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
override fun stop() {
|
|
153
|
-
currentUrl = null
|
|
154
|
-
offerPosted = false
|
|
155
|
-
pendingOfferPost?.let { mainHandler.removeCallbacks(it) }
|
|
156
|
-
pendingOfferPost = null
|
|
157
|
-
// Disarmed before removeSink, so a sink callback already in flight cannot
|
|
158
|
-
// promote the source we are tearing down.
|
|
159
|
-
cancelFirstFrameDeadline()
|
|
160
|
-
firstFrame.reset()
|
|
161
|
-
mainHandler.removeCallbacks(statsPoll)
|
|
162
|
-
try {
|
|
163
|
-
attachedVideo?.removeSink(videoSink)
|
|
164
|
-
} catch (_: Exception) {
|
|
165
|
-
}
|
|
166
|
-
attachedVideo = null
|
|
167
|
-
videoIds.reset()
|
|
168
|
-
audioIds.reset()
|
|
169
|
-
audioTracks.clear()
|
|
170
|
-
// Invalidate callbacks from the peer being disposed below before they can
|
|
171
|
-
// observe the IDLE we are about to set.
|
|
172
|
-
generation++
|
|
173
|
-
transition(SourceState.IDLE, "stop")
|
|
174
|
-
lastDecodedFrames = -1L
|
|
175
|
-
lastDecodedAtMs = 0L
|
|
176
|
-
lastReceivedFrames = -1L
|
|
177
|
-
relay.resetRotationLog()
|
|
178
|
-
// close() stops media; dispose() is what frees the native peer. Without it every
|
|
179
|
-
// WHEP restart leaks a PeerConnection. dispose() also blocks until observer and
|
|
180
|
-
// sink callbacks quiesce, so it must run after removeSink and before dest is reused.
|
|
181
|
-
val peer = pc
|
|
182
|
-
pc = null
|
|
183
|
-
try {
|
|
184
|
-
peer?.close()
|
|
185
|
-
peer?.dispose()
|
|
186
|
-
} catch (error: Exception) {
|
|
187
|
-
Log.w(TAG, "WHEP peer dispose failed", error)
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
private fun ensureFactory() {
|
|
192
|
-
if (factory != null) return
|
|
193
|
-
PeerConnectionFactory.initialize(
|
|
194
|
-
PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions(),
|
|
195
|
-
)
|
|
196
|
-
val mode = AcsInvestigation.decoderMode
|
|
197
|
-
val shared = if (mode == DecoderMode.TEXTURE) {
|
|
198
|
-
EglBase.create().also { egl = it }.eglBaseContext
|
|
199
|
-
} else {
|
|
200
|
-
null
|
|
201
|
-
}
|
|
202
|
-
// libwebrtc renders every received audio track through its device module.
|
|
203
|
-
// That is the wearer's own mic coming back out of the phone (or, over
|
|
204
|
-
// A2DP, the glasses) — the feedback loop. The mixer must still run so the
|
|
205
|
-
// AudioTrackSink gets PCM, so we keep playout but make it inert: media
|
|
206
|
-
// attributes (never a voice-call route) and per-track volume 0 below.
|
|
207
|
-
val adm = JavaAudioDeviceModule.builder(context)
|
|
208
|
-
.setAudioAttributes(
|
|
209
|
-
AudioAttributes.Builder()
|
|
210
|
-
.setUsage(AudioAttributes.USAGE_MEDIA)
|
|
211
|
-
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
|
212
|
-
.build(),
|
|
213
|
-
)
|
|
214
|
-
.setUseHardwareAcousticEchoCanceler(false)
|
|
215
|
-
.setUseHardwareNoiseSuppressor(false)
|
|
216
|
-
.setAudioTrackStateCallback(object : JavaAudioDeviceModule.AudioTrackStateCallback {
|
|
217
|
-
override fun onWebRtcAudioTrackStart() {
|
|
218
|
-
Log.i(TAG, "WHEP ADM playout started (silenced; sink-only)")
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
override fun onWebRtcAudioTrackStop() {
|
|
222
|
-
Log.i(TAG, "WHEP ADM playout stopped")
|
|
223
|
-
}
|
|
224
|
-
})
|
|
225
|
-
.setAudioRecordStateCallback(object : JavaAudioDeviceModule.AudioRecordStateCallback {
|
|
226
|
-
override fun onWebRtcAudioRecordStart() {
|
|
227
|
-
// recv-only transceivers must never open the phone microphone.
|
|
228
|
-
Log.e(TAG, "WHEP ADM started RECORDING — unexpected phone mic capture")
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
override fun onWebRtcAudioRecordStop() {
|
|
232
|
-
Log.i(TAG, "WHEP ADM recording stopped")
|
|
233
|
-
}
|
|
234
|
-
})
|
|
235
|
-
.createAudioDeviceModule()
|
|
236
|
-
// Belt and braces with the per-track volume: the device module zeroes
|
|
237
|
-
// its playout buffer, and recording is disabled outright.
|
|
238
|
-
adm.setSpeakerMute(true)
|
|
239
|
-
adm.setAudioRecordEnabled(false)
|
|
240
|
-
factory = PeerConnectionFactory.builder()
|
|
241
|
-
.setAudioDeviceModule(adm)
|
|
242
|
-
.setVideoEncoderFactory(DefaultVideoEncoderFactory(shared, true, true))
|
|
243
|
-
.setVideoDecoderFactory(DefaultVideoDecoderFactory(shared))
|
|
244
|
-
.createPeerConnectionFactory()
|
|
245
|
-
adm.release()
|
|
246
|
-
Log.i(TAG, "P3 factory decoderMode=$mode egl=${shared != null} adm=media-silent")
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
@Synchronized
|
|
250
|
-
private fun postOfferIfNeeded() {
|
|
251
|
-
if (offerPosted) return
|
|
252
|
-
val url = currentUrl ?: return
|
|
253
|
-
val peer = pc ?: return
|
|
254
|
-
val offer = peer.localDescription?.description ?: return
|
|
255
|
-
offerPosted = true
|
|
256
|
-
postOffer(url, offer, peer, generation)
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
private fun postOffer(url: String, offer: String, peer: PeerConnection, gen: Int) {
|
|
260
|
-
val request = Request.Builder()
|
|
261
|
-
.url(url)
|
|
262
|
-
.header("Content-Type", "application/sdp")
|
|
263
|
-
.post(offer.toRequestBody("application/sdp".toMediaType()))
|
|
264
|
-
.build()
|
|
265
|
-
http.newCall(request).enqueue(object : okhttp3.Callback {
|
|
266
|
-
override fun onFailure(call: okhttp3.Call, e: java.io.IOException) {
|
|
267
|
-
if (gen != generation) return
|
|
268
|
-
Log.e(TAG, "WHEP POST failed", e)
|
|
269
|
-
transition(SourceState.FAILED, "whep_post_failed:${e.javaClass.simpleName}")
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) {
|
|
273
|
-
response.use {
|
|
274
|
-
val answer = it.body?.string().orEmpty()
|
|
275
|
-
if (gen != generation) return
|
|
276
|
-
if (!it.isSuccessful) {
|
|
277
|
-
Log.e(TAG, "WHEP ${it.code}: $answer")
|
|
278
|
-
transition(SourceState.FAILED, "whep_http_${it.code}")
|
|
279
|
-
return
|
|
280
|
-
}
|
|
281
|
-
Log.i(TAG, "P3 WHEP ${it.code} answer bytes=${answer.length}")
|
|
282
|
-
// Stay CONNECTING: setRemoteDescription, ICE and the decoder still have
|
|
283
|
-
// to run, and on device that is 3-6 s before ACS submits a frame.
|
|
284
|
-
armFirstFrame(gen)
|
|
285
|
-
peer.setRemoteDescription(
|
|
286
|
-
SdpAdapter(),
|
|
287
|
-
SessionDescription(SessionDescription.Type.ANSWER, answer),
|
|
288
|
-
)
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
})
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
private fun observerFor(gen: Int) = object : PeerConnection.Observer {
|
|
295
|
-
override fun onSignalingChange(state: PeerConnection.SignalingState) {}
|
|
296
|
-
override fun onIceConnectionChange(state: PeerConnection.IceConnectionState) {
|
|
297
|
-
Log.i(TAG, "ICE $state")
|
|
298
|
-
if (gen != generation) return
|
|
299
|
-
if (state == PeerConnection.IceConnectionState.FAILED ||
|
|
300
|
-
state == PeerConnection.IceConnectionState.DISCONNECTED
|
|
301
|
-
) {
|
|
302
|
-
transition(SourceState.FAILED, "ice_${state.name.lowercase()}")
|
|
303
|
-
} else if (state == PeerConnection.IceConnectionState.CONNECTED ||
|
|
304
|
-
state == PeerConnection.IceConnectionState.COMPLETED
|
|
305
|
-
) {
|
|
306
|
-
// ICE can bounce DISCONNECTED → CONNECTED on its own (consent freshness
|
|
307
|
-
// hiccup); only act if the answer already arrived. LIVE now means a frame
|
|
308
|
-
// reached the sink and a recovered candidate pair is only a promise of
|
|
309
|
-
// one, so go back to CONNECTING and let the next frame promote. Frames
|
|
310
|
-
// that were already flowing promote immediately; if none arrive the
|
|
311
|
-
// rearmed deadline fails us again and the session's rebuild backoff runs.
|
|
312
|
-
if (this@CloudflareWhepSource.state == SourceState.FAILED && offerPosted) {
|
|
313
|
-
transition(SourceState.CONNECTING, "ice_recovered")
|
|
314
|
-
armFirstFrame(gen)
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
override fun onIceConnectionReceivingChange(receiving: Boolean) {}
|
|
319
|
-
override fun onIceGatheringChange(state: PeerConnection.IceGatheringState) {
|
|
320
|
-
Log.i(TAG, "ICE gathering $state")
|
|
321
|
-
if (gen != generation) return
|
|
322
|
-
if (state == PeerConnection.IceGatheringState.COMPLETE) postOfferIfNeeded()
|
|
323
|
-
}
|
|
324
|
-
override fun onIceCandidate(candidate: IceCandidate) {}
|
|
325
|
-
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>) {}
|
|
326
|
-
// Unified Plan + recv-only transceivers: onTrack is the only attach path.
|
|
327
|
-
// onAddStream / onAddTrack still fire for the same track and used to triple-addSink.
|
|
328
|
-
override fun onAddStream(stream: MediaStream) {}
|
|
329
|
-
override fun onRemoveStream(stream: MediaStream) {}
|
|
330
|
-
override fun onDataChannel(channel: org.webrtc.DataChannel) {}
|
|
331
|
-
override fun onRenegotiationNeeded() {}
|
|
332
|
-
override fun onAddTrack(receiver: org.webrtc.RtpReceiver, streams: Array<out MediaStream>) {}
|
|
333
|
-
override fun onTrack(transceiver: RtpTransceiver) {
|
|
334
|
-
if (gen != generation) return
|
|
335
|
-
when (val track = transceiver.receiver.track()) {
|
|
336
|
-
is VideoTrack -> attachVideo(track)
|
|
337
|
-
is AudioTrack -> attachAudio(track)
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
private fun attachVideo(track: VideoTrack) {
|
|
343
|
-
val id = track.id()
|
|
344
|
-
if (!videoIds.claim(id)) {
|
|
345
|
-
stats.onDup()
|
|
346
|
-
Log.i(TAG, "P3 attach kind=video id=$id attached=${videoIds.size()} skipped=${videoIds.skipped()}")
|
|
347
|
-
return
|
|
348
|
-
}
|
|
349
|
-
attachedVideo = track
|
|
350
|
-
track.addSink(videoSink)
|
|
351
|
-
Log.i(TAG, "P3 attach kind=video id=$id attached=${videoIds.size()} skipped=${videoIds.skipped()}")
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* Arms the first-frame gate for [gen] and bounds the wait.
|
|
356
|
-
*
|
|
357
|
-
* An answered WHEP that never delivers a frame used to read LIVE forever
|
|
358
|
-
* behind a frozen image, with nothing above this layer able to see it.
|
|
359
|
-
* Expiring into FAILED hands it to [SourceStateListener], whose rebuild
|
|
360
|
-
* backoff is the only thing that can repair it.
|
|
361
|
-
*/
|
|
362
|
-
private fun armFirstFrame(gen: Int) {
|
|
363
|
-
firstFrame.arm(gen)
|
|
364
|
-
cancelFirstFrameDeadline()
|
|
365
|
-
val task = Runnable {
|
|
366
|
-
firstFrameDeadline = null
|
|
367
|
-
if (gen != generation || !firstFrame.expired(gen)) return@Runnable
|
|
368
|
-
Log.w(TAG, "WHEP answered but delivered no frame in ${FIRST_FRAME_TIMEOUT_MS}ms")
|
|
369
|
-
transition(SourceState.FAILED, "no_first_frame")
|
|
370
|
-
}
|
|
371
|
-
firstFrameDeadline = task
|
|
372
|
-
mainHandler.postDelayed(task, FIRST_FRAME_TIMEOUT_MS)
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
private fun cancelFirstFrameDeadline() {
|
|
376
|
-
firstFrameDeadline?.let { mainHandler.removeCallbacks(it) }
|
|
377
|
-
firstFrameDeadline = null
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/** The one honest LIVE: a decoded frame is on its way to ACS. */
|
|
381
|
-
private fun notePromotableFrame() {
|
|
382
|
-
if (!firstFrame.onFrame(generation)) return
|
|
383
|
-
cancelFirstFrameDeadline()
|
|
384
|
-
transition(SourceState.LIVE, "first_frame")
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
private val videoSink get() = relay.videoSink
|
|
388
|
-
|
|
389
|
-
private fun attachAudio(track: AudioTrack) {
|
|
390
|
-
val id = track.id()
|
|
391
|
-
if (!audioIds.claim(id)) {
|
|
392
|
-
stats.onDup()
|
|
393
|
-
Log.i(TAG, "P3 attach kind=audio id=$id attached=${audioIds.size()} skipped=${audioIds.skipped()}")
|
|
394
|
-
return
|
|
395
|
-
}
|
|
396
|
-
audioTracks.add(track)
|
|
397
|
-
// Output gain is applied after the raw sink callback in ChannelReceive, so
|
|
398
|
-
// volume 0 silences local playout while the sink still gets full-scale PCM.
|
|
399
|
-
track.setVolume(0.0)
|
|
400
|
-
track.setEnabled(true)
|
|
401
|
-
track.addSink(relay.audioSink)
|
|
402
|
-
Log.i(TAG, "P3 attach kind=audio id=$id attached=${audioIds.size()} skipped=${audioIds.skipped()} playoutVolume=0")
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
private fun pollDecodedStats() {
|
|
406
|
-
val peer = pc ?: return
|
|
407
|
-
peer.getStats { report ->
|
|
408
|
-
for (statsReport in report.statsMap.values) {
|
|
409
|
-
if (statsReport.type != "inbound-rtp") continue
|
|
410
|
-
if (statsReport.members["kind"]?.toString() != "video") continue
|
|
411
|
-
val members = statsReport.members
|
|
412
|
-
val decoded = (members["framesDecoded"] as? Number)?.toLong() ?: continue
|
|
413
|
-
val received = (members["framesReceived"] as? Number)?.toLong() ?: -1L
|
|
414
|
-
val now = System.currentTimeMillis()
|
|
415
|
-
if (lastDecodedFrames >= 0 && lastDecodedAtMs > 0) {
|
|
416
|
-
val dt = now - lastDecodedAtMs
|
|
417
|
-
if (dt > 0) {
|
|
418
|
-
stats.decodedFps = (decoded - lastDecodedFrames) * 1000.0 / dt
|
|
419
|
-
if (received >= 0 && lastReceivedFrames >= 0) {
|
|
420
|
-
stats.recvFps = (received - lastReceivedFrames) * 1000.0 / dt
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
lastDecodedFrames = decoded
|
|
425
|
-
lastReceivedFrames = received
|
|
426
|
-
lastDecodedAtMs = now
|
|
427
|
-
// Receive-side disposition. Separates "the network never delivered it"
|
|
428
|
-
// (packetsLost/nack/pli, recv < 15) from "we got it and stalled"
|
|
429
|
-
// (recv ~15 but dropped/freeze climbing).
|
|
430
|
-
stats.setRecvHealth(
|
|
431
|
-
PipelineStats.RecvHealth(
|
|
432
|
-
assembled = (members["framesAssembledFromMultiplePackets"] as? Number)?.toLong() ?: -1L,
|
|
433
|
-
dropped = (members["framesDropped"] as? Number)?.toLong() ?: -1L,
|
|
434
|
-
packetsLost = (members["packetsLost"] as? Number)?.toLong() ?: -1L,
|
|
435
|
-
nack = (members["nackCount"] as? Number)?.toLong() ?: -1L,
|
|
436
|
-
pli = (members["pliCount"] as? Number)?.toLong() ?: -1L,
|
|
437
|
-
freezes = (members["freezeCount"] as? Number)?.toLong() ?: -1L,
|
|
438
|
-
freezeSec = (members["totalFreezesDuration"] as? Number)?.toDouble() ?: -1.0,
|
|
439
|
-
jitter = (members["jitter"] as? Number)?.toDouble() ?: -1.0,
|
|
440
|
-
decodeSec = (members["totalDecodeTime"] as? Number)?.toDouble() ?: -1.0,
|
|
441
|
-
jitterBufferSec = (members["jitterBufferDelay"] as? Number)?.toDouble() ?: -1.0,
|
|
442
|
-
jitterBufferEmits = (members["jitterBufferEmittedCount"] as? Number)?.toLong() ?: -1L,
|
|
443
|
-
decImpl = members["decoderImplementation"]?.toString().orEmpty(),
|
|
444
|
-
),
|
|
445
|
-
)
|
|
446
|
-
return@getStats
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
private fun recvOnly() = RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.RECV_ONLY)
|
|
452
|
-
|
|
453
|
-
private fun iceServers() = PeerConnection.RTCConfiguration(
|
|
454
|
-
listOf(PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()),
|
|
455
|
-
)
|
|
456
|
-
|
|
457
|
-
companion object {
|
|
458
|
-
private const val TAG = "ACS-SPIKE"
|
|
459
|
-
private const val STATS_POLL_MS = 1000L
|
|
460
|
-
|
|
461
|
-
/**
|
|
462
|
-
* How long a WHEP answer has to produce its first frame. Measured on an S22
|
|
463
|
-
* reconnect the gap is ~3.2 s (answer 15:56:47.16 → first frame 15:56:50.36);
|
|
464
|
-
* a cold subscribe on a bad network is slower, so this is deliberately loose.
|
|
465
|
-
* Too tight rebuilds a subscriber that was about to work.
|
|
466
|
-
*/
|
|
467
|
-
private const val FIRST_FRAME_TIMEOUT_MS = 9_000L
|
|
468
|
-
|
|
469
|
-
fun i420PackedSize(width: Int, height: Int) = I420Packer.packedSize(width, height)
|
|
470
|
-
|
|
471
|
-
fun packI420(
|
|
472
|
-
dataY: ByteBuffer,
|
|
473
|
-
strideY: Int,
|
|
474
|
-
dataU: ByteBuffer,
|
|
475
|
-
strideU: Int,
|
|
476
|
-
dataV: ByteBuffer,
|
|
477
|
-
strideV: Int,
|
|
478
|
-
width: Int,
|
|
479
|
-
height: Int,
|
|
480
|
-
dest: ByteBuffer,
|
|
481
|
-
) = I420Packer.pack(dataY, strideY, dataU, strideU, dataV, strideV, width, height, dest)
|
|
482
|
-
|
|
483
|
-
fun percentileMs(samplesNs: LongArray, quantile: Double) = I420Packer.percentileMs(samplesNs, quantile)
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
typealias WhepVideoSource = CloudflareWhepSource
|
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
package com.mentra.acsmeeting.source
|
|
2
|
-
|
|
3
|
-
import android.util.Log
|
|
4
|
-
import com.mentra.acsmeeting.telemetry.PipelineStats
|
|
5
|
-
import com.mentra.acsmeeting.video.FrameGeometry
|
|
6
|
-
import org.webrtc.AudioTrackSink
|
|
7
|
-
import org.webrtc.VideoFrame
|
|
8
|
-
import org.webrtc.VideoSink
|
|
9
|
-
import java.util.concurrent.atomic.AtomicBoolean
|
|
10
|
-
import java.util.concurrent.atomic.AtomicReference
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Hands decoded WebRTC tracks to ACS.
|
|
14
|
-
*
|
|
15
|
-
* Extracted from [CloudflareWhepSource] so the SoftAP path shares it rather than copying it. The
|
|
16
|
-
* scaling and geometry handling here is subtle enough that two copies would drift: buffer
|
|
17
|
-
* coordinates rather than display coordinates, crop-and-scale before `toI420` so libyuv does the
|
|
18
|
-
* work, and a retain/release pair the ACS sender owns. Whether the bitstream arrived from Cloudflare
|
|
19
|
-
* or straight off the hotspot makes no difference past the decoder, so neither should the code.
|
|
20
|
-
*
|
|
21
|
-
* Thread confinement: [videoSink] and [audioSink] are called on libwebrtc's decode and audio
|
|
22
|
-
* threads. Nothing here blocks, and the mutable settings are atomics because they are written from
|
|
23
|
-
* the session thread.
|
|
24
|
-
*/
|
|
25
|
-
class DecodedTrackRelay(
|
|
26
|
-
private val videoListener: VideoFrameListener,
|
|
27
|
-
private val pcmListener: PcmListener,
|
|
28
|
-
private val stats: PipelineStats,
|
|
29
|
-
/** Called on the first frame of each generation so the owner can promote itself to LIVE. */
|
|
30
|
-
private val onPromotableFrame: () -> Unit,
|
|
31
|
-
) {
|
|
32
|
-
private val targetSize = AtomicReference<TargetSize?>(null)
|
|
33
|
-
private val rotationLogged = AtomicBoolean(false)
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Fail closed. The audio policy turns delivery on once the ACS virtual stream is live, and this
|
|
37
|
-
* survives a transport rebuild so a reconnect cannot silently re-open the uplink against a
|
|
38
|
-
* decision that has since changed.
|
|
39
|
-
*/
|
|
40
|
-
@Volatile
|
|
41
|
-
private var pcmEnabled = false
|
|
42
|
-
|
|
43
|
-
fun setTargetSize(size: TargetSize?) = targetSize.set(size)
|
|
44
|
-
|
|
45
|
-
fun setPcmDeliveryEnabled(enabled: Boolean) {
|
|
46
|
-
pcmEnabled = enabled
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
fun resetRotationLog() = rotationLogged.set(false)
|
|
50
|
-
|
|
51
|
-
val videoSink = VideoSink { frame ->
|
|
52
|
-
val sinkStart = System.nanoTime()
|
|
53
|
-
onPromotableFrame()
|
|
54
|
-
stats.onSink()
|
|
55
|
-
stats.recordGap()
|
|
56
|
-
// Guarded because a synthetic or malformed frame can carry 0, and an "age" measured from the
|
|
57
|
-
// epoch would swamp the percentile ring with one absurd sample.
|
|
58
|
-
if (frame.timestampNs > 0) {
|
|
59
|
-
stats.age.record(sinkStart - frame.timestampNs)
|
|
60
|
-
}
|
|
61
|
-
val buffer = frame.buffer
|
|
62
|
-
stats.onFrameBuffer(classifyBuffer(buffer))
|
|
63
|
-
val geometry = FrameGeometry.packSize(buffer.width, buffer.height, frame.rotation)
|
|
64
|
-
if (geometry.rotationNonZero) {
|
|
65
|
-
stats.onRotation()
|
|
66
|
-
if (rotationLogged.compareAndSet(false, true)) {
|
|
67
|
-
Log.w(
|
|
68
|
-
TAG,
|
|
69
|
-
"P3 rotation=${frame.rotation} using buffer ${geometry.width}x${geometry.height} " +
|
|
70
|
-
"(rotatedWidth=${frame.rotatedWidth}x${frame.rotatedHeight} would overrun I420 planes)",
|
|
71
|
-
)
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
val target = targetSize.get()
|
|
75
|
-
var scaled: VideoFrame.Buffer? = null
|
|
76
|
-
val source = if (needsScale(target, geometry.width, geometry.height)) {
|
|
77
|
-
val scaleStart = System.nanoTime()
|
|
78
|
-
scaled = buffer.cropAndScale(0, 0, geometry.width, geometry.height, target!!.width, target.height)
|
|
79
|
-
stats.scale.record(System.nanoTime() - scaleStart)
|
|
80
|
-
scaled
|
|
81
|
-
} else {
|
|
82
|
-
buffer
|
|
83
|
-
}
|
|
84
|
-
val i420Start = System.nanoTime()
|
|
85
|
-
val i420 = source.toI420()
|
|
86
|
-
stats.toI420.record(System.nanoTime() - i420Start)
|
|
87
|
-
if (i420 == null) {
|
|
88
|
-
stats.onDropNullI420()
|
|
89
|
-
scaled?.release()
|
|
90
|
-
stats.sinkCb.record(System.nanoTime() - sinkStart)
|
|
91
|
-
return@VideoSink
|
|
92
|
-
}
|
|
93
|
-
try {
|
|
94
|
-
val w = i420.width
|
|
95
|
-
val h = i420.height
|
|
96
|
-
stats.onStrides(i420.strideY, i420.strideU, i420.strideV, w)
|
|
97
|
-
stats.setSize(w, h)
|
|
98
|
-
videoListener.onVideoFrame(
|
|
99
|
-
I420Planes(
|
|
100
|
-
y = i420.dataY,
|
|
101
|
-
strideY = i420.strideY,
|
|
102
|
-
u = i420.dataU,
|
|
103
|
-
strideU = i420.strideU,
|
|
104
|
-
v = i420.dataV,
|
|
105
|
-
strideV = i420.strideV,
|
|
106
|
-
width = w,
|
|
107
|
-
height = h,
|
|
108
|
-
timestampNs = frame.timestampNs,
|
|
109
|
-
retain = { i420.retain() },
|
|
110
|
-
release = { i420.release() },
|
|
111
|
-
),
|
|
112
|
-
)
|
|
113
|
-
} finally {
|
|
114
|
-
i420.release()
|
|
115
|
-
scaled?.release()
|
|
116
|
-
stats.sinkCb.record(System.nanoTime() - sinkStart)
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
val audioSink = AudioTrackSink { audioData, bitsPerSample, sampleRate, numberOfChannels, _, _ ->
|
|
121
|
-
if (!pcmEnabled || bitsPerSample != 16) return@AudioTrackSink
|
|
122
|
-
val bytes = ByteArray(audioData.remaining())
|
|
123
|
-
audioData.get(bytes)
|
|
124
|
-
pcmListener.onPcm(bytes, sampleRate, numberOfChannels)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
companion object {
|
|
128
|
-
private const val TAG = "ACS-SPIKE"
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Whether a frame needs resizing before ACS sees it.
|
|
132
|
-
*
|
|
133
|
-
* Split out and made pure because it decides whether libyuv runs on every single frame, and
|
|
134
|
-
* because getting it backwards is invisible: an unnecessary scale is a silent CPU cost, and a
|
|
135
|
-
* skipped one is a plane-size mismatch at the sender. A null target means ACS has not
|
|
136
|
-
* negotiated a size yet, so the buffer passes through untouched.
|
|
137
|
-
*/
|
|
138
|
-
fun needsScale(target: TargetSize?, width: Int, height: Int): Boolean =
|
|
139
|
-
target != null && (target.width != width || target.height != height)
|
|
140
|
-
|
|
141
|
-
fun classifyBuffer(buffer: VideoFrame.Buffer): String = when (buffer) {
|
|
142
|
-
is VideoFrame.TextureBuffer -> "tex"
|
|
143
|
-
is VideoFrame.I420Buffer -> "i420"
|
|
144
|
-
else -> "other"
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
package com.mentra.acsmeeting.source
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Guards the promotion to [SourceState.LIVE] on the first video frame.
|
|
5
|
-
*
|
|
6
|
-
* `LIVE` used to fire on the WHEP answer, which is three hops and several
|
|
7
|
-
* seconds before ACS is handed a frame — long enough that Mentra Call told the
|
|
8
|
-
* wearer video was restored 7.5 s before Teams got one. It now means a frame
|
|
9
|
-
* reached the sink, and an answer that never produces one expires instead of
|
|
10
|
-
* reading healthy forever behind a frozen last frame.
|
|
11
|
-
*
|
|
12
|
-
* Armed per peer generation so a frame — or a deadline — belonging to a peer
|
|
13
|
-
* being torn down can neither promote nor fail its replacement.
|
|
14
|
-
*/
|
|
15
|
-
internal class FirstFrameGate {
|
|
16
|
-
private var armedGeneration = -1
|
|
17
|
-
// Read on every sink callback; the slow path takes the lock.
|
|
18
|
-
@Volatile private var promoted = false
|
|
19
|
-
|
|
20
|
-
/** The answer landed (or ICE came back): frames are promised, not delivered. */
|
|
21
|
-
@Synchronized fun arm(generation: Int) {
|
|
22
|
-
armedGeneration = generation
|
|
23
|
-
promoted = false
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** True exactly once per [arm]: this frame is the one that makes the source live. */
|
|
27
|
-
fun onFrame(generation: Int): Boolean {
|
|
28
|
-
if (promoted) return false
|
|
29
|
-
return promote(generation)
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** True while [generation] is armed and still frameless, so it must be rebuilt. */
|
|
33
|
-
@Synchronized fun expired(generation: Int): Boolean = generation == armedGeneration && !promoted
|
|
34
|
-
|
|
35
|
-
@Synchronized fun reset() {
|
|
36
|
-
armedGeneration = -1
|
|
37
|
-
promoted = false
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
@Synchronized private fun promote(generation: Int): Boolean {
|
|
41
|
-
if (promoted || generation != armedGeneration) return false
|
|
42
|
-
promoted = true
|
|
43
|
-
return true
|
|
44
|
-
}
|
|
45
|
-
}
|