@mentra/engine 3.2.1-dev.278 → 3.2.1-dev.282

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/build/generated/releaseMetadata.js +5 -5
  2. package/build/generated/releaseMetadata.js.map +1 -1
  3. package/build/services/AcsMeetingService.d.ts +56 -6
  4. package/build/services/AcsMeetingService.d.ts.map +1 -1
  5. package/build/services/AcsMeetingService.js +162 -40
  6. package/build/services/AcsMeetingService.js.map +1 -1
  7. package/build/services/CallGainSweep.d.ts +97 -0
  8. package/build/services/CallGainSweep.d.ts.map +1 -0
  9. package/build/services/CallGainSweep.js +249 -0
  10. package/build/services/CallGainSweep.js.map +1 -0
  11. package/build/services/GlassesMicProbe.d.ts +2 -0
  12. package/build/services/GlassesMicProbe.d.ts.map +1 -1
  13. package/build/services/GlassesMicProbe.js +20 -19
  14. package/build/services/GlassesMicProbe.js.map +1 -1
  15. package/build/services/LocalMiniappRuntime.d.ts +18 -0
  16. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  17. package/build/services/LocalMiniappRuntime.js +131 -1
  18. package/build/services/LocalMiniappRuntime.js.map +1 -1
  19. package/build/services/MicSessionManager.d.ts +83 -0
  20. package/build/services/MicSessionManager.d.ts.map +1 -0
  21. package/build/services/MicSessionManager.js +201 -0
  22. package/build/services/MicSessionManager.js.map +1 -0
  23. package/build/services/MicStateCoordinator.d.ts +71 -5
  24. package/build/services/MicStateCoordinator.d.ts.map +1 -1
  25. package/build/services/MicStateCoordinator.js +161 -10
  26. package/build/services/MicStateCoordinator.js.map +1 -1
  27. package/build/services/micPolicy.d.ts +131 -0
  28. package/build/services/micPolicy.d.ts.map +1 -0
  29. package/build/services/micPolicy.js +140 -0
  30. package/build/services/micPolicy.js.map +1 -0
  31. package/build/stores/bluetoothSettingKeys.d.ts.map +1 -1
  32. package/build/stores/bluetoothSettingKeys.js +3 -0
  33. package/build/stores/bluetoothSettingKeys.js.map +1 -1
  34. package/build/stores/settings.d.ts +5 -0
  35. package/build/stores/settings.d.ts.map +1 -1
  36. package/build/stores/settings.js +62 -0
  37. package/build/stores/settings.js.map +1 -1
  38. package/build/utils/pcm16.d.ts +20 -0
  39. package/build/utils/pcm16.d.ts.map +1 -1
  40. package/build/utils/pcm16.js +32 -1
  41. package/build/utils/pcm16.js.map +1 -1
  42. package/package.json +8 -8
  43. package/src/generated/releaseMetadata.ts +5 -5
  44. package/src/services/AcsMeetingService.ts +167 -40
  45. package/src/services/CallGainSweep.ts +318 -0
  46. package/src/services/GlassesMicProbe.ts +20 -17
  47. package/src/services/LocalMiniappRuntime.ts +152 -3
  48. package/src/services/MicSessionManager.ts +258 -0
  49. package/src/services/MicStateCoordinator.ts +153 -10
  50. package/src/services/micPolicy.ts +198 -0
  51. package/src/stores/bluetoothSettingKeys.ts +3 -0
  52. package/src/stores/settings.ts +66 -0
  53. package/src/utils/pcm16.ts +43 -1
@@ -0,0 +1,258 @@
1
+ /**
2
+ * MicSessionManager
3
+ *
4
+ * The engine's microphone ownership layer. Applications acquire a lease that
5
+ * says what they are doing; this class tracks the live leases, resolves them
6
+ * through `micPolicy`, and pushes the result to MicStateCoordinator.
7
+ *
8
+ * Everything above it is semantic ("I need a glasses microphone for a voice
9
+ * call"). Everything below it is hardware. Audio *sinks* — AcsMeetingService
10
+ * today, another transport later — are downstream of the PCM this produces and
11
+ * must not reach past it to the coordinator or the Bluetooth SDK.
12
+ */
13
+
14
+ import {Platform} from "react-native"
15
+ import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
16
+
17
+ import {getCallGainSweep} from "./CallGainSweep"
18
+ import micStateCoordinator from "./MicStateCoordinator"
19
+ import {
20
+ resolveMicPolicy,
21
+ type MicPlatformCaps,
22
+ type MicSessionSpec,
23
+ type MicSource,
24
+ type MicUseCase,
25
+ } from "./micPolicy"
26
+
27
+ const LOG_TAG = "MIC_SESSION"
28
+
29
+ /** Raised when a second source is requested while another is already live. */
30
+ export const MIC_SOURCE_CONFLICT = "MIC_SOURCE_CONFLICT"
31
+
32
+ export interface MicSessionOptions {
33
+ /** Package name for a miniapp, or `engine:<name>` for an engine feature. */
34
+ owner: string
35
+ source: MicSource
36
+ useCase: MicUseCase
37
+ }
38
+
39
+ export interface MicSession {
40
+ readonly id: number
41
+ readonly owner: string
42
+ readonly source: MicSource
43
+ readonly useCase: MicUseCase
44
+ /** Idempotent. Releasing a session that is already gone is a no-op. */
45
+ release(): void
46
+ }
47
+
48
+ interface LiveSession extends MicSessionSpec {
49
+ id: number
50
+ owner: string
51
+ }
52
+
53
+ function defaultCaps(): MicPlatformCaps {
54
+ return {
55
+ // iOS has no `setMicSourcePin`, so it cannot promise the phone microphone
56
+ // stays shut, and `glassesLc3UplinkSupported` never selects the BLE LC3
57
+ // uplink there.
58
+ glassesPcmUplink: Platform.OS === "android" && typeof BluetoothSdk.setMicSourcePin === "function",
59
+ }
60
+ }
61
+
62
+ class MicSessionManager {
63
+ private static instance: MicSessionManager | null = null
64
+
65
+ private readonly sessions = new Map<number, LiveSession>()
66
+ private nextId = 1
67
+ private caps: MicPlatformCaps = defaultCaps()
68
+ /** Last pin we asked for, so a no-op change does not re-enter native. */
69
+ private pinned = false
70
+ /**
71
+ * One 15→14→15→13 walk per voice-call generation. Cleared when the last
72
+ * glasses voice_call session drops so the next join can measure again.
73
+ */
74
+ private sweepFinished = false
75
+ /** One-shot A/B walk. Off now that 15/14/13 is measured; Super Mode can still start it. */
76
+ private sweepEnabled = false
77
+
78
+ private constructor() {}
79
+
80
+ public static getInstance(): MicSessionManager {
81
+ if (!MicSessionManager.instance) {
82
+ MicSessionManager.instance = new MicSessionManager()
83
+ }
84
+ return MicSessionManager.instance
85
+ }
86
+
87
+ /** Test seam. Production reads the platform once at construction. */
88
+ public setPlatformCaps(caps: MicPlatformCaps): void {
89
+ this.caps = caps
90
+ }
91
+
92
+ /**
93
+ * Take a microphone lease.
94
+ *
95
+ * Throws [MIC_SOURCE_CONFLICT] when a different source is already live: only
96
+ * the glasses can be pinned, so silently mixing sources would leave one
97
+ * consumer reading a microphone it did not ask for. That is the failure ACS
98
+ * already guards against frame by frame.
99
+ */
100
+ public acquire(options: MicSessionOptions): MicSession {
101
+ const conflicting = [...this.sessions.values()].find((s) => s.source !== options.source)
102
+ if (conflicting) {
103
+ throw new Error(
104
+ `${MIC_SOURCE_CONFLICT}: ${options.owner} asked for "${options.source}" while ` +
105
+ `${conflicting.owner} holds "${conflicting.source}"`,
106
+ )
107
+ }
108
+
109
+ const id = this.nextId++
110
+ this.sessions.set(id, {id, owner: options.owner, source: options.source, useCase: options.useCase})
111
+ console.log(`${LOG_TAG}: acquire #${id} ${options.owner} ${options.useCase}/${options.source}`)
112
+ this.recompute()
113
+
114
+ return {
115
+ id,
116
+ owner: options.owner,
117
+ source: options.source,
118
+ useCase: options.useCase,
119
+ release: () => this.releaseId(id),
120
+ }
121
+ }
122
+
123
+ /** Drop every lease an owner holds. The backstop for unregister / disconnect. */
124
+ public releaseOwner(owner: string): boolean {
125
+ let removed = false
126
+ for (const [id, session] of this.sessions) {
127
+ if (session.owner !== owner) continue
128
+ this.sessions.delete(id)
129
+ removed = true
130
+ }
131
+ if (removed) {
132
+ console.log(`${LOG_TAG}: released all sessions for ${owner}`)
133
+ this.recompute()
134
+ }
135
+ return removed
136
+ }
137
+
138
+ /**
139
+ * Whether an owner holds a glasses lease.
140
+ *
141
+ * Ownership only. Whether that lease has any hardware effect is
142
+ * [resolveMicPolicy]'s business, so on a platform without a glasses PCM
143
+ * uplink this still answers true while nothing is claimed.
144
+ */
145
+ public hasGlassesSession(owner: string): boolean {
146
+ for (const session of this.sessions.values()) {
147
+ if (session.owner === owner && session.source === "glasses") return true
148
+ }
149
+ return false
150
+ }
151
+
152
+ public releaseAll(): void {
153
+ if (this.sessions.size === 0) {
154
+ this.stopSweep("released_all")
155
+ return
156
+ }
157
+ this.sessions.clear()
158
+ console.log(`${LOG_TAG}: released all sessions`)
159
+ this.recompute()
160
+ }
161
+
162
+ public cleanup(): void {
163
+ this.stopSweep("cleanup")
164
+ this.sessions.clear()
165
+ this.pinned = false
166
+ this.sweepFinished = false
167
+ MicSessionManager.instance = null
168
+ }
169
+
170
+ /** Tests disable the auto-walk so acquire still means "policy 14". */
171
+ public setCallGainSweepEnabled(enabled: boolean): void {
172
+ this.sweepEnabled = enabled
173
+ if (!enabled) this.stopSweep("disabled")
174
+ }
175
+
176
+ /**
177
+ * Start or restart the 15→14→15→13 comparison on a live voice_call session.
178
+ * Super Mode uses this so a call already in progress can be measured.
179
+ */
180
+ public startCallGainSweep(): boolean {
181
+ if (!this.hasVoiceCallGlasses()) {
182
+ console.warn(`${LOG_TAG}: CALL_GAIN_SWEEP ignored — no live voice_call glasses session`)
183
+ return false
184
+ }
185
+ if (micStateCoordinator.hasConfiguredMicTuning()) {
186
+ console.warn(
187
+ `${LOG_TAG}: CALL_GAIN_SWEEP Super Mode mic-tuning is set; reset it or the glasses will ignore the sweep`,
188
+ )
189
+ }
190
+ this.sweepFinished = false
191
+ return getCallGainSweep().restart(
192
+ (gain) => micStateCoordinator.setSessionMicTuning({gain}),
193
+ () => {
194
+ this.sweepFinished = true
195
+ this.recompute()
196
+ },
197
+ )
198
+ }
199
+
200
+ private releaseId(id: number): void {
201
+ if (!this.sessions.delete(id)) return
202
+ console.log(`${LOG_TAG}: release #${id}`)
203
+ this.recompute()
204
+ }
205
+
206
+ private hasVoiceCallGlasses(): boolean {
207
+ if (!this.caps.glassesPcmUplink) return false
208
+ for (const session of this.sessions.values()) {
209
+ if (session.useCase === "voice_call" && session.source === "glasses") return true
210
+ }
211
+ return false
212
+ }
213
+
214
+ private stopSweep(reason: string): void {
215
+ if (getCallGainSweep().isActive()) getCallGainSweep().stop(reason)
216
+ }
217
+
218
+ private recompute(): void {
219
+ const policy = resolveMicPolicy([...this.sessions.values()], this.caps)
220
+ const voiceCall = this.hasVoiceCallGlasses()
221
+ if (!voiceCall) {
222
+ this.stopSweep("session_ended")
223
+ this.sweepFinished = false
224
+ }
225
+
226
+ // Pin before claiming PCM, so the first frame the claim produces is
227
+ // already from the right microphone and the phone mic is never opened.
228
+ if (policy.pinGlasses !== this.pinned) {
229
+ this.pinned = policy.pinGlasses
230
+ void Promise.resolve(BluetoothSdk.setMicSourcePin?.(policy.pinGlasses ? "glasses" : null)).catch(
231
+ (error) => {
232
+ console.warn(`${LOG_TAG}: setMicSourcePin failed`, error)
233
+ },
234
+ )
235
+ }
236
+
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)
241
+
242
+ if (getCallGainSweep().isActive()) {
243
+ const gain = getCallGainSweep().currentGain()
244
+ micStateCoordinator.setSessionMicTuning(gain == null ? policy.micTuning : {gain})
245
+ return
246
+ }
247
+
248
+ if (this.sweepEnabled && voiceCall && !this.sweepFinished) {
249
+ this.startCallGainSweep()
250
+ return
251
+ }
252
+
253
+ micStateCoordinator.setSessionMicTuning(policy.micTuning)
254
+ }
255
+ }
256
+
257
+ const micSessionManager = MicSessionManager.getInstance()
258
+ export default micSessionManager
@@ -6,14 +6,29 @@
6
6
  * Local miniapps subscribe to audio_chunk / transcription streams.
7
7
  * This coordinator pushes the aggregate local requirement set to BluetoothSdk
8
8
  * so the mic runs whenever at least one local consumer needs it.
9
+ *
10
+ * It also merges what the live microphone sessions require (MicSessionManager,
11
+ * via `setSessionRequirement` / `setSessionMicTuning`) with the OS preferences
12
+ * the settings store derives. Applications go through MicSessionManager; this
13
+ * class is an engine implementation detail.
9
14
  */
10
15
 
11
16
  import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
12
17
 
13
18
  import {createDebouncedPatchFlusher} from "../utils/debouncedPatch"
19
+ import type {MicTuningProfile} from "./micPolicy"
14
20
 
15
21
  const LOG_TAG = "MIC_COORDINATOR"
16
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
+
17
32
  type MicGate = "vad" | "loudnessGate"
18
33
 
19
34
  interface GateOverride {
@@ -24,6 +39,7 @@ interface GateOverride {
24
39
  interface ConfiguredMicGates {
25
40
  vadEnabled?: boolean | null
26
41
  loudnessGateEnabled?: boolean | null
42
+ micTuning?: Record<string, number> | null
27
43
  }
28
44
 
29
45
  /** Mic-requirement flips are debounced (300ms) and merged into one BLE write
@@ -34,6 +50,10 @@ const flushMicRequirementsPatch = createDebouncedPatchFlusher<Record<string, unk
34
50
  // gate override during the debounce window, and a captured stale value
35
51
  // must not win after that lifecycle transition.
36
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)
37
57
  void Promise.resolve(BluetoothSdk.updateBluetoothSettings(runtimePatch)).catch((err) => {
38
58
  console.error(`${LOG_TAG}: failed to apply mic requirements:`, err)
39
59
  })
@@ -49,15 +69,32 @@ class MicStateCoordinator {
49
69
  private localWantsPcm = false
50
70
  private localWantsLc3 = false
51
71
  /**
52
- * A live ACS call taking the wearer's voice off the glasses over BLE LC3.
72
+ * A live microphone session held through MicSessionManager a voice call today.
53
73
  *
54
74
  * Tracked separately from the miniapp requirement because the two have independent lifetimes:
55
75
  * the call miniapp does not subscribe to `audio_chunk`, and a captions miniapp that stops mid-call
56
76
  * must not take the call's microphone with it.
57
77
  */
58
- private callWantsPcm = false
78
+ private sessionWantsPcm = false
59
79
  private configuredVad: boolean | undefined
60
80
  private configuredLoudnessGate: boolean | undefined
81
+ /** `mic_tuning` as the settings store last derived it: `super_mode ? desired : {}`. */
82
+ private configuredMicTuning: Record<string, number> = {}
83
+ /** Tuning required by the live sessions, resolved by micPolicy. */
84
+ private sessionMicTuning: MicTuningProfile | null = null
85
+ /**
86
+ * Latched once a session profile has actually been written.
87
+ *
88
+ * Before that, `mic_tuning` stays out of every patch, so a device that never
89
+ * runs a profile does not carry the key on unrelated mic writes. After it,
90
+ * the OS value keeps being restated, which is what stops a reconnect after a
91
+ * call from resurrecting the profile.
92
+ */
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
61
98
  private readonly miniappVadOverrides = new Map<string, GateOverride>()
62
99
  private readonly miniappLoudnessGateOverrides = new Map<string, GateOverride>()
63
100
  private overrideSequence = 0
@@ -84,26 +121,104 @@ class MicStateCoordinator {
84
121
  }
85
122
 
86
123
  /**
87
- * Claim or release raw PCM on behalf of an active call.
124
+ * Claim or release raw PCM on behalf of the live microphone sessions.
88
125
  *
89
- * Called by AcsMeetingService around a call whose uplink is the glasses microphone over BLE LC3.
126
+ * MicSessionManager only. Applications acquire a session; they do not reach past it to here.
90
127
  * Releasing is a claim release, not a mic shutdown: if a captions miniapp still wants PCM the
91
128
  * microphone stays on, which is the whole reason this is a separate flag rather than a setter on
92
129
  * the local requirement.
93
130
  */
94
- public setCallRequirement(pcm: boolean): void {
95
- if (this.callWantsPcm === pcm) return
96
- this.callWantsPcm = pcm
97
- console.log(`${LOG_TAG}: call requirement updated — pcm=${pcm}`)
131
+ public setSessionRequirement(pcm: boolean): void {
132
+ if (this.sessionWantsPcm === pcm) return
133
+ this.sessionWantsPcm = pcm
134
+ console.log(`${LOG_TAG}: session requirement updated — pcm=${pcm}`)
135
+ this.applyUnion()
136
+ }
137
+
138
+ /**
139
+ * Apply or drop the tuning the live sessions require.
140
+ *
141
+ * MicSessionManager only. Rides `applyUnion` so the profile and the PCM claim land in one
142
+ * debounced write, resolved at flush time: a session released inside the debounce window wins
143
+ * over the value that was queued.
144
+ */
145
+ public setSessionMicTuning(profile: MicTuningProfile | null): void {
146
+ if (sameProfile(profile, this.sessionMicTuning)) return
147
+ this.sessionMicTuning = profile
148
+ if (profile) this.sessionMicTuningWritten = true
149
+ // Nothing was ever written, so there is nothing to restore.
150
+ else if (!this.sessionMicTuningWritten) return
151
+ console.log(`${LOG_TAG}: session mic tuning ${profile ? JSON.stringify(profile) : "cleared"}`)
152
+ this.applyUnion()
153
+ }
154
+
155
+ /** Last session profile queued, or null when the OS value is in force. */
156
+ public getSessionMicTuning(): MicTuningProfile | null {
157
+ return this.sessionMicTuning
158
+ }
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}`)
98
179
  this.applyUnion()
99
180
  }
100
181
 
182
+ /**
183
+ * Super Mode sliders outrank a session profile. A live override means a gain
184
+ * sweep would write to the coordinator and the glasses would ignore it.
185
+ */
186
+ public hasConfiguredMicTuning(): boolean {
187
+ return Object.keys(this.configuredMicTuning).length > 0
188
+ }
189
+
101
190
  /**
102
191
  * Whether anything on this device needs a continuous raw-PCM timeline. Also the condition that
103
192
  * forces hardware VAD off: a gate that drops silence turns a call into clipped half-words.
104
193
  */
105
194
  private get wantsRawPcm(): boolean {
106
- return this.localWantsPcm || this.callWantsPcm
195
+ return this.localWantsPcm || this.sessionWantsPcm
196
+ }
197
+
198
+ /**
199
+ * The session profile, but only when it wins.
200
+ *
201
+ * A live Super Mode tuning value outranks it: that screen is how a profile's numbers get found
202
+ * on a real call in the first place. Emptiness is by key count — the settings store hands back a
203
+ * fresh `{}` every time, so reference checks would never match.
204
+ */
205
+ private winningSessionMicTuning(): Record<string, number> | undefined {
206
+ if (!this.sessionMicTuning) return undefined
207
+ if (Object.keys(this.configuredMicTuning).length > 0) return undefined
208
+ return {...this.sessionMicTuning} as Record<string, number>
209
+ }
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
107
222
  }
108
223
 
109
224
  /**
@@ -168,6 +283,10 @@ class MicStateCoordinator {
168
283
  : undefined,
169
284
  loudnessGateEnabled:
170
285
  typeof settings.loudness_gate_enabled === "boolean" ? settings.loudness_gate_enabled : undefined,
286
+ micTuning:
287
+ settings.mic_tuning && typeof settings.mic_tuning === "object"
288
+ ? (settings.mic_tuning as Record<string, number>)
289
+ : undefined,
171
290
  })
172
291
 
173
292
  return this.applyActiveRuntimeOverrides(settings)
@@ -195,10 +314,19 @@ class MicStateCoordinator {
195
314
  } else if (vadOverride) {
196
315
  runtimeSettings.voice_activity_detection_enabled = vadOverride.enabled
197
316
  }
317
+ const sessionGate = this.winningSessionLoudnessGate()
198
318
  if (loudnessOverride) {
199
319
  runtimeSettings.loudness_gate_enabled = loudnessOverride.enabled
320
+ } else if (sessionGate !== undefined) {
321
+ runtimeSettings.loudness_gate_enabled = sessionGate
200
322
  }
201
323
 
324
+ // BES forgets mic_tuning on disconnect, so the on-connect replay is what
325
+ // puts a live session's profile back.
326
+ const sessionTuning = this.winningSessionMicTuning()
327
+ if (sessionTuning) runtimeSettings.mic_tuning = sessionTuning
328
+ else if (this.sessionMicTuningWritten) runtimeSettings.mic_tuning = this.configuredMicTuning
329
+
202
330
  return runtimeSettings
203
331
  }
204
332
 
@@ -235,6 +363,9 @@ class MicStateCoordinator {
235
363
  if (configured.loudnessGateEnabled !== undefined) {
236
364
  this.configuredLoudnessGate = configured.loudnessGateEnabled ?? undefined
237
365
  }
366
+ if (configured.micTuning !== undefined) {
367
+ this.configuredMicTuning = configured.micTuning ?? {}
368
+ }
238
369
  }
239
370
 
240
371
  private overridesFor(gate: MicGate): Map<string, GateOverride> {
@@ -260,11 +391,21 @@ class MicStateCoordinator {
260
391
  else if (vadOverride) patch.voice_activity_detection_enabled = vadOverride.enabled
261
392
  else if (this.configuredVad !== undefined) patch.voice_activity_detection_enabled = this.configuredVad
262
393
 
394
+ const sessionGate = this.winningSessionLoudnessGate()
263
395
  if (loudnessOverride) patch.loudness_gate_enabled = loudnessOverride.enabled
396
+ else if (sessionGate !== undefined) patch.loudness_gate_enabled = sessionGate
264
397
  else if (this.configuredLoudnessGate !== undefined) {
265
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
266
403
  }
267
404
 
405
+ const sessionTuning = this.winningSessionMicTuning()
406
+ if (sessionTuning) patch.mic_tuning = sessionTuning
407
+ else if (this.sessionMicTuningWritten) patch.mic_tuning = this.configuredMicTuning
408
+
268
409
  return patch
269
410
  }
270
411
 
@@ -274,7 +415,9 @@ class MicStateCoordinator {
274
415
  public reset(): void {
275
416
  this.localWantsPcm = false
276
417
  this.localWantsLc3 = false
277
- this.callWantsPcm = false
418
+ this.sessionWantsPcm = false
419
+ this.sessionMicTuning = null
420
+ this.sessionLoudnessGate = null
278
421
  this.applyUnion()
279
422
  }
280
423