@mentra/glasses-media 3.2.0-dev.235 → 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.
- package/android/src/main/java/com/mentra/glassesmedia/source/FrameStallGate.kt +61 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/GlassesMediaSource.kt +33 -2
- package/android/src/main/java/com/mentra/glassesmedia/source/LocalWhipIngestSource.kt +160 -1
- package/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestServer.kt +27 -1
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/PipelineStats.kt +12 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/PipelineTicker.kt +8 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
}
|
|
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
|
}
|