@mentra/glasses-media 3.2.1-dev.288 → 3.2.1-dev.291
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 +6 -1
- package/android/src/main/java/com/mentra/glassesmedia/GlassesMediaRelayModule.kt +19 -2
- package/android/src/main/java/com/mentra/glassesmedia/network/InternetHold.kt +15 -0
- package/android/src/main/java/com/mentra/glassesmedia/publisher/PhoneWhipPublisher.kt +4 -5
- package/android/src/main/java/com/mentra/glassesmedia/publisher/RelayAudioInput.kt +33 -0
- package/android/src/main/java/com/mentra/glassesmedia/publisher/RelayPcmInput.kt +25 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestServer.kt +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,15 +37,20 @@ Before merging/releasing, exercise iPhone + glasses: first-use local-network per
|
|
|
37
37
|
`PhoneStreamCoordinator` provisions Cloudflare and owns subscribers for the entire route:
|
|
38
38
|
|
|
39
39
|
```text
|
|
40
|
-
Glasses -- WHIP over SoftAP --> shared receiver
|
|
40
|
+
Glasses -- video-only WHIP over SoftAP --> shared receiver --> phone WHIP publisher --> Cloudflare
|
|
41
|
+
Glasses MCU -- BLE LC3 --> phone PCM decoder ----------------------^
|
|
41
42
|
```
|
|
42
43
|
|
|
43
44
|
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
|
|
|
45
46
|
`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
|
|
|
48
|
+
On Android hosts with microphone pinning and native PCM input, managed streams use the same BLE LC3 capture path as ACS SoftAP calls. An engine-owned `livestream` microphone session requests continuous glasses PCM; it leaves the configured gain in force and disables the call-specific loudness gate. The glasses receive `captureAudio: false`, avoiding SoC microphone capture and audio encoding, while the phone publishes video plus LC3-derived audio to Cloudflare. Attempt-scoped callbacks and native PCM gating reject late audio after stop/retry. Explicit `captureAudio: false` disables audio entirely. iOS and older hosts retain WHIP audio, matching ACS capability gating. ACS's measured 170ms audio delay is specific to its outgoing pipeline and is not applied to the Cloudflare publisher without calibration.
|
|
49
|
+
|
|
47
50
|
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
51
|
|
|
52
|
+
Android temporarily lifts the cellular process pin while creating the local WHIP listener, then restores it before starting the outgoing publisher. Otherwise the listener inherits the cellular socket mark and cannot reply to the glasses. The outgoing audio callback supplies its own PCM frame clock: WebRTC's external-recording mode does not pace callbacks and reports zero captured bytes, so the callback fills the entire buffer and waits for the next frame interval without opening the phone microphone.
|
|
53
|
+
|
|
49
54
|
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
55
|
|
|
51
56
|
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.
|
|
@@ -5,8 +5,11 @@ import android.os.Handler
|
|
|
5
5
|
import android.os.Looper
|
|
6
6
|
import android.os.PowerManager
|
|
7
7
|
import android.provider.Settings
|
|
8
|
+
import android.util.Base64
|
|
9
|
+
import android.util.Log
|
|
8
10
|
import com.mentra.glassesmedia.network.*
|
|
9
11
|
import com.mentra.glassesmedia.publisher.PhoneWhipPublisher
|
|
12
|
+
import com.mentra.glassesmedia.publisher.RelayPcmInput
|
|
10
13
|
import com.mentra.glassesmedia.source.*
|
|
11
14
|
import expo.modules.kotlin.Promise
|
|
12
15
|
import expo.modules.kotlin.modules.Module
|
|
@@ -22,6 +25,7 @@ class GlassesMediaRelayModule : Module() {
|
|
|
22
25
|
private var source: LocalWhipIngestSource? = null
|
|
23
26
|
private var publisher: PhoneWhipPublisher? = null
|
|
24
27
|
private var wakeLock: PowerManager.WakeLock? = null
|
|
28
|
+
private val pcmInput = RelayPcmInput()
|
|
25
29
|
|
|
26
30
|
override fun definition() = ModuleDefinition {
|
|
27
31
|
Name("MentraGlassesMediaRelay")
|
|
@@ -64,14 +68,21 @@ class GlassesMediaRelayModule : Module() {
|
|
|
64
68
|
val outgoing = PhoneWhipPublisher(context, endpoint, options["captureAudio"] != false,
|
|
65
69
|
(options["bitrate"] as? Number)?.toInt() ?: 2_000_000) { state, reason -> emit(id, state, reason) }
|
|
66
70
|
publisher = outgoing
|
|
71
|
+
val bleAudio = options["captureAudio"] != false && options["audioTransport"] == "ble-lc3"
|
|
72
|
+
if (bleAudio) pcmInput.attach(id, outgoing::onPcm)
|
|
73
|
+
Log.i("ManagedRelay", "Audio transport=${options["audioTransport"] ?: "whip"} attempt=$id")
|
|
67
74
|
val incoming = LocalWhipIngestSource(context, VideoFrameListener(outgoing::onVideoFrame),
|
|
68
75
|
PcmListener(outgoing::onPcm), scopedNetwork = scoped)
|
|
69
76
|
source = incoming
|
|
70
77
|
incoming.setStateListener { state, reason ->
|
|
71
78
|
if (state == SourceState.FAILED) emit(id, "failed", "Glasses receiver: $reason")
|
|
72
79
|
}
|
|
73
|
-
incoming.setPcmDeliveryEnabled(options["captureAudio"] != false)
|
|
74
|
-
|
|
80
|
+
incoming.setPcmDeliveryEnabled(options["captureAudio"] != false && !bleAudio)
|
|
81
|
+
// A listener created while pinned to cellular cannot reply to the glasses over Wi-Fi.
|
|
82
|
+
// Restore cellular before opening the outgoing publisher, including when the bind fails.
|
|
83
|
+
hold.withProcessUnpinned {
|
|
84
|
+
incoming.start(SourceConfig("", SourceKind.SOFTAP, scoped.localIpv4()))
|
|
85
|
+
}
|
|
75
86
|
wakeLock = context.getSystemService(PowerManager::class.java)
|
|
76
87
|
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Mentra:managed-relay").apply { acquire() }
|
|
77
88
|
outgoing.start()
|
|
@@ -83,6 +94,11 @@ class GlassesMediaRelayModule : Module() {
|
|
|
83
94
|
}
|
|
84
95
|
}
|
|
85
96
|
|
|
97
|
+
Function("pushOutgoingPcm") { attemptId: String, base64: String, sampleRate: Int, channels: Int ->
|
|
98
|
+
val bytes = try { Base64.decode(base64, Base64.DEFAULT) } catch (_: IllegalArgumentException) { null }
|
|
99
|
+
bytes != null && pcmInput.push(attemptId, bytes, sampleRate, channels)
|
|
100
|
+
}
|
|
101
|
+
|
|
86
102
|
AsyncFunction("stop") { attemptId: String, promise: Promise ->
|
|
87
103
|
worker.execute {
|
|
88
104
|
try {
|
|
@@ -100,6 +116,7 @@ class GlassesMediaRelayModule : Module() {
|
|
|
100
116
|
}
|
|
101
117
|
|
|
102
118
|
private fun cleanup() {
|
|
119
|
+
pcmInput.detach()
|
|
103
120
|
source?.setStateListener(null)
|
|
104
121
|
// Try every cleanup even if one fails. Keep the slot occupied on any failure.
|
|
105
122
|
var failure: Exception? = null
|
|
@@ -239,6 +239,21 @@ class InternetHold(private val context: Context) {
|
|
|
239
239
|
return@synchronized ok
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Create a SoftAP listener without inheriting the cellular socket mark, then restore the pin.
|
|
244
|
+
* Holding the lock prevents a network callback from re-pinning during the bind. The previous
|
|
245
|
+
* routing state is restored even when creating the listener fails.
|
|
246
|
+
*/
|
|
247
|
+
fun <T> withProcessUnpinned(bind: () -> T): T = synchronized(lock) {
|
|
248
|
+
val wasPinned = processPinned
|
|
249
|
+
unbindProcess()
|
|
250
|
+
try {
|
|
251
|
+
bind()
|
|
252
|
+
} finally {
|
|
253
|
+
if (wasPinned) bindProcessToCellular()
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
242
257
|
/** Undo [bindProcessToCellular]. Safe to call when nothing is pinned, and safe to call twice. */
|
|
243
258
|
fun unbindProcess() = synchronized(lock) {
|
|
244
259
|
if (closed) return@synchronized
|
|
@@ -40,6 +40,7 @@ class PhoneWhipPublisher(
|
|
|
40
40
|
private val closeDone = CountDownLatch(1)
|
|
41
41
|
@Volatile private var cleanupFailure: Throwable? = null
|
|
42
42
|
private val pcm = RelayPcmBuffer()
|
|
43
|
+
private val audioInput = RelayAudioInput(pcm)
|
|
43
44
|
private var factory: PeerConnectionFactory? = null
|
|
44
45
|
private var egl: EglBase? = null
|
|
45
46
|
private var adm: JavaAudioDeviceModule? = null
|
|
@@ -78,12 +79,10 @@ class PhoneWhipPublisher(
|
|
|
78
79
|
adm = JavaAudioDeviceModule.builder(context)
|
|
79
80
|
.setInputSampleRate(48_000).setUseStereoInput(false)
|
|
80
81
|
.setUseHardwareAcousticEchoCanceler(false).setUseHardwareNoiseSuppressor(false)
|
|
81
|
-
.setAudioBufferCallback { buffer, _, channels, sampleRate,
|
|
82
|
-
|
|
83
|
-
else for (i in 0 until minOf(bytes, buffer.capacity())) buffer.put(i, 0)
|
|
84
|
-
System.nanoTime()
|
|
82
|
+
.setAudioBufferCallback { buffer, _, channels, sampleRate, _, _ ->
|
|
83
|
+
audioInput.read(buffer, channels, sampleRate)
|
|
85
84
|
}.createAudioDeviceModule().also {
|
|
86
|
-
//
|
|
85
|
+
// External mode has no blocking AudioRecord.read; our callback supplies the clock.
|
|
87
86
|
it.setAudioRecordEnabled(false)
|
|
88
87
|
}
|
|
89
88
|
factory = PeerConnectionFactory.builder()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
package com.mentra.glassesmedia.publisher
|
|
2
|
+
|
|
3
|
+
import java.nio.ByteBuffer
|
|
4
|
+
import java.util.concurrent.TimeUnit
|
|
5
|
+
|
|
6
|
+
/** Supplies the clock normally provided by a blocking AudioRecord.read, without using the mic. */
|
|
7
|
+
class RelayAudioInput(
|
|
8
|
+
private val pcm: RelayPcmBuffer,
|
|
9
|
+
private val nowNs: () -> Long = System::nanoTime,
|
|
10
|
+
private val sleepNs: (Long) -> Unit = { TimeUnit.NANOSECONDS.sleep(it) },
|
|
11
|
+
) {
|
|
12
|
+
private var nextFrameAtNs: Long? = null
|
|
13
|
+
|
|
14
|
+
/** Called only by WebRTC's audio recording thread, with a complete PCM16 frame to fill. */
|
|
15
|
+
fun read(buffer: ByteBuffer, channels: Int, sampleRate: Int): Long {
|
|
16
|
+
require(channels > 0 && sampleRate > 0 && buffer.capacity() >= channels * 2)
|
|
17
|
+
val durationNs = (buffer.capacity() / (channels * 2)).toLong() * 1_000_000_000L / sampleRate
|
|
18
|
+
val deadline = nextFrameAtNs ?: nowNs()
|
|
19
|
+
var now = nowNs()
|
|
20
|
+
while (now < deadline) {
|
|
21
|
+
sleepNs(deadline - now)
|
|
22
|
+
now = nowNs()
|
|
23
|
+
}
|
|
24
|
+
// Preserve cadence across small scheduling delays, but never replay a backlog after a stall.
|
|
25
|
+
nextFrameAtNs = if (now - deadline >= durationNs) now + durationNs else deadline + durationNs
|
|
26
|
+
|
|
27
|
+
// External-recording mode reports bytesRead=0, then sends buffer.capacity() bytes to native.
|
|
28
|
+
// Fill that entire frame; using bytesRead would send only the library's prefilled silence.
|
|
29
|
+
if (channels == 1 && sampleRate == 48_000) pcm.read(buffer, buffer.capacity())
|
|
30
|
+
else for (i in 0 until buffer.capacity()) buffer.put(i, 0)
|
|
31
|
+
return now
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
package com.mentra.glassesmedia.publisher
|
|
2
|
+
|
|
3
|
+
/** Gates JS microphone callbacks by attempt, without queueing audio behind network operations. */
|
|
4
|
+
class RelayPcmInput {
|
|
5
|
+
private var attemptId: String? = null
|
|
6
|
+
private var sink: ((ByteArray, Int, Int) -> Unit)? = null
|
|
7
|
+
|
|
8
|
+
@Synchronized fun attach(id: String, target: (ByteArray, Int, Int) -> Unit) {
|
|
9
|
+
attemptId = id
|
|
10
|
+
sink = target
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@Synchronized fun push(id: String, bytes: ByteArray, sampleRate: Int, channels: Int): Boolean {
|
|
14
|
+
if (id != attemptId || bytes.isEmpty() || sampleRate !in 8_000..192_000 || channels !in 1..8) return false
|
|
15
|
+
val target = sink ?: return false
|
|
16
|
+
target(bytes, sampleRate, channels)
|
|
17
|
+
return true
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Wait for any in-flight push before the owner closes the publisher. */
|
|
21
|
+
@Synchronized fun detach() {
|
|
22
|
+
attemptId = null
|
|
23
|
+
sink = null
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -213,6 +213,7 @@ class WhipIngestServer(
|
|
|
213
213
|
}
|
|
214
214
|
if (!registered) { runCatching { connection.close() }; return }
|
|
215
215
|
accepted.incrementAndGet()
|
|
216
|
+
SoftApTrace.stage("whip_socket_accepted")
|
|
216
217
|
// Each connection on its own thread: a negotiation blocks for the length of an ICE gather,
|
|
217
218
|
// and a DELETE arriving during one must not queue behind it.
|
|
218
219
|
try {
|
|
@@ -371,6 +372,7 @@ class WhipIngestServer(
|
|
|
371
372
|
val (method, target) = WhipIngestProtocol.parseRequestLine(
|
|
372
373
|
reader.readLine() ?: return null,
|
|
373
374
|
) ?: return null
|
|
375
|
+
SoftApTrace.stage("whip_request_line_read", "method" to method)
|
|
374
376
|
|
|
375
377
|
val headers = mutableMapOf<String, String>()
|
|
376
378
|
while (true) {
|
|
@@ -383,6 +385,7 @@ class WhipIngestServer(
|
|
|
383
385
|
|
|
384
386
|
val contentType = WhipIngestProtocol.headerValue(headers, "Content-Type")
|
|
385
387
|
val contentLength = WhipIngestProtocol.headerValue(headers, "Content-Length")?.toLongOrNull()
|
|
388
|
+
SoftApTrace.stage("whip_request_headers_read", "contentLength" to contentLength)
|
|
386
389
|
|
|
387
390
|
// Do not read a body we have already decided to refuse. decide() checks the length first for
|
|
388
391
|
// exactly this reason.
|
|
@@ -398,6 +401,7 @@ class WhipIngestServer(
|
|
|
398
401
|
val count = reader.read(body, read, body.size - read)
|
|
399
402
|
if (count < 0) break
|
|
400
403
|
read += count
|
|
404
|
+
SoftApTrace.stage("whip_request_body_read", "read" to read, "expected" to body.size)
|
|
401
405
|
}
|
|
402
406
|
return WhipIngestProtocol.Request(method, target, contentType, contentLength, String(body, 0, read))
|
|
403
407
|
}
|