@mentra/glasses-media 3.3.0-dev.328 → 3.3.0-dev.329

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.
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/glasses-media",
3
- "version": "3.3.0-dev.328",
3
+ "version": "3.3.0-dev.329",
4
4
  "description": "Shared native glasses media receivers and local network transport",
5
5
  "license": "MIT",
6
6
  "author": "Mentra Community",