@mentra/engine 3.4.0-dev.1182 → 3.4.0-dev.1184

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 (31) 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/build/stores/settings.d.ts.map +1 -1
  22. package/build/stores/settings.js +3 -17
  23. package/build/stores/settings.js.map +1 -1
  24. package/package.json +8 -8
  25. package/src/generated/releaseMetadata.ts +5 -5
  26. package/src/services/DeviceEventRouter.ts +2 -2
  27. package/src/services/LocalMiniappRuntime.ts +325 -213
  28. package/src/services/LocalSttFallbackCoordinator.ts +15 -62
  29. package/src/services/TranscriptionRouting.ts +200 -30
  30. package/src/services/TranscriptionSubscriptions.ts +245 -25
  31. package/src/stores/settings.ts +3 -17
@@ -44,8 +44,8 @@ import { phonePhotoCoordinator } from "./PhonePhotoCoordinator";
44
44
  import { phoneStreamCoordinator } from "./PhoneStreamCoordinator";
45
45
  import { phoneVideoCoordinator } from "./PhoneVideoCoordinator";
46
46
  import { runSentenceTtsPipeline } from "./SentenceTtsPipeline";
47
- import { summarizeTranscriptionRoutes, transcriptionDeliveryRoute } from "./TranscriptionRouting";
48
- import { TranscriptionSubscriptions } from "./TranscriptionSubscriptions";
47
+ import { needsCloud, needsLocal, onDeviceServes, readSpeechProcessing, targetSource, UtteranceTracker, } from "./TranscriptionRouting";
48
+ import { addStreamProcessing, transcriptionRegistrations, TranscriptionSubscriptions, } from "./TranscriptionSubscriptions";
49
49
  import { prepareTtsSentences } from "./TtsTextSanitizer";
50
50
  import { handleWifiAdbRequest } from "./WifiAdbRequest";
51
51
  import { cloudClientService } from "./CloudClientService";
@@ -101,6 +101,19 @@ function locationRateRank(rate) {
101
101
  const i = LOCATION_RATE_PRIORITY.indexOf(rate);
102
102
  return i >= 0 ? i : LOCATION_RATE_PRIORITY.indexOf("passive");
103
103
  }
104
+ /** Why this deployment refuses a transcription listener's processing, or null. */
105
+ function speechProcessingDisabled(processing) {
106
+ const cloud = isFeatureEnabled("cloudSpeech");
107
+ const onDevice = isFeatureEnabled("onDeviceSpeech");
108
+ if (processing === "localOnly" && !onDevice)
109
+ return "On-device speech is disabled by this deployment";
110
+ if (processing === "preferCloud" && !cloud)
111
+ return "Cloud speech is disabled by this deployment";
112
+ if (processing === "preferLocal" && !cloud && !onDevice) {
113
+ return "Cloud and on-device speech are disabled by this deployment";
114
+ }
115
+ return null;
116
+ }
104
117
  const LOG_TAG = "LOCAL_MINIAPP";
105
118
  /**
106
119
  * Cloud speech arrives as raw 24 kHz 16-bit mono PCM and streams into the
@@ -732,8 +745,7 @@ class LocalMiniappRuntime {
732
745
  }
733
746
  this.connectedApps.set(packageName, {
734
747
  subscriptions: new Set(),
735
- forceLocalTranscriptionStreams: new Set(),
736
- cloudTranscriptionStreams: new Set(),
748
+ transcriptionListeners: new TranscriptionSubscriptions(),
737
749
  sendMessage: sendFn,
738
750
  lastPongAt: Date.now(),
739
751
  unansweredPingRounds: 0,
@@ -1332,7 +1344,9 @@ class LocalMiniappRuntime {
1332
1344
  break;
1333
1345
  case MiniappRequestType.TRANSCRIPTION_FINISH: {
1334
1346
  const app = this.connectedApps.get(packageName);
1335
- if (!app?.forceLocalTranscriptionStreams.size ||
1347
+ if (!app?.transcriptionListeners
1348
+ .routes()
1349
+ .some((route) => route.processing === "localOnly" || route.source === "local") ||
1336
1350
  !app.installedManifest?.permissions?.some((p) => p.type === "MICROPHONE")) {
1337
1351
  this.sendResult(packageName, requestId, false, undefined, {
1338
1352
  code: MiniappErrorCode.PERMISSION_DENIED,
@@ -1666,8 +1680,9 @@ class LocalMiniappRuntime {
1666
1680
  // The strictest rate wins when multiple `location_stream` entries appear
1667
1681
  // in one SUBSCRIBE — the same rule we apply across apps.
1668
1682
  let requestedLocationRate = null;
1669
- const forceLocalTranscriptionStreams = new Set();
1670
- const cloudTranscriptionStreams = new Set();
1683
+ // What each transcription stream's entries ask for: `{stream, processing}`,
1684
+ // or `{stream, forceLocal, includeCloud}` from SDKs before `processing`.
1685
+ const streamProcessing = new Map();
1671
1686
  const streams = rawStreams?.map((s) => {
1672
1687
  if (typeof s === "object" && s !== null) {
1673
1688
  if (s.stream === "location_stream") {
@@ -1676,16 +1691,12 @@ class LocalMiniappRuntime {
1676
1691
  }
1677
1692
  return "location_update";
1678
1693
  }
1679
- if (s.forceLocal === true && s.stream.startsWith("transcription:")) {
1680
- forceLocalTranscriptionStreams.add(s.stream);
1681
- }
1682
- if (s.stream.startsWith("transcription:") && (s.forceLocal !== true || s.includeCloud === true)) {
1683
- cloudTranscriptionStreams.add(s.stream);
1684
- }
1694
+ if (s.stream.startsWith("transcription:"))
1695
+ addStreamProcessing(streamProcessing, s);
1685
1696
  return s.stream;
1686
1697
  }
1687
1698
  if (s.startsWith("transcription:"))
1688
- cloudTranscriptionStreams.add(s);
1699
+ addStreamProcessing(streamProcessing, { stream: s });
1689
1700
  return s;
1690
1701
  });
1691
1702
  if (!Array.isArray(streams)) {
@@ -1695,8 +1706,8 @@ class LocalMiniappRuntime {
1695
1706
  });
1696
1707
  return;
1697
1708
  }
1698
- for (const subscription of rawStreams ?? []) {
1699
- const stream = typeof subscription === "string" ? subscription : subscription.stream;
1709
+ const transcriptionListeners = transcriptionRegistrations(payload.transcriptionListeners, streams, streamProcessing);
1710
+ for (const stream of streams) {
1700
1711
  if (stream.startsWith("translation:") && !isFeatureEnabled("cloudSpeech")) {
1701
1712
  this.sendResult(packageName, requestId, false, undefined, {
1702
1713
  code: MiniappErrorCode.NOT_IMPLEMENTED,
@@ -1711,21 +1722,13 @@ class LocalMiniappRuntime {
1711
1722
  });
1712
1723
  return;
1713
1724
  }
1714
- if (!stream.startsWith("transcription:"))
1715
- continue;
1716
- const forceLocal = typeof subscription === "object" && subscription.forceLocal === true;
1717
- const includeCloud = typeof subscription === "object" && subscription.includeCloud === true;
1718
- if (forceLocal && !isFeatureEnabled("onDeviceSpeech")) {
1719
- this.sendResult(packageName, requestId, false, undefined, {
1720
- code: MiniappErrorCode.NOT_IMPLEMENTED,
1721
- message: "On-device speech is disabled by this deployment",
1722
- });
1723
- return;
1724
- }
1725
- if ((!forceLocal || includeCloud) && !isFeatureEnabled("cloudSpeech")) {
1725
+ }
1726
+ for (const { processing } of transcriptionListeners) {
1727
+ const disabled = speechProcessingDisabled(processing);
1728
+ if (disabled) {
1726
1729
  this.sendResult(packageName, requestId, false, undefined, {
1727
1730
  code: MiniappErrorCode.NOT_IMPLEMENTED,
1728
- message: "Cloud speech is disabled by this deployment",
1731
+ message: disabled,
1729
1732
  });
1730
1733
  return;
1731
1734
  }
@@ -1769,35 +1772,8 @@ class LocalMiniappRuntime {
1769
1772
  // A miniapp that subscribes to the model status again gets it again.
1770
1773
  if (!app.subscriptions.has(MiniappStreamType.TRANSCRIPTION_MODEL_STATUS))
1771
1774
  app.lastTranscriptionModelStatus = undefined;
1772
- app.forceLocalTranscriptionStreams = new Set([...forceLocalTranscriptionStreams].filter((stream) => app.subscriptions.has(stream)));
1773
- app.cloudTranscriptionStreams = new Set([...cloudTranscriptionStreams].filter((stream) => app.subscriptions.has(stream)));
1774
1775
  this.replaceStreamSubscribers(packageName, previousSubscriptions, app.subscriptions);
1775
- if (Array.isArray(payload.transcriptionListeners)) {
1776
- const registrations = payload.transcriptionListeners
1777
- .filter((value) => {
1778
- if (typeof value !== "object" || value === null)
1779
- return false;
1780
- const r = value;
1781
- return (typeof r.id === "string" &&
1782
- typeof r.stream === "string" &&
1783
- r.stream.startsWith("transcription:") &&
1784
- app.subscriptions.has(r.stream) &&
1785
- (r.forceLocal === undefined || typeof r.forceLocal === "boolean"));
1786
- })
1787
- .map(({ id, stream, forceLocal }) => ({ id, stream, forceLocal }));
1788
- app.transcriptionListeners ??= new TranscriptionSubscriptions();
1789
- app.transcriptionListeners.replace(registrations, cloudClientService.getAudioPosition());
1790
- }
1791
- else {
1792
- // Older bundles expose one registration per stream. The runtime can still
1793
- // trim app joins/resubscriptions without sending them new payload fields.
1794
- app.transcriptionListeners ??= new TranscriptionSubscriptions();
1795
- app.transcriptionListeners.replace([...app.cloudTranscriptionStreams].map((stream) => ({
1796
- id: `legacy:${stream}`,
1797
- stream,
1798
- legacy: true,
1799
- })), cloudClientService.getAudioPosition());
1800
- }
1776
+ app.transcriptionListeners.replace(transcriptionListeners, cloudClientService.getAudioPosition());
1801
1777
  this.recomputeMicRequirements();
1802
1778
  this.updateCloudSubscriptions();
1803
1779
  this.recomputeHeadingSubscription();
@@ -2370,9 +2346,10 @@ class LocalMiniappRuntime {
2370
2346
  const voiceExplicit = payload.voice_id !== undefined || payload.voice !== undefined;
2371
2347
  const offlineSupportsVoice = !voiceExplicit || voice === "default" || ttsModelManager.getAvailableLanguages().some((l) => l.code === voice);
2372
2348
  const modelId = typeof payload.model_id === "string" ? payload.model_id : undefined;
2373
- const forceLocal = payload.forceLocal === true;
2349
+ // `processing` wins; SDKs before it send only `forceLocal`.
2350
+ const processing = readSpeechProcessing(payload);
2374
2351
  const cloudConnected = cloudClientService.isConnected();
2375
- console.log(`${LOG_TAG}: TTS decision for ${packageName}: cloudConnected=${cloudConnected}, runtimeTts=yes, offlineVoice=${offlineSupportsVoice}, forceLocal=${forceLocal}`);
2352
+ console.log(`${LOG_TAG}: TTS decision for ${packageName}: cloudConnected=${cloudConnected}, runtimeTts=yes, offlineVoice=${offlineSupportsVoice}, processing=${processing}`);
2376
2353
  const playOfflineTts = async (reason) => {
2377
2354
  if (run.cancelled)
2378
2355
  return true;
@@ -2532,26 +2509,37 @@ class LocalMiniappRuntime {
2532
2509
  });
2533
2510
  return true;
2534
2511
  };
2535
- if (forceLocal) {
2512
+ if (processing === "localOnly") {
2536
2513
  if (await playOfflineTts()) {
2537
2514
  return;
2538
2515
  }
2539
2516
  if (run.cancelled)
2540
2517
  return;
2541
- const message = "tts unavailable: forceLocal requested but offline TTS model is not available";
2518
+ const message = "tts unavailable: localOnly requested but offline TTS model is not available";
2542
2519
  sendPlaybackResult(false, message, null, message, false, MiniappErrorCode.TTS_LOCAL_UNAVAILABLE);
2543
2520
  return;
2544
2521
  }
2545
- if (cloudConnected && (await playCloudTts(true))) {
2546
- return;
2547
- }
2548
- // Offline is the disconnected-cloud fallback. If the model is not
2549
- // available, preserve the old behavior by trying cloud as a last resort.
2550
- if (await playOfflineTts(cloudConnected ? undefined : "cloud disconnected")) {
2551
- return;
2522
+ if (processing === "preferLocal") {
2523
+ // On-device first; the cloud when the offline model is missing or can't say it.
2524
+ if (await playOfflineTts()) {
2525
+ return;
2526
+ }
2527
+ if (await playCloudTts(false)) {
2528
+ return;
2529
+ }
2552
2530
  }
2553
- if (await playCloudTts(false)) {
2554
- return;
2531
+ else {
2532
+ if (cloudConnected && (await playCloudTts(true))) {
2533
+ return;
2534
+ }
2535
+ // Offline is the disconnected-cloud fallback. If the model is not
2536
+ // available, preserve the old behavior by trying cloud as a last resort.
2537
+ if (await playOfflineTts(cloudConnected ? undefined : "cloud disconnected")) {
2538
+ return;
2539
+ }
2540
+ if (await playCloudTts(false)) {
2541
+ return;
2542
+ }
2555
2543
  }
2556
2544
  const message = "tts unavailable: no cloud TTS URL or offline TTS model";
2557
2545
  sendPlaybackResult(false, message, null, message, false);
@@ -5190,14 +5178,7 @@ class LocalMiniappRuntime {
5190
5178
  // Add new subscriptions
5191
5179
  const previousSubscriptions = app.subscriptions;
5192
5180
  app.subscriptions = new Set(streams);
5193
- app.forceLocalTranscriptionStreams.clear();
5194
- app.cloudTranscriptionStreams = new Set(streams.filter((stream) => stream.startsWith("transcription:")));
5195
- app.transcriptionListeners ??= new TranscriptionSubscriptions();
5196
- app.transcriptionListeners.replace([...app.cloudTranscriptionStreams].map((stream) => ({
5197
- id: `legacy:${stream}`,
5198
- stream,
5199
- legacy: true,
5200
- })), cloudClientService.getAudioPosition());
5181
+ app.transcriptionListeners.replace(transcriptionRegistrations(undefined, streams, new Map()), cloudClientService.getAudioPosition());
5201
5182
  this.replaceStreamSubscribers(packageName, previousSubscriptions, app.subscriptions);
5202
5183
  this.recomputeMicRequirements();
5203
5184
  this.updateCloudSubscriptions();
@@ -5243,9 +5224,27 @@ class LocalMiniappRuntime {
5243
5224
  * are NOT sent — they come from the Bluetooth SDK, not from cloud.
5244
5225
  */
5245
5226
  updateCloudSubscriptions() {
5227
+ // The on-device recognizer reports its state back synchronously from
5228
+ // setDemand; that report asks for another pass instead of nesting one.
5229
+ if (this.updatingCloudSubscriptions) {
5230
+ this.cloudSubscriptionsDirty = true;
5231
+ return;
5232
+ }
5233
+ this.updatingCloudSubscriptions = true;
5234
+ try {
5235
+ do {
5236
+ this.cloudSubscriptionsDirty = false;
5237
+ this.applyCloudSubscriptions();
5238
+ } while (this.cloudSubscriptionsDirty);
5239
+ }
5240
+ finally {
5241
+ this.updatingCloudSubscriptions = false;
5242
+ }
5243
+ }
5244
+ updatingCloudSubscriptions = false;
5245
+ cloudSubscriptionsDirty = false;
5246
+ applyCloudSubscriptions() {
5246
5247
  const cloudStreams = new Set();
5247
- let hasTranscription = false;
5248
- let hasForceLocalTranscription = false;
5249
5248
  for (const [stream, subscribers] of this.streamSubscribers) {
5250
5249
  if (subscribers.size === 0)
5251
5250
  continue;
@@ -5254,40 +5253,174 @@ class LocalMiniappRuntime {
5254
5253
  if (stream.startsWith("translation:")) {
5255
5254
  cloudStreams.add(stream);
5256
5255
  }
5257
- if (stream.startsWith("transcription:")) {
5258
- hasTranscription = true;
5259
- const routes = summarizeTranscriptionRoutes(subscribers, (packageName) => this.appTranscriptionRoutesForSubscription(packageName, stream));
5260
- if (routes.hasForceLocalSubscriber)
5261
- hasForceLocalTranscription = true;
5262
- if (routes.hasCloudSubscriber)
5263
- cloudStreams.add(stream);
5256
+ }
5257
+ // A transcription stream goes to the cloud only while a listener on it
5258
+ // needs the cloud; on-device transcription runs only while one needs it.
5259
+ this.refreshTranscriptionRouting();
5260
+ let localDemand = false;
5261
+ for (const [packageName, app] of this.connectedApps) {
5262
+ const state = this.transcriptionRoutingState(packageName);
5263
+ for (const route of app.transcriptionListeners.routes()) {
5264
+ if (needsCloud(route, route.source, route.target))
5265
+ cloudStreams.add(route.stream);
5266
+ if (needsLocal(route, route.source, state))
5267
+ localDemand = true;
5264
5268
  }
5265
5269
  }
5266
- // On-device transcription always runs the Speech-page model; its language,
5267
- // not the subscription's, decides what local transcripts are tagged with.
5268
- localSttFallbackCoordinator.onSubscriptionChange(hasTranscription, hasForceLocalTranscription);
5269
- this.broadcastTranscriptionModelStatus();
5270
5270
  // Mirror the same set as typed AudioSubscription[] and push it to the cloud
5271
5271
  // runtime.
5272
5272
  this.ensureCloudResultsWired();
5273
5273
  const subs = this.buildCloudAudioSubscriptions(cloudStreams);
5274
5274
  const nextKey = this.audioSubscriptionKey(subs);
5275
- if (!this.cloudAudioSubscriptionSync.begin(nextKey))
5275
+ if (this.cloudAudioSubscriptionSync.begin(nextKey)) {
5276
+ console.log(`${LOG_TAG}: updateCloudSubscriptions streams=[${Array.from(cloudStreams).join(", ")}] cloudSubs=${subs.length}`);
5277
+ cloudClientService
5278
+ .setSubscriptions(subs)
5279
+ .then(() => {
5280
+ this.cloudAudioSubscriptionSync.succeeded(nextKey);
5281
+ })
5282
+ .catch((err) => {
5283
+ // Best-effort: the cloud may not be connected yet. Keep failed writes
5284
+ // retryable; otherwise a reconnect that recomputes the same desired
5285
+ // transcription set gets deduped and local captions never recover.
5286
+ this.cloudAudioSubscriptionSync.failed(nextKey);
5287
+ console.warn(`${LOG_TAG}: cloud setSubscriptions failed: ${err?.message ?? err}`);
5288
+ });
5289
+ }
5290
+ // On-device transcription always runs the Speech-page model; its language,
5291
+ // not the subscription's, decides what local transcripts are tagged with.
5292
+ localSttFallbackCoordinator.setDemand(localDemand);
5293
+ this.broadcastTranscriptionModelStatus();
5294
+ }
5295
+ // ===========================================================================
5296
+ // Transcription routing (cloud or on-device, per listener)
5297
+ // ===========================================================================
5298
+ /** When each transcript source last spoke, to switch listeners only where both have settled. */
5299
+ utterances = new UtteranceTracker();
5300
+ /** Which sources were up at the last routing pass, to notice one starting. */
5301
+ sourcesUp = { cloud: false, local: false };
5302
+ /** Cloud audio position at the last on-device final. */
5303
+ localFinalWatermark = null;
5304
+ /** Bumped whenever the on-device recognizer starts or stops: a drain holds only for the run it drained. */
5305
+ localGeneration = 0;
5306
+ /** A drain that just completed, for the routing pass it triggers only; and whether one is running. */
5307
+ localDrain = null;
5308
+ localDrainRunning = false;
5309
+ /** Re-routes once a waiting switch's sources settle, since silence brings no transcript to do it. */
5310
+ handoffTimer = null;
5311
+ /** Cloud transcription is allowed by this deployment and connected. */
5312
+ cloudSpeechUp() {
5313
+ return isFeatureEnabled("cloudSpeech") && cloudClientService.isConnected();
5314
+ }
5315
+ /** What one miniapp's listeners can be transcribed with right now. */
5316
+ transcriptionRoutingState(packageName) {
5317
+ return {
5318
+ cloudConnected: this.cloudSpeechUp(),
5319
+ onDevice: isFeatureEnabled("onDeviceSpeech"),
5320
+ localLanguage: localSttFallbackCoordinator.getActiveLanguage(),
5321
+ pickedLanguage: sttModelManager.getBcp47ForLanguage(),
5322
+ hints: this.transcriptionHintsByApp.get(packageName) ?? [],
5323
+ };
5324
+ }
5325
+ /**
5326
+ * Moves every transcription listener toward the source it should use now
5327
+ * (see TranscriptionRouting: a planned switch waits for a settled pause and
5328
+ * a drain of the on-device recognizer). Returns true when any listener's
5329
+ * source or target changed, which can change what must run.
5330
+ */
5331
+ refreshTranscriptionRouting() {
5332
+ const cloudConnected = this.cloudSpeechUp();
5333
+ const localActive = localSttFallbackCoordinator.isActive();
5334
+ const now = Date.now();
5335
+ // A source that stopped reports nothing more; one that started may be
5336
+ // working on speech it has not reported yet.
5337
+ for (const [source, up] of [
5338
+ ["cloud", cloudConnected],
5339
+ ["local", localActive],
5340
+ ]) {
5341
+ if (up && !this.sourcesUp[source])
5342
+ this.utterances.started(source, now);
5343
+ if (!up)
5344
+ this.utterances.clear(source);
5345
+ if (source === "local" && up !== this.sourcesUp.local)
5346
+ this.localGeneration++;
5347
+ this.sourcesUp[source] = up;
5348
+ }
5349
+ const drained = this.localDrain !== null && this.localDrain.generation === this.localGeneration;
5350
+ let changed = false;
5351
+ let needsDrain = false;
5352
+ for (const [packageName, app] of this.connectedApps) {
5353
+ const state = this.transcriptionRoutingState(packageName);
5354
+ const routed = app.transcriptionListeners.route({
5355
+ target: (listener) => targetSource(listener, state),
5356
+ canDeliver: (source) => (source === "cloud" ? cloudConnected : localActive),
5357
+ busy: (source, stream) => this.utterances.busy(source, stream, now),
5358
+ drained,
5359
+ plannedWatermark: this.localDrain?.watermark ?? null,
5360
+ forcedWatermark: this.localFinalWatermark,
5361
+ });
5362
+ if (routed.changed)
5363
+ changed = true;
5364
+ if (routed.needsDrain)
5365
+ needsDrain = true;
5366
+ }
5367
+ if (needsDrain)
5368
+ this.drainLocalRecognizer();
5369
+ this.scheduleHandoff(now);
5370
+ return changed;
5371
+ }
5372
+ /**
5373
+ * Has the on-device recognizer report everything it has heard and start
5374
+ * over (finishTranscription): the boundary of a planned switch. What it
5375
+ * reports goes to the listeners still on-device. The drain then serves the
5376
+ * switches ready in the routing pass it triggers, and only those: the
5377
+ * recognizer keeps hearing audio, so any later switch drains again. A model
5378
+ * that cannot finish early reports nothing, and the quiet window is the
5379
+ * boundary.
5380
+ */
5381
+ drainLocalRecognizer() {
5382
+ if (this.localDrainRunning)
5276
5383
  return;
5277
- console.log(`${LOG_TAG}: updateCloudSubscriptions streams=[${Array.from(cloudStreams).join(", ")}] cloudSubs=${subs.length}`);
5278
- cloudClientService
5279
- .setSubscriptions(subs)
5384
+ this.localDrainRunning = true;
5385
+ const generation = this.localGeneration;
5386
+ const watermark = cloudClientService.getAudioPosition();
5387
+ void this.finishLocalUtterance()
5388
+ .catch((error) => console.warn(`${LOG_TAG}: draining on-device transcription failed`, error))
5280
5389
  .then(() => {
5281
- this.cloudAudioSubscriptionSync.succeeded(nextKey);
5282
- })
5283
- .catch((err) => {
5284
- // Best-effort: the cloud may not be connected yet. Keep failed writes
5285
- // retryable; otherwise a reconnect that recomputes the same desired
5286
- // transcription set gets deduped and local captions never recover.
5287
- this.cloudAudioSubscriptionSync.failed(nextKey);
5288
- console.warn(`${LOG_TAG}: cloud setSubscriptions failed: ${err?.message ?? err}`);
5390
+ this.localDrainRunning = false;
5391
+ // Everything the recognizer had is reported; its next words are new speech.
5392
+ this.utterances.clear("local");
5393
+ this.localDrain = { generation, watermark };
5394
+ const changed = this.refreshTranscriptionRouting();
5395
+ this.localDrain = null;
5396
+ if (changed)
5397
+ this.updateCloudSubscriptions();
5289
5398
  });
5290
5399
  }
5400
+ /**
5401
+ * Arms a timer for the moment the next busy source settles while any
5402
+ * listener waits to switch, so the switch (and dropping what it no longer
5403
+ * needs, such as a cloud stream) happens during silence too.
5404
+ */
5405
+ scheduleHandoff(now) {
5406
+ const waiting = [...this.connectedApps.values()].some((app) => app.transcriptionListeners.switching());
5407
+ const at = waiting ? this.utterances.nextSettle(now) : null;
5408
+ if (this.handoffTimer && at !== null && this.handoffTimer.at <= at)
5409
+ return;
5410
+ if (this.handoffTimer)
5411
+ BgTimer.clearTimeout(this.handoffTimer.id);
5412
+ this.handoffTimer = null;
5413
+ if (at === null)
5414
+ return;
5415
+ const id = BgTimer.setTimeout(() => {
5416
+ if (this.handoffTimer?.id !== id)
5417
+ return;
5418
+ this.handoffTimer = null;
5419
+ if (this.refreshTranscriptionRouting())
5420
+ this.updateCloudSubscriptions();
5421
+ }, at - now);
5422
+ this.handoffTimer = { id, at };
5423
+ }
5291
5424
  /**
5292
5425
  * Build the cloud `AudioSubscription[]` from the miniapp cloud-stream key set.
5293
5426
  *
@@ -5420,7 +5553,8 @@ class LocalMiniappRuntime {
5420
5553
  this.broadcastCloudStatus();
5421
5554
  });
5422
5555
  sttModelManager.onSelectionChanged(() => this.updateCloudSubscriptions());
5423
- localSttFallbackCoordinator.onModelStatusChanged(() => this.broadcastTranscriptionModelStatus());
5556
+ // On-device transcription starting or stopping moves preferLocal and fallback listeners.
5557
+ localSttFallbackCoordinator.onModelStatusChanged(() => this.updateCloudSubscriptions());
5424
5558
  useSettingsStore.subscribe((s) => s.getSetting(ISLAND_SETTINGS_KEYS.localSttFallbackActive), () => {
5425
5559
  this.broadcastCloudStatus();
5426
5560
  });
@@ -5429,9 +5563,11 @@ class LocalMiniappRuntime {
5429
5563
  // On-device transcription model status (session.transcription.onModelStatus)
5430
5564
  // ===========================================================================
5431
5565
  /**
5432
- * The on-device model state as one miniapp sees it. The phone runs one model,
5433
- * the one picked on the Speech page, so a miniapp whose on-device
5434
- * subscriptions all ask for another language is told it's unavailable.
5566
+ * The on-device model state as one miniapp sees it. Its on-device listeners
5567
+ * are the localOnly and preferLocal ones, and every listener while the cloud
5568
+ * is down. The phone runs one model, the one picked on the Speech page, so a
5569
+ * miniapp whose on-device listeners all ask for another language is told
5570
+ * it's unavailable.
5435
5571
  */
5436
5572
  transcriptionModelStatusFor(packageName) {
5437
5573
  const status = localSttFallbackCoordinator.getModelStatus();
@@ -5439,17 +5575,14 @@ class LocalMiniappRuntime {
5439
5575
  if (!isFeatureEnabled("onDeviceSpeech")) {
5440
5576
  return { language, state: "unavailable", reason: "On-device speech is disabled by this deployment" };
5441
5577
  }
5442
- const app = this.connectedApps.get(packageName);
5443
- const cloudConnected = cloudClientService.isConnected();
5444
- const onDeviceLanguages = [...(app?.subscriptions ?? [])]
5445
- .filter((stream) => stream.startsWith(`${MiniappStreamType.TRANSCRIPTION}:`) &&
5446
- (app?.forceLocalTranscriptionStreams.has(stream) === true || !cloudConnected))
5447
- .map((stream) => stream.slice(MiniappStreamType.TRANSCRIPTION.length + 1));
5448
- if (onDeviceLanguages.length === 0) {
5578
+ const cloudConnected = this.cloudSpeechUp();
5579
+ const onDeviceListeners = (this.connectedApps.get(packageName)?.transcriptionListeners.routes() ?? []).filter((listener) => listener.processing !== "preferCloud" || !cloudConnected);
5580
+ if (onDeviceListeners.length === 0) {
5449
5581
  return { language, state: "unavailable", reason: "No on-device transcription subscription" };
5450
5582
  }
5451
5583
  // "auto" follows the picked model; any other language must be the picked one.
5452
- if (!onDeviceLanguages.some((lang) => lang === "auto" || lang.toLowerCase() === language.toLowerCase())) {
5584
+ const hints = this.transcriptionHintsByApp.get(packageName) ?? [];
5585
+ if (!onDeviceListeners.some((listener) => onDeviceServes(listener, language, hints))) {
5453
5586
  return {
5454
5587
  language,
5455
5588
  state: "unavailable",
@@ -5616,6 +5749,10 @@ class LocalMiniappRuntime {
5616
5749
  matchedSubs.add(p);
5617
5750
  }
5618
5751
  }
5752
+ if (normalizedStream.startsWith("transcription:") && transcriptionSource) {
5753
+ this.forwardTranscription(normalizedStream, data, transcriptionSource, tokens, matchedSubs);
5754
+ return;
5755
+ }
5619
5756
  // Touch event per-gesture fan-out. SDK-side dispatch is keyed on the
5620
5757
  // exact streamType, so per-gesture subscribers (`touch_event:single_tap`,
5621
5758
  // etc.) need their own EVENT envelope with the gesture-tagged streamType.
@@ -5640,32 +5777,9 @@ class LocalMiniappRuntime {
5640
5777
  perGestureStream = `${MiniappStreamType.TOUCH_EVENT}:${gesture}`;
5641
5778
  }
5642
5779
  }
5643
- if (normalizedStream.startsWith("transcription:")) {
5644
- // const known = Array.from(this.streamSubscribers.keys())
5645
- // console.log(
5646
- // `${LOG_TAG}: forwardEvent(${streamType} → ${normalizedStream}) matched=${matchedSubs.size} known=[${known.join(", ")}]`,
5647
- // )
5648
- }
5649
- const localFinal = transcriptionSource === "local" && data?.isFinal === true;
5650
- if (matchedSubs.size === 0 && !perGestureStream) {
5651
- if (localFinal)
5652
- this.noteLocalFinalDelivered();
5780
+ if (matchedSubs.size === 0 && !perGestureStream)
5653
5781
  return;
5654
- }
5655
5782
  for (const packageName of matchedSubs) {
5656
- let transcriptionRoute;
5657
- if (normalizedStream.startsWith("transcription:") && transcriptionSource) {
5658
- const routes = this.appTranscriptionRoutesForEvent(packageName, normalizedStream);
5659
- transcriptionRoute =
5660
- transcriptionDeliveryRoute(transcriptionSource, routes, cloudClientService.isConnected()) ?? undefined;
5661
- if (!transcriptionRoute)
5662
- continue;
5663
- }
5664
- if (TRANSCRIPT_TIMING_TELEMETRY && normalizedStream.startsWith("transcription:")) {
5665
- const transcript = data;
5666
- const now = Date.now();
5667
- console.log(`${LOG_TAG}: transcript fanout t=${now} app=${packageName} final=${!!transcript?.isFinal} hostDelta=${transcript?.__hostReceivedAt ? now - transcript.__hostReceivedAt : -1}ms text="${(transcript?.text ?? "").slice(0, 48)}"`);
5668
- }
5669
5783
  // While a nav trip is active the Nav SDK's road-snapped fixes are
5670
5784
  // already being forwarded via addLocationListener (see NAV_START handler).
5671
5785
  // Suppress the raw background-GPS forward for that miniapp so the two
@@ -5674,29 +5788,12 @@ class LocalMiniappRuntime {
5674
5788
  if (normalizedStream === MiniappStreamType.LOCATION_UPDATE && this.navigationHandlers.isTripActive(packageName)) {
5675
5789
  continue;
5676
5790
  }
5677
- const listenerRegistry = this.connectedApps.get(packageName)?.transcriptionListeners;
5678
- const audioPosition = cloudClientService.getAudioPosition();
5679
- if (listenerRegistry && transcriptionSource === "cloud" && tokens !== undefined) {
5680
- for (const projected of listenerRegistry.project(normalizedStream, tokens ?? [], audioPosition, transcriptionRoute ?? "default")) {
5681
- this.sendToMiniapp(packageName, {
5682
- type: MiniappResponseType.EVENT,
5683
- streamType: normalizedStream,
5684
- data: { ...outboundData, text: projected.text },
5685
- transcriptionRoute,
5686
- ...(projected.listenerId ? { listenerId: projected.listenerId } : {}),
5687
- });
5688
- }
5689
- continue;
5690
- }
5691
5791
  this.sendToMiniapp(packageName, {
5692
5792
  type: MiniappResponseType.EVENT,
5693
5793
  streamType: normalizedStream,
5694
5794
  data: outboundData,
5695
- ...(transcriptionRoute ? { transcriptionRoute } : {}),
5696
5795
  });
5697
5796
  }
5698
- if (localFinal)
5699
- this.noteLocalFinalDelivered();
5700
5797
  // Send a separate envelope to per-gesture subscribers tagged with the
5701
5798
  // gesture-specific streamType so SDK-side dispatch routes correctly.
5702
5799
  if (perGestureStream) {
@@ -5712,21 +5809,46 @@ class LocalMiniappRuntime {
5712
5809
  }
5713
5810
  }
5714
5811
  }
5715
- appTranscriptionRoutesForSubscription(packageName, stream) {
5716
- const app = this.connectedApps.get(packageName);
5717
- return {
5718
- cloud: app?.cloudTranscriptionStreams.has(stream) === true,
5719
- forceLocal: app?.forceLocalTranscriptionStreams.has(stream) === true,
5720
- };
5721
- }
5722
- appTranscriptionRoutesForEvent(packageName, stream) {
5723
- const app = this.connectedApps.get(packageName);
5724
- const autoStream = `${MiniappStreamType.TRANSCRIPTION}:auto`;
5725
- return {
5726
- cloud: app?.cloudTranscriptionStreams.has(stream) === true || app?.cloudTranscriptionStreams.has(autoStream) === true,
5727
- forceLocal: app?.forceLocalTranscriptionStreams.has(stream) === true ||
5728
- app?.forceLocalTranscriptionStreams.has(autoStream) === true,
5729
- };
5812
+ /**
5813
+ * Sends one transcript to each listener that hears its stream and currently
5814
+ * gets its text from its source, tagged with that source. Then records where
5815
+ * the source is in its utterance: a final can complete a listener's switch
5816
+ * that waited for it.
5817
+ */
5818
+ forwardTranscription(stream, data, source, tokens, packageNames) {
5819
+ const transcript = (data ?? {});
5820
+ const isFinal = transcript.isFinal === true;
5821
+ // A switch that waited only on an utterance that went stale happens before this transcript.
5822
+ let moved = this.refreshTranscriptionRouting();
5823
+ const audioPosition = cloudClientService.getAudioPosition();
5824
+ for (const packageName of packageNames) {
5825
+ const app = this.connectedApps.get(packageName);
5826
+ if (!app)
5827
+ continue;
5828
+ const deliveries = app.transcriptionListeners.deliveries(stream, source, transcript.text ?? "", tokens, audioPosition);
5829
+ if (TRANSCRIPT_TIMING_TELEMETRY && deliveries.length > 0) {
5830
+ const now = Date.now();
5831
+ console.log(`${LOG_TAG}: transcript fanout t=${now} app=${packageName} source=${source} final=${isFinal} hostDelta=${transcript.__hostReceivedAt ? now - transcript.__hostReceivedAt : -1}ms text="${(transcript.text ?? "").slice(0, 48)}"`);
5832
+ }
5833
+ for (const delivery of deliveries) {
5834
+ this.sendToMiniapp(packageName, {
5835
+ type: MiniappResponseType.EVENT,
5836
+ streamType: stream,
5837
+ data: { ...transcript, text: delivery.text, source },
5838
+ transcriptionRoute: delivery.route,
5839
+ ...(delivery.listenerId ? { listenerId: delivery.listenerId } : {}),
5840
+ });
5841
+ }
5842
+ }
5843
+ if (source === "local" && isFinal) {
5844
+ this.localFinalWatermark = audioPosition ?? this.localFinalWatermark;
5845
+ this.noteLocalFinalDelivered();
5846
+ }
5847
+ this.utterances.note(source, stream, isFinal, Date.now());
5848
+ if (this.refreshTranscriptionRouting())
5849
+ moved = true;
5850
+ if (moved)
5851
+ this.updateCloudSubscriptions();
5730
5852
  }
5731
5853
  /**
5732
5854
  * Normalize incoming event names to miniapp stream type values.