@glassly/engine 0.3.2-dev.574 → 0.3.2-dev.580

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.
@@ -54,6 +54,7 @@ import {
54
54
  import {normalizeAiTier, resolveAiTier} from "./AiTierResolution"
55
55
  import type {AiTier, AiTierProvider, SavedLlmKeys} from "./AiTierResolution"
56
56
  import {HttpError} from "@glassly/cloud-client"
57
+ import type {QuotaStatusPayload} from "@glassly/cloud-protocol"
57
58
  import localDisplayManager from "./LocalDisplayManager"
58
59
  import sttModelManager from "./STTModelManager"
59
60
  import localTranslationService, {type LocalTranslationSubscription} from "./LocalTranslationService"
@@ -108,7 +109,12 @@ import {phonePhotoCoordinator} from "./PhonePhotoCoordinator"
108
109
  import {phoneStreamCoordinator} from "./PhoneStreamCoordinator"
109
110
  import {phoneVideoCoordinator} from "./PhoneVideoCoordinator"
110
111
  import {runSentenceTtsPipeline} from "./SentenceTtsPipeline"
111
- import {summarizeTranscriptionRoutes, transcriptionDeliveryRoute} from "./TranscriptionRouting"
112
+ import {
113
+ summarizeTranscriptionRoutes,
114
+ transcriptionDeliveryRoute,
115
+ type TranscriptionDeliveryRoute,
116
+ type TranscriptionSubscriberRoutes,
117
+ } from "./TranscriptionRouting"
112
118
  import {prepareTtsSentences} from "./TtsTextSanitizer"
113
119
  import {handleWifiAdbRequest} from "./WifiAdbRequest"
114
120
  import {cloudClientService, setBlobNamespaceMigrator} from "./CloudClientService"
@@ -171,6 +177,8 @@ interface ConnectedMiniapp {
171
177
  forceLocalTranscriptionStreams: Set<string>
172
178
  /** Transcription/translation streams with at least one listener using the default/cloud route. */
173
179
  cloudTranscriptionStreams: Set<string>
180
+ /** Transcription streams whose default-route listeners all refuse on-device fallback text. */
181
+ noFallbackTranscriptionStreams: Set<string>
174
182
  sendMessage: (raw: string) => void
175
183
  lastPongAt: number
176
184
  unansweredPingRounds: number
@@ -213,6 +221,9 @@ interface SpeechRun {
213
221
 
214
222
  type TranscriptionEventSource = "cloud" | "local"
215
223
 
224
+ /** The cloud snapshot miniapps see: the phone's connection state plus plan-quota blocks. */
225
+ type MiniappCloudStatus = CloudClientStatusSnapshot & {quotaBlocked: QuotaStatusPayload["feature"][]}
226
+
216
227
  /** Streams that carry speech text and so have a cloud route and an on-device route. */
217
228
  const isSpeechTextStream = (stream: string): boolean =>
218
229
  stream.startsWith("transcription:") || stream.startsWith("translation:")
@@ -1048,6 +1059,7 @@ class LocalMiniappRuntime {
1048
1059
  subscriptions: new Set(),
1049
1060
  forceLocalTranscriptionStreams: new Set(),
1050
1061
  cloudTranscriptionStreams: new Set(),
1062
+ noFallbackTranscriptionStreams: new Set(),
1051
1063
  sendMessage: sendFn,
1052
1064
  lastPongAt: Date.now(),
1053
1065
  unansweredPingRounds: 0,
@@ -2101,7 +2113,10 @@ class LocalMiniappRuntime {
2101
2113
  if (!app) return
2102
2114
 
2103
2115
  const rawStreams = (payload.subscriptions ?? payload.streams) as
2104
- | (string | {stream: string; rate?: string; forceLocal?: boolean; includeCloud?: boolean})[]
2116
+ | (
2117
+ | string
2118
+ | {stream: string; rate?: string; forceLocal?: boolean; includeCloud?: boolean; localFallback?: boolean}
2119
+ )[]
2105
2120
  | undefined
2106
2121
  // Normalize: objects like {stream: "location_stream", rate: "realtime"} → extract stream name
2107
2122
  // "location_stream" is the rate-bearing alias for "location_update".
@@ -2110,6 +2125,7 @@ class LocalMiniappRuntime {
2110
2125
  let requestedLocationRate: string | null = null
2111
2126
  const forceLocalTranscriptionStreams = new Set<string>()
2112
2127
  const cloudTranscriptionStreams = new Set<string>()
2128
+ const noFallbackTranscriptionStreams = new Set<string>()
2113
2129
  const streams = rawStreams?.map((s) => {
2114
2130
  if (typeof s === "object" && s !== null) {
2115
2131
  if (s.stream === "location_stream") {
@@ -2123,6 +2139,7 @@ class LocalMiniappRuntime {
2123
2139
  }
2124
2140
  if (isSpeechTextStream(s.stream) && (s.forceLocal !== true || s.includeCloud === true)) {
2125
2141
  cloudTranscriptionStreams.add(s.stream)
2142
+ if (s.localFallback === false) noFallbackTranscriptionStreams.add(s.stream)
2126
2143
  }
2127
2144
  return s.stream
2128
2145
  }
@@ -2190,6 +2207,9 @@ class LocalMiniappRuntime {
2190
2207
  app.cloudTranscriptionStreams = new Set(
2191
2208
  [...cloudTranscriptionStreams].filter((stream) => app.subscriptions.has(stream)),
2192
2209
  )
2210
+ app.noFallbackTranscriptionStreams = new Set(
2211
+ [...noFallbackTranscriptionStreams].filter((stream) => app.subscriptions.has(stream)),
2212
+ )
2193
2213
  this.replaceStreamSubscribers(packageName, previousSubscriptions, app.subscriptions)
2194
2214
  // The overlay app presents phone notifications on the glasses while it
2195
2215
  // listens to them; recorded for every dashboard so the role can move.
@@ -4863,6 +4883,7 @@ class LocalMiniappRuntime {
4863
4883
  const previousSubscriptions = app.subscriptions
4864
4884
  app.subscriptions = new Set(streams)
4865
4885
  app.forceLocalTranscriptionStreams.clear()
4886
+ app.noFallbackTranscriptionStreams.clear()
4866
4887
  app.cloudTranscriptionStreams = new Set(streams.filter(isSpeechTextStream))
4867
4888
  this.replaceStreamSubscribers(packageName, previousSubscriptions, app.subscriptions)
4868
4889
 
@@ -4929,6 +4950,7 @@ class LocalMiniappRuntime {
4929
4950
  let transcriptionLang: string | null = null
4930
4951
  let forceLocalTranscriptionLang: string | null = null
4931
4952
  let hasForceLocalTranscription = false
4953
+ let hasFallbackTranscription = false
4932
4954
  const localTranslations: LocalTranslationSubscription[] = []
4933
4955
  for (const [stream, subscribers] of this.activeStreamSubscribers()) {
4934
4956
  if (subscribers.size === 0) continue
@@ -4960,6 +4982,7 @@ class LocalMiniappRuntime {
4960
4982
  hasForceLocalTranscription = true
4961
4983
  if (forceLocalTranscriptionLang === null) forceLocalTranscriptionLang = language
4962
4984
  }
4985
+ if (routes.hasFallbackSubscriber) hasFallbackTranscription = true
4963
4986
  if (routes.hasCloudSubscriber) cloudStreams.add(stream)
4964
4987
  }
4965
4988
  }
@@ -4974,6 +4997,7 @@ class LocalMiniappRuntime {
4974
4997
  transcriptionLang !== null || localSourceLang !== null,
4975
4998
  forceLocalTranscriptionLang ?? transcriptionLang ?? localSourceLang,
4976
4999
  hasForceLocalTranscription || hasForceLocalTranslation,
5000
+ hasFallbackTranscription || localSourceLang !== null,
4977
5001
  )
4978
5002
 
4979
5003
  // Mirror the same set as typed AudioSubscription[]. When Soniox-direct is
@@ -5134,12 +5158,15 @@ class LocalMiniappRuntime {
5134
5158
  if (q.feature !== "transcription") return
5135
5159
  if (!q.blocked) {
5136
5160
  this.lastSttQuotaNoticeAt = 0
5137
- void localSttFallbackCoordinator.onSttQuotaBlockedChange(false)
5161
+ void localSttFallbackCoordinator.onSttQuotaBlockedChange(false).then(() => this.broadcastCloudStatus())
5138
5162
  this.onQuotaRestored?.("transcription")
5139
5163
  return
5140
5164
  }
5141
5165
  console.warn(`${LOG_TAG}: cloud transcription blocked by plan quota`)
5142
5166
  void localSttFallbackCoordinator.onSttQuotaBlockedChange(true).then(() => {
5167
+ // Miniapps learn of the block through the status snapshot; the lens
5168
+ // notice below is for the wearer.
5169
+ this.broadcastCloudStatus()
5143
5170
  const localSttActive = localSttFallbackCoordinator.isActive()
5144
5171
  const now = Date.now()
5145
5172
  if (now - this.lastSttQuotaNoticeAt >= STT_QUOTA_NOTICE_DEBOUNCE_MS) {
@@ -5383,7 +5410,7 @@ class LocalMiniappRuntime {
5383
5410
  )
5384
5411
  }
5385
5412
 
5386
- private currentCloudStatus(): CloudClientStatusSnapshot {
5413
+ private currentCloudStatus(): MiniappCloudStatus {
5387
5414
  const base = cloudClientService.getStatus()
5388
5415
  const fallbackActive =
5389
5416
  (useSettingsStore.getState().getSetting(ISLAND_SETTINGS_KEYS.localSttFallbackActive) as boolean | undefined) ===
@@ -5394,6 +5421,7 @@ class LocalMiniappRuntime {
5394
5421
  return {
5395
5422
  status: base.status,
5396
5423
  audioTransport: fallbackActive || sonioxDegraded ? "offline" : base.audioTransport,
5424
+ quotaBlocked: localSttFallbackCoordinator.isSttQuotaBlocked() ? ["transcription"] : [],
5397
5425
  }
5398
5426
  }
5399
5427
 
@@ -5543,7 +5571,7 @@ class LocalMiniappRuntime {
5543
5571
 
5544
5572
  for (const packageName of matchedSubs) {
5545
5573
  if (only !== undefined && packageName !== only) continue
5546
- let transcriptionRoute: "default" | "forceLocal" | "all" | undefined
5574
+ let transcriptionRoute: TranscriptionDeliveryRoute | undefined
5547
5575
  if (normalizedStream.startsWith("transcription:") && transcriptionSource) {
5548
5576
  const routes = this.appTranscriptionRoutesForEvent(packageName, normalizedStream)
5549
5577
  transcriptionRoute =
@@ -5638,17 +5666,12 @@ class LocalMiniappRuntime {
5638
5666
  })
5639
5667
  }
5640
5668
 
5641
- private appTranscriptionRoutesForSubscription(
5642
- packageName: string,
5643
- stream: string,
5644
- ): {
5645
- cloud: boolean
5646
- forceLocal: boolean
5647
- } {
5669
+ private appTranscriptionRoutesForSubscription(packageName: string, stream: string): TranscriptionSubscriberRoutes {
5648
5670
  const app = this.connectedApps.get(packageName)
5649
5671
  return {
5650
5672
  cloud: app?.cloudTranscriptionStreams.has(stream) === true,
5651
5673
  forceLocal: app?.forceLocalTranscriptionStreams.has(stream) === true,
5674
+ localFallback: app?.noFallbackTranscriptionStreams.has(stream) !== true,
5652
5675
  }
5653
5676
  }
5654
5677
 
@@ -5666,21 +5689,20 @@ class LocalMiniappRuntime {
5666
5689
  }
5667
5690
  }
5668
5691
 
5669
- private appTranscriptionRoutesForEvent(
5670
- packageName: string,
5671
- stream: string,
5672
- ): {
5673
- cloud: boolean
5674
- forceLocal: boolean
5675
- } {
5692
+ private appTranscriptionRoutesForEvent(packageName: string, stream: string): TranscriptionSubscriberRoutes {
5676
5693
  const app = this.connectedApps.get(packageName)
5677
5694
  const autoStream = `${MiniappStreamType.TRANSCRIPTION}:auto`
5695
+ const cloudExact = app?.cloudTranscriptionStreams.has(stream) === true
5696
+ const cloudAuto = app?.cloudTranscriptionStreams.has(autoStream) === true
5678
5697
  return {
5679
- cloud:
5680
- app?.cloudTranscriptionStreams.has(stream) === true || app?.cloudTranscriptionStreams.has(autoStream) === true,
5698
+ cloud: cloudExact || cloudAuto,
5681
5699
  forceLocal:
5682
5700
  app?.forceLocalTranscriptionStreams.has(stream) === true ||
5683
5701
  app?.forceLocalTranscriptionStreams.has(autoStream) === true,
5702
+ // Fallback stays accepted while any matching default subscription takes it.
5703
+ localFallback:
5704
+ (cloudExact && app?.noFallbackTranscriptionStreams.has(stream) !== true) ||
5705
+ (cloudAuto && app?.noFallbackTranscriptionStreams.has(autoStream) !== true),
5684
5706
  }
5685
5707
  }
5686
5708
 
@@ -8,7 +8,8 @@ import sttModelManager from "./STTModelManager"
8
8
  * Decides whether on-device Sherpa STT should produce transcripts for local
9
9
  * miniapps, instead of (or in addition to) cloud transcription. Local STT is
10
10
  * active when the cloud is unavailable (disconnected or refusing STT for plan
11
- * quota) or any subscription requires `forceLocal`.
11
+ * quota) and some subscriber accepts fallback text, or any subscription
12
+ * requires `forceLocal`.
12
13
  *
13
14
  * Activation flips `localSttFallbackActive` in the settings store, which
14
15
  * the bluetooth-sdk native side reads to decide whether to feed PCM into
@@ -37,6 +38,8 @@ class LocalSttFallbackCoordinator {
37
38
 
38
39
  private hasTranscriptionSubscription = false
39
40
  private hasForceLocalSubscription = false
41
+ /** Some default-route subscriber takes on-device text while the primary is down. */
42
+ private hasFallbackSubscription = false
40
43
  private activeLanguage: string | null = null
41
44
  /**
42
45
  * Default to "cloud is up" so we never accidentally activate local STT before
@@ -123,10 +126,18 @@ class LocalSttFallbackCoordinator {
123
126
  return this.localActive ? this.currentReason() : null
124
127
  }
125
128
 
126
- onSubscriptionChange(hasTranscription: boolean, language: string | null, hasForceLocal = false): void {
127
- this.log(`onSubscriptionChange(hasTx=${hasTranscription}, lang=${language}, forceLocal=${hasForceLocal})`)
129
+ onSubscriptionChange(
130
+ hasTranscription: boolean,
131
+ language: string | null,
132
+ hasForceLocal = false,
133
+ acceptsFallback = true,
134
+ ): void {
135
+ this.log(
136
+ `onSubscriptionChange(hasTx=${hasTranscription}, lang=${language}, forceLocal=${hasForceLocal}, fallback=${acceptsFallback})`,
137
+ )
128
138
  this.hasTranscriptionSubscription = hasTranscription
129
139
  this.hasForceLocalSubscription = hasTranscription && hasForceLocal
140
+ this.hasFallbackSubscription = hasTranscription && acceptsFallback
130
141
  this.activeLanguage = hasTranscription ? language : null
131
142
  void this.reconcile()
132
143
  }
@@ -256,7 +267,10 @@ class LocalSttFallbackCoordinator {
256
267
  }
257
268
 
258
269
  private shouldUseLocalStt(): boolean {
259
- return this.hasTranscriptionSubscription && (this.hasForceLocalSubscription || !this.primaryUp())
270
+ return (
271
+ this.hasTranscriptionSubscription &&
272
+ (this.hasForceLocalSubscription || (this.hasFallbackSubscription && !this.primaryUp()))
273
+ )
260
274
  }
261
275
 
262
276
  private log(msg: string): void {
@@ -1,14 +1,19 @@
1
1
  export interface TranscriptionRouteSummary {
2
2
  hasCloudSubscriber: boolean
3
3
  hasForceLocalSubscriber: boolean
4
+ /** A default-route subscriber that takes on-device text while the primary is down. */
5
+ hasFallbackSubscriber: boolean
4
6
  }
5
7
 
6
8
  export interface TranscriptionSubscriberRoutes {
7
9
  cloud: boolean
8
10
  forceLocal: boolean
11
+ /** Default-route fallback acceptance; absent means accepted. */
12
+ localFallback?: boolean
9
13
  }
10
14
 
11
- export type TranscriptionDeliveryRoute = "default" | "forceLocal" | "all"
15
+ /** "fallback" is on-device text serving the default route because the primary is down. */
16
+ export type TranscriptionDeliveryRoute = "default" | "forceLocal" | "fallback" | "all"
12
17
 
13
18
  export function summarizeTranscriptionRoutes<T>(
14
19
  subscribers: Iterable<T>,
@@ -16,12 +21,14 @@ export function summarizeTranscriptionRoutes<T>(
16
21
  ): TranscriptionRouteSummary {
17
22
  let hasCloudSubscriber = false
18
23
  let hasForceLocalSubscriber = false
24
+ let hasFallbackSubscriber = false
19
25
  for (const subscriber of subscribers) {
20
26
  const routes = routesFor(subscriber)
21
27
  if (routes.cloud) hasCloudSubscriber = true
22
28
  if (routes.forceLocal) hasForceLocalSubscriber = true
29
+ if (routes.cloud && routes.localFallback !== false) hasFallbackSubscriber = true
23
30
  }
24
- return {hasCloudSubscriber, hasForceLocalSubscriber}
31
+ return {hasCloudSubscriber, hasForceLocalSubscriber, hasFallbackSubscriber}
25
32
  }
26
33
 
27
34
  /**
@@ -41,7 +48,8 @@ export function transcriptionDeliveryRoute(
41
48
  ): TranscriptionDeliveryRoute | null {
42
49
  if (source === "cloud") return routes.cloud ? "default" : null
43
50
  if (primaryUp) return routes.forceLocal ? "forceLocal" : null
44
- if (routes.cloud && routes.forceLocal) return "all"
51
+ const fallback = routes.cloud && routes.localFallback !== false
52
+ if (fallback && routes.forceLocal) return "all"
45
53
  if (routes.forceLocal) return "forceLocal"
46
- return routes.cloud ? "default" : null
54
+ return fallback ? "fallback" : null
47
55
  }
@@ -742,19 +742,6 @@ export const SETTINGS: Record<string, Setting> = {
742
742
  saveOnServer: false,
743
743
  persist: true,
744
744
  },
745
- // Debug: route the R1 ring through the glasses (advStart + RING_CONNECT_INFO,
746
- // events forwarded by the CFW) instead of the phone link. OFF by default:
747
- // the ring sends its touch reports to the glasses whenever it holds glasses
748
- // targets, so the phone link only carries telemetry until those are erased.
749
- // Synced to the Bluetooth SDK, which re-routes a connected ring on change.
750
- // Device-local debug knob, so not saved on the server.
751
- ring_via_glasses: {
752
- key: "ring_via_glasses",
753
- defaultValue: () => false,
754
- writable: true,
755
- saveOnServer: false,
756
- persist: true,
757
- },
758
745
  // Maximum rate at which display updates leave the phone for the glasses.
759
746
  // The Bluetooth SDK paces every scene frame and legacy layout to this,
760
747
  // coalescing faster updates last-wins; clears bypass it. No effect on G2
@@ -1107,7 +1094,6 @@ export const BLUETOOTH_SETTING_KEYS: string[] = [
1107
1094
  SETTINGS.cfw_scene_renderer.key,
1108
1095
  SETTINGS.cfw_fast_ble.key,
1109
1096
  SETTINGS.g2_perf_logs.key,
1110
- SETTINGS.ring_via_glasses.key,
1111
1097
  SETTINGS.display_max_fps.key,
1112
1098
  SETTINGS.twelve_hour_time.key,
1113
1099
  SETTINGS.metric_system.key,