@mentra/engine 3.2.0-dev.221 → 3.2.0-dev.223

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.
Files changed (69) hide show
  1. package/build/generated/releaseMetadata.js +5 -5
  2. package/build/generated/releaseMetadata.js.map +1 -1
  3. package/build/index.d.ts +2 -0
  4. package/build/index.d.ts.map +1 -1
  5. package/build/index.js +1 -0
  6. package/build/index.js.map +1 -1
  7. package/build/services/AcsMeetingService.d.ts +360 -10
  8. package/build/services/AcsMeetingService.d.ts.map +1 -1
  9. package/build/services/AcsMeetingService.js +836 -19
  10. package/build/services/AcsMeetingService.js.map +1 -1
  11. package/build/services/AudioPlaybackService.d.ts +6 -0
  12. package/build/services/AudioPlaybackService.d.ts.map +1 -1
  13. package/build/services/AudioPlaybackService.js +4 -3
  14. package/build/services/AudioPlaybackService.js.map +1 -1
  15. package/build/services/GlassesMicProbe.d.ts +78 -0
  16. package/build/services/GlassesMicProbe.d.ts.map +1 -0
  17. package/build/services/GlassesMicProbe.js +258 -0
  18. package/build/services/GlassesMicProbe.js.map +1 -0
  19. package/build/services/LocalMiniappRuntime.d.ts +83 -0
  20. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  21. package/build/services/LocalMiniappRuntime.js +714 -39
  22. package/build/services/LocalMiniappRuntime.js.map +1 -1
  23. package/build/services/MentraJSLogPipeline.d.ts +39 -0
  24. package/build/services/MentraJSLogPipeline.d.ts.map +1 -1
  25. package/build/services/MentraJSLogPipeline.js +59 -3
  26. package/build/services/MentraJSLogPipeline.js.map +1 -1
  27. package/build/services/MentraJSRouter.d.ts +1 -1
  28. package/build/services/MentraJSRouter.d.ts.map +1 -1
  29. package/build/services/MentraJSRouter.js +2 -2
  30. package/build/services/MentraJSRouter.js.map +1 -1
  31. package/build/services/MicStateCoordinator.d.ts +22 -0
  32. package/build/services/MicStateCoordinator.d.ts.map +1 -1
  33. package/build/services/MicStateCoordinator.js +34 -3
  34. package/build/services/MicStateCoordinator.js.map +1 -1
  35. package/build/services/PhoneStreamCoordinator.d.ts +8 -0
  36. package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
  37. package/build/services/PhoneStreamCoordinator.js +2 -0
  38. package/build/services/PhoneStreamCoordinator.js.map +1 -1
  39. package/build/services/SoftapCallTransport.d.ts +365 -0
  40. package/build/services/SoftapCallTransport.d.ts.map +1 -0
  41. package/build/services/SoftapCallTransport.js +722 -0
  42. package/build/services/SoftapCallTransport.js.map +1 -0
  43. package/build/services/SoftapCleanupBarrier.d.ts +45 -0
  44. package/build/services/SoftapCleanupBarrier.d.ts.map +1 -0
  45. package/build/services/SoftapCleanupBarrier.js +51 -0
  46. package/build/services/SoftapCleanupBarrier.js.map +1 -0
  47. package/build/utils/pcm16.d.ts +35 -0
  48. package/build/utils/pcm16.d.ts.map +1 -0
  49. package/build/utils/pcm16.js +85 -0
  50. package/build/utils/pcm16.js.map +1 -0
  51. package/build/utils/softapTrace.d.ts +39 -0
  52. package/build/utils/softapTrace.d.ts.map +1 -0
  53. package/build/utils/softapTrace.js +124 -0
  54. package/build/utils/softapTrace.js.map +1 -0
  55. package/package.json +7 -7
  56. package/src/generated/releaseMetadata.ts +5 -5
  57. package/src/index.ts +2 -0
  58. package/src/services/AcsMeetingService.ts +956 -22
  59. package/src/services/AudioPlaybackService.ts +12 -3
  60. package/src/services/GlassesMicProbe.ts +300 -0
  61. package/src/services/LocalMiniappRuntime.ts +775 -42
  62. package/src/services/MentraJSLogPipeline.ts +70 -3
  63. package/src/services/MentraJSRouter.ts +2 -2
  64. package/src/services/MicStateCoordinator.ts +35 -3
  65. package/src/services/PhoneStreamCoordinator.ts +10 -0
  66. package/src/services/SoftapCallTransport.ts +928 -0
  67. package/src/services/SoftapCleanupBarrier.ts +72 -0
  68. package/src/utils/pcm16.ts +93 -0
  69. package/src/utils/softapTrace.ts +133 -0
@@ -60,6 +60,12 @@ export interface AudioStreamOpenRequest {
60
60
  channels: number
61
61
  volume?: number
62
62
  stopOtherAudio?: boolean
63
+ /**
64
+ * Playout headroom in ms, which is also this stream's floor latency because the native track
65
+ * fills to its buffer and stays there. Omit it for clip playback; a realtime caller passes a
66
+ * small value and accepts the thinner cushion. Android only.
67
+ */
68
+ jitterMs?: number
63
69
  /**
64
70
  * Called when the stream ends for any reason (drained after close, aborted,
65
71
  * interrupted by other audio, or native error).
@@ -659,8 +665,11 @@ class AudioPlaybackService {
659
665
  * following the media route such as A2DP to connected glasses.
660
666
  */
661
667
  public async openStream(request: AudioStreamOpenRequest): Promise<void> {
662
- const {streamId, appId, sampleRate, channels, volume = 1.0, stopOtherAudio = true} = request
663
- console.log(`AUDIO: Stream open ${streamId} from ${appId}: rate=${sampleRate} ch=${channels}`)
668
+ const {streamId, appId, sampleRate, channels, volume = 1.0, stopOtherAudio = true, jitterMs} = request
669
+ console.log(
670
+ `AUDIO: Stream open ${streamId} from ${appId}: rate=${sampleRate} ch=${channels}` +
671
+ (jitterMs === undefined ? "" : ` jitterMs=${jitterMs}`),
672
+ )
664
673
 
665
674
  await this.ensureAudioModeConfigured()
666
675
  await this.prewarmAudioRouteIfCold()
@@ -673,7 +682,7 @@ class AudioPlaybackService {
673
682
  await this.abortAllStreams(streamId)
674
683
  }
675
684
 
676
- await BluetoothSdk.pcmStreamOpen(streamId, sampleRate, channels, Math.max(0, Math.min(1, volume)))
685
+ await BluetoothSdk.pcmStreamOpen(streamId, sampleRate, channels, Math.max(0, Math.min(1, volume)), jitterMs)
677
686
  this.releaseTailUplinkSuppressions()
678
687
 
679
688
  const stream: StreamState = {
@@ -0,0 +1,300 @@
1
+ /**
2
+ * GlassesMicProbe — on-device level meter for the Bluetooth LC3 microphone.
3
+ *
4
+ * Pins the SDK to the glasses microphone exactly the way a Mentra Call does, then reports the
5
+ * decoded PCM level once a second as `[MIC_PROBE]` log lines, so a soak can tell "the glasses
6
+ * mic is analog-quiet" apart from "the uplink dropped it". Optionally keeps an A2DP PCM stream
7
+ * open to the glasses at the same time (silence or a tone), which is the duplex condition a
8
+ * Teams call puts the BES in: far-end audio playing while the LC3 mic is live.
9
+ *
10
+ * Dev tooling. Reachable from `com.mentra://test/mic-probe`.
11
+ */
12
+ import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
13
+
14
+ import audioPlaybackService from "./AudioPlaybackService"
15
+ import micStateCoordinator from "./MicStateCoordinator"
16
+ import {SETTINGS, useSettingsStore} from "../stores/settings"
17
+ import {summarizePcm16} from "../utils/pcm16"
18
+ import {BgTimer} from "../utils/timers"
19
+
20
+ export type MicProbeA2dpMode = "none" | "silence" | "tone"
21
+
22
+ /**
23
+ * Which microphone to meter. `glasses` is the call path (pinned, LC3 over BLE). `phone` is a
24
+ * control: same meter on the phone's own microphone, so a flat glasses trace can be told apart
25
+ * from a quiet room.
26
+ */
27
+ export type MicProbeSource = "glasses" | "phone"
28
+
29
+ export type MicProbeOptions = {
30
+ /** How long to run before stopping on its own. */
31
+ durationMs: number
32
+ /** What to play to the glasses over A2DP while the mic is live. */
33
+ a2dp: MicProbeA2dpMode
34
+ /** Peak amplitude of the tone as a fraction of full scale (0..1). */
35
+ toneLevel?: number
36
+ /** Microphone under test. Defaults to `glasses`. */
37
+ source?: MicProbeSource
38
+ }
39
+
40
+ const A2DP_MODES: MicProbeA2dpMode[] = ["none", "silence", "tone"]
41
+ const SOURCES: MicProbeSource[] = ["glasses", "phone"]
42
+
43
+ /**
44
+ * Parse `com.mentra://test/mic-probe?seconds=&a2dp=&level=` query values.
45
+ *
46
+ * adb/shell often backslash-escapes `&`, so `seconds=15\&a2dp=none` arrives as
47
+ * `seconds=15\`. Digits-only parsing is what keeps that from falling back to 20s.
48
+ */
49
+ export function parseMicProbeParams(params: Record<string, string | undefined | string[]>): MicProbeOptions {
50
+ const raw = (key: string) => {
51
+ const value = params[key]
52
+ return String(Array.isArray(value) ? value[0] : (value ?? ""))
53
+ }
54
+ const seconds = Number.parseInt(raw("seconds").replace(/[^\d]/g, ""), 10)
55
+ const durationMs = (Number.isFinite(seconds) && seconds > 0 ? Math.min(600, seconds) : 20) * 1000
56
+ const a2dpRaw = raw("a2dp")
57
+ const a2dp = (A2DP_MODES.includes(a2dpRaw as MicProbeA2dpMode) ? a2dpRaw : "none") as MicProbeA2dpMode
58
+ const toneLevel = Number.parseFloat(raw("level").replace(/[^\d.]/g, ""))
59
+ const sourceRaw = raw("mic")
60
+ const source = (SOURCES.includes(sourceRaw as MicProbeSource) ? sourceRaw : "glasses") as MicProbeSource
61
+ return {
62
+ durationMs,
63
+ a2dp,
64
+ toneLevel: Number.isFinite(toneLevel) && toneLevel > 0 ? Math.min(1, toneLevel) : 0.2,
65
+ source,
66
+ }
67
+ }
68
+
69
+ export type MicProbeSample = {
70
+ /** Seconds since the probe started. */
71
+ t: number
72
+ /** Mean absolute sample value over the last second (16-bit scale). */
73
+ meanAbs: number
74
+ /** Largest absolute sample over the last second. */
75
+ peak: number
76
+ /** PCM frames received in the last second. */
77
+ frames: number
78
+ /** Microphone source reported by the SDK for the last frame. */
79
+ source: string
80
+ /** Frames dropped because the SDK reported a non-glasses source. */
81
+ nonGlasses: number
82
+ a2dp: MicProbeA2dpMode
83
+ }
84
+
85
+ type Listener = (sample: MicProbeSample) => void
86
+
87
+ const GLASSES = "glasses"
88
+ const A2DP_RATE = 16000
89
+ const A2DP_CHUNK_MS = 60
90
+ const TONE_HZ = 440
91
+
92
+ // Same meter the call uplink logs with, so probe and call numbers are comparable.
93
+ export {pcmDataView, summarizePcm16} from "../utils/pcm16"
94
+
95
+ /** `chunkMs` of 16 kHz mono PCM16, either digital silence or a sine at `level` full scale. */
96
+ export function makeA2dpChunk(mode: Exclude<MicProbeA2dpMode, "none">, chunkMs: number, phase: number, level = 0.2) {
97
+ const samples = Math.round((A2DP_RATE * chunkMs) / 1000)
98
+ const bytes = new Uint8Array(samples * 2)
99
+ const view = new DataView(bytes.buffer)
100
+ const amp = Math.max(0, Math.min(1, level)) * 32767
101
+ const step = (2 * Math.PI * TONE_HZ) / A2DP_RATE
102
+ for (let i = 0; i < samples; i++) {
103
+ const v = mode === "tone" ? Math.round(Math.sin(phase + i * step) * amp) : 0
104
+ view.setInt16(i * 2, v, true)
105
+ }
106
+ return {bytes, phase: (phase + samples * step) % (2 * Math.PI)}
107
+ }
108
+
109
+ function bytesToBase64(bytes: Uint8Array): string {
110
+ let binary = ""
111
+ const step = 0x8000
112
+ for (let i = 0; i < bytes.length; i += step) {
113
+ binary += String.fromCharCode(...bytes.subarray(i, i + step))
114
+ }
115
+ return btoa(binary)
116
+ }
117
+
118
+ class GlassesMicProbe {
119
+ private running = false
120
+ private stopping: Promise<void> | null = null
121
+ /** Bumped on every start/stop so an await that loses the race cannot re-arm the probe. */
122
+ private generation = 0
123
+ private options: MicProbeOptions | null = null
124
+ private startedAt = 0
125
+ private micSub: {remove: () => void} | null = null
126
+ private tick: number | null = null
127
+ private stopTimer: number | null = null
128
+ private a2dpTimer: number | null = null
129
+ private a2dpStreamId: string | null = null
130
+ private a2dpPhase = 0
131
+ private window: unknown[] = []
132
+ private lastSource = ""
133
+ private nonGlasses = 0
134
+ /** `preferred_mic` before a `source=phone` control run changed it. */
135
+ private savedPreferredMic: string | null = null
136
+ private listeners = new Set<Listener>()
137
+ private lastSample: MicProbeSample | null = null
138
+
139
+ isRunning(): boolean {
140
+ return this.running
141
+ }
142
+
143
+ last(): MicProbeSample | null {
144
+ return this.lastSample
145
+ }
146
+
147
+ subscribe(listener: Listener): () => void {
148
+ this.listeners.add(listener)
149
+ return () => this.listeners.delete(listener)
150
+ }
151
+
152
+ async start(options: MicProbeOptions): Promise<void> {
153
+ // A stop still unpinning the mic must finish before we pin again, or its unpin lands on us.
154
+ if (this.running) await this.stop()
155
+ else if (this.stopping) await this.stopping
156
+ const generation = ++this.generation
157
+ this.running = true
158
+ this.options = options
159
+ this.startedAt = Date.now()
160
+ this.window = []
161
+ this.nonGlasses = 0
162
+ this.lastSource = ""
163
+ this.lastSample = null
164
+ const source = options.source ?? GLASSES
165
+ console.log("[MIC_PROBE] start", options)
166
+
167
+ if (source === GLASSES) {
168
+ // Same order as AcsMeetingService.startGlassesMicUplink: pin first so the first frame the
169
+ // requirement produces is already from the glasses and the phone mic is never opened.
170
+ await Promise.resolve(BluetoothSdk.setMicSourcePin?.(GLASSES)).catch((error) => {
171
+ console.warn("[MIC_PROBE] pin failed", error)
172
+ })
173
+ } else {
174
+ // Control run: no pin (only the glasses can be pinned); steer the ranking with the
175
+ // user preference and put it back on stop.
176
+ const settings = useSettingsStore.getState()
177
+ this.savedPreferredMic = settings.getSetting(SETTINGS.preferred_mic.key) ?? "auto"
178
+ await settings.setSetting(SETTINGS.preferred_mic.key, source, false)
179
+ }
180
+ if (this.generation !== generation) return
181
+ micStateCoordinator.setCallRequirement(true)
182
+ this.micSub = BluetoothSdk.addListener("mic_pcm", (event: {pcm?: unknown; source?: string}) => {
183
+ this.lastSource = event.source ?? ""
184
+ if (event.source !== source) {
185
+ this.nonGlasses += 1
186
+ return
187
+ }
188
+ if (event.pcm) this.window.push(event.pcm)
189
+ })
190
+
191
+ if (options.a2dp !== "none") await this.startA2dp(options.a2dp, options.toneLevel ?? 0.2)
192
+ if (this.generation !== generation) return
193
+
194
+ this.tick = BgTimer.setInterval(() => this.report(), 1000)
195
+ this.stopTimer = BgTimer.setTimeout(() => void this.stop(), options.durationMs)
196
+ }
197
+
198
+ async stop(): Promise<void> {
199
+ this.generation += 1
200
+ if (!this.running) return this.stopping ?? undefined
201
+ this.running = false
202
+ if (this.tick !== null) BgTimer.clearInterval(this.tick)
203
+ if (this.stopTimer !== null) BgTimer.clearTimeout(this.stopTimer)
204
+ this.tick = null
205
+ this.stopTimer = null
206
+ this.report()
207
+ this.micSub?.remove()
208
+ this.micSub = null
209
+ micStateCoordinator.setCallRequirement(false)
210
+ this.stopping = (async () => {
211
+ if ((this.options?.source ?? GLASSES) === GLASSES) {
212
+ await Promise.resolve(BluetoothSdk.setMicSourcePin?.(null)).catch((error) => {
213
+ console.warn("[MIC_PROBE] unpin failed", error)
214
+ })
215
+ } else if (this.savedPreferredMic !== null) {
216
+ const restore = this.savedPreferredMic
217
+ this.savedPreferredMic = null
218
+ await useSettingsStore.getState().setSetting(SETTINGS.preferred_mic.key, restore, false)
219
+ }
220
+ await this.stopA2dp()
221
+ console.log("[MIC_PROBE] stop")
222
+ })().finally(() => {
223
+ this.stopping = null
224
+ })
225
+ return this.stopping
226
+ }
227
+
228
+ private report(): void {
229
+ const frames = this.window
230
+ this.window = []
231
+ let stats = {meanAbs: 0, peak: 0}
232
+ try {
233
+ stats = summarizePcm16(frames)
234
+ } catch (error) {
235
+ const first = frames[0]
236
+ console.warn("[MIC_PROBE] pcm summarize failed", {
237
+ frames: frames.length,
238
+ type: first == null ? "empty" : Object.prototype.toString.call(first),
239
+ byteLength: (first as {byteLength?: number} | undefined)?.byteLength,
240
+ error,
241
+ })
242
+ }
243
+ const sample: MicProbeSample = {
244
+ t: Math.round((Date.now() - this.startedAt) / 1000),
245
+ meanAbs: stats.meanAbs,
246
+ peak: stats.peak,
247
+ frames: frames.length,
248
+ source: this.lastSource,
249
+ nonGlasses: this.nonGlasses,
250
+ a2dp: this.options?.a2dp ?? "none",
251
+ }
252
+ this.lastSample = sample
253
+ console.log(
254
+ `[MIC_PROBE] t=${sample.t} meanAbs=${sample.meanAbs} peak=${sample.peak} frames=${frames.length} ` +
255
+ `source=${sample.source || "-"} nonGlasses=${sample.nonGlasses} a2dp=${sample.a2dp}`,
256
+ )
257
+ for (const listener of this.listeners) listener(sample)
258
+ }
259
+
260
+ private async startA2dp(mode: Exclude<MicProbeA2dpMode, "none">, level: number): Promise<void> {
261
+ const streamId = `mic-probe-${Date.now()}`
262
+ await audioPlaybackService.openStream({
263
+ streamId,
264
+ appId: "mic-probe",
265
+ sampleRate: A2DP_RATE,
266
+ channels: 1,
267
+ stopOtherAudio: true,
268
+ jitterMs: 120,
269
+ onEnded: () => {
270
+ if (this.a2dpStreamId === streamId) this.a2dpStreamId = null
271
+ },
272
+ })
273
+ this.a2dpStreamId = streamId
274
+ this.a2dpPhase = 0
275
+ let inFlight = false
276
+ this.a2dpTimer = BgTimer.setInterval(() => {
277
+ if (inFlight || this.a2dpStreamId !== streamId) return
278
+ inFlight = true
279
+ const chunk = makeA2dpChunk(mode, A2DP_CHUNK_MS, this.a2dpPhase, level)
280
+ this.a2dpPhase = chunk.phase
281
+ audioPlaybackService
282
+ .writeStreamChunk(streamId, bytesToBase64(chunk.bytes))
283
+ .catch((error) => console.warn("[MIC_PROBE] a2dp write failed", error))
284
+ .finally(() => {
285
+ inFlight = false
286
+ })
287
+ }, A2DP_CHUNK_MS)
288
+ }
289
+
290
+ private async stopA2dp(): Promise<void> {
291
+ if (this.a2dpTimer !== null) BgTimer.clearInterval(this.a2dpTimer)
292
+ this.a2dpTimer = null
293
+ const streamId = this.a2dpStreamId
294
+ this.a2dpStreamId = null
295
+ if (streamId) await audioPlaybackService.abortStream(streamId).catch(() => undefined)
296
+ }
297
+ }
298
+
299
+ const glassesMicProbe = new GlassesMicProbe()
300
+ export default glassesMicProbe