@mentra/glasses-media 3.2.0-dev.226 → 3.2.0-dev.232

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.
@@ -0,0 +1,202 @@
1
+ import CoreVideo
2
+ import Foundation
3
+ import WebRTC
4
+
5
+ /// ASG's full-ICE WHIP negotiation, fed from the shared decoded receiver. One peer per attempt.
6
+ final class PhoneWhipPublisher: NSObject {
7
+ private let queue = DispatchQueue(label: "com.mentra.glassesmedia.whip.publish")
8
+ private let frameSlot = DispatchSemaphore(value: 1)
9
+ private let endpoint: URL
10
+ private let captureAudio: Bool
11
+ private let bitrate: Int
12
+ private let onState: (String, String) -> Void
13
+ private let audioDevice = RelayAudioDevice()
14
+ private var factory: RTCPeerConnectionFactory?
15
+ private var peer: RTCPeerConnection?
16
+ private var videoSource: RTCVideoSource?
17
+ private var capturer: RTCVideoCapturer?
18
+ private var resource: URL?
19
+ private var posted = false
20
+ private var localSet = false
21
+ private var stopped = false
22
+ private var failed = false
23
+ private var connected = false
24
+ private var disconnectGeneration = 0
25
+ private var lastTimestamp: Int64 = 0
26
+ private let http: URLSession
27
+
28
+ init(endpoint: URL, captureAudio: Bool, bitrate: Int, onState: @escaping (String, String) -> Void) {
29
+ self.endpoint = endpoint; self.captureAudio = captureAudio; self.bitrate = bitrate; self.onState = onState
30
+ let config = URLSessionConfiguration.ephemeral
31
+ config.allowsCellularAccess = true
32
+ config.timeoutIntervalForRequest = 20
33
+ config.timeoutIntervalForResource = 25
34
+ http = URLSession(configuration: config)
35
+ super.init()
36
+ }
37
+
38
+ func start() {
39
+ queue.async {
40
+ guard !self.stopped else { return }
41
+ RTCInitializeSSL()
42
+ let factory = GlassesPeerFactory.make(audioDevice: self.audioDevice)
43
+ let options = RTCPeerConnectionFactoryOptions()
44
+ options.ignoreWiFiNetworkAdapter = true
45
+ options.ignoreVPNNetworkAdapter = true
46
+ options.ignoreLoopbackNetworkAdapter = true
47
+ factory.setOptions(options)
48
+ self.factory = factory
49
+ let config = RTCConfiguration()
50
+ config.sdpSemantics = .unifiedPlan
51
+ config.iceServers = [RTCIceServer(urlStrings: ["stun:stun.cloudflare.com:3478"])]
52
+ let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
53
+ guard let peer = factory.peerConnection(with: config, constraints: constraints, delegate: self) else {
54
+ self.fail("Could not create WHIP peer"); return
55
+ }
56
+ self.peer = peer
57
+ let source = factory.videoSource()
58
+ self.videoSource = source
59
+ self.capturer = RTCVideoCapturer(delegate: source)
60
+ let video = factory.videoTrack(with: source, trackId: "glasses-video")
61
+ let sendOnly = RTCRtpTransceiverInit()
62
+ sendOnly.direction = .sendOnly
63
+ let transceiver = peer.addTransceiver(with: video, init: sendOnly)
64
+ let codecs = factory.rtpSenderCapabilities(forKind: kRTCMediaStreamTrackKindVideo).codecs.filter { $0.name.lowercased() == "h264" }
65
+ if !codecs.isEmpty { try? transceiver?.setCodecPreferences(codecs, error: ()) }
66
+ if let sender = transceiver?.sender {
67
+ let parameters = sender.parameters
68
+ parameters.encodings.forEach { $0.maxBitrateBps = NSNumber(value: min(12_000_000, max(250_000, self.bitrate))) }
69
+ sender.parameters = parameters
70
+ }
71
+ if self.captureAudio {
72
+ let audioConstraints = RTCMediaConstraints(mandatoryConstraints: ["googEchoCancellation": "false", "googNoiseSuppression": "false", "googAutoGainControl": "false"], optionalConstraints: nil)
73
+ let audio = factory.audioTrack(with: factory.audioSource(with: audioConstraints), trackId: "glasses-audio")
74
+ peer.addTransceiver(with: audio, init: sendOnly)
75
+ }
76
+ peer.offer(for: constraints) { [weak self] offer, error in
77
+ self?.queue.async {
78
+ guard let self, !self.stopped else { return }
79
+ guard let offer, error == nil else { self.fail("WHIP offer failed"); return }
80
+ peer.setLocalDescription(offer) { error in
81
+ self.queue.async {
82
+ guard !self.stopped else { return }
83
+ guard error == nil else { self.fail("WHIP local description failed"); return }
84
+ self.localSet = true; self.maybePost()
85
+ }
86
+ }
87
+ }
88
+ }
89
+ self.queue.asyncAfter(deadline: .now() + 35) {
90
+ if !self.connected { self.fail("WHIP connection timed out") }
91
+ }
92
+ }
93
+ }
94
+
95
+ func onFrame(_ buffer: CVPixelBuffer) {
96
+ guard frameSlot.wait(timeout: .now()) == .success else { return }
97
+ queue.async {
98
+ defer { self.frameSlot.signal() }
99
+ guard !self.stopped, let source = self.videoSource, let capturer = self.capturer else { return }
100
+ self.lastTimestamp = max(Int64(ProcessInfo.processInfo.systemUptime * 1_000_000_000), self.lastTimestamp + 1)
101
+ let frame = RTCVideoFrame(buffer: RTCCVPixelBuffer(pixelBuffer: buffer), rotation: ._0, timeStampNs: self.lastTimestamp)
102
+ source.capturer(capturer, didCapture: frame)
103
+ }
104
+ }
105
+
106
+ func onPcm(_ data: Data, rate: Int, channels: Int) {
107
+ if captureAudio { audioDevice.pcm.push(data, sampleRate: rate, channels: channels) }
108
+ }
109
+
110
+ private func maybePost() {
111
+ guard !stopped, !posted, localSet, let peer, peer.iceGatheringState == .complete, let offer = peer.localDescription else { return }
112
+ posted = true
113
+ var request = URLRequest(url: endpoint)
114
+ request.httpMethod = "POST"
115
+ request.setValue("application/sdp", forHTTPHeaderField: "Content-Type")
116
+ request.httpBody = Data(offer.sdp.utf8)
117
+ // Retain this attempt through a late response, so stop cannot orphan a created WHIP resource.
118
+ http.dataTask(with: request) { data, response, error in
119
+ self.queue.async {
120
+ let response = response as? HTTPURLResponse
121
+ let location = response?.value(forHTTPHeaderField: "Location").flatMap { URL(string: $0, relativeTo: self.endpoint)?.absoluteURL }
122
+ if self.stopped {
123
+ if let location { self.delete(location) }
124
+ self.http.finishTasksAndInvalidate()
125
+ return
126
+ }
127
+ guard error == nil, response?.statusCode == 201, let location, location.scheme == "https",
128
+ let data, let answer = String(data: data, encoding: .utf8), !answer.isEmpty
129
+ else {
130
+ if let location { self.delete(location) }
131
+ self.fail("WHIP server rejected publish (HTTP \(response?.statusCode ?? 0))"); return
132
+ }
133
+ self.resource = location
134
+ peer.setRemoteDescription(RTCSessionDescription(type: .answer, sdp: answer)) { error in
135
+ self.queue.async { if error != nil { self.fail("WHIP answer failed") } }
136
+ }
137
+ }
138
+ }.resume()
139
+ }
140
+
141
+ private func delete(_ url: URL) {
142
+ guard url.scheme == "https" else { return }
143
+ var request = URLRequest(url: url)
144
+ request.httpMethod = "DELETE"
145
+ http.dataTask(with: request) { _, _, _ in }.resume()
146
+ }
147
+
148
+ private func fail(_ reason: String) {
149
+ guard !stopped, !failed else { return }
150
+ failed = true; onState("failed", reason)
151
+ }
152
+
153
+ func stop(completion: @escaping () -> Void) {
154
+ queue.async {
155
+ if !self.stopped {
156
+ self.stopped = true
157
+ self.peer?.close()
158
+ // Drain the external audio callback while its native delegate/factory is still alive.
159
+ _ = self.audioDevice.terminateDevice()
160
+ self.peer = nil
161
+ self.videoSource = nil; self.capturer = nil; self.factory = nil
162
+ if let resource = self.resource { self.delete(resource); self.resource = nil; self.http.finishTasksAndInvalidate() }
163
+ else if !self.posted { self.http.finishTasksAndInvalidate() }
164
+ // An in-flight POST completes cleanup in its callback above.
165
+ }
166
+ completion()
167
+ }
168
+ }
169
+ }
170
+
171
+ extension PhoneWhipPublisher: RTCPeerConnectionDelegate {
172
+ func peerConnection(_: RTCPeerConnection, didChange _: RTCSignalingState) {}
173
+ func peerConnection(_: RTCPeerConnection, didAdd _: RTCMediaStream) {}
174
+ func peerConnection(_: RTCPeerConnection, didRemove _: RTCMediaStream) {}
175
+ func peerConnectionShouldNegotiate(_: RTCPeerConnection) {}
176
+ func peerConnection(_ peerConnection: RTCPeerConnection, didChange state: RTCIceConnectionState) {
177
+ queue.async {
178
+ guard self.peer === peerConnection, !self.stopped else { return }
179
+ switch state {
180
+ case .connected, .completed:
181
+ self.connected = true; self.disconnectGeneration += 1
182
+ self.onState("connected", "Phone publisher connected")
183
+ case .disconnected:
184
+ self.disconnectGeneration += 1
185
+ let gen = self.disconnectGeneration
186
+ self.queue.asyncAfter(deadline: .now() + 10) {
187
+ if gen == self.disconnectGeneration { self.fail("Phone internet connection was lost") }
188
+ }
189
+ case .failed: self.fail("WHIP ICE failed")
190
+ default: break
191
+ }
192
+ }
193
+ }
194
+
195
+ func peerConnection(_ peerConnection: RTCPeerConnection, didChange _: RTCIceGatheringState) {
196
+ queue.async { if self.peer === peerConnection { self.maybePost() } }
197
+ }
198
+
199
+ func peerConnection(_: RTCPeerConnection, didGenerate _: RTCIceCandidate) {}
200
+ func peerConnection(_: RTCPeerConnection, didRemove _: [RTCIceCandidate]) {}
201
+ func peerConnection(_: RTCPeerConnection, didOpen _: RTCDataChannel) {}
202
+ }
@@ -0,0 +1,70 @@
1
+ import AudioToolbox
2
+ import Foundation
3
+ import WebRTC
4
+
5
+ /// WebRTC recording device driven by decoded glasses PCM. No phone mic or AVAudioSession capture.
6
+ final class RelayAudioDevice: NSObject, RTCAudioDevice {
7
+ let pcm = RelayPcmBuffer()
8
+ let deviceInputSampleRate: Double = 48000
9
+ let inputIOBufferDuration: TimeInterval = 0.01
10
+ let inputNumberOfChannels = 1
11
+ let inputLatency: TimeInterval = 0
12
+ let deviceOutputSampleRate: Double = 48000
13
+ let outputIOBufferDuration: TimeInterval = 0.01
14
+ let outputNumberOfChannels = 1
15
+ let outputLatency: TimeInterval = 0
16
+ private(set) var isInitialized = false
17
+ private(set) var isRecordingInitialized = false
18
+ private(set) var isRecording = false
19
+ let isPlayoutInitialized = false
20
+ let isPlaying = false
21
+ private let clock = RelayAudioClock()
22
+
23
+ func initialize(with delegate: RTCAudioDeviceDelegate) -> Bool {
24
+ let storage = UnsafeMutablePointer<Int16>.allocate(capacity: 480)
25
+ let pcm = pcm
26
+ clock.start(render: { sampleTime in
27
+ pcm.read(into: UnsafeMutableBufferPointer(start: storage, count: 480))
28
+ var flags = AudioUnitRenderActionFlags()
29
+ var stamp = AudioTimeStamp()
30
+ stamp.mSampleTime = sampleTime
31
+ stamp.mFlags = .sampleTimeValid
32
+ var buffers = AudioBufferList(mNumberBuffers: 1, mBuffers: AudioBuffer(mNumberChannels: 1, mDataByteSize: 960, mData: storage))
33
+ _ = delegate.deliverRecordedData(&flags, &stamp, 0, 480, &buffers, nil, nil)
34
+ }, finish: { storage.deallocate() })
35
+ isInitialized = true
36
+ return true
37
+ }
38
+
39
+ func terminateDevice() -> Bool {
40
+ clock.stop()
41
+ isInitialized = false; isRecording = false; isRecordingInitialized = false
42
+ return true
43
+ }
44
+
45
+ func initializeRecording() -> Bool {
46
+ isRecordingInitialized = true; return true
47
+ }
48
+
49
+ func startRecording() -> Bool {
50
+ clock.setEnabled(true)
51
+ isRecording = true; return true
52
+ }
53
+
54
+ func stopRecording() -> Bool {
55
+ clock.setEnabled(false)
56
+ isRecording = false; return true
57
+ }
58
+
59
+ func initializePlayout() -> Bool {
60
+ false
61
+ }
62
+
63
+ func startPlayout() -> Bool {
64
+ false
65
+ }
66
+
67
+ func stopPlayout() -> Bool {
68
+ true
69
+ }
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/glasses-media",
3
- "version": "3.2.0-dev.226",
3
+ "version": "3.2.0-dev.232",
4
4
  "description": "Shared native glasses media receivers and local network transport",
5
5
  "license": "MIT",
6
6
  "author": "Mentra Community",