@mentra/cloud-client 0.1.0-beta.0
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/README.md +35 -0
- package/node/index.ts +54 -0
- package/node/transports.ts +133 -0
- package/package.json +58 -0
- package/react-native/index.ts +38 -0
- package/react-native/transports.ts +231 -0
- package/src/client.ts +256 -0
- package/src/config.ts +84 -0
- package/src/errors.ts +54 -0
- package/src/http.ts +259 -0
- package/src/index.ts +70 -0
- package/src/logger.ts +34 -0
- package/src/modules/auth/auth.ts +553 -0
- package/src/modules/auth/jwt.ts +82 -0
- package/src/modules/auth/token-store.ts +138 -0
- package/src/modules/core/core.ts +202 -0
- package/src/modules/core/reports.ts +153 -0
- package/src/modules/runtime/audio-udp.ts +192 -0
- package/src/modules/runtime/camera.ts +192 -0
- package/src/modules/runtime/connection.ts +782 -0
- package/src/modules/runtime/emitter.ts +129 -0
- package/src/modules/runtime/maps.ts +92 -0
- package/src/modules/runtime/runtime.ts +557 -0
- package/src/modules/runtime/status.ts +12 -0
- package/src/modules/runtime/subscriptions.ts +136 -0
- package/src/modules/runtime/tts.ts +81 -0
- package/src/transports.ts +77 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview `cloud.runtime`: the live session module.
|
|
3
|
+
*
|
|
4
|
+
* This is wiring only. It implements the public `RuntimeModule` by delegating to
|
|
5
|
+
* five collaborators and routing messages between them:
|
|
6
|
+
* - connection: owns the WebSocket, the handshake, reconnect, and liveness.
|
|
7
|
+
* - emitter: the one typed event emitter the public `on*` methods wrap.
|
|
8
|
+
* - subscriptions: the REST full-replace writer with the version counter.
|
|
9
|
+
* - camera: managed photo/stream (REST request, await the WebSocket push).
|
|
10
|
+
* - audio: the UDP audio path (encrypt each frame, hand bytes to the socket).
|
|
11
|
+
* - tts: runtime TTS source preparation for host playback.
|
|
12
|
+
*
|
|
13
|
+
* Keeping the orchestration here, and the mechanics in the collaborators, means
|
|
14
|
+
* each piece is testable on its own and this file reads as the protocol flow:
|
|
15
|
+
* connect, route inbound messages to events, re-send subscriptions on reconnect.
|
|
16
|
+
*
|
|
17
|
+
* See docs/issues/004-cloud-client/spec.md ("cloud.runtime") and design.md.
|
|
18
|
+
*/
|
|
19
|
+
import type {
|
|
20
|
+
AudioSubscription,
|
|
21
|
+
TranscriptionData,
|
|
22
|
+
TranslationData,
|
|
23
|
+
ProtocolError,
|
|
24
|
+
ConnectionAck,
|
|
25
|
+
CloudToClientMessage,
|
|
26
|
+
} from "@mentra/cloud-protocol";
|
|
27
|
+
import type { Logger } from "../../logger";
|
|
28
|
+
import type { Connection } from "./connection";
|
|
29
|
+
import { HandshakeRejectedError } from "./connection";
|
|
30
|
+
import type { RuntimeEmitter, RuntimeEvents } from "./emitter";
|
|
31
|
+
import type { Subscriptions } from "./subscriptions";
|
|
32
|
+
import type { Camera, PhotoOptions, StreamOptions, ManagedStream, StreamStatusResult } from "./camera";
|
|
33
|
+
import type { Maps, DirectionsRequest, DirectionsResult, LatLng, ReverseGeocodeResult } from "./maps";
|
|
34
|
+
import type { Tts, RuntimeTtsSpeakOptions, RuntimeTtsSpeechSource } from "./tts";
|
|
35
|
+
import type { UdpAudio } from "./audio-udp";
|
|
36
|
+
import type { RuntimeSnapshot } from "./status";
|
|
37
|
+
|
|
38
|
+
const UDP_PROBE_INTERVAL_MS = 1_000;
|
|
39
|
+
const UDP_LIVENESS_TIMEOUT_MS = 3_000;
|
|
40
|
+
|
|
41
|
+
// Re-export the camera option/result types so a host importing the runtime gets
|
|
42
|
+
// them from one place alongside the module that produces them.
|
|
43
|
+
export type { PhotoOptions, StreamOptions, ManagedStream, StreamStatusResult } from "./camera";
|
|
44
|
+
export type {
|
|
45
|
+
DirectionsRequest,
|
|
46
|
+
DirectionsResult,
|
|
47
|
+
Route,
|
|
48
|
+
RouteStep,
|
|
49
|
+
LatLng,
|
|
50
|
+
TravelMode,
|
|
51
|
+
ManeuverKind,
|
|
52
|
+
RouteAvoidances,
|
|
53
|
+
ReverseGeocodeResult,
|
|
54
|
+
} from "./maps";
|
|
55
|
+
export type { RuntimeTtsSpeakOptions, RuntimeTtsSpeechSource } from "./tts";
|
|
56
|
+
export type { RuntimeAudioTransport } from "./audio-udp";
|
|
57
|
+
export type { RuntimeStatus, RuntimeSnapshot } from "./status";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The public runtime surface, implemented by `Runtime` below.
|
|
61
|
+
*
|
|
62
|
+
* Defined here (rather than in the protocol package) because it is the client's
|
|
63
|
+
* API, not a wire type: it composes the wire types from
|
|
64
|
+
* `@mentra/cloud-protocol` into the methods a host calls. The per-event
|
|
65
|
+
* `on*` methods are sugar over the generic typed emitter, so there is one source
|
|
66
|
+
* of truth and no event-name strings to mistype. Every subscribe call returns an
|
|
67
|
+
* unsubscribe function.
|
|
68
|
+
*/
|
|
69
|
+
export interface RuntimeModule {
|
|
70
|
+
connect(): Promise<void>;
|
|
71
|
+
close(): void;
|
|
72
|
+
|
|
73
|
+
setSubscriptions(subs: AudioSubscription[]): Promise<void>;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Encrypt and send one captured audio frame over UDP. On the phone the native
|
|
77
|
+
* audio bridge calls this per frame (mic -> codec -> here); the bytes are
|
|
78
|
+
* encrypted in the shared core and handed to the injected UDP socket. A frame
|
|
79
|
+
* sent before the session is configured is dropped, not thrown on.
|
|
80
|
+
*/
|
|
81
|
+
sendAudioFrame(frame: Uint8Array): void;
|
|
82
|
+
|
|
83
|
+
getStatus(): RuntimeSnapshot;
|
|
84
|
+
|
|
85
|
+
onTranscript(handler: (data: TranscriptionData) => void): () => void;
|
|
86
|
+
onTranslation(handler: (data: TranslationData) => void): () => void;
|
|
87
|
+
|
|
88
|
+
requestManagedPhoto(opts: PhotoOptions): Promise<{ requestId: string; readUrl: string }>;
|
|
89
|
+
startManagedPhoto(opts: PhotoOptions): Promise<{ requestId: string; uploadUrl: string; readUrl: string }>;
|
|
90
|
+
awaitManagedPhotoReady(requestId: string): Promise<{ requestId: string; readUrl: string }>;
|
|
91
|
+
startManagedStream(opts: StreamOptions): Promise<ManagedStream>;
|
|
92
|
+
getManagedStreamStatus(streamId: string): Promise<StreamStatusResult>;
|
|
93
|
+
stopManagedStream(streamId: string): Promise<void>;
|
|
94
|
+
tts: {
|
|
95
|
+
speak(text: string, options?: RuntimeTtsSpeakOptions): Promise<RuntimeTtsSpeechSource>;
|
|
96
|
+
};
|
|
97
|
+
maps: {
|
|
98
|
+
directions(req: DirectionsRequest): Promise<DirectionsResult>;
|
|
99
|
+
reverseGeocode(coord: LatLng): Promise<ReverseGeocodeResult>;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
onConnected(handler: () => void): () => void;
|
|
103
|
+
onDisconnected(handler: (info: { reason: string }) => void): () => void;
|
|
104
|
+
onStatusChanged(handler: (status: RuntimeSnapshot) => void): () => void;
|
|
105
|
+
onError(handler: (err: ProtocolError) => void): () => void;
|
|
106
|
+
|
|
107
|
+
on<K extends keyof RuntimeEvents>(event: K, handler: (data: RuntimeEvents[K]) => void): () => void;
|
|
108
|
+
off<K extends keyof RuntimeEvents>(event: K, handler: (data: RuntimeEvents[K]) => void): void;
|
|
109
|
+
onAny(handler: (event: keyof RuntimeEvents, data: unknown) => void): () => void;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface RuntimeDeps {
|
|
113
|
+
connection: Connection;
|
|
114
|
+
emitter: RuntimeEmitter;
|
|
115
|
+
subscriptions: Subscriptions;
|
|
116
|
+
camera: Camera;
|
|
117
|
+
tts: Tts;
|
|
118
|
+
maps: Maps;
|
|
119
|
+
audio: UdpAudio;
|
|
120
|
+
logger: Logger;
|
|
121
|
+
/**
|
|
122
|
+
* Force `cloud.auth` to drop its cached access token and refresh now.
|
|
123
|
+
*
|
|
124
|
+
* Called when the cloud rejects the handshake with `AUTH_EXPIRED` even though
|
|
125
|
+
* the client thought its token was fresh (clock skew or a mid-session revoke).
|
|
126
|
+
* The connection re-reads the token through its own `getToken` on the next
|
|
127
|
+
* open, so this only has to invalidate-and-refresh; it returns the new token
|
|
128
|
+
* but the runtime ignores it (the reopen picks it up).
|
|
129
|
+
*/
|
|
130
|
+
forceRefreshToken: () => Promise<string>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Protocol error code the cloud sends when the handshake token is expired. */
|
|
134
|
+
const AUTH_EXPIRED_CODE = "AUTH_EXPIRED";
|
|
135
|
+
|
|
136
|
+
export class Runtime implements RuntimeModule {
|
|
137
|
+
private readonly connection: Connection;
|
|
138
|
+
private readonly emitter: RuntimeEmitter;
|
|
139
|
+
private readonly subscriptions: Subscriptions;
|
|
140
|
+
private readonly camera: Camera;
|
|
141
|
+
public readonly tts: Tts;
|
|
142
|
+
public readonly maps: Maps;
|
|
143
|
+
private readonly audio: UdpAudio;
|
|
144
|
+
private readonly logger: Logger;
|
|
145
|
+
private readonly forceRefreshToken: () => Promise<string>;
|
|
146
|
+
private status: RuntimeSnapshot = {
|
|
147
|
+
status: "disconnected",
|
|
148
|
+
audioTransport: "none",
|
|
149
|
+
};
|
|
150
|
+
private hostClosed = true;
|
|
151
|
+
private udpProbeTimer: ReturnType<typeof setInterval> | null = null;
|
|
152
|
+
private udpProbeStartedAt = 0;
|
|
153
|
+
private lastUdpAckAt = 0;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Whether the inbound-message routing has been wired to the connection yet.
|
|
157
|
+
*
|
|
158
|
+
* Wiring happens lazily on the first `connect()` so a re-`connect()` (after a
|
|
159
|
+
* `close()`) does not register the same routing twice and double-emit events.
|
|
160
|
+
*/
|
|
161
|
+
private routed = false;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Whether the first successful open has been handled by `connect()`.
|
|
165
|
+
*
|
|
166
|
+
* The connection fires `onState("open")` on every open, including the first.
|
|
167
|
+
* `connect()` already handles the first open directly (configure audio, emit
|
|
168
|
+
* connected), so this flag lets the state callback treat only *subsequent*
|
|
169
|
+
* opens as reconnects, avoiding a double audio-configure and a re-send on the
|
|
170
|
+
* very first connect.
|
|
171
|
+
*/
|
|
172
|
+
private opened = false;
|
|
173
|
+
|
|
174
|
+
constructor(deps: RuntimeDeps) {
|
|
175
|
+
this.connection = deps.connection;
|
|
176
|
+
this.emitter = deps.emitter;
|
|
177
|
+
this.subscriptions = deps.subscriptions;
|
|
178
|
+
this.camera = deps.camera;
|
|
179
|
+
this.tts = deps.tts;
|
|
180
|
+
this.maps = deps.maps;
|
|
181
|
+
this.audio = deps.audio;
|
|
182
|
+
this.logger = deps.logger;
|
|
183
|
+
this.forceRefreshToken = deps.forceRefreshToken;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Open the live session and start routing inbound messages to events.
|
|
188
|
+
*
|
|
189
|
+
* The connection owns the handshake (init/ack), reconnect with backoff, and
|
|
190
|
+
* liveness; this method wires the routing once, then opens it. On the ack we
|
|
191
|
+
* configure the UDP audio path (the ack carries the sessionTag, host/port, and
|
|
192
|
+
* key) and announce `connected`. A later reconnect re-runs the handshake inside
|
|
193
|
+
* the connection and re-fires the open path through the wired callbacks, so the
|
|
194
|
+
* subscription re-send below is what restores a fresh session's audio set.
|
|
195
|
+
*
|
|
196
|
+
* If the cloud rejects the handshake with a fatal `AUTH_EXPIRED` (the token the
|
|
197
|
+
* client presented was expired or revoked despite the client thinking it was
|
|
198
|
+
* fresh), we force `cloud.auth` to refresh and reopen ONCE. The connection
|
|
199
|
+
* re-reads the freshly refreshed token through its own `getToken` on the
|
|
200
|
+
* reopen. We retry only once: if the second open also fails, the original error
|
|
201
|
+
* surfaces, because a second failure means refresh did not fix it and looping
|
|
202
|
+
* would not help.
|
|
203
|
+
*/
|
|
204
|
+
async connect(): Promise<void> {
|
|
205
|
+
this.hostClosed = false;
|
|
206
|
+
this.wireRouting();
|
|
207
|
+
const ack = await this.openWithAuthRetry();
|
|
208
|
+
if (!this.opened) {
|
|
209
|
+
this.onOpened(ack);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Open the connection, retrying exactly once on a fatal `AUTH_EXPIRED`.
|
|
215
|
+
*
|
|
216
|
+
* Only `AUTH_EXPIRED` triggers the retry; any other handshake rejection (a bad
|
|
217
|
+
* protocol version, a hard auth failure) is surfaced as-is, since refreshing
|
|
218
|
+
* the token would not change the outcome. The retry forces a token refresh
|
|
219
|
+
* first, then reopens; if that reopen also rejects, the reopen's error
|
|
220
|
+
* propagates so the host sees the real, post-refresh failure.
|
|
221
|
+
*/
|
|
222
|
+
private async openWithAuthRetry(): Promise<ConnectionAck> {
|
|
223
|
+
try {
|
|
224
|
+
return await this.connection.open();
|
|
225
|
+
} catch (err) {
|
|
226
|
+
if (!(err instanceof HandshakeRejectedError) || err.code !== AUTH_EXPIRED_CODE) {
|
|
227
|
+
throw err;
|
|
228
|
+
}
|
|
229
|
+
// The cloud says the token is expired even though we believed it fresh.
|
|
230
|
+
// Refresh once (the reopen re-reads the new token via getToken) and retry.
|
|
231
|
+
this.logger.warn("handshake rejected AUTH_EXPIRED; refreshing token and reopening once");
|
|
232
|
+
await this.forceRefreshToken();
|
|
233
|
+
return this.connection.open();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Wire the connection's message/state callbacks to the emitter and camera.
|
|
239
|
+
*
|
|
240
|
+
* Done once (guarded by `routed`). Inbound routing:
|
|
241
|
+
* - stream.transcript -> emitter "transcript"
|
|
242
|
+
* - stream.translation -> emitter "translation"
|
|
243
|
+
* - error -> emitter "error" (the typed ProtocolError payload)
|
|
244
|
+
* - photo pushes -> camera.handlePush (resolves the pending request)
|
|
245
|
+
*
|
|
246
|
+
* Connection state drives `disconnected` and the subscription re-send on
|
|
247
|
+
* reconnect. We hand every message to `camera.handlePush` regardless of type
|
|
248
|
+
* because it self-filters to photo pushes, which keeps the routing switch here
|
|
249
|
+
* about the events this module owns.
|
|
250
|
+
*/
|
|
251
|
+
private wireRouting(): void {
|
|
252
|
+
if (this.routed) return;
|
|
253
|
+
this.routed = true;
|
|
254
|
+
|
|
255
|
+
this.connection.onMessage((msg: CloudToClientMessage) => {
|
|
256
|
+
switch (msg.type) {
|
|
257
|
+
case "stream.transcript":
|
|
258
|
+
// The discriminated union narrows `msg.payload` to TranscriptionData
|
|
259
|
+
// here, so the emit is fully typed with no cast.
|
|
260
|
+
this.emitter.emit("transcript", msg.payload);
|
|
261
|
+
break;
|
|
262
|
+
case "stream.translation":
|
|
263
|
+
this.emitter.emit("translation", msg.payload);
|
|
264
|
+
break;
|
|
265
|
+
case "audio.udp_liveness_ack":
|
|
266
|
+
this.handleUdpLivenessAck(msg.payload);
|
|
267
|
+
break;
|
|
268
|
+
case "error":
|
|
269
|
+
this.emitter.emit("error", msg.payload);
|
|
270
|
+
break;
|
|
271
|
+
default:
|
|
272
|
+
// Not one of the events this module surfaces directly. It may still be
|
|
273
|
+
// a camera push, so hand it on; camera ignores anything that is not a
|
|
274
|
+
// photo event.
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
// Managed photo/stream completions are pushes too; let camera claim them.
|
|
278
|
+
this.camera.handlePush(msg);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
this.connection.onState((state) => {
|
|
282
|
+
if (state === "connecting") {
|
|
283
|
+
this.updateStatus({
|
|
284
|
+
status: this.opened ? "reconnecting" : "connecting",
|
|
285
|
+
});
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (state === "open") {
|
|
290
|
+
if (!this.opened) {
|
|
291
|
+
// Usually the first open is handled by connect() directly. If that
|
|
292
|
+
// initial attempt failed, Connection still keeps retrying underneath;
|
|
293
|
+
// the first later successful retry reaches us only through this state
|
|
294
|
+
// callback, so it must become the initial open instead of being
|
|
295
|
+
// ignored.
|
|
296
|
+
const ack = this.connection.ack;
|
|
297
|
+
if (ack) this.onOpened(ack);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// A reconnect re-opened the socket and redid the handshake. The cloud may
|
|
301
|
+
// have a fresh session with an empty subscription set, so re-send the
|
|
302
|
+
// current set (at the current version) to restore live transcription.
|
|
303
|
+
void this.handleReopen();
|
|
304
|
+
} else if (state === "closed") {
|
|
305
|
+
this.stopUdpLiveness();
|
|
306
|
+
this.updateStatus({
|
|
307
|
+
status: this.hostClosed
|
|
308
|
+
? "disconnected"
|
|
309
|
+
: this.opened
|
|
310
|
+
? "reconnecting"
|
|
311
|
+
: "connecting",
|
|
312
|
+
audioTransport: "none",
|
|
313
|
+
});
|
|
314
|
+
this.emitter.emit("disconnected", { reason: "socket closed" });
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* React to a (re)opened socket after the handshake completed.
|
|
321
|
+
*
|
|
322
|
+
* Reconfigures the UDP audio path against the new ack (a fresh session brings a
|
|
323
|
+
* fresh key and tag) and re-sends the subscription set so the new session is
|
|
324
|
+
* not left transcribing against an empty set. Guarded on the ack being present
|
|
325
|
+
* because a reconnect's `open` state can briefly precede the new ack being
|
|
326
|
+
* recorded; the next `open` (or an explicit setSubscriptions) then covers it.
|
|
327
|
+
*/
|
|
328
|
+
private async handleReopen(): Promise<void> {
|
|
329
|
+
const ack = this.connection.ack;
|
|
330
|
+
if (!ack) return;
|
|
331
|
+
this.configureAudio(ack);
|
|
332
|
+
this.updateStatus({ status: "connected" });
|
|
333
|
+
// Announce the reconnection the same way the first open does. Without this,
|
|
334
|
+
// a host that tracks liveness via `onConnected`/`onDisconnected` would stay
|
|
335
|
+
// stuck "disconnected" after every reconnect (its flag never flips back),
|
|
336
|
+
// even though the socket is live and transcription has resumed.
|
|
337
|
+
this.emitter.emit("connected", undefined);
|
|
338
|
+
try {
|
|
339
|
+
await this.subscriptions.resend(ack.sessionId);
|
|
340
|
+
} catch (err) {
|
|
341
|
+
// A failed re-send is not fatal to the connection: log and let the next
|
|
342
|
+
// explicit setSubscriptions or reconnect retry. Never log token material.
|
|
343
|
+
this.logger.warn("subscription resend failed after reconnect", {
|
|
344
|
+
sessionId: ack.sessionId,
|
|
345
|
+
});
|
|
346
|
+
void err;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Handle the first successful open from `connect()`.
|
|
352
|
+
*
|
|
353
|
+
* Configures audio from the ack and announces `connected`. Subscriptions are
|
|
354
|
+
* not re-sent here because the initial set rides in `connection.init` (seeded
|
|
355
|
+
* with the session); the re-send path is specifically for reconnects.
|
|
356
|
+
*/
|
|
357
|
+
private onOpened(ack: ConnectionAck): void {
|
|
358
|
+
this.opened = true;
|
|
359
|
+
this.configureAudio(ack);
|
|
360
|
+
this.updateStatus({ status: "connected" });
|
|
361
|
+
this.emitter.emit("connected", undefined);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Configure the UDP audio path from an ack, when the cloud offered UDP audio.
|
|
366
|
+
*
|
|
367
|
+
* The ack's `audio` block is optional: on the WebSocket audio fallback there is
|
|
368
|
+
* no UDP and no key, so there is nothing to configure and we leave the audio
|
|
369
|
+
* path idle.
|
|
370
|
+
*/
|
|
371
|
+
private configureAudio(ack: ConnectionAck): void {
|
|
372
|
+
if (ack.audio) {
|
|
373
|
+
this.audio.configure(ack.audio);
|
|
374
|
+
this.updateStatus({ audioTransport: "udp" });
|
|
375
|
+
this.startUdpLiveness();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
this.audio.close();
|
|
379
|
+
this.stopUdpLiveness();
|
|
380
|
+
this.updateStatus({ audioTransport: "none" });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Close the session: tear down the UDP audio path and the WebSocket.
|
|
385
|
+
*
|
|
386
|
+
* Audio is closed first so no frame is sent on a socket that is going away.
|
|
387
|
+
*/
|
|
388
|
+
close(): void {
|
|
389
|
+
this.hostClosed = true;
|
|
390
|
+
this.opened = false;
|
|
391
|
+
this.stopUdpLiveness();
|
|
392
|
+
this.audio.close();
|
|
393
|
+
this.updateStatus({ status: "disconnected", audioTransport: "none" });
|
|
394
|
+
this.connection.close();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Replace the cloud's subscription set for the current session.
|
|
399
|
+
*
|
|
400
|
+
* Uses the `sessionId` from the current ack so the cloud ties the write to this
|
|
401
|
+
* session. Throws if there is no live session (no ack), because a subscription
|
|
402
|
+
* write without a session would be silently ignored by the cloud and a silent
|
|
403
|
+
* no-op is worse than a clear error here.
|
|
404
|
+
*/
|
|
405
|
+
async setSubscriptions(subs: AudioSubscription[]): Promise<void> {
|
|
406
|
+
const ack = this.connection.ack;
|
|
407
|
+
if (!ack) {
|
|
408
|
+
throw new Error("Cannot set subscriptions before the session is connected");
|
|
409
|
+
}
|
|
410
|
+
await this.subscriptions.set(subs, ack.sessionId);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Encrypt and send one audio frame over the UDP path (see RuntimeModule). */
|
|
414
|
+
sendAudioFrame(frame: Uint8Array): void {
|
|
415
|
+
if (this.status.audioTransport === "ws") {
|
|
416
|
+
const packet = this.audio.buildPlainFrame(frame);
|
|
417
|
+
if (packet) {
|
|
418
|
+
this.connection.sendBinary(packet);
|
|
419
|
+
}
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (this.status.audioTransport === "udp") {
|
|
424
|
+
this.audio.sendFrame(frame);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
getStatus(): RuntimeSnapshot {
|
|
429
|
+
return { ...this.status };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// --- Camera: managed photo/stream (delegated) -----------------------------
|
|
433
|
+
|
|
434
|
+
requestManagedPhoto(opts: PhotoOptions): Promise<{ requestId: string; readUrl: string }> {
|
|
435
|
+
return this.camera.requestPhoto(opts);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Device-side managed photo: presign now, deliver bytes yourself, then await ready. */
|
|
439
|
+
startManagedPhoto(opts: PhotoOptions): Promise<{ requestId: string; uploadUrl: string; readUrl: string }> {
|
|
440
|
+
return this.camera.startPhoto(opts);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
awaitManagedPhotoReady(requestId: string): Promise<{ requestId: string; readUrl: string }> {
|
|
444
|
+
return this.camera.awaitPhotoReady(requestId);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
startManagedStream(opts: StreamOptions): Promise<ManagedStream> {
|
|
448
|
+
return this.camera.startStream(opts);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
getManagedStreamStatus(streamId: string): Promise<StreamStatusResult> {
|
|
452
|
+
return this.camera.streamStatus(streamId);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
stopManagedStream(streamId: string): Promise<void> {
|
|
456
|
+
return this.camera.stopStream(streamId);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// --- Events (delegated to the one typed emitter) --------------------------
|
|
460
|
+
|
|
461
|
+
onTranscript(handler: (data: TranscriptionData) => void): () => void {
|
|
462
|
+
return this.emitter.on("transcript", handler);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
onTranslation(handler: (data: TranslationData) => void): () => void {
|
|
466
|
+
return this.emitter.on("translation", handler);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
onConnected(handler: () => void): () => void {
|
|
470
|
+
// The "connected" event carries no payload; adapt the void payload to the
|
|
471
|
+
// caller's zero-arg handler.
|
|
472
|
+
return this.emitter.on("connected", () => handler());
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
onDisconnected(handler: (info: { reason: string }) => void): () => void {
|
|
476
|
+
return this.emitter.on("disconnected", handler);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
onStatusChanged(handler: (status: RuntimeSnapshot) => void): () => void {
|
|
480
|
+
return this.emitter.on("status", handler);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
onError(handler: (err: ProtocolError) => void): () => void {
|
|
484
|
+
return this.emitter.on("error", handler);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
on<K extends keyof RuntimeEvents>(event: K, handler: (data: RuntimeEvents[K]) => void): () => void {
|
|
488
|
+
return this.emitter.on(event, handler);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
off<K extends keyof RuntimeEvents>(event: K, handler: (data: RuntimeEvents[K]) => void): void {
|
|
492
|
+
this.emitter.off(event, handler);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
onAny(handler: (event: keyof RuntimeEvents, data: unknown) => void): () => void {
|
|
496
|
+
return this.emitter.onAny(handler);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private updateStatus(next: Partial<RuntimeSnapshot>): void {
|
|
500
|
+
const snapshot = { ...this.status, ...next };
|
|
501
|
+
if (
|
|
502
|
+
snapshot.status === this.status.status &&
|
|
503
|
+
snapshot.audioTransport === this.status.audioTransport
|
|
504
|
+
) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
this.status = snapshot;
|
|
508
|
+
this.emitter.emit("status", { ...snapshot });
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private startUdpLiveness(): void {
|
|
512
|
+
this.stopUdpLiveness();
|
|
513
|
+
this.udpProbeStartedAt = Date.now();
|
|
514
|
+
this.lastUdpAckAt = 0;
|
|
515
|
+
this.sendUdpProbe();
|
|
516
|
+
this.udpProbeTimer = setInterval(() => {
|
|
517
|
+
this.sendUdpProbe();
|
|
518
|
+
this.checkUdpLiveness();
|
|
519
|
+
}, UDP_PROBE_INTERVAL_MS);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private stopUdpLiveness(): void {
|
|
523
|
+
if (this.udpProbeTimer) {
|
|
524
|
+
clearInterval(this.udpProbeTimer);
|
|
525
|
+
this.udpProbeTimer = null;
|
|
526
|
+
}
|
|
527
|
+
this.udpProbeStartedAt = 0;
|
|
528
|
+
this.lastUdpAckAt = 0;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private sendUdpProbe(): void {
|
|
532
|
+
const tag = this.audio.sessionTag;
|
|
533
|
+
if (tag === null) return;
|
|
534
|
+
const probeId = `${tag}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
|
|
535
|
+
this.audio.sendProbe(probeId);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private checkUdpLiveness(): void {
|
|
539
|
+
if (this.status.status !== "connected") return;
|
|
540
|
+
const since = this.lastUdpAckAt || this.udpProbeStartedAt;
|
|
541
|
+
if (since === 0 || Date.now() - since < UDP_LIVENESS_TIMEOUT_MS) return;
|
|
542
|
+
this.updateStatus({ audioTransport: this.connection.isOpen ? "ws" : "none" });
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private handleUdpLivenessAck(payload: {
|
|
546
|
+
sessionId: string;
|
|
547
|
+
sessionTag: number;
|
|
548
|
+
probeId: string;
|
|
549
|
+
receivedAt: number;
|
|
550
|
+
}): void {
|
|
551
|
+
if (payload.sessionTag !== this.audio.sessionTag) return;
|
|
552
|
+
this.lastUdpAckAt = Date.now();
|
|
553
|
+
if (this.status.status === "connected") {
|
|
554
|
+
this.updateStatus({ audioTransport: "udp" });
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RuntimeAudioTransport } from "./audio-udp";
|
|
2
|
+
|
|
3
|
+
export type RuntimeStatus =
|
|
4
|
+
| "connecting"
|
|
5
|
+
| "connected"
|
|
6
|
+
| "reconnecting"
|
|
7
|
+
| "disconnected";
|
|
8
|
+
|
|
9
|
+
export interface RuntimeSnapshot {
|
|
10
|
+
status: RuntimeStatus;
|
|
11
|
+
audioTransport: RuntimeAudioTransport;
|
|
12
|
+
}
|