@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,7 +1,6 @@
1
1
  import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
2
2
  import {ISLAND_SETTINGS_KEYS} from "../runtime/config"
3
3
  import {useSettingsStore} from "../stores/settings"
4
- import {cloudClientService} from "./CloudClientService"
5
4
  import {ModelRetry} from "./ModelRetry"
6
5
  import offlineSpeechModelService from "./OfflineSpeechModelService"
7
6
  import sttModelManager from "./STTModelManager"
@@ -23,10 +22,10 @@ export interface LocalSttModelStatus {
23
22
  }
24
23
 
25
24
  /**
26
- * Decides whether on-device Sherpa STT should produce transcripts for local
27
- * miniapps, instead of (or in addition to) cloud transcription. Local STT is
28
- * active when the cloud is unavailable or any subscription requires
29
- * `forceLocal`.
25
+ * Runs on-device Sherpa STT for local miniapps while LocalMiniappRuntime says
26
+ * a transcription listener needs it (`setDemand`): a `localOnly` listener, a
27
+ * `preferLocal` one the picked model can serve, any listener while the cloud
28
+ * is unreachable, or one still finishing an on-device utterance.
30
29
  *
31
30
  * There is one recognizer and it always runs the model picked on the Speech
32
31
  * page. When on-device transcription is needed and that model is missing, it
@@ -39,22 +38,14 @@ export interface LocalSttModelStatus {
39
38
  * `local_transcription` Bridge event; the host wires that event to
40
39
  * `localMiniappRuntime.forwardEvent`, with this coordinator providing the
41
40
  * subscription-active gate so we never publish transcripts to miniapps that
42
- * aren't asking for them. LocalMiniappRuntime filters mixed local/cloud
43
- * delivery per miniapp.
41
+ * aren't asking for them. LocalMiniappRuntime routes local and cloud
42
+ * transcripts to each listener.
44
43
  */
45
44
  class LocalSttFallbackCoordinator {
46
45
  private static instance: LocalSttFallbackCoordinator
47
46
 
48
- private hasTranscriptionSubscription = false
49
- private hasForceLocalSubscription = false
50
- /**
51
- * Default to "cloud is up" so we never accidentally activate local STT before
52
- * the cloud client has had a chance to start. We attach lazily on the first
53
- * subscription/reconcile pass; once attached, this field reflects the real
54
- * cloud-client runtime status.
55
- */
56
- private cloudConnected = true
57
- private cloudAdapterAttached = false
47
+ /** A miniapp transcription listener needs on-device transcripts. */
48
+ private demand = false
58
49
  private selectionAttached = false
59
50
  private hydration?: Promise<unknown>
60
51
  private hydrated = false
@@ -86,24 +77,6 @@ class LocalSttFallbackCoordinator {
86
77
  return LocalSttFallbackCoordinator.instance
87
78
  }
88
79
 
89
- /**
90
- * Lazy-attach the engine cloud connection listener. The coordinator is
91
- * constructed at module load, before engine.start() brings up the cloud
92
- * client, so we defer until the first reconcile.
93
- */
94
- private attachCloudAdapterIfReady(): void {
95
- if (this.cloudAdapterAttached) return
96
- this.cloudConnected = cloudClientService.isConnected()
97
- cloudClientService.onStatusChanged((status) => {
98
- const connected = status.status === "connected"
99
- if (this.cloudConnected === connected) return
100
- this.log(`cloud connection -> ${connected ? "up" : "down"}`)
101
- this.cloudConnected = connected
102
- this.reconcile()
103
- })
104
- this.cloudAdapterAttached = true
105
- }
106
-
107
80
  /** A model change on the Speech page reloads the recognizer while it is in use. */
108
81
  private attachSelectionIfReady(): void {
109
82
  if (this.selectionAttached) return
@@ -134,32 +107,17 @@ class LocalSttFallbackCoordinator {
134
107
  }
135
108
  }
136
109
 
137
- onSubscriptionChange(hasTranscription: boolean, hasForceLocal = false): void {
138
- if (
139
- this.hasTranscriptionSubscription !== hasTranscription ||
140
- this.hasForceLocalSubscription !== (hasTranscription && hasForceLocal)
141
- ) {
142
- this.log(`onSubscriptionChange(hasTx=${hasTranscription}, forceLocal=${hasForceLocal})`)
143
- }
144
- this.hasTranscriptionSubscription = hasTranscription
145
- this.hasForceLocalSubscription = hasTranscription && hasForceLocal
110
+ /** Whether a miniapp transcription listener needs on-device transcripts. */
111
+ setDemand(demand: boolean): void {
112
+ if (this.demand !== demand) this.log(`on-device demand -> ${demand}`)
113
+ this.demand = demand
146
114
  this.reconcile()
147
115
  }
148
116
 
149
- /**
150
- * Informational hook, retained for API stability. No longer called: the
151
- * authoritative switching signal is the cloud-client adapter (local miniapps
152
- * are powered only by V2). v1 cloud transcripts deliberately do NOT drive this
153
- * coordinator anymore. Kept for future hysteresis if connection-state alone
154
- * proves too coarse.
155
- */
156
- onCloudTranscript(): void {}
157
-
158
117
  private reconcile(): void {
159
- this.attachCloudAdapterIfReady()
160
118
  this.attachSelectionIfReady()
161
- if (!this.shouldUseLocalStt()) {
162
- this.stop(this.hasTranscriptionSubscription ? "cloud reconnected" : "subscription gone")
119
+ if (!this.demand) {
120
+ this.stop("no listener needs it")
163
121
  return
164
122
  }
165
123
  if (!this.hydrated) {
@@ -191,8 +149,7 @@ class LocalSttFallbackCoordinator {
191
149
 
192
150
  private async start(code: string): Promise<void> {
193
151
  const generation = ++this.generation
194
- const current = () =>
195
- generation === this.generation && this.shouldUseLocalStt() && sttModelManager.getCurrentLanguage() === code
152
+ const current = () => generation === this.generation && this.demand && sttModelManager.getCurrentLanguage() === code
196
153
 
197
154
  if (this.localActive) {
198
155
  // Switching models: hold transcripts until the new model is loaded.
@@ -297,10 +254,6 @@ class LocalSttFallbackCoordinator {
297
254
  }
298
255
  }
299
256
 
300
- private shouldUseLocalStt(): boolean {
301
- return this.hasTranscriptionSubscription && (this.hasForceLocalSubscription || !this.cloudConnected)
302
- }
303
-
304
257
  private log(msg: string): void {
305
258
  console.log(`[LocalSttFallback] ${msg}`)
306
259
  }
@@ -1,37 +1,207 @@
1
- export interface TranscriptionRouteSummary {
2
- hasCloudSubscriber: boolean
3
- hasForceLocalSubscriber: boolean
1
+ /**
2
+ * Where each miniapp transcription listener gets its text: the cloud or the
3
+ * phone's on-device recognizer.
4
+ *
5
+ * A listener asks for one of three behaviours (the SDK's `processing`):
6
+ * - preferCloud (default): the cloud; on-device only while the cloud is
7
+ * unreachable.
8
+ * - preferLocal: on-device while the running model transcribes the
9
+ * listener's language, otherwise the cloud.
10
+ * - localOnly: on-device only; never the cloud.
11
+ *
12
+ * Each listener receives one source at a time (`handoffStep`). A planned
13
+ * switch, while its current source still works, waits for a pause in which
14
+ * both sources have settled (`UtteranceTracker`), then for a drain of the
15
+ * on-device recognizer (it reports everything it heard so far and starts
16
+ * over). The drain is the boundary: on-device text from before it stays with
17
+ * the old side, on-device text after it is new speech, and the listener's
18
+ * cloud text starts at the cloud audio position where the drain began. So a
19
+ * listener never misses or repeats speech. A model that cannot be drained
20
+ * leaves the quiet window as the boundary. A forced switch, when the current
21
+ * source stops, happens at once; a move to the cloud then starts at the
22
+ * cloud audio position of the last on-device final, so an utterance cut short
23
+ * on-device is completed in the cloud and finished ones are not repeated.
24
+ * The listeners' sources and targets decide what runs: a stream is requested
25
+ * from the cloud only while a listener on it needs the cloud, and the
26
+ * on-device recognizer runs only while a listener needs it.
27
+ */
28
+
29
+ export type SpeechProcessing = "preferCloud" | "preferLocal" | "localOnly"
30
+ export type TranscriptionSource = "cloud" | "local"
31
+
32
+ const PROCESSING: ReadonlySet<unknown> = new Set<SpeechProcessing>(["preferCloud", "preferLocal", "localOnly"])
33
+ const AUTO_STREAM = "transcription:auto"
34
+ const STREAM_PREFIX = "transcription:"
35
+
36
+ /**
37
+ * A source has settled once it has produced nothing for this long since its
38
+ * last final (or since it started): a pause in speech, longer than the
39
+ * recognizers' own end-of-speech delays.
40
+ */
41
+ export const SOURCE_QUIET_MS = 3000
42
+ /** An utterance whose final never came has ended after this long without transcripts. */
43
+ export const UTTERANCE_STALE_MS = 6000
44
+
45
+ /**
46
+ * The processing a listener, subscription or speak request asks for.
47
+ * `processing` wins; without it, the `forceLocal: true` of SDKs before it
48
+ * means localOnly. Anything else is preferCloud.
49
+ */
50
+ export function readSpeechProcessing(request: {processing?: unknown; forceLocal?: unknown}): SpeechProcessing {
51
+ if (PROCESSING.has(request.processing)) return request.processing as SpeechProcessing
52
+ return request.forceLocal === true ? "localOnly" : "preferCloud"
53
+ }
54
+
55
+ export interface RoutedListener {
56
+ /** `transcription:<language>` or `transcription:auto`. */
57
+ stream: string
58
+ processing: SpeechProcessing
59
+ }
60
+
61
+ /** What the phone can transcribe with right now, as one miniapp sees it. */
62
+ export interface TranscriptionRoutingState {
63
+ cloudConnected: boolean
64
+ /** On-device speech is allowed by this deployment. */
65
+ onDevice: boolean
66
+ /** BCP-47 language of the running on-device model, or null while none runs. */
67
+ localLanguage: string | null
68
+ /** BCP-47 language of the model picked on the Speech page. */
69
+ pickedLanguage: string
70
+ /** The miniapp's language hints (bare codes, from transcription.configure). */
71
+ hints: readonly string[]
72
+ }
73
+
74
+ /**
75
+ * Whether an on-device model in `language` transcribes what the listener asks
76
+ * for. A specific language must be the model's. Auto-detect follows the
77
+ * model's language, the one the user picked; but a preferLocal listener whose
78
+ * miniapp hinted at another language keeps the cloud, which tells them apart.
79
+ */
80
+ export function onDeviceServes(listener: RoutedListener, language: string, hints: readonly string[]): boolean {
81
+ const requested = listener.stream.slice(STREAM_PREFIX.length).toLowerCase()
82
+ if (requested === language.toLowerCase()) return true
83
+ if (listener.stream !== AUTO_STREAM) return false
84
+ if (listener.processing !== "preferLocal") return true
85
+ const base = language.split("-")[0].toLowerCase()
86
+ return hints.every((hint) => hint.toLowerCase() === base)
87
+ }
88
+
89
+ /** The source the listener should receive now. */
90
+ export function targetSource(listener: RoutedListener, state: TranscriptionRoutingState): TranscriptionSource {
91
+ if (listener.processing === "localOnly" || !state.cloudConnected) return "local"
92
+ if (listener.processing === "preferCloud") return "cloud"
93
+ return state.onDevice && state.localLanguage !== null && onDeviceServes(listener, state.localLanguage, state.hints)
94
+ ? "local"
95
+ : "cloud"
96
+ }
97
+
98
+ /**
99
+ * What a listener on `current` does on its way to `target`:
100
+ * - "settle": it has no source yet or is already there; take `target`.
101
+ * - "forced": `current` can no longer deliver; move now.
102
+ * - "wait": a source is still busy on its streams; stay.
103
+ * - "drain": both settled, but the on-device recognizer has spoken since its
104
+ * last drain; stay until a drain completes.
105
+ * - "planned": both settled and drained; move now.
106
+ */
107
+ export type HandoffStep = "settle" | "forced" | "wait" | "drain" | "planned"
108
+
109
+ export function handoffStep(
110
+ current: TranscriptionSource | null,
111
+ target: TranscriptionSource,
112
+ canDeliver: (source: TranscriptionSource) => boolean,
113
+ busy: (source: TranscriptionSource) => boolean,
114
+ drained: boolean,
115
+ ): HandoffStep {
116
+ if (current === null || current === target) return "settle"
117
+ if (!canDeliver(current)) return "forced"
118
+ if (busy(current) || busy(target)) return "wait"
119
+ return drained ? "planned" : "drain"
120
+ }
121
+
122
+ /** Whether the listener needs its stream from the cloud. */
123
+ export function needsCloud(
124
+ listener: RoutedListener,
125
+ source: TranscriptionSource | null,
126
+ target: TranscriptionSource,
127
+ ): boolean {
128
+ if (listener.processing === "localOnly") return false
129
+ return listener.processing === "preferCloud" || source === "cloud" || target === "cloud"
4
130
  }
5
131
 
6
- export interface TranscriptionSubscriberRoutes {
7
- cloud: boolean
8
- forceLocal: boolean
132
+ /**
133
+ * Whether the listener needs the on-device recognizer running. preferLocal
134
+ * asks for it whenever the picked model transcribes its language, which is
135
+ * what starts (and, when missing, downloads) that model.
136
+ */
137
+ export function needsLocal(
138
+ listener: RoutedListener,
139
+ source: TranscriptionSource | null,
140
+ state: TranscriptionRoutingState,
141
+ ): boolean {
142
+ if (source === "local" || listener.processing === "localOnly" || !state.cloudConnected) return true
143
+ return (
144
+ listener.processing === "preferLocal" &&
145
+ state.onDevice &&
146
+ onDeviceServes(listener, state.pickedLanguage, state.hints)
147
+ )
9
148
  }
10
149
 
11
- export type TranscriptionDeliveryRoute = "default" | "forceLocal" | "all"
150
+ /** When each source last produced a transcript on each stream, to tell when it has settled. */
151
+ export class UtteranceTracker {
152
+ /** `${source} ${stream}` -> its last transcript; stream "*" marks a source start, for every stream. */
153
+ private readonly last = new Map<string, {at: number; final: boolean}>()
154
+
155
+ constructor(
156
+ private readonly quietMs = SOURCE_QUIET_MS,
157
+ private readonly staleMs = UTTERANCE_STALE_MS,
158
+ ) {}
159
+
160
+ /** Records a transcript: an interim opens (or continues) an utterance, a final ends it. */
161
+ note(source: TranscriptionSource, stream: string, isFinal: boolean, now: number): void {
162
+ this.last.set(`${source} ${stream}`, {at: now, final: isFinal})
163
+ }
164
+
165
+ /**
166
+ * A source started (the cloud connected, or the on-device model loaded): it
167
+ * may already be working on speech it has not reported, so it settles only
168
+ * after a quiet window.
169
+ */
170
+ started(source: TranscriptionSource, now: number): void {
171
+ this.clear(source)
172
+ this.note(source, "*", true, now)
173
+ }
174
+
175
+ /** Forgets a source that stopped: it never reports anything more. */
176
+ clear(source: TranscriptionSource): void {
177
+ for (const key of [...this.last.keys()]) {
178
+ if (key.startsWith(`${source} `)) this.last.delete(key)
179
+ }
180
+ }
181
+
182
+ /** Whether `source` may still report speech that a listener on `stream` hears. */
183
+ busy(source: TranscriptionSource, stream: string, now: number): boolean {
184
+ for (const [key, last] of this.last) {
185
+ if (now >= this.settlesAt(last)) continue
186
+ const [keySource, keyStream] = key.split(" ")
187
+ if (keySource !== source) continue
188
+ if (keyStream === "*" || stream === AUTO_STREAM || keyStream === stream) return true
189
+ }
190
+ return false
191
+ }
192
+
193
+ /** When the next busy stream settles, or null when every one has. */
194
+ nextSettle(now: number): number | null {
195
+ let next: number | null = null
196
+ for (const [key, last] of this.last) {
197
+ const at = this.settlesAt(last)
198
+ if (now >= at) this.last.delete(key)
199
+ else if (next === null || at < next) next = at
200
+ }
201
+ return next
202
+ }
12
203
 
13
- export function summarizeTranscriptionRoutes<T>(
14
- subscribers: Iterable<T>,
15
- routesFor: (subscriber: T) => TranscriptionSubscriberRoutes,
16
- ): TranscriptionRouteSummary {
17
- let hasCloudSubscriber = false
18
- let hasForceLocalSubscriber = false
19
- for (const subscriber of subscribers) {
20
- const routes = routesFor(subscriber)
21
- if (routes.cloud) hasCloudSubscriber = true
22
- if (routes.forceLocal) hasForceLocalSubscriber = true
204
+ private settlesAt(last: {at: number; final: boolean}): number {
205
+ return last.at + (last.final ? this.quietMs : this.staleMs)
23
206
  }
24
- return {hasCloudSubscriber, hasForceLocalSubscriber}
25
- }
26
-
27
- export function transcriptionDeliveryRoute(
28
- source: "cloud" | "local",
29
- routes: TranscriptionSubscriberRoutes,
30
- cloudConnected: boolean,
31
- ): TranscriptionDeliveryRoute | null {
32
- if (source === "cloud") return routes.cloud ? "default" : null
33
- if (cloudConnected) return routes.forceLocal ? "forceLocal" : null
34
- if (routes.cloud && routes.forceLocal) return "all"
35
- if (routes.forceLocal) return "forceLocal"
36
- return routes.cloud ? "default" : null
37
207
  }
@@ -1,20 +1,132 @@
1
1
  import type {AudioPosition, TranscriptionToken} from "@mentra/cloud-protocol"
2
+ import {
3
+ handoffStep,
4
+ readSpeechProcessing,
5
+ type RoutedListener,
6
+ type SpeechProcessing,
7
+ type TranscriptionSource,
8
+ } from "./TranscriptionRouting"
2
9
 
3
10
  export interface TranscriptionListenerRegistration {
4
11
  id: string
5
12
  stream: string
6
- forceLocal?: boolean
13
+ processing: SpeechProcessing
14
+ /**
15
+ * Registered for an SDK that predates listener ids. It gets un-addressed
16
+ * events, which that SDK fans out to every listener of the same class, so
17
+ * all of a class's legacy registrations move between sources together.
18
+ */
7
19
  legacy?: boolean
8
20
  }
9
21
 
22
+ /** A transcription stream's entry in SUBSCRIBE `subscriptions`. */
23
+ export interface TranscriptionStreamEntry {
24
+ stream: string
25
+ processing?: unknown
26
+ /** SDKs before `processing`: the stream has localOnly listeners. */
27
+ forceLocal?: unknown
28
+ /** SDKs before `processing`, with forceLocal: the stream also has cloud listeners. */
29
+ includeCloud?: unknown
30
+ }
31
+
32
+ /** Records what one transcription stream entry asks for. */
33
+ export function addStreamProcessing(
34
+ byStream: Map<string, Set<SpeechProcessing>>,
35
+ entry: TranscriptionStreamEntry,
36
+ ): void {
37
+ const processing = byStream.get(entry.stream) ?? new Set<SpeechProcessing>()
38
+ byStream.set(entry.stream, processing)
39
+ processing.add(readSpeechProcessing(entry))
40
+ if (entry.processing === undefined && entry.forceLocal === true && entry.includeCloud === true) {
41
+ processing.add("preferCloud")
42
+ }
43
+ }
44
+
45
+ /**
46
+ * The transcription listeners of one SUBSCRIBE. SDKs register each listener
47
+ * (`transcriptionListeners`: `{id, stream, processing}`, or `forceLocal`
48
+ * before `processing`). A subscribed stream without registered listeners, as
49
+ * from SDKs that predate registration, gets one legacy registration per
50
+ * processing its stream entries ask for.
51
+ */
52
+ export function transcriptionRegistrations(
53
+ registered: unknown,
54
+ streams: string[],
55
+ streamProcessing: Map<string, Set<SpeechProcessing>>,
56
+ ): TranscriptionListenerRegistration[] {
57
+ const subscribed = new Set(streams.filter((stream) => stream.startsWith("transcription:")))
58
+ const registrations: TranscriptionListenerRegistration[] = []
59
+ for (const value of Array.isArray(registered) ? registered : []) {
60
+ if (typeof value !== "object" || value === null) continue
61
+ const listener = value as {id?: unknown; stream?: unknown; processing?: unknown; forceLocal?: unknown}
62
+ if (typeof listener.id !== "string" || typeof listener.stream !== "string") continue
63
+ if (!subscribed.has(listener.stream)) continue
64
+ registrations.push({id: listener.id, stream: listener.stream, processing: readSpeechProcessing(listener)})
65
+ }
66
+ const registeredStreams = new Set(registrations.map((registration) => registration.stream))
67
+ for (const stream of subscribed) {
68
+ if (registeredStreams.has(stream)) continue
69
+ for (const processing of streamProcessing.get(stream) ?? ["preferCloud" as const]) {
70
+ registrations.push({id: `legacy:${processing}:${stream}`, stream, processing, legacy: true})
71
+ }
72
+ }
73
+ return registrations
74
+ }
75
+
76
+ /** What `TranscriptionSubscriptions.route` moves listeners by. */
77
+ export interface TranscriptionHandoff {
78
+ target: (listener: RoutedListener) => TranscriptionSource
79
+ canDeliver: (source: TranscriptionSource) => boolean
80
+ /** Whether `source` is still busy on `stream` (see UtteranceTracker). */
81
+ busy: (source: TranscriptionSource, stream: string) => boolean
82
+ /** The on-device recognizer has been drained since it last spoke. */
83
+ drained: boolean
84
+ /** Cloud audio position where the last drain began. */
85
+ plannedWatermark: AudioPosition | null
86
+ /** Cloud audio position of the last on-device final. */
87
+ forcedWatermark: AudioPosition | null
88
+ }
89
+
90
+ /**
91
+ * Class an SDK from before `processing` filters routed events on: localOnly
92
+ * listeners take "forceLocal", the others "default".
93
+ */
94
+ export type TranscriptionDeliveryRoute = "default" | "forceLocal"
95
+
96
+ export interface TranscriptionDelivery {
97
+ /** Absent for a legacy class: the SDK fans the event out by `route`. */
98
+ listenerId?: string
99
+ route: TranscriptionDeliveryRoute
100
+ text: string
101
+ }
102
+
103
+ export interface TranscriptionListenerRoute extends RoutedListener {
104
+ id: string
105
+ /** Where the listener gets its text now; null before its first routing. */
106
+ source: TranscriptionSource | null
107
+ /** Where it is going (its source, unless a switch waits for a pause). */
108
+ target: TranscriptionSource
109
+ }
110
+
10
111
  interface Listener extends TranscriptionListenerRegistration {
11
112
  cutoff: AudioPosition | null
12
113
  sessions: Set<number>
13
114
  }
14
115
 
15
- /** Runtime-owned text projection. Token detail never crosses the miniapp bridge. */
116
+ interface GroupRoute {
117
+ source: TranscriptionSource
118
+ target: TranscriptionSource
119
+ }
120
+
121
+ /**
122
+ * One miniapp's transcription listeners: which source each one gets its text
123
+ * from, and its runtime-owned text projection. Token detail never crosses the
124
+ * miniapp bridge.
125
+ */
16
126
  export class TranscriptionSubscriptions {
17
127
  private listeners = new Map<string, Listener>()
128
+ /** Routing of each group: a listener, or one legacy class. */
129
+ private routing = new Map<string, GroupRoute>()
18
130
 
19
131
  observe(current: AudioPosition | null): void {
20
132
  if (!current) return
@@ -28,41 +140,149 @@ export class TranscriptionSubscriptions {
28
140
  const existing = this.listeners.get(registration.id)
29
141
  next.set(
30
142
  registration.id,
31
- existing?.stream === registration.stream && !!existing.forceLocal === !!registration.forceLocal
143
+ existing?.stream === registration.stream &&
144
+ existing.processing === registration.processing &&
145
+ !!existing.legacy === !!registration.legacy
32
146
  ? existing
33
147
  : {...registration, cutoff, sessions: new Set(cutoff ? [cutoff.sessionTag] : [])},
34
148
  )
35
149
  }
36
150
  this.listeners = next
151
+ const groups = new Set([...next.values()].map(groupOf))
152
+ for (const group of [...this.routing.keys()]) {
153
+ if (!groups.has(group)) this.routing.delete(group)
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Moves each group toward its target source (see `handoffStep`). A group
159
+ * that moves from on-device to the cloud starts its positioned cloud text
160
+ * at a watermark, so cloud tokens for speech it already got on-device are
161
+ * never projected to it: where the drain began after a planned move, the
162
+ * last on-device final after a forced one.
163
+ */
164
+ route(handoff: TranscriptionHandoff): {changed: boolean; needsDrain: boolean} {
165
+ let changed = false
166
+ let needsDrain = false
167
+ for (const [group, members] of this.groups()) {
168
+ // A legacy class moves on-device only when that serves all its streams.
169
+ const target = members.every((member) => handoff.target(member) === "local") ? "local" : "cloud"
170
+ const previous = this.routing.get(group)
171
+ const step = handoffStep(
172
+ previous?.source ?? null,
173
+ target,
174
+ handoff.canDeliver,
175
+ (candidate) => members.some((member) => handoff.busy(candidate, member.stream)),
176
+ handoff.drained,
177
+ )
178
+ if (step === "drain") needsDrain = true
179
+ const source = step === "wait" || step === "drain" ? previous!.source : target
180
+ if (previous?.source === source && previous.target === target) continue
181
+ changed = true
182
+ this.routing.set(group, {source, target})
183
+ const watermark = step === "planned" ? handoff.plannedWatermark : handoff.forcedWatermark
184
+ if (previous?.source === "local" && source === "cloud" && watermark) {
185
+ for (const member of members) advanceCutoff(member, watermark)
186
+ }
187
+ }
188
+ return {changed, needsDrain}
37
189
  }
38
190
 
39
- project(
191
+ /** Whether a listener is waiting for its sources to settle before it switches. */
192
+ switching(): boolean {
193
+ for (const routing of this.routing.values()) {
194
+ if (routing.source !== routing.target) return true
195
+ }
196
+ return false
197
+ }
198
+
199
+ /** Every listener with its current routing. */
200
+ routes(): TranscriptionListenerRoute[] {
201
+ return [...this.listeners.values()].map((listener) => {
202
+ const routing = this.routing.get(groupOf(listener))
203
+ return {
204
+ id: listener.id,
205
+ stream: listener.stream,
206
+ processing: listener.processing,
207
+ source: routing?.source ?? null,
208
+ target: routing?.target ?? (listener.processing === "localOnly" ? "local" : "cloud"),
209
+ }
210
+ })
211
+ }
212
+
213
+ /**
214
+ * The events one transcript produces for this miniapp: one per listener
215
+ * that hears `stream` and currently gets its text from `source`. Cloud
216
+ * transcripts with token positions are trimmed to what each listener may
217
+ * hear; without positions (`tokens` undefined) the text goes as is.
218
+ */
219
+ deliveries(
40
220
  stream: string,
41
- tokens: TranscriptionToken[],
221
+ source: TranscriptionSource,
222
+ text: string,
223
+ tokens: TranscriptionToken[] | undefined,
42
224
  current: AudioPosition | null,
43
- route: "default" | "forceLocal" | "all",
44
- ) {
225
+ ): TranscriptionDelivery[] {
45
226
  this.observe(current)
46
- const events: Array<{listenerId?: string; text: string}> = []
227
+ const deliveries: TranscriptionDelivery[] = []
47
228
  for (const listener of this.listeners.values()) {
48
229
  if (listener.stream !== stream && listener.stream !== "transcription:auto") continue
49
- if (listener.forceLocal ? route !== "forceLocal" && route !== "all" : route === "forceLocal") continue
50
- const eligible = tokens.filter((token) => {
51
- const position = token.audioPosition
52
- if (!position) return false
53
- if (position.sessionTag === listener.cutoff?.sessionTag) return position.offsetMs >= listener.cutoff.offsetMs
54
- return listener.sessions.has(position.sessionTag)
55
- })
56
- const text = eligible
57
- .map((token) => token.text)
58
- .join("")
59
- .trim()
60
- if (text && listener.legacy) {
61
- const existing = events.find((event) => event.listenerId === undefined)
62
- if (!existing) events.push({text})
63
- else if (text.length > existing.text.length) existing.text = text
64
- } else if (text) events.push({listenerId: listener.id, text})
230
+ if (this.routing.get(groupOf(listener))?.source !== source) continue
231
+ const listenerText = tokens ? projectTokens(listener, tokens) : text
232
+ if (tokens && !listenerText) continue
233
+ const route: TranscriptionDeliveryRoute = listener.processing === "localOnly" ? "forceLocal" : "default"
234
+ if (!listener.legacy) {
235
+ deliveries.push({listenerId: listener.id, route, text: listenerText})
236
+ continue
237
+ }
238
+ const existing = deliveries.find((delivery) => delivery.listenerId === undefined && delivery.route === route)
239
+ if (!existing) deliveries.push({route, text: listenerText})
240
+ else if (listenerText.length > existing.text.length) existing.text = listenerText
65
241
  }
66
- return events
242
+ return deliveries
67
243
  }
244
+
245
+ private groups(): Map<string, Listener[]> {
246
+ const groups = new Map<string, Listener[]>()
247
+ for (const listener of this.listeners.values()) {
248
+ const group = groupOf(listener)
249
+ const members = groups.get(group)
250
+ if (members) members.push(listener)
251
+ else groups.set(group, [listener])
252
+ }
253
+ return groups
254
+ }
255
+ }
256
+
257
+ function groupOf(listener: TranscriptionListenerRegistration): string {
258
+ return listener.legacy ? `legacy ${listener.processing}` : `listener ${listener.id}`
259
+ }
260
+
261
+ /**
262
+ * Moves a listener's cloud text start up to `watermark`, never back before
263
+ * where it already starts (its registration, or an earlier handoff). A
264
+ * watermark in a session the listener never saw predates it.
265
+ */
266
+ function advanceCutoff(listener: Listener, watermark: AudioPosition): void {
267
+ const cutoff = listener.cutoff
268
+ if (cutoff?.sessionTag === watermark.sessionTag) {
269
+ if (watermark.offsetMs > cutoff.offsetMs) listener.cutoff = watermark
270
+ return
271
+ }
272
+ if (!listener.sessions.has(watermark.sessionTag)) return
273
+ listener.cutoff = watermark
274
+ listener.sessions = new Set([watermark.sessionTag])
275
+ }
276
+
277
+ function projectTokens(listener: Listener, tokens: TranscriptionToken[]): string {
278
+ return tokens
279
+ .filter((token) => {
280
+ const position = token.audioPosition
281
+ if (!position) return false
282
+ if (position.sessionTag === listener.cutoff?.sessionTag) return position.offsetMs >= listener.cutoff.offsetMs
283
+ return listener.sessions.has(position.sessionTag)
284
+ })
285
+ .map((token) => token.text)
286
+ .join("")
287
+ .trim()
68
288
  }