@mentra/glasses-media 3.2.0-dev.225

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.
Files changed (56) hide show
  1. package/README.md +35 -0
  2. package/android/build.gradle +84 -0
  3. package/android/src/main/AndroidManifest.xml +6 -0
  4. package/android/src/main/java/com/mentra/glassesmedia/network/InternetHold.kt +501 -0
  5. package/android/src/main/java/com/mentra/glassesmedia/network/Ipv4Prefix.kt +51 -0
  6. package/android/src/main/java/com/mentra/glassesmedia/network/NetworkFacts.kt +81 -0
  7. package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkChangeDetector.kt +355 -0
  8. package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkError.kt +109 -0
  9. package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkObserver.kt +160 -0
  10. package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkReadiness.kt +103 -0
  11. package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkRequestSpec.kt +53 -0
  12. package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkState.kt +127 -0
  13. package/android/src/main/java/com/mentra/glassesmedia/network/ScopedSoftApNetwork.kt +573 -0
  14. package/android/src/main/java/com/mentra/glassesmedia/source/BitmapFont.kt +99 -0
  15. package/android/src/main/java/com/mentra/glassesmedia/source/CloudflareWhepSource.kt +487 -0
  16. package/android/src/main/java/com/mentra/glassesmedia/source/DecodedTrackRelay.kt +147 -0
  17. package/android/src/main/java/com/mentra/glassesmedia/source/FirstFrameGate.kt +45 -0
  18. package/android/src/main/java/com/mentra/glassesmedia/source/GlassesMediaSource.kt +143 -0
  19. package/android/src/main/java/com/mentra/glassesmedia/source/LocalWhipIngestSource.kt +772 -0
  20. package/android/src/main/java/com/mentra/glassesmedia/source/MediaListeners.kt +57 -0
  21. package/android/src/main/java/com/mentra/glassesmedia/source/SdpAdapter.kt +17 -0
  22. package/android/src/main/java/com/mentra/glassesmedia/source/SelectedIcePair.kt +139 -0
  23. package/android/src/main/java/com/mentra/glassesmedia/source/SoftApIceDiagnosis.kt +144 -0
  24. package/android/src/main/java/com/mentra/glassesmedia/source/SoftApIcePolicy.kt +46 -0
  25. package/android/src/main/java/com/mentra/glassesmedia/source/SoftApSdpGuard.kt +160 -0
  26. package/android/src/main/java/com/mentra/glassesmedia/source/SyntheticFrameFactory.kt +205 -0
  27. package/android/src/main/java/com/mentra/glassesmedia/source/SyntheticI420Source.kt +214 -0
  28. package/android/src/main/java/com/mentra/glassesmedia/source/TrackRegistry.kt +33 -0
  29. package/android/src/main/java/com/mentra/glassesmedia/source/VideoSourceArm.kt +110 -0
  30. package/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestProtocol.kt +207 -0
  31. package/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestServer.kt +356 -0
  32. package/android/src/main/java/com/mentra/glassesmedia/telemetry/AvSyncProbe.kt +155 -0
  33. package/android/src/main/java/com/mentra/glassesmedia/telemetry/ChromaProbe.kt +78 -0
  34. package/android/src/main/java/com/mentra/glassesmedia/telemetry/PipelineStats.kt +372 -0
  35. package/android/src/main/java/com/mentra/glassesmedia/telemetry/PipelineTicker.kt +79 -0
  36. package/android/src/main/java/com/mentra/glassesmedia/telemetry/ProcessCpu.kt +15 -0
  37. package/android/src/main/java/com/mentra/glassesmedia/telemetry/RingPercentile.kt +48 -0
  38. package/android/src/main/java/com/mentra/glassesmedia/telemetry/VideoQuality.kt +178 -0
  39. package/android/src/main/java/com/mentra/glassesmedia/telemetry/VideoRateVerdict.kt +145 -0
  40. package/android/src/main/java/com/mentra/glassesmedia/trace/SoftApTrace.kt +143 -0
  41. package/android/src/main/java/com/mentra/glassesmedia/video/FrameGeometry.kt +22 -0
  42. package/android/src/main/java/com/mentra/glassesmedia/video/I420Packer.kt +72 -0
  43. package/expo-module.config.json +12 -0
  44. package/index.js +1 -0
  45. package/ios/CoreKit/Sources/GlassesMediaCore/GlassesMediaSource.swift +104 -0
  46. package/ios/CoreKit/Sources/GlassesMediaCore/LocalMediaPolicy.swift +51 -0
  47. package/ios/CoreKit/Sources/GlassesMediaCore/WhipIngestServer.swift +145 -0
  48. package/ios/CoreKit/Sources/GlassesMediaCore/WhipRequest.swift +59 -0
  49. package/ios/DecodedGlassesMediaSource.swift +9 -0
  50. package/ios/DecodedPcm.swift +33 -0
  51. package/ios/GlassesHotspotNetwork.swift +185 -0
  52. package/ios/GlassesMedia.podspec +18 -0
  53. package/ios/LocalWhipIngestSource.swift +325 -0
  54. package/ios/ReceiveOnlyAudioDevice.swift +106 -0
  55. package/ios/WhepVideoSource.swift +332 -0
  56. package/package.json +27 -0
@@ -0,0 +1,332 @@
1
+ import AVFoundation
2
+ import CoreVideo
3
+ import Foundation
4
+ import WebRTC
5
+
6
+ /// Recvonly WHEP subscriber. Decoded frames become CVPixelBuffers for ACS.
7
+ /// Remote PCM is the P4 hard gate (RTCAudioRenderer on LiveKit/WebRTC-SDK 137).
8
+ public final class WhepVideoSource: NSObject, DecodedGlassesMediaSource {
9
+ public var onFrame: ((CVPixelBuffer) -> Void)?
10
+ public var onPcm: ((Data, Int, Int) -> Void)?
11
+ /// Fires on every health transition. `.failed` is the one nothing else can see:
12
+ /// ICE dropped or the WHEP endpoint went away while the ACS call stays connected.
13
+ public var onStateChange: ((SourceState, String) -> Void)?
14
+ public private(set) var state: SourceState = .idle
15
+
16
+ private var factory: RTCPeerConnectionFactory?
17
+ private var pc: RTCPeerConnection?
18
+ private var currentUrl: String?
19
+ private var offerPosted = false
20
+ private var pendingOfferPost: DispatchWorkItem?
21
+ // Fail closed like Android: the audio policy turns delivery on once the ACS
22
+ // virtual stream is live. Survives restart() so a WHEP rebuild cannot silently
23
+ // re-open the uplink against the current mute decision.
24
+ private var pcmEnabled = false
25
+ private var audioTracks: [RTCAudioTrack] = []
26
+ private var attachedVideo: RTCVideoTrack?
27
+ /// Every peer generation gets its own id so a callback from a peer being torn
28
+ /// down (ICE failed racing a rebuild) cannot mark the new one failed.
29
+ private var generation = 0
30
+ // `.live` means "a frame reached the renderer", not "the WHEP endpoint
31
+ // answered" — the answer lands seconds before ACS submits anything, and an
32
+ // answer that never produces a frame used to read healthy forever behind a
33
+ // frozen image. Armed per generation so a frame from a peer being torn down
34
+ // cannot promote its replacement.
35
+ private var firstFrameGeneration = -1
36
+ private var firstFramePromoted = false
37
+ private var firstFrameDeadline: DispatchWorkItem?
38
+ private var frameCount = 0
39
+ private var lastFpsLog = Date()
40
+ private lazy var http: URLSession = {
41
+ let config = URLSessionConfiguration.ephemeral
42
+ config.timeoutIntervalForRequest = 20
43
+ config.timeoutIntervalForResource = 20
44
+ return URLSession(configuration: config)
45
+ }()
46
+
47
+ override public init() {
48
+ super.init()
49
+ }
50
+
51
+ public func start(config: SourceConfig) {
52
+ start(whepUrl: config.url)
53
+ }
54
+
55
+ public func start(whepUrl: String) {
56
+ stop()
57
+ currentUrl = whepUrl
58
+ offerPosted = false
59
+ generation += 1
60
+ let gen = generation
61
+ transition(.connecting, reason: "start")
62
+ RTCInitializeSSL()
63
+ let encoder = RTCDefaultVideoEncoderFactory()
64
+ let decoder = RTCDefaultVideoDecoderFactory()
65
+ factory = RTCPeerConnectionFactory(encoderFactory: encoder, decoderFactory: decoder, audioDevice: ReceiveOnlyAudioDevice())
66
+ let config = RTCConfiguration()
67
+ config.iceServers = [RTCIceServer(urlStrings: ["stun:stun.l.google.com:19302"])]
68
+ let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
69
+ guard let factory, let peer = factory.peerConnection(with: config, constraints: constraints, delegate: self) else {
70
+ transition(.failed, reason: "peer_create_failed")
71
+ return
72
+ }
73
+ pc = peer
74
+ peer.addTransceiver(of: .video, init: RTCRtpTransceiverInit.recvOnly())
75
+ peer.addTransceiver(of: .audio, init: RTCRtpTransceiverInit.recvOnly())
76
+ peer.offer(for: constraints) { [weak self] sdp, error in
77
+ guard let self, self.generation == gen else { return }
78
+ guard let sdp, error == nil else {
79
+ self.transition(.failed, reason: "offer_failed")
80
+ return
81
+ }
82
+ peer.setLocalDescription(sdp) { [weak self] _ in
83
+ // Track the delayed post so a same-instance restart can cancel it and
84
+ // avoid publishing a stale peer's SDP before ICE finishes.
85
+ let work = DispatchWorkItem { [weak self] in self?.postOfferIfNeeded() }
86
+ self?.pendingOfferPost = work
87
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: work)
88
+ }
89
+ }
90
+ }
91
+
92
+ public func restart(config: SourceConfig) {
93
+ // Only reuse the existing subscriber when the URL is unchanged AND the peer
94
+ // is still healthy. After ICE failed/disconnected (e.g. a Wi-Fi drop reusing
95
+ // the same WHEP URL) we must rebuild, or glasses video and mic never recover.
96
+ // `.connecting` is reusable and now spans answer → first frame; rebuilding
97
+ // there would only restart a wait that is bounded by the deadline below.
98
+ if config.url == currentUrl, state != .failed { return }
99
+ start(config: config)
100
+ }
101
+
102
+ /// Rebuild on the current URL even when the peer looks healthy (phone changed
103
+ /// networks; ICE may not have noticed the dead candidate pair yet).
104
+ public func forceRestart() {
105
+ guard let url = currentUrl else { return }
106
+ NSLog("ACS-SPIKE WHEP forced rebuild state=\(state.rawValue)")
107
+ start(whepUrl: url)
108
+ }
109
+
110
+ public func updateUrl(_ whepUrl: String) {
111
+ restart(config: SourceConfig(url: whepUrl))
112
+ }
113
+
114
+ public func setPcmDeliveryEnabled(_ enabled: Bool) {
115
+ setPcmEnabled(enabled)
116
+ }
117
+
118
+ public func setPcmEnabled(_ enabled: Bool) {
119
+ pcmEnabled = enabled
120
+ audioTracks.forEach { $0.isEnabled = enabled }
121
+ NSLog("ACS-SPIKE WHEP audio track enabled=\(enabled)")
122
+ }
123
+
124
+ public func stop() {
125
+ currentUrl = nil
126
+ offerPosted = false
127
+ pendingOfferPost?.cancel()
128
+ pendingOfferPost = nil
129
+ // Disarmed before the renderer is removed, so a frame already in flight
130
+ // cannot promote the source we are tearing down.
131
+ cancelFirstFrameDeadline()
132
+ firstFrameGeneration = -1
133
+ firstFramePromoted = false
134
+ audioTracks.forEach { $0.remove(self as RTCAudioRenderer) }
135
+ audioTracks.removeAll()
136
+ attachedVideo?.remove(self as RTCVideoRenderer)
137
+ attachedVideo = nil
138
+ // Invalidate callbacks from the peer being closed below before they can
139
+ // observe the idle we are about to set.
140
+ generation += 1
141
+ transition(.idle, reason: "stop")
142
+ // Drop our reference first so delegate callbacks fired by close() fail isCurrent().
143
+ let peer = pc
144
+ pc = nil
145
+ peer?.close()
146
+ }
147
+
148
+ private func transition(_ next: SourceState, reason: String) {
149
+ let previous = state
150
+ state = next
151
+ guard previous != next else { return }
152
+ NSLog("ACS-SPIKE WHEP source \(previous.rawValue) -> \(next.rawValue) (\(reason))")
153
+ onStateChange?(next, reason)
154
+ }
155
+
156
+ private func attachVideo(_ track: RTCVideoTrack) {
157
+ // Unified Plan delivers the same remote video via didAdd stream and
158
+ // didStartReceivingOn; rendering it twice doubles the frames handed to ACS.
159
+ if let attachedVideo, attachedVideo === track { return }
160
+ attachedVideo?.remove(self as RTCVideoRenderer)
161
+ attachedVideo = track
162
+ track.add(self as RTCVideoRenderer)
163
+ }
164
+
165
+ private func attachAudio(_ track: RTCAudioTrack) {
166
+ // Unified Plan fires didAdd stream, didStartReceivingOn and add-track for
167
+ // the same remote audio; only wire the renderer once or Teams hears doubled
168
+ // PCM.
169
+ if audioTracks.contains(where: { $0 === track }) {
170
+ track.isEnabled = pcmEnabled
171
+ return
172
+ }
173
+ audioTracks.append(track)
174
+ track.isEnabled = pcmEnabled
175
+ track.add(self as RTCAudioRenderer)
176
+ }
177
+
178
+ private func postOfferIfNeeded() {
179
+ guard !offerPosted, let url = currentUrl, let peer = pc, let sdp = peer.localDescription?.sdp else { return }
180
+ offerPosted = true
181
+ postOffer(url: url, sdp: sdp, peer: peer, gen: generation)
182
+ }
183
+
184
+ private func postOffer(url: String, sdp: String, peer: RTCPeerConnection, gen: Int) {
185
+ guard let endpoint = URL(string: url) else {
186
+ transition(.failed, reason: "whep_bad_url")
187
+ return
188
+ }
189
+ var request = URLRequest(url: endpoint)
190
+ request.httpMethod = "POST"
191
+ request.setValue("application/sdp", forHTTPHeaderField: "Content-Type")
192
+ request.httpBody = sdp.data(using: .utf8)
193
+ http.dataTask(with: request) { [weak self] data, response, error in
194
+ guard let self, self.generation == gen else { return }
195
+ let code = (response as? HTTPURLResponse)?.statusCode ?? -1
196
+ NSLog("ACS-SPIKE P3 WHEP \(code) answer bytes=\(data?.count ?? 0)")
197
+ guard error == nil, (200 ..< 300).contains(code),
198
+ let data, let answer = String(data: data, encoding: .utf8)
199
+ else {
200
+ NSLog("ACS-SPIKE WHEP answer rejected code=\(code) error=\(error?.localizedDescription ?? "none")")
201
+ self.transition(.failed, reason: error != nil ? "whep_post_failed" : "whep_http_\(code)")
202
+ return
203
+ }
204
+ // Stay `.connecting`: setRemoteDescription, ICE and the decoder still have
205
+ // to run before ACS is handed anything.
206
+ self.armFirstFrame(gen: gen)
207
+ let desc = RTCSessionDescription(type: .answer, sdp: answer)
208
+ peer.setRemoteDescription(desc) { _ in }
209
+ }.resume()
210
+ }
211
+
212
+ /// Arms the first-frame gate for `gen` and bounds the wait. An answered WHEP
213
+ /// that never delivers is invisible to every other layer — ACS keeps the call
214
+ /// up on a frozen frame — so it has to expire into `.failed` and let the
215
+ /// module's rebuild backoff repair it.
216
+ private func armFirstFrame(gen: Int) {
217
+ firstFrameGeneration = gen
218
+ firstFramePromoted = false
219
+ cancelFirstFrameDeadline()
220
+ let work = DispatchWorkItem { [weak self] in
221
+ guard let self else { return }
222
+ self.firstFrameDeadline = nil
223
+ guard self.generation == gen, self.firstFrameGeneration == gen, !self.firstFramePromoted else { return }
224
+ NSLog("ACS-SPIKE WHEP answered but delivered no frame in \(Self.firstFrameTimeout)s")
225
+ self.transition(.failed, reason: "no_first_frame")
226
+ }
227
+ firstFrameDeadline = work
228
+ DispatchQueue.main.asyncAfter(deadline: .now() + Self.firstFrameTimeout, execute: work)
229
+ }
230
+
231
+ private func cancelFirstFrameDeadline() {
232
+ firstFrameDeadline?.cancel()
233
+ firstFrameDeadline = nil
234
+ }
235
+
236
+ /// The one honest `.live`: a decoded frame is on its way to ACS.
237
+ private func notePromotableFrame() {
238
+ guard !firstFramePromoted, firstFrameGeneration == generation else { return }
239
+ firstFramePromoted = true
240
+ cancelFirstFrameDeadline()
241
+ transition(.live, reason: "first_frame")
242
+ }
243
+
244
+ private func isCurrent(_ peerConnection: RTCPeerConnection) -> Bool {
245
+ pc === peerConnection
246
+ }
247
+
248
+ /// Matches Android's FIRST_FRAME_TIMEOUT_MS: a measured answer → first frame
249
+ /// gap is ~3 s, and a cold subscribe on a bad network is slower.
250
+ private static let firstFrameTimeout: TimeInterval = 9
251
+ }
252
+
253
+ extension WhepVideoSource: RTCPeerConnectionDelegate {
254
+ public func peerConnection(_: RTCPeerConnection, didChange _: RTCSignalingState) {}
255
+ public func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) {
256
+ guard isCurrent(peerConnection) else { return }
257
+ stream.videoTracks.first.map(attachVideo)
258
+ stream.audioTracks.first.map(attachAudio)
259
+ }
260
+
261
+ public func peerConnection(_: RTCPeerConnection, didRemove _: RTCMediaStream) {}
262
+ public func peerConnectionShouldNegotiate(_: RTCPeerConnection) {}
263
+ public func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {
264
+ NSLog("ACS-SPIKE ICE \(newState.rawValue)")
265
+ guard isCurrent(peerConnection) else { return }
266
+ if newState == .failed || newState == .disconnected {
267
+ transition(.failed, reason: newState == .failed ? "ice_failed" : "ice_disconnected")
268
+ } else if newState == .connected || newState == .completed {
269
+ // ICE can bounce disconnected → connected on its own; only act once the
270
+ // answer has been applied. A recovered candidate pair is a promise of
271
+ // frames, not a frame, so go back to `.connecting` and let the next one
272
+ // promote — immediately if media was already flowing, and if nothing
273
+ // arrives the rearmed deadline fails us again for the rebuild backoff.
274
+ if state == .failed, offerPosted {
275
+ transition(.connecting, reason: "ice_recovered")
276
+ armFirstFrame(gen: generation)
277
+ }
278
+ }
279
+ }
280
+
281
+ public func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) {
282
+ guard isCurrent(peerConnection) else { return }
283
+ if newState == .complete { postOfferIfNeeded() }
284
+ }
285
+
286
+ public func peerConnection(_: RTCPeerConnection, didGenerate _: RTCIceCandidate) {}
287
+ public func peerConnection(_: RTCPeerConnection, didRemove _: [RTCIceCandidate]) {}
288
+ public func peerConnection(_: RTCPeerConnection, didOpen _: RTCDataChannel) {}
289
+ public func peerConnection(_ peerConnection: RTCPeerConnection, didStartReceivingOn transceiver: RTCRtpTransceiver) {
290
+ guard isCurrent(peerConnection) else { return }
291
+ if let video = transceiver.receiver.track as? RTCVideoTrack {
292
+ attachVideo(video)
293
+ }
294
+ if let audio = transceiver.receiver.track as? RTCAudioTrack {
295
+ attachAudio(audio)
296
+ }
297
+ }
298
+ }
299
+
300
+ extension WhepVideoSource: RTCVideoRenderer {
301
+ public func setSize(_: CGSize) {}
302
+ public func renderFrame(_ frame: RTCVideoFrame?) {
303
+ guard let frame, let buffer = frame.buffer as? RTCCVPixelBuffer else { return }
304
+ notePromotableFrame()
305
+ frameCount += 1
306
+ let now = Date()
307
+ if now.timeIntervalSince(lastFpsLog) >= 1 {
308
+ NSLog("ACS-SPIKE P3 video \(Int(frame.width))x\(Int(frame.height)) fps=\(frameCount)")
309
+ frameCount = 0
310
+ lastFpsLog = now
311
+ }
312
+ onFrame?(buffer.pixelBuffer)
313
+ }
314
+ }
315
+
316
+ extension WhepVideoSource: RTCAudioRenderer {
317
+ public func render(pcmBuffer: AVAudioPCMBuffer) {
318
+ guard pcmEnabled else { return }
319
+ let channels = Int(pcmBuffer.format.channelCount)
320
+ let rate = Int(pcmBuffer.format.sampleRate)
321
+ guard let data = DecodedPcm.pcm16(pcmBuffer) else { return }
322
+ onPcm?(data, rate, channels)
323
+ }
324
+ }
325
+
326
+ private extension RTCRtpTransceiverInit {
327
+ static func recvOnly() -> RTCRtpTransceiverInit {
328
+ let value = RTCRtpTransceiverInit()
329
+ value.direction = .recvOnly
330
+ return value
331
+ }
332
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@mentra/glasses-media",
3
+ "version": "3.2.0-dev.225",
4
+ "description": "Shared native glasses media receivers and local network transport",
5
+ "license": "MIT",
6
+ "author": "Mentra Community",
7
+ "homepage": "https://github.com/Mentra-Community/MentraOS",
8
+ "main": "index.js",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/Mentra-Community/MentraOS.git",
12
+ "directory": "mobile/modules/glasses-media"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "files": [
18
+ "index.js",
19
+ "expo-module.config.json",
20
+ "android/build.gradle",
21
+ "android/src/main",
22
+ "ios/*.podspec",
23
+ "ios/*.swift",
24
+ "ios/CoreKit/Sources",
25
+ "README.md"
26
+ ]
27
+ }