@mentra/glasses-media 3.2.0-dev.225 → 3.2.0-dev.227

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Glasses media transport
2
2
 
3
- Native receivers and network helpers shared by media publishers. This library has no ACS SDK dependency. `acs-meeting` is its first consumer; a future Cloudflare publisher can consume the same decoded video and PCM without joining a meeting.
3
+ Native receivers, WHIP publishers and network helpers shared by the Mentra App. This library has no ACS SDK dependency. ACS consumes decoded media for calls; managed WebRTC streams feed the same decoded video and PCM to `PhoneWhipPublisher`.
4
4
 
5
5
  - Android: the existing `GlassesMediaSource`, local WHIP and WHEP receivers, scoped SoftAP networking, decoded I420/PCM delivery, and transport diagnostics moved here. The ACS session and raw outgoing streams remain in `acs-meeting`.
6
6
  - iOS: `DecodedGlassesMediaSource` delivers `CVPixelBuffer` video and interleaved PCM16. `LocalWhipIngestSource` answers the glasses' existing WHIP client; `WhepVideoSource` retains the cloud subscription path.
@@ -12,7 +12,7 @@ The publisher owns its outgoing encoders, audio policy, and destination. Receive
12
12
 
13
13
  The host signs in, enables the glasses hotspot, joins it, prepares the receiver and publisher, then tells the glasses to publish to the returned `ingestUrl`. A successful listener bind is not a live stream: `live` requires a decoded video frame. iOS waits for full ICE gathering and returns only actual host candidates on the hotspot interface. Requests and queued media are bounded.
14
14
 
15
- Stop the glasses publisher before stopping the receiver and releasing the network. Await `LocalWhipIngestSource.stop(completion:)` before reusing resources. It cancels pending offers, closes the peer and HTTP listener, and drains teardown before completing. A local source must not silently restart onto a new port; the host must coordinate any new URL with the glasses. WHEP can rebuild its existing URL.
15
+ Stop the glasses publisher before stopping the receiver and releasing the network. Await `LocalWhipIngestSource.stop(completion:)` before reusing resources on iOS. Android owners discarding a receiver use `close()` to drain HTTP negotiation and release its retained factory and EGL resources. It cancels pending offers, closes the peer and HTTP listener, and drains teardown before completing. A local source must not silently restart onto a new port; the host must coordinate any new URL with the glasses. WHEP can rebuild its existing URL.
16
16
 
17
17
  ## Verification
18
18
 
@@ -32,4 +32,20 @@ xcodebuild -project mobile/ios/Pods/Pods.xcodeproj -target AcsMeeting \
32
32
 
33
33
  Before merging/releasing, exercise iPhone + glasses: first-use local-network permission and hotspot prompts; join/admit/mute/leave; music/camera sounds; rapid Start/Cancel/Start during sign-in, hotspot join, and WHIP negotiation; hotspot loss; repeated joins; screen-off/background streaming; and a sustained call while measuring latency, audio/video alignment, and thermal behavior. Screen-off operation and background gallery transfers already exist in the Mentra App; these checks qualify the new media pipeline. A simulator build and loopback HTTP tests cannot establish physical Wi-Fi/cellular routing or camera behavior.
34
34
 
35
- Cloudflare publishing and changes to RTMP/SRT routes are outside this library extraction.
35
+ ## Managed WebRTC relay
36
+
37
+ `PhoneStreamCoordinator` provisions Cloudflare and owns subscribers for the entire route:
38
+
39
+ ```text
40
+ Glasses -- WHIP over SoftAP --> shared receiver -- decoded video/PCM --> phone WHIP publisher --> Cloudflare
41
+ ```
42
+
43
+ Only `ingest: "whip"` selects this route. Default SRT and explicit RTMP continue publishing directly from the glasses. WHIP never falls back to SRT/RTMP when Cloudflare omits its WHIP URL. The phone needs mobile data; the glasses need no internet Wi-Fi credentials. Livestreamer's Stream Here path skips its glasses Wi-Fi setup gate accordingly.
44
+
45
+ `MentraGlassesMediaRelay.prepare` joins the persistent hotspot, prepares both native peers, and returns a local WHIP URL for the BLE start command. Cloudflare credentials stay on the phone. Video queues hold at most one frame; decoded PCM is downmixed/resampled into a bounded 200ms, 48kHz mono queue. WebRTC re-encodes both tracks. No phone microphone is captured. Android uses the WebRTC library's external AudioRecord buffer mode; iOS uses a custom `RTCAudioDevice`.
46
+
47
+ Android's process-wide network inventory exposes both the scoped hotspot and real cellular handles during a relay. Per-factory masks keep the receiver on Wi-Fi and the publisher off Wi-Fi; signaling also uses the cellular network's socket factory and DNS. ACS retains its existing hotspot-only inventory. iOS uses corresponding per-factory masks after validating the cellular default route.
48
+
49
+ An attempt-specific ID isolates late callbacks and glasses status. Reconnect tears down the glasses publisher, both phone peers and hotspot before rebuilding against the same provisioned input. Three retries bound consecutive failures; a minute of stable streaming renews the retry budget. Last-subscriber stop cancels startup/backoff, awaits native cleanup and disables the hotspot. Failed cleanup retains ownership and can be retried by stop. BLE-unreachable stop/hotspot commands are deferred until reconnection. ACS and managed streaming share a hotspot reservation. Native media queues keep running when the UI is backgrounded; Android holds a partial wake lock and uses the engine's background timers for retry/readiness.
50
+
51
+ Device qualification for this outgoing pipeline must include Android and iPhone: audio/video playback on the provisioned WHEP URL, mute/video-only capture, last-subscriber stop, cancellation at each setup step, cellular/hotspot/BLE loss and recovery, repeated starts, screen-off/background operation and a sustained thermal/audio-sync run. Unit tests and platform compilation cover lifecycle/format contracts; they do not establish those hardware results.
@@ -3,4 +3,5 @@
3
3
  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
4
4
  <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
5
5
  <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
6
+ <uses-permission android:name="android.permission.WAKE_LOCK" />
6
7
  </manifest>
@@ -0,0 +1,113 @@
1
+ package com.mentra.glassesmedia
2
+
3
+ import android.content.Intent
4
+ import android.os.Handler
5
+ import android.os.Looper
6
+ import android.os.PowerManager
7
+ import android.provider.Settings
8
+ import com.mentra.glassesmedia.network.*
9
+ import com.mentra.glassesmedia.publisher.PhoneWhipPublisher
10
+ import com.mentra.glassesmedia.source.*
11
+ import expo.modules.kotlin.Promise
12
+ import expo.modules.kotlin.modules.Module
13
+ import expo.modules.kotlin.modules.ModuleDefinition
14
+ import java.util.concurrent.Executors
15
+
16
+ /** Native resources for one coordinator-owned relay attempt. All transitions use one executor. */
17
+ class GlassesMediaRelayModule : Module() {
18
+ private val worker = Executors.newSingleThreadExecutor()
19
+ private var activeId: String? = null
20
+ private var network: ScopedSoftApNetwork? = null
21
+ private var internet: InternetHold? = null
22
+ private var source: LocalWhipIngestSource? = null
23
+ private var publisher: PhoneWhipPublisher? = null
24
+ private var wakeLock: PowerManager.WakeLock? = null
25
+
26
+ override fun definition() = ModuleDefinition {
27
+ Name("MentraGlassesMediaRelay")
28
+ Events("onRelayState")
29
+
30
+ AsyncFunction("prepare") { options: Map<String, Any?>, promise: Promise ->
31
+ worker.execute {
32
+ try {
33
+ check(activeId == null) { "Previous relay has not stopped" }
34
+ val id = requireNotNull(options["attemptId"] as? String)
35
+ val endpoint = requireNotNull(options["ingestUrl"] as? String)
36
+ val ssid = requireNotNull(options["ssid"] as? String)
37
+ val password = requireNotNull(options["password"] as? String)
38
+ val context = requireNotNull(appContext.reactContext).applicationContext
39
+ activeId = id
40
+ val hold = InternetHold(context).also { internet = it }
41
+ check(hold.awaitValidatedCellular().validated) { "Turn on phone mobile data to stream through the glasses hotspot" }
42
+ val scoped = ScopedSoftApNetwork(context).also { network = it }
43
+ if (!scoped.isWifiEnabled()) {
44
+ Handler(Looper.getMainLooper()).post {
45
+ appContext.currentActivity?.startActivity(Intent(Settings.Panel.ACTION_WIFI))
46
+ }
47
+ check(scoped.awaitWifiEnabled()) { "Phone Wi-Fi is disabled" }
48
+ Thread.sleep(ScopedSoftApNetwork.WIFI_ENABLE_SETTLE_MS)
49
+ }
50
+ val listener = object : ScopedSoftApNetwork.Listener {
51
+ override fun onAvailable(network: android.net.Network, localIpv4: String) = Unit
52
+ override fun onLost(error: ScopedNetworkError) { emit(id, "failed", "Glasses hotspot connection was lost") }
53
+ }
54
+ try {
55
+ scoped.join(ssid, password, listener)
56
+ } catch (error: ScopedNetworkError.Unavailable) {
57
+ // Same recovery as ACS: the first specifier can reject while leaving office Wi-Fi.
58
+ Thread.sleep(ScopedSoftApNetwork.UNAVAILABLE_RETRY_SETTLE_MS)
59
+ scoped.join(ssid, password, listener)
60
+ }
61
+ ScopedNetworkChangeDetector.registerRelayNetwork(scoped)
62
+ val outgoing = PhoneWhipPublisher(context, endpoint, options["captureAudio"] != false,
63
+ (options["bitrate"] as? Number)?.toInt() ?: 2_000_000) { state, reason -> emit(id, state, reason) }
64
+ publisher = outgoing
65
+ val incoming = LocalWhipIngestSource(context, VideoFrameListener(outgoing::onVideoFrame),
66
+ PcmListener(outgoing::onPcm), scopedNetwork = scoped)
67
+ source = incoming
68
+ incoming.setStateListener { state, reason ->
69
+ if (state == SourceState.FAILED) emit(id, "failed", "Glasses receiver: $reason")
70
+ }
71
+ incoming.setPcmDeliveryEnabled(options["captureAudio"] != false)
72
+ incoming.start(SourceConfig("", SourceKind.SOFTAP, scoped.localIpv4()))
73
+ wakeLock = context.getSystemService(PowerManager::class.java)
74
+ .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Mentra:managed-relay").apply { acquire() }
75
+ outgoing.start()
76
+ promise.resolve(requireNotNull(incoming.ingestUrl))
77
+ } catch (error: Exception) {
78
+ // The owner still calls stop: preserve ownership until every acquired resource is gone.
79
+ promise.reject("RELAY_PREPARE_FAILED", error.message, error)
80
+ }
81
+ }
82
+ }
83
+
84
+ AsyncFunction("stop") { attemptId: String, promise: Promise ->
85
+ worker.execute {
86
+ try {
87
+ if (activeId == attemptId) cleanup()
88
+ promise.resolve(null)
89
+ } catch (error: Exception) { promise.reject("RELAY_STOP_FAILED", error.message, error) }
90
+ }
91
+ }
92
+
93
+ OnDestroy { worker.execute { runCatching { cleanup() }; worker.shutdown() } }
94
+ }
95
+
96
+ private fun emit(id: String, state: String, reason: String) {
97
+ sendEvent("onRelayState", mapOf("attemptId" to id, "state" to state, "reason" to reason))
98
+ }
99
+
100
+ private fun cleanup() {
101
+ source?.setStateListener(null)
102
+ // Try every cleanup even if one fails. Keep the slot occupied on any failure.
103
+ var failure: Exception? = null
104
+ fun step(action: () -> Unit) { try { action() } catch (e: Exception) { failure = failure ?: e } }
105
+ step { source?.close(); source = null }
106
+ step { publisher?.close(); publisher = null }
107
+ step { network?.let { it.release(); ScopedNetworkChangeDetector.releaseRelayNetwork(it) }; network = null }
108
+ step { internet?.close(); internet = null }
109
+ step { wakeLock?.let { if (it.isHeld) it.release() }; wakeLock = null }
110
+ failure?.let { throw it }
111
+ activeId = null
112
+ }
113
+ }
@@ -142,7 +142,7 @@ internal constructor(
142
142
  )
143
143
  }
144
144
 
145
- val merged = mergeScopedNetwork(stock, scoped)
145
+ val merged = mergeScopedNetwork(stock, scoped, registry.includesInternet())
146
146
  lastPublished = snapshotOf(merged)
147
147
  SoftApTrace.stage(
148
148
  "webrtc_network_inventory",
@@ -235,12 +235,39 @@ internal constructor(
235
235
  context: Context,
236
236
  scoped: () -> ScopedInterface?,
237
237
  ): Wired {
238
- val filter = ScopedNetworkObserver(downstream, scoped)
238
+ val filter = ScopedNetworkObserver(downstream, scoped) { registry.includesInternet() }
239
239
  return Wired(NetworkMonitorAutoDetect(filter, context), scoped, filter)
240
240
  }
241
241
 
242
242
  @JvmStatic
243
243
  fun install(scopedNetworkSupplier: () -> ScopedSoftApNetwork?) {
244
+ registry.registerAcs(scopedNetworkSupplier)
245
+ installMonitor()
246
+ }
247
+
248
+ private val registry = ScopedNetworkRegistry<ScopedSoftApNetwork>()
249
+
250
+ /** Receiver fallback for standalone users; never replaces ACS's live supplier. */
251
+ @JvmStatic
252
+ fun registerReceiverNetwork(network: ScopedSoftApNetwork) {
253
+ registry.registerReceiver(network)
254
+ installMonitor()
255
+ }
256
+
257
+ @JvmStatic
258
+ fun releaseReceiverNetwork(network: ScopedSoftApNetwork) { registry.releaseReceiver(network) }
259
+
260
+ /** Keep real internet handles visible for the outgoing peer; each factory masks its leg. */
261
+ @JvmStatic
262
+ fun registerRelayNetwork(network: ScopedSoftApNetwork) {
263
+ registry.registerRelay(network)
264
+ installMonitor()
265
+ }
266
+
267
+ @JvmStatic
268
+ fun releaseRelayNetwork(network: ScopedSoftApNetwork) { registry.releaseRelay(network) }
269
+
270
+ private fun installMonitor() {
244
271
  val monitor = NetworkMonitor.getInstance()
245
272
  // A live detector was built by an earlier factory (or the stock one) and will not be
246
273
  // replaced until monitoring restarts; say so in the trace so a missing host candidate
@@ -252,7 +279,7 @@ internal constructor(
252
279
  monitor.setNetworkChangeDetectorFactory { observer, context ->
253
280
  val wired =
254
281
  wire(observer, context) {
255
- scopedNetworkSupplier()?.let { resolveScopedInterface(context, it) }
282
+ registry.current()?.let { resolveScopedInterface(context, it) }
256
283
  }
257
284
  ScopedNetworkChangeDetector(wired.delegate, wired.scoped, wired.observer)
258
285
  }
@@ -300,9 +327,12 @@ internal constructor(
300
327
  fun mergeScopedNetwork(
301
328
  detected: List<NetworkChangeDetector.NetworkInformation>?,
302
329
  scoped: ScopedInterface?,
330
+ includeInternet: Boolean = false,
303
331
  ): List<NetworkChangeDetector.NetworkInformation> {
304
332
  val hotspot = scoped?.let { toNetworkInformation(it) } ?: return detected.orEmpty()
305
- return listOf(hotspot)
333
+ return if (includeInternet) {
334
+ detected.orEmpty().filter { it.handle != hotspot.handle && it.name != hotspot.name } + hotspot
335
+ } else listOf(hotspot)
306
336
  }
307
337
 
308
338
  /** Dotted-quad for the inventory trace. */
@@ -33,6 +33,7 @@ import org.webrtc.NetworkChangeDetector
33
33
  internal class ScopedNetworkObserver(
34
34
  private val downstream: NetworkChangeDetector.Observer,
35
35
  private val scoped: () -> ScopedNetworkChangeDetector.ScopedInterface?,
36
+ private val includeInternet: () -> Boolean = { false },
36
37
  ) : NetworkChangeDetector.Observer() {
37
38
 
38
39
  private val lock = Any()
@@ -114,17 +115,20 @@ internal class ScopedNetworkObserver(
114
115
  }
115
116
 
116
117
  override fun onConnectionTypeChanged(connectionType: NetworkChangeDetector.ConnectionType) {
117
- if (scoped() != null) {
118
+ if (scoped() != null && !includeInternet()) {
118
119
  downstream.onConnectionTypeChanged(NetworkChangeDetector.ConnectionType.CONNECTION_WIFI)
119
120
  return
120
121
  }
121
- restore()
122
+ if (scoped() == null) restore()
122
123
  downstream.onConnectionTypeChanged(connectionType)
123
124
  }
124
125
 
125
126
  override fun onNetworkConnect(networkInfo: NetworkChangeDetector.NetworkInformation) {
126
127
  if (scoped() != null) {
127
- synchronized(lock) { stock[networkInfo.handle] = networkInfo }
128
+ synchronized(lock) {
129
+ stock[networkInfo.handle] = networkInfo
130
+ if (includeInternet()) publish(ScopedNetworkChangeDetector.mergeScopedNetwork(stock.values.toList(), scoped(), true))
131
+ }
128
132
  SoftApTrace.stage(
129
133
  "webrtc_stock_connect_suppressed",
130
134
  "name" to networkInfo.name,
@@ -141,7 +145,10 @@ internal class ScopedNetworkObserver(
141
145
 
142
146
  override fun onNetworkDisconnect(networkHandle: Long) {
143
147
  if (scoped() != null) {
144
- synchronized(lock) { stock.remove(networkHandle) }
148
+ synchronized(lock) {
149
+ stock.remove(networkHandle)
150
+ if (includeInternet()) publish(ScopedNetworkChangeDetector.mergeScopedNetwork(stock.values.toList(), scoped(), true))
151
+ }
145
152
  return
146
153
  }
147
154
  restore()
@@ -0,0 +1,16 @@
1
+ package com.mentra.glassesmedia.network
2
+
3
+ /** Separate registrations keep a receiver from replacing the call owner's live network supplier. */
4
+ internal class ScopedNetworkRegistry<T : Any> {
5
+ private var acs: () -> T? = { null }
6
+ private var receiver: T? = null
7
+ private var relay: T? = null
8
+
9
+ @Synchronized fun registerAcs(supplier: () -> T?) { acs = supplier }
10
+ @Synchronized fun registerReceiver(network: T) { receiver = network }
11
+ @Synchronized fun releaseReceiver(network: T) { if (receiver === network) receiver = null }
12
+ @Synchronized fun registerRelay(network: T) { relay = network }
13
+ @Synchronized fun releaseRelay(network: T) { if (relay === network) relay = null }
14
+ @Synchronized fun current(): T? = relay ?: acs() ?: receiver
15
+ @Synchronized fun includesInternet(): Boolean = relay != null
16
+ }
@@ -0,0 +1,263 @@
1
+ package com.mentra.glassesmedia.publisher
2
+
3
+ import android.content.Context
4
+ import android.net.ConnectivityManager
5
+ import android.net.NetworkCapabilities
6
+ import android.os.Handler
7
+ import android.os.HandlerThread
8
+ import com.mentra.glassesmedia.source.I420Planes
9
+ import java.io.IOException
10
+ import java.util.concurrent.CountDownLatch
11
+ import java.util.concurrent.TimeUnit
12
+ import java.util.concurrent.atomic.AtomicBoolean
13
+ import okhttp3.Call
14
+ import okhttp3.Callback
15
+ import okhttp3.HttpUrl
16
+ import okhttp3.HttpUrl.Companion.toHttpUrl
17
+ import okhttp3.MediaType.Companion.toMediaType
18
+ import okhttp3.OkHttpClient
19
+ import okhttp3.Request
20
+ import okhttp3.RequestBody.Companion.toRequestBody
21
+ import okhttp3.Response
22
+ import org.webrtc.*
23
+ import org.webrtc.audio.JavaAudioDeviceModule
24
+
25
+ /**
26
+ * ASG WhipStreamingService's full-ICE offer/POST/answer/Location lifecycle, with decoded glasses
27
+ * tracks instead of camera/mic capture. One instance per attempt; the coordinator owns retries.
28
+ */
29
+ class PhoneWhipPublisher(
30
+ private val context: Context,
31
+ private val endpoint: String,
32
+ private val captureAudio: Boolean,
33
+ private val bitrate: Int,
34
+ private val onState: (String, String) -> Unit,
35
+ ) {
36
+ private val thread = HandlerThread("Mentra WHIP uplink").apply { start() }
37
+ private val queue = Handler(thread.looper)
38
+ private val closed = AtomicBoolean(false)
39
+ private val frameBusy = AtomicBoolean(false)
40
+ private val closeDone = CountDownLatch(1)
41
+ @Volatile private var cleanupFailure: Throwable? = null
42
+ private val pcm = RelayPcmBuffer()
43
+ private var factory: PeerConnectionFactory? = null
44
+ private var egl: EglBase? = null
45
+ private var adm: JavaAudioDeviceModule? = null
46
+ private var peer: PeerConnection? = null
47
+ private var videoSource: VideoSource? = null
48
+ private var videoTrack: VideoTrack? = null
49
+ private var audioSource: AudioSource? = null
50
+ private var audioTrack: AudioTrack? = null
51
+ private var client: OkHttpClient? = null
52
+ private var resource: HttpUrl? = null
53
+ private var posted = false
54
+ private var localSet = false
55
+ private var connected = false
56
+ private var failureSent = false
57
+ private var lastTimestamp = 0L
58
+ private val disconnectDeadline = Runnable { fail("Phone internet connection was lost") }
59
+
60
+ fun start() {
61
+ queue.post {
62
+ if (closed.get()) return@post
63
+ try {
64
+ val url = endpoint.toHttpUrl()
65
+ require(url.isHttps) { "Managed WHIP requires HTTPS" }
66
+ val manager = context.getSystemService(ConnectivityManager::class.java)
67
+ val internet = manager.allNetworks.firstOrNull { network ->
68
+ manager.getNetworkCapabilities(network)?.let {
69
+ it.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
70
+ it.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
71
+ } == true
72
+ } ?: error("Phone mobile data is unavailable")
73
+ client = OkHttpClient.Builder().socketFactory(internet.socketFactory)
74
+ .dns(object : okhttp3.Dns { override fun lookup(hostname: String) = internet.getAllByName(hostname).toList() })
75
+ .callTimeout(20, TimeUnit.SECONDS).followRedirects(false).build()
76
+ PeerConnectionFactory.initialize(PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions())
77
+ egl = EglBase.create()
78
+ adm = JavaAudioDeviceModule.builder(context)
79
+ .setInputSampleRate(48_000).setUseStereoInput(false)
80
+ .setUseHardwareAcousticEchoCanceler(false).setUseHardwareNoiseSuppressor(false)
81
+ .setAudioBufferCallback { buffer, _, channels, sampleRate, bytes, _ ->
82
+ if (channels == 1 && sampleRate == 48_000) pcm.read(buffer, bytes)
83
+ else for (i in 0 until minOf(bytes, buffer.capacity())) buffer.put(i, 0)
84
+ System.nanoTime()
85
+ }.createAudioDeviceModule().also {
86
+ // WebRTC's external-buffer mode: paced callbacks without AudioRecord/microphone.
87
+ it.setAudioRecordEnabled(false)
88
+ }
89
+ factory = PeerConnectionFactory.builder()
90
+ .setOptions(PeerConnectionFactory.Options().apply {
91
+ // The receiver keeps Wi-Fi; this peer uses real cellular handles from the inventory.
92
+ networkIgnoreMask = 2 or 8 or 16 // WIFI, VPN, LOOPBACK
93
+ })
94
+ .setAudioDeviceModule(adm)
95
+ .setVideoEncoderFactory(DefaultVideoEncoderFactory(egl!!.eglBaseContext, true, true))
96
+ .setVideoDecoderFactory(DefaultVideoDecoderFactory(egl!!.eglBaseContext))
97
+ .createPeerConnectionFactory()
98
+ val config = PeerConnection.RTCConfiguration(listOf(
99
+ PeerConnection.IceServer.builder("stun:stun.cloudflare.com:3478").createIceServer(),
100
+ )).apply { sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN }
101
+ peer = factory!!.createPeerConnection(config, observer) ?: error("Could not create WHIP peer")
102
+ videoSource = factory!!.createVideoSource(false).also { it.capturerObserver.onCapturerStarted(true) }
103
+ videoTrack = factory!!.createVideoTrack("glasses-video", videoSource)
104
+ val transceiver = peer!!.addTransceiver(videoTrack, RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.SEND_ONLY))
105
+ val codecs = factory!!.getRtpSenderCapabilities(MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO).codecs
106
+ val h264 = codecs.filter { it.name.equals("H264", ignoreCase = true) }
107
+ if (h264.isNotEmpty()) transceiver.setCodecPreferences(h264)
108
+ val parameters = transceiver.sender.parameters
109
+ parameters.encodings.forEach { it.maxBitrateBps = bitrate.coerceIn(250_000, 12_000_000) }
110
+ transceiver.sender.parameters = parameters
111
+ if (captureAudio) {
112
+ val constraints = MediaConstraints().apply {
113
+ for (key in listOf("googEchoCancellation", "googNoiseSuppression", "googAutoGainControl"))
114
+ mandatory.add(MediaConstraints.KeyValuePair(key, "false"))
115
+ }
116
+ audioSource = factory!!.createAudioSource(constraints)
117
+ audioTrack = factory!!.createAudioTrack("glasses-audio", audioSource)
118
+ peer!!.addTransceiver(audioTrack, RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.SEND_ONLY))
119
+ }
120
+ peer!!.createOffer(sdpObserver(onCreate = { offer ->
121
+ peer?.setLocalDescription(sdpObserver(onSet = { localSet = true; maybePost() }), offer)
122
+ }), MediaConstraints())
123
+ queue.postDelayed({ if (!connected && !closed.get()) fail("WHIP connection timed out") }, 35_000)
124
+ } catch (error: Exception) { fail(error.message ?: "WHIP startup failed") }
125
+ }
126
+ }
127
+
128
+ fun onPcm(bytes: ByteArray, rate: Int, channels: Int) {
129
+ if (!closed.get() && captureAudio) pcm.push(bytes, rate, channels)
130
+ }
131
+
132
+ fun onVideoFrame(planes: I420Planes) {
133
+ if (closed.get() || !planes.planesReadable() || !frameBusy.compareAndSet(false, true)) return
134
+ // Copy before returning to the receiver. At most one frame is waiting on the publisher queue.
135
+ val buffer = JavaI420Buffer.allocate(planes.width, planes.height)
136
+ fun copy(src: java.nio.ByteBuffer, srcStride: Int, dst: java.nio.ByteBuffer, dstStride: Int, width: Int, height: Int) {
137
+ val source = src.duplicate()
138
+ val base = source.position()
139
+ val destination = dst.duplicate()
140
+ for (row in 0 until height) {
141
+ source.limit(source.capacity()).position(base + row * srcStride)
142
+ source.limit(base + row * srcStride + width)
143
+ destination.position(row * dstStride)
144
+ destination.put(source)
145
+ }
146
+ }
147
+ try {
148
+ copy(planes.y, planes.strideY, buffer.dataY, buffer.strideY, planes.width, planes.height)
149
+ copy(planes.u, planes.strideU, buffer.dataU, buffer.strideU, (planes.width + 1) / 2, (planes.height + 1) / 2)
150
+ copy(planes.v, planes.strideV, buffer.dataV, buffer.strideV, (planes.width + 1) / 2, (planes.height + 1) / 2)
151
+ if (!queue.post {
152
+ try {
153
+ if (!closed.get()) {
154
+ lastTimestamp = maxOf(System.nanoTime(), lastTimestamp + 1)
155
+ val frame = VideoFrame(buffer, 0, lastTimestamp)
156
+ videoSource?.capturerObserver?.onFrameCaptured(frame)
157
+ }
158
+ } finally { buffer.release(); frameBusy.set(false) }
159
+ }) { buffer.release(); frameBusy.set(false) }
160
+ } catch (_: Exception) { buffer.release(); frameBusy.set(false) }
161
+ }
162
+
163
+ private fun maybePost() {
164
+ val pc = peer ?: return
165
+ if (closed.get() || posted || !localSet || pc.iceGatheringState() != PeerConnection.IceGatheringState.COMPLETE) return
166
+ posted = true
167
+ val http = client ?: return
168
+ val request = Request.Builder().url(endpoint)
169
+ .post(pc.localDescription.description.toRequestBody("application/sdp".toMediaType())).build()
170
+ http.newCall(request).enqueue(object : Callback {
171
+ override fun onFailure(call: Call, error: IOException) { queue.post { fail("WHIP signaling failed") } }
172
+ override fun onResponse(call: Call, response: Response) {
173
+ response.use {
174
+ val location = response.header("Location")?.let { request.url.resolve(it) }
175
+ val answer = response.body?.string().orEmpty()
176
+ // A late successful POST still owns a remote resource and must DELETE it after stop.
177
+ if (closed.get()) { location?.let { delete(http, it) }; return }
178
+ queue.post {
179
+ if (closed.get()) { location?.let { delete(http, it) }; return@post }
180
+ if (response.code != 201 || location == null || !location.isHttps || answer.isBlank()) {
181
+ location?.takeIf { it.isHttps }?.let { delete(http, it) }
182
+ fail("WHIP server rejected publish (HTTP ${response.code})")
183
+ return@post
184
+ }
185
+ resource = location
186
+ peer?.setRemoteDescription(sdpObserver(), SessionDescription(SessionDescription.Type.ANSWER, answer))
187
+ }
188
+ }
189
+ }
190
+ })
191
+ }
192
+
193
+ private fun fail(reason: String) {
194
+ if (closed.get() || failureSent) return
195
+ failureSent = true
196
+ onState("failed", reason)
197
+ }
198
+
199
+ private fun sdpObserver(onCreate: (SessionDescription) -> Unit = {}, onSet: () -> Unit = {}) = object : SdpObserver {
200
+ override fun onCreateSuccess(sdp: SessionDescription) { queue.post { if (!closed.get()) onCreate(sdp) } }
201
+ override fun onSetSuccess() { queue.post { if (!closed.get()) onSet() } }
202
+ override fun onCreateFailure(error: String) { queue.post { fail("WHIP offer failed") } }
203
+ override fun onSetFailure(error: String) { queue.post { fail("WHIP description failed") } }
204
+ }
205
+
206
+ private val observer = object : PeerConnection.Observer {
207
+ override fun onIceConnectionChange(state: PeerConnection.IceConnectionState) { queue.post {
208
+ if (closed.get()) return@post
209
+ when (state) {
210
+ PeerConnection.IceConnectionState.CONNECTED, PeerConnection.IceConnectionState.COMPLETED -> {
211
+ connected = true; queue.removeCallbacks(disconnectDeadline); onState("connected", "Phone publisher connected")
212
+ }
213
+ PeerConnection.IceConnectionState.DISCONNECTED -> { queue.removeCallbacks(disconnectDeadline); queue.postDelayed(disconnectDeadline, 10_000) }
214
+ PeerConnection.IceConnectionState.FAILED -> fail("WHIP ICE failed")
215
+ else -> Unit
216
+ }
217
+ } }
218
+ override fun onIceGatheringChange(state: PeerConnection.IceGatheringState) { queue.post { maybePost() } }
219
+ override fun onSignalingChange(state: PeerConnection.SignalingState) = Unit
220
+ override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit
221
+ override fun onIceCandidate(candidate: IceCandidate) = Unit
222
+ override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>) = Unit
223
+ override fun onAddStream(stream: MediaStream) = Unit
224
+ override fun onRemoveStream(stream: MediaStream) = Unit
225
+ override fun onDataChannel(channel: DataChannel) = Unit
226
+ override fun onRenegotiationNeeded() = Unit
227
+ override fun onAddTrack(receiver: RtpReceiver, streams: Array<out MediaStream>) = Unit
228
+ }
229
+
230
+ /** Called off WebRTC threads. Drains callbacks before disposing factory/ADM/encoder resources. */
231
+ fun close() {
232
+ if (closed.compareAndSet(false, true)) {
233
+ queue.post {
234
+ fun step(action: () -> Unit) {
235
+ try { action() } catch (error: Throwable) { cleanupFailure = cleanupFailure ?: error }
236
+ }
237
+ try {
238
+ step { resource?.let { url -> client?.let { delete(it, url) } }; resource = null }
239
+ step { peer?.close() }
240
+ step { peer?.dispose(); peer = null }
241
+ step { videoSource?.capturerObserver?.onCapturerStopped() }
242
+ step { videoTrack?.dispose() }
243
+ step { videoSource?.dispose() }
244
+ step { audioTrack?.dispose() }
245
+ step { audioSource?.dispose() }
246
+ step { factory?.dispose() }
247
+ step { adm?.release() }
248
+ step { egl?.release() }
249
+ } finally { closeDone.countDown(); thread.quitSafely() }
250
+ }
251
+ }
252
+ check(closeDone.await(20, TimeUnit.SECONDS)) { "Phone publisher cleanup is still pending" }
253
+ cleanupFailure?.let { throw IllegalStateException("Phone publisher cleanup failed", it) }
254
+ }
255
+
256
+ private fun delete(http: OkHttpClient, url: HttpUrl) {
257
+ if (!url.isHttps) return
258
+ http.newCall(Request.Builder().url(url).delete().build()).enqueue(object : Callback {
259
+ override fun onFailure(call: Call, e: IOException) = Unit
260
+ override fun onResponse(call: Call, response: Response) { response.close() }
261
+ })
262
+ }
263
+ }
@@ -0,0 +1,54 @@
1
+ package com.mentra.glassesmedia.publisher
2
+
3
+ import java.nio.ByteBuffer
4
+
5
+ /** Bounded 200ms mono PCM queue. Downmix/resample decoded audio to WebRTC's 48kHz clock. */
6
+ class RelayPcmBuffer(private val capacity: Int = 9_600) {
7
+ private val samples = ShortArray(capacity)
8
+ private var head = 0
9
+ private var count = 0
10
+ private var phase = 0.0
11
+ private var rate = 0
12
+ private var channels = 0
13
+ private var previous: Double? = null
14
+
15
+ @Synchronized fun push(bytes: ByteArray, sampleRate: Int, channelCount: Int) {
16
+ if (sampleRate !in 8_000..192_000 || channelCount !in 1..8) return
17
+ if (rate != sampleRate || channels != channelCount) {
18
+ rate = sampleRate; channels = channelCount; phase = 0.0; previous = null
19
+ }
20
+ val step = sampleRate / 48_000.0
21
+ for (frame in 0 until bytes.size / (2 * channelCount)) {
22
+ var sum = 0.0
23
+ for (channel in 0 until channelCount) {
24
+ val offset = (frame * channelCount + channel) * 2
25
+ sum += ((bytes[offset].toInt() and 255) or (bytes[offset + 1].toInt() shl 8)).toShort()
26
+ }
27
+ val value = sum / channelCount
28
+ val prior = previous
29
+ if (prior != null) {
30
+ while (phase < 1.0) {
31
+ append((prior + (value - prior) * phase).toInt().toShort())
32
+ phase += step
33
+ }
34
+ phase -= 1.0
35
+ }
36
+ previous = value
37
+ }
38
+ }
39
+
40
+ private fun append(value: Short) {
41
+ if (count == capacity) { head = (head + 1) % capacity; count-- }
42
+ samples[(head + count) % capacity] = value
43
+ count++
44
+ }
45
+
46
+ /** Always fills the requested buffer. Underruns are silence, never stale microphone data. */
47
+ @Synchronized fun read(output: ByteBuffer, byteCount: Int) {
48
+ for (offset in 0 until minOf(byteCount, output.capacity()) step 2) {
49
+ val value = if (count > 0) samples[head].also { head = (head + 1) % capacity; count-- } else 0
50
+ output.put(offset, (value.toInt() and 255).toByte())
51
+ if (offset + 1 < output.capacity()) output.put(offset + 1, (value.toInt() shr 8).toByte())
52
+ }
53
+ }
54
+ }
@@ -161,6 +161,18 @@ class LocalWhipIngestSource(
161
161
  server?.let { runCatching { it.stop() } }
162
162
  server = null
163
163
  disposePeer()
164
+ scopedNetwork?.let { ScopedNetworkChangeDetector.releaseReceiverNetwork(it) }
165
+ }
166
+
167
+ /** Terminal teardown for owners that discard this receiver instead of reusing its factory. */
168
+ fun close() {
169
+ generation++
170
+ check(server?.closeAndAwait() != false) { "Local WHIP requests are still draining" }
171
+ stop()
172
+ factory?.dispose()
173
+ factory = null
174
+ egl?.release()
175
+ egl = null
164
176
  }
165
177
 
166
178
  // -----------------------------------------------------------------
@@ -226,9 +238,11 @@ class LocalWhipIngestSource(
226
238
  peer.setRemoteDescription(
227
239
  object : SdpAdapter() {
228
240
  override fun onSetSuccess() {
241
+ if (gen != generation) { answered.countDown(); return }
229
242
  peer.createAnswer(
230
243
  object : SdpAdapter() {
231
244
  override fun onCreateSuccess(sdp: SessionDescription) {
245
+ if (gen != generation) { answered.countDown(); return }
232
246
  peer.setLocalDescription(
233
247
  object : SdpAdapter() {
234
248
  override fun onSetSuccess() = answered.countDown()
@@ -404,10 +418,9 @@ class LocalWhipIngestSource(
404
418
  }
405
419
 
406
420
  private fun ensureFactory() {
421
+ // Standalone receivers can supply a fallback without replacing the owner's live registration.
422
+ scopedNetwork?.let { ScopedNetworkChangeDetector.registerReceiverNetwork(it) }
407
423
  if (factory != null) return
408
- // Normally already installed by the module at creation; repeating it here is idempotent and
409
- // covers a factory built from a code path that bypassed the module (tests, future callers).
410
- scopedNetwork?.let { scoped -> ScopedNetworkChangeDetector.install { scoped } }
411
424
  PeerConnectionFactory.initialize(
412
425
  PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions(),
413
426
  )