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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"micPolicy.js","sourceRoot":"","sources":["../../src/services/micPolicy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAwCH;;;;;;;;;;;;;;;GAeG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAyC;IACzE,UAAU,EAAE,EAAC,IAAI,EAAE,EAAE,EAAC;IACtB,aAAa,EAAE,EAAE;IACjB,eAAe,EAAE,EAAE;IACnB,UAAU,EAAE,EAAE;CACf,CAAA;AAED;;;;;GAKG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAsB,CAAC,iBAAiB,CAAC,CAAA;AAEzE,yDAAyD;AACzD,MAAM,CAAC,MAAM,aAAa,GAA0B;IAClD,YAAY;IACZ,eAAe;IACf,iBAAiB;IACjB,YAAY;CACb,CAAA;AAED,iFAAiF;AACjF,MAAM,CAAC,MAAM,mBAAmB,GAAG,SAAS,CAAA;AAE5C,kDAAkD;AAClD,MAAM,CAAC,MAAM,qBAAqB,GAA0B,CAAC,YAAY,CAAC,CAAA;AAE1E;;;;;;;;GAQG;AACH,MAAM,UAAU,gBAAgB,CAC9B,QAAmC,EACnC,IAAqB;IAErB,MAAM,SAAS,GAAG,IAAI,CAAC,gBAAgB,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,KAAK,SAAS,CAAC,CAAA;IAEnG,IAAI,UAAU,GAAG,KAAK,CAAA;IACtB,IAAI,IAAwB,CAAA;IAC5B,KAAK,MAAM,OAAO,IAAI,SAAS,EAAE,CAAC;QAChC,IAAI,OAAO,CAAC,MAAM,KAAK,SAAS;YAAE,SAAQ;QAC1C,UAAU,GAAG,IAAI,CAAA;QACjB,MAAM,WAAW,GAAG,qBAAqB,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,IAAI,CAAA;QAChE,sEAAsE;QACtE,0BAA0B;QAC1B,IAAI,OAAO,WAAW,KAAK,QAAQ,IAAI,CAAC,IAAI,KAAK,SAAS,IAAI,WAAW,GAAG,IAAI,CAAC,EAAE,CAAC;YAClF,IAAI,GAAG,WAAW,CAAA;QACpB,CAAC;IACH,CAAC;IAED,OAAO;QACL,MAAM,EAAE,SAAS,CAAC,MAAM,GAAG,CAAC;QAC5B,UAAU;QACV,SAAS,EAAE,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAC,IAAI,EAAC;KAC9C,CAAA;AACH,CAAC","sourcesContent":["/**\n * Microphone use-case policy.\n *\n * Applications say what they are doing (\"voice_call\"); the engine decides what\n * that requires of the hardware. Nothing above this file knows that a voice\n * call means ADC index 14, and no miniapp can ask for a gain directly.\n *\n * Pure: every function here is a function of the live session set. The lease\n * bookkeeping lives in MicSessionManager, the merge with OS preferences lives\n * in MicStateCoordinator, and the wire write lives below that again.\n */\n\n/** What an application is doing with the microphone. */\nexport type MicUseCase = \"voice_call\" | \"transcription\" | \"voice_assistant\" | \"diagnostic\"\n\n/** Which microphone the audio comes from. */\nexport type MicSource = \"glasses\" | \"phone\"\n\n/** Subset of the `mic_tuning` wire payload. Fields left out keep firmware defaults. */\nexport type MicTuningProfile = {\n /** codec_adc_vol index, 0-15. */\n gain?: number\n}\n\n/** A live microphone lease. */\nexport type MicSessionSpec = {\n useCase: MicUseCase\n source: MicSource\n}\n\n/** What the hardware should do, given every live session. */\nexport type ResolvedMicPolicy = {\n /** Someone needs a continuous raw-PCM timeline, which also forces hardware VAD off. */\n rawPcm: boolean\n /** Pin the Bluetooth SDK to the glasses microphone. Only the glasses can be pinned. */\n pinGlasses: boolean\n /** Tuning override for the glasses, or null to leave the OS value in force. */\n micTuning: MicTuningProfile | null\n}\n\n/** Platform facts the policy cannot infer from the sessions alone. */\nexport type MicPlatformCaps = {\n /**\n * Whether this platform can take the wearer's voice off the glasses as raw\n * PCM over BLE LC3. False on iOS, which has no `setMicSourcePin` and never\n * selects the `ble-lc3` uplink.\n */\n glassesPcmUplink: boolean\n}\n\n/**\n * Mentra Live ships CODEC_SADC_VOL = 15, the last entry of codec_adc_vol[] and\n * +32 dB. That table steps 2 dB at a time everywhere except the final step,\n * which jumps 6 dB from +26, so the default sits at the ceiling one oversized\n * step above everything else. It suits a wearer dictating to a transcription\n * miniapp across a room; it clips a wearer talking into a Teams call.\n *\n * Gain is the only knob here on purpose. A call forces hardware VAD off and\n * the Barrier is off by default, which puts the firmware gate in its `pass`\n * branch, so the RMS thresholds cannot affect a call.\n *\n * Index 14 is +26 dB: one step down, and the step that removes the table's\n * anomalous jump. A same-voice sweep showed 15 rails hard (2.5–4.2% clip);\n * 14/13/12 still kiss the rail on syllable tips. 13 is +24 dB, the middle\n * of that band, while we listen for loudness vs residual clip.\n */\nexport const MIC_USE_CASE_PROFILES: Record<MicUseCase, MicTuningProfile> = {\n voice_call: {gain: 13},\n transcription: {},\n voice_assistant: {},\n diagnostic: {},\n}\n\n/**\n * Packages allowed to hold a `voice_call` session.\n *\n * Which app may make a voice call is policy, so it lives beside the profiles\n * rather than in the request handler that enforces it.\n */\nexport const VOICE_CALL_PACKAGES: readonly string[] = [\"com.mentra.call\"]\n\n/** Every use case, for validating an inbound request. */\nexport const MIC_USE_CASES: readonly MicUseCase[] = [\n \"voice_call\",\n \"transcription\",\n \"voice_assistant\",\n \"diagnostic\",\n]\n\n/** Owners of engine-internal sessions. Miniapps cannot claim these use cases. */\nexport const ENGINE_OWNER_PREFIX = \"engine:\"\n\n/** Use cases only engine features may acquire. */\nexport const ENGINE_ONLY_USE_CASES: readonly MicUseCase[] = [\"diagnostic\"]\n\n/**\n * Resolve every live session into one hardware state.\n *\n * On a platform without a glasses PCM uplink a glasses session is a lease and\n * nothing more: it satisfies ownership checks so an iOS call can still be\n * modelled the same way, but it claims no PCM, pins nothing, and applies no\n * gain, because the wearer's voice does not reach the call through the BES\n * BLE path there.\n */\nexport function resolveMicPolicy(\n sessions: readonly MicSessionSpec[],\n caps: MicPlatformCaps,\n): ResolvedMicPolicy {\n const effective = caps.glassesPcmUplink ? sessions : sessions.filter((s) => s.source !== \"glasses\")\n\n let pinGlasses = false\n let gain: number | undefined\n for (const session of effective) {\n if (session.source !== \"glasses\") continue\n pinGlasses = true\n const profileGain = MIC_USE_CASE_PROFILES[session.useCase]?.gain\n // Lowest index wins: clipping is the irreversible failure, a slightly\n // quiet assistant is not.\n if (typeof profileGain === \"number\" && (gain === undefined || profileGain < gain)) {\n gain = profileGain\n }\n }\n\n return {\n rawPcm: effective.length > 0,\n pinGlasses,\n micTuning: gain === undefined ? null : {gain},\n }\n}\n"]}
1
+ {"version":3,"file":"micPolicy.js","sourceRoot":"","sources":["../../src/services/micPolicy.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Microphone use-case policy.\n *\n * Applications say what they are doing (\"voice_call\"); the engine decides what\n * that requires of the hardware. Nothing above this file knows that a voice\n * call means ADC index 14, and no miniapp can ask for a gain directly.\n *\n * Pure: every function here is a function of the live session set. The lease\n * bookkeeping lives in MicSessionManager, the merge with OS preferences lives\n * in MicStateCoordinator, and the wire write lives below that again.\n */\n\n/** What an application is doing with the microphone. */\nexport type MicUseCase = \"voice_call\" | \"transcription\" | \"voice_assistant\" | \"diagnostic\"\n\n/** Which microphone the audio comes from. */\nexport type MicSource = \"glasses\" | \"phone\"\n\n/** Subset of the `mic_tuning` wire payload. Fields left out keep firmware defaults. */\nexport type MicTuningProfile = {\n /** codec_adc_vol index, 0-15. */\n gain?: number\n /** Center-mic RMS to open the gate. */\n open?: number\n /** Center-mic RMS to close it again. */\n close?: number\n /** Open threshold while the speaker is elevated. */\n sp_open?: number\n /** Close threshold while the speaker is elevated. */\n sp_close?: number\n}\n\n/** `codec_adc_vol[]`: index to dB. Index 0 is mute and is never offered. */\nexport const GAIN_DB = [-99, 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 32]\n\n/**\n * Mirrored from `center_mic_vad_get_default_config` and `CODEC_SADC_VOL`.\n *\n * The four RMS numbers are measured *after* the ADC gain, so they are only\n * meaningful next to the gain they were calibrated at, which is index 15.\n */\nexport const MIC_TUNING_FIRMWARE_DEFAULTS = {\n gain: 15,\n open: 1350,\n close: 945,\n sp_open: 2900,\n sp_close: 1600,\n} as const\n\n/**\n * Re-express the firmware's thresholds at a different gain.\n *\n * The gate compares post-gain RMS, so moving the gain without moving the\n * thresholds silently changes what the gate means: at -8 dB the wearer has to\n * be 2.5x louder to clear a number that was chosen for +32 dB. Scaling both by\n * the same factor keeps the *acoustic* trip point the profile was tuned for,\n * for the wearer and for speaker leak alike.\n */\nexport function scaleMicTuningToGain(gain: number): Required<MicTuningProfile> {\n const index = Math.min(GAIN_DB.length - 1, Math.max(1, Math.round(gain)))\n const factor = Math.pow(10, (GAIN_DB[index]! - GAIN_DB[MIC_TUNING_FIRMWARE_DEFAULTS.gain]!) / 20)\n return {\n gain: index,\n open: Math.round(MIC_TUNING_FIRMWARE_DEFAULTS.open * factor),\n close: Math.round(MIC_TUNING_FIRMWARE_DEFAULTS.close * factor),\n sp_open: Math.round(MIC_TUNING_FIRMWARE_DEFAULTS.sp_open * factor),\n sp_close: Math.round(MIC_TUNING_FIRMWARE_DEFAULTS.sp_close * factor),\n }\n}\n\n/** A live microphone lease. */\nexport type MicSessionSpec = {\n useCase: MicUseCase\n source: MicSource\n}\n\n/** What the hardware should do, given every live session. */\nexport type ResolvedMicPolicy = {\n /** Someone needs a continuous raw-PCM timeline, which also forces hardware VAD off. */\n rawPcm: boolean\n /** Pin the Bluetooth SDK to the glasses microphone. Only the glasses can be pinned. */\n pinGlasses: boolean\n /** Tuning override for the glasses, or null to leave the OS value in force. */\n micTuning: MicTuningProfile | null\n /** Run the center-mic loudness gate, or null to leave the OS value in force. */\n loudnessGate: boolean | null\n}\n\n/** Platform facts the policy cannot infer from the sessions alone. */\nexport type MicPlatformCaps = {\n /**\n * Whether this platform can take the wearer's voice off the glasses as raw\n * PCM over BLE LC3. False on iOS, which has no `setMicSourcePin` and never\n * selects the `ble-lc3` uplink.\n */\n glassesPcmUplink: boolean\n}\n\n/**\n * Mentra Live ships CODEC_SADC_VOL = 15, the last entry of codec_adc_vol[] and\n * +32 dB. That table steps 2 dB at a time everywhere except the final step,\n * which jumps 6 dB from +26, so the default sits at the ceiling one oversized\n * step above everything else. It suits a wearer dictating to a transcription\n * miniapp across a room; it clips a wearer talking into a Teams call.\n *\n * Index 14 is +26 dB: one step down, and the step that removes the table's\n * anomalous jump. A same-voice sweep showed 15 rails hard (2.5–4.2% clip);\n * 14/13/12 still kiss the rail on syllable tips. 13 is +24 dB, the middle\n * of that band, while we listen for loudness vs residual clip.\n *\n * The thresholds ride along with the gain rather than being stated here, so\n * changing the index above cannot leave the gate calibrated for a level the\n * ADC no longer produces.\n */\nexport const MIC_USE_CASE_PROFILES: Record<MicUseCase, MicTuningProfile> = {\n voice_call: scaleMicTuningToGain(13),\n transcription: {},\n voice_assistant: {},\n diagnostic: {},\n}\n\n/**\n * Use cases that run the center-mic loudness gate (\"Barrier\").\n *\n * A voice call is the one place it earns its keep. There is no echo canceller\n * on the LC3 uplink — `MENTRA_LC3_SPEECH_PROCESSING` is 0 and the capture path\n * ignores the playback reference — so without a gate the far end hears itself\n * through the wearer's speaker. Barrier is the safe half of the firmware's two\n * gates: it zeroes a quiet frame but never stops transmitting, where VAD drops\n * the stream outright. Paired with the speaker-elevated thresholds above, that\n * suppresses leak while the far end is talking and costs the wearer nothing.\n */\nexport const MIC_USE_CASE_LOUDNESS_GATE: Record<MicUseCase, boolean> = {\n voice_call: true,\n transcription: false,\n voice_assistant: false,\n diagnostic: false,\n}\n\n/**\n * Packages allowed to hold a `voice_call` session.\n *\n * Which app may make a voice call is policy, so it lives beside the profiles\n * rather than in the request handler that enforces it.\n */\nexport const VOICE_CALL_PACKAGES: readonly string[] = [\"com.mentra.call\"]\n\n/** Every use case, for validating an inbound request. */\nexport const MIC_USE_CASES: readonly MicUseCase[] = [\n \"voice_call\",\n \"transcription\",\n \"voice_assistant\",\n \"diagnostic\",\n]\n\n/** Owners of engine-internal sessions. Miniapps cannot claim these use cases. */\nexport const ENGINE_OWNER_PREFIX = \"engine:\"\n\n/** Use cases only engine features may acquire. */\nexport const ENGINE_ONLY_USE_CASES: readonly MicUseCase[] = [\"diagnostic\"]\n\n/**\n * Resolve every live session into one hardware state.\n *\n * On a platform without a glasses PCM uplink a glasses session is a lease and\n * nothing more: it satisfies ownership checks so an iOS call can still be\n * modelled the same way, but it claims no PCM, pins nothing, and applies no\n * gain, because the wearer's voice does not reach the call through the BES\n * BLE path there.\n */\nexport function resolveMicPolicy(\n sessions: readonly MicSessionSpec[],\n caps: MicPlatformCaps,\n): ResolvedMicPolicy {\n const effective = caps.glassesPcmUplink ? sessions : sessions.filter((s) => s.source !== \"glasses\")\n\n let pinGlasses = false\n let loudnessGate = false\n let winning: MicTuningProfile | null = null\n for (const session of effective) {\n if (session.source !== \"glasses\") continue\n pinGlasses = true\n if (MIC_USE_CASE_LOUDNESS_GATE[session.useCase]) loudnessGate = true\n const profile = MIC_USE_CASE_PROFILES[session.useCase]\n if (typeof profile?.gain !== \"number\") continue\n // Lowest index wins: clipping is the irreversible failure, a slightly\n // quiet assistant is not. The whole profile travels with it, because its\n // thresholds are only meaningful at its own gain.\n if (winning === null || profile.gain < winning.gain!) winning = profile\n }\n\n return {\n rawPcm: effective.length > 0,\n pinGlasses,\n micTuning: winning,\n loudnessGate: pinGlasses ? loudnessGate : null,\n }\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/engine",
3
- "version": "3.2.1-dev.279",
3
+ "version": "3.2.1-dev.283",
4
4
  "description": "Mentra Engine — miniapp registry, WebView bridge, and miniapp store",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -97,13 +97,13 @@
97
97
  "registry": "https://registry.npmjs.org/"
98
98
  },
99
99
  "dependencies": {
100
- "@mentra/acs-meeting": "3.2.1-dev.279",
101
- "@mentra/glasses-media": "3.2.1-dev.279",
102
- "@mentra/bluetooth-sdk": "3.2.1-dev.279",
103
- "@mentra/cloud-client": "3.2.1-dev.279",
104
- "@mentra/cloud-protocol": "3.2.1-dev.279",
105
- "@mentra/crust": "3.2.1-dev.279",
106
- "@mentra/miniapp": "3.2.1-dev.279",
100
+ "@mentra/acs-meeting": "3.2.1-dev.283",
101
+ "@mentra/glasses-media": "3.2.1-dev.283",
102
+ "@mentra/bluetooth-sdk": "3.2.1-dev.283",
103
+ "@mentra/cloud-client": "3.2.1-dev.283",
104
+ "@mentra/cloud-protocol": "3.2.1-dev.283",
105
+ "@mentra/crust": "3.2.1-dev.283",
106
+ "@mentra/miniapp": "3.2.1-dev.283",
107
107
  "buffer": "^6.0.3",
108
108
  "events": "^3.3.0",
109
109
  "react-native-marked": "8.1.1",
@@ -12,9 +12,9 @@ export interface EngineReleaseMetadata {
12
12
  export const ENGINE_RELEASE_METADATA: Readonly<EngineReleaseMetadata> = Object.freeze({
13
13
  "schemaVersion": 1,
14
14
  "familyBaseVersion": "3.2.1",
15
- "releaseIdentity": "3.2.1-dev.279",
16
- "releaseSetId": "mentra-3.2.1-dev.279",
17
- "sourceCommit": "b89b07413c06947f1a2f4a321ce23927eaebd4e4",
18
- "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-dev.279.json",
19
- "otaManifestSha256": "725cccaec20e39be8f75dbe1e511092aa4812ab46cade44a583843282f0c0eb3"
15
+ "releaseIdentity": "3.2.1-dev.283",
16
+ "releaseSetId": "mentra-3.2.1-dev.283",
17
+ "sourceCommit": "da43fa96393855e1a19dedc923bc496c315d2da7",
18
+ "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.2.1/mentra-live-ota-3.2.1-dev.283.json",
19
+ "otaManifestSha256": "8a0b32bd78c4f7b58b67d9eddd854b70f6a417d698a52d38b1f5ead81910e068"
20
20
  })
@@ -11,6 +11,8 @@ import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
11
11
  import audioPlaybackService from "./AudioPlaybackService"
12
12
  import {getCallGainSweep} from "./CallGainSweep"
13
13
  import micStateCoordinator from "./MicStateCoordinator"
14
+ import micSessionManager, {type MicSession} from "./MicSessionManager"
15
+ import {ENGINE_OWNER_PREFIX} from "./micPolicy"
14
16
  import {SETTINGS, useSettingsStore} from "../stores/settings"
15
17
  import {Pcm16LevelMeter, pcm16WindowStats} from "../utils/pcm16"
16
18
  import {softapTrace, softapTraceFailure, softapTraceId} from "../utils/softapTrace"
@@ -620,6 +622,17 @@ class AcsMeetingService {
620
622
  private callOrigin: AcsCallOrigin = "unknown"
621
623
  private micTransport: MicTransport = "whip"
622
624
  private micSub: {remove: () => void} | null = null
625
+ /** Backstop lease, held only while this call is actually reading the glasses mic. */
626
+ private micSession: MicSession | null = null
627
+ private micTuningSub: {remove: () => void} | null = null
628
+ private micRmsSub: {remove: () => void} | null = null
629
+ /** Only turn telemetry off again if this call is what turned it on. */
630
+ private micRmsEnabled = false
631
+ private gateSamples = 0
632
+ private gateClosed = 0
633
+ private gateElevated = 0
634
+ private gateClosedElevated = 0
635
+ private gateRmsMax = 0
623
636
  /** True between the pin/requirement being taken and released, so release is exactly once. */
624
637
  private micUplinkActive = false
625
638
  private micFramesForwarded = 0
@@ -1302,10 +1315,12 @@ class AcsMeetingService {
1302
1315
  /**
1303
1316
  * Start forwarding the glasses microphone into ACS for this call.
1304
1317
  *
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.
1318
+ * This class is a sink: it never names a gain, a pin or a setting. It does take a semantic
1319
+ * voice_call lease first, because reading the glasses mic without one is what put this call on
1320
+ * the OS default of VAD-on a speech gate that drops the wearer's uplink whenever the GX8002
1321
+ * disagrees, which during a call is most of the time the far end is talking. The lease is a
1322
+ * backstop: when the miniapp already holds one this simply merges with it, and micPolicy still
1323
+ * decides what voice_call means for the hardware.
1309
1324
  *
1310
1325
  * `generation` is captured by the listener so a frame that lands after this call ended is
1311
1326
  * dropped rather than pushed at a native session that has left the meeting.
@@ -1327,6 +1342,8 @@ class AcsMeetingService {
1327
1342
  this.micLevel.take()
1328
1343
  this.lastMicLevel = null
1329
1344
  try {
1345
+ this.acquireMicSession()
1346
+ this.startMicGateTelemetry()
1330
1347
  this.micUplinkActive = true
1331
1348
  this.micSub = BluetoothSdk.addListener("mic_pcm", (event: {pcm?: ArrayBuffer; sampleRate?: number; source?: string}) => {
1332
1349
  if (generation !== this.callGeneration) {
@@ -1378,13 +1395,14 @@ class AcsMeetingService {
1378
1395
  /**
1379
1396
  * Stop reading the glasses microphone. Safe to call when this call never started.
1380
1397
  *
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.
1398
+ * Only this call's own backstop lease is dropped. A lease the miniapp took is its to release,
1399
+ * and letting go of ours before that owner releases is what keeps a normal hang-up from looking
1400
+ * like the wearer's microphone disappearing.
1384
1401
  */
1385
1402
  private stopGlassesMicUplink(): void {
1386
1403
  this.micSub?.remove()
1387
1404
  this.micSub = null
1405
+ this.releaseMicSession()
1388
1406
  if (this.micUplinkActive) {
1389
1407
  this.micUplinkActive = false
1390
1408
  console.log("[AcsMeeting] phase=glasses-mic-uplink-stop", {
@@ -1393,11 +1411,113 @@ class AcsMeetingService {
1393
1411
  dropsNonGlasses: this.micDropsNonGlasses,
1394
1412
  gaps: this.micGaps,
1395
1413
  gapMsMax: this.micGapMsMax,
1414
+ ...this.gateSummary(),
1396
1415
  })
1397
1416
  }
1417
+ this.stopMicGateTelemetry()
1398
1418
  this.micTransport = "whip"
1399
1419
  }
1400
1420
 
1421
+ /**
1422
+ * Watch what the glasses are actually running for the length of the call.
1423
+ *
1424
+ * Two different questions, both unanswerable from this side otherwise. `mic_tuning_state` is
1425
+ * the post-clamp reply, so it catches a profile the firmware rewrote rather than accepted.
1426
+ * `mic_rms` carries the Barrier's own verdict per frame, which is the only way to tell a gate
1427
+ * that is suppressing speaker leak from one that is suppressing the wearer.
1428
+ */
1429
+ private startMicGateTelemetry(): void {
1430
+ this.gateSamples = 0
1431
+ this.gateClosed = 0
1432
+ this.gateElevated = 0
1433
+ this.gateClosedElevated = 0
1434
+ this.gateRmsMax = 0
1435
+
1436
+ try {
1437
+ this.micTuningSub = BluetoothSdk.addListener("mic_tuning_state", (event: Record<string, unknown>) => {
1438
+ console.log("[AcsMeeting] phase=glasses-mic-tuning-applied", event)
1439
+ })
1440
+ this.micRmsSub = BluetoothSdk.addListener(
1441
+ "mic_rms",
1442
+ (event: {rms?: number; gateOpen?: boolean; speakerElevated?: boolean}) => {
1443
+ this.gateSamples += 1
1444
+ if (event.gateOpen === false) this.gateClosed += 1
1445
+ if (event.speakerElevated) {
1446
+ this.gateElevated += 1
1447
+ if (event.gateOpen === false) this.gateClosedElevated += 1
1448
+ }
1449
+ if (typeof event.rms === "number" && event.rms > this.gateRmsMax) this.gateRmsMax = event.rms
1450
+ },
1451
+ )
1452
+ void Promise.resolve(BluetoothSdk.setMicRmsTelemetry?.(true))
1453
+ .then(() => {
1454
+ this.micRmsEnabled = true
1455
+ })
1456
+ .catch((error) => console.warn("[AcsMeeting] mic RMS telemetry unavailable", error))
1457
+ void Promise.resolve(BluetoothSdk.requestMicTuningState?.()).catch(() => {})
1458
+ } catch (error) {
1459
+ console.warn("[AcsMeeting] mic gate telemetry unavailable", error)
1460
+ }
1461
+ }
1462
+
1463
+ private stopMicGateTelemetry(): void {
1464
+ this.micTuningSub?.remove()
1465
+ this.micTuningSub = null
1466
+ this.micRmsSub?.remove()
1467
+ this.micRmsSub = null
1468
+ if (!this.micRmsEnabled) return
1469
+ this.micRmsEnabled = false
1470
+ // Super Mode may have the readout open behind this call; it re-requests on focus.
1471
+ void Promise.resolve(BluetoothSdk.setMicRmsTelemetry?.(false)).catch(() => {})
1472
+ }
1473
+
1474
+ /** Barrier's behaviour over the window, as percentages a listening test can be checked against. */
1475
+ private gateSummary(): Record<string, number> {
1476
+ if (this.gateSamples === 0) return {}
1477
+ const pct = (n: number, of: number) => (of === 0 ? 0 : Math.round((n / of) * 1000) / 10)
1478
+ return {
1479
+ gateSamples: this.gateSamples,
1480
+ gateClosedPct: pct(this.gateClosed, this.gateSamples),
1481
+ speakerElevatedPct: pct(this.gateElevated, this.gateSamples),
1482
+ // The number that matters: closed while the far end was quiet means the wearer was cut.
1483
+ gateClosedQuietPct: pct(this.gateClosed - this.gateClosedElevated, this.gateSamples - this.gateElevated),
1484
+ gateRmsMax: this.gateRmsMax,
1485
+ }
1486
+ }
1487
+
1488
+ /**
1489
+ * Guarantee a voice_call session for as long as this call reads the glasses mic.
1490
+ *
1491
+ * The miniapp asking for one is the intended path; this covers the builds where it cannot,
1492
+ * because a bundled Call that predates MIC_ACQUIRE joins anyway rather than failing. Without
1493
+ * this the uplink runs under whatever the OS settings say, and the shipped default is VAD-on.
1494
+ */
1495
+ private acquireMicSession(): void {
1496
+ if (this.micSession) return
1497
+ try {
1498
+ this.micSession = micSessionManager.acquire({
1499
+ owner: `${ENGINE_OWNER_PREFIX}acs-uplink`,
1500
+ source: "glasses",
1501
+ useCase: "voice_call",
1502
+ })
1503
+ } catch (error) {
1504
+ // A source conflict means something else already owns the microphone. Forwarding whatever
1505
+ // it captures is still better than dropping the wearer from the call.
1506
+ console.warn("[AcsMeeting] glasses mic session unavailable", error)
1507
+ }
1508
+ }
1509
+
1510
+ private releaseMicSession(): void {
1511
+ const session = this.micSession
1512
+ if (!session) return
1513
+ this.micSession = null
1514
+ try {
1515
+ session.release()
1516
+ } catch (error) {
1517
+ console.warn("[AcsMeeting] releasing the glasses mic session failed", error)
1518
+ }
1519
+ }
1520
+
1401
1521
  /**
1402
1522
  * Report a call whose pinned microphone stopped delivering. There is deliberately no fallback:
1403
1523
  * the wearer agreed to be heard from the glasses, and quietly switching to the phone in the
@@ -1448,32 +1568,14 @@ class AcsMeetingService {
1448
1568
  nearClip: stats.nearClip,
1449
1569
  nearClipPct: stats.nearClipPct,
1450
1570
  gain,
1571
+ barrier: micStateCoordinator.getSessionLoudnessGate(),
1451
1572
  sweep: sweep.currentLabel(),
1452
1573
  dropsStale: this.micDropsStale,
1453
1574
  dropsNonGlasses: this.micDropsNonGlasses,
1454
1575
  gaps: this.micGaps,
1455
1576
  gapMsMax: this.micGapMsMax,
1577
+ ...this.gateSummary(),
1456
1578
  })
1457
- // #region agent log
1458
- fetch("http://127.0.0.1:7905/ingest/5a9713c9-45ff-4d09-9435-2adc5db5e91d", {
1459
- method: "POST",
1460
- headers: {"Content-Type": "application/json", "X-Debug-Session-Id": "828181"},
1461
- body: JSON.stringify({
1462
- sessionId: "828181",
1463
- runId: "run1",
1464
- hypothesisId: "E",
1465
- location: "AcsMeetingService.ts:logMicUplink",
1466
- message: "phone decoded glasses PCM window",
1467
- data: {
1468
- fps: Math.round((this.micFramesWindow * 1000) / elapsed),
1469
- meanAbs: level.meanAbs,
1470
- peak: level.peak,
1471
- frames: this.micFramesForwarded,
1472
- },
1473
- timestamp: Date.now(),
1474
- }),
1475
- }).catch(() => {})
1476
- // #endregion
1477
1579
  this.micFramesWindow = 0
1478
1580
  }
1479
1581
 
@@ -235,6 +235,9 @@ class MicSessionManager {
235
235
  }
236
236
 
237
237
  micStateCoordinator.setSessionRequirement(policy.rawPcm)
238
+ // Barrier is the only echo control left once the PCM claim turns hardware VAD off, so it
239
+ // rides with the claim rather than with the tuning a sweep may be rewriting.
240
+ micStateCoordinator.setSessionLoudnessGate(policy.loudnessGate)
238
241
 
239
242
  if (getCallGainSweep().isActive()) {
240
243
  const gain = getCallGainSweep().currentGain()
@@ -20,6 +20,15 @@ import type {MicTuningProfile} from "./micPolicy"
20
20
 
21
21
  const LOG_TAG = "MIC_COORDINATOR"
22
22
 
23
+ /** Field-wise compare, so a profile that moves a threshold without the gain still lands. */
24
+ function sameProfile(a: MicTuningProfile | null, b: MicTuningProfile | null): boolean {
25
+ if (a === b) return true
26
+ if (!a || !b) return false
27
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]) as Set<keyof MicTuningProfile>
28
+ for (const key of keys) if (a[key] !== b[key]) return false
29
+ return true
30
+ }
31
+
23
32
  type MicGate = "vad" | "loudnessGate"
24
33
 
25
34
  interface GateOverride {
@@ -41,6 +50,10 @@ const flushMicRequirementsPatch = createDebouncedPatchFlusher<Record<string, unk
41
50
  // gate override during the debounce window, and a captured stale value
42
51
  // must not win after that lifecycle transition.
43
52
  const runtimePatch = micStateCoordinator.applyEffectiveGatePolicy(patch)
53
+ // The merged patch, not the intent that produced it. Everything above this line is a
54
+ // preference; this is the only record of what the glasses were actually told, and the three
55
+ // keys a call depends on (VAD off, Barrier on, the tuning) are only decided here at flush.
56
+ console.log(`${LOG_TAG}: write`, runtimePatch)
44
57
  void Promise.resolve(BluetoothSdk.updateBluetoothSettings(runtimePatch)).catch((err) => {
45
58
  console.error(`${LOG_TAG}: failed to apply mic requirements:`, err)
46
59
  })
@@ -78,6 +91,10 @@ class MicStateCoordinator {
78
91
  * call from resurrecting the profile.
79
92
  */
80
93
  private sessionMicTuningWritten = false
94
+ /** Barrier required by the live sessions, or null when they have no opinion. */
95
+ private sessionLoudnessGate: boolean | null = null
96
+ /** Latched like the tuning, so the OS value is restated once a session has moved it. */
97
+ private sessionLoudnessGateWritten = false
81
98
  private readonly miniappVadOverrides = new Map<string, GateOverride>()
82
99
  private readonly miniappLoudnessGateOverrides = new Map<string, GateOverride>()
83
100
  private overrideSequence = 0
@@ -126,7 +143,7 @@ class MicStateCoordinator {
126
143
  * over the value that was queued.
127
144
  */
128
145
  public setSessionMicTuning(profile: MicTuningProfile | null): void {
129
- if (profile?.gain === this.sessionMicTuning?.gain) return
146
+ if (sameProfile(profile, this.sessionMicTuning)) return
130
147
  this.sessionMicTuning = profile
131
148
  if (profile) this.sessionMicTuningWritten = true
132
149
  // Nothing was ever written, so there is nothing to restore.
@@ -140,6 +157,28 @@ class MicStateCoordinator {
140
157
  return this.sessionMicTuning
141
158
  }
142
159
 
160
+ /** Last session Barrier queued, or null when the OS value is in force. For call logging. */
161
+ public getSessionLoudnessGate(): boolean | null {
162
+ return this.sessionLoudnessGate
163
+ }
164
+
165
+ /**
166
+ * Run or stop the center-mic loudness gate on behalf of the live sessions.
167
+ *
168
+ * MicSessionManager only, and the counterpart to the PCM claim rather than a second opinion on
169
+ * it: raw PCM turns hardware VAD off, which leaves Barrier as the only thing standing between
170
+ * the far end and its own echo. Unlike VAD this never stops the stream, so the worst a wrong
171
+ * threshold costs is a zeroed frame instead of a dropped word.
172
+ */
173
+ public setSessionLoudnessGate(enabled: boolean | null): void {
174
+ if (this.sessionLoudnessGate === enabled) return
175
+ this.sessionLoudnessGate = enabled
176
+ if (enabled !== null) this.sessionLoudnessGateWritten = true
177
+ else if (!this.sessionLoudnessGateWritten) return
178
+ console.log(`${LOG_TAG}: session loudness gate ${enabled === null ? "cleared" : enabled}`)
179
+ this.applyUnion()
180
+ }
181
+
143
182
  /**
144
183
  * Super Mode sliders outrank a session profile. A live override means a gain
145
184
  * sweep would write to the coordinator and the glasses would ignore it.
@@ -169,6 +208,19 @@ class MicStateCoordinator {
169
208
  return {...this.sessionMicTuning} as Record<string, number>
170
209
  }
171
210
 
211
+ /**
212
+ * The session's Barrier, but only when it wins.
213
+ *
214
+ * Suppressed by a live Super Mode tuning value for the same reason the gain is: that screen is
215
+ * the manual override, and a gate running against hand-entered thresholds is the one case where
216
+ * the session's numbers are the wrong ones.
217
+ */
218
+ private winningSessionLoudnessGate(): boolean | undefined {
219
+ if (this.sessionLoudnessGate === null) return undefined
220
+ if (Object.keys(this.configuredMicTuning).length > 0) return undefined
221
+ return this.sessionLoudnessGate
222
+ }
223
+
172
224
  /**
173
225
  * Apply a miniapp-owned gate override without changing the OS preference.
174
226
  * Overrides are lifecycle-scoped and last-live-owner-wins independently for
@@ -262,8 +314,11 @@ class MicStateCoordinator {
262
314
  } else if (vadOverride) {
263
315
  runtimeSettings.voice_activity_detection_enabled = vadOverride.enabled
264
316
  }
317
+ const sessionGate = this.winningSessionLoudnessGate()
265
318
  if (loudnessOverride) {
266
319
  runtimeSettings.loudness_gate_enabled = loudnessOverride.enabled
320
+ } else if (sessionGate !== undefined) {
321
+ runtimeSettings.loudness_gate_enabled = sessionGate
267
322
  }
268
323
 
269
324
  // BES forgets mic_tuning on disconnect, so the on-connect replay is what
@@ -336,9 +391,15 @@ class MicStateCoordinator {
336
391
  else if (vadOverride) patch.voice_activity_detection_enabled = vadOverride.enabled
337
392
  else if (this.configuredVad !== undefined) patch.voice_activity_detection_enabled = this.configuredVad
338
393
 
394
+ const sessionGate = this.winningSessionLoudnessGate()
339
395
  if (loudnessOverride) patch.loudness_gate_enabled = loudnessOverride.enabled
396
+ else if (sessionGate !== undefined) patch.loudness_gate_enabled = sessionGate
340
397
  else if (this.configuredLoudnessGate !== undefined) {
341
398
  patch.loudness_gate_enabled = this.configuredLoudnessGate
399
+ } else if (this.sessionLoudnessGateWritten) {
400
+ // A session moved it and the device carries no preference, so restate the product default
401
+ // rather than leaving the call's gate running after it ended.
402
+ patch.loudness_gate_enabled = false
342
403
  }
343
404
 
344
405
  const sessionTuning = this.winningSessionMicTuning()
@@ -356,6 +417,7 @@ class MicStateCoordinator {
356
417
  this.localWantsLc3 = false
357
418
  this.sessionWantsPcm = false
358
419
  this.sessionMicTuning = null
420
+ this.sessionLoudnessGate = null
359
421
  this.applyUnion()
360
422
  }
361
423
 
@@ -20,6 +20,52 @@ export type MicSource = "glasses" | "phone"
20
20
  export type MicTuningProfile = {
21
21
  /** codec_adc_vol index, 0-15. */
22
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
+ }
23
69
  }
24
70
 
25
71
  /** A live microphone lease. */
@@ -36,6 +82,8 @@ export type ResolvedMicPolicy = {
36
82
  pinGlasses: boolean
37
83
  /** Tuning override for the glasses, or null to leave the OS value in force. */
38
84
  micTuning: MicTuningProfile | null
85
+ /** Run the center-mic loudness gate, or null to leave the OS value in force. */
86
+ loudnessGate: boolean | null
39
87
  }
40
88
 
41
89
  /** Platform facts the policy cannot infer from the sessions alone. */
@@ -55,22 +103,40 @@ export type MicPlatformCaps = {
55
103
  * step above everything else. It suits a wearer dictating to a transcription
56
104
  * miniapp across a room; it clips a wearer talking into a Teams call.
57
105
  *
58
- * Gain is the only knob here on purpose. A call forces hardware VAD off and
59
- * the Barrier is off by default, which puts the firmware gate in its `pass`
60
- * branch, so the RMS thresholds cannot affect a call.
61
- *
62
106
  * Index 14 is +26 dB: one step down, and the step that removes the table's
63
107
  * anomalous jump. A same-voice sweep showed 15 rails hard (2.5–4.2% clip);
64
108
  * 14/13/12 still kiss the rail on syllable tips. 13 is +24 dB, the middle
65
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.
66
114
  */
67
115
  export const MIC_USE_CASE_PROFILES: Record<MicUseCase, MicTuningProfile> = {
68
- voice_call: {gain: 13},
116
+ voice_call: scaleMicTuningToGain(13),
69
117
  transcription: {},
70
118
  voice_assistant: {},
71
119
  diagnostic: {},
72
120
  }
73
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
+
74
140
  /**
75
141
  * Packages allowed to hold a `voice_call` session.
76
142
  *
@@ -109,21 +175,24 @@ export function resolveMicPolicy(
109
175
  const effective = caps.glassesPcmUplink ? sessions : sessions.filter((s) => s.source !== "glasses")
110
176
 
111
177
  let pinGlasses = false
112
- let gain: number | undefined
178
+ let loudnessGate = false
179
+ let winning: MicTuningProfile | null = null
113
180
  for (const session of effective) {
114
181
  if (session.source !== "glasses") continue
115
182
  pinGlasses = true
116
- const profileGain = MIC_USE_CASE_PROFILES[session.useCase]?.gain
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
117
186
  // Lowest index wins: clipping is the irreversible failure, a slightly
118
- // quiet assistant is not.
119
- if (typeof profileGain === "number" && (gain === undefined || profileGain < gain)) {
120
- gain = profileGain
121
- }
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
122
190
  }
123
191
 
124
192
  return {
125
193
  rawPcm: effective.length > 0,
126
194
  pinGlasses,
127
- micTuning: gain === undefined ? null : {gain},
195
+ micTuning: winning,
196
+ loudnessGate: pinGlasses ? loudnessGate : null,
128
197
  }
129
198
  }