@mentra/glasses-media 3.2.0-dev.233 → 3.2.0-dev.245

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,61 @@
1
+ package com.mentra.glassesmedia.source
2
+
3
+ /**
4
+ * Watches a *live* ingest for a decoder that has stopped advancing.
5
+ *
6
+ * [FirstFrameGate] only ever guards the first frame; once promoted it never
7
+ * fires again. An ingest that dies mid-call therefore reads as healthy from
8
+ * every angle above this layer — ICE stays connected, audio keeps flowing, and
9
+ * the wearer sits behind a frozen tile while the call still reports live video.
10
+ * Two "video frozen, audio still works" reports arrived with nothing in the
11
+ * traces marking the moment frames stopped.
12
+ *
13
+ * Kept free of `org.webrtc` types so the decision is unit testable; the source
14
+ * feeds it one stats sample at a time and acts on [Verdict.Stalled].
15
+ */
16
+ internal class FrameStallGate(private val stallSamples: Int) {
17
+ sealed interface Verdict {
18
+ /** Frames are advancing, or this sample cannot say. */
19
+ data object Healthy : Verdict
20
+
21
+ /** Frames have stopped but not yet for long enough to call it. */
22
+ data class Suspected(val samples: Int) : Verdict
23
+
24
+ /** Report once, then stay quiet until the next arm. */
25
+ data class Stalled(val samples: Int) : Verdict
26
+ }
27
+
28
+ private var stalled = 0
29
+ private var reported = false
30
+
31
+ val samples: Int
32
+ get() = stalled
33
+
34
+ /** A new peer generation, or a source that re-earned LIVE. */
35
+ fun arm() {
36
+ stalled = 0
37
+ reported = false
38
+ }
39
+
40
+ /**
41
+ * @param live the source has painted at least one frame; before that the first-frame gate owns
42
+ * the verdict.
43
+ * @param iceConnected a disconnected transport is its own failure, already handled upstream.
44
+ * @param fps decoded frames per second, negative when the sample has no prior read to rate
45
+ * against. A rate needs two reads, so the first sample is never evidence.
46
+ */
47
+ fun sample(live: Boolean, iceConnected: Boolean, fps: Double): Verdict {
48
+ if (!live || !iceConnected || fps < 0.0) {
49
+ stalled = 0
50
+ return Verdict.Healthy
51
+ }
52
+ if (fps > 0.0) {
53
+ stalled = 0
54
+ return Verdict.Healthy
55
+ }
56
+ stalled++
57
+ if (stalled < stallSamples || reported) return Verdict.Suspected(stalled)
58
+ reported = true
59
+ return Verdict.Stalled(stalled)
60
+ }
61
+ }
@@ -100,6 +100,15 @@ class GlassesMediaController(
100
100
  private var source: GlassesMediaSource? = null
101
101
  private var stateListener: SourceStateListener? = null
102
102
 
103
+ /**
104
+ * The SoftAP listener this controller has let go of, kept only until its port is released.
105
+ *
106
+ * [stop] drops the source, but a WHIP listener answers `410` for a few seconds after that and
107
+ * still holds its port. Losing the reference here is what made the port's release unobservable
108
+ * from above, so the next call bound into it.
109
+ */
110
+ private var retiringIngest: LocalWhipIngestSource? = null
111
+
103
112
  val state: SourceState
104
113
  get() = source?.state ?: SourceState.IDLE
105
114
 
@@ -108,7 +117,7 @@ class GlassesMediaController(
108
117
  get() = source?.ingestUrl
109
118
 
110
119
  fun attach(video: VideoFrameListener, pcm: PcmListener, config: SourceConfig) {
111
- source?.stop()
120
+ retire()
112
121
  source = factory.create(video, pcm, config).also {
113
122
  it.setStateListener(stateListener)
114
123
  it.start(config)
@@ -129,10 +138,32 @@ class GlassesMediaController(
129
138
  }
130
139
 
131
140
  fun stop() {
132
- source?.stop()
141
+ retire()
142
+ }
143
+
144
+ private fun retire() {
145
+ val previous = source
146
+ previous?.stop()
147
+ if (previous is LocalWhipIngestSource) retiringIngest = previous
133
148
  source = null
134
149
  }
135
150
 
151
+ /**
152
+ * Has the SoftAP listener actually released its port? `true` when there is nothing to wait for.
153
+ *
154
+ * A `false` here is the teardown barrier's signal to force the close rather than to give up:
155
+ * the next call's bind fails on exactly this port.
156
+ */
157
+ fun awaitIngestClosed(timeoutMs: Long): Boolean = retiringIngest?.awaitIngestClosed(timeoutMs) ?: true
158
+
159
+ /** Close the retiring listener now, skipping the tombstone. Only for a barrier that timed out. */
160
+ fun forceCloseIngest() {
161
+ // Do not drop the handle here: the host re-asks awaitIngestClosed after this to verify the port
162
+ // was released, and a null reference answers `true` unconditionally, hiding a failed close. The
163
+ // next retire() reassigns retiringIngest, so keeping the closed one until then is harmless.
164
+ retiringIngest?.forceCloseIngest()
165
+ }
166
+
136
167
  fun setPcmDeliveryEnabled(enabled: Boolean) {
137
168
  source?.setPcmDeliveryEnabled(enabled)
138
169
  }
@@ -68,6 +68,7 @@ class LocalWhipIngestSource(
68
68
 
69
69
  private val relay = DecodedTrackRelay(videoListener, pcmListener, stats) { notePromotableFrame() }
70
70
  private val firstFrame = FirstFrameGate()
71
+ private val frameStall = FrameStallGate(INGEST_STALL_SAMPLES)
71
72
  private val videoIds = TrackRegistry()
72
73
  private val audioIds = TrackRegistry()
73
74
  private val audioTracks = CopyOnWriteArrayList<AudioTrack>()
@@ -81,8 +82,14 @@ class LocalWhipIngestSource(
81
82
  @Volatile private var attachedVideo: VideoTrack? = null
82
83
  @Volatile private var boundUrl: String? = null
83
84
  @Volatile private var stateListener: SourceStateListener? = null
85
+ /** The listener handed to the tombstone thread by [stop]. See [awaitIngestClosed]. */
86
+ @Volatile private var retiring: WhipIngestServer? = null
84
87
  @Volatile private var firstFrameDeadline: Runnable? = null
85
88
  @Volatile private var selectedPairTask: Runnable? = null
89
+ @Volatile private var ingestSampleTask: Runnable? = null
90
+ private var lastIngestBytes = -1L
91
+ private var lastIngestFrames = -1L
92
+ private var lastIngestSampleAtMs = 0L
86
93
 
87
94
  /**
88
95
  * Invalidates callbacks from a peer we are disposing. A negotiation can be mid-gather when the
@@ -148,6 +155,7 @@ class LocalWhipIngestSource(
148
155
  boundUrl = null
149
156
  cancelFirstFrameDeadline()
150
157
  cancelSelectedPairProof()
158
+ cancelIngestSampling()
151
159
  firstFrame.reset()
152
160
  detachTracks()
153
161
  relay.resetRotationLog()
@@ -158,17 +166,46 @@ class LocalWhipIngestSource(
158
166
 
159
167
  // stop() leaves the listener answering 410 for a few seconds, so a POST the glasses already
160
168
  // sent gets an answer it can act on instead of a reset it would retry.
161
- server?.let { runCatching { it.stop() } }
169
+ server?.let {
170
+ // Held past the field being cleared: this source is done with it, but the port is not free
171
+ // until the tombstone thread closes it, and the next call needs that port.
172
+ retiring = it
173
+ runCatching { it.stop() }
174
+ }
162
175
  server = null
163
176
  disposePeer()
164
177
  scopedNetwork?.let { ScopedNetworkChangeDetector.releaseReceiverNetwork(it) }
165
178
  }
166
179
 
180
+ /**
181
+ * Wait out the tombstone. `false` means the port is still held and the caller must force it.
182
+ *
183
+ * Trivially true when nothing was ever bound, so a teardown after a join that failed before the
184
+ * listener existed does not spend the whole bound discovering there is nothing to wait for.
185
+ */
186
+ fun awaitIngestClosed(timeoutMs: Long): Boolean = (retiring ?: server)?.awaitClosed(timeoutMs) ?: true
187
+
188
+ /**
189
+ * Drop the listener now, tombstone or not.
190
+ *
191
+ * Only for the barrier's forced path: closing early means an in-flight request from the glasses
192
+ * gets a connection reset instead of `410`, which is a worse answer — but it is a better outcome
193
+ * than a next call that cannot bind its port.
194
+ */
195
+ fun forceCloseIngest() {
196
+ // Keep the handle: forceSoftapCleanup re-asks awaitIngestClosed to confirm the port is really
197
+ // free. Nulling here would make that check read `null -> true` and mask a closeNow that threw,
198
+ // so the next Start binds a port this listener still holds. closeNow/awaitClosed are idempotent,
199
+ // and a fresh stop() reassigns `retiring`, so leaving it set is safe.
200
+ (retiring ?: server)?.let { runCatching { it.closeNow() } }
201
+ }
202
+
167
203
  /** Terminal teardown for owners that discard this receiver instead of reusing its factory. */
168
204
  fun close() {
169
205
  generation++
170
206
  check(server?.closeAndAwait() != false) { "Local WHIP requests are still draining" }
171
207
  stop()
208
+ forceCloseIngest()
172
209
  factory?.dispose()
173
210
  factory = null
174
211
  egl?.release()
@@ -360,6 +397,7 @@ class LocalWhipIngestSource(
360
397
  }
361
398
 
362
399
  armFirstFrame(gen)
400
+ armIngestSampling(gen)
363
401
  return Result.success(local)
364
402
  }
365
403
 
@@ -370,6 +408,7 @@ class LocalWhipIngestSource(
370
408
  generation++
371
409
  cancelFirstFrameDeadline()
372
410
  cancelSelectedPairProof()
411
+ cancelIngestSampling()
373
412
  detachTracks()
374
413
  disposePeer()
375
414
  if (state != SourceState.IDLE) transition(SourceState.FAILED, "publisher_terminated")
@@ -623,6 +662,115 @@ class LocalWhipIngestSource(
623
662
  selectedPairTask = null
624
663
  }
625
664
 
665
+ /**
666
+ * Sample the glasses→phone leg for as long as this peer lives.
667
+ *
668
+ * The ACS side already reports what leaves the phone; without this the two hops are impossible
669
+ * to tell apart, and "the joined call looked compressed" has two completely different causes —
670
+ * the glasses encoder sending little, or the phone throttling its own uplink from plenty. Same
671
+ * 2 s cadence as `acs_bwe_sample` so the two series line up without interpolation.
672
+ */
673
+ private fun armIngestSampling(gen: Int) {
674
+ cancelIngestSampling()
675
+ lastIngestBytes = -1L
676
+ lastIngestFrames = -1L
677
+ lastIngestSampleAtMs = 0L
678
+ stats.inboundBitrateBps = null
679
+ frameStall.arm()
680
+ scheduleIngestSample(gen)
681
+ }
682
+
683
+ private fun scheduleIngestSample(gen: Int) {
684
+ val task = Runnable {
685
+ if (gen != generation) return@Runnable
686
+ sampleIngest(gen)
687
+ scheduleIngestSample(gen)
688
+ }
689
+ ingestSampleTask = task
690
+ mainHandler.postDelayed(task, INGEST_SAMPLE_INTERVAL_MS)
691
+ }
692
+
693
+ private fun cancelIngestSampling() {
694
+ ingestSampleTask?.let { mainHandler.removeCallbacks(it) }
695
+ ingestSampleTask = null
696
+ }
697
+
698
+ private fun sampleIngest(gen: Int) {
699
+ val peer = pc ?: return
700
+ val iceState = runCatching { peer.iceConnectionState()?.name?.lowercase() }.getOrNull() ?: "unknown"
701
+ runCatching {
702
+ peer.getStats { report ->
703
+ if (gen != generation) return@getStats
704
+ val inbound = report.statsMap.values.firstOrNull {
705
+ it.type == "inbound-rtp" && (it.members["kind"] ?: it.members["mediaType"]) == "video"
706
+ }
707
+ val now = android.os.SystemClock.elapsedRealtime()
708
+ val bytes = (inbound?.members?.get("bytesReceived") as? Number)?.toLong() ?: -1L
709
+ val frames = (inbound?.members?.get("framesDecoded") as? Number)?.toLong() ?: -1L
710
+ val elapsedMs = if (lastIngestSampleAtMs == 0L) 0L else now - lastIngestSampleAtMs
711
+ // Rates need two reads. The first sample reports -1 rather than dividing by the time since
712
+ // the epoch, which would print a plausible and meaningless number.
713
+ val bitrate = if (lastIngestBytes < 0 || bytes < lastIngestBytes || elapsedMs <= 0) -1L
714
+ else (bytes - lastIngestBytes) * 8_000L / elapsedMs
715
+ val fps = if (lastIngestFrames < 0 || frames < lastIngestFrames || elapsedMs <= 0) -1.0
716
+ else (frames - lastIngestFrames) * 1000.0 / elapsedMs
717
+ lastIngestBytes = bytes
718
+ lastIngestFrames = frames
719
+ lastIngestSampleAtMs = now
720
+ // Mirrored onto the shared stats so the ACS side can name this hop's rate inside a
721
+ // low-bitrate episode. Null rather than -1 there: "no reading yet" must not average in.
722
+ stats.inboundBitrateBps = if (bitrate >= 0) bitrate else null
723
+ SoftApTrace.stage(
724
+ "whip_ingest_sample",
725
+ "iceState" to iceState,
726
+ "sourceState" to state.name.lowercase(),
727
+ "inboundBitrateBps" to bitrate,
728
+ "inboundFps" to PipelineStats.formatRate(fps),
729
+ "decodedFps" to (stats.decodedFps?.let { PipelineStats.formatRate(it) } ?: "na"),
730
+ "bytesReceived" to bytes,
731
+ "framesDecoded" to frames,
732
+ "stalledSamples" to frameStall.samples,
733
+ )
734
+ noteIngestStall(gen, iceState, fps, frames)
735
+ }
736
+ }.onFailure { Log.w(TAG, "SoftAP ingest sample failed", it) }
737
+ }
738
+
739
+ /**
740
+ * Fail a live session whose decoder has stopped advancing. See [FrameStallGate].
741
+ *
742
+ * Keep the peer and listener: `AcsMeetingSession` suppresses session-level rebuilds
743
+ * for SoftAP because rebinding would strand the glasses on a port they were never told about.
744
+ * `FAILED` surfaces to the host as `mediaSource: failed`, which moves the call out of "video
745
+ * live". A resumed frame can restore LIVE on this peer, or the glasses can reconnect on the
746
+ * unchanged URL. A decoder pause need not cause an ICE state change or a new WHIP offer.
747
+ */
748
+ @Synchronized
749
+ private fun noteIngestStall(gen: Int, iceState: String, fps: Double, frames: Long) {
750
+ if (gen != generation) return
751
+ val verdict = frameStall.sample(
752
+ live = state == SourceState.LIVE,
753
+ // Host-only SoftAP settles on COMPLETED, not CONNECTED, and stays there for the whole call.
754
+ // Treating only "connected" as live would reset the stall count every sample — the same
755
+ // steady state onIceConnectionChange and WHEP both count as healthy — and never fail a freeze.
756
+ iceConnected = iceState == "connected" || iceState == "completed",
757
+ fps = fps,
758
+ )
759
+ if (verdict !is FrameStallGate.Verdict.Stalled) return
760
+ Log.w(TAG, "SoftAP ingest decoded no frames across ${verdict.samples} samples; failing source")
761
+ SoftApTrace.failure(
762
+ "ingest_frames_stalled",
763
+ "samples" to verdict.samples,
764
+ "intervalMs" to INGEST_SAMPLE_INTERVAL_MS,
765
+ "framesDecoded" to frames,
766
+ )
767
+ // Serialize re-arming and failure against the decode callback so a resumed frame cannot
768
+ // promote LIVE just before this sample overwrites it with FAILED.
769
+ if (gen != generation) return
770
+ firstFrame.arm(gen)
771
+ transition(SourceState.FAILED, "frames_stalled")
772
+ }
773
+
626
774
  /** Reads the prefix per sample: the phone can lose and rejoin the hotspot mid-call. */
627
775
  private fun sampleIcePath(gen: Int, onSample: (IcePathVerdict) -> Unit) {
628
776
  val peer = pc ?: return
@@ -738,9 +886,14 @@ class LocalWhipIngestSource(
738
886
  )
739
887
  }
740
888
 
889
+ @Synchronized
741
890
  private fun notePromotableFrame() {
742
891
  if (!firstFrame.onFrame(generation)) return
743
892
  cancelFirstFrameDeadline()
893
+ // Re-armed on every promotion, not just per peer: ICE recovery re-earns LIVE on the same peer
894
+ // without restarting the sampler, and a gate still holding its last verdict would let the
895
+ // second freeze of a call go unreported.
896
+ frameStall.arm()
744
897
  SoftApTrace.stage("ingest_first_frame")
745
898
  transition(SourceState.LIVE, "first_frame")
746
899
  }
@@ -781,5 +934,11 @@ class LocalWhipIngestSource(
781
934
 
782
935
  /** Long enough that a healthy 15 fps feed cannot show a flat byte counter across the two reads. */
783
936
  private const val SELECTED_PAIR_SAMPLE_GAP_MS = 1_200L
937
+
938
+ /** Matches the ACS-side `acs_bwe_sample` cadence so the two hops can be read side by side. */
939
+ private const val INGEST_SAMPLE_INTERVAL_MS = 2_000L
940
+
941
+ /** Matches [FIRST_FRAME_TIMEOUT_MS] at the sample cadence: 6s of a frozen decoder. */
942
+ private const val INGEST_STALL_SAMPLES = 3
784
943
  }
785
944
  }
@@ -11,6 +11,7 @@ import java.net.ServerSocket
11
11
  import java.net.Socket
12
12
  import java.net.SocketException
13
13
  import java.util.UUID
14
+ import java.util.concurrent.CountDownLatch
14
15
  import java.util.concurrent.Executors
15
16
  import java.util.concurrent.TimeUnit
16
17
  import java.util.concurrent.atomic.AtomicInteger
@@ -72,6 +73,16 @@ class WhipIngestServer(
72
73
  private val activeSockets = java.util.concurrent.ConcurrentHashMap.newKeySet<Socket>()
73
74
  private val accepted = AtomicInteger()
74
75
 
76
+ /**
77
+ * Open while a listener is bound; counted down once the socket is actually closed.
78
+ *
79
+ * [stop] returns immediately and finishes on a tombstone thread [TOMBSTONE_MS] later, so nothing
80
+ * above this class could tell the port had been released. The next call would bind before this
81
+ * one let go and fail with a port that was still ours. Starts already-counted-down so a server
82
+ * that never bound is trivially "closed".
83
+ */
84
+ @Volatile private var closed = CountDownLatch(0)
85
+
75
86
  /** Bound endpoint, or null before [start]. This is the URL the glasses must be told to POST to. */
76
87
  val boundEndpoint: WhipIngestProtocol.Endpoint?
77
88
  get() = synchronized(lock) { endpoint }
@@ -93,6 +104,7 @@ class WhipIngestServer(
93
104
  val bound = WhipIngestProtocol.Endpoint(address.hostAddress ?: "127.0.0.1", socket.localPort)
94
105
  server = socket
95
106
  endpoint = bound
107
+ closed = CountDownLatch(1)
96
108
  state = WhipIngestProtocol.State()
97
109
  acceptThread = Thread({ acceptLoop(socket) }, "whip-ingest-accept").apply {
98
110
  isDaemon = true
@@ -149,13 +161,27 @@ class WhipIngestServer(
149
161
  return connections.awaitTermination(timeoutMs, TimeUnit.MILLISECONDS)
150
162
  }
151
163
 
164
+ /**
165
+ * Block until the listener is really gone, or the bound expires.
166
+ *
167
+ * The teardown barrier's whole job is to answer "can the next call bind this port", and only
168
+ * this can answer it: [stop] hands the close to a thread that runs [TOMBSTONE_MS] later. `false`
169
+ * means the caller must force the close rather than proceed — a timeout here is not consent.
170
+ */
171
+ fun awaitClosed(timeoutMs: Long = TOMBSTONE_MS + 500): Boolean =
172
+ closed.await(timeoutMs, TimeUnit.MILLISECONDS)
173
+
152
174
  private fun closeListener() {
153
175
  val socket = synchronized(lock) {
154
176
  val current = server
155
177
  server = null
156
178
  endpoint = null
157
179
  current
158
- } ?: return
180
+ }
181
+ // Counted down even on the early return: a second close must not leave a waiter parked on a
182
+ // latch that nothing will ever open again.
183
+ closed.countDown()
184
+ if (socket == null) return
159
185
  runCatching { socket.close() }
160
186
  activeSockets.forEach { runCatching { it.close() } }
161
187
  connections.shutdownNow()
@@ -108,6 +108,15 @@ class PipelineStats(
108
108
  @Volatile var lastSubFps: Double = 0.0
109
109
  @Volatile var decodedFps: Double? = null
110
110
  @Volatile var recvFps: Double? = null
111
+ /**
112
+ * Glasses→phone WHIP rate, mirrored here from the ingest source's own sampler.
113
+ *
114
+ * Published on the shared stats rather than kept local to the ingest source because it is the
115
+ * field that assigns blame for a bad picture, and the thing that needs it is the ACS side: a
116
+ * collapsed uplink alongside a full-rate source is ACS's rate controller, while both low is a
117
+ * starved pipeline. Null until two reads have been taken — a rate needs a delta.
118
+ */
119
+ @Volatile var inboundBitrateBps: Long? = null
111
120
  @Volatile var wireFps: Double? = null
112
121
  @Volatile var wireWidth: Int? = null
113
122
  @Volatile var wireHeight: Int? = null
@@ -282,6 +291,9 @@ class PipelineStats(
282
291
  return sink.get() - settled
283
292
  }
284
293
 
294
+ /** Frames refused by the outgoing pacer alone, separated from the other drop reasons. */
295
+ fun dropPacedCount(): Int = dropPaced.get()
296
+
285
297
  fun dupCount(): Int = dup.get()
286
298
  fun rotCount(): Int = rot.get()
287
299
 
@@ -11,6 +11,13 @@ class PipelineTicker(
11
11
  looper: Looper = Looper.getMainLooper(),
12
12
  private val elapsedCpuMs: () -> Long = { Process.getElapsedCpuTime() },
13
13
  private val cores: Int = Runtime.getRuntime().availableProcessors(),
14
+ /**
15
+ * Run once per tick, after the ladder lines.
16
+ *
17
+ * A second timer for sampling would drift against this one, and two sets of numbers a second
18
+ * apart cannot be compared. Whoever samples here decides its own cadence by counting ticks.
19
+ */
20
+ private val onTick: (() -> Unit)? = null,
14
21
  private val emit: (String) -> Unit,
15
22
  ) {
16
23
  private val handler = Handler(looper)
@@ -47,6 +54,7 @@ class PipelineTicker(
47
54
  ),
48
55
  )
49
56
  avSync?.let { emit(it.tick(stats.e2e.p50(), stats.age.p50())) }
57
+ onTick?.invoke()
50
58
  handler.postDelayed(this, INTERVAL_MS)
51
59
  }
52
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/glasses-media",
3
- "version": "3.2.0-dev.233",
3
+ "version": "3.2.0-dev.245",
4
4
  "description": "Shared native glasses media receivers and local network transport",
5
5
  "license": "MIT",
6
6
  "author": "Mentra Community",