@mentra/engine 3.2.0-dev.115 → 3.2.0-dev.120

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 (65) hide show
  1. package/LICENSE +201 -0
  2. package/build/engine.d.ts.map +1 -1
  3. package/build/engine.js +7 -2
  4. package/build/engine.js.map +1 -1
  5. package/build/generated/releaseMetadata.js +5 -5
  6. package/build/generated/releaseMetadata.js.map +1 -1
  7. package/build/runtime/streamConfig.d.ts +7 -0
  8. package/build/runtime/streamConfig.d.ts.map +1 -1
  9. package/build/runtime/streamConfig.js +7 -0
  10. package/build/runtime/streamConfig.js.map +1 -1
  11. package/build/services/AcsMeetingService.d.ts +154 -0
  12. package/build/services/AcsMeetingService.d.ts.map +1 -0
  13. package/build/services/AcsMeetingService.js +477 -0
  14. package/build/services/AcsMeetingService.js.map +1 -0
  15. package/build/services/DeviceEventRouter.d.ts.map +1 -1
  16. package/build/services/DeviceEventRouter.js +12 -3
  17. package/build/services/DeviceEventRouter.js.map +1 -1
  18. package/build/services/GlassesSettingsSync.d.ts.map +1 -1
  19. package/build/services/GlassesSettingsSync.js +11 -1
  20. package/build/services/GlassesSettingsSync.js.map +1 -1
  21. package/build/services/GlassesStatusProjection.d.ts +12 -0
  22. package/build/services/GlassesStatusProjection.d.ts.map +1 -1
  23. package/build/services/GlassesStatusProjection.js +23 -0
  24. package/build/services/GlassesStatusProjection.js.map +1 -1
  25. package/build/services/LocalMiniappRuntime.d.ts +6 -0
  26. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  27. package/build/services/LocalMiniappRuntime.js +130 -1
  28. package/build/services/LocalMiniappRuntime.js.map +1 -1
  29. package/build/services/MentraJSRouter.d.ts.map +1 -1
  30. package/build/services/MentraJSRouter.js +8 -1
  31. package/build/services/MentraJSRouter.js.map +1 -1
  32. package/build/services/PhoneCameraFovCoordinator.d.ts +18 -0
  33. package/build/services/PhoneCameraFovCoordinator.d.ts.map +1 -1
  34. package/build/services/PhoneCameraFovCoordinator.js +36 -0
  35. package/build/services/PhoneCameraFovCoordinator.js.map +1 -1
  36. package/build/services/PhoneStreamCoordinator.d.ts +67 -1
  37. package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
  38. package/build/services/PhoneStreamCoordinator.js +204 -15
  39. package/build/services/PhoneStreamCoordinator.js.map +1 -1
  40. package/build/services/StreamLifecycleController.d.ts +23 -5
  41. package/build/services/StreamLifecycleController.d.ts.map +1 -1
  42. package/build/services/StreamLifecycleController.js +31 -10
  43. package/build/services/StreamLifecycleController.js.map +1 -1
  44. package/build/services/acsAudioSource.d.ts +24 -0
  45. package/build/services/acsAudioSource.d.ts.map +1 -0
  46. package/build/services/acsAudioSource.js +37 -0
  47. package/build/services/acsAudioSource.js.map +1 -0
  48. package/build/utils/devMiniappLaunch.d.ts.map +1 -1
  49. package/build/utils/devMiniappLaunch.js +4 -0
  50. package/build/utils/devMiniappLaunch.js.map +1 -1
  51. package/package.json +8 -7
  52. package/src/engine.ts +10 -2
  53. package/src/generated/releaseMetadata.ts +5 -5
  54. package/src/runtime/streamConfig.ts +13 -0
  55. package/src/services/AcsMeetingService.ts +570 -0
  56. package/src/services/DeviceEventRouter.ts +12 -3
  57. package/src/services/GlassesSettingsSync.ts +13 -3
  58. package/src/services/GlassesStatusProjection.ts +28 -0
  59. package/src/services/LocalMiniappRuntime.ts +142 -1
  60. package/src/services/MentraJSRouter.ts +7 -1
  61. package/src/services/PhoneCameraFovCoordinator.ts +34 -0
  62. package/src/services/PhoneStreamCoordinator.ts +232 -19
  63. package/src/services/StreamLifecycleController.ts +42 -12
  64. package/src/services/acsAudioSource.ts +53 -0
  65. package/src/utils/devMiniappLaunch.ts +4 -0
@@ -0,0 +1,570 @@
1
+ /**
2
+ * Host-side ACS meeting controller. One active meeting. Native module owns
3
+ * WHEP + ACS; this service maps that into miniapp envelopes and pipes
4
+ * incoming PCM into AudioPlaybackService (A2DP / PcmStreamPlayer).
5
+ */
6
+
7
+ import audioPlaybackService from "./AudioPlaybackService"
8
+ import {SETTINGS, useSettingsStore} from "../stores/settings"
9
+ import {ACS_CALL_MIC, type ResolvedAudioSource, type SourceReason} from "./acsAudioSource"
10
+
11
+ export {ACS_CALL_MIC}
12
+ export type {ResolvedAudioSource, SourceReason}
13
+
14
+ type MeetingPhase = "idle" | "connecting" | "lobby" | "connected" | "disconnected" | "error"
15
+
16
+ export type AudioSafety = "safe" | "degraded" | "unsafe"
17
+ export type ActiveStream = "none" | "virtual" | "local"
18
+ /**
19
+ * Health of the glasses WHEP subscription that feeds the call. `failed` means ICE
20
+ * dropped or the WHEP endpoint went away (glasses stopped publishing, phone changed
21
+ * networks); the ACS call itself may still be `connected` with a frozen last frame.
22
+ */
23
+ export type MediaSourceState = "idle" | "connecting" | "live" | "failed"
24
+
25
+ export type MeetingParticipantState = "idle" | "connecting" | "connected" | "lobby" | "hold" | "disconnected"
26
+
27
+ export interface MeetingParticipant {
28
+ id: string
29
+ displayName: string | null
30
+ state: MeetingParticipantState
31
+ isMuted: boolean
32
+ isSpeaking: boolean
33
+ }
34
+
35
+ export interface MeetingState {
36
+ state: MeetingPhase
37
+ muted: boolean
38
+ error?: string
39
+ meetingUrl?: string
40
+ provider?: "acs-teams"
41
+ audioSource?: "glasses" | "phone"
42
+ audioSourceReason?: SourceReason
43
+ activeStream?: ActiveStream
44
+ audioSafety?: AudioSafety
45
+ mediaSource?: MediaSourceState
46
+ participants?: MeetingParticipant[]
47
+ }
48
+
49
+ /**
50
+ * The call microphone is [ACS_CALL_MIC]. Flip that constant to `"glasses"`
51
+ * to restore glasses WHIP → Cloudflare → WHEP PCM. Do not use ACS
52
+ * LocalOutgoingAudioStream for phone: that path makes ACS own the route
53
+ * (MODE_IN_COMMUNICATION + forced speaker) and opens an echo loop.
54
+ * preferred_mic governs MentraOS STT capture, not this call.
55
+ */
56
+ export function resolveAcsAudioSource(): ResolvedAudioSource {
57
+ return {source: ACS_CALL_MIC, reason: "explicit"}
58
+ }
59
+
60
+ const PARTICIPANT_STATES = new Set<MeetingParticipantState>([
61
+ "idle",
62
+ "connecting",
63
+ "connected",
64
+ "lobby",
65
+ "hold",
66
+ "disconnected",
67
+ ])
68
+
69
+ export function parseMeetingParticipants(raw: unknown): MeetingParticipant[] | undefined {
70
+ if (!Array.isArray(raw)) return undefined
71
+ const result: MeetingParticipant[] = []
72
+ for (const entry of raw) {
73
+ if (!entry || typeof entry !== "object") continue
74
+ const value = entry as Record<string, unknown>
75
+ if (typeof value.id !== "string" || !value.id) continue
76
+ const state = PARTICIPANT_STATES.has(value.state as MeetingParticipantState)
77
+ ? (value.state as MeetingParticipantState)
78
+ : "idle"
79
+ result.push({
80
+ id: value.id,
81
+ displayName: typeof value.displayName === "string" && value.displayName ? value.displayName : null,
82
+ state,
83
+ isMuted: Boolean(value.isMuted),
84
+ isSpeaking: Boolean(value.isSpeaking),
85
+ })
86
+ }
87
+ return result
88
+ }
89
+
90
+ export type AcsOutgoingVideo = {
91
+ width: number
92
+ height: number
93
+ fps: number
94
+ maxBitrateBps: number
95
+ }
96
+
97
+ /** ACS VirtualOutgoingVideoStream documented 16:9 sizes. P540 is 960×540, not 540×960. */
98
+ const ACS_VIRTUAL_CAMERA_SIZES = new Set(["1280x720", "960x540"])
99
+
100
+ export function parseAcsOutgoingVideo(raw: unknown): AcsOutgoingVideo | undefined {
101
+ if (raw == null) return undefined
102
+ if (typeof raw !== "object") throw new Error("video must be an object")
103
+ const value = raw as Record<string, unknown>
104
+ const width = Number(value.width)
105
+ const height = Number(value.height)
106
+ const fps = Number(value.fps)
107
+ const maxBitrateBps = Number(value.maxBitrateBps)
108
+ if (![width, height, fps, maxBitrateBps].every(Number.isFinite)) {
109
+ throw new Error("video requires width, height, fps, and maxBitrateBps")
110
+ }
111
+ if (!ACS_VIRTUAL_CAMERA_SIZES.has(`${width}x${height}`) || fps < 1 || fps > 30 || maxBitrateBps <= 0) {
112
+ throw new Error(`unsupported ACS video ${width}x${height}@${fps}`)
113
+ }
114
+ return {width, height, fps, maxBitrateBps}
115
+ }
116
+
117
+ type NativeModule = {
118
+ join(options: {
119
+ meetingUrl: string
120
+ token: string
121
+ whepUrl: string
122
+ displayName?: string
123
+ dumpPcmWav?: boolean
124
+ audioSource?: "glasses" | "phone"
125
+ video?: AcsOutgoingVideo
126
+ }): Promise<MeetingState>
127
+ leave(): Promise<void>
128
+ setMuted(muted: boolean): Promise<MeetingState>
129
+ setAudioSource(source: "glasses" | "phone"): Promise<MeetingState>
130
+ updateVideoSource(whepUrl: string): Promise<void>
131
+ /** Force a WHEP rebuild on the current URL. Absent on natives that predate it. */
132
+ restartVideoSource?(): Promise<void>
133
+ getState(): Promise<MeetingState>
134
+ addListener(event: string, listener: (event: Record<string, unknown>) => void): {remove: () => void}
135
+ }
136
+
137
+ let nativeModule: NativeModule | null | undefined
138
+
139
+ function getNative(): NativeModule | null {
140
+ if (nativeModule !== undefined) return nativeModule
141
+ try {
142
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
143
+ nativeModule = require("@mentra/acs-meeting").default as NativeModule
144
+ } catch {
145
+ nativeModule = null
146
+ }
147
+ return nativeModule
148
+ }
149
+
150
+ /** Test seam: skip the native require and inject a fake ACS module. */
151
+ export function setAcsMeetingNativeForTests(mod: NativeModule | null | undefined): void {
152
+ nativeModule = mod
153
+ }
154
+
155
+ /**
156
+ * Phone connectivity feed. Only the subset of `@react-native-community/netinfo`
157
+ * this service needs, so tests can inject a fake and hosts without the package
158
+ * (or a bare test runtime) degrade to "no phone-network awareness" instead of
159
+ * failing to load the meeting service.
160
+ */
161
+ export type PhoneNetworkInfo = {type: string; isConnected: boolean | null}
162
+ type PhoneNetworkSource = {
163
+ addEventListener(listener: (state: PhoneNetworkInfo) => void): () => void
164
+ }
165
+
166
+ let phoneNetwork: PhoneNetworkSource | null | undefined
167
+
168
+ function getPhoneNetwork(): PhoneNetworkSource | null {
169
+ if (phoneNetwork !== undefined) return phoneNetwork
170
+ try {
171
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
172
+ const netInfo = require("@react-native-community/netinfo") as {default?: PhoneNetworkSource} & PhoneNetworkSource
173
+ phoneNetwork = netInfo.default ?? netInfo
174
+ } catch {
175
+ phoneNetwork = null
176
+ }
177
+ return phoneNetwork
178
+ }
179
+
180
+ /** Test seam: inject a fake phone-network feed (or `null` to disable it). */
181
+ export function setAcsMeetingPhoneNetworkForTests(source: PhoneNetworkSource | null | undefined): void {
182
+ phoneNetwork = source
183
+ }
184
+
185
+ function parseActiveStream(value: unknown): ActiveStream | undefined {
186
+ if (value === "none" || value === "virtual" || value === "local") return value
187
+ return undefined
188
+ }
189
+
190
+ function parseAudioSafety(value: unknown): AudioSafety | undefined {
191
+ if (value === "safe" || value === "degraded" || value === "unsafe") return value
192
+ return undefined
193
+ }
194
+
195
+ function parseMediaSource(value: unknown): MediaSourceState | undefined {
196
+ if (value === "idle" || value === "connecting" || value === "live" || value === "failed") return value
197
+ return undefined
198
+ }
199
+
200
+ /** Meeting phases during which the WHEP feed should be alive and is worth restarting. */
201
+ const MEDIA_ACTIVE_PHASES = new Set<MeetingPhase>(["connecting", "lobby", "connected"])
202
+ /** Floor between WHEP restarts we trigger from the host, so a flapping network cannot thrash native. */
203
+ const MEDIA_RESTART_MIN_INTERVAL_MS = 3000
204
+
205
+ /** Formats the native PcmStreamPlayer accepts (mono only). */
206
+ const PCM_SAMPLE_RATES = new Set([16000, 24000, 48000])
207
+ const PCM_BACKLOG_WARN_MS = 600
208
+
209
+ class AcsMeetingService {
210
+ private owner: string | null = null
211
+ private pcmStreamId: string | null = null
212
+ private pcmFormat: {sampleRate: number; channels: number} | null = null
213
+ private pcmReopen: Promise<void> | null = null
214
+ private pcmWriteChain: Promise<void> = Promise.resolve()
215
+ private lastBacklogWarnAt = 0
216
+ private subscriptions: Array<{remove: () => void}> = []
217
+ private lastState: MeetingState = {state: "idle", muted: false}
218
+ private onState: ((packageName: string, state: MeetingState) => void) | null = null
219
+ /** WHEP URL native is (or should be) subscribed to; what a host-triggered restart re-feeds. */
220
+ private whepUrl: string | null = null
221
+ private phoneNetworkUnsub: (() => void) | null = null
222
+ private lastPhoneNetworkKey: string | null = null
223
+ private lastMediaRestartAt = 0
224
+
225
+ setStateHandler(handler: (packageName: string, state: MeetingState) => void): void {
226
+ this.onState = handler
227
+ }
228
+
229
+ getState(): MeetingState {
230
+ return {...this.lastState}
231
+ }
232
+
233
+ ownerPackage(): string | null {
234
+ return this.owner
235
+ }
236
+
237
+ async join(
238
+ packageName: string,
239
+ args: {
240
+ meetingUrl: string
241
+ token: string
242
+ whepUrl: string
243
+ displayName?: string
244
+ video?: AcsOutgoingVideo
245
+ },
246
+ ): Promise<MeetingState> {
247
+ const native = getNative()
248
+ if (!native) {
249
+ console.warn("[AcsMeeting] phase=join-native nativeLoaded=false")
250
+ throw new Error("ACS meeting module is not available on this host")
251
+ }
252
+ if (this.owner && this.owner !== packageName) {
253
+ throw new Error("Another miniapp already has an active meeting")
254
+ }
255
+ // Validate before claiming ownership so a bad request cannot leave the slot taken.
256
+ const video = args.video ? parseAcsOutgoingVideo(args.video) : undefined
257
+ const resolved = resolveAcsAudioSource()
258
+ this.owner = packageName
259
+ this.whepUrl = args.whepUrl
260
+ this.bindNative(native, packageName)
261
+ console.log("[AcsMeeting] phase=join-native", {
262
+ packageName,
263
+ nativeLoaded: true,
264
+ hasToken: Boolean(args.token),
265
+ hasWhep: Boolean(args.whepUrl),
266
+ audioSource: resolved.source,
267
+ audioSourceReason: resolved.reason,
268
+ preferredMic: useSettingsStore.getState().getSetting(SETTINGS.preferred_mic.key),
269
+ })
270
+ try {
271
+ const state = await native.join({
272
+ meetingUrl: args.meetingUrl,
273
+ token: args.token,
274
+ whepUrl: args.whepUrl,
275
+ displayName: args.displayName,
276
+ audioSource: resolved.source,
277
+ ...(video ? {video} : {}),
278
+ })
279
+ this.lastState = {
280
+ ...state,
281
+ audioSource: resolved.source,
282
+ audioSourceReason: resolved.reason,
283
+ }
284
+ console.log("[AcsMeeting] phase=join-native-ok", {state: state.state, muted: state.muted})
285
+ } catch (error) {
286
+ // Native never joined (or is unwinding). Release the slot so the same or another
287
+ // miniapp can retry, and make sure nothing half-joined lingers in Teams.
288
+ console.warn("[AcsMeeting] phase=join-native-failed", {
289
+ packageName,
290
+ error: error instanceof Error ? error.message : String(error),
291
+ })
292
+ await native.leave().catch((leaveError) => {
293
+ console.warn("[AcsMeeting] native leave after failed join also failed", leaveError)
294
+ })
295
+ await this.releaseHostState()
296
+ throw error
297
+ }
298
+ this.watchPhoneNetwork(native)
299
+ // Return audio is additive: the wearer is already in the meeting with camera and
300
+ // mic. A playback failure must not reject the join — that would leave the miniapp
301
+ // believing it never joined while native keeps the wearer in Teams.
302
+ try {
303
+ await this.ensurePcmPlayback(packageName)
304
+ console.log("[AcsMeeting] phase=pcm-playback", {streamId: this.pcmStreamId})
305
+ } catch (error) {
306
+ console.warn("[AcsMeeting] phase=pcm-playback-failed; continuing without return audio", {
307
+ error: error instanceof Error ? error.message : String(error),
308
+ })
309
+ }
310
+ return this.lastState
311
+ }
312
+
313
+ async leave(packageName: string): Promise<void> {
314
+ if (this.owner && this.owner !== packageName) return
315
+ const native = getNative()
316
+ try {
317
+ await native?.leave()
318
+ } finally {
319
+ await this.releaseHostState()
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Drop everything the host set up around a session: the network watcher, return
325
+ * audio, native listeners, ownership. Runs after native has been told to leave
326
+ * (or after a join that never produced a native call).
327
+ */
328
+ private async releaseHostState(): Promise<void> {
329
+ this.unwatchPhoneNetwork()
330
+ await this.stopPcm()
331
+ this.unbindNative()
332
+ this.owner = null
333
+ this.whepUrl = null
334
+ this.lastMediaRestartAt = 0
335
+ this.lastState = {state: "idle", muted: false}
336
+ }
337
+
338
+ async setMuted(packageName: string, muted: boolean): Promise<MeetingState> {
339
+ this.assertOwner(packageName)
340
+ const native = getNative()
341
+ if (!native) throw new Error("ACS meeting module is not available on this host")
342
+ const state = await native.setMuted(muted)
343
+ this.lastState = {
344
+ ...this.lastState,
345
+ ...state,
346
+ audioSourceReason: this.lastState.audioSourceReason,
347
+ }
348
+ return this.lastState
349
+ }
350
+
351
+ async updateVideoSource(packageName: string, whepUrl: string): Promise<void> {
352
+ this.assertOwner(packageName)
353
+ const native = getNative()
354
+ if (!native) throw new Error("ACS meeting module is not available on this host")
355
+ this.whepUrl = whepUrl
356
+ await native.updateVideoSource(whepUrl)
357
+ }
358
+
359
+ async readState(packageName: string): Promise<MeetingState> {
360
+ if (this.owner && this.owner !== packageName) {
361
+ return {state: "idle", muted: false}
362
+ }
363
+ const native = getNative()
364
+ if (!native) return {state: "idle", muted: false}
365
+ const state = await native.getState()
366
+ this.lastState = state
367
+ return state
368
+ }
369
+
370
+ async leaveIfOwner(packageName: string): Promise<void> {
371
+ if (this.owner === packageName) await this.leave(packageName)
372
+ }
373
+
374
+ private assertOwner(packageName: string): void {
375
+ if (this.owner !== packageName) {
376
+ throw new Error(
377
+ this.owner ? "This miniapp does not own the active meeting" : "No active meeting; call meeting.join first",
378
+ )
379
+ }
380
+ }
381
+
382
+ private unbindNative(): void {
383
+ this.subscriptions.forEach((sub) => sub.remove())
384
+ this.subscriptions = []
385
+ }
386
+
387
+ private bindNative(native: NativeModule, packageName: string): void {
388
+ this.unbindNative()
389
+ this.subscriptions = [
390
+ native.addListener("onState", (event) => {
391
+ const audioSafety = parseAudioSafety(event.audioSafety)
392
+ if (audioSafety === "unsafe") {
393
+ console.error("[AcsMeeting] phase=audio-unsafe", {
394
+ activeStream: event.activeStream,
395
+ audioSource: event.audioSource,
396
+ })
397
+ }
398
+ const participants = parseMeetingParticipants(event.participants)
399
+ const mediaSource = parseMediaSource(event.mediaSource)
400
+ const state: MeetingState = {
401
+ state: (event.state as MeetingPhase) ?? "idle",
402
+ muted: Boolean(event.muted),
403
+ error: event.error as string | undefined,
404
+ meetingUrl: event.meetingUrl as string | undefined,
405
+ provider: "acs-teams",
406
+ audioSource: event.audioSource === "phone" ? "phone" : "glasses",
407
+ audioSourceReason: this.lastState.audioSourceReason,
408
+ activeStream: parseActiveStream(event.activeStream),
409
+ audioSafety,
410
+ ...(mediaSource ? {mediaSource} : {}),
411
+ ...(participants ? {participants} : {}),
412
+ }
413
+ this.lastState = state
414
+ console.log("[AcsMeeting] phase=native-state", {
415
+ state: state.state,
416
+ muted: state.muted,
417
+ error: state.error,
418
+ audioSource: state.audioSource,
419
+ activeStream: state.activeStream,
420
+ audioSafety: state.audioSafety,
421
+ mediaSource: state.mediaSource,
422
+ participants: participants?.length,
423
+ })
424
+ this.onState?.(packageName, state)
425
+ }),
426
+ native.addListener("onIncomingPcm", (event) => {
427
+ const base64 = event.base64 as string | undefined
428
+ if (!base64 || !this.owner) return
429
+ // Serialize: native async calls may complete out of order, and PCM
430
+ // chunks written out of order are audible as garbling.
431
+ this.pcmWriteChain = this.pcmWriteChain
432
+ .then(() => this.writeIncomingPcm(packageName, base64, event.sampleRate, event.channels))
433
+ .catch(() => undefined)
434
+ }),
435
+ ]
436
+ }
437
+
438
+ /**
439
+ * The WHEP PeerConnection does not survive the phone changing networks
440
+ * (Wi-Fi↔cellular, or a different Wi-Fi): ICE fails and native parks the source
441
+ * as `failed` while the ACS call itself reconnects on its own. Nudge native to
442
+ * rebuild the subscription whenever the phone's network identity changes during
443
+ * a live meeting. Native ignores the request when the source is healthy on the
444
+ * same URL, so a spurious event costs nothing.
445
+ */
446
+ private watchPhoneNetwork(native: NativeModule): void {
447
+ this.unwatchPhoneNetwork()
448
+ const source = getPhoneNetwork()
449
+ if (!source) return
450
+ this.lastPhoneNetworkKey = null
451
+ try {
452
+ this.phoneNetworkUnsub = source.addEventListener((info) => {
453
+ const key = `${info.type}:${info.isConnected === false ? "offline" : "online"}`
454
+ const previous = this.lastPhoneNetworkKey
455
+ this.lastPhoneNetworkKey = key
456
+ // First callback is NetInfo replaying the current state, not a change.
457
+ if (previous === null || previous === key) return
458
+ if (info.isConnected === false) return
459
+ void this.restartMediaSource(native, `phone network ${previous} → ${key}`)
460
+ })
461
+ } catch (error) {
462
+ console.warn("[AcsMeeting] phone network watch unavailable", error)
463
+ this.phoneNetworkUnsub = null
464
+ }
465
+ }
466
+
467
+ private unwatchPhoneNetwork(): void {
468
+ this.phoneNetworkUnsub?.()
469
+ this.phoneNetworkUnsub = null
470
+ this.lastPhoneNetworkKey = null
471
+ }
472
+
473
+ private async restartMediaSource(native: NativeModule, reason: string): Promise<void> {
474
+ const whepUrl = this.whepUrl
475
+ if (!this.owner || !whepUrl || !MEDIA_ACTIVE_PHASES.has(this.lastState.state)) return
476
+ const now = Date.now()
477
+ if (now - this.lastMediaRestartAt < MEDIA_RESTART_MIN_INTERVAL_MS) return
478
+ this.lastMediaRestartAt = now
479
+ console.log("[AcsMeeting] phase=media-restart", {reason, mediaSource: this.lastState.mediaSource})
480
+ try {
481
+ // A same-URL updateVideoSource is a no-op while native still believes the
482
+ // peer is healthy; after a network switch that belief is exactly what is wrong.
483
+ if (native.restartVideoSource) await native.restartVideoSource()
484
+ else await native.updateVideoSource(whepUrl)
485
+ } catch (error) {
486
+ console.warn("[AcsMeeting] media restart failed", {reason, error})
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Native normalizes to 16 kHz mono, so this is normally a straight write.
492
+ * If the format ever differs, reopen the player to match rather than play
493
+ * the bytes at the wrong rate (that is what slows and deepens the audio).
494
+ */
495
+ private async writeIncomingPcm(
496
+ packageName: string,
497
+ base64: string,
498
+ rawRate: unknown,
499
+ rawChannels: unknown,
500
+ ): Promise<void> {
501
+ const sampleRate = Number(rawRate) || 16000
502
+ const channels = Number(rawChannels) || 1
503
+ if (!PCM_SAMPLE_RATES.has(sampleRate) || channels !== 1) {
504
+ console.warn("[AcsMeeting] incoming PCM format unsupported; dropping chunk", {sampleRate, channels})
505
+ return
506
+ }
507
+ if (this.pcmFormat && (this.pcmFormat.sampleRate !== sampleRate || this.pcmFormat.channels !== channels)) {
508
+ if (!this.pcmReopen) {
509
+ console.warn("[AcsMeeting] incoming PCM format changed; reopening player", {
510
+ from: this.pcmFormat,
511
+ to: {sampleRate, channels},
512
+ })
513
+ this.pcmReopen = this.stopPcm()
514
+ .then(() => this.ensurePcmPlayback(packageName, sampleRate, channels))
515
+ .finally(() => {
516
+ this.pcmReopen = null
517
+ })
518
+ }
519
+ await this.pcmReopen
520
+ } else if (!this.pcmStreamId) {
521
+ await this.ensurePcmPlayback(packageName, sampleRate, channels)
522
+ }
523
+ const streamId = this.pcmStreamId
524
+ if (!streamId) return
525
+ try {
526
+ const result = await audioPlaybackService.writeStreamChunk(streamId, base64)
527
+ const bufferedMs = result?.bufferedMs
528
+ if (typeof bufferedMs === "number" && bufferedMs > PCM_BACKLOG_WARN_MS) {
529
+ const now = Date.now()
530
+ if (now - this.lastBacklogWarnAt > 5000) {
531
+ this.lastBacklogWarnAt = now
532
+ console.warn("[AcsMeeting] incoming PCM backlog high", {bufferedMs})
533
+ }
534
+ }
535
+ } catch (error) {
536
+ console.warn("[AcsMeeting] incoming PCM write failed", error)
537
+ }
538
+ }
539
+
540
+ private async ensurePcmPlayback(packageName: string, sampleRate = 16000, channels = 1): Promise<void> {
541
+ if (this.pcmStreamId) return
542
+ const streamId = `acs-in-${packageName}-${Date.now()}`
543
+ await audioPlaybackService.openStream({
544
+ streamId,
545
+ appId: packageName,
546
+ sampleRate,
547
+ channels,
548
+ stopOtherAudio: true,
549
+ onEnded: () => {
550
+ if (this.pcmStreamId === streamId) {
551
+ this.pcmStreamId = null
552
+ this.pcmFormat = null
553
+ }
554
+ },
555
+ })
556
+ this.pcmStreamId = streamId
557
+ this.pcmFormat = {sampleRate, channels}
558
+ }
559
+
560
+ private async stopPcm(): Promise<void> {
561
+ const streamId = this.pcmStreamId
562
+ this.pcmStreamId = null
563
+ this.pcmFormat = null
564
+ if (!streamId) return
565
+ await audioPlaybackService.abortStream(streamId).catch(() => undefined)
566
+ }
567
+ }
568
+
569
+ const acsMeetingService = new AcsMeetingService()
570
+ export default acsMeetingService
@@ -50,14 +50,23 @@ export function startDeviceEventRouter(): void {
50
50
  // single source of truth — so every path converges here: wifi_status_change,
51
51
  // onGlassesStatus, and BLE disconnect. Effective connectivity requires the glasses
52
52
  // to be connected AND on Wi-Fi, so a disconnect correctly flips `connected` false.
53
+ // `linkConnected` carries the BLE leg on its own so a miniapp can tell "glasses
54
+ // out of Bluetooth range" from "glasses on Bluetooth but off Wi-Fi" — the two
55
+ // need opposite recoveries (wait vs. open Wi-Fi setup).
53
56
  subs.push({
54
57
  remove: useGlassesStore.subscribe(
55
58
  (s) => {
56
- const connected = isGlassesConnected(s.connection) && s.wifi.state === "connected"
59
+ const linkConnected = isGlassesConnected(s.connection)
60
+ const connected = linkConnected && s.wifi.state === "connected"
61
+ // ssid/localIp describe how to reach the glasses over Wi-Fi, so they are
62
+ // only meaningful under effective connectivity. Gate them on `connected`
63
+ // (not raw wifi.state) so a BLE drop with a stale "connected" Wi-Fi leg
64
+ // never hands miniapps an unreachable address.
57
65
  return {
58
66
  connected,
59
- ssid: s.wifi.state === "connected" ? s.wifi.ssid : undefined,
60
- localIp: s.wifi.state === "connected" ? s.wifi.localIp : undefined,
67
+ linkConnected,
68
+ ssid: linkConnected && s.wifi.state === "connected" ? s.wifi.ssid : undefined,
69
+ localIp: linkConnected && s.wifi.state === "connected" ? s.wifi.localIp : undefined,
61
70
  }
62
71
  },
63
72
  (wifi) => localMiniappRuntime.forwardEvent("glasses_wifi", wifi),
@@ -20,6 +20,7 @@ import {useGlassesStore} from "../stores/glasses"
20
20
  import {createDebouncedPatchFlusher} from "../utils/debouncedPatch"
21
21
  import {isGlassesConnected} from "./GlassesReadiness"
22
22
  import micStateCoordinator from "./MicStateCoordinator"
23
+ import {phoneCameraFovCoordinator} from "./PhoneCameraFovCoordinator"
23
24
 
24
25
  /**
25
26
  * Change-pushes are debounced (300ms) and merged so a burst of setSetting
@@ -124,9 +125,18 @@ export function startGlassesSettingsSync(): void {
124
125
  const connected = isGlassesConnected(useGlassesStore.getState().connection)
125
126
  if (connected && !wasConnected) {
126
127
  // Background sync: log-and-continue if the device drops right after connect.
127
- void pushDeviceSettingsOnConnect().catch((error) => {
128
- console.warn("GlassesSettingsSync: on-connect settings push failed:", error)
129
- })
128
+ void pushDeviceSettingsOnConnect()
129
+ .catch((error) => {
130
+ console.warn("GlassesSettingsSync: on-connect settings push failed:", error)
131
+ })
132
+ .finally(() => {
133
+ // That push replays the persistent camera_fov, which stomps whatever
134
+ // override a miniapp currently owns. Re-assert after it either way:
135
+ // a failed push may still have written some keys.
136
+ void phoneCameraFovCoordinator.reapplyEffectiveOverride().catch((error) => {
137
+ console.warn("GlassesSettingsSync: camera FOV re-apply failed:", error)
138
+ })
139
+ })
130
140
  }
131
141
  wasConnected = connected
132
142
  })
@@ -14,6 +14,34 @@
14
14
  import BluetoothSdk, {type PublicGlassesStatus} from "@mentra/bluetooth-sdk"
15
15
  import {useCoreStore} from "../stores/core"
16
16
  import {useGlassesStore} from "../stores/glasses"
17
+ import {isGlassesConnected} from "./GlassesReadiness"
18
+
19
+ /** Miniapp `session.glasses.onConnection` payload. */
20
+ export type MiniappConnectionData = {
21
+ connected: boolean
22
+ modelName?: string
23
+ }
24
+
25
+ /**
26
+ * Native `glasses_status` is a store delta (`connection.state`, battery, wifi…).
27
+ * Miniapps subscribe to a boolean `connected` field. Forwarding the raw status
28
+ * makes `Boolean(data.connected)` false on every heartbeat while the Mentra App
29
+ * still shows the glasses linked.
30
+ */
31
+ export function toMiniappConnectionData(status: unknown): MiniappConnectionData | null {
32
+ if (!status || typeof status !== "object") return null
33
+ const rec = status as Partial<PublicGlassesStatus> & {connected?: boolean; modelName?: string}
34
+ if (typeof rec.connected === "boolean") {
35
+ const modelName = rec.modelName || rec.deviceModel
36
+ return {connected: rec.connected, ...(modelName ? {modelName} : {})}
37
+ }
38
+ if (!rec.connection) return null
39
+ const modelName = rec.deviceModel
40
+ return {
41
+ connected: isGlassesConnected(rec.connection),
42
+ ...(modelName ? {modelName} : {}),
43
+ }
44
+ }
17
45
 
18
46
  let unsubs: Array<() => void> = []
19
47
  let projectionRunId = 0