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