@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,198 @@
1
+ /**
2
+ * Microphone use-case policy.
3
+ *
4
+ * Applications say what they are doing ("voice_call"); the engine decides what
5
+ * that requires of the hardware. Nothing above this file knows that a voice
6
+ * call means ADC index 14, and no miniapp can ask for a gain directly.
7
+ *
8
+ * Pure: every function here is a function of the live session set. The lease
9
+ * bookkeeping lives in MicSessionManager, the merge with OS preferences lives
10
+ * in MicStateCoordinator, and the wire write lives below that again.
11
+ */
12
+
13
+ /** What an application is doing with the microphone. */
14
+ export type MicUseCase = "voice_call" | "transcription" | "voice_assistant" | "diagnostic"
15
+
16
+ /** Which microphone the audio comes from. */
17
+ export type MicSource = "glasses" | "phone"
18
+
19
+ /** Subset of the `mic_tuning` wire payload. Fields left out keep firmware defaults. */
20
+ export type MicTuningProfile = {
21
+ /** codec_adc_vol index, 0-15. */
22
+ gain?: number
23
+ /** Center-mic RMS to open the gate. */
24
+ open?: number
25
+ /** Center-mic RMS to close it again. */
26
+ close?: number
27
+ /** Open threshold while the speaker is elevated. */
28
+ sp_open?: number
29
+ /** Close threshold while the speaker is elevated. */
30
+ sp_close?: number
31
+ }
32
+
33
+ /** `codec_adc_vol[]`: index to dB. Index 0 is mute and is never offered. */
34
+ export const GAIN_DB = [-99, 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 32]
35
+
36
+ /**
37
+ * Mirrored from `center_mic_vad_get_default_config` and `CODEC_SADC_VOL`.
38
+ *
39
+ * The four RMS numbers are measured *after* the ADC gain, so they are only
40
+ * meaningful next to the gain they were calibrated at, which is index 15.
41
+ */
42
+ export const MIC_TUNING_FIRMWARE_DEFAULTS = {
43
+ gain: 15,
44
+ open: 1350,
45
+ close: 945,
46
+ sp_open: 2900,
47
+ sp_close: 1600,
48
+ } as const
49
+
50
+ /**
51
+ * Re-express the firmware's thresholds at a different gain.
52
+ *
53
+ * The gate compares post-gain RMS, so moving the gain without moving the
54
+ * thresholds silently changes what the gate means: at -8 dB the wearer has to
55
+ * be 2.5x louder to clear a number that was chosen for +32 dB. Scaling both by
56
+ * the same factor keeps the *acoustic* trip point the profile was tuned for,
57
+ * for the wearer and for speaker leak alike.
58
+ */
59
+ export function scaleMicTuningToGain(gain: number): Required<MicTuningProfile> {
60
+ const index = Math.min(GAIN_DB.length - 1, Math.max(1, Math.round(gain)))
61
+ const factor = Math.pow(10, (GAIN_DB[index]! - GAIN_DB[MIC_TUNING_FIRMWARE_DEFAULTS.gain]!) / 20)
62
+ return {
63
+ gain: index,
64
+ open: Math.round(MIC_TUNING_FIRMWARE_DEFAULTS.open * factor),
65
+ close: Math.round(MIC_TUNING_FIRMWARE_DEFAULTS.close * factor),
66
+ sp_open: Math.round(MIC_TUNING_FIRMWARE_DEFAULTS.sp_open * factor),
67
+ sp_close: Math.round(MIC_TUNING_FIRMWARE_DEFAULTS.sp_close * factor),
68
+ }
69
+ }
70
+
71
+ /** A live microphone lease. */
72
+ export type MicSessionSpec = {
73
+ useCase: MicUseCase
74
+ source: MicSource
75
+ }
76
+
77
+ /** What the hardware should do, given every live session. */
78
+ export type ResolvedMicPolicy = {
79
+ /** Someone needs a continuous raw-PCM timeline, which also forces hardware VAD off. */
80
+ rawPcm: boolean
81
+ /** Pin the Bluetooth SDK to the glasses microphone. Only the glasses can be pinned. */
82
+ pinGlasses: boolean
83
+ /** Tuning override for the glasses, or null to leave the OS value in force. */
84
+ micTuning: MicTuningProfile | null
85
+ /** Run the center-mic loudness gate, or null to leave the OS value in force. */
86
+ loudnessGate: boolean | null
87
+ }
88
+
89
+ /** Platform facts the policy cannot infer from the sessions alone. */
90
+ export type MicPlatformCaps = {
91
+ /**
92
+ * Whether this platform can take the wearer's voice off the glasses as raw
93
+ * PCM over BLE LC3. False on iOS, which has no `setMicSourcePin` and never
94
+ * selects the `ble-lc3` uplink.
95
+ */
96
+ glassesPcmUplink: boolean
97
+ }
98
+
99
+ /**
100
+ * Mentra Live ships CODEC_SADC_VOL = 15, the last entry of codec_adc_vol[] and
101
+ * +32 dB. That table steps 2 dB at a time everywhere except the final step,
102
+ * which jumps 6 dB from +26, so the default sits at the ceiling one oversized
103
+ * step above everything else. It suits a wearer dictating to a transcription
104
+ * miniapp across a room; it clips a wearer talking into a Teams call.
105
+ *
106
+ * Index 14 is +26 dB: one step down, and the step that removes the table's
107
+ * anomalous jump. A same-voice sweep showed 15 rails hard (2.5–4.2% clip);
108
+ * 14/13/12 still kiss the rail on syllable tips. 13 is +24 dB, the middle
109
+ * of that band, while we listen for loudness vs residual clip.
110
+ *
111
+ * The thresholds ride along with the gain rather than being stated here, so
112
+ * changing the index above cannot leave the gate calibrated for a level the
113
+ * ADC no longer produces.
114
+ */
115
+ export const MIC_USE_CASE_PROFILES: Record<MicUseCase, MicTuningProfile> = {
116
+ voice_call: scaleMicTuningToGain(13),
117
+ transcription: {},
118
+ voice_assistant: {},
119
+ diagnostic: {},
120
+ }
121
+
122
+ /**
123
+ * Use cases that run the center-mic loudness gate ("Barrier").
124
+ *
125
+ * A voice call is the one place it earns its keep. There is no echo canceller
126
+ * on the LC3 uplink — `MENTRA_LC3_SPEECH_PROCESSING` is 0 and the capture path
127
+ * ignores the playback reference — so without a gate the far end hears itself
128
+ * through the wearer's speaker. Barrier is the safe half of the firmware's two
129
+ * gates: it zeroes a quiet frame but never stops transmitting, where VAD drops
130
+ * the stream outright. Paired with the speaker-elevated thresholds above, that
131
+ * suppresses leak while the far end is talking and costs the wearer nothing.
132
+ */
133
+ export const MIC_USE_CASE_LOUDNESS_GATE: Record<MicUseCase, boolean> = {
134
+ voice_call: true,
135
+ transcription: false,
136
+ voice_assistant: false,
137
+ diagnostic: false,
138
+ }
139
+
140
+ /**
141
+ * Packages allowed to hold a `voice_call` session.
142
+ *
143
+ * Which app may make a voice call is policy, so it lives beside the profiles
144
+ * rather than in the request handler that enforces it.
145
+ */
146
+ export const VOICE_CALL_PACKAGES: readonly string[] = ["com.mentra.call"]
147
+
148
+ /** Every use case, for validating an inbound request. */
149
+ export const MIC_USE_CASES: readonly MicUseCase[] = [
150
+ "voice_call",
151
+ "transcription",
152
+ "voice_assistant",
153
+ "diagnostic",
154
+ ]
155
+
156
+ /** Owners of engine-internal sessions. Miniapps cannot claim these use cases. */
157
+ export const ENGINE_OWNER_PREFIX = "engine:"
158
+
159
+ /** Use cases only engine features may acquire. */
160
+ export const ENGINE_ONLY_USE_CASES: readonly MicUseCase[] = ["diagnostic"]
161
+
162
+ /**
163
+ * Resolve every live session into one hardware state.
164
+ *
165
+ * On a platform without a glasses PCM uplink a glasses session is a lease and
166
+ * nothing more: it satisfies ownership checks so an iOS call can still be
167
+ * modelled the same way, but it claims no PCM, pins nothing, and applies no
168
+ * gain, because the wearer's voice does not reach the call through the BES
169
+ * BLE path there.
170
+ */
171
+ export function resolveMicPolicy(
172
+ sessions: readonly MicSessionSpec[],
173
+ caps: MicPlatformCaps,
174
+ ): ResolvedMicPolicy {
175
+ const effective = caps.glassesPcmUplink ? sessions : sessions.filter((s) => s.source !== "glasses")
176
+
177
+ let pinGlasses = false
178
+ let loudnessGate = false
179
+ let winning: MicTuningProfile | null = null
180
+ for (const session of effective) {
181
+ if (session.source !== "glasses") continue
182
+ pinGlasses = true
183
+ if (MIC_USE_CASE_LOUDNESS_GATE[session.useCase]) loudnessGate = true
184
+ const profile = MIC_USE_CASE_PROFILES[session.useCase]
185
+ if (typeof profile?.gain !== "number") continue
186
+ // Lowest index wins: clipping is the irreversible failure, a slightly
187
+ // quiet assistant is not. The whole profile travels with it, because its
188
+ // thresholds are only meaningful at its own gain.
189
+ if (winning === null || profile.gain < winning.gain!) winning = profile
190
+ }
191
+
192
+ return {
193
+ rawPcm: effective.length > 0,
194
+ pinGlasses,
195
+ micTuning: winning,
196
+ loudnessGate: pinGlasses ? loudnessGate : null,
197
+ }
198
+ }
@@ -7,6 +7,9 @@ export const MENTRA_LIVE_SETTING_KEYS: string[] = [
7
7
  "preferred_mic",
8
8
  "voice_activity_detection_enabled",
9
9
  "loudness_gate_enabled",
10
+ // Effective tuning only. mic_tuning_desired is engine-side state and must
11
+ // never appear here, or a persisted super-mode value would reach the glasses.
12
+ "mic_tuning",
10
13
  "core_token",
11
14
  "auth_email",
12
15
  "button_photo_size",
@@ -464,6 +464,42 @@ export const SETTINGS: Record<string, Setting> = {
464
464
  saveOnServer: true,
465
465
  persist: true,
466
466
  },
467
+ /*
468
+ * Mentra Live mic tuning, split in two on purpose.
469
+ *
470
+ * `mic_tuning_desired` is what the super user set. It persists so that
471
+ * turning super mode off and on again does not lose the calibration, and it
472
+ * stays out of BLUETOOTH_SETTING_KEYS so it can never reach the glasses on
473
+ * its own. It is device-local: this is a hardware calibration aid, not
474
+ * something that should follow an account onto another pair of glasses.
475
+ *
476
+ * `mic_tuning` is the effective value, derived in getBluetoothSettings as
477
+ * `super_mode ? desired : {}`. It is the only one native ever sees. Because
478
+ * it is not persisted and native seeds no default for it, a process that
479
+ * connects before the engine has authorized anything can only send a reset
480
+ * -- the super-mode guarantee is then a property of what native can hold,
481
+ * not of which initialiser happened to run first.
482
+ *
483
+ * `{}` rather than null for "no tuning": the native store drops null writes,
484
+ * so null would leave a previously applied value in place.
485
+ *
486
+ * The empty object is a stable singleton so GlassesSettingsSync's reference
487
+ * diff does not treat every unrelated settings write as a mic_tuning change.
488
+ */
489
+ mic_tuning_desired: {
490
+ key: "mic_tuning_desired",
491
+ defaultValue: () => null,
492
+ writable: true,
493
+ saveOnServer: false,
494
+ persist: true,
495
+ },
496
+ mic_tuning: {
497
+ key: "mic_tuning",
498
+ defaultValue: () => ({}),
499
+ writable: true,
500
+ saveOnServer: false,
501
+ persist: false,
502
+ },
467
503
  always_on_status_bar: {
468
504
  key: "always_on_status_bar",
469
505
  defaultValue: () => false,
@@ -757,6 +793,8 @@ export const BLUETOOTH_SETTING_KEYS: string[] = [
757
793
  SETTINGS.power_saving_mode.key,
758
794
  SETTINGS.voice_activity_detection_enabled.key,
759
795
  SETTINGS.loudness_gate_enabled.key,
796
+ // Effective tuning only; mic_tuning_desired deliberately stays engine-side.
797
+ SETTINGS.mic_tuning.key,
760
798
  SETTINGS.lc3_frame_size.key,
761
799
  SETTINGS.preferred_mic.key,
762
800
  SETTINGS.screen_disabled.key,
@@ -819,6 +857,9 @@ export const PAIRING_IDENTITY_KEYS: string[] = Object.values(SETTINGS)
819
857
  .filter((setting) => setting.nativeAuthoritative)
820
858
  .map((setting) => setting.key)
821
859
 
860
+ /** Stable empty effective tuning. Native reads this as `{"reset":1}`. */
861
+ const EMPTY_MIC_TUNING: Record<string, number> = Object.freeze({})
862
+
822
863
  // const PER_GLASSES_SETTINGS_KEYS: string[] = [SETTINGS.preferred_mic.key]
823
864
 
824
865
  export interface SettingsState {
@@ -834,6 +875,11 @@ export interface SettingsState {
834
875
  loadAllSettings: () => AsyncResult<void, Error>
835
876
  // Utility methods
836
877
  getBluetoothSettings: () => Record<string, any>
878
+ /**
879
+ * The mic tuning native is allowed to send: the desired value while super
880
+ * mode is on, `{}` (meaning "reset to firmware defaults") otherwise.
881
+ */
882
+ getEffectiveMicTuning: () => Record<string, number>
837
883
  resetAllSettingsLocally: () => void
838
884
  }
839
885
 
@@ -1193,10 +1239,30 @@ export const useSettingsStore = create<SettingsState>()(
1193
1239
  if (key === SETTINGS.core_token.key && (typeof value !== "string" || value.trim().length === 0)) {
1194
1240
  continue
1195
1241
  }
1242
+ if (key === SETTINGS.mic_tuning.key) {
1243
+ // Derived here rather than stored, so there is no path that can push
1244
+ // a persisted tuning to the glasses while super mode is off. Every
1245
+ // sync route (change diff, on-connect replay, full seed) reads this.
1246
+ bluetoothSettings[key] = state.getEffectiveMicTuning()
1247
+ continue
1248
+ }
1196
1249
  bluetoothSettings[key] = value
1197
1250
  }
1198
1251
  return bluetoothSettings
1199
1252
  },
1253
+ getEffectiveMicTuning: () => {
1254
+ const state = get()
1255
+ if (!state.getSetting(SETTINGS.super_mode.key)) return EMPTY_MIC_TUNING
1256
+ const desired = state.getSetting(SETTINGS.mic_tuning_desired.key)
1257
+ if (!desired || typeof desired !== "object") return EMPTY_MIC_TUNING
1258
+ const effective: Record<string, number> = {}
1259
+ for (const [key, value] of Object.entries(desired as Record<string, unknown>)) {
1260
+ if (typeof value === "number" && Number.isFinite(value)) {
1261
+ effective[key] = Math.round(value)
1262
+ }
1263
+ }
1264
+ return Object.keys(effective).length === 0 ? EMPTY_MIC_TUNING : effective
1265
+ },
1200
1266
  resetAllSettingsLocally: () => {
1201
1267
  set((_state) => ({
1202
1268
  settings: getDefaultSettings(),
@@ -29,6 +29,11 @@ export function pcmDataView(frame: unknown): DataView | null {
29
29
  return null
30
30
  }
31
31
 
32
+ /** Signed 16-bit full scale. `-32768` abs-counts as 32768 and is also a rail. */
33
+ export const PCM16_FULL_SCALE = 32767
34
+ /** Headroom line: loud speech that is not yet hard-clipped. */
35
+ export const PCM16_NEAR_CLIP = 30000
36
+
32
37
  export type Pcm16Level = {
33
38
  /** Mean absolute sample value (16-bit scale). ~30–60 is a quiet room on Mentra Live LC3. */
34
39
  meanAbs: number
@@ -36,6 +41,31 @@ export type Pcm16Level = {
36
41
  peak: number
37
42
  /** Samples counted. */
38
43
  samples: number
44
+ /** Samples at the int16 rail (`>= 32767`). */
45
+ clipped: number
46
+ /** Samples at or above [PCM16_NEAR_CLIP]. */
47
+ nearClip: number
48
+ }
49
+
50
+ export type Pcm16WindowStats = Pcm16Level & {
51
+ /** `peak / 32767`, percent. */
52
+ peakPct: number
53
+ /** `clipped / samples`, percent. */
54
+ clipPct: number
55
+ /** `nearClip / samples`, percent. */
56
+ nearClipPct: number
57
+ }
58
+
59
+ /** Percentages for a closed window. Zero samples → all percents 0. */
60
+ export function pcm16WindowStats(level: Pcm16Level): Pcm16WindowStats {
61
+ const denom = level.samples || 1
62
+ const pct = (count: number) => Math.round((count / denom) * 1000) / 10
63
+ return {
64
+ ...level,
65
+ peakPct: Math.round((level.peak / PCM16_FULL_SCALE) * 1000) / 10,
66
+ clipPct: level.samples ? pct(level.clipped) : 0,
67
+ nearClipPct: level.samples ? pct(level.nearClip) : 0,
68
+ }
39
69
  }
40
70
 
41
71
  /** Level statistics over a batch of PCM16 frames. Unreadable frames are skipped, never thrown on. */
@@ -43,6 +73,8 @@ export function summarizePcm16(frames: unknown[]): Pcm16Level {
43
73
  let sum = 0
44
74
  let peak = 0
45
75
  let samples = 0
76
+ let clipped = 0
77
+ let nearClip = 0
46
78
  for (const frame of frames) {
47
79
  const view = pcmDataView(frame)
48
80
  if (!view) continue
@@ -51,10 +83,12 @@ export function summarizePcm16(frames: unknown[]): Pcm16Level {
51
83
  const v = Math.abs(view.getInt16(i * 2, true))
52
84
  sum += v
53
85
  if (v > peak) peak = v
86
+ if (v >= PCM16_FULL_SCALE) clipped++
87
+ if (v >= PCM16_NEAR_CLIP) nearClip++
54
88
  }
55
89
  samples += n
56
90
  }
57
- return {meanAbs: samples ? Math.round(sum / samples) : 0, peak, samples}
91
+ return {meanAbs: samples ? Math.round(sum / samples) : 0, peak, samples, clipped, nearClip}
58
92
  }
59
93
 
60
94
  /**
@@ -65,6 +99,8 @@ export class Pcm16LevelMeter {
65
99
  private sum = 0
66
100
  private peakValue = 0
67
101
  private count = 0
102
+ private clipped = 0
103
+ private nearClip = 0
68
104
 
69
105
  add(frame: unknown): void {
70
106
  const view = pcmDataView(frame)
@@ -74,6 +110,8 @@ export class Pcm16LevelMeter {
74
110
  const v = Math.abs(view.getInt16(i * 2, true))
75
111
  this.sum += v
76
112
  if (v > this.peakValue) this.peakValue = v
113
+ if (v >= PCM16_FULL_SCALE) this.clipped++
114
+ if (v >= PCM16_NEAR_CLIP) this.nearClip++
77
115
  }
78
116
  this.count += n
79
117
  }
@@ -84,10 +122,14 @@ export class Pcm16LevelMeter {
84
122
  meanAbs: this.count ? Math.round(this.sum / this.count) : 0,
85
123
  peak: this.peakValue,
86
124
  samples: this.count,
125
+ clipped: this.clipped,
126
+ nearClip: this.nearClip,
87
127
  }
88
128
  this.sum = 0
89
129
  this.peakValue = 0
90
130
  this.count = 0
131
+ this.clipped = 0
132
+ this.nearClip = 0
91
133
  return level
92
134
  }
93
135
  }