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