@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.
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/services/AcsMeetingService.d.ts +22 -6
- package/build/services/AcsMeetingService.d.ts.map +1 -1
- package/build/services/AcsMeetingService.js +39 -20
- package/build/services/AcsMeetingService.js.map +1 -1
- package/build/services/CallGainSweep.d.ts +97 -0
- package/build/services/CallGainSweep.d.ts.map +1 -0
- package/build/services/CallGainSweep.js +249 -0
- package/build/services/CallGainSweep.js.map +1 -0
- package/build/services/GlassesMicProbe.d.ts +2 -0
- package/build/services/GlassesMicProbe.d.ts.map +1 -1
- package/build/services/GlassesMicProbe.js +20 -19
- package/build/services/GlassesMicProbe.js.map +1 -1
- package/build/services/LocalMiniappRuntime.d.ts +18 -0
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +131 -1
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/MicSessionManager.d.ts +83 -0
- package/build/services/MicSessionManager.d.ts.map +1 -0
- package/build/services/MicSessionManager.js +198 -0
- package/build/services/MicSessionManager.js.map +1 -0
- package/build/services/MicStateCoordinator.d.ts +48 -5
- package/build/services/MicStateCoordinator.d.ts.map +1 -1
- package/build/services/MicStateCoordinator.js +91 -10
- package/build/services/MicStateCoordinator.js.map +1 -1
- package/build/services/micPolicy.d.ts +84 -0
- package/build/services/micPolicy.d.ts.map +1 -0
- package/build/services/micPolicy.js +82 -0
- package/build/services/micPolicy.js.map +1 -0
- package/build/stores/bluetoothSettingKeys.d.ts.map +1 -1
- package/build/stores/bluetoothSettingKeys.js +3 -0
- package/build/stores/bluetoothSettingKeys.js.map +1 -1
- package/build/stores/settings.d.ts +5 -0
- package/build/stores/settings.d.ts.map +1 -1
- package/build/stores/settings.js +62 -0
- package/build/stores/settings.js.map +1 -1
- package/build/utils/pcm16.d.ts +20 -0
- package/build/utils/pcm16.d.ts.map +1 -1
- package/build/utils/pcm16.js +32 -1
- package/build/utils/pcm16.js.map +1 -1
- package/package.json +8 -8
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/services/AcsMeetingService.ts +45 -20
- package/src/services/CallGainSweep.ts +318 -0
- package/src/services/GlassesMicProbe.ts +20 -17
- package/src/services/LocalMiniappRuntime.ts +152 -3
- package/src/services/MicSessionManager.ts +255 -0
- package/src/services/MicStateCoordinator.ts +91 -10
- package/src/services/micPolicy.ts +129 -0
- package/src/stores/bluetoothSettingKeys.ts +3 -0
- package/src/stores/settings.ts +66 -0
- 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
|
-
*
|
|
1291
|
-
*
|
|
1292
|
-
*
|
|
1293
|
-
*
|
|
1294
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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:
|
|
1426
|
-
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
|
|
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
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|