@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.
- package/README.md +19 -3
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/mentra/glassesmedia/GlassesMediaRelayModule.kt +113 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkChangeDetector.kt +34 -4
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkObserver.kt +11 -4
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkRegistry.kt +16 -0
- package/android/src/main/java/com/mentra/glassesmedia/publisher/PhoneWhipPublisher.kt +263 -0
- package/android/src/main/java/com/mentra/glassesmedia/publisher/RelayPcmBuffer.kt +54 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/LocalWhipIngestSource.kt +16 -3
- package/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestServer.kt +31 -15
- package/expo-module.config.json +6 -2
- package/ios/CoreKit/Sources/GlassesMediaCore/LocalMediaPolicy.swift +13 -1
- package/ios/CoreKit/Sources/GlassesMediaCore/RelayAudioClock.swift +56 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/RelayPcmBuffer.swift +57 -0
- package/ios/GlassesHotspotNetwork.swift +30 -6
- package/ios/GlassesMedia.podspec +3 -1
- package/ios/GlassesMediaRelayModule.swift +133 -0
- package/ios/GlassesPeerFactory.swift +23 -0
- package/ios/LocalWhipIngestSource.swift +1 -1
- package/ios/PhoneWhipPublisher.swift +202 -0
- package/ios/RelayAudioDevice.swift +70 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|