@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.
@@ -69,6 +69,7 @@ class WhipIngestServer(
69
69
  private val connections = Executors.newCachedThreadPool { runnable ->
70
70
  Thread(runnable, "whip-ingest-conn").apply { isDaemon = true }
71
71
  }
72
+ private val activeSockets = java.util.concurrent.ConcurrentHashMap.newKeySet<Socket>()
72
73
  private val accepted = AtomicInteger()
73
74
 
74
75
  /** Bound endpoint, or null before [start]. This is the URL the glasses must be told to POST to. */
@@ -141,6 +142,13 @@ class WhipIngestServer(
141
142
  closeListener()
142
143
  }
143
144
 
145
+ /** Hard teardown barrier: no request may still create a peer after this returns. */
146
+ fun closeAndAwait(timeoutMs: Long = 15_000): Boolean {
147
+ closeNow()
148
+ connections.shutdownNow()
149
+ return connections.awaitTermination(timeoutMs, TimeUnit.MILLISECONDS)
150
+ }
151
+
144
152
  private fun closeListener() {
145
153
  val socket = synchronized(lock) {
146
154
  val current = server
@@ -149,6 +157,7 @@ class WhipIngestServer(
149
157
  current
150
158
  } ?: return
151
159
  runCatching { socket.close() }
160
+ activeSockets.forEach { runCatching { it.close() } }
152
161
  connections.shutdownNow()
153
162
  SoftApTrace.stage("whip_listener_closed", "accepted" to accepted.get())
154
163
  }
@@ -163,6 +172,10 @@ class WhipIngestServer(
163
172
  Log.w(TAG, "accept failed", error)
164
173
  return
165
174
  }
175
+ val registered = synchronized(lock) {
176
+ if (server !== socket) false else { activeSockets.add(connection); true }
177
+ }
178
+ if (!registered) { runCatching { connection.close() }; return }
166
179
  accepted.incrementAndGet()
167
180
  // Each connection on its own thread: a negotiation blocks for the length of an ICE gather,
168
181
  // and a DELETE arriving during one must not queue behind it.
@@ -170,28 +183,31 @@ class WhipIngestServer(
170
183
  connections.execute { serve(connection) }
171
184
  } catch (_: java.util.concurrent.RejectedExecutionException) {
172
185
  runCatching { connection.close() }
186
+ activeSockets.remove(connection)
173
187
  }
174
188
  }
175
189
  }
176
190
 
177
191
  private fun serve(connection: Socket) {
178
- connection.use { socket ->
179
- socket.soTimeout = READ_TIMEOUT_MS
180
- val output = BufferedOutputStream(socket.getOutputStream())
181
- try {
182
- val request = readRequest(socket.getInputStream())
183
- if (request == null) {
184
- write(output, WhipIngestProtocol.Response(400, "Bad Request", body = "malformed request"))
185
- return
186
- }
187
- write(output, handle(request))
188
- } catch (error: Exception) {
189
- Log.w(TAG, "connection failed", error)
190
- runCatching {
191
- write(output, WhipIngestProtocol.Response(400, "Bad Request", body = "read failed"))
192
+ try {
193
+ connection.use { socket ->
194
+ socket.soTimeout = READ_TIMEOUT_MS
195
+ val output = BufferedOutputStream(socket.getOutputStream())
196
+ try {
197
+ val request = readRequest(socket.getInputStream())
198
+ if (request == null) {
199
+ write(output, WhipIngestProtocol.Response(400, "Bad Request", body = "malformed request"))
200
+ return
201
+ }
202
+ write(output, handle(request))
203
+ } catch (error: Exception) {
204
+ Log.w(TAG, "connection failed", error)
205
+ runCatching {
206
+ write(output, WhipIngestProtocol.Response(400, "Bad Request", body = "read failed"))
207
+ }
192
208
  }
193
209
  }
194
- }
210
+ } finally { activeSockets.remove(connection) }
195
211
  }
196
212
 
197
213
  private fun handle(request: WhipIngestProtocol.Request): WhipIngestProtocol.Response {
@@ -4,9 +4,13 @@
4
4
  "android"
5
5
  ],
6
6
  "apple": {
7
- "modules": []
7
+ "modules": [
8
+ "GlassesMediaRelayModule"
9
+ ]
8
10
  },
9
11
  "android": {
10
- "modules": []
12
+ "modules": [
13
+ "com.mentra.glassesmedia.GlassesMediaRelayModule"
14
+ ]
11
15
  }
12
16
  }
@@ -31,6 +31,14 @@ public enum LocalMediaPolicy {
31
31
  (bytes[0] == 192 && bytes[1] == 168)
32
32
  }
33
33
 
34
+ /// SSID association can finish before DHCP replaces the previous Wi-Fi address.
35
+ /// Accept only a client address on the /24 advertised by the glasses over BLE.
36
+ public static func isHotspotClientAddress(_ address: String, gateway: String) -> Bool {
37
+ guard isPrivate(gateway), sameSubnet(address, gateway), address != gateway,
38
+ let bytes = ipv4(address) else { return false }
39
+ return bytes[3] > 0 && bytes[3] < 255
40
+ }
41
+
34
42
  /// Keep only concrete host candidates on the local link. Never rewrite a cellular socket's
35
43
  /// advertised address: a candidate must already belong to the interface it claims.
36
44
  public static func localSdp(_ sdp: String, address: String, answer: Bool) throws -> String {
@@ -45,7 +53,11 @@ public enum LocalMediaPolicy {
45
53
  candidates += 1
46
54
  return true
47
55
  }
48
- guard candidates > 0 else { throw LocalMediaError("No host ICE candidate on the glasses hotspot") }
56
+ guard candidates > 0 else {
57
+ throw LocalMediaError(answer
58
+ ? "Phone answer has no host ICE candidate on the glasses hotspot"
59
+ : "Glasses offer has no host ICE candidate on the glasses hotspot")
60
+ }
49
61
  return lines.joined(separator: "\r\n") + "\r\n"
50
62
  }
51
63
  }
@@ -0,0 +1,56 @@
1
+ import Foundation
2
+
3
+ /// A paced audio worker whose stop is a barrier for the final render callback and its resources.
4
+ public final class RelayAudioClock {
5
+ private let condition = NSCondition()
6
+ private var quitting = false
7
+ private var exited = true
8
+ private var enabled = false
9
+
10
+ public init() {}
11
+
12
+ public func start(render: @escaping (Double) -> Void, finish: @escaping () -> Void = {}) {
13
+ condition.lock()
14
+ precondition(exited, "Previous audio worker must stop before reinitialization")
15
+ quitting = false; enabled = false; exited = false
16
+ condition.unlock()
17
+ let thread = Thread { [self] in
18
+ defer {
19
+ finish()
20
+ condition.lock(); exited = true; condition.broadcast(); condition.unlock()
21
+ }
22
+ var sampleTime: Double = 0
23
+ var next = ProcessInfo.processInfo.systemUptime
24
+ while true {
25
+ condition.lock()
26
+ while !enabled && !quitting {
27
+ condition.wait(); next = ProcessInfo.processInfo.systemUptime
28
+ }
29
+ let stop = quitting
30
+ condition.unlock()
31
+ if stop { return }
32
+ render(sampleTime)
33
+ sampleTime += 480
34
+ next += 0.01
35
+ let now = ProcessInfo.processInfo.systemUptime
36
+ if next > now { Thread.sleep(forTimeInterval: next - now) }
37
+ else if now - next > 0.1 { next = now }
38
+ }
39
+ }
40
+ thread.name = "Mentra glasses audio publish"
41
+ thread.qualityOfService = .userInitiated
42
+ thread.start()
43
+ }
44
+
45
+ public func setEnabled(_ value: Bool) {
46
+ condition.lock(); enabled = value; condition.broadcast(); condition.unlock()
47
+ }
48
+
49
+ public func stop() {
50
+ condition.lock(); quitting = true; enabled = false; condition.broadcast()
51
+ while !exited {
52
+ condition.wait()
53
+ }
54
+ condition.unlock()
55
+ }
56
+ }
@@ -0,0 +1,57 @@
1
+ import Foundation
2
+
3
+ /// Bounded decoded audio queue. Converts PCM16 to 48kHz mono without touching phone capture.
4
+ public final class RelayPcmBuffer {
5
+ private let lock = NSLock()
6
+ private var samples: [Int16]
7
+ private var head = 0
8
+ private var count = 0
9
+ private var phase = 0.0
10
+ private var rate = 0
11
+ private var channels = 0
12
+ private var previous: Double?
13
+
14
+ public init(capacity: Int = 9600) {
15
+ samples = Array(repeating: 0, count: capacity)
16
+ }
17
+
18
+ public func push(_ data: Data, sampleRate: Int, channels channelCount: Int) {
19
+ guard (8000 ... 192_000).contains(sampleRate), (1 ... 8).contains(channelCount) else { return }
20
+ lock.lock(); defer { lock.unlock() }
21
+ if rate != sampleRate || channels != channelCount {
22
+ rate = sampleRate; channels = channelCount; phase = 0; previous = nil
23
+ }
24
+ let step = Double(sampleRate) / 48000
25
+ data.withUnsafeBytes { raw in
26
+ let bytes = raw.bindMemory(to: UInt8.self)
27
+ for frame in 0 ..< data.count / (2 * channelCount) {
28
+ var sum = 0.0
29
+ for channel in 0 ..< channelCount {
30
+ let offset = (frame * channelCount + channel) * 2
31
+ sum += Double(Int16(bitPattern: UInt16(bytes[offset]) | UInt16(bytes[offset + 1]) << 8))
32
+ }
33
+ let value = sum / Double(channelCount)
34
+ if let prior = previous {
35
+ while phase < 1 {
36
+ if count == samples.count { head = (head + 1) % samples.count; count -= 1 }
37
+ samples[(head + count) % samples.count] = Int16(prior + (value - prior) * phase)
38
+ count += 1
39
+ phase += step
40
+ }
41
+ phase -= 1
42
+ }
43
+ previous = value
44
+ }
45
+ }
46
+ }
47
+
48
+ /// Pads underruns with silence. Drops oldest samples on overrun to bound live latency.
49
+ public func read(into output: UnsafeMutableBufferPointer<Int16>) {
50
+ lock.lock(); defer { lock.unlock() }
51
+ for i in output.indices {
52
+ if count > 0 {
53
+ output[i] = samples[head]; head = (head + 1) % samples.count; count -= 1
54
+ } else { output[i] = 0 }
55
+ }
56
+ }
57
+ }
@@ -7,7 +7,9 @@ import NetworkExtension
7
7
  public final class GlassesHotspotNetwork {
8
8
  private let queue = DispatchQueue(label: "com.mentra.glassesmedia.hotspot")
9
9
  private var ssid: String?
10
+ private var lastHotspotSSID: String?
10
11
  private var localAddress: String?
12
+ private var gatewayAddress: String?
11
13
  private var generation = 0
12
14
  private var applying = false
13
15
  private var cancelled = false
@@ -17,12 +19,17 @@ public final class GlassesHotspotNetwork {
17
19
  public var onLost: ((String) -> Void)?
18
20
  public init() {}
19
21
 
20
- public func join(ssid: String, passphrase: String, completion: @escaping (Result<String, Error>) -> Void) {
22
+ public func join(ssid: String, passphrase: String, gateway: String? = nil, completion: @escaping (Result<String, Error>) -> Void) {
21
23
  queue.async {
22
24
  guard self.ssid == nil, !self.applying else { completion(.failure(LocalMediaError("Previous hotspot session has not finished cleaning up"))); return }
25
+ if let gateway, !LocalMediaPolicy.isPrivate(gateway) {
26
+ completion(.failure(LocalMediaError("The glasses reported an invalid hotspot gateway"))); return
27
+ }
23
28
  self.generation += 1
24
29
  let gen = self.generation
25
30
  self.ssid = ssid
31
+ self.lastHotspotSSID = ssid
32
+ self.gatewayAddress = gateway
26
33
  self.cancelled = false
27
34
  self.applying = true
28
35
  self.joinReply = completion
@@ -75,7 +82,7 @@ public final class GlassesHotspotNetwork {
75
82
  public func probeGateway(completion: @escaping (Bool, String) -> Void) {
76
83
  queue.async {
77
84
  guard let address = self.localAddress else { completion(false, "No joined hotspot"); return }
78
- let gateway = address.split(separator: ".").prefix(3).joined(separator: ".") + ".1"
85
+ let gateway = self.gatewayAddress ?? address.split(separator: ".").prefix(3).joined(separator: ".") + ".1"
79
86
  let parameters = NWParameters.tcp
80
87
  parameters.requiredInterfaceType = .wifi
81
88
  let connection = NWConnection(host: NWEndpoint.Host(gateway), port: 8089, using: parameters)
@@ -99,7 +106,7 @@ public final class GlassesHotspotNetwork {
99
106
  }
100
107
  }
101
108
 
102
- public func awaitInternet(completion: @escaping (Bool, String) -> Void) {
109
+ public func awaitInternet(requireCellular: Bool = true, completion: @escaping (Bool, String) -> Void) {
103
110
  queue.async {
104
111
  let monitor = NWPathMonitor()
105
112
  var finished = false
@@ -111,9 +118,21 @@ public final class GlassesHotspotNetwork {
111
118
  }
112
119
  monitor.pathUpdateHandler = { path in
113
120
  if path.status == .satisfied, path.usesInterfaceType(.cellular) { finish(true, "cellular") }
121
+ // Once the hotspot is released, a return to the user's Wi-Fi is also valid.
122
+ // Do not mistake the departing glasses AP's local-only path for restored internet.
123
+ if !requireCellular, path.status == .satisfied, path.usesInterfaceType(.wifi) {
124
+ NEHotspotNetwork.fetchCurrent { network in
125
+ self.queue.async {
126
+ guard let network, !network.ssid.isEmpty, network.ssid != self.lastHotspotSSID else { return }
127
+ finish(true, "wifi")
128
+ }
129
+ }
130
+ }
114
131
  }
115
132
  monitor.start(queue: self.queue)
116
- self.queue.asyncAfter(deadline: .now() + 15) { finish(false, "Cellular internet did not become the default route") }
133
+ self.queue.asyncAfter(deadline: .now() + 15) {
134
+ finish(false, requireCellular ? "Cellular internet did not become the default route" : "Internet did not return after leaving the glasses hotspot")
135
+ }
117
136
  }
118
137
  }
119
138
 
@@ -122,14 +141,18 @@ public final class GlassesHotspotNetwork {
122
141
  NEHotspotNetwork.fetchCurrent { [weak self] network in
123
142
  self?.queue.async {
124
143
  guard let self, gen == self.generation, !self.cancelled else { return }
125
- if network?.ssid == ssid, let address = Self.wifiAddress() {
144
+ if network?.ssid == ssid, let address = Self.wifiAddress(),
145
+ self.gatewayAddress.map({ LocalMediaPolicy.isHotspotClientAddress(address, gateway: $0) }) ?? true
146
+ {
126
147
  self.localAddress = address
127
148
  self.startMonitor(generation: gen)
128
149
  self.finishJoin(.success(address))
129
150
  } else if remaining > 0 {
130
151
  self.queue.asyncAfter(deadline: .now() + 0.5) { self.waitForAddress(ssid: ssid, generation: gen, remaining: remaining - 1) }
131
152
  } else {
132
- self.finishJoin(.failure(LocalMediaError("Glasses hotspot has no verified Wi-Fi address")))
153
+ let association = network == nil ? "unavailable" : (network?.ssid == ssid ? "matched" : "different")
154
+ let address = Self.wifiAddress() ?? "none"
155
+ self.finishJoin(.failure(LocalMediaError("Glasses hotspot has no verified Wi-Fi address (SSID=\(association), Wi-Fi IPv4=\(address))")))
133
156
  self.finishLeave()
134
157
  }
135
158
  }
@@ -160,6 +183,7 @@ public final class GlassesHotspotNetwork {
160
183
  if let ssid { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
161
184
  ssid = nil
162
185
  localAddress = nil
186
+ gatewayAddress = nil
163
187
  let replies = leaveReplies
164
188
  leaveReplies.removeAll()
165
189
  replies.forEach { $0() }
@@ -11,7 +11,9 @@ Pod::Spec.new do |s|
11
11
  s.platforms = { :ios => '15.1' }
12
12
  s.swift_version = '5.9'
13
13
  s.static_framework = true
14
- s.dependency 'WebRTC-SDK', '137.7151.09'
14
+ s.dependency 'ExpoModulesCore'
15
+ # Keep in sync with the iOS-only @livekit/react-native-webrtc podspec patch.
16
+ s.dependency 'WebRTC-SDK', '144.7559.15'
15
17
  s.frameworks = 'AVFoundation', 'CoreMedia', 'CoreVideo', 'Network', 'NetworkExtension'
16
18
  s.source_files = '*.{swift,h,m,mm}', 'CoreKit/Sources/GlassesMediaCore/*.swift'
17
19
  s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
@@ -0,0 +1,133 @@
1
+ import ExpoModulesCore
2
+ import Foundation
3
+
4
+ public final class GlassesMediaRelayModule: Module {
5
+ private let queue = DispatchQueue(label: "com.mentra.glassesmedia.relay")
6
+ private var session: ManagedRelaySession?
7
+
8
+ public func definition() -> ModuleDefinition {
9
+ Name("MentraGlassesMediaRelay")
10
+ Events("onRelayState")
11
+
12
+ AsyncFunction("prepare") { (options: [String: Any], promise: Promise) in
13
+ guard let id = options["attemptId"] as? String,
14
+ let ingest = options["ingestUrl"] as? String, let url = URL(string: ingest), url.scheme == "https",
15
+ let ssid = options["ssid"] as? String, let password = options["password"] as? String,
16
+ let gateway = options["gatewayAddress"] as? String
17
+ else {
18
+ throw LocalMediaError("Invalid relay options")
19
+ }
20
+ self.queue.async {
21
+ guard self.session == nil else { promise.reject(LocalMediaError("Previous relay has not stopped")); return }
22
+ let session = ManagedRelaySession(id: id, queue: self.queue)
23
+ self.session = session
24
+ session.start(ssid: ssid, password: password, gateway: gateway, endpoint: url,
25
+ captureAudio: options["captureAudio"] as? Bool ?? true,
26
+ bitrate: options["bitrate"] as? Int ?? 2_000_000,
27
+ onState: { [weak self] state, reason in
28
+ self?.sendEvent("onRelayState", ["attemptId": id, "state": state, "reason": reason])
29
+ }) { result in
30
+ switch result {
31
+ case let .success(url): promise.resolve(url)
32
+ case let .failure(error): promise.reject(error)
33
+ }
34
+ }
35
+ }
36
+ }
37
+
38
+ AsyncFunction("stop") { (id: String, promise: Promise) in
39
+ self.queue.async {
40
+ guard let session = self.session, session.id == id else { promise.resolve(nil); return }
41
+ session.stop {
42
+ if self.session === session { self.session = nil }
43
+ promise.resolve(nil)
44
+ }
45
+ }
46
+ }
47
+
48
+ OnDestroy {
49
+ self.queue.async {
50
+ self.session?.stop { self.session = nil }
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ /// Serialized owner of both native peers and the persistent hotspot join; no JS media callbacks.
57
+ private final class ManagedRelaySession {
58
+ let id: String
59
+ private let queue: DispatchQueue
60
+ private let hotspot = GlassesHotspotNetwork()
61
+ private var receiver: LocalWhipIngestSource?
62
+ private var publisher: PhoneWhipPublisher?
63
+ private var stopped = false
64
+ private var stopFinished = false
65
+ private var stopReplies: [() -> Void] = []
66
+ private var ready: ((Result<String, Error>) -> Void)?
67
+
68
+ init(id: String, queue: DispatchQueue) {
69
+ self.id = id; self.queue = queue
70
+ }
71
+
72
+ func start(ssid: String, password: String, gateway: String, endpoint: URL, captureAudio: Bool, bitrate: Int,
73
+ onState: @escaping (String, String) -> Void, completion: @escaping (Result<String, Error>) -> Void)
74
+ {
75
+ ready = completion
76
+ hotspot.onLost = { reason in onState("failed", reason) }
77
+ hotspot.join(ssid: ssid, passphrase: password, gateway: gateway) { result in
78
+ self.queue.async {
79
+ guard !self.stopped else { return }
80
+ switch result {
81
+ case let .failure(error): self.finish(.failure(error))
82
+ case let .success(address):
83
+ self.hotspot.awaitInternet { usable, _ in
84
+ self.queue.async {
85
+ guard !self.stopped else { return }
86
+ guard usable else { self.finish(.failure(LocalMediaError("Turn on phone mobile data to stream through the glasses hotspot"))); return }
87
+ let publisher = PhoneWhipPublisher(endpoint: endpoint, captureAudio: captureAudio, bitrate: bitrate, onState: onState)
88
+ self.publisher = publisher
89
+ let receiver = LocalWhipIngestSource()
90
+ self.receiver = receiver
91
+ receiver.onFrame = { [weak publisher] in publisher?.onFrame($0) }
92
+ receiver.onPcm = { [weak publisher] in publisher?.onPcm($0, rate: $1, channels: $2) }
93
+ receiver.onStateChange = { state, reason in if state == .failed { onState("failed", "Glasses receiver: \(reason)") } }
94
+ receiver.setPcmDeliveryEnabled(captureAudio)
95
+ receiver.prepare(config: SourceConfig(url: "", kind: .softap, bindAddress: address)) { result in
96
+ self.queue.async {
97
+ guard !self.stopped else { return }
98
+ if case .success = result { publisher.start() }
99
+ self.finish(result)
100
+ }
101
+ }
102
+ }
103
+ }
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ private func finish(_ result: Result<String, Error>) {
110
+ let callback = ready; ready = nil; callback?(result)
111
+ }
112
+
113
+ func stop(completion: @escaping () -> Void) {
114
+ if stopFinished { completion(); return }
115
+ stopReplies.append(completion)
116
+ guard !stopped else { return }
117
+ stopped = true
118
+ finish(.failure(LocalMediaError("Relay cancelled")))
119
+ hotspot.onLost = nil
120
+ let group = DispatchGroup()
121
+ if let receiver {
122
+ group.enter(); receiver.stop { group.leave() }
123
+ }
124
+ if let publisher {
125
+ group.enter(); publisher.stop { group.leave() }
126
+ }
127
+ group.enter(); hotspot.leave { group.leave() }
128
+ group.notify(queue: queue) {
129
+ self.receiver = nil; self.publisher = nil; self.stopFinished = true
130
+ let replies = self.stopReplies; self.stopReplies.removeAll(); replies.forEach { $0() }
131
+ }
132
+ }
133
+ }
@@ -0,0 +1,23 @@
1
+ import WebRTC
2
+
3
+ /// Both legs need real interface enumeration: local Wi-Fi can be absent from the default
4
+ /// internet NWPath while cellular carries the uplink. Each factory's adapter mask and the
5
+ /// receiver's SDP policy still restrict candidates to the appropriate leg.
6
+ enum GlassesPeerFactory {
7
+ /// M144 honors this field trial for factories with custom audio devices; M137 installed
8
+ /// NWPathMonitor unconditionally (including when disableNetworkMonitor was set).
9
+ /// The SDK setting is process-wide. Set it once and never toggle it around factory creation:
10
+ /// receiver, relay, and React Native WebRTC factories may initialize concurrently.
11
+ private static let configure: Void = {
12
+ RTCPeerConnectionFactory.configureFieldTrials("WebRTC-Network-UseNWPathMonitor/Disabled/")
13
+ }()
14
+
15
+ static func make(audioDevice: RTCAudioDevice) -> RTCPeerConnectionFactory {
16
+ _ = configure
17
+ return RTCPeerConnectionFactory(
18
+ encoderFactory: RTCDefaultVideoEncoderFactory(),
19
+ decoderFactory: RTCDefaultVideoDecoderFactory(),
20
+ audioDevice: audioDevice
21
+ )
22
+ }
23
+ }
@@ -67,7 +67,7 @@ public final class LocalWhipIngestSource: NSObject, DecodedGlassesMediaSource {
67
67
  let gen = self.generation
68
68
  self.transition(.connecting, "listening")
69
69
  RTCInitializeSSL()
70
- let factory = RTCPeerConnectionFactory(encoderFactory: RTCDefaultVideoEncoderFactory(), decoderFactory: RTCDefaultVideoDecoderFactory(), audioDevice: ReceiveOnlyAudioDevice())
70
+ let factory = GlassesPeerFactory.make(audioDevice: ReceiveOnlyAudioDevice())
71
71
  let options = RTCPeerConnectionFactoryOptions()
72
72
  options.ignoreCellularNetworkAdapter = true
73
73
  options.ignoreVPNNetworkAdapter = true