@mentra/engine 3.2.1-dev.287 → 3.2.1-dev.289

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.
@@ -1,6 +1,7 @@
1
1
  import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
2
2
  import type {StreamStartRequest, StreamStatusEvent} from "@mentra/bluetooth-sdk/internal"
3
3
  import {acquireGlassesHotspot} from "./GlassesHotspotLease"
4
+ import {pcmToBase64} from "../utils/pcmToBase64"
4
5
 
5
6
  export interface RelayOptions {
6
7
  streamId: string
@@ -24,9 +25,11 @@ interface NativeRelay {
24
25
  password: string
25
26
  gatewayAddress?: string
26
27
  captureAudio: boolean
28
+ audioTransport: "whip" | "ble-lc3" | "none"
27
29
  bitrate: number
28
30
  }): Promise<string>
29
31
  stop(attemptId: string): Promise<void>
32
+ pushOutgoingPcm?(attemptId: string, base64: string, sampleRate: number, channels: number): boolean
30
33
  addListener(event: "onRelayState", listener: (event: NativeRelayEvent) => void): {remove(): void}
31
34
  }
32
35
 
@@ -40,6 +43,11 @@ export interface RelayDependencies {
40
43
  sleep(ms: number): Promise<void>
41
44
  now(): number
42
45
  acquire(): () => void
46
+ /** Present only on hosts that can pin and decode the glasses' BLE microphone, as ACS does. */
47
+ microphone?: {
48
+ acquire(): () => void
49
+ subscribe(listener: (event: {pcm?: ArrayBuffer; sampleRate?: number; source?: string}) => void): {remove(): void}
50
+ }
43
51
  }
44
52
 
45
53
  export interface ManagedRelay {
@@ -50,7 +58,7 @@ export interface ManagedRelay {
50
58
  handleGlassesStatus(event: StreamStatusEvent): void
51
59
  }
52
60
 
53
- /** One attempt at a time; media stays native. All retries unwind both peers and the hotspot. */
61
+ /** One attempt at a time; video stays native. All retries unwind both peers, mic and hotspot. */
54
62
  export class ManagedWebRtcRelay implements ManagedRelay {
55
63
  private cancelled = false
56
64
  private started = false
@@ -67,6 +75,8 @@ export class ManagedWebRtcRelay implements ManagedRelay {
67
75
  private retries = 0
68
76
  private readyAt: number | null = null
69
77
  private stopping: Promise<void> | null = null
78
+ private releaseMic: (() => void) | null = null
79
+ private micListener: {remove(): void} | null = null
70
80
 
71
81
  constructor(
72
82
  private readonly options: RelayOptions,
@@ -114,6 +124,10 @@ export class ManagedWebRtcRelay implements ManagedRelay {
114
124
  this.attemptId = `${this.options.streamId}-relay-${++this.attempt}`
115
125
  this.attemptError = null
116
126
  this.checkpoint()
127
+ const captureAudio = this.options.captureAudio !== false
128
+ const lc3 = captureAudio && !!this.deps.microphone && typeof this.deps.native.pushOutgoingPcm === "function"
129
+ // Claim through the same policy/ownership layer as ACS before turning off WHIP audio.
130
+ if (lc3) this.releaseMic = this.deps.microphone!.acquire()
117
131
  this.hotspotTouched = true // A timed-out BLE command may still have enabled the AP.
118
132
  const hotspot = await this.deps.hotspot(true)
119
133
  this.checkpoint()
@@ -128,10 +142,35 @@ export class ManagedWebRtcRelay implements ManagedRelay {
128
142
  ssid: hotspot.ssid,
129
143
  password: hotspot.password,
130
144
  gatewayAddress: hotspot.localIp,
131
- captureAudio: this.options.captureAudio !== false,
145
+ captureAudio,
146
+ audioTransport: lc3 ? "ble-lc3" : captureAudio ? "whip" : "none",
132
147
  bitrate: this.options.video?.bitrate ?? 2_000_000,
133
148
  })
134
149
  this.checkpoint()
150
+ if (lc3) {
151
+ const id = this.attemptId
152
+ let received = false
153
+ this.micListener = this.deps.microphone!.subscribe((event) => {
154
+ if (this.cancelled || this.attemptId !== id || this.attemptError) return
155
+ if (event.source !== "glasses") {
156
+ this.failed(new Error("Managed stream lost the glasses microphone"))
157
+ return
158
+ }
159
+ if (!event.pcm?.byteLength) return
160
+ try {
161
+ const accepted = this.deps.native.pushOutgoingPcm!(id, pcmToBase64(event.pcm), event.sampleRate ?? 16000, 1)
162
+ if (accepted && !received) {
163
+ received = true
164
+ console.log("[ManagedRelay] First BLE LC3 audio frame", {
165
+ attemptId: id,
166
+ sampleRate: event.sampleRate ?? 16000,
167
+ })
168
+ }
169
+ } catch (error) {
170
+ this.failed(asError(error))
171
+ }
172
+ })
173
+ }
135
174
  this.glassesTouched = true
136
175
  const result = await this.deps.startGlasses({
137
176
  type: "start_stream",
@@ -139,7 +178,7 @@ export class ManagedWebRtcRelay implements ManagedRelay {
139
178
  streamUrl: url,
140
179
  ice: {stun: ""},
141
180
  sound: this.options.sound ?? true,
142
- captureAudio: this.options.captureAudio !== false,
181
+ captureAudio: captureAudio && !lc3,
143
182
  ...(this.options.video !== undefined ? {video: this.options.video} : {}),
144
183
  ...(this.options.audio !== undefined ? {audio: this.options.audio} : {}),
145
184
  })
@@ -223,6 +262,12 @@ export class ManagedWebRtcRelay implements ManagedRelay {
223
262
  // Invalidate callbacks before intentionally stopping the glasses or dropping the network.
224
263
  const id = this.attemptId
225
264
  this.attemptId = null
265
+ await step(async () => {
266
+ this.micListener?.remove()
267
+ this.micListener = null
268
+ this.releaseMic?.()
269
+ this.releaseMic = null
270
+ })
226
271
  if (this.glassesTouched)
227
272
  await step(async () => {
228
273
  if (this.deps.connected()) await this.deps.stopGlasses()
@@ -264,6 +309,8 @@ export function createManagedWebRtcRelay(
264
309
  const {requireNativeModule} = require("expo-modules-core") as typeof import("expo-modules-core")
265
310
  const {BgTimer} = require("../utils/timers") as typeof import("../utils/timers")
266
311
  const native = requireNativeModule<NativeRelay>("MentraGlassesMediaRelay")
312
+ const {Platform} = require("react-native") as typeof import("react-native")
313
+ const {default: micSessionManager} = require("./MicSessionManager") as typeof import("./MicSessionManager")
267
314
  return new ManagedWebRtcRelay(options, onStatus, onFailure, {
268
315
  native,
269
316
  hotspot: (enabled) => BluetoothSdk.setHotspotState(enabled),
@@ -274,5 +321,18 @@ export function createManagedWebRtcRelay(
274
321
  now: Date.now,
275
322
  sleep: (ms) => new Promise((resolve) => BgTimer.setTimeout(resolve, ms)),
276
323
  acquire: acquireGlassesHotspot,
324
+ ...(Platform.OS === "android" && typeof BluetoothSdk.setMicSourcePin === "function"
325
+ ? {
326
+ microphone: {
327
+ acquire: () =>
328
+ micSessionManager.acquire({
329
+ owner: `engine:managed-relay:${options.streamId}`,
330
+ source: "glasses",
331
+ useCase: "livestream",
332
+ }).release,
333
+ subscribe: (listener) => BluetoothSdk.addListener("mic_pcm", listener),
334
+ },
335
+ }
336
+ : {}),
277
337
  })
278
338
  }
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  /** What an application is doing with the microphone. */
14
- export type MicUseCase = "voice_call" | "transcription" | "voice_assistant" | "diagnostic"
14
+ export type MicUseCase = "voice_call" | "transcription" | "voice_assistant" | "diagnostic" | "livestream"
15
15
 
16
16
  /** Which microphone the audio comes from. */
17
17
  export type MicSource = "glasses" | "phone"
@@ -117,6 +117,7 @@ export const MIC_USE_CASE_PROFILES: Record<MicUseCase, MicTuningProfile> = {
117
117
  transcription: {},
118
118
  voice_assistant: {},
119
119
  diagnostic: {},
120
+ livestream: {}, // Preserve the configured capture gain; there is no call return audio.
120
121
  }
121
122
 
122
123
  /**
@@ -135,6 +136,7 @@ export const MIC_USE_CASE_LOUDNESS_GATE: Record<MicUseCase, boolean> = {
135
136
  transcription: false,
136
137
  voice_assistant: false,
137
138
  diagnostic: false,
139
+ livestream: false,
138
140
  }
139
141
 
140
142
  /**
@@ -151,13 +153,14 @@ export const MIC_USE_CASES: readonly MicUseCase[] = [
151
153
  "transcription",
152
154
  "voice_assistant",
153
155
  "diagnostic",
156
+ "livestream",
154
157
  ]
155
158
 
156
159
  /** Owners of engine-internal sessions. Miniapps cannot claim these use cases. */
157
160
  export const ENGINE_OWNER_PREFIX = "engine:"
158
161
 
159
162
  /** Use cases only engine features may acquire. */
160
- export const ENGINE_ONLY_USE_CASES: readonly MicUseCase[] = ["diagnostic"]
163
+ export const ENGINE_ONLY_USE_CASES: readonly MicUseCase[] = ["diagnostic", "livestream"]
161
164
 
162
165
  /**
163
166
  * Resolve every live session into one hardware state.
@@ -0,0 +1,10 @@
1
+ /** Encode decoded LC3 PCM for native sinks. Hermes has btoa, but no Node Buffer. */
2
+ export function pcmToBase64(pcm: ArrayBuffer): string {
3
+ const bytes = new Uint8Array(pcm)
4
+ let binary = ""
5
+ const step = 0x8000
6
+ for (let i = 0; i < bytes.length; i += step) {
7
+ binary += String.fromCharCode(...bytes.subarray(i, i + step))
8
+ }
9
+ return btoa(binary)
10
+ }