@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.
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/services/DeviceEventRouter.js +2 -2
- package/build/services/DeviceEventRouter.js.map +1 -1
- package/build/services/LocalMiniappRuntime.d.ts +55 -5
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +293 -171
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/LocalSttFallbackCoordinator.d.ts +10 -32
- package/build/services/LocalSttFallbackCoordinator.d.ts.map +1 -1
- package/build/services/LocalSttFallbackCoordinator.js +16 -58
- package/build/services/LocalSttFallbackCoordinator.js.map +1 -1
- package/build/services/TranscriptionRouting.d.ts +112 -9
- package/build/services/TranscriptionRouting.d.ts.map +1 -1
- package/build/services/TranscriptionRouting.js +159 -21
- package/build/services/TranscriptionRouting.js.map +1 -1
- package/build/services/TranscriptionSubscriptions.d.ts +87 -6
- package/build/services/TranscriptionSubscriptions.d.ts.map +1 -1
- package/build/services/TranscriptionSubscriptions.js +174 -26
- package/build/services/TranscriptionSubscriptions.js.map +1 -1
- package/package.json +8 -8
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/services/DeviceEventRouter.ts +2 -2
- package/src/services/LocalMiniappRuntime.ts +325 -213
- package/src/services/LocalSttFallbackCoordinator.ts +15 -62
- package/src/services/TranscriptionRouting.ts +200 -30
- package/src/services/TranscriptionSubscriptions.ts +245 -25
|
@@ -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 {
|
|
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
|
-
|
|
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?.
|
|
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
|
-
|
|
1670
|
-
|
|
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.
|
|
1680
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1699
|
-
|
|
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
|
-
|
|
1715
|
-
|
|
1716
|
-
const
|
|
1717
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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},
|
|
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 (
|
|
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:
|
|
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 (
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
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
|
-
|
|
2554
|
-
|
|
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.
|
|
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
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
|
|
5263
|
-
|
|
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 (
|
|
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
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
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.
|
|
5282
|
-
|
|
5283
|
-
.
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
|
-
|
|
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
|
-
|
|
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.
|
|
5433
|
-
* the
|
|
5434
|
-
*
|
|
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
|
|
5443
|
-
const
|
|
5444
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
5718
|
-
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
const
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
|
|
5728
|
-
|
|
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.
|