@mentra/engine 3.4.0-dev.1182 → 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
@@ -57,8 +57,22 @@ import {phonePhotoCoordinator} from "./PhonePhotoCoordinator"
57
57
  import {phoneStreamCoordinator} from "./PhoneStreamCoordinator"
58
58
  import {phoneVideoCoordinator} from "./PhoneVideoCoordinator"
59
59
  import {runSentenceTtsPipeline} from "./SentenceTtsPipeline"
60
- import {summarizeTranscriptionRoutes, transcriptionDeliveryRoute} from "./TranscriptionRouting"
61
- import {TranscriptionSubscriptions, type TranscriptionListenerRegistration} from "./TranscriptionSubscriptions"
60
+ import {
61
+ needsCloud,
62
+ needsLocal,
63
+ onDeviceServes,
64
+ readSpeechProcessing,
65
+ targetSource,
66
+ UtteranceTracker,
67
+ type SpeechProcessing,
68
+ type TranscriptionRoutingState,
69
+ } from "./TranscriptionRouting"
70
+ import {
71
+ addStreamProcessing,
72
+ transcriptionRegistrations,
73
+ TranscriptionSubscriptions,
74
+ type TranscriptionStreamEntry,
75
+ } from "./TranscriptionSubscriptions"
62
76
  import {prepareTtsSentences} from "./TtsTextSanitizer"
63
77
  import {handleWifiAdbRequest} from "./WifiAdbRequest"
64
78
  import {cloudClientService} from "./CloudClientService"
@@ -90,7 +104,13 @@ import {
90
104
  resolveCaptureAudio,
91
105
  } from "../runtime/streamConfig"
92
106
  import {toLanguageHint} from "@mentra/cloud-protocol/languages"
93
- import type {AudioSubscription, LanguageSource, TranscriptionData, TranslationData} from "@mentra/cloud-protocol"
107
+ import type {
108
+ AudioPosition,
109
+ AudioSubscription,
110
+ LanguageSource,
111
+ TranscriptionData,
112
+ TranslationData,
113
+ } from "@mentra/cloud-protocol"
94
114
  import {buildMiniappManifestSnapshot, type MiniappRuntimeDiagnosticSnapshot} from "../utils/miniappDiagnostics"
95
115
  import sttModelManager from "./STTModelManager"
96
116
  import ttsModelManager from "./TTSModelManager"
@@ -178,12 +198,9 @@ export interface InstalledMiniappManifest {
178
198
  type SpeakerStateValue = "idle" | "loading" | "playing" | "stopped" | "error"
179
199
 
180
200
  interface ConnectedMiniapp {
181
- transcriptionListeners?: TranscriptionSubscriptions
201
+ /** Transcription listeners, each with the source (cloud or on-device) it gets text from. */
202
+ transcriptionListeners: TranscriptionSubscriptions
182
203
  subscriptions: Set<string>
183
- /** Transcription streams this app explicitly requires to stay on-device. */
184
- forceLocalTranscriptionStreams: Set<string>
185
- /** Transcription streams with at least one listener using the default/cloud route. */
186
- cloudTranscriptionStreams: Set<string>
187
204
  sendMessage: (raw: string) => void
188
205
  lastPongAt: number
189
206
  unansweredPingRounds: number
@@ -263,6 +280,18 @@ function locationRateRank(rate: string | null | undefined): number {
263
280
  return i >= 0 ? i : LOCATION_RATE_PRIORITY.indexOf("passive")
264
281
  }
265
282
 
283
+ /** Why this deployment refuses a transcription listener's processing, or null. */
284
+ function speechProcessingDisabled(processing: SpeechProcessing): string | null {
285
+ const cloud = isFeatureEnabled("cloudSpeech")
286
+ const onDevice = isFeatureEnabled("onDeviceSpeech")
287
+ if (processing === "localOnly" && !onDevice) return "On-device speech is disabled by this deployment"
288
+ if (processing === "preferCloud" && !cloud) return "Cloud speech is disabled by this deployment"
289
+ if (processing === "preferLocal" && !cloud && !onDevice) {
290
+ return "Cloud and on-device speech are disabled by this deployment"
291
+ }
292
+ return null
293
+ }
294
+
266
295
  const LOG_TAG = "LOCAL_MINIAPP"
267
296
 
268
297
  /**
@@ -1023,8 +1052,7 @@ class LocalMiniappRuntime {
1023
1052
  }
1024
1053
  this.connectedApps.set(packageName, {
1025
1054
  subscriptions: new Set(),
1026
- forceLocalTranscriptionStreams: new Set(),
1027
- cloudTranscriptionStreams: new Set(),
1055
+ transcriptionListeners: new TranscriptionSubscriptions(),
1028
1056
  sendMessage: sendFn,
1029
1057
  lastPongAt: Date.now(),
1030
1058
  unansweredPingRounds: 0,
@@ -1678,7 +1706,9 @@ class LocalMiniappRuntime {
1678
1706
  case MiniappRequestType.TRANSCRIPTION_FINISH: {
1679
1707
  const app = this.connectedApps.get(packageName)
1680
1708
  if (
1681
- !app?.forceLocalTranscriptionStreams.size ||
1709
+ !app?.transcriptionListeners
1710
+ .routes()
1711
+ .some((route) => route.processing === "localOnly" || route.source === "local") ||
1682
1712
  !app.installedManifest?.permissions?.some((p) => p.type === "MICROPHONE")
1683
1713
  ) {
1684
1714
  this.sendResult(packageName, requestId, false, undefined, {
@@ -2053,15 +2083,15 @@ class LocalMiniappRuntime {
2053
2083
  if (!app) return
2054
2084
 
2055
2085
  const rawStreams = (payload.subscriptions ?? payload.streams) as
2056
- | (string | {stream: string; rate?: string; forceLocal?: boolean; includeCloud?: boolean})[]
2057
- | undefined
2086
+ (string | ({rate?: string} & TranscriptionStreamEntry))[] | undefined
2058
2087
  // Normalize: objects like {stream: "location_stream", rate: "realtime"} → extract stream name
2059
2088
  // "location_stream" is the rate-bearing alias for "location_update".
2060
2089
  // The strictest rate wins when multiple `location_stream` entries appear
2061
2090
  // in one SUBSCRIBE — the same rule we apply across apps.
2062
2091
  let requestedLocationRate: string | null = null
2063
- const forceLocalTranscriptionStreams = new Set<string>()
2064
- const cloudTranscriptionStreams = new Set<string>()
2092
+ // What each transcription stream's entries ask for: `{stream, processing}`,
2093
+ // or `{stream, forceLocal, includeCloud}` from SDKs before `processing`.
2094
+ const streamProcessing = new Map<string, Set<SpeechProcessing>>()
2065
2095
  const streams = rawStreams?.map((s) => {
2066
2096
  if (typeof s === "object" && s !== null) {
2067
2097
  if (s.stream === "location_stream") {
@@ -2070,15 +2100,10 @@ class LocalMiniappRuntime {
2070
2100
  }
2071
2101
  return "location_update"
2072
2102
  }
2073
- if (s.forceLocal === true && s.stream.startsWith("transcription:")) {
2074
- forceLocalTranscriptionStreams.add(s.stream)
2075
- }
2076
- if (s.stream.startsWith("transcription:") && (s.forceLocal !== true || s.includeCloud === true)) {
2077
- cloudTranscriptionStreams.add(s.stream)
2078
- }
2103
+ if (s.stream.startsWith("transcription:")) addStreamProcessing(streamProcessing, s)
2079
2104
  return s.stream
2080
2105
  }
2081
- if (s.startsWith("transcription:")) cloudTranscriptionStreams.add(s)
2106
+ if (s.startsWith("transcription:")) addStreamProcessing(streamProcessing, {stream: s})
2082
2107
  return s
2083
2108
  }) as string[] | undefined
2084
2109
  if (!Array.isArray(streams)) {
@@ -2088,9 +2113,9 @@ class LocalMiniappRuntime {
2088
2113
  })
2089
2114
  return
2090
2115
  }
2116
+ const transcriptionListeners = transcriptionRegistrations(payload.transcriptionListeners, streams, streamProcessing)
2091
2117
 
2092
- for (const subscription of rawStreams ?? []) {
2093
- const stream = typeof subscription === "string" ? subscription : subscription.stream
2118
+ for (const stream of streams) {
2094
2119
  if (stream.startsWith("translation:") && !isFeatureEnabled("cloudSpeech")) {
2095
2120
  this.sendResult(packageName, requestId, false, undefined, {
2096
2121
  code: MiniappErrorCode.NOT_IMPLEMENTED,
@@ -2105,20 +2130,13 @@ class LocalMiniappRuntime {
2105
2130
  })
2106
2131
  return
2107
2132
  }
2108
- if (!stream.startsWith("transcription:")) continue
2109
- const forceLocal = typeof subscription === "object" && subscription.forceLocal === true
2110
- const includeCloud = typeof subscription === "object" && subscription.includeCloud === true
2111
- if (forceLocal && !isFeatureEnabled("onDeviceSpeech")) {
2112
- this.sendResult(packageName, requestId, false, undefined, {
2113
- code: MiniappErrorCode.NOT_IMPLEMENTED,
2114
- message: "On-device speech is disabled by this deployment",
2115
- })
2116
- return
2117
- }
2118
- if ((!forceLocal || includeCloud) && !isFeatureEnabled("cloudSpeech")) {
2133
+ }
2134
+ for (const {processing} of transcriptionListeners) {
2135
+ const disabled = speechProcessingDisabled(processing)
2136
+ if (disabled) {
2119
2137
  this.sendResult(packageName, requestId, false, undefined, {
2120
2138
  code: MiniappErrorCode.NOT_IMPLEMENTED,
2121
- message: "Cloud speech is disabled by this deployment",
2139
+ message: disabled,
2122
2140
  })
2123
2141
  return
2124
2142
  }
@@ -2162,43 +2180,8 @@ class LocalMiniappRuntime {
2162
2180
  // A miniapp that subscribes to the model status again gets it again.
2163
2181
  if (!app.subscriptions.has(MiniappStreamType.TRANSCRIPTION_MODEL_STATUS))
2164
2182
  app.lastTranscriptionModelStatus = undefined
2165
- app.forceLocalTranscriptionStreams = new Set(
2166
- [...forceLocalTranscriptionStreams].filter((stream) => app.subscriptions.has(stream)),
2167
- )
2168
- app.cloudTranscriptionStreams = new Set(
2169
- [...cloudTranscriptionStreams].filter((stream) => app.subscriptions.has(stream)),
2170
- )
2171
2183
  this.replaceStreamSubscribers(packageName, previousSubscriptions, app.subscriptions)
2172
-
2173
- if (Array.isArray(payload.transcriptionListeners)) {
2174
- const registrations = payload.transcriptionListeners
2175
- .filter((value): value is TranscriptionListenerRegistration => {
2176
- if (typeof value !== "object" || value === null) return false
2177
- const r = value as Partial<TranscriptionListenerRegistration>
2178
- return (
2179
- typeof r.id === "string" &&
2180
- typeof r.stream === "string" &&
2181
- r.stream.startsWith("transcription:") &&
2182
- app.subscriptions.has(r.stream) &&
2183
- (r.forceLocal === undefined || typeof r.forceLocal === "boolean")
2184
- )
2185
- })
2186
- .map(({id, stream, forceLocal}) => ({id, stream, forceLocal}))
2187
- app.transcriptionListeners ??= new TranscriptionSubscriptions()
2188
- app.transcriptionListeners.replace(registrations, cloudClientService.getAudioPosition())
2189
- } else {
2190
- // Older bundles expose one registration per stream. The runtime can still
2191
- // trim app joins/resubscriptions without sending them new payload fields.
2192
- app.transcriptionListeners ??= new TranscriptionSubscriptions()
2193
- app.transcriptionListeners.replace(
2194
- [...app.cloudTranscriptionStreams].map((stream) => ({
2195
- id: `legacy:${stream}`,
2196
- stream,
2197
- legacy: true,
2198
- })),
2199
- cloudClientService.getAudioPosition(),
2200
- )
2201
- }
2184
+ app.transcriptionListeners.replace(transcriptionListeners, cloudClientService.getAudioPosition())
2202
2185
 
2203
2186
  this.recomputeMicRequirements()
2204
2187
  this.updateCloudSubscriptions()
@@ -2841,10 +2824,11 @@ class LocalMiniappRuntime {
2841
2824
  !voiceExplicit || voice === "default" || ttsModelManager.getAvailableLanguages().some((l) => l.code === voice)
2842
2825
 
2843
2826
  const modelId = typeof payload.model_id === "string" ? payload.model_id : undefined
2844
- const forceLocal = payload.forceLocal === true
2827
+ // `processing` wins; SDKs before it send only `forceLocal`.
2828
+ const processing = readSpeechProcessing(payload)
2845
2829
  const cloudConnected = cloudClientService.isConnected()
2846
2830
  console.log(
2847
- `${LOG_TAG}: TTS decision for ${packageName}: cloudConnected=${cloudConnected}, runtimeTts=yes, offlineVoice=${offlineSupportsVoice}, forceLocal=${forceLocal}`,
2831
+ `${LOG_TAG}: TTS decision for ${packageName}: cloudConnected=${cloudConnected}, runtimeTts=yes, offlineVoice=${offlineSupportsVoice}, processing=${processing}`,
2848
2832
  )
2849
2833
 
2850
2834
  const playOfflineTts = async (reason?: string): Promise<boolean> => {
@@ -3019,28 +3003,38 @@ class LocalMiniappRuntime {
3019
3003
  return true
3020
3004
  }
3021
3005
 
3022
- if (forceLocal) {
3006
+ if (processing === "localOnly") {
3023
3007
  if (await playOfflineTts()) {
3024
3008
  return
3025
3009
  }
3026
3010
  if (run.cancelled) return
3027
- const message = "tts unavailable: forceLocal requested but offline TTS model is not available"
3011
+ const message = "tts unavailable: localOnly requested but offline TTS model is not available"
3028
3012
  sendPlaybackResult(false, message, null, message, false, MiniappErrorCode.TTS_LOCAL_UNAVAILABLE)
3029
3013
  return
3030
3014
  }
3031
3015
 
3032
- if (cloudConnected && (await playCloudTts(true))) {
3033
- return
3034
- }
3016
+ if (processing === "preferLocal") {
3017
+ // On-device first; the cloud when the offline model is missing or can't say it.
3018
+ if (await playOfflineTts()) {
3019
+ return
3020
+ }
3021
+ if (await playCloudTts(false)) {
3022
+ return
3023
+ }
3024
+ } else {
3025
+ if (cloudConnected && (await playCloudTts(true))) {
3026
+ return
3027
+ }
3035
3028
 
3036
- // Offline is the disconnected-cloud fallback. If the model is not
3037
- // available, preserve the old behavior by trying cloud as a last resort.
3038
- if (await playOfflineTts(cloudConnected ? undefined : "cloud disconnected")) {
3039
- return
3040
- }
3029
+ // Offline is the disconnected-cloud fallback. If the model is not
3030
+ // available, preserve the old behavior by trying cloud as a last resort.
3031
+ if (await playOfflineTts(cloudConnected ? undefined : "cloud disconnected")) {
3032
+ return
3033
+ }
3041
3034
 
3042
- if (await playCloudTts(false)) {
3043
- return
3035
+ if (await playCloudTts(false)) {
3036
+ return
3037
+ }
3044
3038
  }
3045
3039
 
3046
3040
  const message = "tts unavailable: no cloud TTS URL or offline TTS model"
@@ -5973,15 +5967,8 @@ class LocalMiniappRuntime {
5973
5967
  // Add new subscriptions
5974
5968
  const previousSubscriptions = app.subscriptions
5975
5969
  app.subscriptions = new Set(streams)
5976
- app.forceLocalTranscriptionStreams.clear()
5977
- app.cloudTranscriptionStreams = new Set(streams.filter((stream) => stream.startsWith("transcription:")))
5978
- app.transcriptionListeners ??= new TranscriptionSubscriptions()
5979
5970
  app.transcriptionListeners.replace(
5980
- [...app.cloudTranscriptionStreams].map((stream) => ({
5981
- id: `legacy:${stream}`,
5982
- stream,
5983
- legacy: true,
5984
- })),
5971
+ transcriptionRegistrations(undefined, streams, new Map()),
5985
5972
  cloudClientService.getAudioPosition(),
5986
5973
  )
5987
5974
  this.replaceStreamSubscribers(packageName, previousSubscriptions, app.subscriptions)
@@ -6033,9 +6020,28 @@ class LocalMiniappRuntime {
6033
6020
  * are NOT sent — they come from the Bluetooth SDK, not from cloud.
6034
6021
  */
6035
6022
  private updateCloudSubscriptions(): void {
6023
+ // The on-device recognizer reports its state back synchronously from
6024
+ // setDemand; that report asks for another pass instead of nesting one.
6025
+ if (this.updatingCloudSubscriptions) {
6026
+ this.cloudSubscriptionsDirty = true
6027
+ return
6028
+ }
6029
+ this.updatingCloudSubscriptions = true
6030
+ try {
6031
+ do {
6032
+ this.cloudSubscriptionsDirty = false
6033
+ this.applyCloudSubscriptions()
6034
+ } while (this.cloudSubscriptionsDirty)
6035
+ } finally {
6036
+ this.updatingCloudSubscriptions = false
6037
+ }
6038
+ }
6039
+
6040
+ private updatingCloudSubscriptions = false
6041
+ private cloudSubscriptionsDirty = false
6042
+
6043
+ private applyCloudSubscriptions(): void {
6036
6044
  const cloudStreams = new Set<string>()
6037
- let hasTranscription = false
6038
- let hasForceLocalTranscription = false
6039
6045
  for (const [stream, subscribers] of this.streamSubscribers) {
6040
6046
  if (subscribers.size === 0) continue
6041
6047
  // Only transcription / translation need cloud delivery. Location and
@@ -6043,43 +6049,171 @@ class LocalMiniappRuntime {
6043
6049
  if (stream.startsWith("translation:")) {
6044
6050
  cloudStreams.add(stream)
6045
6051
  }
6046
- if (stream.startsWith("transcription:")) {
6047
- hasTranscription = true
6048
- const routes = summarizeTranscriptionRoutes(subscribers, (packageName) =>
6049
- this.appTranscriptionRoutesForSubscription(packageName, stream),
6050
- )
6051
- if (routes.hasForceLocalSubscriber) hasForceLocalTranscription = true
6052
- if (routes.hasCloudSubscriber) cloudStreams.add(stream)
6052
+ }
6053
+ // A transcription stream goes to the cloud only while a listener on it
6054
+ // needs the cloud; on-device transcription runs only while one needs it.
6055
+ this.refreshTranscriptionRouting()
6056
+ let localDemand = false
6057
+ for (const [packageName, app] of this.connectedApps) {
6058
+ const state = this.transcriptionRoutingState(packageName)
6059
+ for (const route of app.transcriptionListeners.routes()) {
6060
+ if (needsCloud(route, route.source, route.target)) cloudStreams.add(route.stream)
6061
+ if (needsLocal(route, route.source, state)) localDemand = true
6053
6062
  }
6054
6063
  }
6055
- // On-device transcription always runs the Speech-page model; its language,
6056
- // not the subscription's, decides what local transcripts are tagged with.
6057
- localSttFallbackCoordinator.onSubscriptionChange(hasTranscription, hasForceLocalTranscription)
6058
- this.broadcastTranscriptionModelStatus()
6059
6064
 
6060
6065
  // Mirror the same set as typed AudioSubscription[] and push it to the cloud
6061
6066
  // runtime.
6062
6067
  this.ensureCloudResultsWired()
6063
6068
  const subs = this.buildCloudAudioSubscriptions(cloudStreams)
6064
6069
  const nextKey = this.audioSubscriptionKey(subs)
6065
- if (!this.cloudAudioSubscriptionSync.begin(nextKey)) return
6066
- console.log(
6067
- `${LOG_TAG}: updateCloudSubscriptions streams=[${Array.from(cloudStreams).join(", ")}] cloudSubs=${subs.length}`,
6068
- )
6069
- cloudClientService
6070
- .setSubscriptions(subs)
6071
- .then(() => {
6072
- this.cloudAudioSubscriptionSync.succeeded(nextKey)
6070
+ if (this.cloudAudioSubscriptionSync.begin(nextKey)) {
6071
+ console.log(
6072
+ `${LOG_TAG}: updateCloudSubscriptions streams=[${Array.from(cloudStreams).join(", ")}] cloudSubs=${subs.length}`,
6073
+ )
6074
+ cloudClientService
6075
+ .setSubscriptions(subs)
6076
+ .then(() => {
6077
+ this.cloudAudioSubscriptionSync.succeeded(nextKey)
6078
+ })
6079
+ .catch((err) => {
6080
+ // Best-effort: the cloud may not be connected yet. Keep failed writes
6081
+ // retryable; otherwise a reconnect that recomputes the same desired
6082
+ // transcription set gets deduped and local captions never recover.
6083
+ this.cloudAudioSubscriptionSync.failed(nextKey)
6084
+ console.warn(`${LOG_TAG}: cloud setSubscriptions failed: ${(err as Error)?.message ?? err}`)
6085
+ })
6086
+ }
6087
+
6088
+ // On-device transcription always runs the Speech-page model; its language,
6089
+ // not the subscription's, decides what local transcripts are tagged with.
6090
+ localSttFallbackCoordinator.setDemand(localDemand)
6091
+ this.broadcastTranscriptionModelStatus()
6092
+ }
6093
+
6094
+ // ===========================================================================
6095
+ // Transcription routing (cloud or on-device, per listener)
6096
+ // ===========================================================================
6097
+
6098
+ /** When each transcript source last spoke, to switch listeners only where both have settled. */
6099
+ private readonly utterances = new UtteranceTracker()
6100
+ /** Which sources were up at the last routing pass, to notice one starting. */
6101
+ private sourcesUp = {cloud: false, local: false}
6102
+ /** Cloud audio position at the last on-device final. */
6103
+ private localFinalWatermark: AudioPosition | null = null
6104
+ /** Bumped whenever the on-device recognizer starts or stops: a drain holds only for the run it drained. */
6105
+ private localGeneration = 0
6106
+ /** A drain that just completed, for the routing pass it triggers only; and whether one is running. */
6107
+ private localDrain: {generation: number; watermark: AudioPosition | null} | null = null
6108
+ private localDrainRunning = false
6109
+ /** Re-routes once a waiting switch's sources settle, since silence brings no transcript to do it. */
6110
+ private handoffTimer: {id: number; at: number} | null = null
6111
+
6112
+ /** Cloud transcription is allowed by this deployment and connected. */
6113
+ private cloudSpeechUp(): boolean {
6114
+ return isFeatureEnabled("cloudSpeech") && cloudClientService.isConnected()
6115
+ }
6116
+
6117
+ /** What one miniapp's listeners can be transcribed with right now. */
6118
+ private transcriptionRoutingState(packageName: string): TranscriptionRoutingState {
6119
+ return {
6120
+ cloudConnected: this.cloudSpeechUp(),
6121
+ onDevice: isFeatureEnabled("onDeviceSpeech"),
6122
+ localLanguage: localSttFallbackCoordinator.getActiveLanguage(),
6123
+ pickedLanguage: sttModelManager.getBcp47ForLanguage(),
6124
+ hints: this.transcriptionHintsByApp.get(packageName) ?? [],
6125
+ }
6126
+ }
6127
+
6128
+ /**
6129
+ * Moves every transcription listener toward the source it should use now
6130
+ * (see TranscriptionRouting: a planned switch waits for a settled pause and
6131
+ * a drain of the on-device recognizer). Returns true when any listener's
6132
+ * source or target changed, which can change what must run.
6133
+ */
6134
+ private refreshTranscriptionRouting(): boolean {
6135
+ const cloudConnected = this.cloudSpeechUp()
6136
+ const localActive = localSttFallbackCoordinator.isActive()
6137
+ const now = Date.now()
6138
+ // A source that stopped reports nothing more; one that started may be
6139
+ // working on speech it has not reported yet.
6140
+ for (const [source, up] of [
6141
+ ["cloud", cloudConnected],
6142
+ ["local", localActive],
6143
+ ] as const) {
6144
+ if (up && !this.sourcesUp[source]) this.utterances.started(source, now)
6145
+ if (!up) this.utterances.clear(source)
6146
+ if (source === "local" && up !== this.sourcesUp.local) this.localGeneration++
6147
+ this.sourcesUp[source] = up
6148
+ }
6149
+ const drained = this.localDrain !== null && this.localDrain.generation === this.localGeneration
6150
+ let changed = false
6151
+ let needsDrain = false
6152
+ for (const [packageName, app] of this.connectedApps) {
6153
+ const state = this.transcriptionRoutingState(packageName)
6154
+ const routed = app.transcriptionListeners.route({
6155
+ target: (listener) => targetSource(listener, state),
6156
+ canDeliver: (source) => (source === "cloud" ? cloudConnected : localActive),
6157
+ busy: (source, stream) => this.utterances.busy(source, stream, now),
6158
+ drained,
6159
+ plannedWatermark: this.localDrain?.watermark ?? null,
6160
+ forcedWatermark: this.localFinalWatermark,
6073
6161
  })
6074
- .catch((err) => {
6075
- // Best-effort: the cloud may not be connected yet. Keep failed writes
6076
- // retryable; otherwise a reconnect that recomputes the same desired
6077
- // transcription set gets deduped and local captions never recover.
6078
- this.cloudAudioSubscriptionSync.failed(nextKey)
6079
- console.warn(`${LOG_TAG}: cloud setSubscriptions failed: ${(err as Error)?.message ?? err}`)
6162
+ if (routed.changed) changed = true
6163
+ if (routed.needsDrain) needsDrain = true
6164
+ }
6165
+ if (needsDrain) this.drainLocalRecognizer()
6166
+ this.scheduleHandoff(now)
6167
+ return changed
6168
+ }
6169
+
6170
+ /**
6171
+ * Has the on-device recognizer report everything it has heard and start
6172
+ * over (finishTranscription): the boundary of a planned switch. What it
6173
+ * reports goes to the listeners still on-device. The drain then serves the
6174
+ * switches ready in the routing pass it triggers, and only those: the
6175
+ * recognizer keeps hearing audio, so any later switch drains again. A model
6176
+ * that cannot finish early reports nothing, and the quiet window is the
6177
+ * boundary.
6178
+ */
6179
+ private drainLocalRecognizer(): void {
6180
+ if (this.localDrainRunning) return
6181
+ this.localDrainRunning = true
6182
+ const generation = this.localGeneration
6183
+ const watermark = cloudClientService.getAudioPosition()
6184
+ void this.finishLocalUtterance()
6185
+ .catch((error) => console.warn(`${LOG_TAG}: draining on-device transcription failed`, error))
6186
+ .then(() => {
6187
+ this.localDrainRunning = false
6188
+ // Everything the recognizer had is reported; its next words are new speech.
6189
+ this.utterances.clear("local")
6190
+ this.localDrain = {generation, watermark}
6191
+ const changed = this.refreshTranscriptionRouting()
6192
+ this.localDrain = null
6193
+ if (changed) this.updateCloudSubscriptions()
6080
6194
  })
6081
6195
  }
6082
6196
 
6197
+ /**
6198
+ * Arms a timer for the moment the next busy source settles while any
6199
+ * listener waits to switch, so the switch (and dropping what it no longer
6200
+ * needs, such as a cloud stream) happens during silence too.
6201
+ */
6202
+ private scheduleHandoff(now: number): void {
6203
+ const waiting = [...this.connectedApps.values()].some((app) => app.transcriptionListeners.switching())
6204
+ const at = waiting ? this.utterances.nextSettle(now) : null
6205
+ if (this.handoffTimer && at !== null && this.handoffTimer.at <= at) return
6206
+ if (this.handoffTimer) BgTimer.clearTimeout(this.handoffTimer.id)
6207
+ this.handoffTimer = null
6208
+ if (at === null) return
6209
+ const id = BgTimer.setTimeout(() => {
6210
+ if (this.handoffTimer?.id !== id) return
6211
+ this.handoffTimer = null
6212
+ if (this.refreshTranscriptionRouting()) this.updateCloudSubscriptions()
6213
+ }, at - now)
6214
+ this.handoffTimer = {id, at}
6215
+ }
6216
+
6083
6217
  /**
6084
6218
  * Build the cloud `AudioSubscription[]` from the miniapp cloud-stream key set.
6085
6219
  *
@@ -6226,7 +6360,8 @@ class LocalMiniappRuntime {
6226
6360
  this.broadcastCloudStatus()
6227
6361
  })
6228
6362
  sttModelManager.onSelectionChanged(() => this.updateCloudSubscriptions())
6229
- localSttFallbackCoordinator.onModelStatusChanged(() => this.broadcastTranscriptionModelStatus())
6363
+ // On-device transcription starting or stopping moves preferLocal and fallback listeners.
6364
+ localSttFallbackCoordinator.onModelStatusChanged(() => this.updateCloudSubscriptions())
6230
6365
 
6231
6366
  useSettingsStore.subscribe(
6232
6367
  (s) => s.getSetting(ISLAND_SETTINGS_KEYS.localSttFallbackActive),
@@ -6241,9 +6376,11 @@ class LocalMiniappRuntime {
6241
6376
  // ===========================================================================
6242
6377
 
6243
6378
  /**
6244
- * The on-device model state as one miniapp sees it. The phone runs one model,
6245
- * the one picked on the Speech page, so a miniapp whose on-device
6246
- * subscriptions all ask for another language is told it's unavailable.
6379
+ * The on-device model state as one miniapp sees it. Its on-device listeners
6380
+ * are the localOnly and preferLocal ones, and every listener while the cloud
6381
+ * is down. The phone runs one model, the one picked on the Speech page, so a
6382
+ * miniapp whose on-device listeners all ask for another language is told
6383
+ * it's unavailable.
6247
6384
  */
6248
6385
  private transcriptionModelStatusFor(packageName: string): {
6249
6386
  language: string
@@ -6256,20 +6393,16 @@ class LocalMiniappRuntime {
6256
6393
  if (!isFeatureEnabled("onDeviceSpeech")) {
6257
6394
  return {language, state: "unavailable", reason: "On-device speech is disabled by this deployment"}
6258
6395
  }
6259
- const app = this.connectedApps.get(packageName)
6260
- const cloudConnected = cloudClientService.isConnected()
6261
- const onDeviceLanguages = [...(app?.subscriptions ?? [])]
6262
- .filter(
6263
- (stream) =>
6264
- stream.startsWith(`${MiniappStreamType.TRANSCRIPTION}:`) &&
6265
- (app?.forceLocalTranscriptionStreams.has(stream) === true || !cloudConnected),
6266
- )
6267
- .map((stream) => stream.slice(MiniappStreamType.TRANSCRIPTION.length + 1))
6268
- if (onDeviceLanguages.length === 0) {
6396
+ const cloudConnected = this.cloudSpeechUp()
6397
+ const onDeviceListeners = (this.connectedApps.get(packageName)?.transcriptionListeners.routes() ?? []).filter(
6398
+ (listener) => listener.processing !== "preferCloud" || !cloudConnected,
6399
+ )
6400
+ if (onDeviceListeners.length === 0) {
6269
6401
  return {language, state: "unavailable", reason: "No on-device transcription subscription"}
6270
6402
  }
6271
6403
  // "auto" follows the picked model; any other language must be the picked one.
6272
- if (!onDeviceLanguages.some((lang) => lang === "auto" || lang.toLowerCase() === language.toLowerCase())) {
6404
+ const hints = this.transcriptionHintsByApp.get(packageName) ?? []
6405
+ if (!onDeviceListeners.some((listener) => onDeviceServes(listener, language, hints))) {
6273
6406
  return {
6274
6407
  language,
6275
6408
  state: "unavailable",
@@ -6441,6 +6574,11 @@ class LocalMiniappRuntime {
6441
6574
  }
6442
6575
  }
6443
6576
 
6577
+ if (normalizedStream.startsWith("transcription:") && transcriptionSource) {
6578
+ this.forwardTranscription(normalizedStream, data, transcriptionSource, tokens, matchedSubs)
6579
+ return
6580
+ }
6581
+
6444
6582
  // Touch event per-gesture fan-out. SDK-side dispatch is keyed on the
6445
6583
  // exact streamType, so per-gesture subscribers (`touch_event:single_tap`,
6446
6584
  // etc.) need their own EVENT envelope with the gesture-tagged streamType.
@@ -6465,36 +6603,9 @@ class LocalMiniappRuntime {
6465
6603
  }
6466
6604
  }
6467
6605
 
6468
- if (normalizedStream.startsWith("transcription:")) {
6469
- // const known = Array.from(this.streamSubscribers.keys())
6470
- // console.log(
6471
- // `${LOG_TAG}: forwardEvent(${streamType} → ${normalizedStream}) matched=${matchedSubs.size} known=[${known.join(", ")}]`,
6472
- // )
6473
- }
6474
-
6475
- const localFinal = transcriptionSource === "local" && (data as {isFinal?: boolean} | null)?.isFinal === true
6476
- if (matchedSubs.size === 0 && !perGestureStream) {
6477
- if (localFinal) this.noteLocalFinalDelivered()
6478
- return
6479
- }
6606
+ if (matchedSubs.size === 0 && !perGestureStream) return
6480
6607
 
6481
6608
  for (const packageName of matchedSubs) {
6482
- let transcriptionRoute: "default" | "forceLocal" | "all" | undefined
6483
- if (normalizedStream.startsWith("transcription:") && transcriptionSource) {
6484
- const routes = this.appTranscriptionRoutesForEvent(packageName, normalizedStream)
6485
- transcriptionRoute =
6486
- transcriptionDeliveryRoute(transcriptionSource, routes, cloudClientService.isConnected()) ?? undefined
6487
- if (!transcriptionRoute) continue
6488
- }
6489
- if (TRANSCRIPT_TIMING_TELEMETRY && normalizedStream.startsWith("transcription:")) {
6490
- const transcript = data as {text?: string; isFinal?: boolean; __hostReceivedAt?: number} | null
6491
- const now = Date.now()
6492
- console.log(
6493
- `${LOG_TAG}: transcript fanout t=${now} app=${packageName} final=${!!transcript?.isFinal} hostDelta=${
6494
- transcript?.__hostReceivedAt ? now - transcript.__hostReceivedAt : -1
6495
- }ms text="${(transcript?.text ?? "").slice(0, 48)}"`,
6496
- )
6497
- }
6498
6609
  // While a nav trip is active the Nav SDK's road-snapped fixes are
6499
6610
  // already being forwarded via addLocationListener (see NAV_START handler).
6500
6611
  // Suppress the raw background-GPS forward for that miniapp so the two
@@ -6503,35 +6614,13 @@ class LocalMiniappRuntime {
6503
6614
  if (normalizedStream === MiniappStreamType.LOCATION_UPDATE && this.navigationHandlers.isTripActive(packageName)) {
6504
6615
  continue
6505
6616
  }
6506
- const listenerRegistry = this.connectedApps.get(packageName)?.transcriptionListeners
6507
- const audioPosition = cloudClientService.getAudioPosition()
6508
- if (listenerRegistry && transcriptionSource === "cloud" && tokens !== undefined) {
6509
- for (const projected of listenerRegistry.project(
6510
- normalizedStream,
6511
- tokens ?? [],
6512
- audioPosition,
6513
- transcriptionRoute ?? "default",
6514
- )) {
6515
- this.sendToMiniapp(packageName, {
6516
- type: MiniappResponseType.EVENT,
6517
- streamType: normalizedStream,
6518
- data: {...(outboundData as object), text: projected.text},
6519
- transcriptionRoute,
6520
- ...(projected.listenerId ? {listenerId: projected.listenerId} : {}),
6521
- })
6522
- }
6523
- continue
6524
- }
6525
6617
  this.sendToMiniapp(packageName, {
6526
6618
  type: MiniappResponseType.EVENT,
6527
6619
  streamType: normalizedStream,
6528
6620
  data: outboundData,
6529
- ...(transcriptionRoute ? {transcriptionRoute} : {}),
6530
6621
  })
6531
6622
  }
6532
6623
 
6533
- if (localFinal) this.noteLocalFinalDelivered()
6534
-
6535
6624
  // Send a separate envelope to per-gesture subscribers tagged with the
6536
6625
  // gesture-specific streamType so SDK-side dispatch routes correctly.
6537
6626
  if (perGestureStream) {
@@ -6548,36 +6637,59 @@ class LocalMiniappRuntime {
6548
6637
  }
6549
6638
  }
6550
6639
 
6551
- private appTranscriptionRoutesForSubscription(
6552
- packageName: string,
6640
+ /**
6641
+ * Sends one transcript to each listener that hears its stream and currently
6642
+ * gets its text from its source, tagged with that source. Then records where
6643
+ * the source is in its utterance: a final can complete a listener's switch
6644
+ * that waited for it.
6645
+ */
6646
+ private forwardTranscription(
6553
6647
  stream: string,
6554
- ): {
6555
- cloud: boolean
6556
- forceLocal: boolean
6557
- } {
6558
- const app = this.connectedApps.get(packageName)
6559
- return {
6560
- cloud: app?.cloudTranscriptionStreams.has(stream) === true,
6561
- forceLocal: app?.forceLocalTranscriptionStreams.has(stream) === true,
6648
+ data: unknown,
6649
+ source: TranscriptionEventSource,
6650
+ tokens: TranscriptionData["tokens"] | undefined,
6651
+ packageNames: Set<string>,
6652
+ ): void {
6653
+ const transcript = (data ?? {}) as {text?: string; isFinal?: boolean; __hostReceivedAt?: number}
6654
+ const isFinal = transcript.isFinal === true
6655
+ // A switch that waited only on an utterance that went stale happens before this transcript.
6656
+ let moved = this.refreshTranscriptionRouting()
6657
+ const audioPosition = cloudClientService.getAudioPosition()
6658
+ for (const packageName of packageNames) {
6659
+ const app = this.connectedApps.get(packageName)
6660
+ if (!app) continue
6661
+ const deliveries = app.transcriptionListeners.deliveries(
6662
+ stream,
6663
+ source,
6664
+ transcript.text ?? "",
6665
+ tokens,
6666
+ audioPosition,
6667
+ )
6668
+ if (TRANSCRIPT_TIMING_TELEMETRY && deliveries.length > 0) {
6669
+ const now = Date.now()
6670
+ console.log(
6671
+ `${LOG_TAG}: transcript fanout t=${now} app=${packageName} source=${source} final=${isFinal} hostDelta=${
6672
+ transcript.__hostReceivedAt ? now - transcript.__hostReceivedAt : -1
6673
+ }ms text="${(transcript.text ?? "").slice(0, 48)}"`,
6674
+ )
6675
+ }
6676
+ for (const delivery of deliveries) {
6677
+ this.sendToMiniapp(packageName, {
6678
+ type: MiniappResponseType.EVENT,
6679
+ streamType: stream,
6680
+ data: {...transcript, text: delivery.text, source},
6681
+ transcriptionRoute: delivery.route,
6682
+ ...(delivery.listenerId ? {listenerId: delivery.listenerId} : {}),
6683
+ })
6684
+ }
6562
6685
  }
6563
- }
6564
-
6565
- private appTranscriptionRoutesForEvent(
6566
- packageName: string,
6567
- stream: string,
6568
- ): {
6569
- cloud: boolean
6570
- forceLocal: boolean
6571
- } {
6572
- const app = this.connectedApps.get(packageName)
6573
- const autoStream = `${MiniappStreamType.TRANSCRIPTION}:auto`
6574
- return {
6575
- cloud:
6576
- app?.cloudTranscriptionStreams.has(stream) === true || app?.cloudTranscriptionStreams.has(autoStream) === true,
6577
- forceLocal:
6578
- app?.forceLocalTranscriptionStreams.has(stream) === true ||
6579
- app?.forceLocalTranscriptionStreams.has(autoStream) === true,
6686
+ if (source === "local" && isFinal) {
6687
+ this.localFinalWatermark = audioPosition ?? this.localFinalWatermark
6688
+ this.noteLocalFinalDelivered()
6580
6689
  }
6690
+ this.utterances.note(source, stream, isFinal, Date.now())
6691
+ if (this.refreshTranscriptionRouting()) moved = true
6692
+ if (moved) this.updateCloudSubscriptions()
6581
6693
  }
6582
6694
 
6583
6695
  /**