@mentra/engine 3.2.1-dev.278 → 3.2.1-dev.282

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 (53) hide show
  1. package/build/generated/releaseMetadata.js +5 -5
  2. package/build/generated/releaseMetadata.js.map +1 -1
  3. package/build/services/AcsMeetingService.d.ts +56 -6
  4. package/build/services/AcsMeetingService.d.ts.map +1 -1
  5. package/build/services/AcsMeetingService.js +162 -40
  6. package/build/services/AcsMeetingService.js.map +1 -1
  7. package/build/services/CallGainSweep.d.ts +97 -0
  8. package/build/services/CallGainSweep.d.ts.map +1 -0
  9. package/build/services/CallGainSweep.js +249 -0
  10. package/build/services/CallGainSweep.js.map +1 -0
  11. package/build/services/GlassesMicProbe.d.ts +2 -0
  12. package/build/services/GlassesMicProbe.d.ts.map +1 -1
  13. package/build/services/GlassesMicProbe.js +20 -19
  14. package/build/services/GlassesMicProbe.js.map +1 -1
  15. package/build/services/LocalMiniappRuntime.d.ts +18 -0
  16. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  17. package/build/services/LocalMiniappRuntime.js +131 -1
  18. package/build/services/LocalMiniappRuntime.js.map +1 -1
  19. package/build/services/MicSessionManager.d.ts +83 -0
  20. package/build/services/MicSessionManager.d.ts.map +1 -0
  21. package/build/services/MicSessionManager.js +201 -0
  22. package/build/services/MicSessionManager.js.map +1 -0
  23. package/build/services/MicStateCoordinator.d.ts +71 -5
  24. package/build/services/MicStateCoordinator.d.ts.map +1 -1
  25. package/build/services/MicStateCoordinator.js +161 -10
  26. package/build/services/MicStateCoordinator.js.map +1 -1
  27. package/build/services/micPolicy.d.ts +131 -0
  28. package/build/services/micPolicy.d.ts.map +1 -0
  29. package/build/services/micPolicy.js +140 -0
  30. package/build/services/micPolicy.js.map +1 -0
  31. package/build/stores/bluetoothSettingKeys.d.ts.map +1 -1
  32. package/build/stores/bluetoothSettingKeys.js +3 -0
  33. package/build/stores/bluetoothSettingKeys.js.map +1 -1
  34. package/build/stores/settings.d.ts +5 -0
  35. package/build/stores/settings.d.ts.map +1 -1
  36. package/build/stores/settings.js +62 -0
  37. package/build/stores/settings.js.map +1 -1
  38. package/build/utils/pcm16.d.ts +20 -0
  39. package/build/utils/pcm16.d.ts.map +1 -1
  40. package/build/utils/pcm16.js +32 -1
  41. package/build/utils/pcm16.js.map +1 -1
  42. package/package.json +8 -8
  43. package/src/generated/releaseMetadata.ts +5 -5
  44. package/src/services/AcsMeetingService.ts +167 -40
  45. package/src/services/CallGainSweep.ts +318 -0
  46. package/src/services/GlassesMicProbe.ts +20 -17
  47. package/src/services/LocalMiniappRuntime.ts +152 -3
  48. package/src/services/MicSessionManager.ts +258 -0
  49. package/src/services/MicStateCoordinator.ts +153 -10
  50. package/src/services/micPolicy.ts +198 -0
  51. package/src/stores/bluetoothSettingKeys.ts +3 -0
  52. package/src/stores/settings.ts +66 -0
  53. package/src/utils/pcm16.ts +43 -1
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Timed A/B/A/C gain comparison for a live voice call.
3
+ *
4
+ * Mentra Live's last ADC step is +32 dB (index 15); the step below is +26 dB
5
+ * (14), then 2 dB per index. Close-talk clips at 15 and still rails at 14, so
6
+ * a call has to be measured at 15 → 14 → 15 → 13 on the same voice, not by
7
+ * swapping phones or days.
8
+ *
9
+ * Production policy stays in micPolicy. This object only writes a temporary
10
+ * override and logs speech-gated clip stats so a human can pick the index.
11
+ */
12
+
13
+ import {PCM16_FULL_SCALE, pcm16WindowStats, type Pcm16Level} from "../utils/pcm16"
14
+
15
+ const LOG_TAG = "CALL_GAIN_SWEEP"
16
+
17
+ /** codec_adc_vol[] dB, same table as Super Mode. */
18
+ const GAIN_DB = [-99, 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 32]
19
+
20
+ export const CALL_GAIN_SWEEP_STEPS = [
21
+ {gain: 15, label: "15a"},
22
+ {gain: 14, label: "14"},
23
+ {gain: 15, label: "15b"},
24
+ {gain: 13, label: "13"},
25
+ ] as const
26
+
27
+ export const CALL_GAIN_SWEEP_STEP_MS = 25_000
28
+ /** Quiet room on this path is meanAbs ≈ 90–120. Talk starts well above this. */
29
+ export const CALL_GAIN_SWEEP_SPEECH_MEAN_ABS = 400
30
+
31
+ export type CallGainSweepStep = (typeof CALL_GAIN_SWEEP_STEPS)[number]
32
+
33
+ export type CallGainSweepPhaseSummary = {
34
+ label: string
35
+ gain: number
36
+ db: number
37
+ windows: number
38
+ speechWindows: number
39
+ quietWindows: number
40
+ clippedWindows: number
41
+ nearClipWindows: number
42
+ speechMeanAbs: number
43
+ speechPeakMax: number
44
+ speechPeakPct: number
45
+ speechClipPct: number
46
+ speechNearClipPct: number
47
+ samples: number
48
+ speechSamples: number
49
+ clipped: number
50
+ nearClip: number
51
+ }
52
+
53
+ export type CallGainSweepRecommendation = {
54
+ gain: number
55
+ reason: string
56
+ }
57
+
58
+ export type CallGainSweepClock = {
59
+ now: () => number
60
+ setTimeout: (fn: () => void, ms: number) => unknown
61
+ clearTimeout: (id: unknown) => void
62
+ log: (...args: unknown[]) => void
63
+ }
64
+
65
+ type PhaseBucket = {
66
+ step: CallGainSweepStep
67
+ windows: number
68
+ speechWindows: number
69
+ quietWindows: number
70
+ clippedWindows: number
71
+ nearClipWindows: number
72
+ speechMeanAbsSum: number
73
+ speechPeakMax: number
74
+ samples: number
75
+ speechSamples: number
76
+ clipped: number
77
+ nearClip: number
78
+ speechClipped: number
79
+ speechNearClip: number
80
+ }
81
+
82
+ const defaultClock = (): CallGainSweepClock => ({
83
+ now: () => Date.now(),
84
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
85
+ clearTimeout: (id) => clearTimeout(id as ReturnType<typeof setTimeout>),
86
+ log: (...args) => console.log(...args),
87
+ })
88
+
89
+ export function gainDb(index: number): number {
90
+ return GAIN_DB[index] ?? Number.NaN
91
+ }
92
+
93
+ /**
94
+ * Highest gain whose speech windows stay under the clip lines.
95
+ *
96
+ * Clip is the failure (rail = 32767). Near-clip at 30000 is the warning that
97
+ * the next syllable will hit the rail. 15 is only legal if both 15a and 15b
98
+ * agree; otherwise a loud stretch on one of them was luck.
99
+ */
100
+ export function recommendCallGain(phases: readonly CallGainSweepPhaseSummary[]): CallGainSweepRecommendation {
101
+ const speech = phases.filter((p) => p.speechWindows > 0)
102
+ if (speech.length === 0) {
103
+ return {gain: 13, reason: "no speech windows; stay at 13 until the sweep is talked through"}
104
+ }
105
+
106
+ const fifteen = speech.filter((p) => p.gain === 15)
107
+ const fourteen = speech.find((p) => p.gain === 14)
108
+ const thirteen = speech.find((p) => p.gain === 13)
109
+
110
+ const clean = (p: CallGainSweepPhaseSummary) => p.speechClipPct < 0.5 && p.speechNearClipPct < 5
111
+ const fifteenClean = fifteen.length >= 2 && fifteen.every(clean)
112
+ if (fifteenClean) return {gain: 15, reason: "both 15 phases stayed under 0.5% clip / 5% near-clip"}
113
+ if (fourteen && clean(fourteen)) {
114
+ return {gain: 14, reason: "15 clips; 14 stayed under 0.5% clip / 5% near-clip"}
115
+ }
116
+ if (thirteen && clean(thirteen)) {
117
+ return {gain: 13, reason: "15 and 14 clip; 13 stayed under 0.5% clip / 5% near-clip"}
118
+ }
119
+ return {gain: 13, reason: "every measured step still clipped; 13 is the floor of this sweep"}
120
+ }
121
+
122
+ function emptyBucket(step: CallGainSweepStep): PhaseBucket {
123
+ return {
124
+ step,
125
+ windows: 0,
126
+ speechWindows: 0,
127
+ quietWindows: 0,
128
+ clippedWindows: 0,
129
+ nearClipWindows: 0,
130
+ speechMeanAbsSum: 0,
131
+ speechPeakMax: 0,
132
+ samples: 0,
133
+ speechSamples: 0,
134
+ clipped: 0,
135
+ nearClip: 0,
136
+ speechClipped: 0,
137
+ speechNearClip: 0,
138
+ }
139
+ }
140
+
141
+ function summarizeBucket(bucket: PhaseBucket): CallGainSweepPhaseSummary {
142
+ const speechClipPct =
143
+ bucket.speechSamples > 0 ? Math.round((bucket.speechClipped / bucket.speechSamples) * 1000) / 10 : 0
144
+ const speechNearClipPct =
145
+ bucket.speechSamples > 0 ? Math.round((bucket.speechNearClip / bucket.speechSamples) * 1000) / 10 : 0
146
+ return {
147
+ label: bucket.step.label,
148
+ gain: bucket.step.gain,
149
+ db: gainDb(bucket.step.gain),
150
+ windows: bucket.windows,
151
+ speechWindows: bucket.speechWindows,
152
+ quietWindows: bucket.quietWindows,
153
+ clippedWindows: bucket.clippedWindows,
154
+ nearClipWindows: bucket.nearClipWindows,
155
+ speechMeanAbs:
156
+ bucket.speechWindows > 0 ? Math.round(bucket.speechMeanAbsSum / bucket.speechWindows) : 0,
157
+ speechPeakMax: bucket.speechPeakMax,
158
+ speechPeakPct: Math.round((bucket.speechPeakMax / PCM16_FULL_SCALE) * 1000) / 10,
159
+ speechClipPct,
160
+ speechNearClipPct,
161
+ samples: bucket.samples,
162
+ speechSamples: bucket.speechSamples,
163
+ clipped: bucket.clipped,
164
+ nearClip: bucket.nearClip,
165
+ }
166
+ }
167
+
168
+ export class CallGainSweep {
169
+ private readonly clock: CallGainSweepClock
170
+ private applyGain: ((gain: number) => void) | null = null
171
+ private onDone: (() => void) | null = null
172
+ private index = -1
173
+ private timer: unknown = null
174
+ private enteredAt = 0
175
+ private bucket: PhaseBucket | null = null
176
+ private readonly finished: CallGainSweepPhaseSummary[] = []
177
+
178
+ constructor(clock: CallGainSweepClock = defaultClock()) {
179
+ this.clock = clock
180
+ }
181
+
182
+ isActive(): boolean {
183
+ return this.index >= 0
184
+ }
185
+
186
+ currentGain(): number | null {
187
+ return this.index >= 0 ? CALL_GAIN_SWEEP_STEPS[this.index].gain : null
188
+ }
189
+
190
+ currentLabel(): string | null {
191
+ return this.index >= 0 ? CALL_GAIN_SWEEP_STEPS[this.index].label : null
192
+ }
193
+
194
+ /**
195
+ * Begin the 15 → 14 → 15 → 13 walk.
196
+ * Returns false when a sweep is already mid-phase so a recompute cannot reset the clock.
197
+ */
198
+ start(applyGain: (gain: number) => void, onDone?: () => void): boolean {
199
+ if (this.isActive()) return false
200
+ this.applyGain = applyGain
201
+ this.onDone = onDone ?? null
202
+ this.finished.length = 0
203
+ this.enter(0)
204
+ return true
205
+ }
206
+
207
+ /** Super Mode / explicit re-run. Aborts the current phase if one is live. */
208
+ restart(applyGain: (gain: number) => void, onDone?: () => void): boolean {
209
+ if (this.isActive()) this.stop("restart")
210
+ return this.start(applyGain, onDone)
211
+ }
212
+
213
+ stop(reason: string): void {
214
+ if (!this.isActive() && this.timer == null) return
215
+ this.closePhase("aborted")
216
+ this.clearTimer()
217
+ this.clock.log(`${LOG_TAG} stop`, {reason, phases: this.finished.slice()})
218
+ this.index = -1
219
+ this.bucket = null
220
+ this.applyGain = null
221
+ this.onDone = null
222
+ }
223
+
224
+ ingest(level: Pcm16Level): void {
225
+ if (!this.bucket) return
226
+ const stats = pcm16WindowStats(level)
227
+ const speech = level.meanAbs >= CALL_GAIN_SWEEP_SPEECH_MEAN_ABS
228
+ this.bucket.windows++
229
+ this.bucket.samples += level.samples
230
+ this.bucket.clipped += level.clipped
231
+ this.bucket.nearClip += level.nearClip
232
+ if (speech) {
233
+ this.bucket.speechWindows++
234
+ this.bucket.speechSamples += level.samples
235
+ this.bucket.speechClipped += level.clipped
236
+ this.bucket.speechNearClip += level.nearClip
237
+ this.bucket.speechMeanAbsSum += level.meanAbs
238
+ if (level.peak > this.bucket.speechPeakMax) this.bucket.speechPeakMax = level.peak
239
+ } else {
240
+ this.bucket.quietWindows++
241
+ }
242
+ if (level.clipped > 0) this.bucket.clippedWindows++
243
+ if (level.nearClip > 0) this.bucket.nearClipWindows++
244
+
245
+ this.clock.log(`${LOG_TAG} window`, {
246
+ label: this.bucket.step.label,
247
+ gain: this.bucket.step.gain,
248
+ db: gainDb(this.bucket.step.gain),
249
+ speech,
250
+ meanAbs: stats.meanAbs,
251
+ peak: stats.peak,
252
+ peakPct: stats.peakPct,
253
+ clipped: stats.clipped,
254
+ clipPct: stats.clipPct,
255
+ nearClip: stats.nearClip,
256
+ nearClipPct: stats.nearClipPct,
257
+ samples: stats.samples,
258
+ })
259
+ // RN pauses setTimeout while Mentra is behind Mentra Call. The ACS
260
+ // uplink still delivers 1 s windows, so the hold is measured here.
261
+ if (this.clock.now() - this.enteredAt >= CALL_GAIN_SWEEP_STEP_MS) this.advance()
262
+ }
263
+
264
+ private enter(index: number): void {
265
+ this.index = index
266
+ const step = CALL_GAIN_SWEEP_STEPS[index]
267
+ this.bucket = emptyBucket(step)
268
+ this.enteredAt = this.clock.now()
269
+ this.applyGain?.(step.gain)
270
+ this.clock.log(`${LOG_TAG} phase-start`, {
271
+ label: step.label,
272
+ gain: step.gain,
273
+ db: gainDb(step.gain),
274
+ holdMs: CALL_GAIN_SWEEP_STEP_MS,
275
+ remaining: CALL_GAIN_SWEEP_STEPS.length - index,
276
+ })
277
+ this.timer = this.clock.setTimeout(() => this.advance(), CALL_GAIN_SWEEP_STEP_MS)
278
+ }
279
+
280
+ private advance(): void {
281
+ this.timer = null
282
+ this.closePhase("complete")
283
+ const next = this.index + 1
284
+ if (next >= CALL_GAIN_SWEEP_STEPS.length) {
285
+ const pick = recommendCallGain(this.finished)
286
+ this.clock.log(`${LOG_TAG} done`, {phases: this.finished.slice(), pick})
287
+ this.index = -1
288
+ this.bucket = null
289
+ const done = this.onDone
290
+ this.applyGain = null
291
+ this.onDone = null
292
+ done?.()
293
+ return
294
+ }
295
+ this.enter(next)
296
+ }
297
+
298
+ private closePhase(how: "complete" | "aborted"): void {
299
+ if (!this.bucket || this.bucket.windows === 0) return
300
+ const summary = summarizeBucket(this.bucket)
301
+ this.finished.push(summary)
302
+ this.clock.log(`${LOG_TAG} phase-${how}`, summary)
303
+ this.bucket = null
304
+ }
305
+
306
+ private clearTimer(): void {
307
+ if (this.timer == null) return
308
+ this.clock.clearTimeout(this.timer)
309
+ this.timer = null
310
+ }
311
+ }
312
+
313
+ const sharedSweep = new CallGainSweep()
314
+
315
+ /** Process-wide sweep. MicSessionManager writes it; the ACS uplink meters it. */
316
+ export function getCallGainSweep(): CallGainSweep {
317
+ return sharedSweep
318
+ }
@@ -12,7 +12,7 @@
12
12
  import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
13
13
 
14
14
  import audioPlaybackService from "./AudioPlaybackService"
15
- import micStateCoordinator from "./MicStateCoordinator"
15
+ import micSessionManager, {type MicSession} from "./MicSessionManager"
16
16
  import {SETTINGS, useSettingsStore} from "../stores/settings"
17
17
  import {summarizePcm16} from "../utils/pcm16"
18
18
  import {BgTimer} from "../utils/timers"
@@ -85,6 +85,8 @@ export type MicProbeSample = {
85
85
  type Listener = (sample: MicProbeSample) => void
86
86
 
87
87
  const GLASSES = "glasses"
88
+ /** Lease owner for probe runs. Engine features own their sessions; miniapps cannot claim this. */
89
+ const MIC_PROBE_OWNER = "engine:mic-probe"
88
90
  const A2DP_RATE = 16000
89
91
  const A2DP_CHUNK_MS = 60
90
92
  const TONE_HZ = 440
@@ -133,6 +135,8 @@ class GlassesMicProbe {
133
135
  private nonGlasses = 0
134
136
  /** `preferred_mic` before a `source=phone` control run changed it. */
135
137
  private savedPreferredMic: string | null = null
138
+ /** The probe's microphone lease, which owns the pin and the PCM claim while a run is live. */
139
+ private micSession: MicSession | null = null
136
140
  private listeners = new Set<Listener>()
137
141
  private lastSample: MicProbeSample | null = null
138
142
 
@@ -164,21 +168,22 @@ class GlassesMicProbe {
164
168
  const source = options.source ?? GLASSES
165
169
  console.log("[MIC_PROBE] start", options)
166
170
 
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.
171
+ if (source !== GLASSES) {
172
+ // Control run: the manager cannot pin a phone (only the glasses can be pinned), so steer the
173
+ // ranking with the user preference and put it back on stop. That is probe behaviour, not mic
174
+ // policy, which is why it stays here rather than moving into MicSessionManager.
176
175
  const settings = useSettingsStore.getState()
177
176
  this.savedPreferredMic = settings.getSetting(SETTINGS.preferred_mic.key) ?? "auto"
178
177
  await settings.setSetting(SETTINGS.preferred_mic.key, source, false)
179
178
  }
180
179
  if (this.generation !== generation) return
181
- micStateCoordinator.setCallRequirement(true)
180
+ // `diagnostic` carries no tuning profile on purpose: the probe has to measure the gain users
181
+ // actually get, not one it asked for.
182
+ this.micSession = micSessionManager.acquire({
183
+ owner: MIC_PROBE_OWNER,
184
+ source: source === GLASSES ? "glasses" : "phone",
185
+ useCase: "diagnostic",
186
+ })
182
187
  this.micSub = BluetoothSdk.addListener("mic_pcm", (event: {pcm?: unknown; source?: string}) => {
183
188
  this.lastSource = event.source ?? ""
184
189
  if (event.source !== source) {
@@ -206,13 +211,11 @@ class GlassesMicProbe {
206
211
  this.report()
207
212
  this.micSub?.remove()
208
213
  this.micSub = null
209
- micStateCoordinator.setCallRequirement(false)
214
+ // Releasing the lease is what unpins the glasses and drops the PCM claim.
215
+ this.micSession?.release()
216
+ this.micSession = null
210
217
  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) {
218
+ if ((this.options?.source ?? GLASSES) !== GLASSES && this.savedPreferredMic !== null) {
216
219
  const restore = this.savedPreferredMic
217
220
  this.savedPreferredMic = null
218
221
  await useSettingsStore.getState().setSetting(SETTINGS.preferred_mic.key, restore, false)
@@ -46,6 +46,8 @@ import type {DisplayPayload} from "./LocalDisplayManager"
46
46
  import headingService from "./HeadingService"
47
47
  import localSttFallbackCoordinator from "./LocalSttFallbackCoordinator"
48
48
  import micStateCoordinator from "./MicStateCoordinator"
49
+ import micSessionManager, {MIC_SOURCE_CONFLICT, type MicSession} from "./MicSessionManager"
50
+ import {ENGINE_ONLY_USE_CASES, MIC_USE_CASES, VOICE_CALL_PACKAGES, type MicUseCase} from "./micPolicy"
49
51
  import {BlobStore} from "./BlobStore"
50
52
  import {CloudAudioSubscriptionSync} from "./CloudAudioSubscriptionSync"
51
53
  import {phoneCameraFovCoordinator} from "./PhoneCameraFovCoordinator"
@@ -574,6 +576,14 @@ class LocalMiniappRuntime {
574
576
  */
575
577
  private transcriptionHintsByApp = new Map<string, string[]>()
576
578
 
579
+ /**
580
+ * Microphone sessions taken through session.mic.acquire, by session id.
581
+ *
582
+ * Bookkeeping only, so a release can be matched to its owner; MicSessionManager holds the real
583
+ * leases and resolves them into hardware state.
584
+ */
585
+ private micSessionsByApp = new Map<number, {packageName: string; session: MicSession}>()
586
+
577
587
  /** Ping interval handle. */
578
588
  private pingIntervalId: number | null = null
579
589
  private foregroundProbeTimers: Map<string, number> = new Map()
@@ -980,6 +990,10 @@ class LocalMiniappRuntime {
980
990
  public unregisterApp(packageName: string): void {
981
991
  console.log(`${LOG_TAG}: unregisterApp(${packageName})`)
982
992
  const releasedMicGateOverride = micStateCoordinator.clearMiniappGateOverrides(packageName)
993
+ // Backstop for a miniapp that crashed or was killed mid-call: the microphone profile and the
994
+ // PCM claim must not outlive the app that asked for them.
995
+ this.forgetMicSessions(packageName)
996
+ micSessionManager.releaseOwner(packageName)
983
997
  this.clearForegroundProbe(packageName)
984
998
  this.clearMiniappAuthRefresh(packageName)
985
999
  this.clearMiniappAuthDeliveryRetry(packageName)
@@ -1259,6 +1273,12 @@ class LocalMiniappRuntime {
1259
1273
  case MiniappRequestType.MIC_SET_LOUDNESS_GATE_ENABLED:
1260
1274
  void this.handleMicSetLoudnessGateEnabled(packageName, payload, requestId)
1261
1275
  break
1276
+ case MiniappRequestType.MIC_ACQUIRE:
1277
+ this.handleMicAcquire(packageName, payload, requestId)
1278
+ break
1279
+ case MiniappRequestType.MIC_RELEASE:
1280
+ this.handleMicRelease(packageName, payload, requestId)
1281
+ break
1262
1282
  case MiniappRequestType.PING:
1263
1283
  // SDK should handle this itself; reply PONG just in case
1264
1284
  this.sendToMiniapp(packageName, {type: MiniappResponseType.PONG}, requestId)
@@ -3220,6 +3240,100 @@ class LocalMiniappRuntime {
3220
3240
  }
3221
3241
  }
3222
3242
 
3243
+ /**
3244
+ * session.mic.acquire — take a semantic microphone session.
3245
+ *
3246
+ * The miniapp names a use case; MicSessionManager and micPolicy decide what that means for the
3247
+ * hardware. This handler is only the gate: who is allowed to ask for what.
3248
+ */
3249
+ private handleMicAcquire(packageName: string, payload: Record<string, unknown>, requestId?: string): void {
3250
+ const app = this.connectedApps.get(packageName)
3251
+ const hasMicPermission = app?.installedManifest?.permissions?.some((p) => p.type === "MICROPHONE")
3252
+ if (!hasMicPermission) {
3253
+ logPermissionNotDeclared(packageName, "MICROPHONE", "to acquire a microphone session", `{"type": "MICROPHONE"}`)
3254
+ this.sendResult(packageName, requestId, false, undefined, {
3255
+ code: MiniappErrorCode.PERMISSION_NOT_DECLARED,
3256
+ message: `MICROPHONE permission not declared in miniapp.json. Add {"type": "MICROPHONE"} to the "permissions" array.`,
3257
+ permission: "MICROPHONE",
3258
+ operation: MiniappRequestType.MIC_ACQUIRE,
3259
+ })
3260
+ return
3261
+ }
3262
+
3263
+ const source = payload.source
3264
+ const useCase = payload.useCase
3265
+ if (source !== "glasses" && source !== "phone") {
3266
+ this.sendResult(packageName, requestId, false, undefined, {
3267
+ code: MiniappErrorCode.INVALID_ARGUMENT,
3268
+ message: `source must be "glasses" or "phone"`,
3269
+ })
3270
+ return
3271
+ }
3272
+ if (!MIC_USE_CASES.includes(useCase as MicUseCase)) {
3273
+ this.sendResult(packageName, requestId, false, undefined, {
3274
+ code: MiniappErrorCode.INVALID_ARGUMENT,
3275
+ message: `useCase must be one of ${MIC_USE_CASES.join(", ")}`,
3276
+ })
3277
+ return
3278
+ }
3279
+
3280
+ // A voice call gets a microphone profile tuned for close-talk speech, so which app may ask for
3281
+ // one is a product decision, not a permission a manifest can grant itself.
3282
+ if (useCase === "voice_call" && !VOICE_CALL_PACKAGES.includes(packageName)) {
3283
+ this.sendResult(packageName, requestId, false, undefined, {
3284
+ code: MiniappErrorCode.PERMISSION_DENIED,
3285
+ message: `${packageName} is not allowed to acquire a voice_call microphone session`,
3286
+ })
3287
+ return
3288
+ }
3289
+ // Diagnostic leases exist so the mic probe measures what users actually get. Handing one to a
3290
+ // miniapp would also hand it a glasses lease it could pair with a meeting join.
3291
+ if (ENGINE_ONLY_USE_CASES.includes(useCase as MicUseCase)) {
3292
+ this.sendResult(packageName, requestId, false, undefined, {
3293
+ code: MiniappErrorCode.PERMISSION_DENIED,
3294
+ message: `the ${useCase} microphone use case is reserved for the Mentra App`,
3295
+ })
3296
+ return
3297
+ }
3298
+
3299
+ try {
3300
+ const session = micSessionManager.acquire({owner: packageName, source, useCase: useCase as MicUseCase})
3301
+ this.micSessionsByApp.set(session.id, {packageName, session})
3302
+ console.log(`${LOG_TAG}: mic_acquire #${session.id} ${useCase}/${source} (by ${packageName})`)
3303
+ this.sendResult(packageName, requestId, true, {sessionId: session.id})
3304
+ } catch (err) {
3305
+ const message = err instanceof Error ? err.message : "mic acquire error"
3306
+ console.warn(`${LOG_TAG}: mic_acquire failed for ${packageName}:`, message)
3307
+ this.sendResult(packageName, requestId, false, undefined, {
3308
+ code: message.startsWith(MIC_SOURCE_CONFLICT) ? MiniappErrorCode.MIC_SOURCE_CONFLICT : MiniappErrorCode.INTERNAL,
3309
+ message,
3310
+ })
3311
+ }
3312
+ }
3313
+
3314
+ /** session.mic.acquire(...).release() — drop one session, if the caller owns it. */
3315
+ private handleMicRelease(packageName: string, payload: Record<string, unknown>, requestId?: string): void {
3316
+ const sessionId = payload.sessionId
3317
+ const entry = typeof sessionId === "number" ? this.micSessionsByApp.get(sessionId) : undefined
3318
+ if (!entry || entry.packageName !== packageName) {
3319
+ // Idempotent by design: a miniapp releasing twice, or releasing after unregister already
3320
+ // dropped its sessions, is not an error worth failing a teardown path over.
3321
+ this.sendResult(packageName, requestId, true)
3322
+ return
3323
+ }
3324
+ this.micSessionsByApp.delete(entry.session.id)
3325
+ entry.session.release()
3326
+ console.log(`${LOG_TAG}: mic_release #${entry.session.id} (by ${packageName})`)
3327
+ this.sendResult(packageName, requestId, true)
3328
+ }
3329
+
3330
+ /** Drop the bookkeeping for every session an app owns. The manager is the source of truth. */
3331
+ private forgetMicSessions(packageName: string): void {
3332
+ for (const [id, entry] of this.micSessionsByApp) {
3333
+ if (entry.packageName === packageName) this.micSessionsByApp.delete(id)
3334
+ }
3335
+ }
3336
+
3223
3337
  /**
3224
3338
  * session.system.scanQr — host camera overlay. Must not clear miniapp
3225
3339
  * foreground; the host seam is responsible for presenting a Modal on top.
@@ -3749,6 +3863,18 @@ class LocalMiniappRuntime {
3749
3863
  // asked too late to explain itself, and ACS fails the join outright without the camera.
3750
3864
  if (!(await this.requireOsPermission(packageName, requestId, PermissionFeatures.CAMERA, "camera"))) return
3751
3865
  if (!(await this.requireOsPermission(packageName, requestId, PermissionFeatures.MICROPHONE, "microphone"))) return
3866
+ // The wearer's voice rides a microphone session, not the meeting. An app allowed to make voice
3867
+ // calls must hold one before the sink starts, or it would get a call configured for dictation.
3868
+ // Other joiners are not required to: without a session the BLE LC3 uplink is simply not
3869
+ // selected and the audio rides the WHIP capture path, exactly as it did before.
3870
+ const glassesSession = micSessionManager.hasGlassesSession(packageName)
3871
+ if (VOICE_CALL_PACKAGES.includes(packageName) && !glassesSession) {
3872
+ this.sendResult(packageName, requestId, false, undefined, {
3873
+ code: MiniappErrorCode.MIC_SESSION_REQUIRED,
3874
+ message: "acquire a glasses microphone session before joining a meeting",
3875
+ })
3876
+ return
3877
+ }
3752
3878
  const meetingUrl = typeof payload.meetingUrl === "string" ? payload.meetingUrl : ""
3753
3879
  const token = typeof payload.token === "string" ? payload.token : ""
3754
3880
  const displayName = typeof payload.displayName === "string" ? payload.displayName : undefined
@@ -3799,7 +3925,14 @@ class LocalMiniappRuntime {
3799
3925
  })
3800
3926
  const startedAt = Date.now()
3801
3927
  try {
3802
- const state = await this.joinSoftapMeeting(packageName, {meetingUrl, token, displayName, video, origin})
3928
+ const state = await this.joinSoftapMeeting(packageName, {
3929
+ meetingUrl,
3930
+ token,
3931
+ displayName,
3932
+ video,
3933
+ origin,
3934
+ glassesSession,
3935
+ })
3803
3936
  softapTrace("meeting_join_result", {
3804
3937
  packageName,
3805
3938
  requestId: requestId ?? "none",
@@ -3827,6 +3960,7 @@ class LocalMiniappRuntime {
3827
3960
  videoSource,
3828
3961
  displayName,
3829
3962
  origin,
3963
+ glassesSession,
3830
3964
  ...(video ? {video} : {}),
3831
3965
  })
3832
3966
  this.sendResult(packageName, requestId, true, state)
@@ -3855,7 +3989,14 @@ class LocalMiniappRuntime {
3855
3989
  */
3856
3990
  private async joinSoftapMeeting(
3857
3991
  packageName: string,
3858
- args: {meetingUrl: string; token: string; displayName?: string; video?: AcsOutgoingVideo; origin?: AcsCallOrigin},
3992
+ args: {
3993
+ meetingUrl: string
3994
+ token: string
3995
+ displayName?: string
3996
+ video?: AcsOutgoingVideo
3997
+ origin?: AcsCallOrigin
3998
+ glassesSession: boolean
3999
+ },
3859
4000
  ): Promise<MeetingState> {
3860
4001
  // Reserve before awaiting retirement: a second Start or Cancel must see this request,
3861
4002
  // including while it is waiting for its predecessor's native cleanup.
@@ -4015,7 +4156,14 @@ class LocalMiniappRuntime {
4015
4156
  private async runSoftapAttempt(
4016
4157
  attempt: SoftapAttempt,
4017
4158
  previous: SoftapAttempt | null,
4018
- args: {meetingUrl: string; token: string; displayName?: string; video?: AcsOutgoingVideo; origin?: AcsCallOrigin},
4159
+ args: {
4160
+ meetingUrl: string
4161
+ token: string
4162
+ displayName?: string
4163
+ video?: AcsOutgoingVideo
4164
+ origin?: AcsCallOrigin
4165
+ glassesSession: boolean
4166
+ },
4019
4167
  ): Promise<MeetingState> {
4020
4168
  const packageName = attempt.packageName
4021
4169
  if (previous) {
@@ -4131,6 +4279,7 @@ class LocalMiniappRuntime {
4131
4279
  videoSource: options.videoSource,
4132
4280
  displayName: options.displayName,
4133
4281
  origin: args.origin,
4282
+ glassesSession: args.glassesSession,
4134
4283
  ...(args.video ? {video: args.video} : {}),
4135
4284
  }),
4136
4285
  leaveMeeting: (pkg) => acsMeetingService.leave(pkg),