@mentra/engine 3.4.0-dev.1181 → 3.4.0-dev.1183

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 (27) hide show
  1. package/build/generated/releaseMetadata.js +5 -5
  2. package/build/generated/releaseMetadata.js.map +1 -1
  3. package/build/services/DeviceEventRouter.js +2 -2
  4. package/build/services/DeviceEventRouter.js.map +1 -1
  5. package/build/services/LocalMiniappRuntime.d.ts +55 -5
  6. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  7. package/build/services/LocalMiniappRuntime.js +293 -171
  8. package/build/services/LocalMiniappRuntime.js.map +1 -1
  9. package/build/services/LocalSttFallbackCoordinator.d.ts +10 -32
  10. package/build/services/LocalSttFallbackCoordinator.d.ts.map +1 -1
  11. package/build/services/LocalSttFallbackCoordinator.js +16 -58
  12. package/build/services/LocalSttFallbackCoordinator.js.map +1 -1
  13. package/build/services/TranscriptionRouting.d.ts +112 -9
  14. package/build/services/TranscriptionRouting.d.ts.map +1 -1
  15. package/build/services/TranscriptionRouting.js +159 -21
  16. package/build/services/TranscriptionRouting.js.map +1 -1
  17. package/build/services/TranscriptionSubscriptions.d.ts +87 -6
  18. package/build/services/TranscriptionSubscriptions.d.ts.map +1 -1
  19. package/build/services/TranscriptionSubscriptions.js +174 -26
  20. package/build/services/TranscriptionSubscriptions.js.map +1 -1
  21. package/package.json +8 -8
  22. package/src/generated/releaseMetadata.ts +5 -5
  23. package/src/services/DeviceEventRouter.ts +2 -2
  24. package/src/services/LocalMiniappRuntime.ts +325 -213
  25. package/src/services/LocalSttFallbackCoordinator.ts +15 -62
  26. package/src/services/TranscriptionRouting.ts +200 -30
  27. package/src/services/TranscriptionSubscriptions.ts +245 -25
@@ -1 +1 @@
1
- {"version":3,"file":"TranscriptionRouting.js","sourceRoot":"","sources":["../../src/services/TranscriptionRouting.ts"],"names":[],"mappings":"AAYA,MAAM,UAAU,4BAA4B,CAC1C,WAAwB,EACxB,SAA2D;IAE3D,IAAI,kBAAkB,GAAG,KAAK,CAAA;IAC9B,IAAI,uBAAuB,GAAG,KAAK,CAAA;IACnC,KAAK,MAAM,UAAU,IAAI,WAAW,EAAE,CAAC;QACrC,MAAM,MAAM,GAAG,SAAS,CAAC,UAAU,CAAC,CAAA;QACpC,IAAI,MAAM,CAAC,KAAK;YAAE,kBAAkB,GAAG,IAAI,CAAA;QAC3C,IAAI,MAAM,CAAC,UAAU;YAAE,uBAAuB,GAAG,IAAI,CAAA;IACvD,CAAC;IACD,OAAO,EAAC,kBAAkB,EAAE,uBAAuB,EAAC,CAAA;AACtD,CAAC;AAED,MAAM,UAAU,0BAA0B,CACxC,MAAyB,EACzB,MAAqC,EACrC,cAAuB;IAEvB,IAAI,MAAM,KAAK,OAAO;QAAE,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAA;IAC9D,IAAI,cAAc;QAAE,OAAO,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAA;IAClE,IAAI,MAAM,CAAC,KAAK,IAAI,MAAM,CAAC,UAAU;QAAE,OAAO,KAAK,CAAA;IACnD,IAAI,MAAM,CAAC,UAAU;QAAE,OAAO,YAAY,CAAA;IAC1C,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAA;AACxC,CAAC","sourcesContent":["export interface TranscriptionRouteSummary {\n hasCloudSubscriber: boolean\n hasForceLocalSubscriber: boolean\n}\n\nexport interface TranscriptionSubscriberRoutes {\n cloud: boolean\n forceLocal: boolean\n}\n\nexport type TranscriptionDeliveryRoute = \"default\" | \"forceLocal\" | \"all\"\n\nexport function summarizeTranscriptionRoutes<T>(\n subscribers: Iterable<T>,\n routesFor: (subscriber: T) => TranscriptionSubscriberRoutes,\n): TranscriptionRouteSummary {\n let hasCloudSubscriber = false\n let hasForceLocalSubscriber = false\n for (const subscriber of subscribers) {\n const routes = routesFor(subscriber)\n if (routes.cloud) hasCloudSubscriber = true\n if (routes.forceLocal) hasForceLocalSubscriber = true\n }\n return {hasCloudSubscriber, hasForceLocalSubscriber}\n}\n\nexport function transcriptionDeliveryRoute(\n source: \"cloud\" | \"local\",\n routes: TranscriptionSubscriberRoutes,\n cloudConnected: boolean,\n): TranscriptionDeliveryRoute | null {\n if (source === \"cloud\") return routes.cloud ? \"default\" : null\n if (cloudConnected) return routes.forceLocal ? \"forceLocal\" : null\n if (routes.cloud && routes.forceLocal) return \"all\"\n if (routes.forceLocal) return \"forceLocal\"\n return routes.cloud ? \"default\" : null\n}\n"]}
1
+ {"version":3,"file":"TranscriptionRouting.js","sourceRoot":"","sources":["../../src/services/TranscriptionRouting.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Where each miniapp transcription listener gets its text: the cloud or the\n * phone's on-device recognizer.\n *\n * A listener asks for one of three behaviours (the SDK's `processing`):\n * - preferCloud (default): the cloud; on-device only while the cloud is\n * unreachable.\n * - preferLocal: on-device while the running model transcribes the\n * listener's language, otherwise the cloud.\n * - localOnly: on-device only; never the cloud.\n *\n * Each listener receives one source at a time (`handoffStep`). A planned\n * switch, while its current source still works, waits for a pause in which\n * both sources have settled (`UtteranceTracker`), then for a drain of the\n * on-device recognizer (it reports everything it heard so far and starts\n * over). The drain is the boundary: on-device text from before it stays with\n * the old side, on-device text after it is new speech, and the listener's\n * cloud text starts at the cloud audio position where the drain began. So a\n * listener never misses or repeats speech. A model that cannot be drained\n * leaves the quiet window as the boundary. A forced switch, when the current\n * source stops, happens at once; a move to the cloud then starts at the\n * cloud audio position of the last on-device final, so an utterance cut short\n * on-device is completed in the cloud and finished ones are not repeated.\n * The listeners' sources and targets decide what runs: a stream is requested\n * from the cloud only while a listener on it needs the cloud, and the\n * on-device recognizer runs only while a listener needs it.\n */\n\nexport type SpeechProcessing = \"preferCloud\" | \"preferLocal\" | \"localOnly\"\nexport type TranscriptionSource = \"cloud\" | \"local\"\n\nconst PROCESSING: ReadonlySet<unknown> = new Set<SpeechProcessing>([\"preferCloud\", \"preferLocal\", \"localOnly\"])\nconst AUTO_STREAM = \"transcription:auto\"\nconst STREAM_PREFIX = \"transcription:\"\n\n/**\n * A source has settled once it has produced nothing for this long since its\n * last final (or since it started): a pause in speech, longer than the\n * recognizers' own end-of-speech delays.\n */\nexport const SOURCE_QUIET_MS = 3000\n/** An utterance whose final never came has ended after this long without transcripts. */\nexport const UTTERANCE_STALE_MS = 6000\n\n/**\n * The processing a listener, subscription or speak request asks for.\n * `processing` wins; without it, the `forceLocal: true` of SDKs before it\n * means localOnly. Anything else is preferCloud.\n */\nexport function readSpeechProcessing(request: {processing?: unknown; forceLocal?: unknown}): SpeechProcessing {\n if (PROCESSING.has(request.processing)) return request.processing as SpeechProcessing\n return request.forceLocal === true ? \"localOnly\" : \"preferCloud\"\n}\n\nexport interface RoutedListener {\n /** `transcription:<language>` or `transcription:auto`. */\n stream: string\n processing: SpeechProcessing\n}\n\n/** What the phone can transcribe with right now, as one miniapp sees it. */\nexport interface TranscriptionRoutingState {\n cloudConnected: boolean\n /** On-device speech is allowed by this deployment. */\n onDevice: boolean\n /** BCP-47 language of the running on-device model, or null while none runs. */\n localLanguage: string | null\n /** BCP-47 language of the model picked on the Speech page. */\n pickedLanguage: string\n /** The miniapp's language hints (bare codes, from transcription.configure). */\n hints: readonly string[]\n}\n\n/**\n * Whether an on-device model in `language` transcribes what the listener asks\n * for. A specific language must be the model's. Auto-detect follows the\n * model's language, the one the user picked; but a preferLocal listener whose\n * miniapp hinted at another language keeps the cloud, which tells them apart.\n */\nexport function onDeviceServes(listener: RoutedListener, language: string, hints: readonly string[]): boolean {\n const requested = listener.stream.slice(STREAM_PREFIX.length).toLowerCase()\n if (requested === language.toLowerCase()) return true\n if (listener.stream !== AUTO_STREAM) return false\n if (listener.processing !== \"preferLocal\") return true\n const base = language.split(\"-\")[0].toLowerCase()\n return hints.every((hint) => hint.toLowerCase() === base)\n}\n\n/** The source the listener should receive now. */\nexport function targetSource(listener: RoutedListener, state: TranscriptionRoutingState): TranscriptionSource {\n if (listener.processing === \"localOnly\" || !state.cloudConnected) return \"local\"\n if (listener.processing === \"preferCloud\") return \"cloud\"\n return state.onDevice && state.localLanguage !== null && onDeviceServes(listener, state.localLanguage, state.hints)\n ? \"local\"\n : \"cloud\"\n}\n\n/**\n * What a listener on `current` does on its way to `target`:\n * - \"settle\": it has no source yet or is already there; take `target`.\n * - \"forced\": `current` can no longer deliver; move now.\n * - \"wait\": a source is still busy on its streams; stay.\n * - \"drain\": both settled, but the on-device recognizer has spoken since its\n * last drain; stay until a drain completes.\n * - \"planned\": both settled and drained; move now.\n */\nexport type HandoffStep = \"settle\" | \"forced\" | \"wait\" | \"drain\" | \"planned\"\n\nexport function handoffStep(\n current: TranscriptionSource | null,\n target: TranscriptionSource,\n canDeliver: (source: TranscriptionSource) => boolean,\n busy: (source: TranscriptionSource) => boolean,\n drained: boolean,\n): HandoffStep {\n if (current === null || current === target) return \"settle\"\n if (!canDeliver(current)) return \"forced\"\n if (busy(current) || busy(target)) return \"wait\"\n return drained ? \"planned\" : \"drain\"\n}\n\n/** Whether the listener needs its stream from the cloud. */\nexport function needsCloud(\n listener: RoutedListener,\n source: TranscriptionSource | null,\n target: TranscriptionSource,\n): boolean {\n if (listener.processing === \"localOnly\") return false\n return listener.processing === \"preferCloud\" || source === \"cloud\" || target === \"cloud\"\n}\n\n/**\n * Whether the listener needs the on-device recognizer running. preferLocal\n * asks for it whenever the picked model transcribes its language, which is\n * what starts (and, when missing, downloads) that model.\n */\nexport function needsLocal(\n listener: RoutedListener,\n source: TranscriptionSource | null,\n state: TranscriptionRoutingState,\n): boolean {\n if (source === \"local\" || listener.processing === \"localOnly\" || !state.cloudConnected) return true\n return (\n listener.processing === \"preferLocal\" &&\n state.onDevice &&\n onDeviceServes(listener, state.pickedLanguage, state.hints)\n )\n}\n\n/** When each source last produced a transcript on each stream, to tell when it has settled. */\nexport class UtteranceTracker {\n /** `${source} ${stream}` -> its last transcript; stream \"*\" marks a source start, for every stream. */\n private readonly last = new Map<string, {at: number; final: boolean}>()\n\n constructor(\n private readonly quietMs = SOURCE_QUIET_MS,\n private readonly staleMs = UTTERANCE_STALE_MS,\n ) {}\n\n /** Records a transcript: an interim opens (or continues) an utterance, a final ends it. */\n note(source: TranscriptionSource, stream: string, isFinal: boolean, now: number): void {\n this.last.set(`${source} ${stream}`, {at: now, final: isFinal})\n }\n\n /**\n * A source started (the cloud connected, or the on-device model loaded): it\n * may already be working on speech it has not reported, so it settles only\n * after a quiet window.\n */\n started(source: TranscriptionSource, now: number): void {\n this.clear(source)\n this.note(source, \"*\", true, now)\n }\n\n /** Forgets a source that stopped: it never reports anything more. */\n clear(source: TranscriptionSource): void {\n for (const key of [...this.last.keys()]) {\n if (key.startsWith(`${source} `)) this.last.delete(key)\n }\n }\n\n /** Whether `source` may still report speech that a listener on `stream` hears. */\n busy(source: TranscriptionSource, stream: string, now: number): boolean {\n for (const [key, last] of this.last) {\n if (now >= this.settlesAt(last)) continue\n const [keySource, keyStream] = key.split(\" \")\n if (keySource !== source) continue\n if (keyStream === \"*\" || stream === AUTO_STREAM || keyStream === stream) return true\n }\n return false\n }\n\n /** When the next busy stream settles, or null when every one has. */\n nextSettle(now: number): number | null {\n let next: number | null = null\n for (const [key, last] of this.last) {\n const at = this.settlesAt(last)\n if (now >= at) this.last.delete(key)\n else if (next === null || at < next) next = at\n }\n return next\n }\n\n private settlesAt(last: {at: number; final: boolean}): number {\n return last.at + (last.final ? this.quietMs : this.staleMs)\n }\n}\n"]}
@@ -1,18 +1,99 @@
1
1
  import type { AudioPosition, TranscriptionToken } from "@mentra/cloud-protocol";
2
+ import { type RoutedListener, type SpeechProcessing, type TranscriptionSource } from "./TranscriptionRouting";
2
3
  export interface TranscriptionListenerRegistration {
3
4
  id: string;
4
5
  stream: string;
5
- forceLocal?: boolean;
6
+ processing: SpeechProcessing;
7
+ /**
8
+ * Registered for an SDK that predates listener ids. It gets un-addressed
9
+ * events, which that SDK fans out to every listener of the same class, so
10
+ * all of a class's legacy registrations move between sources together.
11
+ */
6
12
  legacy?: boolean;
7
13
  }
8
- /** Runtime-owned text projection. Token detail never crosses the miniapp bridge. */
14
+ /** A transcription stream's entry in SUBSCRIBE `subscriptions`. */
15
+ export interface TranscriptionStreamEntry {
16
+ stream: string;
17
+ processing?: unknown;
18
+ /** SDKs before `processing`: the stream has localOnly listeners. */
19
+ forceLocal?: unknown;
20
+ /** SDKs before `processing`, with forceLocal: the stream also has cloud listeners. */
21
+ includeCloud?: unknown;
22
+ }
23
+ /** Records what one transcription stream entry asks for. */
24
+ export declare function addStreamProcessing(byStream: Map<string, Set<SpeechProcessing>>, entry: TranscriptionStreamEntry): void;
25
+ /**
26
+ * The transcription listeners of one SUBSCRIBE. SDKs register each listener
27
+ * (`transcriptionListeners`: `{id, stream, processing}`, or `forceLocal`
28
+ * before `processing`). A subscribed stream without registered listeners, as
29
+ * from SDKs that predate registration, gets one legacy registration per
30
+ * processing its stream entries ask for.
31
+ */
32
+ export declare function transcriptionRegistrations(registered: unknown, streams: string[], streamProcessing: Map<string, Set<SpeechProcessing>>): TranscriptionListenerRegistration[];
33
+ /** What `TranscriptionSubscriptions.route` moves listeners by. */
34
+ export interface TranscriptionHandoff {
35
+ target: (listener: RoutedListener) => TranscriptionSource;
36
+ canDeliver: (source: TranscriptionSource) => boolean;
37
+ /** Whether `source` is still busy on `stream` (see UtteranceTracker). */
38
+ busy: (source: TranscriptionSource, stream: string) => boolean;
39
+ /** The on-device recognizer has been drained since it last spoke. */
40
+ drained: boolean;
41
+ /** Cloud audio position where the last drain began. */
42
+ plannedWatermark: AudioPosition | null;
43
+ /** Cloud audio position of the last on-device final. */
44
+ forcedWatermark: AudioPosition | null;
45
+ }
46
+ /**
47
+ * Class an SDK from before `processing` filters routed events on: localOnly
48
+ * listeners take "forceLocal", the others "default".
49
+ */
50
+ export type TranscriptionDeliveryRoute = "default" | "forceLocal";
51
+ export interface TranscriptionDelivery {
52
+ /** Absent for a legacy class: the SDK fans the event out by `route`. */
53
+ listenerId?: string;
54
+ route: TranscriptionDeliveryRoute;
55
+ text: string;
56
+ }
57
+ export interface TranscriptionListenerRoute extends RoutedListener {
58
+ id: string;
59
+ /** Where the listener gets its text now; null before its first routing. */
60
+ source: TranscriptionSource | null;
61
+ /** Where it is going (its source, unless a switch waits for a pause). */
62
+ target: TranscriptionSource;
63
+ }
64
+ /**
65
+ * One miniapp's transcription listeners: which source each one gets its text
66
+ * from, and its runtime-owned text projection. Token detail never crosses the
67
+ * miniapp bridge.
68
+ */
9
69
  export declare class TranscriptionSubscriptions {
10
70
  private listeners;
71
+ /** Routing of each group: a listener, or one legacy class. */
72
+ private routing;
11
73
  observe(current: AudioPosition | null): void;
12
74
  replace(registrations: TranscriptionListenerRegistration[], cutoff: AudioPosition | null): void;
13
- project(stream: string, tokens: TranscriptionToken[], current: AudioPosition | null, route: "default" | "forceLocal" | "all"): {
14
- listenerId?: string;
15
- text: string;
16
- }[];
75
+ /**
76
+ * Moves each group toward its target source (see `handoffStep`). A group
77
+ * that moves from on-device to the cloud starts its positioned cloud text
78
+ * at a watermark, so cloud tokens for speech it already got on-device are
79
+ * never projected to it: where the drain began after a planned move, the
80
+ * last on-device final after a forced one.
81
+ */
82
+ route(handoff: TranscriptionHandoff): {
83
+ changed: boolean;
84
+ needsDrain: boolean;
85
+ };
86
+ /** Whether a listener is waiting for its sources to settle before it switches. */
87
+ switching(): boolean;
88
+ /** Every listener with its current routing. */
89
+ routes(): TranscriptionListenerRoute[];
90
+ /**
91
+ * The events one transcript produces for this miniapp: one per listener
92
+ * that hears `stream` and currently gets its text from `source`. Cloud
93
+ * transcripts with token positions are trimmed to what each listener may
94
+ * hear; without positions (`tokens` undefined) the text goes as is.
95
+ */
96
+ deliveries(stream: string, source: TranscriptionSource, text: string, tokens: TranscriptionToken[] | undefined, current: AudioPosition | null): TranscriptionDelivery[];
97
+ private groups;
17
98
  }
18
99
  //# sourceMappingURL=TranscriptionSubscriptions.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"TranscriptionSubscriptions.d.ts","sourceRoot":"","sources":["../../src/services/TranscriptionSubscriptions.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,aAAa,EAAE,kBAAkB,EAAC,MAAM,wBAAwB,CAAA;AAE7E,MAAM,WAAW,iCAAiC;IAChD,EAAE,EAAE,MAAM,CAAA;IACV,MAAM,EAAE,MAAM,CAAA;IACd,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,MAAM,CAAC,EAAE,OAAO,CAAA;CACjB;AAOD,oFAAoF;AACpF,qBAAa,0BAA0B;IACrC,OAAO,CAAC,SAAS,CAA8B;IAE/C,OAAO,CAAC,OAAO,EAAE,aAAa,GAAG,IAAI,GAAG,IAAI;IAK5C,OAAO,CAAC,aAAa,EAAE,iCAAiC,EAAE,EAAE,MAAM,EAAE,aAAa,GAAG,IAAI,GAAG,IAAI;IAe/F,OAAO,CACL,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,kBAAkB,EAAE,EAC5B,OAAO,EAAE,aAAa,GAAG,IAAI,EAC7B,KAAK,EAAE,SAAS,GAAG,YAAY,GAAG,KAAK;qBAGL,MAAM;cAAQ,MAAM;;CAsBzD"}
1
+ {"version":3,"file":"TranscriptionSubscriptions.d.ts","sourceRoot":"","sources":["../../src/services/TranscriptionSubscriptions.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,52 @@
1
- /** Runtime-owned text projection. Token detail never crosses the miniapp bridge. */
1
+ import { handoffStep, readSpeechProcessing, } from "./TranscriptionRouting";
2
+ /** Records what one transcription stream entry asks for. */
3
+ export function addStreamProcessing(byStream, entry) {
4
+ const processing = byStream.get(entry.stream) ?? new Set();
5
+ byStream.set(entry.stream, processing);
6
+ processing.add(readSpeechProcessing(entry));
7
+ if (entry.processing === undefined && entry.forceLocal === true && entry.includeCloud === true) {
8
+ processing.add("preferCloud");
9
+ }
10
+ }
11
+ /**
12
+ * The transcription listeners of one SUBSCRIBE. SDKs register each listener
13
+ * (`transcriptionListeners`: `{id, stream, processing}`, or `forceLocal`
14
+ * before `processing`). A subscribed stream without registered listeners, as
15
+ * from SDKs that predate registration, gets one legacy registration per
16
+ * processing its stream entries ask for.
17
+ */
18
+ export function transcriptionRegistrations(registered, streams, streamProcessing) {
19
+ const subscribed = new Set(streams.filter((stream) => stream.startsWith("transcription:")));
20
+ const registrations = [];
21
+ for (const value of Array.isArray(registered) ? registered : []) {
22
+ if (typeof value !== "object" || value === null)
23
+ continue;
24
+ const listener = value;
25
+ if (typeof listener.id !== "string" || typeof listener.stream !== "string")
26
+ continue;
27
+ if (!subscribed.has(listener.stream))
28
+ continue;
29
+ registrations.push({ id: listener.id, stream: listener.stream, processing: readSpeechProcessing(listener) });
30
+ }
31
+ const registeredStreams = new Set(registrations.map((registration) => registration.stream));
32
+ for (const stream of subscribed) {
33
+ if (registeredStreams.has(stream))
34
+ continue;
35
+ for (const processing of streamProcessing.get(stream) ?? ["preferCloud"]) {
36
+ registrations.push({ id: `legacy:${processing}:${stream}`, stream, processing, legacy: true });
37
+ }
38
+ }
39
+ return registrations;
40
+ }
41
+ /**
42
+ * One miniapp's transcription listeners: which source each one gets its text
43
+ * from, and its runtime-owned text projection. Token detail never crosses the
44
+ * miniapp bridge.
45
+ */
2
46
  export class TranscriptionSubscriptions {
3
47
  listeners = new Map();
48
+ /** Routing of each group: a listener, or one legacy class. */
49
+ routing = new Map();
4
50
  observe(current) {
5
51
  if (!current)
6
52
  return;
@@ -12,43 +58,145 @@ export class TranscriptionSubscriptions {
12
58
  const next = new Map();
13
59
  for (const registration of registrations) {
14
60
  const existing = this.listeners.get(registration.id);
15
- next.set(registration.id, existing?.stream === registration.stream && !!existing.forceLocal === !!registration.forceLocal
61
+ next.set(registration.id, existing?.stream === registration.stream &&
62
+ existing.processing === registration.processing &&
63
+ !!existing.legacy === !!registration.legacy
16
64
  ? existing
17
65
  : { ...registration, cutoff, sessions: new Set(cutoff ? [cutoff.sessionTag] : []) });
18
66
  }
19
67
  this.listeners = next;
68
+ const groups = new Set([...next.values()].map(groupOf));
69
+ for (const group of [...this.routing.keys()]) {
70
+ if (!groups.has(group))
71
+ this.routing.delete(group);
72
+ }
73
+ }
74
+ /**
75
+ * Moves each group toward its target source (see `handoffStep`). A group
76
+ * that moves from on-device to the cloud starts its positioned cloud text
77
+ * at a watermark, so cloud tokens for speech it already got on-device are
78
+ * never projected to it: where the drain began after a planned move, the
79
+ * last on-device final after a forced one.
80
+ */
81
+ route(handoff) {
82
+ let changed = false;
83
+ let needsDrain = false;
84
+ for (const [group, members] of this.groups()) {
85
+ // A legacy class moves on-device only when that serves all its streams.
86
+ const target = members.every((member) => handoff.target(member) === "local") ? "local" : "cloud";
87
+ const previous = this.routing.get(group);
88
+ const step = handoffStep(previous?.source ?? null, target, handoff.canDeliver, (candidate) => members.some((member) => handoff.busy(candidate, member.stream)), handoff.drained);
89
+ if (step === "drain")
90
+ needsDrain = true;
91
+ const source = step === "wait" || step === "drain" ? previous.source : target;
92
+ if (previous?.source === source && previous.target === target)
93
+ continue;
94
+ changed = true;
95
+ this.routing.set(group, { source, target });
96
+ const watermark = step === "planned" ? handoff.plannedWatermark : handoff.forcedWatermark;
97
+ if (previous?.source === "local" && source === "cloud" && watermark) {
98
+ for (const member of members)
99
+ advanceCutoff(member, watermark);
100
+ }
101
+ }
102
+ return { changed, needsDrain };
20
103
  }
21
- project(stream, tokens, current, route) {
104
+ /** Whether a listener is waiting for its sources to settle before it switches. */
105
+ switching() {
106
+ for (const routing of this.routing.values()) {
107
+ if (routing.source !== routing.target)
108
+ return true;
109
+ }
110
+ return false;
111
+ }
112
+ /** Every listener with its current routing. */
113
+ routes() {
114
+ return [...this.listeners.values()].map((listener) => {
115
+ const routing = this.routing.get(groupOf(listener));
116
+ return {
117
+ id: listener.id,
118
+ stream: listener.stream,
119
+ processing: listener.processing,
120
+ source: routing?.source ?? null,
121
+ target: routing?.target ?? (listener.processing === "localOnly" ? "local" : "cloud"),
122
+ };
123
+ });
124
+ }
125
+ /**
126
+ * The events one transcript produces for this miniapp: one per listener
127
+ * that hears `stream` and currently gets its text from `source`. Cloud
128
+ * transcripts with token positions are trimmed to what each listener may
129
+ * hear; without positions (`tokens` undefined) the text goes as is.
130
+ */
131
+ deliveries(stream, source, text, tokens, current) {
22
132
  this.observe(current);
23
- const events = [];
133
+ const deliveries = [];
24
134
  for (const listener of this.listeners.values()) {
25
135
  if (listener.stream !== stream && listener.stream !== "transcription:auto")
26
136
  continue;
27
- if (listener.forceLocal ? route !== "forceLocal" && route !== "all" : route === "forceLocal")
137
+ if (this.routing.get(groupOf(listener))?.source !== source)
138
+ continue;
139
+ const listenerText = tokens ? projectTokens(listener, tokens) : text;
140
+ if (tokens && !listenerText)
141
+ continue;
142
+ const route = listener.processing === "localOnly" ? "forceLocal" : "default";
143
+ if (!listener.legacy) {
144
+ deliveries.push({ listenerId: listener.id, route, text: listenerText });
28
145
  continue;
29
- const eligible = tokens.filter((token) => {
30
- const position = token.audioPosition;
31
- if (!position)
32
- return false;
33
- if (position.sessionTag === listener.cutoff?.sessionTag)
34
- return position.offsetMs >= listener.cutoff.offsetMs;
35
- return listener.sessions.has(position.sessionTag);
36
- });
37
- const text = eligible
38
- .map((token) => token.text)
39
- .join("")
40
- .trim();
41
- if (text && listener.legacy) {
42
- const existing = events.find((event) => event.listenerId === undefined);
43
- if (!existing)
44
- events.push({ text });
45
- else if (text.length > existing.text.length)
46
- existing.text = text;
47
146
  }
48
- else if (text)
49
- events.push({ listenerId: listener.id, text });
147
+ const existing = deliveries.find((delivery) => delivery.listenerId === undefined && delivery.route === route);
148
+ if (!existing)
149
+ deliveries.push({ route, text: listenerText });
150
+ else if (listenerText.length > existing.text.length)
151
+ existing.text = listenerText;
50
152
  }
51
- return events;
153
+ return deliveries;
52
154
  }
155
+ groups() {
156
+ const groups = new Map();
157
+ for (const listener of this.listeners.values()) {
158
+ const group = groupOf(listener);
159
+ const members = groups.get(group);
160
+ if (members)
161
+ members.push(listener);
162
+ else
163
+ groups.set(group, [listener]);
164
+ }
165
+ return groups;
166
+ }
167
+ }
168
+ function groupOf(listener) {
169
+ return listener.legacy ? `legacy ${listener.processing}` : `listener ${listener.id}`;
170
+ }
171
+ /**
172
+ * Moves a listener's cloud text start up to `watermark`, never back before
173
+ * where it already starts (its registration, or an earlier handoff). A
174
+ * watermark in a session the listener never saw predates it.
175
+ */
176
+ function advanceCutoff(listener, watermark) {
177
+ const cutoff = listener.cutoff;
178
+ if (cutoff?.sessionTag === watermark.sessionTag) {
179
+ if (watermark.offsetMs > cutoff.offsetMs)
180
+ listener.cutoff = watermark;
181
+ return;
182
+ }
183
+ if (!listener.sessions.has(watermark.sessionTag))
184
+ return;
185
+ listener.cutoff = watermark;
186
+ listener.sessions = new Set([watermark.sessionTag]);
187
+ }
188
+ function projectTokens(listener, tokens) {
189
+ return tokens
190
+ .filter((token) => {
191
+ const position = token.audioPosition;
192
+ if (!position)
193
+ return false;
194
+ if (position.sessionTag === listener.cutoff?.sessionTag)
195
+ return position.offsetMs >= listener.cutoff.offsetMs;
196
+ return listener.sessions.has(position.sessionTag);
197
+ })
198
+ .map((token) => token.text)
199
+ .join("")
200
+ .trim();
53
201
  }
54
202
  //# sourceMappingURL=TranscriptionSubscriptions.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"TranscriptionSubscriptions.js","sourceRoot":"","sources":["../../src/services/TranscriptionSubscriptions.ts"],"names":[],"mappings":"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","sourcesContent":["import type {AudioPosition, TranscriptionToken} from \"@mentra/cloud-protocol\"\n\nexport interface TranscriptionListenerRegistration {\n id: string\n stream: string\n forceLocal?: boolean\n legacy?: boolean\n}\n\ninterface Listener extends TranscriptionListenerRegistration {\n cutoff: AudioPosition | null\n sessions: Set<number>\n}\n\n/** Runtime-owned text projection. Token detail never crosses the miniapp bridge. */\nexport class TranscriptionSubscriptions {\n private listeners = new Map<string, Listener>()\n\n observe(current: AudioPosition | null): void {\n if (!current) return\n for (const listener of this.listeners.values()) listener.sessions.add(current.sessionTag)\n }\n\n replace(registrations: TranscriptionListenerRegistration[], cutoff: AudioPosition | null): void {\n this.observe(cutoff)\n const next = new Map<string, Listener>()\n for (const registration of registrations) {\n const existing = this.listeners.get(registration.id)\n next.set(\n registration.id,\n existing?.stream === registration.stream && !!existing.forceLocal === !!registration.forceLocal\n ? existing\n : {...registration, cutoff, sessions: new Set(cutoff ? [cutoff.sessionTag] : [])},\n )\n }\n this.listeners = next\n }\n\n project(\n stream: string,\n tokens: TranscriptionToken[],\n current: AudioPosition | null,\n route: \"default\" | \"forceLocal\" | \"all\",\n ) {\n this.observe(current)\n const events: Array<{listenerId?: string; text: string}> = []\n for (const listener of this.listeners.values()) {\n if (listener.stream !== stream && listener.stream !== \"transcription:auto\") continue\n if (listener.forceLocal ? route !== \"forceLocal\" && route !== \"all\" : route === \"forceLocal\") continue\n const eligible = tokens.filter((token) => {\n const position = token.audioPosition\n if (!position) return false\n if (position.sessionTag === listener.cutoff?.sessionTag) return position.offsetMs >= listener.cutoff.offsetMs\n return listener.sessions.has(position.sessionTag)\n })\n const text = eligible\n .map((token) => token.text)\n .join(\"\")\n .trim()\n if (text && listener.legacy) {\n const existing = events.find((event) => event.listenerId === undefined)\n if (!existing) events.push({text})\n else if (text.length > existing.text.length) existing.text = text\n } else if (text) events.push({listenerId: listener.id, text})\n }\n return events\n }\n}\n"]}
1
+ {"version":3,"file":"TranscriptionSubscriptions.js","sourceRoot":"","sources":["../../src/services/TranscriptionSubscriptions.ts"],"names":[],"mappings":"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","sourcesContent":["import type {AudioPosition, TranscriptionToken} from \"@mentra/cloud-protocol\"\nimport {\n handoffStep,\n readSpeechProcessing,\n type RoutedListener,\n type SpeechProcessing,\n type TranscriptionSource,\n} from \"./TranscriptionRouting\"\n\nexport interface TranscriptionListenerRegistration {\n id: string\n stream: string\n processing: SpeechProcessing\n /**\n * Registered for an SDK that predates listener ids. It gets un-addressed\n * events, which that SDK fans out to every listener of the same class, so\n * all of a class's legacy registrations move between sources together.\n */\n legacy?: boolean\n}\n\n/** A transcription stream's entry in SUBSCRIBE `subscriptions`. */\nexport interface TranscriptionStreamEntry {\n stream: string\n processing?: unknown\n /** SDKs before `processing`: the stream has localOnly listeners. */\n forceLocal?: unknown\n /** SDKs before `processing`, with forceLocal: the stream also has cloud listeners. */\n includeCloud?: unknown\n}\n\n/** Records what one transcription stream entry asks for. */\nexport function addStreamProcessing(\n byStream: Map<string, Set<SpeechProcessing>>,\n entry: TranscriptionStreamEntry,\n): void {\n const processing = byStream.get(entry.stream) ?? new Set<SpeechProcessing>()\n byStream.set(entry.stream, processing)\n processing.add(readSpeechProcessing(entry))\n if (entry.processing === undefined && entry.forceLocal === true && entry.includeCloud === true) {\n processing.add(\"preferCloud\")\n }\n}\n\n/**\n * The transcription listeners of one SUBSCRIBE. SDKs register each listener\n * (`transcriptionListeners`: `{id, stream, processing}`, or `forceLocal`\n * before `processing`). A subscribed stream without registered listeners, as\n * from SDKs that predate registration, gets one legacy registration per\n * processing its stream entries ask for.\n */\nexport function transcriptionRegistrations(\n registered: unknown,\n streams: string[],\n streamProcessing: Map<string, Set<SpeechProcessing>>,\n): TranscriptionListenerRegistration[] {\n const subscribed = new Set(streams.filter((stream) => stream.startsWith(\"transcription:\")))\n const registrations: TranscriptionListenerRegistration[] = []\n for (const value of Array.isArray(registered) ? registered : []) {\n if (typeof value !== \"object\" || value === null) continue\n const listener = value as {id?: unknown; stream?: unknown; processing?: unknown; forceLocal?: unknown}\n if (typeof listener.id !== \"string\" || typeof listener.stream !== \"string\") continue\n if (!subscribed.has(listener.stream)) continue\n registrations.push({id: listener.id, stream: listener.stream, processing: readSpeechProcessing(listener)})\n }\n const registeredStreams = new Set(registrations.map((registration) => registration.stream))\n for (const stream of subscribed) {\n if (registeredStreams.has(stream)) continue\n for (const processing of streamProcessing.get(stream) ?? [\"preferCloud\" as const]) {\n registrations.push({id: `legacy:${processing}:${stream}`, stream, processing, legacy: true})\n }\n }\n return registrations\n}\n\n/** What `TranscriptionSubscriptions.route` moves listeners by. */\nexport interface TranscriptionHandoff {\n target: (listener: RoutedListener) => TranscriptionSource\n canDeliver: (source: TranscriptionSource) => boolean\n /** Whether `source` is still busy on `stream` (see UtteranceTracker). */\n busy: (source: TranscriptionSource, stream: string) => boolean\n /** The on-device recognizer has been drained since it last spoke. */\n drained: boolean\n /** Cloud audio position where the last drain began. */\n plannedWatermark: AudioPosition | null\n /** Cloud audio position of the last on-device final. */\n forcedWatermark: AudioPosition | null\n}\n\n/**\n * Class an SDK from before `processing` filters routed events on: localOnly\n * listeners take \"forceLocal\", the others \"default\".\n */\nexport type TranscriptionDeliveryRoute = \"default\" | \"forceLocal\"\n\nexport interface TranscriptionDelivery {\n /** Absent for a legacy class: the SDK fans the event out by `route`. */\n listenerId?: string\n route: TranscriptionDeliveryRoute\n text: string\n}\n\nexport interface TranscriptionListenerRoute extends RoutedListener {\n id: string\n /** Where the listener gets its text now; null before its first routing. */\n source: TranscriptionSource | null\n /** Where it is going (its source, unless a switch waits for a pause). */\n target: TranscriptionSource\n}\n\ninterface Listener extends TranscriptionListenerRegistration {\n cutoff: AudioPosition | null\n sessions: Set<number>\n}\n\ninterface GroupRoute {\n source: TranscriptionSource\n target: TranscriptionSource\n}\n\n/**\n * One miniapp's transcription listeners: which source each one gets its text\n * from, and its runtime-owned text projection. Token detail never crosses the\n * miniapp bridge.\n */\nexport class TranscriptionSubscriptions {\n private listeners = new Map<string, Listener>()\n /** Routing of each group: a listener, or one legacy class. */\n private routing = new Map<string, GroupRoute>()\n\n observe(current: AudioPosition | null): void {\n if (!current) return\n for (const listener of this.listeners.values()) listener.sessions.add(current.sessionTag)\n }\n\n replace(registrations: TranscriptionListenerRegistration[], cutoff: AudioPosition | null): void {\n this.observe(cutoff)\n const next = new Map<string, Listener>()\n for (const registration of registrations) {\n const existing = this.listeners.get(registration.id)\n next.set(\n registration.id,\n existing?.stream === registration.stream &&\n existing.processing === registration.processing &&\n !!existing.legacy === !!registration.legacy\n ? existing\n : {...registration, cutoff, sessions: new Set(cutoff ? [cutoff.sessionTag] : [])},\n )\n }\n this.listeners = next\n const groups = new Set([...next.values()].map(groupOf))\n for (const group of [...this.routing.keys()]) {\n if (!groups.has(group)) this.routing.delete(group)\n }\n }\n\n /**\n * Moves each group toward its target source (see `handoffStep`). A group\n * that moves from on-device to the cloud starts its positioned cloud text\n * at a watermark, so cloud tokens for speech it already got on-device are\n * never projected to it: where the drain began after a planned move, the\n * last on-device final after a forced one.\n */\n route(handoff: TranscriptionHandoff): {changed: boolean; needsDrain: boolean} {\n let changed = false\n let needsDrain = false\n for (const [group, members] of this.groups()) {\n // A legacy class moves on-device only when that serves all its streams.\n const target = members.every((member) => handoff.target(member) === \"local\") ? \"local\" : \"cloud\"\n const previous = this.routing.get(group)\n const step = handoffStep(\n previous?.source ?? null,\n target,\n handoff.canDeliver,\n (candidate) => members.some((member) => handoff.busy(candidate, member.stream)),\n handoff.drained,\n )\n if (step === \"drain\") needsDrain = true\n const source = step === \"wait\" || step === \"drain\" ? previous!.source : target\n if (previous?.source === source && previous.target === target) continue\n changed = true\n this.routing.set(group, {source, target})\n const watermark = step === \"planned\" ? handoff.plannedWatermark : handoff.forcedWatermark\n if (previous?.source === \"local\" && source === \"cloud\" && watermark) {\n for (const member of members) advanceCutoff(member, watermark)\n }\n }\n return {changed, needsDrain}\n }\n\n /** Whether a listener is waiting for its sources to settle before it switches. */\n switching(): boolean {\n for (const routing of this.routing.values()) {\n if (routing.source !== routing.target) return true\n }\n return false\n }\n\n /** Every listener with its current routing. */\n routes(): TranscriptionListenerRoute[] {\n return [...this.listeners.values()].map((listener) => {\n const routing = this.routing.get(groupOf(listener))\n return {\n id: listener.id,\n stream: listener.stream,\n processing: listener.processing,\n source: routing?.source ?? null,\n target: routing?.target ?? (listener.processing === \"localOnly\" ? \"local\" : \"cloud\"),\n }\n })\n }\n\n /**\n * The events one transcript produces for this miniapp: one per listener\n * that hears `stream` and currently gets its text from `source`. Cloud\n * transcripts with token positions are trimmed to what each listener may\n * hear; without positions (`tokens` undefined) the text goes as is.\n */\n deliveries(\n stream: string,\n source: TranscriptionSource,\n text: string,\n tokens: TranscriptionToken[] | undefined,\n current: AudioPosition | null,\n ): TranscriptionDelivery[] {\n this.observe(current)\n const deliveries: TranscriptionDelivery[] = []\n for (const listener of this.listeners.values()) {\n if (listener.stream !== stream && listener.stream !== \"transcription:auto\") continue\n if (this.routing.get(groupOf(listener))?.source !== source) continue\n const listenerText = tokens ? projectTokens(listener, tokens) : text\n if (tokens && !listenerText) continue\n const route: TranscriptionDeliveryRoute = listener.processing === \"localOnly\" ? \"forceLocal\" : \"default\"\n if (!listener.legacy) {\n deliveries.push({listenerId: listener.id, route, text: listenerText})\n continue\n }\n const existing = deliveries.find((delivery) => delivery.listenerId === undefined && delivery.route === route)\n if (!existing) deliveries.push({route, text: listenerText})\n else if (listenerText.length > existing.text.length) existing.text = listenerText\n }\n return deliveries\n }\n\n private groups(): Map<string, Listener[]> {\n const groups = new Map<string, Listener[]>()\n for (const listener of this.listeners.values()) {\n const group = groupOf(listener)\n const members = groups.get(group)\n if (members) members.push(listener)\n else groups.set(group, [listener])\n }\n return groups\n }\n}\n\nfunction groupOf(listener: TranscriptionListenerRegistration): string {\n return listener.legacy ? `legacy ${listener.processing}` : `listener ${listener.id}`\n}\n\n/**\n * Moves a listener's cloud text start up to `watermark`, never back before\n * where it already starts (its registration, or an earlier handoff). A\n * watermark in a session the listener never saw predates it.\n */\nfunction advanceCutoff(listener: Listener, watermark: AudioPosition): void {\n const cutoff = listener.cutoff\n if (cutoff?.sessionTag === watermark.sessionTag) {\n if (watermark.offsetMs > cutoff.offsetMs) listener.cutoff = watermark\n return\n }\n if (!listener.sessions.has(watermark.sessionTag)) return\n listener.cutoff = watermark\n listener.sessions = new Set([watermark.sessionTag])\n}\n\nfunction projectTokens(listener: Listener, tokens: TranscriptionToken[]): string {\n return tokens\n .filter((token) => {\n const position = token.audioPosition\n if (!position) return false\n if (position.sessionTag === listener.cutoff?.sessionTag) return position.offsetMs >= listener.cutoff.offsetMs\n return listener.sessions.has(position.sessionTag)\n })\n .map((token) => token.text)\n .join(\"\")\n .trim()\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/engine",
3
- "version": "3.4.0-dev.1181",
3
+ "version": "3.4.0-dev.1183",
4
4
  "description": "Mentra Engine — miniapp registry, WebView bridge, and miniapp store",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -97,13 +97,13 @@
97
97
  "registry": "https://registry.npmjs.org/"
98
98
  },
99
99
  "dependencies": {
100
- "@mentra/acs-meeting": "3.4.0-dev.1181",
101
- "@mentra/glasses-media": "3.4.0-dev.1181",
102
- "@mentra/bluetooth-sdk": "3.4.0-dev.1181",
103
- "@mentra/cloud-client": "3.4.0-dev.1181",
104
- "@mentra/cloud-protocol": "3.4.0-dev.1181",
105
- "@mentra/crust": "3.4.0-dev.1181",
106
- "@mentra/miniapp": "3.4.0-dev.1181",
100
+ "@mentra/acs-meeting": "3.4.0-dev.1183",
101
+ "@mentra/glasses-media": "3.4.0-dev.1183",
102
+ "@mentra/bluetooth-sdk": "3.4.0-dev.1183",
103
+ "@mentra/cloud-client": "3.4.0-dev.1183",
104
+ "@mentra/cloud-protocol": "3.4.0-dev.1183",
105
+ "@mentra/crust": "3.4.0-dev.1183",
106
+ "@mentra/miniapp": "3.4.0-dev.1183",
107
107
  "buffer": "^6.0.3",
108
108
  "events": "^3.3.0",
109
109
  "jszip": "^3.10.1",
@@ -12,9 +12,9 @@ export interface EngineReleaseMetadata {
12
12
  export const ENGINE_RELEASE_METADATA: Readonly<EngineReleaseMetadata> = Object.freeze({
13
13
  "schemaVersion": 1,
14
14
  "familyBaseVersion": "3.4.0",
15
- "releaseIdentity": "3.4.0-dev.1181",
16
- "releaseSetId": "mentra-3.4.0-dev.1181",
17
- "sourceCommit": "486022c0a25c1d5b54ecce3725672afaeafae172",
18
- "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.1181.json",
19
- "otaManifestSha256": "4266ae9fed494ab64870adec6aee8e5348be878ed74c30f4bc98ba48e6e90548"
15
+ "releaseIdentity": "3.4.0-dev.1183",
16
+ "releaseSetId": "mentra-3.4.0-dev.1183",
17
+ "sourceCommit": "3b1ea68f95843885e8b28c924d7245b6be3a3a84",
18
+ "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.1183.json",
19
+ "otaManifestSha256": "63989883382aa713297879854ee1a2930cf2c61dc1d0c0e675dda1a8f9b25ff6"
20
20
  })
@@ -250,8 +250,8 @@ export function startDeviceEventRouter(): void {
250
250
  }),
251
251
  )
252
252
  // On-device STT transcripts → local miniapps only while the local engine is
253
- // active (cloud fallback or an explicit forceLocal subscription). The runtime
254
- // filters mixed local/cloud delivery per miniapp to avoid double-delivery.
253
+ // active (a localOnly or preferLocal listener, or the cloud fallback). The
254
+ // runtime routes each listener to one source, so nothing arrives twice.
255
255
  // forwardEvent is subscriber-gated — a no-op when no miniapp listens.
256
256
  // (Was MantleManager.handle_local_transcription; its offline-captions display branch
257
257
  // went away with the pseudo captions renderer.)