@mentra/glasses-media 3.2.1-dev.307 → 3.2.1-dev.308
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
CHANGED
|
@@ -49,6 +49,8 @@ On Android hosts with microphone pinning and native PCM input, managed streams u
|
|
|
49
49
|
|
|
50
50
|
Android's process-wide network inventory exposes both the scoped hotspot and real cellular handles during a relay. Per-factory masks keep the receiver on Wi-Fi and the publisher off Wi-Fi; signaling also uses the cellular network's socket factory and DNS. ACS retains its existing hotspot-only inventory. iOS uses corresponding per-factory masks after validating the cellular default route.
|
|
51
51
|
|
|
52
|
+
The iOS internet publisher posts its offer on the first server-reflexive candidate, complete ICE gathering, or a 1.5-second gather deadline, matching the glasses publisher's STUN policy. It posts once and ignores late triggers after stop. The local SoftAP receiver still waits for complete host-only gathering. Incident logs record the outgoing negotiation stage, candidate counts, HTTP result and first decoded video frame without SDP or publish credentials.
|
|
53
|
+
|
|
52
54
|
Android temporarily lifts the cellular process pin while creating the local WHIP listener, then restores it before starting the outgoing publisher. Otherwise the listener inherits the cellular socket mark and cannot reply to the glasses. The outgoing audio callback supplies its own PCM frame clock: WebRTC's external-recording mode does not pace callbacks and reports zero captured bytes, so the callback fills the entire buffer and waits for the next frame interval without opening the phone microphone.
|
|
53
55
|
|
|
54
56
|
An attempt-specific ID isolates late callbacks and glasses status. Reconnect tears down the glasses publisher, both phone peers and hotspot before rebuilding against the same provisioned input. Three retries bound consecutive failures; a minute of stable streaming renews the retry budget. Last-subscriber stop cancels startup/backoff, awaits native cleanup and disables the hotspot. Failed cleanup retains ownership and can be retried by stop. BLE-unreachable stop/hotspot commands are deferred until reconnection. ACS and managed streaming share a hotspot reservation. Native media queues keep running when the UI is backgrounded; Android holds a partial wake lock and uses the engine's background timers for retry/readiness.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/// Internet WHIP uses the same bounded STUN gather as the glasses publisher.
|
|
2
|
+
/// The local, host-only SoftAP receiver must still wait for complete gathering.
|
|
3
|
+
public struct WhipOfferGate {
|
|
4
|
+
public enum Signal { case localDescriptionSet, serverReflexiveCandidate, gatheringComplete, deadline, stop }
|
|
5
|
+
|
|
6
|
+
private var localSet = false
|
|
7
|
+
private var ready = false
|
|
8
|
+
private var posted = false
|
|
9
|
+
private var stopped = false
|
|
10
|
+
|
|
11
|
+
public init() {}
|
|
12
|
+
|
|
13
|
+
/// True once per publisher, after both the description and a POST trigger exist.
|
|
14
|
+
public mutating func observe(_ signal: Signal) -> Bool {
|
|
15
|
+
switch signal {
|
|
16
|
+
case .localDescriptionSet: localSet = true
|
|
17
|
+
case .serverReflexiveCandidate, .gatheringComplete, .deadline: ready = true
|
|
18
|
+
case .stop: stopped = true
|
|
19
|
+
}
|
|
20
|
+
guard localSet, ready, !posted, !stopped else { return false }
|
|
21
|
+
posted = true
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -2,7 +2,7 @@ import CoreVideo
|
|
|
2
2
|
import Foundation
|
|
3
3
|
import WebRTC
|
|
4
4
|
|
|
5
|
-
/// ASG's
|
|
5
|
+
/// ASG's bounded STUN-gather WHIP negotiation, fed from the shared decoded receiver.
|
|
6
6
|
final class PhoneWhipPublisher: NSObject {
|
|
7
7
|
private let queue = DispatchQueue(label: "com.mentra.glassesmedia.whip.publish")
|
|
8
8
|
private let frameSlot = DispatchSemaphore(value: 1)
|
|
@@ -17,7 +17,10 @@ final class PhoneWhipPublisher: NSObject {
|
|
|
17
17
|
private var capturer: RTCVideoCapturer?
|
|
18
18
|
private var resource: URL?
|
|
19
19
|
private var posted = false
|
|
20
|
-
private var
|
|
20
|
+
private var offerGate = WhipOfferGate()
|
|
21
|
+
private var candidateCount = 0
|
|
22
|
+
private var phase = "creating offer"
|
|
23
|
+
private var firstFrameSeen = false
|
|
21
24
|
private var stopped = false
|
|
22
25
|
private var failed = false
|
|
23
26
|
private var connected = false
|
|
@@ -81,13 +84,19 @@ final class PhoneWhipPublisher: NSObject {
|
|
|
81
84
|
self.queue.async {
|
|
82
85
|
guard !self.stopped else { return }
|
|
83
86
|
guard error == nil else { self.fail("WHIP local description failed"); return }
|
|
84
|
-
self.
|
|
87
|
+
self.phase = "gathering ICE"
|
|
88
|
+
self.maybePost(.localDescriptionSet)
|
|
89
|
+
if peer.iceGatheringState == .complete { self.maybePost(.gatheringComplete) }
|
|
90
|
+
// STUN gathering can stall longer than the entire start request.
|
|
91
|
+
// Match the glasses publisher's 1.5s cap; the remote ICE-lite
|
|
92
|
+
// candidates in the answer let this peer initiate connectivity.
|
|
93
|
+
self.queue.asyncAfter(deadline: .now() + 1.5) { self.maybePost(.deadline) }
|
|
85
94
|
}
|
|
86
95
|
}
|
|
87
96
|
}
|
|
88
97
|
}
|
|
89
98
|
self.queue.asyncAfter(deadline: .now() + 35) {
|
|
90
|
-
if !self.connected { self.fail("WHIP connection timed out") }
|
|
99
|
+
if !self.connected { self.fail("WHIP connection timed out while \(self.phase) (\(self.candidateCount) candidates)") }
|
|
91
100
|
}
|
|
92
101
|
}
|
|
93
102
|
}
|
|
@@ -97,6 +106,10 @@ final class PhoneWhipPublisher: NSObject {
|
|
|
97
106
|
queue.async {
|
|
98
107
|
defer { self.frameSlot.signal() }
|
|
99
108
|
guard !self.stopped, let source = self.videoSource, let capturer = self.capturer else { return }
|
|
109
|
+
if !self.firstFrameSeen {
|
|
110
|
+
self.firstFrameSeen = true
|
|
111
|
+
self.onState("diagnostic", "First decoded glasses video frame reached the phone publisher")
|
|
112
|
+
}
|
|
100
113
|
self.lastTimestamp = max(Int64(ProcessInfo.processInfo.systemUptime * 1_000_000_000), self.lastTimestamp + 1)
|
|
101
114
|
let frame = RTCVideoFrame(buffer: RTCCVPixelBuffer(pixelBuffer: buffer), rotation: ._0, timeStampNs: self.lastTimestamp)
|
|
102
115
|
source.capturer(capturer, didCapture: frame)
|
|
@@ -107,9 +120,12 @@ final class PhoneWhipPublisher: NSObject {
|
|
|
107
120
|
if captureAudio { audioDevice.pcm.push(data, sampleRate: rate, channels: channels) }
|
|
108
121
|
}
|
|
109
122
|
|
|
110
|
-
private func maybePost() {
|
|
111
|
-
guard !stopped, !
|
|
123
|
+
private func maybePost(_ signal: WhipOfferGate.Signal) {
|
|
124
|
+
guard !stopped, !failed, let peer, offerGate.observe(signal) else { return }
|
|
125
|
+
guard let offer = peer.localDescription else { fail("WHIP local description unavailable"); return }
|
|
112
126
|
posted = true
|
|
127
|
+
phase = "sending offer"
|
|
128
|
+
onState("diagnostic", "Posting WHIP offer (\(candidateCount) candidates)")
|
|
113
129
|
var request = URLRequest(url: endpoint)
|
|
114
130
|
request.httpMethod = "POST"
|
|
115
131
|
request.setValue("application/sdp", forHTTPHeaderField: "Content-Type")
|
|
@@ -128,9 +144,16 @@ final class PhoneWhipPublisher: NSObject {
|
|
|
128
144
|
let data, let answer = String(data: data, encoding: .utf8), !answer.isEmpty
|
|
129
145
|
else {
|
|
130
146
|
if let location { self.delete(location) }
|
|
131
|
-
|
|
147
|
+
if let error = error as NSError? {
|
|
148
|
+
self.fail("WHIP signaling failed (\(error.domain) \(error.code))")
|
|
149
|
+
} else {
|
|
150
|
+
self.fail("WHIP server rejected publish (HTTP \(response?.statusCode ?? 0))")
|
|
151
|
+
}
|
|
152
|
+
return
|
|
132
153
|
}
|
|
133
154
|
self.resource = location
|
|
155
|
+
self.phase = "connecting ICE"
|
|
156
|
+
self.onState("diagnostic", "WHIP server accepted publish (HTTP 201)")
|
|
134
157
|
peer.setRemoteDescription(RTCSessionDescription(type: .answer, sdp: answer)) { error in
|
|
135
158
|
self.queue.async { if error != nil { self.fail("WHIP answer failed") } }
|
|
136
159
|
}
|
|
@@ -154,6 +177,7 @@ final class PhoneWhipPublisher: NSObject {
|
|
|
154
177
|
queue.async {
|
|
155
178
|
if !self.stopped {
|
|
156
179
|
self.stopped = true
|
|
180
|
+
_ = self.offerGate.observe(.stop)
|
|
157
181
|
self.peer?.close()
|
|
158
182
|
// Drain the external audio callback while its native delegate/factory is still alive.
|
|
159
183
|
_ = self.audioDevice.terminateDevice()
|
|
@@ -176,6 +200,7 @@ extension PhoneWhipPublisher: RTCPeerConnectionDelegate {
|
|
|
176
200
|
func peerConnection(_ peerConnection: RTCPeerConnection, didChange state: RTCIceConnectionState) {
|
|
177
201
|
queue.async {
|
|
178
202
|
guard self.peer === peerConnection, !self.stopped else { return }
|
|
203
|
+
self.onState("diagnostic", "WHIP ICE state \(state.rawValue)")
|
|
179
204
|
switch state {
|
|
180
205
|
case .connected, .completed:
|
|
181
206
|
self.connected = true; self.disconnectGeneration += 1
|
|
@@ -192,11 +217,22 @@ extension PhoneWhipPublisher: RTCPeerConnectionDelegate {
|
|
|
192
217
|
}
|
|
193
218
|
}
|
|
194
219
|
|
|
195
|
-
func peerConnection(_ peerConnection: RTCPeerConnection, didChange
|
|
196
|
-
queue.async {
|
|
220
|
+
func peerConnection(_ peerConnection: RTCPeerConnection, didChange state: RTCIceGatheringState) {
|
|
221
|
+
queue.async {
|
|
222
|
+
guard self.peer === peerConnection, !self.stopped else { return }
|
|
223
|
+
self.onState("diagnostic", "WHIP ICE gathering state \(state.rawValue) (\(self.candidateCount) candidates)")
|
|
224
|
+
if state == .complete { self.maybePost(.gatheringComplete) }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) {
|
|
229
|
+
queue.async {
|
|
230
|
+
guard self.peer === peerConnection, !self.stopped else { return }
|
|
231
|
+
self.candidateCount += 1
|
|
232
|
+
if candidate.sdp.contains(" typ srflx ") { self.maybePost(.serverReflexiveCandidate) }
|
|
233
|
+
}
|
|
197
234
|
}
|
|
198
235
|
|
|
199
|
-
func peerConnection(_: RTCPeerConnection, didGenerate _: RTCIceCandidate) {}
|
|
200
236
|
func peerConnection(_: RTCPeerConnection, didRemove _: [RTCIceCandidate]) {}
|
|
201
237
|
func peerConnection(_: RTCPeerConnection, didOpen _: RTCDataChannel) {}
|
|
202
238
|
}
|