@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,325 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import CoreVideo
|
|
3
|
+
import Foundation
|
|
4
|
+
import WebRTC
|
|
5
|
+
|
|
6
|
+
/// The same local WHIP contract as Android: glasses offer, phone answers, publishers receive
|
|
7
|
+
/// decoded buffers. All peer lifecycle transitions and generation checks run on `queue`.
|
|
8
|
+
public final class LocalWhipIngestSource: NSObject, DecodedGlassesMediaSource {
|
|
9
|
+
public var onFrame: ((CVPixelBuffer) -> Void)?
|
|
10
|
+
public var onPcm: ((Data, Int, Int) -> Void)?
|
|
11
|
+
public var onStateChange: ((SourceState, String) -> Void)?
|
|
12
|
+
private let queue = DispatchQueue(label: "com.mentra.glassesmedia.whip.peer")
|
|
13
|
+
private let queueKey = DispatchSpecificKey<Bool>()
|
|
14
|
+
private var currentState: SourceState = .idle
|
|
15
|
+
private var url: String?
|
|
16
|
+
public var state: SourceState {
|
|
17
|
+
DispatchQueue.getSpecific(key: queueKey) == true ? currentState : queue.sync { currentState }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public var ingestUrl: String? {
|
|
21
|
+
DispatchQueue.getSpecific(key: queueKey) == true ? url : queue.sync { url }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private var address = ""
|
|
25
|
+
private var generation = 0
|
|
26
|
+
private var factory: RTCPeerConnectionFactory?
|
|
27
|
+
private var peer: RTCPeerConnection?
|
|
28
|
+
private var server: WhipIngestServer?
|
|
29
|
+
private var video: RTCVideoTrack?
|
|
30
|
+
private var audio: [RTCAudioTrack] = []
|
|
31
|
+
private var renderer: LocalMediaRenderer?
|
|
32
|
+
private var pcmEnabled = false
|
|
33
|
+
private var pendingAnswer: ((Result<String, Error>) -> Void)?
|
|
34
|
+
private var localDescriptionSet = false
|
|
35
|
+
private var firstFrameSeen = false
|
|
36
|
+
private var stopping = false
|
|
37
|
+
private var stopReplies: [() -> Void] = []
|
|
38
|
+
private let videoSlot = DispatchSemaphore(value: 1)
|
|
39
|
+
private let audioSlots = DispatchSemaphore(value: 8)
|
|
40
|
+
|
|
41
|
+
override public init() {
|
|
42
|
+
super.init()
|
|
43
|
+
queue.setSpecific(key: queueKey, value: true)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
public func start(config: SourceConfig) {
|
|
47
|
+
prepare(config: config) { _ in }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
public func restart(config _: SourceConfig) {
|
|
51
|
+
// A new URL must be delivered to the glasses by the owner. Never silently rotate it.
|
|
52
|
+
queue.async { self.transition(.failed, "local_restart_requires_republish") }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public func forceRestart() {
|
|
56
|
+
queue.async { self.transition(.failed, "local_restart_requires_republish") }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// Resolves only after the HTTP listener is bound. The owner can then tell the glasses to publish.
|
|
60
|
+
public func prepare(config: SourceConfig, completion: @escaping (Result<String, Error>) -> Void) {
|
|
61
|
+
queue.async {
|
|
62
|
+
guard self.server == nil, !self.stopping, let address = config.bindAddress, LocalMediaPolicy.isPrivate(address) else {
|
|
63
|
+
completion(.failure(LocalMediaError("A free receiver and the phone's hotspot address are required"))); return
|
|
64
|
+
}
|
|
65
|
+
self.address = address
|
|
66
|
+
self.generation += 1
|
|
67
|
+
let gen = self.generation
|
|
68
|
+
self.transition(.connecting, "listening")
|
|
69
|
+
RTCInitializeSSL()
|
|
70
|
+
let factory = RTCPeerConnectionFactory(encoderFactory: RTCDefaultVideoEncoderFactory(), decoderFactory: RTCDefaultVideoDecoderFactory(), audioDevice: ReceiveOnlyAudioDevice())
|
|
71
|
+
let options = RTCPeerConnectionFactoryOptions()
|
|
72
|
+
options.ignoreCellularNetworkAdapter = true
|
|
73
|
+
options.ignoreVPNNetworkAdapter = true
|
|
74
|
+
options.ignoreLoopbackNetworkAdapter = true
|
|
75
|
+
options.ignoreEthernetNetworkAdapter = true
|
|
76
|
+
factory.setOptions(options)
|
|
77
|
+
self.factory = factory
|
|
78
|
+
let server = WhipIngestServer(
|
|
79
|
+
negotiate: { [weak self] offer, reply in
|
|
80
|
+
guard let self else { reply(.failure(LocalMediaError("Receiver released"))); return }
|
|
81
|
+
self.queue.async { self.negotiate(offer, completion: reply) }
|
|
82
|
+
},
|
|
83
|
+
terminate: { [weak self] in
|
|
84
|
+
self?.queue.async {
|
|
85
|
+
guard let self else { return }
|
|
86
|
+
self.closePeer()
|
|
87
|
+
if self.currentState != .idle { self.transition(.failed, "publisher_terminated") }
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
publisherFailed: { [weak self] in self?.state == .failed }
|
|
91
|
+
)
|
|
92
|
+
self.server = server
|
|
93
|
+
server.start(address: address) { [weak self] result in
|
|
94
|
+
guard let self else { completion(.failure(LocalMediaError("Receiver released"))); return }
|
|
95
|
+
self.queue.async {
|
|
96
|
+
guard gen == self.generation, !self.stopping else { completion(.failure(LocalMediaError("Receiver cancelled"))); return }
|
|
97
|
+
switch result {
|
|
98
|
+
case let .success(url): self.url = url
|
|
99
|
+
case let .failure(error): self.transition(.failed, error.localizedDescription)
|
|
100
|
+
}
|
|
101
|
+
completion(result)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
public func setPcmDeliveryEnabled(_ enabled: Bool) {
|
|
108
|
+
queue.async { self.pcmEnabled = enabled }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
public func stop() {
|
|
112
|
+
stop(completion: {})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
public func stop(completion: @escaping () -> Void) {
|
|
116
|
+
queue.async {
|
|
117
|
+
self.stopReplies.append(completion)
|
|
118
|
+
guard !self.stopping else { return }
|
|
119
|
+
self.stopping = true
|
|
120
|
+
self.generation += 1
|
|
121
|
+
self.url = nil
|
|
122
|
+
self.closePeer()
|
|
123
|
+
self.transition(.idle, "stop")
|
|
124
|
+
let server = self.server
|
|
125
|
+
self.server = nil
|
|
126
|
+
let finish = {
|
|
127
|
+
self.queue.async {
|
|
128
|
+
self.factory = nil
|
|
129
|
+
self.stopping = false
|
|
130
|
+
let replies = self.stopReplies
|
|
131
|
+
self.stopReplies.removeAll()
|
|
132
|
+
replies.forEach { $0() }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if let server { server.stop(completion: finish) } else { finish() }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private func negotiate(_ offer: String, completion: @escaping (Result<String, Error>) -> Void) {
|
|
140
|
+
guard let factory, !stopping, currentState != .idle else { completion(.failure(LocalMediaError("Receiver stopped"))); return }
|
|
141
|
+
do {
|
|
142
|
+
let localOffer = try LocalMediaPolicy.localSdp(offer, address: address, answer: false)
|
|
143
|
+
closePeer()
|
|
144
|
+
generation += 1
|
|
145
|
+
let gen = generation
|
|
146
|
+
transition(.connecting, "offer")
|
|
147
|
+
let configuration = RTCConfiguration()
|
|
148
|
+
configuration.iceServers = []
|
|
149
|
+
configuration.sdpSemantics = .unifiedPlan
|
|
150
|
+
configuration.bundlePolicy = .maxBundle
|
|
151
|
+
configuration.tcpCandidatePolicy = .disabled
|
|
152
|
+
configuration.continualGatheringPolicy = .gatherOnce
|
|
153
|
+
let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
|
|
154
|
+
guard let peer = factory.peerConnection(with: configuration, constraints: constraints, delegate: self) else { throw LocalMediaError("Peer creation failed") }
|
|
155
|
+
self.peer = peer
|
|
156
|
+
pendingAnswer = completion
|
|
157
|
+
renderer = LocalMediaRenderer(
|
|
158
|
+
frame: { [weak self] buffer in self?.deliverFrame(buffer, generation: gen) },
|
|
159
|
+
pcm: { [weak self] pcm, rate, channels in self?.deliverPcm(pcm, rate: rate, channels: channels, generation: gen) }
|
|
160
|
+
)
|
|
161
|
+
peer.setRemoteDescription(RTCSessionDescription(type: .offer, sdp: localOffer)) { [weak self, weak peer] error in
|
|
162
|
+
self?.queue.async {
|
|
163
|
+
guard let self, let peer, gen == self.generation, self.peer === peer else { return }
|
|
164
|
+
if let error { self.fail(error); return }
|
|
165
|
+
peer.answer(for: constraints) { [weak self, weak peer] answer, error in
|
|
166
|
+
self?.queue.async {
|
|
167
|
+
guard let self, let peer, gen == self.generation, self.peer === peer else { return }
|
|
168
|
+
guard let answer, error == nil else { self.fail(error ?? LocalMediaError("Answer creation failed")); return }
|
|
169
|
+
peer.setLocalDescription(answer) { [weak self, weak peer] error in
|
|
170
|
+
self?.queue.async {
|
|
171
|
+
guard let self, let peer, gen == self.generation, self.peer === peer else { return }
|
|
172
|
+
if let error { self.fail(error); return }
|
|
173
|
+
self.localDescriptionSet = true
|
|
174
|
+
self.finishAnswerIfGathered()
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
queue.asyncAfter(deadline: .now() + 9) { [weak self] in
|
|
182
|
+
guard let self, gen == self.generation, self.pendingAnswer != nil else { return }
|
|
183
|
+
self.fail(LocalMediaError("WHIP negotiation timed out"))
|
|
184
|
+
}
|
|
185
|
+
} catch { completion(.failure(error)); transition(.failed, error.localizedDescription) }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private func finishAnswerIfGathered() {
|
|
189
|
+
guard localDescriptionSet, let peer, peer.iceGatheringState == .complete, let completion = pendingAnswer else { return }
|
|
190
|
+
do {
|
|
191
|
+
let answer = try LocalMediaPolicy.localSdp(peer.localDescription?.sdp ?? "", address: address, answer: true)
|
|
192
|
+
pendingAnswer = nil
|
|
193
|
+
completion(.success(answer))
|
|
194
|
+
let gen = generation
|
|
195
|
+
queue.asyncAfter(deadline: .now() + 6) { [weak self] in
|
|
196
|
+
guard let self, gen == self.generation, !self.firstFrameSeen else { return }
|
|
197
|
+
self.fail(LocalMediaError("No decoded video after WHIP negotiation"))
|
|
198
|
+
}
|
|
199
|
+
} catch { fail(error) }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private func closePeer() {
|
|
203
|
+
generation += 1
|
|
204
|
+
let completion = pendingAnswer
|
|
205
|
+
pendingAnswer = nil
|
|
206
|
+
completion?(.failure(LocalMediaError("Negotiation cancelled")))
|
|
207
|
+
if let renderer {
|
|
208
|
+
video?.remove(renderer)
|
|
209
|
+
audio.forEach { $0.remove(renderer) }
|
|
210
|
+
}
|
|
211
|
+
video = nil
|
|
212
|
+
audio.removeAll()
|
|
213
|
+
renderer = nil
|
|
214
|
+
let previous = peer
|
|
215
|
+
peer = nil
|
|
216
|
+
previous?.close()
|
|
217
|
+
localDescriptionSet = false
|
|
218
|
+
firstFrameSeen = false
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private func fail(_ error: Error) {
|
|
222
|
+
let completion = pendingAnswer
|
|
223
|
+
pendingAnswer = nil
|
|
224
|
+
completion?(.failure(error))
|
|
225
|
+
closePeer()
|
|
226
|
+
transition(.failed, error.localizedDescription)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private func attach(_ track: RTCMediaStreamTrack) {
|
|
230
|
+
guard let renderer else { return }
|
|
231
|
+
if let track = track as? RTCVideoTrack, video !== track {
|
|
232
|
+
video?.remove(renderer)
|
|
233
|
+
video = track
|
|
234
|
+
track.add(renderer)
|
|
235
|
+
}
|
|
236
|
+
if let track = track as? RTCAudioTrack, !audio.contains(where: { $0 === track }) {
|
|
237
|
+
audio.append(track)
|
|
238
|
+
track.isEnabled = true
|
|
239
|
+
track.add(renderer)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private func deliverFrame(_ buffer: CVPixelBuffer, generation gen: Int) {
|
|
244
|
+
// Bound queued media and return immediately: close() can wait for WebRTC render callbacks.
|
|
245
|
+
guard videoSlot.wait(timeout: .now()) == .success else { return }
|
|
246
|
+
queue.async { [self] in
|
|
247
|
+
defer { videoSlot.signal() }
|
|
248
|
+
guard gen == generation, peer != nil, !stopping else { return }
|
|
249
|
+
firstFrameSeen = true
|
|
250
|
+
transition(.live, "first_frame")
|
|
251
|
+
onFrame?(buffer)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private func deliverPcm(_ pcm: Data, rate: Int, channels: Int, generation gen: Int) {
|
|
256
|
+
guard audioSlots.wait(timeout: .now()) == .success else { return }
|
|
257
|
+
queue.async { [self] in
|
|
258
|
+
defer { audioSlots.signal() }
|
|
259
|
+
guard gen == generation, peer != nil, pcmEnabled, !stopping else { return }
|
|
260
|
+
onPcm?(pcm, rate, channels)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private func transition(_ state: SourceState, _ reason: String) {
|
|
265
|
+
guard currentState != state else { return }
|
|
266
|
+
currentState = state
|
|
267
|
+
NSLog("GLASSES-MEDIA source=\(state.rawValue) reason=\(reason)")
|
|
268
|
+
onStateChange?(state, reason)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
extension LocalWhipIngestSource: RTCPeerConnectionDelegate {
|
|
273
|
+
public func peerConnection(_: RTCPeerConnection, didChange _: RTCSignalingState) {}
|
|
274
|
+
public func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) {
|
|
275
|
+
queue.async { guard self.peer === peerConnection else { return }; stream.videoTracks.forEach(self.attach); stream.audioTracks.forEach(self.attach) }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
public func peerConnection(_: RTCPeerConnection, didRemove _: RTCMediaStream) {}
|
|
279
|
+
public func peerConnectionShouldNegotiate(_: RTCPeerConnection) {}
|
|
280
|
+
public func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {
|
|
281
|
+
queue.async {
|
|
282
|
+
guard self.peer === peerConnection else { return }
|
|
283
|
+
if newState == .failed { self.fail(LocalMediaError("Local ICE failed")) }
|
|
284
|
+
if newState == .disconnected {
|
|
285
|
+
self.firstFrameSeen = false
|
|
286
|
+
self.transition(.connecting, "ice_disconnected")
|
|
287
|
+
let gen = self.generation
|
|
288
|
+
self.queue.asyncAfter(deadline: .now() + 6) {
|
|
289
|
+
guard gen == self.generation, self.peer === peerConnection, !self.firstFrameSeen else { return }
|
|
290
|
+
self.fail(LocalMediaError("Local ICE disconnected"))
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
public func peerConnection(_ peerConnection: RTCPeerConnection, didChange _: RTCIceGatheringState) {
|
|
297
|
+
queue.async { guard self.peer === peerConnection else { return }; self.finishAnswerIfGathered() }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
public func peerConnection(_: RTCPeerConnection, didGenerate _: RTCIceCandidate) {}
|
|
301
|
+
public func peerConnection(_: RTCPeerConnection, didRemove _: [RTCIceCandidate]) {}
|
|
302
|
+
public func peerConnection(_: RTCPeerConnection, didOpen _: RTCDataChannel) {}
|
|
303
|
+
public func peerConnection(_ peerConnection: RTCPeerConnection, didStartReceivingOn transceiver: RTCRtpTransceiver) {
|
|
304
|
+
queue.async { guard self.peer === peerConnection, let track = transceiver.receiver.track else { return }; self.attach(track) }
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private final class LocalMediaRenderer: NSObject, RTCVideoRenderer, RTCAudioRenderer {
|
|
309
|
+
let frame: (CVPixelBuffer) -> Void
|
|
310
|
+
let pcm: (Data, Int, Int) -> Void
|
|
311
|
+
init(frame: @escaping (CVPixelBuffer) -> Void, pcm: @escaping (Data, Int, Int) -> Void) {
|
|
312
|
+
self.frame = frame; self.pcm = pcm
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
func setSize(_: CGSize) {}
|
|
316
|
+
func renderFrame(_ frame: RTCVideoFrame?) {
|
|
317
|
+
guard let frame, let buffer = frame.buffer as? RTCCVPixelBuffer else { return }
|
|
318
|
+
self.frame(buffer.pixelBuffer)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
func render(pcmBuffer: AVAudioPCMBuffer) {
|
|
322
|
+
guard let data = DecodedPcm.pcm16(pcmBuffer) else { return }
|
|
323
|
+
pcm(data, Int(pcmBuffer.format.sampleRate), Int(pcmBuffer.format.channelCount))
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import AudioToolbox
|
|
2
|
+
import Foundation
|
|
3
|
+
import WebRTC
|
|
4
|
+
|
|
5
|
+
/// Pulls the receive mixer on a dedicated thread so remote PCM renderers run, but never opens
|
|
6
|
+
/// the phone microphone, loudspeaker, or AVAudioSession. The publisher owns audio routing.
|
|
7
|
+
final class ReceiveOnlyAudioDevice: NSObject, RTCAudioDevice {
|
|
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 isPlayoutInitialized = false
|
|
18
|
+
private(set) var isPlaying = false
|
|
19
|
+
let isRecordingInitialized = false
|
|
20
|
+
let isRecording = false
|
|
21
|
+
private let condition = NSCondition()
|
|
22
|
+
private var quitting = false
|
|
23
|
+
private var exited = true
|
|
24
|
+
private var playing = false
|
|
25
|
+
|
|
26
|
+
func initialize(with delegate: RTCAudioDeviceDelegate) -> Bool {
|
|
27
|
+
condition.lock()
|
|
28
|
+
quitting = false
|
|
29
|
+
playing = false
|
|
30
|
+
exited = false
|
|
31
|
+
condition.unlock()
|
|
32
|
+
isInitialized = true
|
|
33
|
+
let thread = Thread { [self, delegate] in
|
|
34
|
+
let storage = UnsafeMutablePointer<Int16>.allocate(capacity: 480)
|
|
35
|
+
defer {
|
|
36
|
+
storage.deallocate()
|
|
37
|
+
condition.lock(); exited = true; condition.broadcast(); condition.unlock()
|
|
38
|
+
}
|
|
39
|
+
var sampleTime: Double = 0
|
|
40
|
+
while true {
|
|
41
|
+
condition.lock()
|
|
42
|
+
while !playing && !quitting {
|
|
43
|
+
condition.wait()
|
|
44
|
+
}
|
|
45
|
+
let stop = quitting
|
|
46
|
+
condition.unlock()
|
|
47
|
+
if stop { return }
|
|
48
|
+
var flags = AudioUnitRenderActionFlags()
|
|
49
|
+
var stamp = AudioTimeStamp()
|
|
50
|
+
stamp.mSampleTime = sampleTime
|
|
51
|
+
stamp.mFlags = .sampleTimeValid
|
|
52
|
+
var buffers = AudioBufferList(mNumberBuffers: 1, mBuffers: AudioBuffer(mNumberChannels: 1, mDataByteSize: 960, mData: storage))
|
|
53
|
+
_ = delegate.getPlayoutData(&flags, &stamp, 0, 480, &buffers)
|
|
54
|
+
sampleTime += 480
|
|
55
|
+
Thread.sleep(forTimeInterval: 0.01)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
thread.name = "Mentra local audio receive"
|
|
59
|
+
thread.qualityOfService = .userInitiated
|
|
60
|
+
thread.start()
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func terminateDevice() -> Bool {
|
|
65
|
+
condition.lock()
|
|
66
|
+
quitting = true
|
|
67
|
+
playing = false
|
|
68
|
+
condition.broadcast()
|
|
69
|
+
while !exited {
|
|
70
|
+
condition.wait()
|
|
71
|
+
}
|
|
72
|
+
condition.unlock()
|
|
73
|
+
isInitialized = false
|
|
74
|
+
isPlaying = false
|
|
75
|
+
isPlayoutInitialized = false
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func initializePlayout() -> Bool {
|
|
80
|
+
isPlayoutInitialized = true; return true
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func startPlayout() -> Bool {
|
|
84
|
+
condition.lock(); playing = true; condition.broadcast(); condition.unlock()
|
|
85
|
+
isPlaying = true
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
func stopPlayout() -> Bool {
|
|
90
|
+
condition.lock(); playing = false; condition.unlock()
|
|
91
|
+
isPlaying = false
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func initializeRecording() -> Bool {
|
|
96
|
+
false
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func startRecording() -> Bool {
|
|
100
|
+
false
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func stopRecording() -> Bool {
|
|
104
|
+
true
|
|
105
|
+
}
|
|
106
|
+
}
|