@mentra/glasses-media 3.2.1-dev.311 → 3.2.1
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/android/src/main/java/com/mentra/glassesmedia/network/InternetHold.kt +8 -7
- package/android/src/main/java/com/mentra/glassesmedia/publisher/PhoneWhipPublisher.kt +11 -8
- package/android/src/main/java/com/mentra/glassesmedia/source/DecodedFrameTap.kt +224 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/DecodedFrameTapCore.swift +204 -0
- package/ios/DecodedFrameTap.swift +64 -0
- package/package.json +1 -1
|
@@ -447,13 +447,6 @@ class InternetHold(private val context: Context) {
|
|
|
447
447
|
private fun cellularIsValidated(manager: ConnectivityManager): Boolean =
|
|
448
448
|
findValidatedCellular(manager) != null
|
|
449
449
|
|
|
450
|
-
private fun findValidatedCellular(manager: ConnectivityManager): Network? =
|
|
451
|
-
manager.allNetworks.firstOrNull { network ->
|
|
452
|
-
val capabilities = manager.getNetworkCapabilities(network) ?: return@firstOrNull false
|
|
453
|
-
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
|
|
454
|
-
isValidatedInternet(capabilities)
|
|
455
|
-
}
|
|
456
|
-
|
|
457
450
|
private fun connectivityManager(): ConnectivityManager? =
|
|
458
451
|
context.applicationContext.getSystemService(ConnectivityManager::class.java)
|
|
459
452
|
|
|
@@ -466,6 +459,14 @@ class InternetHold(private val context: Context) {
|
|
|
466
459
|
|
|
467
460
|
private const val DEFAULT_POLL_MS = 200L
|
|
468
461
|
|
|
462
|
+
/** Exclude validated carrier IMS/DUN networks that do not provide app internet. */
|
|
463
|
+
internal fun findValidatedCellular(manager: ConnectivityManager): Network? =
|
|
464
|
+
manager.allNetworks.firstOrNull { network ->
|
|
465
|
+
val capabilities = manager.getNetworkCapabilities(network) ?: return@firstOrNull false
|
|
466
|
+
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
|
|
467
|
+
isValidatedInternet(capabilities)
|
|
468
|
+
}
|
|
469
|
+
|
|
469
470
|
fun isValidatedInternet(capabilities: NetworkCapabilities): Boolean =
|
|
470
471
|
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
|
471
472
|
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
|
@@ -2,9 +2,10 @@ package com.mentra.glassesmedia.publisher
|
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
4
|
import android.net.ConnectivityManager
|
|
5
|
-
import android.net.NetworkCapabilities
|
|
6
5
|
import android.os.Handler
|
|
7
6
|
import android.os.HandlerThread
|
|
7
|
+
import com.mentra.glassesmedia.network.InternetHold
|
|
8
|
+
import com.mentra.glassesmedia.trace.SoftApTrace
|
|
8
9
|
import com.mentra.glassesmedia.source.I420Planes
|
|
9
10
|
import java.io.IOException
|
|
10
11
|
import java.util.concurrent.CountDownLatch
|
|
@@ -65,12 +66,9 @@ class PhoneWhipPublisher(
|
|
|
65
66
|
val url = endpoint.toHttpUrl()
|
|
66
67
|
require(url.isHttps) { "Managed WHIP requires HTTPS" }
|
|
67
68
|
val manager = context.getSystemService(ConnectivityManager::class.java)
|
|
68
|
-
val internet = manager
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
it.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
|
72
|
-
} == true
|
|
73
|
-
} ?: error("Phone mobile data is unavailable")
|
|
69
|
+
val internet = InternetHold.findValidatedCellular(manager)
|
|
70
|
+
?: error("Phone mobile data is unavailable")
|
|
71
|
+
SoftApTrace.stage("whip_uplink_network", "network" to internet)
|
|
74
72
|
client = OkHttpClient.Builder().socketFactory(internet.socketFactory)
|
|
75
73
|
.dns(object : okhttp3.Dns { override fun lookup(hostname: String) = internet.getAllByName(hostname).toList() })
|
|
76
74
|
.callTimeout(20, TimeUnit.SECONDS).followRedirects(false).build()
|
|
@@ -167,7 +165,12 @@ class PhoneWhipPublisher(
|
|
|
167
165
|
val request = Request.Builder().url(endpoint)
|
|
168
166
|
.post(pc.localDescription.description.toRequestBody("application/sdp".toMediaType())).build()
|
|
169
167
|
http.newCall(request).enqueue(object : Callback {
|
|
170
|
-
override fun onFailure(call: Call, error: IOException) {
|
|
168
|
+
override fun onFailure(call: Call, error: IOException) {
|
|
169
|
+
// Exception messages can contain the credential-bearing publish URL. Record types only.
|
|
170
|
+
SoftApTrace.failure("whip_uplink_signaling", "error" to error.javaClass.simpleName,
|
|
171
|
+
"cause" to error.cause?.javaClass?.simpleName)
|
|
172
|
+
queue.post { fail("WHIP signaling failed (${error.javaClass.simpleName})") }
|
|
173
|
+
}
|
|
171
174
|
override fun onResponse(call: Call, response: Response) {
|
|
172
175
|
response.use {
|
|
173
176
|
val location = response.header("Location")?.let { request.url.resolve(it) }
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
package com.mentra.glassesmedia.source
|
|
2
|
+
|
|
3
|
+
import android.util.Log
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A second, optional consumer of decoded glasses video, sitting beside the ACS sender.
|
|
7
|
+
*
|
|
8
|
+
* The call owns this pipeline; the preview is a guest. [offer] therefore never blocks, never lets
|
|
9
|
+
* a `Throwable` reach the decoder thread, and never does real work — a sink may only decide
|
|
10
|
+
* whether it wants the frame, retain it, and schedule bounded work elsewhere. A preview that
|
|
11
|
+
* crashes, stalls, or falls behind must cost the call nothing.
|
|
12
|
+
*
|
|
13
|
+
* [detach] is generation-checked because sessions overlap: a call ending late must not tear down
|
|
14
|
+
* the subscription a newer call already installed.
|
|
15
|
+
*
|
|
16
|
+
* ## Cost with nothing attached
|
|
17
|
+
*
|
|
18
|
+
* With no sink and telemetry off, [offer] is one volatile read and a return. Sink, error handler
|
|
19
|
+
* and the telemetry flag live in one immutable [State] so that read is the only thing the decoder
|
|
20
|
+
* thread pays.
|
|
21
|
+
*
|
|
22
|
+
* ## Why the metrics live here
|
|
23
|
+
*
|
|
24
|
+
* These describe the call, not the preview. With [setTelemetryEnabled] on they are collected even
|
|
25
|
+
* with no sink attached, so a preview-off run is a usable baseline for the same numbers with the
|
|
26
|
+
* preview on.
|
|
27
|
+
*/
|
|
28
|
+
object DecodedFrameTap {
|
|
29
|
+
private const val TAG = "FRAME-PREVIEW"
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A snapshot of decoder-thread observations, drained by the reader.
|
|
33
|
+
*
|
|
34
|
+
* Sums rather than a percentile ring because this object cannot depend on the preview module,
|
|
35
|
+
* and because the two questions here are "is the mean cost negligible" and "was there ever a
|
|
36
|
+
* bad one" — a mean and a max answer both.
|
|
37
|
+
*/
|
|
38
|
+
data class Metrics(
|
|
39
|
+
val framesOffered: Long,
|
|
40
|
+
val framesWithSink: Long,
|
|
41
|
+
val offerTotalNs: Long,
|
|
42
|
+
val offerMaxNs: Long,
|
|
43
|
+
val cadenceSamples: Long,
|
|
44
|
+
val cadenceTotalNs: Long,
|
|
45
|
+
val cadenceMaxNs: Long,
|
|
46
|
+
val sinkExceptions: Long,
|
|
47
|
+
/** Frames the ACS sender accepted, recorded at its call sites after [offer]. */
|
|
48
|
+
val acsFramesSent: Long = 0,
|
|
49
|
+
) {
|
|
50
|
+
/** Mean time inside [offer] over every timed frame, with or without a sink. */
|
|
51
|
+
val offerMeanUs: Double get() = if (framesOffered > 0) offerTotalNs.toDouble() / framesOffered / 1000.0 else 0.0
|
|
52
|
+
val offerMaxUs: Double get() = offerMaxNs / 1000.0
|
|
53
|
+
val cadenceMeanMs: Double get() = if (cadenceSamples > 0) cadenceTotalNs.toDouble() / cadenceSamples / 1_000_000.0 else 0.0
|
|
54
|
+
val cadenceMaxMs: Double get() = cadenceMaxNs / 1_000_000.0
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private class State(
|
|
58
|
+
val sink: VideoFrameListener?,
|
|
59
|
+
val onSinkError: ((Throwable) -> Unit)?,
|
|
60
|
+
val generation: Long,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
@Volatile
|
|
64
|
+
private var state: State? = null
|
|
65
|
+
|
|
66
|
+
private var generation: Long = 0
|
|
67
|
+
private var currentSink: VideoFrameListener? = null
|
|
68
|
+
private var currentOnSinkError: ((Throwable) -> Unit)? = null
|
|
69
|
+
|
|
70
|
+
@Volatile
|
|
71
|
+
var telemetryEnabled: Boolean = false
|
|
72
|
+
private set
|
|
73
|
+
|
|
74
|
+
private val metricsLock = Any()
|
|
75
|
+
private var framesOffered = 0L
|
|
76
|
+
private var framesWithSink = 0L
|
|
77
|
+
private var offerTotalNs = 0L
|
|
78
|
+
private var offerMaxNs = 0L
|
|
79
|
+
private var cadenceSamples = 0L
|
|
80
|
+
private var cadenceTotalNs = 0L
|
|
81
|
+
private var cadenceMaxNs = 0L
|
|
82
|
+
private var sinkExceptions = 0L
|
|
83
|
+
private var acsFramesSent = 0L
|
|
84
|
+
private var lastOfferAtNs = 0L
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Install [listener] and return the generation that owns it. [onSinkError] runs on the decoder
|
|
88
|
+
* thread after a throw has been caught and counted; it must only schedule work elsewhere.
|
|
89
|
+
*/
|
|
90
|
+
@Synchronized
|
|
91
|
+
fun attach(listener: VideoFrameListener, onSinkError: ((Throwable) -> Unit)? = null): Long {
|
|
92
|
+
generation += 1
|
|
93
|
+
currentSink = listener
|
|
94
|
+
currentOnSinkError = onSinkError
|
|
95
|
+
publish()
|
|
96
|
+
Log.i(TAG, "tap attached generation=$generation")
|
|
97
|
+
return generation
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Remove the sink only if it is still the one [generation] installed. */
|
|
101
|
+
@Synchronized
|
|
102
|
+
fun detach(generation: Long): Boolean {
|
|
103
|
+
if (generation != this.generation || currentSink == null) return false
|
|
104
|
+
currentSink = null
|
|
105
|
+
currentOnSinkError = null
|
|
106
|
+
publish()
|
|
107
|
+
Log.i(TAG, "tap detached generation=$generation")
|
|
108
|
+
return true
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** True while [generation] still owns the installed sink. */
|
|
112
|
+
@Synchronized
|
|
113
|
+
fun isCurrent(generation: Long): Boolean = generation == this.generation && currentSink != null
|
|
114
|
+
|
|
115
|
+
/** Collect cadence and offer cost even with no sink, so preview off/on can be compared. */
|
|
116
|
+
@Synchronized
|
|
117
|
+
fun setTelemetryEnabled(enabled: Boolean) {
|
|
118
|
+
telemetryEnabled = enabled
|
|
119
|
+
publish()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
fun hasSink(): Boolean = state?.sink != null
|
|
123
|
+
|
|
124
|
+
private fun publish() {
|
|
125
|
+
val wasCollecting = state != null
|
|
126
|
+
state = if (currentSink == null && !telemetryEnabled) {
|
|
127
|
+
null
|
|
128
|
+
} else {
|
|
129
|
+
State(currentSink, currentOnSinkError, generation)
|
|
130
|
+
}
|
|
131
|
+
if (!wasCollecting && state != null) {
|
|
132
|
+
// The first gap after collection resumes would span the whole idle period.
|
|
133
|
+
synchronized(metricsLock) { lastOfferAtNs = 0L }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Read and clear the accumulated observations. Called about once a second by the reporter. */
|
|
138
|
+
fun drainMetrics(): Metrics = synchronized(metricsLock) {
|
|
139
|
+
val snapshot = Metrics(
|
|
140
|
+
framesOffered = framesOffered,
|
|
141
|
+
framesWithSink = framesWithSink,
|
|
142
|
+
offerTotalNs = offerTotalNs,
|
|
143
|
+
offerMaxNs = offerMaxNs,
|
|
144
|
+
cadenceSamples = cadenceSamples,
|
|
145
|
+
cadenceTotalNs = cadenceTotalNs,
|
|
146
|
+
cadenceMaxNs = cadenceMaxNs,
|
|
147
|
+
sinkExceptions = sinkExceptions,
|
|
148
|
+
acsFramesSent = acsFramesSent,
|
|
149
|
+
)
|
|
150
|
+
framesOffered = 0
|
|
151
|
+
framesWithSink = 0
|
|
152
|
+
offerTotalNs = 0
|
|
153
|
+
offerMaxNs = 0
|
|
154
|
+
cadenceSamples = 0
|
|
155
|
+
cadenceTotalNs = 0
|
|
156
|
+
cadenceMaxNs = 0
|
|
157
|
+
sinkExceptions = 0
|
|
158
|
+
acsFramesSent = 0
|
|
159
|
+
snapshot
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The ACS sender accepted a frame. Counted under the same gate as [offer], so the send rate is
|
|
164
|
+
* available whenever the tap's other call metrics are, including preview-off baselines.
|
|
165
|
+
*/
|
|
166
|
+
fun recordAcsSend() {
|
|
167
|
+
if (state == null) return
|
|
168
|
+
synchronized(metricsLock) { acsFramesSent += 1 }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Called on libwebrtc's decode thread, immediately before the frame goes to ACS.
|
|
173
|
+
*
|
|
174
|
+
* The catch is not defensive padding: the alternative is a preview bug throwing on the decoder
|
|
175
|
+
* thread and taking the wearer's call video with it.
|
|
176
|
+
*/
|
|
177
|
+
fun offer(planes: I420Planes) {
|
|
178
|
+
val current = state ?: return
|
|
179
|
+
val started = System.nanoTime()
|
|
180
|
+
val listener = current.sink
|
|
181
|
+
|
|
182
|
+
var failure: Throwable? = null
|
|
183
|
+
if (listener != null) {
|
|
184
|
+
try {
|
|
185
|
+
listener.onVideoFrame(planes)
|
|
186
|
+
} catch (error: Throwable) {
|
|
187
|
+
failure = error
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
val elapsed = System.nanoTime() - started
|
|
192
|
+
val firstFailure: Boolean
|
|
193
|
+
synchronized(metricsLock) {
|
|
194
|
+
framesOffered += 1
|
|
195
|
+
if (lastOfferAtNs > 0) {
|
|
196
|
+
val gap = started - lastOfferAtNs
|
|
197
|
+
cadenceSamples += 1
|
|
198
|
+
cadenceTotalNs += gap
|
|
199
|
+
if (gap > cadenceMaxNs) cadenceMaxNs = gap
|
|
200
|
+
}
|
|
201
|
+
lastOfferAtNs = started
|
|
202
|
+
offerTotalNs += elapsed
|
|
203
|
+
if (elapsed > offerMaxNs) offerMaxNs = elapsed
|
|
204
|
+
if (listener != null) framesWithSink += 1
|
|
205
|
+
firstFailure = failure != null && sinkExceptions == 0L
|
|
206
|
+
if (failure != null) sinkExceptions += 1
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (failure != null) {
|
|
210
|
+
if (firstFailure) {
|
|
211
|
+
try {
|
|
212
|
+
Log.w(TAG, "preview sink threw generation=${current.generation}; call frame unaffected", failure)
|
|
213
|
+
} catch (ignored: Throwable) {
|
|
214
|
+
// Logging renders the sink's throwable, whose message and stack are preview code too.
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
current.onSinkError?.invoke(failure)
|
|
219
|
+
} catch (ignored: Throwable) {
|
|
220
|
+
// The handler is preview code too; it gets no more access to this thread than the sink.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// The frame-agnostic half of `DecodedFrameTap`: sink ownership, failure isolation and metrics.
|
|
4
|
+
///
|
|
5
|
+
/// The call owns the decoder; a preview sink is a guest. `offer` never lets a thrown error reach
|
|
6
|
+
/// the decoder thread, and with no sink attached and telemetry off it costs one uncontended lock
|
|
7
|
+
/// around a single reference load (Swift has no portable atomics for iOS 15).
|
|
8
|
+
/// Swift runtime traps (force-unwraps, bounds failures) are process failures and cannot be caught
|
|
9
|
+
/// here; the preview prevents them by validating geometry before touching memory.
|
|
10
|
+
public final class DecodedFrameTapCore<Frame> {
|
|
11
|
+
/// A snapshot of decoder-thread observations, drained by the reader.
|
|
12
|
+
public struct Metrics {
|
|
13
|
+
public let framesOffered: Int
|
|
14
|
+
public let framesWithSink: Int
|
|
15
|
+
public let offerTotalNs: Int64
|
|
16
|
+
public let offerMaxNs: Int64
|
|
17
|
+
public let cadenceSamples: Int
|
|
18
|
+
public let cadenceTotalNs: Int64
|
|
19
|
+
public let cadenceMaxNs: Int64
|
|
20
|
+
public let sinkExceptions: Int
|
|
21
|
+
/// Frames the ACS sender accepted, recorded at its call sites after `offer`.
|
|
22
|
+
public let acsFramesSent: Int
|
|
23
|
+
|
|
24
|
+
/// Mean time inside `offer` over every timed frame, with or without a sink.
|
|
25
|
+
public var offerMeanUs: Double {
|
|
26
|
+
framesOffered > 0 ? Double(offerTotalNs) / Double(framesOffered) / 1000.0 : 0
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public var offerMaxUs: Double { Double(offerMaxNs) / 1000.0 }
|
|
30
|
+
|
|
31
|
+
public var cadenceMeanMs: Double {
|
|
32
|
+
cadenceSamples > 0 ? Double(cadenceTotalNs) / Double(cadenceSamples) / 1_000_000.0 : 0
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public var cadenceMaxMs: Double { Double(cadenceMaxNs) / 1_000_000.0 }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private final class State {
|
|
39
|
+
let sink: ((Frame) throws -> Void)?
|
|
40
|
+
let onSinkError: ((Error) -> Void)?
|
|
41
|
+
init(sink: ((Frame) throws -> Void)?, onSinkError: ((Error) -> Void)?) {
|
|
42
|
+
self.sink = sink
|
|
43
|
+
self.onSinkError = onSinkError
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private let stateLock = NSLock()
|
|
48
|
+
/// Replaced wholesale, never mutated, so `offer` reads one reference under a short lock.
|
|
49
|
+
private var state: State?
|
|
50
|
+
private var generation: UInt64 = 0
|
|
51
|
+
private var sink: ((Frame) throws -> Void)?
|
|
52
|
+
private var onSinkError: ((Error) -> Void)?
|
|
53
|
+
public private(set) var telemetryEnabled = false
|
|
54
|
+
|
|
55
|
+
private let metricsLock = NSLock()
|
|
56
|
+
private var framesOffered = 0
|
|
57
|
+
private var framesWithSink = 0
|
|
58
|
+
private var offerTotalNs: Int64 = 0
|
|
59
|
+
private var offerMaxNs: Int64 = 0
|
|
60
|
+
private var cadenceSamples = 0
|
|
61
|
+
private var cadenceTotalNs: Int64 = 0
|
|
62
|
+
private var cadenceMaxNs: Int64 = 0
|
|
63
|
+
private var sinkExceptions = 0
|
|
64
|
+
private var acsFramesSent = 0
|
|
65
|
+
private var lastOfferAtNs: Int64 = 0
|
|
66
|
+
|
|
67
|
+
private let clock: () -> Int64
|
|
68
|
+
|
|
69
|
+
public init(clock: @escaping () -> Int64 = { Int64(DispatchTime.now().uptimeNanoseconds) }) {
|
|
70
|
+
self.clock = clock
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// Install the sink and return the generation that owns it. `onSinkError` runs on the decoder
|
|
74
|
+
/// thread after a thrown error has been caught and counted; it must only schedule work.
|
|
75
|
+
@discardableResult
|
|
76
|
+
public func attach(_ sink: @escaping (Frame) throws -> Void, onSinkError: ((Error) -> Void)? = nil) -> UInt64 {
|
|
77
|
+
stateLock.lock()
|
|
78
|
+
defer { stateLock.unlock() }
|
|
79
|
+
generation &+= 1
|
|
80
|
+
self.sink = sink
|
|
81
|
+
self.onSinkError = onSinkError
|
|
82
|
+
publishLocked()
|
|
83
|
+
return generation
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// Remove the sink only if it is still the one `generation` installed.
|
|
87
|
+
@discardableResult
|
|
88
|
+
public func detach(generation: UInt64) -> Bool {
|
|
89
|
+
stateLock.lock()
|
|
90
|
+
defer { stateLock.unlock() }
|
|
91
|
+
guard generation == self.generation, sink != nil else { return false }
|
|
92
|
+
sink = nil
|
|
93
|
+
onSinkError = nil
|
|
94
|
+
publishLocked()
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public func isCurrent(generation: UInt64) -> Bool {
|
|
99
|
+
stateLock.lock()
|
|
100
|
+
defer { stateLock.unlock() }
|
|
101
|
+
return generation == self.generation && sink != nil
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public var hasSink: Bool {
|
|
105
|
+
stateLock.lock()
|
|
106
|
+
defer { stateLock.unlock() }
|
|
107
|
+
return sink != nil
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// Collect cadence and offer cost even with no sink, so preview off/on can be compared.
|
|
111
|
+
public func setTelemetryEnabled(_ enabled: Bool) {
|
|
112
|
+
stateLock.lock()
|
|
113
|
+
defer { stateLock.unlock() }
|
|
114
|
+
telemetryEnabled = enabled
|
|
115
|
+
publishLocked()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private func publishLocked() {
|
|
119
|
+
let wasCollecting = state != nil
|
|
120
|
+
state = (sink == nil && !telemetryEnabled) ? nil : State(sink: sink, onSinkError: onSinkError)
|
|
121
|
+
if !wasCollecting, state != nil {
|
|
122
|
+
// The first gap after collection resumes would span the whole idle period.
|
|
123
|
+
metricsLock.lock()
|
|
124
|
+
lastOfferAtNs = 0
|
|
125
|
+
metricsLock.unlock()
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// Read and clear the accumulated observations. Called about once a second by the reporter.
|
|
130
|
+
public func drainMetrics() -> Metrics {
|
|
131
|
+
metricsLock.lock()
|
|
132
|
+
defer { metricsLock.unlock() }
|
|
133
|
+
let snapshot = Metrics(
|
|
134
|
+
framesOffered: framesOffered,
|
|
135
|
+
framesWithSink: framesWithSink,
|
|
136
|
+
offerTotalNs: offerTotalNs,
|
|
137
|
+
offerMaxNs: offerMaxNs,
|
|
138
|
+
cadenceSamples: cadenceSamples,
|
|
139
|
+
cadenceTotalNs: cadenceTotalNs,
|
|
140
|
+
cadenceMaxNs: cadenceMaxNs,
|
|
141
|
+
sinkExceptions: sinkExceptions,
|
|
142
|
+
acsFramesSent: acsFramesSent
|
|
143
|
+
)
|
|
144
|
+
framesOffered = 0
|
|
145
|
+
framesWithSink = 0
|
|
146
|
+
offerTotalNs = 0
|
|
147
|
+
offerMaxNs = 0
|
|
148
|
+
cadenceSamples = 0
|
|
149
|
+
cadenceTotalNs = 0
|
|
150
|
+
cadenceMaxNs = 0
|
|
151
|
+
sinkExceptions = 0
|
|
152
|
+
acsFramesSent = 0
|
|
153
|
+
return snapshot
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/// The ACS sender accepted a frame. Counted under the same gate as `offer`.
|
|
157
|
+
public func recordAcsSend() {
|
|
158
|
+
stateLock.lock()
|
|
159
|
+
let collecting = state != nil
|
|
160
|
+
stateLock.unlock()
|
|
161
|
+
guard collecting else { return }
|
|
162
|
+
metricsLock.lock()
|
|
163
|
+
acsFramesSent += 1
|
|
164
|
+
metricsLock.unlock()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// Called on the decoder thread, immediately before the frame goes to ACS.
|
|
168
|
+
public func offer(_ frame: Frame) {
|
|
169
|
+
stateLock.lock()
|
|
170
|
+
let current = state
|
|
171
|
+
stateLock.unlock()
|
|
172
|
+
guard let current else { return }
|
|
173
|
+
|
|
174
|
+
let started = clock()
|
|
175
|
+
var failure: Error?
|
|
176
|
+
if let sink = current.sink {
|
|
177
|
+
do {
|
|
178
|
+
try sink(frame)
|
|
179
|
+
} catch {
|
|
180
|
+
failure = error
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
let elapsed = clock() - started
|
|
184
|
+
|
|
185
|
+
metricsLock.lock()
|
|
186
|
+
framesOffered += 1
|
|
187
|
+
if lastOfferAtNs > 0 {
|
|
188
|
+
let gap = started - lastOfferAtNs
|
|
189
|
+
cadenceSamples += 1
|
|
190
|
+
cadenceTotalNs += gap
|
|
191
|
+
if gap > cadenceMaxNs { cadenceMaxNs = gap }
|
|
192
|
+
}
|
|
193
|
+
lastOfferAtNs = started
|
|
194
|
+
offerTotalNs += elapsed
|
|
195
|
+
if elapsed > offerMaxNs { offerMaxNs = elapsed }
|
|
196
|
+
if current.sink != nil { framesWithSink += 1 }
|
|
197
|
+
if failure != nil { sinkExceptions += 1 }
|
|
198
|
+
metricsLock.unlock()
|
|
199
|
+
|
|
200
|
+
if let failure {
|
|
201
|
+
current.onSinkError?(failure)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import CoreVideo
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// A second, optional consumer of decoded glasses video, sitting beside the ACS sender.
|
|
5
|
+
///
|
|
6
|
+
/// The call owns this pipeline; the preview is a guest. `offer` never blocks, never lets a thrown
|
|
7
|
+
/// error reach its caller, and never does real work — a sink may only decide whether it wants the
|
|
8
|
+
/// frame, retain it, and schedule bounded work elsewhere. The logic lives in
|
|
9
|
+
/// `DecodedFrameTapCore` so it can be unit tested without CoreVideo.
|
|
10
|
+
///
|
|
11
|
+
/// Detach is generation-checked because sessions overlap: a call ending late must not tear down
|
|
12
|
+
/// the subscription a newer call already installed.
|
|
13
|
+
public final class DecodedFrameTap {
|
|
14
|
+
public static let shared = DecodedFrameTap()
|
|
15
|
+
|
|
16
|
+
public typealias Metrics = DecodedFrameTapCore<CVPixelBuffer>.Metrics
|
|
17
|
+
|
|
18
|
+
private let core = DecodedFrameTapCore<CVPixelBuffer>()
|
|
19
|
+
|
|
20
|
+
public init() {}
|
|
21
|
+
|
|
22
|
+
/// Install the preview sink and return the generation that owns it.
|
|
23
|
+
@discardableResult
|
|
24
|
+
public func attach(
|
|
25
|
+
_ sink: @escaping (CVPixelBuffer) throws -> Void,
|
|
26
|
+
onSinkError: ((Error) -> Void)? = nil
|
|
27
|
+
) -> UInt64 {
|
|
28
|
+
core.attach(sink, onSinkError: onSinkError)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// Remove the sink only if it is still the one this generation installed.
|
|
32
|
+
@discardableResult
|
|
33
|
+
public func detach(generation: UInt64) -> Bool {
|
|
34
|
+
core.detach(generation: generation)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public func isCurrent(generation: UInt64) -> Bool {
|
|
38
|
+
core.isCurrent(generation: generation)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public var hasSink: Bool { core.hasSink }
|
|
42
|
+
|
|
43
|
+
/// Collect cadence, offer cost and the ACS send count even with no sink attached.
|
|
44
|
+
public func setTelemetryEnabled(_ enabled: Bool) {
|
|
45
|
+
core.setTelemetryEnabled(enabled)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public var telemetryEnabled: Bool { core.telemetryEnabled }
|
|
49
|
+
|
|
50
|
+
/// Read and clear the accumulated observations. Called about once a second by the reporter.
|
|
51
|
+
public func drainMetrics() -> Metrics {
|
|
52
|
+
core.drainMetrics()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// The ACS sender accepted a frame. Called by acs-meeting right after a successful send.
|
|
56
|
+
public func recordAcsSend() {
|
|
57
|
+
core.recordAcsSend()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// Called on the decoder's thread, immediately before the frame goes to ACS.
|
|
61
|
+
public func offer(_ buffer: CVPixelBuffer) {
|
|
62
|
+
core.offer(buffer)
|
|
63
|
+
}
|
|
64
|
+
}
|