@mentra/engine 3.2.1-dev.277 → 3.2.1-dev.279

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 +22 -6
  4. package/build/services/AcsMeetingService.d.ts.map +1 -1
  5. package/build/services/AcsMeetingService.js +39 -20
  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 +198 -0
  22. package/build/services/MicSessionManager.js.map +1 -0
  23. package/build/services/MicStateCoordinator.d.ts +48 -5
  24. package/build/services/MicStateCoordinator.d.ts.map +1 -1
  25. package/build/services/MicStateCoordinator.js +91 -10
  26. package/build/services/MicStateCoordinator.js.map +1 -1
  27. package/build/services/micPolicy.d.ts +84 -0
  28. package/build/services/micPolicy.d.ts.map +1 -0
  29. package/build/services/micPolicy.js +82 -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 +45 -20
  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 +255 -0
  49. package/src/services/MicStateCoordinator.ts +91 -10
  50. package/src/services/micPolicy.ts +129 -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
@@ -9,9 +9,10 @@ import {Platform} from "react-native"
9
9
  import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
10
10
 
11
11
  import audioPlaybackService from "./AudioPlaybackService"
12
+ import {getCallGainSweep} from "./CallGainSweep"
12
13
  import micStateCoordinator from "./MicStateCoordinator"
13
14
  import {SETTINGS, useSettingsStore} from "../stores/settings"
14
- import {Pcm16LevelMeter} from "../utils/pcm16"
15
+ import {Pcm16LevelMeter, pcm16WindowStats} from "../utils/pcm16"
15
16
  import {softapTrace, softapTraceFailure, softapTraceId} from "../utils/softapTrace"
16
17
  import {ACS_CALL_MIC, type AcsAudioSource, type ResolvedAudioSource, type SourceReason} from "./acsAudioSource"
17
18
  import type {SoftapProgress} from "./SoftapCallTransport"
@@ -204,8 +205,13 @@ export function glassesLc3UplinkSupported(args: {
204
205
  audioSource: AcsAudioSource
205
206
  hasPushOutgoingPcm: boolean
206
207
  platform: string
208
+ /** Somebody holds a glasses microphone session through MicSessionManager. */
209
+ glassesSession: boolean
207
210
  }): boolean {
208
211
  if (!(softapBleLc3UplinkForTests ?? SOFTAP_BLE_LC3_UPLINK)) return false
212
+ // The microphone belongs to whoever leased it. Without a lease nothing has pinned the glasses
213
+ // or claimed raw PCM, so this call has no wearer audio to read off them.
214
+ if (!args.glassesSession) return false
209
215
  // iOS has no `setMicSourcePin` yet, so it cannot promise the phone microphone stays shut.
210
216
  if (args.platform !== "android") return false
211
217
  // WHEP audio comes back from Cloudflare already mixed into the subscribed track; there is no
@@ -537,6 +543,8 @@ const GLASSES_MIC_SOURCE = "glasses"
537
543
  const GLASSES_MIC_GRACE_MS = 1000
538
544
  /** Cadence of the uplink health line, matching the native P8 ladder. */
539
545
  const MIC_UPLINK_LOG_INTERVAL_MS = 5000
546
+ /** During a gain sweep, one-second windows so each 25 s phase has enough speech samples. */
547
+ const MIC_UPLINK_SWEEP_LOG_INTERVAL_MS = 1000
540
548
  /** A 50 ms LC3 frame arriving more than this late is a missed beat, not jitter. */
541
549
  const MIC_GAP_WARN_MS = 90
542
550
 
@@ -972,6 +980,12 @@ class AcsMeetingService {
972
980
  displayName?: string
973
981
  video?: AcsOutgoingVideo
974
982
  origin?: AcsCallOrigin
983
+ /**
984
+ * Whether the caller holds a glasses microphone session. The uplink is the wearer's voice,
985
+ * so it is only taken off the glasses when somebody has actually claimed that microphone
986
+ * through MicSessionManager; this class never claims it itself.
987
+ */
988
+ glassesSession?: boolean
975
989
  },
976
990
  ): Promise<MeetingState> {
977
991
  const native = getNative()
@@ -998,6 +1012,7 @@ class AcsMeetingService {
998
1012
  audioSource: resolved.source,
999
1013
  hasPushOutgoingPcm: typeof native.pushOutgoingPcm === "function",
1000
1014
  platform: Platform.OS,
1015
+ glassesSession: args.glassesSession ?? false,
1001
1016
  })
1002
1017
  this.micTransport = lc3Uplink ? "ble-lc3" : resolved.source === "phone" ? "phone" : "whip"
1003
1018
  this.bindNative(native, packageName)
@@ -1287,11 +1302,13 @@ class AcsMeetingService {
1287
1302
  /**
1288
1303
  * Start forwarding the glasses microphone into ACS for this call.
1289
1304
  *
1290
- * Order matters and is the whole point: pin the Bluetooth SDK to the glasses *before* asking it
1291
- * for PCM, so the first frame the mic requirement produces is already from the right source and
1292
- * the phone microphone is never opened even for one buffer. `generation` is captured by the
1293
- * listener so a frame that lands after this call ended is dropped rather than pushed at a native
1294
- * session that has left the meeting.
1305
+ * This class is a sink. The microphone is pinned, claimed and tuned by MicSessionManager on
1306
+ * behalf of whoever leased it; all that happens here is reading the PCM that produces. Reaching
1307
+ * past that to the Bluetooth SDK or MicStateCoordinator would put hardware policy back in the
1308
+ * hands of one audio consumer.
1309
+ *
1310
+ * `generation` is captured by the listener so a frame that lands after this call ended is
1311
+ * dropped rather than pushed at a native session that has left the meeting.
1295
1312
  */
1296
1313
  private startGlassesMicUplink(generation: number): void {
1297
1314
  if (this.micTransport !== "ble-lc3") return
@@ -1310,11 +1327,7 @@ class AcsMeetingService {
1310
1327
  this.micLevel.take()
1311
1328
  this.lastMicLevel = null
1312
1329
  try {
1313
- void Promise.resolve(BluetoothSdk.setMicSourcePin?.(GLASSES_MIC_SOURCE)).catch((error) => {
1314
- console.warn("[AcsMeeting] pinning the glasses microphone failed", error)
1315
- })
1316
1330
  this.micUplinkActive = true
1317
- micStateCoordinator.setCallRequirement(true)
1318
1331
  this.micSub = BluetoothSdk.addListener("mic_pcm", (event: {pcm?: ArrayBuffer; sampleRate?: number; source?: string}) => {
1319
1332
  if (generation !== this.callGeneration) {
1320
1333
  this.micDropsStale += 1
@@ -1362,18 +1375,18 @@ class AcsMeetingService {
1362
1375
  }
1363
1376
  }
1364
1377
 
1365
- /** Release the microphone claims this call took. Safe to call when it never took them. */
1378
+ /**
1379
+ * Stop reading the glasses microphone. Safe to call when this call never started.
1380
+ *
1381
+ * Only the subscription is dropped. The microphone itself belongs to whoever leased it through
1382
+ * MicSessionManager, and unsubscribing here before that owner releases is what keeps a normal
1383
+ * hang-up from looking like the wearer's microphone disappearing.
1384
+ */
1366
1385
  private stopGlassesMicUplink(): void {
1367
1386
  this.micSub?.remove()
1368
1387
  this.micSub = null
1369
1388
  if (this.micUplinkActive) {
1370
1389
  this.micUplinkActive = false
1371
- micStateCoordinator.setCallRequirement(false)
1372
- // Last, and unconditionally: while the pin is set no other consumer can pick a microphone,
1373
- // so leaving it behind would leave captions and the cloud uplink stuck on the glasses.
1374
- void Promise.resolve(BluetoothSdk.setMicSourcePin?.(null)).catch((error) => {
1375
- console.warn("[AcsMeeting] releasing the glasses microphone pin failed", error)
1376
- })
1377
1390
  console.log("[AcsMeeting] phase=glasses-mic-uplink-stop", {
1378
1391
  frames: this.micFramesForwarded,
1379
1392
  dropsStale: this.micDropsStale,
@@ -1413,17 +1426,29 @@ class AcsMeetingService {
1413
1426
  private logMicUplink(): void {
1414
1427
  const now = Date.now()
1415
1428
  const elapsed = now - this.lastMicUplinkLogAt
1416
- if (elapsed < MIC_UPLINK_LOG_INTERVAL_MS) return
1429
+ const sweep = getCallGainSweep()
1430
+ const interval = sweep.isActive() ? MIC_UPLINK_SWEEP_LOG_INTERVAL_MS : MIC_UPLINK_LOG_INTERVAL_MS
1431
+ if (elapsed < interval) return
1417
1432
  this.lastMicUplinkLogAt = now
1418
1433
  // Level, not just cadence: a 20 Hz stream of the noise floor and a 20 Hz stream of speech
1419
1434
  // have the same framesPerSecond. Quiet room on Mentra Live LC3 is meanAbs ≈30–60.
1420
1435
  const level = this.micLevel.take()
1436
+ const stats = pcm16WindowStats(level)
1421
1437
  this.lastMicLevel = {meanAbs: level.meanAbs, peak: level.peak}
1438
+ const gain = sweep.currentGain() ?? micStateCoordinator.getSessionMicTuning()?.gain ?? null
1439
+ if (sweep.isActive()) sweep.ingest(level)
1422
1440
  console.log("[AcsMeeting] phase=glasses-mic-uplink", {
1423
1441
  framesPerSecond: Math.round((this.micFramesWindow * 1000) / elapsed),
1424
1442
  frames: this.micFramesForwarded,
1425
- meanAbs: level.meanAbs,
1426
- peak: level.peak,
1443
+ meanAbs: stats.meanAbs,
1444
+ peak: stats.peak,
1445
+ peakPct: stats.peakPct,
1446
+ clipped: stats.clipped,
1447
+ clipPct: stats.clipPct,
1448
+ nearClip: stats.nearClip,
1449
+ nearClipPct: stats.nearClipPct,
1450
+ gain,
1451
+ sweep: sweep.currentLabel(),
1427
1452
  dropsStale: this.micDropsStale,
1428
1453
  dropsNonGlasses: this.micDropsNonGlasses,
1429
1454
  gaps: this.micGaps,
@@ -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)