@mentra/cloud-client 0.1.0-dev.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/node/index.ts +54 -0
- package/node/transports.ts +133 -0
- package/package.json +46 -0
- package/react-native/index.ts +38 -0
- package/react-native/transports.ts +216 -0
- package/src/client.ts +265 -0
- package/src/config.ts +84 -0
- package/src/errors.ts +54 -0
- package/src/http.ts +273 -0
- package/src/index.ts +70 -0
- package/src/logger.ts +34 -0
- package/src/modules/auth/auth.ts +548 -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 +68 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The audio subscription REST writer, with the version counter.
|
|
3
|
+
*
|
|
4
|
+
* Subscriptions move over REST (not the WebSocket) so the client gets
|
|
5
|
+
* request/response and the WebSocket stays a downstream push channel. This class
|
|
6
|
+
* owns the full-replace semantics: every change ships the entire desired set,
|
|
7
|
+
* not a diff, so the cloud never has to reconcile partial updates.
|
|
8
|
+
*
|
|
9
|
+
* Two fields exist purely to survive the legacy scars the cloud guards against:
|
|
10
|
+
* - `version`: a monotonic counter the client bumps on every change, so the
|
|
11
|
+
* cloud can discard an out-of-order arrival (a retried or reordered write
|
|
12
|
+
* cannot clobber a newer set).
|
|
13
|
+
* - `sessionId`: tied to the current session from `connection.ack`, so a write
|
|
14
|
+
* from a stale session is ignored by the cloud.
|
|
15
|
+
*
|
|
16
|
+
* See docs/issues/002-cloud-runtime/audio/wire.md ("Subscription REST endpoint")
|
|
17
|
+
* and docs/issues/004-cloud-client/design.md ("Subscriptions").
|
|
18
|
+
*/
|
|
19
|
+
import type { HttpClient } from "../../http";
|
|
20
|
+
import type { AudioSubscription } from "@mentra/cloud-protocol";
|
|
21
|
+
|
|
22
|
+
/** The REST path the cloud exposes for the full-replace subscription write. */
|
|
23
|
+
const SUBSCRIPTIONS_PATH = "/api/audio/subscriptions";
|
|
24
|
+
|
|
25
|
+
export interface SubscriptionsDeps {
|
|
26
|
+
http: HttpClient;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class Subscriptions {
|
|
30
|
+
private readonly http: HttpClient;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The last set we sent. Kept so a reconnect can re-send the exact same set at
|
|
34
|
+
* the current version without the caller having to remember it.
|
|
35
|
+
*/
|
|
36
|
+
private current: AudioSubscription[] = [];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The monotonic version of the set above. Starts at 0 so the first real
|
|
40
|
+
* `set()` ships version 1; the cloud treats higher as newer.
|
|
41
|
+
*/
|
|
42
|
+
private version = 0;
|
|
43
|
+
|
|
44
|
+
constructor(deps: SubscriptionsDeps) {
|
|
45
|
+
this.http = deps.http;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The current desired subscription set (the last one `set()` recorded).
|
|
50
|
+
*
|
|
51
|
+
* Read by the connection's `initPayload` so a (re)connect's `connection.init`
|
|
52
|
+
* carries the live set as `audio.initialSubscriptions`. That makes the cloud
|
|
53
|
+
* seed the new session's subscription key NON-EMPTY at handshake, so the
|
|
54
|
+
* reconnected session transcribes atomically — without depending on the
|
|
55
|
+
* follow-up REST resend landing (whose control-stream nudge can be missed by a
|
|
56
|
+
* just-created `$`-positioned consumer group on the new owner pod). Returns a
|
|
57
|
+
* copy so a caller cannot mutate our cached set.
|
|
58
|
+
*/
|
|
59
|
+
currentSet(): AudioSubscription[] {
|
|
60
|
+
return [...this.current];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Replace the cloud's subscription set with `subs` for this session.
|
|
65
|
+
*
|
|
66
|
+
* Bumps the version on every call (even when the set is unchanged) so the
|
|
67
|
+
* cloud always sees a strictly increasing version and applies the latest. The
|
|
68
|
+
* full set is sent each time because the contract is full-replace, not a diff:
|
|
69
|
+
* the cloud holds the authoritative set and we overwrite it wholesale.
|
|
70
|
+
*/
|
|
71
|
+
async set(subs: AudioSubscription[], sessionId: string): Promise<void> {
|
|
72
|
+
// Snapshot the set so a later in-place mutation by the caller cannot change
|
|
73
|
+
// what we believe we sent (and would re-send on reconnect).
|
|
74
|
+
this.current = [...subs];
|
|
75
|
+
this.version += 1;
|
|
76
|
+
await this.send(sessionId);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Re-send the current set at the current version for a (possibly new) session.
|
|
81
|
+
*
|
|
82
|
+
* Used on reconnect: the cloud may have a fresh session whose subscription set
|
|
83
|
+
* is empty, so without this re-send a live transcription would silently stop.
|
|
84
|
+
* We do NOT bump the version here, because this is a replay of the same
|
|
85
|
+
* logical set rather than a new change; the new `sessionId` is what makes the
|
|
86
|
+
* write land against the reconnected session.
|
|
87
|
+
*/
|
|
88
|
+
async resend(sessionId: string): Promise<void> {
|
|
89
|
+
await this.send(sessionId);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Ship the current set, retrying REJECTED writes (not just transport
|
|
94
|
+
* failures). A "stale-session" rejection means a half-open ghost session
|
|
95
|
+
* still holds the cloud's subscription record — typically the app's previous
|
|
96
|
+
* process after a force-stop/crash, whose socket died without a close frame.
|
|
97
|
+
* The cloud refuses to hand the record to us while the ghost still looks
|
|
98
|
+
* alive, but its liveness window lapses within ~45s and the cloud then
|
|
99
|
+
* accepts a takeover write — so a patient retry self-heals where a
|
|
100
|
+
* fire-and-forget write would leave transcription silently dead until the
|
|
101
|
+
* next reconnect. "stale-version" adopts the cloud's authoritative version
|
|
102
|
+
* and retries once above it.
|
|
103
|
+
*/
|
|
104
|
+
private async send(sessionId: string): Promise<void> {
|
|
105
|
+
const RETRY_DELAYS_MS = [5_000, 15_000, 30_000, 45_000];
|
|
106
|
+
for (let attempt = 0; ; attempt++) {
|
|
107
|
+
// The full-replace PUT is idempotent: replaying it lands the same state,
|
|
108
|
+
// so the HTTP helper may retry it on transient network failure too.
|
|
109
|
+
const res = await this.http.put<{
|
|
110
|
+
applied: boolean;
|
|
111
|
+
version: number;
|
|
112
|
+
reason: string | null;
|
|
113
|
+
}>(
|
|
114
|
+
SUBSCRIPTIONS_PATH,
|
|
115
|
+
{
|
|
116
|
+
subscriptions: this.current,
|
|
117
|
+
sessionId,
|
|
118
|
+
version: this.version,
|
|
119
|
+
},
|
|
120
|
+
{ idempotent: true },
|
|
121
|
+
);
|
|
122
|
+
if (res?.applied !== false) return;
|
|
123
|
+
|
|
124
|
+
if (res.reason === "stale-version") {
|
|
125
|
+
// The cloud holds a newer version for this session (e.g. a replayed
|
|
126
|
+
// older write). Adopt its counter and go strictly above it.
|
|
127
|
+
this.version = Math.max(this.version, res.version) + 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const delay = RETRY_DELAYS_MS[attempt];
|
|
132
|
+
if (res.reason !== "stale-session" || delay === undefined) return;
|
|
133
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Runtime TTS API.
|
|
3
|
+
*
|
|
4
|
+
* The host owns the actual playback device (phone speaker / glasses audio
|
|
5
|
+
* route), but the cloud-client owns the runtime endpoint, auth, and fast
|
|
6
|
+
* validation. Callers receive a playable audio source only after the runtime
|
|
7
|
+
* confirms the route exists and returns audio.
|
|
8
|
+
*/
|
|
9
|
+
import type { HttpClient } from "../../http";
|
|
10
|
+
|
|
11
|
+
const TTS_PATH = "/api/tts/speak";
|
|
12
|
+
|
|
13
|
+
export interface RuntimeTtsSpeakOptions {
|
|
14
|
+
voiceId?: string;
|
|
15
|
+
voice_id?: string;
|
|
16
|
+
modelId?: string;
|
|
17
|
+
model_id?: string;
|
|
18
|
+
voiceSettings?: Record<string, unknown>;
|
|
19
|
+
voice_settings?: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RuntimeTtsSpeechSource {
|
|
23
|
+
audioUrl: string;
|
|
24
|
+
contentType: string;
|
|
25
|
+
source: "cloud";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TtsDeps {
|
|
29
|
+
http: HttpClient;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class Tts {
|
|
33
|
+
private readonly http: HttpClient;
|
|
34
|
+
|
|
35
|
+
constructor(deps: TtsDeps) {
|
|
36
|
+
this.http = deps.http;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Prepare cloud TTS for host playback.
|
|
41
|
+
*
|
|
42
|
+
* This intentionally returns an audio source rather than playing audio itself:
|
|
43
|
+
* React Native and Node hosts have different playback devices. The important
|
|
44
|
+
* boundary is that callers do not construct runtime URLs or discover 404s via
|
|
45
|
+
* a media player timeout.
|
|
46
|
+
*/
|
|
47
|
+
async speak(text: string, options: RuntimeTtsSpeakOptions = {}): Promise<RuntimeTtsSpeechSource> {
|
|
48
|
+
if (!text.trim()) {
|
|
49
|
+
throw new Error("runtime.tts.speak requires text");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const path = this.buildTtsPath(text, options);
|
|
53
|
+
const res = await this.http.head(path, { idempotent: true });
|
|
54
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
55
|
+
if (!contentType.toLowerCase().startsWith("audio/")) {
|
|
56
|
+
throw new Error(`Runtime TTS returned non-audio content-type: ${contentType || "missing"}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
audioUrl: this.http.url(path),
|
|
61
|
+
contentType,
|
|
62
|
+
source: "cloud",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private buildTtsPath(text: string, options: RuntimeTtsSpeakOptions): string {
|
|
67
|
+
const query = new URLSearchParams();
|
|
68
|
+
query.set("text", text);
|
|
69
|
+
|
|
70
|
+
const voiceId = options.voiceId ?? options.voice_id;
|
|
71
|
+
if (voiceId) query.set("voice_id", voiceId);
|
|
72
|
+
|
|
73
|
+
const modelId = options.modelId ?? options.model_id;
|
|
74
|
+
if (modelId) query.set("model_id", modelId);
|
|
75
|
+
|
|
76
|
+
const voiceSettings = options.voiceSettings ?? options.voice_settings;
|
|
77
|
+
if (voiceSettings) query.set("voice_settings", JSON.stringify(voiceSettings));
|
|
78
|
+
|
|
79
|
+
return `${TTS_PATH}?${query.toString()}`;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The three platform pieces the shared core takes as inputs.
|
|
3
|
+
*
|
|
4
|
+
* A phone and a server differ in exactly three places: how they open a
|
|
5
|
+
* WebSocket, how they send UDP, and where they store tokens. The shared logic
|
|
6
|
+
* only ever touches these interfaces, never a real socket, so the same core code
|
|
7
|
+
* runs on the device and in a Node/Bun test harness. The `react-native` and
|
|
8
|
+
* `node` wrappers each supply concrete implementations.
|
|
9
|
+
*
|
|
10
|
+
* See docs/issues/004-cloud-client/spec.md ("Construction") and design.md.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A text WebSocket, reduced to the surface the runtime needs.
|
|
15
|
+
*
|
|
16
|
+
* Callbacks (not an EventEmitter) so the interface is identical across the RN
|
|
17
|
+
* built-in socket, nitro-websockets, and the Node `ws` package, none of which
|
|
18
|
+
* share an event API.
|
|
19
|
+
*/
|
|
20
|
+
export interface WebSocketLike {
|
|
21
|
+
send(data: string): void;
|
|
22
|
+
sendBinary(data: Uint8Array): void;
|
|
23
|
+
close(): void;
|
|
24
|
+
onOpen(cb: () => void): void;
|
|
25
|
+
onMessage(cb: (data: string) => void): void;
|
|
26
|
+
onClose(cb: (info: { code: number; reason: string }) => void): void;
|
|
27
|
+
onError(cb: (err: unknown) => void): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A UDP socket for the audio path.
|
|
32
|
+
*
|
|
33
|
+
* The encrypted audio bytes are built in the shared core and handed here as raw
|
|
34
|
+
* bytes, so this interface stays a thin send/receive with no codec or crypto
|
|
35
|
+
* knowledge. `host`/`port` come from `connection.ack`, not from construction,
|
|
36
|
+
* because they are assigned per session.
|
|
37
|
+
*/
|
|
38
|
+
export interface UdpSocketLike {
|
|
39
|
+
send(bytes: Uint8Array, host: string, port: number): void;
|
|
40
|
+
onMessage(cb: (bytes: Uint8Array) => void): void;
|
|
41
|
+
close(): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A tiny key/value store for credentials.
|
|
46
|
+
*
|
|
47
|
+
* Only the refresh token needs to survive a relaunch, so the surface stays at
|
|
48
|
+
* get/set/delete. On the device this is the OS secure store; in tests it is
|
|
49
|
+
* memory or a temp file. Async because the secure store is async.
|
|
50
|
+
*/
|
|
51
|
+
export interface KeyValueStore {
|
|
52
|
+
get(key: string): Promise<string | null>;
|
|
53
|
+
set(key: string, value: string): Promise<void>;
|
|
54
|
+
delete(key: string): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The bundle of platform pieces handed to the root `CloudClient`.
|
|
59
|
+
*
|
|
60
|
+
* `ws` and `udp` are factories (not instances) because the runtime opens a fresh
|
|
61
|
+
* socket on every (re)connect and a fresh UDP socket per session, rather than
|
|
62
|
+
* reusing one for the client's whole lifetime.
|
|
63
|
+
*/
|
|
64
|
+
export interface CloudClientTransports {
|
|
65
|
+
ws: (url: string) => WebSocketLike;
|
|
66
|
+
udp: () => UdpSocketLike;
|
|
67
|
+
storage: KeyValueStore;
|
|
68
|
+
}
|