@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.
- package/README.md +35 -0
- package/android/build.gradle +84 -0
- package/android/src/main/AndroidManifest.xml +6 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/InternetHold.kt +501 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/Ipv4Prefix.kt +51 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/NetworkFacts.kt +81 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkChangeDetector.kt +355 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkError.kt +109 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkObserver.kt +160 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkReadiness.kt +103 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkRequestSpec.kt +53 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkState.kt +127 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedSoftApNetwork.kt +573 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/BitmapFont.kt +99 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/CloudflareWhepSource.kt +487 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/DecodedTrackRelay.kt +147 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/FirstFrameGate.kt +45 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/GlassesMediaSource.kt +143 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/LocalWhipIngestSource.kt +772 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/MediaListeners.kt +57 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/SdpAdapter.kt +17 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/SelectedIcePair.kt +139 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/SoftApIceDiagnosis.kt +144 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/SoftApIcePolicy.kt +46 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/SoftApSdpGuard.kt +160 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/SyntheticFrameFactory.kt +205 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/SyntheticI420Source.kt +214 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/TrackRegistry.kt +33 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/VideoSourceArm.kt +110 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestProtocol.kt +207 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestServer.kt +356 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/AvSyncProbe.kt +155 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/ChromaProbe.kt +78 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/PipelineStats.kt +372 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/PipelineTicker.kt +79 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/ProcessCpu.kt +15 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/RingPercentile.kt +48 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/VideoQuality.kt +178 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/VideoRateVerdict.kt +145 -0
- package/android/src/main/java/com/mentra/glassesmedia/trace/SoftApTrace.kt +143 -0
- package/android/src/main/java/com/mentra/glassesmedia/video/FrameGeometry.kt +22 -0
- package/android/src/main/java/com/mentra/glassesmedia/video/I420Packer.kt +72 -0
- package/expo-module.config.json +12 -0
- package/index.js +1 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/GlassesMediaSource.swift +104 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/LocalMediaPolicy.swift +51 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/WhipIngestServer.swift +145 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/WhipRequest.swift +59 -0
- package/ios/DecodedGlassesMediaSource.swift +9 -0
- package/ios/DecodedPcm.swift +33 -0
- package/ios/GlassesHotspotNetwork.swift +185 -0
- package/ios/GlassesMedia.podspec +18 -0
- package/ios/LocalWhipIngestSource.swift +325 -0
- package/ios/ReceiveOnlyAudioDevice.swift +106 -0
- package/ios/WhepVideoSource.swift +332 -0
- 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
|
+
}
|