@mentra/engine 3.2.0-dev.116 → 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.
- package/build/engine.d.ts.map +1 -1
- package/build/engine.js +7 -2
- package/build/engine.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/runtime/streamConfig.d.ts +7 -0
- package/build/runtime/streamConfig.d.ts.map +1 -1
- package/build/runtime/streamConfig.js +7 -0
- package/build/runtime/streamConfig.js.map +1 -1
- package/build/services/AcsMeetingService.d.ts +154 -0
- package/build/services/AcsMeetingService.d.ts.map +1 -0
- package/build/services/AcsMeetingService.js +477 -0
- package/build/services/AcsMeetingService.js.map +1 -0
- package/build/services/DeviceEventRouter.d.ts.map +1 -1
- package/build/services/DeviceEventRouter.js +12 -3
- package/build/services/DeviceEventRouter.js.map +1 -1
- package/build/services/GlassesSettingsSync.d.ts.map +1 -1
- package/build/services/GlassesSettingsSync.js +11 -1
- package/build/services/GlassesSettingsSync.js.map +1 -1
- package/build/services/GlassesStatusProjection.d.ts +12 -0
- package/build/services/GlassesStatusProjection.d.ts.map +1 -1
- package/build/services/GlassesStatusProjection.js +23 -0
- package/build/services/GlassesStatusProjection.js.map +1 -1
- package/build/services/LocalMiniappRuntime.d.ts +6 -0
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +130 -1
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/MentraJSRouter.d.ts.map +1 -1
- package/build/services/MentraJSRouter.js +8 -1
- package/build/services/MentraJSRouter.js.map +1 -1
- package/build/services/PhoneCameraFovCoordinator.d.ts +18 -0
- package/build/services/PhoneCameraFovCoordinator.d.ts.map +1 -1
- package/build/services/PhoneCameraFovCoordinator.js +36 -0
- package/build/services/PhoneCameraFovCoordinator.js.map +1 -1
- package/build/services/PhoneStreamCoordinator.d.ts +67 -1
- package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
- package/build/services/PhoneStreamCoordinator.js +204 -15
- package/build/services/PhoneStreamCoordinator.js.map +1 -1
- package/build/services/StreamLifecycleController.d.ts +23 -5
- package/build/services/StreamLifecycleController.d.ts.map +1 -1
- package/build/services/StreamLifecycleController.js +31 -10
- package/build/services/StreamLifecycleController.js.map +1 -1
- package/build/services/acsAudioSource.d.ts +24 -0
- package/build/services/acsAudioSource.d.ts.map +1 -0
- package/build/services/acsAudioSource.js +37 -0
- package/build/services/acsAudioSource.js.map +1 -0
- package/build/utils/devMiniappLaunch.d.ts.map +1 -1
- package/build/utils/devMiniappLaunch.js +4 -0
- package/build/utils/devMiniappLaunch.js.map +1 -1
- package/package.json +7 -6
- package/src/engine.ts +10 -2
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/runtime/streamConfig.ts +13 -0
- package/src/services/AcsMeetingService.ts +570 -0
- package/src/services/DeviceEventRouter.ts +12 -3
- package/src/services/GlassesSettingsSync.ts +13 -3
- package/src/services/GlassesStatusProjection.ts +28 -0
- package/src/services/LocalMiniappRuntime.ts +142 -1
- package/src/services/MentraJSRouter.ts +7 -1
- package/src/services/PhoneCameraFovCoordinator.ts +34 -0
- package/src/services/PhoneStreamCoordinator.ts +232 -19
- package/src/services/StreamLifecycleController.ts +42 -12
- package/src/services/acsAudioSource.ts +53 -0
- 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
|
|
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
|
-
|
|
60
|
-
|
|
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()
|
|
128
|
-
|
|
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
|