@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 full-ICE WHIP negotiation, fed from the shared decoded receiver. One peer per attempt.
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 localSet = false
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.localSet = true; self.maybePost()
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, !posted, localSet, let peer, peer.iceGatheringState == .complete, let offer = peer.localDescription else { return }
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
- self.fail("WHIP server rejected publish (HTTP \(response?.statusCode ?? 0))"); return
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 _: RTCIceGatheringState) {
196
- queue.async { if self.peer === peerConnection { self.maybePost() } }
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/glasses-media",
3
- "version": "3.2.1-dev.307",
3
+ "version": "3.2.1-dev.308",
4
4
  "description": "Shared native glasses media receivers and local network transport",
5
5
  "license": "MIT",
6
6
  "author": "Mentra Community",