@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.
- 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
|
@@ -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 {
|
|
61
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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?.
|
|
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
|
-
|
|
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
|
-
|
|
2064
|
-
|
|
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.
|
|
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:"))
|
|
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
|
|
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
|
-
|
|
2109
|
-
|
|
2110
|
-
const
|
|
2111
|
-
if (
|
|
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:
|
|
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
|
-
|
|
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},
|
|
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 (
|
|
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:
|
|
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 (
|
|
3033
|
-
|
|
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
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
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
|
-
|
|
3043
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6047
|
-
|
|
6048
|
-
|
|
6049
|
-
|
|
6050
|
-
|
|
6051
|
-
|
|
6052
|
-
|
|
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 (
|
|
6066
|
-
|
|
6067
|
-
|
|
6068
|
-
|
|
6069
|
-
|
|
6070
|
-
|
|
6071
|
-
|
|
6072
|
-
|
|
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
|
-
.
|
|
6075
|
-
|
|
6076
|
-
|
|
6077
|
-
|
|
6078
|
-
|
|
6079
|
-
|
|
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
|
-
|
|
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.
|
|
6245
|
-
* the
|
|
6246
|
-
*
|
|
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
|
|
6260
|
-
const
|
|
6261
|
-
|
|
6262
|
-
|
|
6263
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
6552
|
-
|
|
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
|
-
|
|
6556
|
-
|
|
6557
|
-
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
|
|
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
|
-
|
|
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
|
/**
|