arcane-os 0.5.18 → 0.6.0
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/CHANGELOG.md +27 -3
- package/README.md +18 -12
- package/browser-runtime/ai/browser-speech-providers.mjs +64 -60
- package/browser-runtime/ai/browser-wasm-llm-provider.mjs +31 -11
- package/browser-runtime/ai/browser-wllama-runtime.mjs +78 -0
- package/browser-runtime/ai/speech-worker-runtime.mjs +4 -5
- package/docs/architecture.md +1 -1
- package/docs/publishing.md +10 -4
- package/docs/reference/ai/browser-speech.md +65 -38
- package/docs/reference/ai/browser-wasm.md +29 -6
- package/docs/reference/behavioral-testing.md +1 -1
- package/docs/reference/inventory/runtime-modules.json +2 -2
- package/docs/reference/runtime-modules.md +32 -17
- package/docs/reference/sdk-api.md +32 -11
- package/examples/wasm-ai-demo/README.md +5 -3
- package/examples/wasm-ai-demo/app.js +1 -1
- package/package.json +1 -1
- package/runtime/arcane/components/chat.html +53 -10
- package/runtime/arcane/modules/AI.js +27 -29
|
@@ -55,11 +55,11 @@ export const speechSelection = {
|
|
|
55
55
|
};
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
The omitted execution record below uses the SDK's
|
|
59
|
-
selection
|
|
58
|
+
The omitted execution record below uses the SDK's NPU, GPU, then CPU automatic
|
|
59
|
+
selection. This basic configuration uses `fp32` because
|
|
60
60
|
[Kokoro.js recommends `fp32` when using WebGPU](https://github.com/hexgrad/kokoro/tree/main/kokoro.js#usage).
|
|
61
|
-
Automatic fallback carries this same selected model and dtype
|
|
62
|
-
does not rewrite the application selection. If you intentionally choose
|
|
61
|
+
Automatic fallback carries this same selected model and dtype between devices;
|
|
62
|
+
the SDK does not rewrite the application selection. If you intentionally choose
|
|
63
63
|
another dtype, evaluate that exact model, browser, and execution route.
|
|
64
64
|
`selectedDevice` reports routing after load, not pronunciation, text fidelity,
|
|
65
65
|
or audio quality.
|
|
@@ -229,9 +229,10 @@ playback. `replay()` keeps completed and pending provider segments and retries
|
|
|
229
229
|
only failed missing segments.
|
|
230
230
|
|
|
231
231
|
The default `{device:'auto',maxConcurrentRequests:4}` attempts the full ONNX
|
|
232
|
-
Worker/session pool on
|
|
233
|
-
|
|
234
|
-
|
|
232
|
+
Worker/session pool on WebNN NPU, then WebGPU, then CPU through WASM. It skips
|
|
233
|
+
an accelerator when its browser API is absent and replaces a failed candidate
|
|
234
|
+
with a fresh pool before trying the next device. The basic configuration above
|
|
235
|
+
keeps `fp32` throughout that sequence. Use the status example below to read
|
|
235
236
|
`selectedDevice`; console node-assignment warnings alone do not identify the
|
|
236
237
|
selected execution device or assess the generated audio.
|
|
237
238
|
|
|
@@ -588,8 +589,20 @@ console.log(speechText.append('ing', true)); // ing
|
|
|
588
589
|
|
|
589
590
|
## Choose a device or reduce memory use
|
|
590
591
|
|
|
591
|
-
|
|
592
|
-
|
|
592
|
+
Both speech roles accept `execution:{device,maxConcurrentRequests}`. Omitting
|
|
593
|
+
`stt.execution` selects `{device:'auto',maxConcurrentRequests:1}`; omitting
|
|
594
|
+
`tts.execution` selects `{device:'auto',maxConcurrentRequests:4}`. Whisper keeps
|
|
595
|
+
one transcription slot. Kokoro accepts capacities 1 through 4.
|
|
596
|
+
|
|
597
|
+
Automatic selection tries `webnn-npu` when `navigator.ml.createContext` is
|
|
598
|
+
exposed, then `webgpu` when `navigator.gpu` is exposed, then CPU through `wasm`.
|
|
599
|
+
API exposure only determines which upstream backend to attempt; it
|
|
600
|
+
does not establish compatible hardware, operators, or model shapes. A failed
|
|
601
|
+
candidate is fully cleaned up before the next candidate uses fresh Workers
|
|
602
|
+
with the same prepared model and dtype. An explicit `webnn-npu`, `webgpu`, or
|
|
603
|
+
`wasm` selection attempts only that backend and reports its failure.
|
|
604
|
+
|
|
605
|
+
These are four alternative TTS configurations:
|
|
593
606
|
|
|
594
607
|
```javascript
|
|
595
608
|
async function selectSpeechExecution(execution) {
|
|
@@ -609,15 +622,22 @@ async function selectSpeechExecution(execution) {
|
|
|
609
622
|
|
|
610
623
|
// Choose and call one from your application settings action:
|
|
611
624
|
// await selectSpeechExecution({ device: 'auto' });
|
|
625
|
+
// await selectSpeechExecution({ device: 'webnn-npu' });
|
|
612
626
|
// await selectSpeechExecution({ device: 'webgpu' });
|
|
613
627
|
// await selectSpeechExecution({ device: 'wasm', maxConcurrentRequests: 1 });
|
|
614
628
|
```
|
|
615
629
|
|
|
616
|
-
The override accepts integers 1, 2, 3, or 4
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
630
|
+
The TTS capacity override accepts integers 1, 2, 3, or 4; an STT capacity
|
|
631
|
+
override accepts only 1. Apply the same device choices to the configured
|
|
632
|
+
`stt.execution` record. A configuration change leaves TTS muted; explicitly
|
|
633
|
+
load/unmute again.
|
|
634
|
+
|
|
635
|
+
The selected upstream versions expose WebNN NPU through
|
|
636
|
+
[Transformers.js device selection](https://github.com/huggingface/transformers.js/blob/4.2.0/packages/transformers/src/backends/onnx.js)
|
|
637
|
+
and [Kokoro.js device forwarding](https://github.com/hexgrad/kokoro/blob/664c76a704021239ba59c84dcbaa4d3dece01fe9/kokoro.js/src/kokoro.js).
|
|
638
|
+
WebNN compatibility depends on the model's shapes and operations, browser,
|
|
639
|
+
drivers, and hardware. Unsupported operations may run through WASM even after
|
|
640
|
+
an NPU session loads; see the [ONNX Runtime WebNN contract](https://onnxruntime.ai/docs/tutorials/web/ep-webnn.html).
|
|
621
641
|
|
|
622
642
|
## Inspect the requested and selected device
|
|
623
643
|
|
|
@@ -627,15 +647,15 @@ loading. This explicitly reads the selected provider's current report; ordinary
|
|
|
627
647
|
separate execution-state event subscription.
|
|
628
648
|
|
|
629
649
|
```javascript
|
|
630
|
-
function printSpeechStatus() {
|
|
631
|
-
const status = ai.providerRuntime.status(
|
|
650
|
+
function printSpeechStatus(role = 'tts') {
|
|
651
|
+
const status = ai.providerRuntime.status(role, { execution: true });
|
|
632
652
|
const execution = status.execution;
|
|
633
|
-
console.log('
|
|
653
|
+
console.log('Speech role and state:', role, status.state);
|
|
634
654
|
if (execution) {
|
|
635
655
|
console.log('Requested device:', execution.requestedDevice);
|
|
636
656
|
console.log('Selected device:', execution.selectedDevice);
|
|
637
657
|
console.log('Capacity:', execution.maxConcurrentRequests);
|
|
638
|
-
console.log('Active
|
|
658
|
+
console.log('Active requests:', execution.activeRequestCount);
|
|
639
659
|
console.log('Automatic WASM fallback:',
|
|
640
660
|
execution.requestedDevice === 'auto' && execution.selectedDevice === 'wasm');
|
|
641
661
|
}
|
|
@@ -645,13 +665,18 @@ function printSpeechStatus() {
|
|
|
645
665
|
Call `printSpeechStatus()` after the load in `sayHello()` or from your status
|
|
646
666
|
button. The same projection is at
|
|
647
667
|
`ai.providerRuntime.status(null, {execution:true}).roles.tts.execution`.
|
|
668
|
+
For configured Whisper, call `printSpeechStatus('stt')` or read the corresponding
|
|
669
|
+
`roles.stt.execution` projection.
|
|
648
670
|
`selectedDevice` is `null`
|
|
649
671
|
until a pool is selected and returns to `null` on unload. Providers without an
|
|
650
672
|
execution report omit `execution`; do not infer a device from `navigator.gpu`
|
|
651
673
|
or a configured preference alone. An explicit inspection can throw a provider
|
|
652
674
|
status error; handle it with the same `error.code` / `error.message` pattern.
|
|
653
|
-
|
|
654
|
-
|
|
675
|
+
`selectedDevice` names the backend requested by the successful upstream session
|
|
676
|
+
load. It does not prove that every operation ran on a physical NPU or GPU, or
|
|
677
|
+
establish transcription correctness, pronunciation, or audio quality. Evaluate
|
|
678
|
+
actual speech output for the model, dtype, browser, and device combinations your
|
|
679
|
+
application supports.
|
|
655
680
|
|
|
656
681
|
## Stop, mute, cancel, and release
|
|
657
682
|
|
|
@@ -991,11 +1016,12 @@ they do not silently convert into a rejection policy.
|
|
|
991
1016
|
The Worker applies only the selected runtime settings needed to run the chosen
|
|
992
1017
|
provider:
|
|
993
1018
|
|
|
994
|
-
- Kokoro forwards the pool's selected `webgpu
|
|
1019
|
+
- Kokoro forwards the pool's selected `webnn-npu`, `webgpu`, or `wasm` device to
|
|
995
1020
|
`KokoroTTS.from_pretrained()`. Its configured dtype remains exactly the
|
|
996
|
-
caller-selected dtype on
|
|
1021
|
+
caller-selected dtype on every path. The WASM path also uses
|
|
997
1022
|
`namespace.env.wasmPaths = {mjs,wasm}`.
|
|
998
|
-
- Transformers
|
|
1023
|
+
- Transformers forwards the selected device to its speech-recognition pipeline,
|
|
1024
|
+
preserves the caller-selected dtype, and uses
|
|
999
1025
|
`namespace.env.backends.onnx.wasm.wasmPaths = {mjs,wasm}`, keeps remote model
|
|
1000
1026
|
loading enabled, and applies caller-selected `numThreads` when present.
|
|
1001
1027
|
|
|
@@ -1029,15 +1055,15 @@ The constructors also accept ordinary `model` and `runtime` descriptors instead
|
|
|
1029
1055
|
of `graph`; the two forms are mutually exclusive. Both forms require an
|
|
1030
1056
|
SDK-created DBOPFS speech artifact store. `localOnly` remains `true`.
|
|
1031
1057
|
|
|
1032
|
-
|
|
1033
|
-
`{device,maxConcurrentRequests}`. `device` is `auto`, `webgpu`, or
|
|
1034
|
-
`
|
|
1035
|
-
`{device:'auto',maxConcurrentRequests:
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1058
|
+
Both constructors accept the exact `execution` record
|
|
1059
|
+
`{device,maxConcurrentRequests}`. `device` is `auto`, `webnn-npu`, `webgpu`, or
|
|
1060
|
+
`wasm`. Whisper permits capacity 1 and defaults to
|
|
1061
|
+
`{device:'auto',maxConcurrentRequests:1}`. Kokoro permits integer capacities 1
|
|
1062
|
+
through 4 and defaults to `{device:'auto',maxConcurrentRequests:4}`. `auto`
|
|
1063
|
+
tries WebNN NPU when `navigator.ml.createContext` is exposed, then WebGPU when
|
|
1064
|
+
`navigator.gpu` is exposed, then WASM. Before advancing after a failed load,
|
|
1065
|
+
the SDK tears down the candidate and creates fresh Workers using the same
|
|
1066
|
+
prepared model and dtype. Explicit device selections never fall back.
|
|
1041
1067
|
|
|
1042
1068
|
Each constructor returns an `arcane-ai-provider/2` object with:
|
|
1043
1069
|
|
|
@@ -1123,12 +1149,13 @@ mutable values.
|
|
|
1123
1149
|
Provider states are `unloaded`, `loading`, `ready`, `unloading`, `error`, and
|
|
1124
1150
|
`disposed`. `status()` includes role, provider/model ids, state, lifecycle
|
|
1125
1151
|
status and reason, active operation, loaded/busy flags, generation, error code,
|
|
1126
|
-
cache state, and warnings.
|
|
1152
|
+
cache state, and warnings. Both speech roles include an `execution` record
|
|
1127
1153
|
with requested and selected device, request limit, and active request count. A
|
|
1128
|
-
successful `selectedDevice
|
|
1129
|
-
|
|
1130
|
-
overlap
|
|
1131
|
-
security field is absent
|
|
1154
|
+
successful `selectedDevice` reports the backend requested by the upstream model
|
|
1155
|
+
load. It does not prove that every operation ran on a physical NPU or GPU,
|
|
1156
|
+
that accelerator kernels overlap, or that generated speech is correct. WebNN
|
|
1157
|
+
may execute unsupported operations through WASM. A security field is absent
|
|
1158
|
+
in ordinary mode.
|
|
1132
1159
|
|
|
1133
1160
|
The provider/2 load context accepts an optional progress callback for interface
|
|
1134
1161
|
compatibility, but the current browser-speech artifact and Worker transport
|
|
@@ -1203,7 +1230,7 @@ operation.
|
|
|
1203
1230
|
## Ownership
|
|
1204
1231
|
|
|
1205
1232
|
- Applications own model, runtime, dtype, sample-rate, voice, profile, prompt,
|
|
1206
|
-
catalog, activation, optional TTS execution override, and presentation
|
|
1233
|
+
catalog, activation, optional STT/TTS execution override, and presentation
|
|
1207
1234
|
policy.
|
|
1208
1235
|
- Upstream publishers own their runtime, model, voice, and license delivery.
|
|
1209
1236
|
- The SDK owns storage, materialization, routing, Worker lifecycle, normalized
|
|
@@ -101,10 +101,31 @@ active-worker count changes, and the download completes. At known completion, re
|
|
|
101
101
|
and active transfers are zero. Applications format the raw measures into B,
|
|
102
102
|
KB, MB, GB, transfer-rate, and duration labels.
|
|
103
103
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
104
|
+
Initialization keeps `phase:'initialize'` and reports a `stage` and a readable
|
|
105
|
+
`message` from the packaged runtime's existing log signals: runtime startup,
|
|
106
|
+
engine startup, model metadata, graphics-device initialization, model weights,
|
|
107
|
+
inference context, and inference graph. Warmup is shown only if the runtime
|
|
108
|
+
actually reports it; the default upstream log level may omit that signal.
|
|
109
|
+
These messages do not change the runtime's log level or replace its complete
|
|
110
|
+
diagnostic output.
|
|
111
|
+
|
|
112
|
+
Initialization has `total:null` and omits `completed` and `unit`, so its progress
|
|
113
|
+
bar is indeterminate. Downloaded-file completion is not activation completion.
|
|
114
|
+
The metadata tensor count and GPU layer assignment are descriptive only:
|
|
115
|
+
upstream reports assigned layers before model-weight loading has finished.
|
|
116
|
+
The pinned runtime does not expose a measured overall initialization fraction
|
|
117
|
+
or a shader-compilation completion count. Readiness still requires the load
|
|
118
|
+
operation to resolve and the runtime to confirm the model is loaded.
|
|
119
|
+
|
|
120
|
+
While a load remains active, the provider repeats its current record every five
|
|
121
|
+
seconds with `heartbeat:true` and an updated `elapsedMs`. During initialization,
|
|
122
|
+
`phaseElapsedMs` measures time in the current stage and `activityElapsedMs`
|
|
123
|
+
measures time since the most recent runtime log signal. A signal without a
|
|
124
|
+
recognized stage updates activity time without triggering another UI render.
|
|
125
|
+
The shared chat loading panel shows the observed stage and those timings. A
|
|
126
|
+
heartbeat only shows that the provider's timer ran; it does not establish
|
|
127
|
+
runtime advancement, estimate a completion time, or advance the progress bar.
|
|
128
|
+
Late runtime progress is ignored after cancellation or supersession.
|
|
108
129
|
|
|
109
130
|
The DBOPFS store uses one bounded transfer axis. Ordered multi-file GGUF sets
|
|
110
131
|
download several members concurrently and preserve completed shards across a
|
|
@@ -470,8 +491,10 @@ identity, default model metadata,
|
|
|
470
491
|
`streamChat`, `use`, `probe`, and `dispose`. Direct provider `load()` selects a
|
|
471
492
|
catalog model and returns `{model,status}`;
|
|
472
493
|
the public AI API module's `ai.load()` returns the flat controller status.
|
|
473
|
-
Direct `load({onProgress})` forwards the same
|
|
474
|
-
records used by the controller and provider/2
|
|
494
|
+
Direct `load({onProgress})` forwards the same download progress and observed
|
|
495
|
+
initialization-stage/activity records used by the controller and provider/2
|
|
496
|
+
adapter. Initialization has no completion percentage unless the runtime reports
|
|
497
|
+
a meaningful completion total.
|
|
475
498
|
Provider `security` carries the provider/model-binding `secure` intent. Direct
|
|
476
499
|
`provider.load({security})` and `ai.load({security})` supply the operation
|
|
477
500
|
intent. They do not activate checking in the ordinary development contract.
|
|
@@ -41,7 +41,7 @@ development evidence; it is not native artifact or release acceptance.
|
|
|
41
41
|
| Browser runtime modules | Every shipped ESM module parses and its export inventory matches the catalog; pure helpers run focused success/error cases. | DOM, OPFS, media, and Web Component journeys use a browser harness. |
|
|
42
42
|
| Provider-neutral AI runtime and chat/speech activation | Provider/2 registration, three-role configuration, TWiN Cloud LLM readiness, on-device Whisper/Kokoro selection, opt-in STT startup, Core speech readiness, independent LLM/STT/TTS load/unload/status, capacity-1 FIFO settlement for LLM/STT, bounded parallel synthesis with FIFO admission for an explicitly capable TTS provider, owned STT signals, TTS mute lifecycle, route-owned voice defaults, immediate chunk synthesis admission, original-order audio-clock scheduling, sticky-state-only readiness for both speech components, selected-unloaded activation request/cancellation/error behavior, programmatic voice recording, transcript-replacement supersession of late settlement, direct `AI.fetchSTT` result delivery, rejection of non-local speech configuration, and absence of silent provider fallback are represented against complete providers and host callbacks. | Real model/runtime availability remains the selected provider's evidence boundary; provider-promise settlement, state, an abort signal, or an activation event does not by itself prove underlying provider work stopped or native, cloud, or browser-model availability. |
|
|
43
43
|
| Browser-WASM local AI | The exported namespace, canonical ordered `{id, files:[{name?,url},...]}` descriptor, nonempty provider `sources` catalog, default `secure:false`, dormant `secure:true` intent, public AI API module lifecycle, lazy/manual policy, successful Wllama-load requirement, abort normalization, complete output and reasoning, all-choice validation, required structural-call `arguments.message`, exact call identity, and matching tool-result sequencing are represented in deterministic fixture sources. | A real Chrome exercise may load the selected Wllama runtime and model only after explicit user action. It is not an ordinary publication gate or an implicit model download. |
|
|
44
|
-
| Browser speech | Caller-owned Whisper/Kokoro selection, independent STT/TTS routes, ordinary direct upstream runtime/model use, dormant `secure:true` intent, DBOPFS cache, materialized known-route/native-fallback behavior, GPU-
|
|
44
|
+
| Browser speech | Caller-owned Whisper/Kokoro selection, independent STT/TTS routes, ordinary direct upstream runtime/model use, dormant `secure:true` intent, DBOPFS cache, materialized known-route/native-fallback behavior, NPU-to-GPU-to-CPU automatic selection for both roles, fresh Workers after a failed device load, explicit-device failure without fallback, bounded Kokoro Worker/session pools, out-of-order synthesis settlement with FIFO admission, request-targeted TTS cancellation, destructive lifecycle cleanup, Blob/File STT conversion, WAV TTS conversion, complete text/audio, and no cloud fallback are represented with synthetic artifacts and adapters. | A real runtime/model/voice download, WebNN or WebGPU model load, physical accelerator behavior, and actual transcription or synthesis use the application's selected upstream packages/providers, browser media support, and explicit user action. A selected backend is not proof that every operation ran on that physical accelerator; WebNN may execute unsupported operations through WASM. |
|
|
45
45
|
| Persistent chat and document context | Atomic in-memory/history commit, explicit per-turn persistence, streamed/non-stream fallback, session-owned callback fields, complete data callbacks, per-turn request options, exact per-choice streamed/terminal call correlation before publication, terminal-only call acceptance, ordered parallel-call sequencing, atomic all-ID nonblank executed/declined/cancelled/not-executed result batches, readable unmodified malformed pre-existing rows, complete UI transcript metadata, generic visible failure outcomes with complete console diagnostics, BFCache-preserving component lifecycle, complete bootstrap/search/context, caller-source evaluation, cancellation, and partial-read handling are represented with app-scoped adapters. | Live Core/provider inference and durable browser storage remain separate authorities; tests never treat a fake chat function or in-memory adapter as host/storage proof. |
|
|
46
46
|
| Core bridge docs | Canonical namespace/method/event/entity inventories match their one-per-member guides and required sections. | Live Core conformance belongs in Arcane OS because Core implementation is not shipped as SDK source. |
|
|
47
47
|
| Arcane Ollama wrapper | Missing-host error, method forwarding, text/readiness normalization, unload request, and stream-option forwarding run against a deterministic fake `Arcane.ollama`. | Real managed-service, model download/create, GPU/resource admission, and service restart require an admitted Arcane host. |
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"repository": "https://github.com/TheWizardNexus/arcane-os-sdk.git",
|
|
6
6
|
"branch": "main",
|
|
7
7
|
"path": "runtime/arcane",
|
|
8
|
-
"sdkVersion": "0.
|
|
8
|
+
"sdkVersion": "0.6.0",
|
|
9
9
|
"protocol": "arcane/1"
|
|
10
10
|
},
|
|
11
11
|
"artifactCount": 85,
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"summary": "Owns provider-selectable chat and the one-time caller-authority browser STT/TTS configuration, lifecycle, synthesis, transcription, and playback boundary.",
|
|
30
30
|
"availability": "Browser + native bridge + TWiN Cloud",
|
|
31
31
|
"protocol": "arcane-ai-browser-speech-configuration/1, AIProviderRuntime arcane-ai-provider/2 routes, globalThis.arcaneEvents, TWiN Cloud HTTPS, Arcane.ollama, Arcane.speech, Android WebView bridge",
|
|
32
|
-
"normalization": "Complete mutable caller-owned browser speech authority, SDK-owned provider registration/replacement/disposal, explicit STT/TTS activation, normalized Kokoro TTS execution with default
|
|
32
|
+
"normalization": "Complete mutable caller-owned browser speech authority, SDK-owned provider registration/replacement/disposal, explicit STT/TTS activation, normalized Whisper STT and Kokoro TTS execution with default capacities 1 and 4 respectively, explicit provider-neutral execution snapshots through status(role,{execution:true}), sticky readiness, canonical window.user readiness without compatibility-event object-identity admission or polling, exact ordered structural calls with required arguments.message, atomic nonblank all-ID tool-result sequencing, complete all-choice streaming/data validation, native Ollama structural adaptation, complete-response then all validated tool callbacks then completion ordering, observed async callbacks, Blob/File transcription, automatic repeated-formatting-mark removal from cloned outbound TTS input while caller and visible content stays exact, immediate exact-segment synthesis, per-call voice/speed capture, optional final-segment pause on the existing audio clock, optional per-call terminal playback completion, detached preparation with complete semantic DBOPFS audio reuse and same-owner pending request sharing, independent prepared playback without a model load for cached audio, ordered audio-clock scheduling, playable audio, active-generation TTS operation-failure routing, mute, and cancellation are normalized; provider/model/runtime/voice policy remains caller-owned. Both speech roles default execution.device to auto: webnn-npu when navigator.ml.createContext is exposed, then webgpu when navigator.gpu is exposed, then wasm for CPU execution. Failed backend loads clean up the pool before trying fresh Workers with the same prepared model and dtype; explicit devices never fall back. STT capacity only accepts 1; TTS accepts 1 through 4. Execution snapshots expose requestedDevice, selectedDevice, maxConcurrentRequests, and activeRequestCount for both roles; selectedDevice is null while unloaded and names the successful upstream session backend request when loaded. It does not prove every operation used the physical NPU: WebNN unsupported operations may use WASM, and compatibility depends on the browser, driver, hardware, and graph.",
|
|
33
33
|
"surface": "Browser-speech protocol/event/error/reason constants; default `AI`; read-only `providerRuntime`, `browserSpeechConfiguration`, and `browserSpeechDescriptor`; explicit `providerRuntime.status(role,{execution:true})` snapshots; `streamTTS(text,end,options={})` with optional voice, speed, pauseAfterMs, and waitForPlayback; automatic speech-input cleanup with SDK-internal preparation metadata; `prepareTTS({parts,storage,identity,signal,onState})` returning ordered segments, state, ready, getAudio(index), and cancel(); `playPreparedTTS(prepared,{signal,onState})` returning state, error, finished, pause(), resume(), and stop() with one playback lane per AI and observational state/error callbacks; configure/dispose speech, route lifecycle, declaration-validated chat/stream requests with exact ordered calls, synthesis/transcription, and playback controls; initializes from current canonical user readiness, installs `window.ai`, and projects `ai-ready` plus active-generation `ai-tts-failure`."
|
|
34
34
|
},
|
|
35
35
|
{
|
|
@@ -253,10 +253,15 @@ user activation intent exposed by the shared speech component.
|
|
|
253
253
|
`setSpeechMuted(false)` records the public unmuted state only after the selected
|
|
254
254
|
TTS route reaches ready; a failed load leaves the public state muted. In contrast,
|
|
255
255
|
`setSpeechMuted(true)` cancels active TTS work and unloads that role.
|
|
256
|
-
The optional browser-speech `tts.execution`
|
|
257
|
-
`device:'auto'|'webgpu'|'wasm'` and
|
|
258
|
-
|
|
259
|
-
Worker/session slots
|
|
256
|
+
The optional browser-speech `stt.execution` and `tts.execution` records select
|
|
257
|
+
`device:'auto'|'webnn-npu'|'webgpu'|'wasm'` and `maxConcurrentRequests`.
|
|
258
|
+
Omission uses NPU, GPU, then CPU automatic selection with one Whisper slot and
|
|
259
|
+
four bounded Kokoro Worker/session slots. Whisper accepts only capacity 1;
|
|
260
|
+
Kokoro accepts integers 1 through 4. Automatic loading attempts WebNN NPU when
|
|
261
|
+
`navigator.ml.createContext` is exposed, then WebGPU when `navigator.gpu` is
|
|
262
|
+
exposed, then WASM. A failed candidate is cleaned up before fresh Workers try
|
|
263
|
+
the next device with the same prepared model and dtype. Explicit device
|
|
264
|
+
selections report failure without falling back.
|
|
260
265
|
Capacity 4 means up to four segments synthesize at once. Segment 5 and later
|
|
261
266
|
wait in the SDK's FIFO queue; they are not dropped. Synthesis may finish out
|
|
262
267
|
of order, but playback waits for earlier segments and plays exact input order.
|
|
@@ -265,11 +270,15 @@ latency. This capacity does not establish physical GPU kernel overlap.
|
|
|
265
270
|
|
|
266
271
|
After configuration, explicitly inspect execution through
|
|
267
272
|
`ai.providerRuntime.status('tts', {execution:true}).execution`. When supplied
|
|
268
|
-
by the selected provider, this read returns its execution snapshot.
|
|
269
|
-
|
|
273
|
+
by the selected provider, this read returns its execution snapshot. Whisper and
|
|
274
|
+
Kokoro report `requestedDevice`, `selectedDevice`, `maxConcurrentRequests`, and
|
|
270
275
|
`activeRequestCount`. `selectedDevice` is `null` before load and after unload.
|
|
271
276
|
`requestedDevice === 'auto' && selectedDevice === 'wasm'` identifies automatic
|
|
272
|
-
WASM fallback after a successful load.
|
|
277
|
+
WASM fallback after a successful load. Read the same fields for Whisper with
|
|
278
|
+
`status('stt', {execution:true})`. The selected device is the backend requested
|
|
279
|
+
by a successful upstream session load, not proof that every operation ran on a
|
|
280
|
+
physical accelerator; WebNN may execute unsupported operations through WASM.
|
|
281
|
+
Calling `status()` without options keeps
|
|
273
282
|
the existing sticky lifecycle snapshot and does not inspect provider execution.
|
|
274
283
|
Provider inspection failures are surfaced to the caller.
|
|
275
284
|
`fetchTTS({model,voice,input,responseFormat,speed},signal,preparation={})` accepts the public
|
|
@@ -501,9 +510,11 @@ await ai.disposeBrowserSpeech({signal});
|
|
|
501
510
|
|
|
502
511
|
The record is a mutable plain data record with exactly
|
|
503
512
|
`{protocol,id,dbopfs,tableName?,stt?,tts?}` and at least one role. Each supplied
|
|
504
|
-
mutable STT role is exactly
|
|
505
|
-
`{providerId,
|
|
506
|
-
|
|
513
|
+
mutable STT or TTS role is exactly
|
|
514
|
+
`{providerId,graph,security?,offline,execution?}` or
|
|
515
|
+
`{providerId,model,runtime,security?,offline,execution?}`. The optional
|
|
516
|
+
`execution` record contains `{device,maxConcurrentRequests}` with the
|
|
517
|
+
role-specific defaults and capacities described above. The graph and
|
|
507
518
|
direct authority forms are mutually exclusive; `providerId` and `id` are nonblank exact strings,
|
|
508
519
|
`graph` is the role-matching graph returned by the SDK browser
|
|
509
520
|
speech artifact API, and `offline` is boolean. The direct form forwards its
|
|
@@ -526,8 +537,9 @@ or reproduce DBOPFS cache logic.
|
|
|
526
537
|
|
|
527
538
|
The returned descriptor is exactly `{protocol,configurationId,stt,tts}`; an
|
|
528
539
|
external, unmanaged role is `null`. A managed STT descriptor is
|
|
529
|
-
`{role:'stt',providerId,modelId,artifactGraphId?,offline}`; TTS adds
|
|
530
|
-
`defaultVoice
|
|
540
|
+
`{role:'stt',providerId,modelId,artifactGraphId?,offline,execution}`; TTS adds
|
|
541
|
+
`defaultVoice`. Both include the normalized `execution` record.
|
|
542
|
+
`artifactGraphId` is present only for the graph form.
|
|
531
543
|
`browserSpeechConfiguration` returns the exact caller-owned record when no
|
|
532
544
|
managed role is carried. After a partial replacement that carries another
|
|
533
545
|
managed role, it returns a mutable merged record with the replacement call's
|
|
@@ -764,16 +776,19 @@ never loads or downloads a model. `load()` forwards provider progress into the
|
|
|
764
776
|
sticky role record; `unload()` and `dispose()` abort owned work, await exposed
|
|
765
777
|
settlement, and verify provider status before publishing terminal state.
|
|
766
778
|
|
|
767
|
-
`status('
|
|
768
|
-
adds its optional `execution` snapshot to a
|
|
779
|
+
`status('stt', {execution:true})` or `status('tts', {execution:true})` explicitly
|
|
780
|
+
reads the selected provider and adds its optional `execution` snapshot to a
|
|
781
|
+
copy of the role record.
|
|
769
782
|
`status(null, {execution:true})` provides the equivalent projection under
|
|
770
783
|
`roles.llm`, `roles.stt`, and `roles.tts`. Providers that do not supply execution
|
|
771
784
|
omit that field. No provider load or sticky-state event is triggered; default
|
|
772
785
|
`status()` keeps its existing identity and behavior. A provider inspection
|
|
773
|
-
error propagates. Kokoro
|
|
786
|
+
error propagates. Each Whisper or Kokoro execution report contains `requestedDevice`,
|
|
774
787
|
`selectedDevice` (`null` while unloaded), `maxConcurrentRequests`, and
|
|
775
|
-
`activeRequestCount
|
|
776
|
-
|
|
788
|
+
`activeRequestCount`. The selected device names the backend requested by a
|
|
789
|
+
successful upstream session load. It does not prove that every operation ran
|
|
790
|
+
on a physical NPU or GPU, or that accelerator kernels overlap; WebNN may use
|
|
791
|
+
WASM for unsupported operations.
|
|
777
792
|
|
|
778
793
|
`validateSpeechConfiguration(value)` returns one mutable two-role selection
|
|
779
794
|
record without committing it, where `value` is the closed `{stt,tts}` record.
|
|
@@ -6437,8 +6437,10 @@ createBrowserWhisperProvider(options={})
|
|
|
6437
6437
|
|
|
6438
6438
|
The recognized options are
|
|
6439
6439
|
`{id='arcane-browser-whisper',localOnly=true,graph,model,runtime,appSecurity,
|
|
6440
|
-
security,store,offline=false}`.
|
|
6441
|
-
`
|
|
6440
|
+
security,store,offline=false,execution={device:'auto',maxConcurrentRequests:1}}`.
|
|
6441
|
+
`execution.device` is `auto`, `webnn-npu`, `webgpu`, or `wasm`; its capacity is
|
|
6442
|
+
exactly 1. `graph` is mutually exclusive with `model` and `runtime`. The mutable
|
|
6443
|
+
result is
|
|
6442
6444
|
`{protocol:'arcane-ai-provider/2',role:'stt',id,localOnly:true,
|
|
6443
6445
|
maxConcurrentRequests:1,catalog,inspect,status,load,request,unload,dispose}`.
|
|
6444
6446
|
The only request operation is
|
|
@@ -6448,8 +6450,22 @@ re-freeze the cloned record.
|
|
|
6448
6450
|
|
|
6449
6451
|
`status()` returns
|
|
6450
6452
|
`{role,providerId,modelId,state,lifecycleStatus,lifecycleReason,activeOperation,
|
|
6451
|
-
loaded,busy,generation,errorCode,cache,warnings}` and includes
|
|
6452
|
-
for an explicit secure intent.
|
|
6453
|
+
loaded,busy,generation,errorCode,cache,warnings,execution}` and includes
|
|
6454
|
+
`security` only for an explicit secure intent.
|
|
6455
|
+
`execution` reports `requestedDevice`, `selectedDevice`,
|
|
6456
|
+
`maxConcurrentRequests`, and `activeRequestCount`; `selectedDevice` is `null`
|
|
6457
|
+
before load and after unload. The high-level projection is
|
|
6458
|
+
`ai.providerRuntime.status('stt', {execution:true}).execution`.
|
|
6459
|
+
|
|
6460
|
+
Automatic loading tries `webnn-npu` when `navigator.ml.createContext` is
|
|
6461
|
+
exposed, then `webgpu` when `navigator.gpu` is exposed, then CPU through `wasm`.
|
|
6462
|
+
A failed candidate is cleaned up before a fresh Worker tries the next backend
|
|
6463
|
+
with the same prepared model and dtype. Explicit selections do not fall back.
|
|
6464
|
+
`selectedDevice` names the backend requested by the successful upstream session
|
|
6465
|
+
load; it does not prove that every operation ran on a physical accelerator.
|
|
6466
|
+
WebNN may execute unsupported operations through WASM, and exact model,
|
|
6467
|
+
browser, driver, and hardware compatibility remains upstream.
|
|
6468
|
+
|
|
6453
6469
|
States are `unloaded`, `loading`, `ready`, `unloading`, `error`, and
|
|
6454
6470
|
`disposed`. Compatible concurrent loads coalesce; concurrent requests fail as
|
|
6455
6471
|
`ARCANE_AI_PROVIDER_BUSY`. Cancellation after the Worker request begins
|
|
@@ -6523,8 +6539,8 @@ createBrowserKokoroProvider(options={})
|
|
|
6523
6539
|
The recognized options are
|
|
6524
6540
|
`{id='arcane-browser-kokoro',localOnly=true,graph,model,runtime,appSecurity,
|
|
6525
6541
|
security,store,offline=false,execution={device:'auto',maxConcurrentRequests:4}}`.
|
|
6526
|
-
`execution.device` is `auto`, `webgpu`, or `wasm`; its capacity is
|
|
6527
|
-
from 1 through 4. `graph` is mutually exclusive with `model` and `runtime`. The
|
|
6542
|
+
`execution.device` is `auto`, `webnn-npu`, `webgpu`, or `wasm`; its capacity is
|
|
6543
|
+
an integer from 1 through 4. `graph` is mutually exclusive with `model` and `runtime`. The
|
|
6528
6544
|
mutable result is
|
|
6529
6545
|
`{protocol:'arcane-ai-provider/2',role:'tts',id,localOnly:true,
|
|
6530
6546
|
maxConcurrentRequests,catalog,inspect,status,load,request,unload,dispose}`. The
|
|
@@ -6543,10 +6559,11 @@ mono 16-bit PCM. Unsupported formats fail
|
|
|
6543
6559
|
`ARCANE_AI_UNSUPPORTED_RESPONSE_FORMAT`; malformed adapter audio fails
|
|
6544
6560
|
`ARCANE_AI_INVALID_PROVIDER_RESULT`. Unknown fields and accessors reject as malformed.
|
|
6545
6561
|
|
|
6546
|
-
Automatic execution
|
|
6547
|
-
|
|
6548
|
-
|
|
6549
|
-
|
|
6562
|
+
Automatic execution tries a complete WebNN NPU pool when
|
|
6563
|
+
`navigator.ml.createContext` is exposed, then WebGPU when `navigator.gpu` is
|
|
6564
|
+
exposed, then CPU through WASM. A failed candidate is cleaned up before a fresh
|
|
6565
|
+
pool tries the next backend. Explicit device selections do not fall back. Every
|
|
6566
|
+
slot loads the same caller-selected model and dtype in a distinct Worker so the
|
|
6550
6567
|
selected adapter's per-isolate inference serialization does not serialize the
|
|
6551
6568
|
pool. Direct provider `status().execution` reports `requestedDevice`,
|
|
6552
6569
|
`selectedDevice`, `maxConcurrentRequests`, and `activeRequestCount`.
|
|
@@ -6555,7 +6572,11 @@ by `AI.configureBrowserSpeech()`, use
|
|
|
6555
6572
|
`ai.providerRuntime.status('tts', {execution:true}).execution`. Requested
|
|
6556
6573
|
`auto` with selected `wasm` means automatic fallback occurred. Inspection
|
|
6557
6574
|
errors propagate; the default runtime `status()` remains a sticky lifecycle
|
|
6558
|
-
read.
|
|
6575
|
+
read. `selectedDevice` names the backend requested by the successful upstream
|
|
6576
|
+
session load. These fields do not prove that every operation ran on a physical
|
|
6577
|
+
NPU or GPU, or that accelerator kernels overlap. WebNN may execute unsupported
|
|
6578
|
+
operations through WASM; exact model, browser, driver, and hardware
|
|
6579
|
+
compatibility remains upstream.
|
|
6559
6580
|
|
|
6560
6581
|
Capacity 4 means up to four segments synthesize at once. Segment 5 and later
|
|
6561
6582
|
wait in the SDK's provider-neutral FIFO queue; they are not dropped. Synthesis
|
|
@@ -6,13 +6,15 @@ For the smallest first request, start with the [browser speech quick start](../.
|
|
|
6
6
|
|
|
7
7
|
## Speech defaults and inspection
|
|
8
8
|
|
|
9
|
-
The demo
|
|
9
|
+
The demo omits both speech execution records in `speechConfiguration(dbopfs)` so it consumes the SDK defaults: `{device:'auto',maxConcurrentRequests:1}` for Whisper STT and `{device:'auto',maxConcurrentRequests:4}` for Kokoro TTS. Auto tries WebNN NPU when `navigator.ml.createContext` is exposed, then WebGPU when `navigator.gpu` is exposed, then CPU through WASM. Failed loads are cleaned up before fresh Workers try the next device with the same prepared model and dtype. Explicit `webnn-npu`, `webgpu`, and `wasm` selections do not fall back.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Change the application's TTS record to `execution:{device:'wasm',maxConcurrentRequests:1}` to use fewer synthesis sessions. TTS accepts capacities 1 through 4; Whisper accepts only 1 and the LLM retains capacity one.
|
|
12
|
+
|
|
13
|
+
The maintained Kokoro selection uses `fp32` because [Kokoro.js recommends `fp32` when using WebGPU](https://github.com/hexgrad/kokoro/tree/main/kokoro.js#usage). Automatic fallback keeps that dtype on every device. `selectedDevice` identifies the backend requested by the successful upstream session load. It does not prove every operation ran on a physical NPU or GPU; WebNN may use WASM for unsupported operations. Exact model, browser, driver, and hardware compatibility and actual speech quality require evaluation on that combination.
|
|
12
14
|
|
|
13
15
|
Capacity 4 means up to four segments synthesize at once. Segment 5 and later wait in the SDK's FIFO queue; they are not dropped. Synthesis may finish out of order, but playback waits for earlier segments and plays exact input order. Each slot owns a Worker/model session, so raising capacity trades memory for latency.
|
|
14
16
|
|
|
15
|
-
Use the shared Speech component to load/unmute speech, then select **Inspect speech execution** below Chat. The button reads `ai.providerRuntime.status('tts',{execution:true}).execution` and displays requested device, selected device, capacity, active requests, and automatic WASM fallback. It is a snapshot at the moment clicked. `selectedDevice:null` means no pool is selected
|
|
17
|
+
Use the shared Speech component to load/unmute speech, then select **Inspect speech execution** below Chat. The button reads `ai.providerRuntime.status('tts',{execution:true}).execution` and displays requested device, selected device, capacity, active requests, and automatic WASM fallback. It is a snapshot at the moment clicked. `selectedDevice:null` means no pool is selected. The corresponding Whisper report is available through `ai.providerRuntime.status('stt',{execution:true}).execution`; reading either report does not load a model.
|
|
16
18
|
|
|
17
19
|
Shared Speech still owns mute, stop, and voice controls. The example's status button inspects the public report without loading a model or reaching into private providers.
|
|
18
20
|
|
|
@@ -434,7 +434,7 @@ function speechConfiguration(dbopfs) {
|
|
|
434
434
|
offline: false,
|
|
435
435
|
},
|
|
436
436
|
tts: {
|
|
437
|
-
// Omit execution
|
|
437
|
+
// Omit execution for NPU, GPU, then CPU selection and four synthesis slots.
|
|
438
438
|
// Kokoro.js recommends fp32 for the WebGPU route attempted by auto.
|
|
439
439
|
providerId: "wasm-ai-demo-browser-kokoro",
|
|
440
440
|
model: {
|
package/package.json
CHANGED
|
@@ -2039,6 +2039,20 @@
|
|
|
2039
2039
|
}
|
|
2040
2040
|
|
|
2041
2041
|
function determinateProgress(){
|
|
2042
|
+
if(role?.progress?.phase==='initialize'){
|
|
2043
|
+
const {completed,total,unit}=role.progress;
|
|
2044
|
+
if(
|
|
2045
|
+
!is.finite(total)
|
|
2046
|
+
||total<=0
|
|
2047
|
+
||!is.finite(completed)
|
|
2048
|
+
||completed<0
|
|
2049
|
+
||!is.string(unit)
|
|
2050
|
+
||!unit
|
|
2051
|
+
){
|
|
2052
|
+
return null;
|
|
2053
|
+
}
|
|
2054
|
+
return {completed,total,unit,byteTelemetry:false};
|
|
2055
|
+
}
|
|
2042
2056
|
const totalBytes=role?.progress?.totalBytes;
|
|
2043
2057
|
const loadedBytes=role?.progress?.loadedBytes;
|
|
2044
2058
|
if(
|
|
@@ -2160,8 +2174,10 @@
|
|
|
2160
2174
|
return `${active} of ${limit} ${noun}${limit===1?'':'s'} active`;
|
|
2161
2175
|
}
|
|
2162
2176
|
|
|
2163
|
-
function progressElapsedText(
|
|
2164
|
-
|
|
2177
|
+
function progressElapsedText(
|
|
2178
|
+
elapsedMilliseconds,
|
|
2179
|
+
suffix='elapsed'
|
|
2180
|
+
){
|
|
2165
2181
|
if(!is.finite(elapsedMilliseconds)||elapsedMilliseconds<0){
|
|
2166
2182
|
return '';
|
|
2167
2183
|
}
|
|
@@ -2170,12 +2186,12 @@
|
|
|
2170
2186
|
const minutes=Math.floor(elapsedSeconds%3600/60);
|
|
2171
2187
|
const seconds=elapsedSeconds%60;
|
|
2172
2188
|
if(hours>0){
|
|
2173
|
-
return `${hours}h ${minutes}m
|
|
2189
|
+
return `${hours}h ${minutes}m ${suffix}`;
|
|
2174
2190
|
}
|
|
2175
2191
|
if(minutes>0){
|
|
2176
|
-
return `${minutes}m ${String(seconds).padStart(2,'0')}s
|
|
2192
|
+
return `${minutes}m ${String(seconds).padStart(2,'0')}s ${suffix}`;
|
|
2177
2193
|
}
|
|
2178
|
-
return `${seconds}s
|
|
2194
|
+
return `${seconds}s ${suffix}`;
|
|
2179
2195
|
}
|
|
2180
2196
|
|
|
2181
2197
|
function progressMessage(){
|
|
@@ -2183,7 +2199,14 @@
|
|
|
2183
2199
|
const phaseValue=is.string(role?.progress?.phase)&&role.progress.phase
|
|
2184
2200
|
?role.progress.phase
|
|
2185
2201
|
:'loading';
|
|
2186
|
-
const
|
|
2202
|
+
const initializing=phaseValue==='initialize';
|
|
2203
|
+
const phase=initializing
|
|
2204
|
+
?is.string(role?.progress?.message)&&role.progress.message
|
|
2205
|
+
?role.progress.message
|
|
2206
|
+
:is.string(role?.progress?.stage)&&role.progress.stage
|
|
2207
|
+
?role.progress.stage.replaceAll('-',' ')
|
|
2208
|
+
:'Initializing the selected model'
|
|
2209
|
+
:phaseValue.replaceAll('-',' ');
|
|
2187
2210
|
const parts=[
|
|
2188
2211
|
`Loading ${modelDisplayName()} through the Arcane SDK`,
|
|
2189
2212
|
phase
|
|
@@ -2211,18 +2234,38 @@
|
|
|
2211
2234
|
:measured.unit;
|
|
2212
2235
|
parts.push(`${measured.completed} of ${measured.total} ${suffix}`);
|
|
2213
2236
|
}
|
|
2214
|
-
const transfers=activeTransferText();
|
|
2237
|
+
const transfers=initializing?'':activeTransferText();
|
|
2215
2238
|
if(transfers){
|
|
2216
2239
|
parts.push(transfers);
|
|
2217
2240
|
}
|
|
2218
|
-
const elapsed=progressElapsedText();
|
|
2241
|
+
const elapsed=progressElapsedText(Number(role?.progress?.elapsedMs));
|
|
2219
2242
|
if(elapsed){
|
|
2220
2243
|
parts.push(elapsed);
|
|
2221
|
-
if(role?.progress?.heartbeat===true){
|
|
2244
|
+
if(!initializing&&role?.progress?.heartbeat===true){
|
|
2222
2245
|
parts.push('Still working');
|
|
2223
2246
|
}
|
|
2224
2247
|
}
|
|
2225
|
-
if(
|
|
2248
|
+
if(initializing){
|
|
2249
|
+
const phaseElapsed=progressElapsedText(
|
|
2250
|
+
role.progress.phaseElapsedMs,
|
|
2251
|
+
'in this stage'
|
|
2252
|
+
);
|
|
2253
|
+
if(phaseElapsed){
|
|
2254
|
+
parts.push(phaseElapsed);
|
|
2255
|
+
}
|
|
2256
|
+
if(role.progress.heartbeat===true){
|
|
2257
|
+
const activityElapsed=progressElapsedText(
|
|
2258
|
+
role.progress.activityElapsedMs,
|
|
2259
|
+
'ago'
|
|
2260
|
+
);
|
|
2261
|
+
if(activityElapsed){
|
|
2262
|
+
parts.push(`Last runtime activity ${activityElapsed}`);
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
parts.push(measured
|
|
2266
|
+
?'Keep this tab open'
|
|
2267
|
+
:'Completion percentage unavailable; keep this tab open');
|
|
2268
|
+
}else if(!measured?.byteTelemetry){
|
|
2226
2269
|
parts.push('The first activation can take several minutes; keep this tab open');
|
|
2227
2270
|
}
|
|
2228
2271
|
return parts.join(' · ');
|