@mentra/acs-meeting 3.2.0-dev.221 → 3.2.0-dev.222
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 +4 -2
- package/android/build.gradle +23 -0
- package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +291 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/mentra/acsmeeting/AbandonedCallAgent.kt +36 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +392 -22
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +909 -56
- package/android/src/main/java/com/mentra/acsmeeting/CallCapabilities.kt +33 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +21 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/AudioUplinkChain.kt +126 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +27 -4
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +45 -10
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +110 -5
- package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +501 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +51 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +81 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +355 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +109 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +160 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +103 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +53 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +127 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +573 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +10 -101
- package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +147 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +47 -5
- package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +772 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +73 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +17 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +139 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +144 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +46 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +160 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +3 -1
- package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +69 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +207 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +356 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +155 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +94 -3
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +33 -5
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +15 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +178 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +145 -0
- package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +143 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +23 -4
- package/android/src/main/java/com/mentra/acsmeeting/video/FramePacer.kt +70 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +1 -1
- package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +36 -3
- package/android/src/test/java/com/mentra/acsmeeting/AbandonedCallAgentTest.kt +74 -0
- package/android/src/test/java/com/mentra/acsmeeting/CallCapabilitiesTest.kt +47 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/AudioUplinkChainTest.kt +250 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +44 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +34 -4
- package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +96 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +102 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +148 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +59 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +59 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +35 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +165 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +96 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +187 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +172 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +141 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +69 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +40 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +41 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +26 -8
- package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +109 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +165 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +148 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +76 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +275 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +9 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +356 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +411 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +83 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +69 -3
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +182 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +149 -0
- package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +97 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +34 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/FramePacerTest.kt +105 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +5 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +38 -3
- package/build/AcsMeeting.types.d.ts +5 -0
- package/build/AcsMeeting.types.d.ts.map +1 -1
- package/build/AcsMeeting.types.js.map +1 -1
- package/build/AcsMeetingModule.d.ts +23 -0
- package/build/AcsMeetingModule.d.ts.map +1 -1
- package/build/AcsMeetingModule.js.map +1 -1
- package/build/AcsMeetingModule.web.d.ts +15 -0
- package/build/AcsMeetingModule.web.d.ts.map +1 -1
- package/build/AcsMeetingModule.web.js +13 -0
- package/build/AcsMeetingModule.web.js.map +1 -1
- package/ios/AcsMeetingModule.swift +213 -2
- package/package.json +1 -1
- package/src/AcsMeeting.types.ts +5 -0
- package/src/AcsMeetingModule.ts +13 -0
- package/src/AcsMeetingModule.web.ts +13 -0
|
@@ -4,14 +4,12 @@ import android.content.Context
|
|
|
4
4
|
import android.media.AudioAttributes
|
|
5
5
|
import android.util.Log
|
|
6
6
|
import com.mentra.acsmeeting.telemetry.PipelineStats
|
|
7
|
-
import com.mentra.acsmeeting.video.FrameGeometry
|
|
8
7
|
import com.mentra.acsmeeting.video.I420Packer
|
|
9
8
|
import okhttp3.MediaType.Companion.toMediaType
|
|
10
9
|
import okhttp3.OkHttpClient
|
|
11
10
|
import okhttp3.Request
|
|
12
11
|
import okhttp3.RequestBody.Companion.toRequestBody
|
|
13
12
|
import org.webrtc.AudioTrack
|
|
14
|
-
import org.webrtc.AudioTrackSink
|
|
15
13
|
import org.webrtc.DefaultVideoDecoderFactory
|
|
16
14
|
import org.webrtc.DefaultVideoEncoderFactory
|
|
17
15
|
import org.webrtc.EglBase
|
|
@@ -23,15 +21,11 @@ import org.webrtc.PeerConnectionFactory
|
|
|
23
21
|
import org.webrtc.RtpTransceiver
|
|
24
22
|
import org.webrtc.SdpObserver
|
|
25
23
|
import org.webrtc.SessionDescription
|
|
26
|
-
import org.webrtc.VideoFrame
|
|
27
|
-
import org.webrtc.VideoSink
|
|
28
24
|
import org.webrtc.VideoTrack
|
|
29
25
|
import org.webrtc.audio.JavaAudioDeviceModule
|
|
30
26
|
import java.nio.ByteBuffer
|
|
31
27
|
import java.util.concurrent.CopyOnWriteArrayList
|
|
32
28
|
import java.util.concurrent.TimeUnit
|
|
33
|
-
import java.util.concurrent.atomic.AtomicBoolean
|
|
34
|
-
import java.util.concurrent.atomic.AtomicReference
|
|
35
29
|
import java.util.concurrent.atomic.AtomicLong
|
|
36
30
|
|
|
37
31
|
/**
|
|
@@ -51,10 +45,6 @@ class CloudflareWhepSource(
|
|
|
51
45
|
private var pc: PeerConnection? = null
|
|
52
46
|
private var currentUrl: String? = null
|
|
53
47
|
@Volatile private var offerPosted = false
|
|
54
|
-
// Fail closed: the audio policy turns delivery on once the ACS virtual
|
|
55
|
-
// stream is live. Survives restart() so a WHEP rebuild cannot silently
|
|
56
|
-
// re-open the uplink (or the local playout) against the current decision.
|
|
57
|
-
@Volatile private var pcmEnabled = false
|
|
58
48
|
@Volatile override var state: SourceState = SourceState.IDLE
|
|
59
49
|
private set
|
|
60
50
|
@Volatile private var stateListener: SourceStateListener? = null
|
|
@@ -67,8 +57,9 @@ class CloudflareWhepSource(
|
|
|
67
57
|
private val videoIds = TrackRegistry()
|
|
68
58
|
private val audioIds = TrackRegistry()
|
|
69
59
|
@Volatile private var attachedVideo: VideoTrack? = null
|
|
70
|
-
|
|
71
|
-
|
|
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() }
|
|
72
63
|
private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
|
73
64
|
private var pendingOfferPost: Runnable? = null
|
|
74
65
|
// LIVE means "ACS is being handed frames", not "the WHEP endpoint answered".
|
|
@@ -122,8 +113,8 @@ class CloudflareWhepSource(
|
|
|
122
113
|
// Reuse the existing subscriber only when the URL is unchanged AND the peer is
|
|
123
114
|
// still healthy. After ICE DISCONNECTED/FAILED (e.g. a Wi-Fi drop that
|
|
124
115
|
// reuses the same Cloudflare WHEP URL) we must rebuild, or glasses video and
|
|
125
|
-
// mic never recover. See
|
|
126
|
-
if (
|
|
116
|
+
// mic never recover. See SourceReusePolicy for why CONNECTING is reusable.
|
|
117
|
+
if (SourceReusePolicy.canReuse(currentUrl, state, config)) return
|
|
127
118
|
start(config)
|
|
128
119
|
}
|
|
129
120
|
|
|
@@ -150,12 +141,12 @@ class CloudflareWhepSource(
|
|
|
150
141
|
}
|
|
151
142
|
|
|
152
143
|
override fun setPcmDeliveryEnabled(enabled: Boolean) {
|
|
153
|
-
|
|
144
|
+
relay.setPcmDeliveryEnabled(enabled)
|
|
154
145
|
Log.i(TAG, "WHEP audio PCM delivery enabled=$enabled")
|
|
155
146
|
}
|
|
156
147
|
|
|
157
148
|
override fun setTargetSize(size: TargetSize?) {
|
|
158
|
-
|
|
149
|
+
relay.setTargetSize(size)
|
|
159
150
|
}
|
|
160
151
|
|
|
161
152
|
override fun stop() {
|
|
@@ -183,7 +174,7 @@ class CloudflareWhepSource(
|
|
|
183
174
|
lastDecodedFrames = -1L
|
|
184
175
|
lastDecodedAtMs = 0L
|
|
185
176
|
lastReceivedFrames = -1L
|
|
186
|
-
|
|
177
|
+
relay.resetRotationLog()
|
|
187
178
|
// close() stops media; dispose() is what frees the native peer. Without it every
|
|
188
179
|
// WHEP restart leaks a PeerConnection. dispose() also blocks until observer and
|
|
189
180
|
// sink callbacks quiesce, so it must run after removeSink and before dest is reused.
|
|
@@ -393,75 +384,7 @@ class CloudflareWhepSource(
|
|
|
393
384
|
transition(SourceState.LIVE, "first_frame")
|
|
394
385
|
}
|
|
395
386
|
|
|
396
|
-
private val videoSink =
|
|
397
|
-
val sinkStart = System.nanoTime()
|
|
398
|
-
notePromotableFrame()
|
|
399
|
-
stats.onSink()
|
|
400
|
-
stats.recordGap()
|
|
401
|
-
val buffer = frame.buffer
|
|
402
|
-
stats.onFrameBuffer(classifyBuffer(buffer))
|
|
403
|
-
val geometry = FrameGeometry.packSize(buffer.width, buffer.height, frame.rotation)
|
|
404
|
-
if (geometry.rotationNonZero) {
|
|
405
|
-
stats.onRotation()
|
|
406
|
-
if (rotationLogged.compareAndSet(false, true)) {
|
|
407
|
-
Log.w(
|
|
408
|
-
TAG,
|
|
409
|
-
"P3 rotation=${frame.rotation} using buffer ${geometry.width}x${geometry.height} " +
|
|
410
|
-
"(rotatedWidth=${frame.rotatedWidth}x${frame.rotatedHeight} would overrun I420 planes)",
|
|
411
|
-
)
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
val target = targetSize.get()
|
|
415
|
-
var scaled: VideoFrame.Buffer? = null
|
|
416
|
-
val source = if (target != null && (target.width != geometry.width || target.height != geometry.height)) {
|
|
417
|
-
val scaleStart = System.nanoTime()
|
|
418
|
-
scaled = buffer.cropAndScale(0, 0, geometry.width, geometry.height, target.width, target.height)
|
|
419
|
-
stats.scale.record(System.nanoTime() - scaleStart)
|
|
420
|
-
scaled
|
|
421
|
-
} else {
|
|
422
|
-
buffer
|
|
423
|
-
}
|
|
424
|
-
val i420Start = System.nanoTime()
|
|
425
|
-
val i420 = source.toI420()
|
|
426
|
-
stats.toI420.record(System.nanoTime() - i420Start)
|
|
427
|
-
if (i420 == null) {
|
|
428
|
-
stats.onDropNullI420()
|
|
429
|
-
scaled?.release()
|
|
430
|
-
stats.sinkCb.record(System.nanoTime() - sinkStart)
|
|
431
|
-
return@VideoSink
|
|
432
|
-
}
|
|
433
|
-
try {
|
|
434
|
-
val w = i420.width
|
|
435
|
-
val h = i420.height
|
|
436
|
-
stats.onStrides(i420.strideY, i420.strideU, i420.strideV, w)
|
|
437
|
-
stats.setSize(w, h)
|
|
438
|
-
videoListener.onVideoFrame(
|
|
439
|
-
I420Planes(
|
|
440
|
-
y = i420.dataY,
|
|
441
|
-
strideY = i420.strideY,
|
|
442
|
-
u = i420.dataU,
|
|
443
|
-
strideU = i420.strideU,
|
|
444
|
-
v = i420.dataV,
|
|
445
|
-
strideV = i420.strideV,
|
|
446
|
-
width = w,
|
|
447
|
-
height = h,
|
|
448
|
-
timestampNs = frame.timestampNs,
|
|
449
|
-
retain = { i420.retain() },
|
|
450
|
-
release = { i420.release() },
|
|
451
|
-
),
|
|
452
|
-
)
|
|
453
|
-
} finally {
|
|
454
|
-
i420.release()
|
|
455
|
-
scaled?.release()
|
|
456
|
-
stats.sinkCb.record(System.nanoTime() - sinkStart)
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
private fun classifyBuffer(buffer: VideoFrame.Buffer): String = when (buffer) {
|
|
461
|
-
is VideoFrame.TextureBuffer -> "tex"
|
|
462
|
-
is VideoFrame.I420Buffer -> "i420"
|
|
463
|
-
else -> "other"
|
|
464
|
-
}
|
|
387
|
+
private val videoSink get() = relay.videoSink
|
|
465
388
|
|
|
466
389
|
private fun attachAudio(track: AudioTrack) {
|
|
467
390
|
val id = track.id()
|
|
@@ -475,17 +398,10 @@ class CloudflareWhepSource(
|
|
|
475
398
|
// volume 0 silences local playout while the sink still gets full-scale PCM.
|
|
476
399
|
track.setVolume(0.0)
|
|
477
400
|
track.setEnabled(true)
|
|
478
|
-
track.addSink(audioSink)
|
|
401
|
+
track.addSink(relay.audioSink)
|
|
479
402
|
Log.i(TAG, "P3 attach kind=audio id=$id attached=${audioIds.size()} skipped=${audioIds.skipped()} playoutVolume=0")
|
|
480
403
|
}
|
|
481
404
|
|
|
482
|
-
private val audioSink = AudioTrackSink { audioData, bitsPerSample, sampleRate, numberOfChannels, numberOfFrames, _ ->
|
|
483
|
-
if (!pcmEnabled || bitsPerSample != 16) return@AudioTrackSink
|
|
484
|
-
val bytes = ByteArray(audioData.remaining())
|
|
485
|
-
audioData.get(bytes)
|
|
486
|
-
pcmListener.onPcm(bytes, sampleRate, numberOfChannels)
|
|
487
|
-
}
|
|
488
|
-
|
|
489
405
|
private fun pollDecodedStats() {
|
|
490
406
|
val peer = pc ?: return
|
|
491
407
|
peer.getStats { report ->
|
|
@@ -569,10 +485,3 @@ class CloudflareWhepSource(
|
|
|
569
485
|
}
|
|
570
486
|
|
|
571
487
|
typealias WhepVideoSource = CloudflareWhepSource
|
|
572
|
-
|
|
573
|
-
private open class SdpAdapter : SdpObserver {
|
|
574
|
-
override fun onCreateSuccess(sdp: SessionDescription) {}
|
|
575
|
-
override fun onSetSuccess() {}
|
|
576
|
-
override fun onCreateFailure(error: String) {}
|
|
577
|
-
override fun onSetFailure(error: String) {}
|
|
578
|
-
}
|
|
@@ -0,0 +1,147 @@
|
|
|
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,12 +1,29 @@
|
|
|
1
1
|
package com.mentra.acsmeeting.source
|
|
2
2
|
|
|
3
|
-
enum class SourceKind {
|
|
3
|
+
enum class SourceKind {
|
|
4
|
+
/** Subscribe to a Cloudflare WHEP endpoint. The phone is the offerer. */
|
|
5
|
+
WHEP,
|
|
6
|
+
DIRECT,
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Serve a WHIP endpoint on the glasses hotspot and let the glasses publish into it. The phone is
|
|
10
|
+
* the answerer, and no traffic leaves the local link. See [LocalWhipIngestSource].
|
|
11
|
+
*/
|
|
12
|
+
SOFTAP,
|
|
13
|
+
}
|
|
4
14
|
|
|
5
15
|
enum class SourceState { IDLE, CONNECTING, LIVE, FAILED }
|
|
6
16
|
|
|
7
17
|
data class SourceConfig(
|
|
18
|
+
/**
|
|
19
|
+
* WHEP: the endpoint to subscribe to. SOFTAP: unused, because the URL is not known until the
|
|
20
|
+
* listener has bound a port — the source reports it back through
|
|
21
|
+
* [GlassesMediaSource.ingestUrl] instead.
|
|
22
|
+
*/
|
|
8
23
|
val url: String,
|
|
9
24
|
val kind: SourceKind = SourceKind.WHEP,
|
|
25
|
+
/** SOFTAP only: the phone's own IPv4 address on the hotspot, to bind the listener to. */
|
|
26
|
+
val bindAddress: String? = null,
|
|
10
27
|
)
|
|
11
28
|
|
|
12
29
|
/** ACS-negotiated output size. One object so a frame cannot observe a torn width/height. */
|
|
@@ -22,9 +39,18 @@ data class TargetSize(val width: Int, val height: Int)
|
|
|
22
39
|
* spans the answer → first frame window too. It cannot strand a caller, because
|
|
23
40
|
* every `CONNECTING` is bounded: the offer post fails, or the answer's
|
|
24
41
|
* first-frame deadline expires into `FAILED` and this returns false.
|
|
42
|
+
*
|
|
43
|
+
* SoftAP never reuses: the listener, port and peer belong to one publish attempt,
|
|
44
|
+
* and the ingest URL is an output of binding, not an input that could stay valid.
|
|
45
|
+
*
|
|
46
|
+
* Owned by an object rather than a file-level function so Kotlin incremental
|
|
47
|
+
* compile cannot see two [GlassesMediaSourceKt] facades and report a phantom
|
|
48
|
+
* overload of the same signature.
|
|
25
49
|
*/
|
|
26
|
-
|
|
27
|
-
currentUrl
|
|
50
|
+
object SourceReusePolicy {
|
|
51
|
+
fun canReuse(currentUrl: String?, state: SourceState, next: SourceConfig): Boolean =
|
|
52
|
+
currentUrl == next.url && next.kind == SourceKind.WHEP && state != SourceState.FAILED
|
|
53
|
+
}
|
|
28
54
|
|
|
29
55
|
/**
|
|
30
56
|
* Observes source health transitions. `FAILED` is the one that matters: ICE
|
|
@@ -47,13 +73,25 @@ interface GlassesMediaSource {
|
|
|
47
73
|
fun forceRestart() {}
|
|
48
74
|
fun stop()
|
|
49
75
|
val state: SourceState
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* SOFTAP only: the WHIP URL the glasses must POST their offer to, known only after [start] has
|
|
79
|
+
* bound a listener. Null for every other kind, where the URL is an input rather than an output.
|
|
80
|
+
*/
|
|
81
|
+
val ingestUrl: String? get() = null
|
|
82
|
+
|
|
50
83
|
fun setPcmDeliveryEnabled(enabled: Boolean)
|
|
51
84
|
fun setTargetSize(size: TargetSize?) {}
|
|
52
85
|
fun setStateListener(listener: SourceStateListener?) {}
|
|
53
86
|
}
|
|
54
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Builds the transport for one attempt. Takes [SourceConfig] because the kind decides the class:
|
|
90
|
+
* a Cloudflare subscription and a SoftAP listener are different objects, not one object with a
|
|
91
|
+
* mode flag, and the session must not have to know which.
|
|
92
|
+
*/
|
|
55
93
|
fun interface GlassesMediaSourceFactory {
|
|
56
|
-
fun create(video: VideoFrameListener, pcm: PcmListener): GlassesMediaSource
|
|
94
|
+
fun create(video: VideoFrameListener, pcm: PcmListener, config: SourceConfig): GlassesMediaSource
|
|
57
95
|
}
|
|
58
96
|
|
|
59
97
|
class GlassesMediaController(
|
|
@@ -65,9 +103,13 @@ class GlassesMediaController(
|
|
|
65
103
|
val state: SourceState
|
|
66
104
|
get() = source?.state ?: SourceState.IDLE
|
|
67
105
|
|
|
106
|
+
/** SOFTAP only: the URL the glasses must publish to, once a listener has bound. */
|
|
107
|
+
val ingestUrl: String?
|
|
108
|
+
get() = source?.ingestUrl
|
|
109
|
+
|
|
68
110
|
fun attach(video: VideoFrameListener, pcm: PcmListener, config: SourceConfig) {
|
|
69
111
|
source?.stop()
|
|
70
|
-
source = factory.create(video, pcm).also {
|
|
112
|
+
source = factory.create(video, pcm, config).also {
|
|
71
113
|
it.setStateListener(stateListener)
|
|
72
114
|
it.start(config)
|
|
73
115
|
}
|