arcane-os 0.10.1 → 0.11.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0
4
+
5
+ - Add explicit browser chat CPU selection with `loadDefaults:{gpuLayers:0}`
6
+ or `load({gpuLayers:0})`. Use the packaged Wllama CPU path without WebGPU
7
+ requirements or adapter initialization. Omission retains full GPU offload;
8
+ model sources, cache, complete responses, streaming, and cancellation remain
9
+ on their existing owners.
10
+ - On mobile browsers and tablets, keep shared Speech focused on voice output:
11
+ hide and disable transcription activation and recording, omit transcription
12
+ status/progress, and retain mute/unmute. Chat retains End. Desktop speech and
13
+ the dedicated transcription component retain their existing behavior.
14
+
3
15
  ## 0.10.1
4
16
 
5
17
  - Open developer error modals only for newly captured live incidents. Restoring
package/README.md CHANGED
@@ -19,7 +19,7 @@ version-locked SDK runtime, while an integrated Arcane checkout uses its live
19
19
  `arcane/` runtime. Both profiles preserve the same app URLs, theme, packaging,
20
20
  event, cancellation, and browser run contracts.
21
21
 
22
- This checkout defines the `0.10.1` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.11.0` SDK contract. Applications pin one exact npm
23
23
  version and lockfile; registry state is deliberately not baked into application
24
24
  artifacts.
25
25
 
@@ -41,7 +41,7 @@ Create one browser application, install its pinned SDK, and start its source
41
41
  server:
42
42
 
43
43
  ```bash
44
- npx arcane-os@0.10.1 new hello-speech --path ./hello-speech --target browser
44
+ npx arcane-os@0.11.0 new hello-speech --path ./hello-speech --target browser
45
45
  cd hello-speech
46
46
  npm install
47
47
  npm run dev
@@ -392,7 +392,7 @@ uses the same controller for automatic memory extraction.
392
392
  Create a new repository-shaped Arcane application with the exact stable SDK:
393
393
 
394
394
  ```bash
395
- npx arcane-os@0.10.1 new my-app --path ./my-app --target portable --git
395
+ npx arcane-os@0.11.0 new my-app --path ./my-app --target portable --git
396
396
  cd my-app
397
397
  npm install
398
398
  npm run dev
@@ -402,7 +402,7 @@ To enroll an existing repository, install the exact SDK and initialize only
402
402
  missing Arcane files:
403
403
 
404
404
  ```bash
405
- npm install --save-dev --save-exact arcane-os@0.10.1
405
+ npm install --save-dev --save-exact arcane-os@0.11.0
406
406
  npm exec -- arcane init my-app --target portable
407
407
  ```
408
408
 
@@ -418,7 +418,7 @@ npm exec -- arcane-os targets
418
418
  No global SDK install or standalone Arcane CLI is required. The application
419
419
  repository's exact npm dependency and lockfile own the CLI and toolchain version.
420
420
 
421
- Use `npx arcane-os@0.10.1` for the initial bootstrap because it names this npm
421
+ Use `npx arcane-os@0.11.0` for the initial bootstrap because it names this npm
422
422
  package explicitly; bare `npx arcane` outside an installed project could resolve
423
423
  a different package. Both installed commands invoke the same headless toolchain.
424
424
  Project-local npm scripts use the SDK pinned by that app's `package-lock.json`,
@@ -438,7 +438,7 @@ node ./bin/arcane.mjs new local-app --path ../local-app --target portable --git
438
438
 
439
439
  # From the generated app repository
440
440
  cd ../local-app
441
- npm install --save-dev --save-exact ../arcane-os-sdk/arcane-os-0.10.1.tgz
441
+ npm install --save-dev --save-exact ../arcane-os-sdk/arcane-os-0.11.0.tgz
442
442
  npm ci
443
443
  ```
444
444
 
@@ -447,7 +447,7 @@ same location. The lockfile retains the selected package dependency while
447
447
  Arcane uses the installed package name and version. Local directory `file:` dependencies are not
448
448
  accepted because npm may install them as links; use a packed `.tgz`. A GitHub
449
449
  runner also needs that tarball at the locked path. After publication, replace
450
- the local declaration with the exact `arcane-os@0.10.1` registry package and
450
+ the local declaration with the exact `arcane-os@0.11.0` registry package and
451
451
  commit the regenerated lock.
452
452
 
453
453
  Generated repositories use `npm ci --ignore-scripts` in CI. Run dependency
@@ -586,7 +586,7 @@ package installation, or assertions.
586
586
 
587
587
  ## Current target support
588
588
 
589
- Version `0.10.1` exposes one browser target and five explicitly paired
589
+ Version `0.11.0` exposes one browser target and five explicitly paired
590
590
  native development targets: a non-runnable portable directory, a
591
591
  Windows x64 unsigned-local-test EXE bundle, Linux x64 and Linux ARM64
592
592
  unsigned-local-test DEBs, and an Android development-signed APK. The
@@ -2605,7 +2605,7 @@ function capabilityLoadPlan(runtimeCapabilities, defaults, options = {}) {
2605
2605
  contextTokens,
2606
2606
  batchTokens,
2607
2607
  microBatchTokens,
2608
- gpuLayers: 99_999,
2608
+ gpuLayers: configured.gpuLayers === 0 ? 0 : 99_999,
2609
2609
  ...(reasoning!==undefined?{ reasoning }:{}),
2610
2610
  ...(chatTemplate!==undefined?{ chatTemplate }:{}),
2611
2611
  ...(jinja!==undefined?{ jinja }:{}),
@@ -2625,14 +2625,20 @@ function sameLoadPlan(left, right) {
2625
2625
  && JSON.stringify(left?.templateDefaults) === JSON.stringify(right?.templateDefaults);
2626
2626
  }
2627
2627
 
2628
- function stableModelFailure(error) {
2628
+ function stableModelFailure(error, loadPlan) {
2629
2629
  const code = is.string(error?.code) ? error.code : "";
2630
2630
  const message = is.string(error?.message) ? error.message : "";
2631
2631
  if (code === "ARCANE_AI_MODEL_SHARD_TOO_LARGE") {
2632
2632
  return completeValue({ code });
2633
2633
  }
2634
2634
  if (/(?:out of memory|allocation failed|failed to allocate|memory exhausted)/iu.test(message)) {
2635
- return completeValue({ code: "ARCANE_AI_MODEL_GPU_MEMORY_INSUFFICIENT" });
2635
+ return completeValue(
2636
+ {
2637
+ code: loadPlan.gpuLayers === 0
2638
+ ? 'ARCANE_AI_MODEL_CPU_MEMORY_INSUFFICIENT'
2639
+ : 'ARCANE_AI_MODEL_GPU_MEMORY_INSUFFICIENT'
2640
+ }
2641
+ );
2636
2642
  }
2637
2643
  if (code === "ARCANE_AI_WEBGPU_EVIDENCE_INVALID") {
2638
2644
  return completeValue({ code: "ARCANE_AI_MODEL_FULL_OFFLOAD_UNPROVEN" });
@@ -2667,10 +2673,10 @@ function capabilityPolicy(
2667
2673
  if (runtimeCapabilities.secureContext !== true) {
2668
2674
  add("ARCANE_AI_SECURE_CONTEXT_REQUIRED", "incompatible");
2669
2675
  }
2670
- if (runtimeCapabilities.webgpuApiPresent !== true) {
2676
+ if (loadPlan.gpuLayers > 0 && runtimeCapabilities.webgpuApiPresent !== true) {
2671
2677
  add("ARCANE_AI_WEBGPU_API_UNAVAILABLE", "incompatible");
2672
2678
  }
2673
- if (failure) {
2679
+ if (failure && failure.gpuLayers === loadPlan.gpuLayers) {
2674
2680
  add(failure.code, "incompatible");
2675
2681
  }
2676
2682
  if (storage?.compatibility === "incompatible") {
@@ -2679,7 +2685,13 @@ function capabilityPolicy(
2679
2685
  add(storage?.code ?? "ARCANE_AI_STORAGE_NOT_MEASURED", "unknown");
2680
2686
  }
2681
2687
  const webgpu = runtimeEvidence?.webgpu;
2682
- if (state === "ready" && webgpu?.observed === true) {
2688
+ if (loadPlan.gpuLayers === 0) {
2689
+ const cpuLoaded = state === 'ready' && runtimeEvidence?.executionDevice === 'cpu';
2690
+ add(
2691
+ cpuLoaded ? 'ARCANE_AI_CPU_MODEL_LOADED' : 'ARCANE_AI_CPU_MODEL_NOT_LOADED',
2692
+ cpuLoaded ? 'compatible' : 'unknown',
2693
+ );
2694
+ } else if (state === "ready" && webgpu?.observed === true) {
2683
2695
  add(
2684
2696
  "ARCANE_AI_WEBGPU_EXECUTION_OBSERVED",
2685
2697
  "compatible",
@@ -2799,6 +2811,8 @@ export function createBrowserWasmLlmProvider({
2799
2811
  webgpu: runtimeCapabilities.webgpu,
2800
2812
  webgpuApiPresent: runtimeCapabilities.webgpuApiPresent,
2801
2813
  webgpuOperational: runtimeCapabilities.webgpuOperational,
2814
+ webgpuRequired: activeLoadPlan.gpuLayers > 0,
2815
+ executionDevice: activeLoadPlan.gpuLayers === 0 ? 'cpu' : 'webgpu',
2802
2816
  webgpuEvidenceProtocol: runtimeCapabilities.webgpuEvidenceProtocol,
2803
2817
  webgpuAdapterSelectionEvent: WEBGPU_ADAPTER_SELECTED_EVENT,
2804
2818
  crossOriginIsolated: runtimeCapabilities.crossOriginIsolated,
@@ -2920,7 +2934,7 @@ export function createBrowserWasmLlmProvider({
2920
2934
  if (!sameLoadPlan(activeLoadPlan, requestedLoadPlan)) {
2921
2935
  throw fail(
2922
2936
  "ARCANE_AI_LOAD_PLAN_RELOAD_REQUIRED",
2923
- "Unload the browser-WASM model before changing its context or batch load plan.",
2937
+ "Unload the browser-WASM model before changing its execution device, context, or batch load plan.",
2924
2938
  );
2925
2939
  }
2926
2940
  activeSecurity = effectiveSecurity;
@@ -2936,7 +2950,7 @@ export function createBrowserWasmLlmProvider({
2936
2950
  if (!sameLoadPlan(activeLoadPlan, requestedLoadPlan)) {
2937
2951
  throw fail(
2938
2952
  "ARCANE_AI_LOAD_PLAN_RELOAD_REQUIRED",
2939
- "The in-flight browser-WASM load uses a different context or batch plan.",
2953
+ "The in-flight browser-WASM load uses a different execution device, context, or batch plan.",
2940
2954
  );
2941
2955
  }
2942
2956
  activeSecurity = effectiveSecurity;
@@ -3082,8 +3096,13 @@ export function createBrowserWasmLlmProvider({
3082
3096
  operation: "load",
3083
3097
  signal: normalizationSignal(surfaced, signal),
3084
3098
  });
3085
- const modelFailure = stableModelFailure(normalized);
3086
- if (modelFailure) modelFailures.set(activeSource.id, modelFailure);
3099
+ const modelFailure = stableModelFailure(normalized, activeLoadPlan);
3100
+ if (modelFailure) {
3101
+ modelFailures.set(
3102
+ activeSource.id,
3103
+ {...modelFailure, gpuLayers: activeLoadPlan.gpuLayers}
3104
+ );
3105
+ }
3087
3106
  if (generation === lifecycleGeneration && state === "loading") {
3088
3107
  state = "error";
3089
3108
  errorState = completeValue({ code: normalized.code, message: normalized.message });
@@ -3196,6 +3215,10 @@ export function createBrowserWasmLlmProvider({
3196
3215
  state = "unloaded";
3197
3216
  errorState = null;
3198
3217
  activeSecurity = null;
3218
+ activeLoadPlan = capabilityLoadPlan(
3219
+ measuredRuntimeCapabilities(runtime.capabilities()),
3220
+ runtimeLoadDefaults,
3221
+ );
3199
3222
  return status();
3200
3223
  } catch (error) {
3201
3224
  const normalized = normalizeArcaneAIError(error, {
@@ -3394,7 +3417,7 @@ export function adaptV1LlmProvider(provider) {
3394
3417
  [capabilities?.webAssembly === true, "WebAssembly"],
3395
3418
  [capabilities?.opfs === true, "OPFS"],
3396
3419
  [capabilities?.secureContext === true, "a secure context"],
3397
- [capabilities?.webgpuApiPresent === true, "the WebGPU API"],
3420
+ [capabilities?.webgpuRequired === false || capabilities?.webgpuApiPresent === true, "the WebGPU API"],
3398
3421
  ];
3399
3422
  const missing = requirements.find(([available]) => !available)?.[1] ?? null;
3400
3423
  if (missing) {
@@ -20,7 +20,9 @@ export const BROWSER_WASM_RUNTIME_AUTHORITY = completeValue({
20
20
  protocol: "arcane-ai-browser-wasm/2",
21
21
  provider: "wllama",
22
22
  executionPolicy: {
23
- webgpuRequired: true,
23
+ webgpuRequired: false,
24
+ defaultGpuLayers: FULL_GPU_LAYERS,
25
+ cpuGpuLayers: 0,
24
26
  cpuFallback: false,
25
27
  cancellation: "abortSignal-plus-llama-cancel-acknowledgement",
26
28
  cleanup: "worker-termination-only",
@@ -71,6 +73,7 @@ function runtimeCapabilitySnapshot(evidence) {
71
73
  webgpu: webgpuOperational,
72
74
  webgpuApiPresent: Boolean(navigatorObject?.gpu),
73
75
  webgpuOperational,
76
+ executionDevice: evidence?.executionDevice ?? null,
74
77
  webgpuEvidenceProtocol: RUNTIME_EVIDENCE_PROTOCOL,
75
78
  crossOriginIsolated: globalThis.crossOriginIsolated === true,
76
79
  secureContext: globalThis.isSecureContext === true,
@@ -329,6 +332,7 @@ function initialEvidence() {
329
332
  return completeValue({
330
333
  protocol: RUNTIME_EVIDENCE_PROTOCOL,
331
334
  state: "unloaded",
335
+ executionDevice: null,
332
336
  webgpu: {
333
337
  observed: false,
334
338
  apiPresent: Boolean(globalThis.navigator?.gpu),
@@ -339,7 +343,8 @@ function initialEvidence() {
339
343
  }
340
344
 
341
345
  /**
342
- * Creates one packaged, WebGPU-required Wllama session. This factory has no
346
+ * Creates one packaged Wllama session. GPU loading is the default; gpuLayers: 0
347
+ * selects the upstream CPU path. This factory has no
343
348
  * network or browser side effects until load() is called. Runtime URLs are
344
349
  * fixed relative to this module for npm and materialized /arcane/sdk trees.
345
350
  */
@@ -473,7 +478,12 @@ export function createPackagedWllamaRuntime({ logger = arcaneLogging } = {}) {
473
478
  if (!is.object(globalThis.WebAssembly)) {
474
479
  throw new Error("WebAssembly is unavailable in this browser.");
475
480
  }
476
- if (!globalThis.navigator?.gpu) {
481
+ const gpuLayers = options.gpuLayers === 0
482
+ ? 0
483
+ : normalizePositiveInteger(options.gpuLayers, FULL_GPU_LAYERS);
484
+ const webgpuRequired = gpuLayers > 0;
485
+ const executionDevice = webgpuRequired ? 'webgpu' : 'cpu';
486
+ if (webgpuRequired && !globalThis.navigator?.gpu) {
477
487
  throw runtimeFailure(
478
488
  "ARCANE_AI_WEBGPU_REQUIRED",
479
489
  "A WebGPU API is required, but navigator presence alone will not establish operational execution.",
@@ -486,9 +496,6 @@ export function createPackagedWllamaRuntime({ logger = arcaneLogging } = {}) {
486
496
  const next = newEngine();
487
497
  const threads = normalizePositiveInteger(options.threads, 1);
488
498
  const contextTokens = normalizePositiveInteger(options.contextTokens, 4_096);
489
- const gpuLayers = options.gpuLayers === undefined
490
- ? FULL_GPU_LAYERS
491
- : normalizePositiveInteger(options.gpuLayers, FULL_GPU_LAYERS);
492
499
  const loadOptions = {
493
500
  n_threads: threads,
494
501
  n_ctx: contextTokens,
@@ -527,7 +534,8 @@ export function createPackagedWllamaRuntime({ logger = arcaneLogging } = {}) {
527
534
 
528
535
  publishEvidence({
529
536
  state: "loading",
530
- webgpu: { observed: false, apiPresent: true },
537
+ executionDevice,
538
+ webgpu: { observed: false, apiPresent: Boolean(globalThis.navigator?.gpu) },
531
539
  cancellation: null,
532
540
  cleanup: null,
533
541
  });
@@ -568,43 +576,45 @@ export function createPackagedWllamaRuntime({ logger = arcaneLogging } = {}) {
568
576
  );
569
577
  }
570
578
  let adapter = null;
571
- const telemetryOperation = trackOperation(
572
- Promise.resolve().then(
573
- function observeLoadedGpu() {
574
- return next.arcaneTelemetry();
579
+ if (webgpuRequired) {
580
+ const telemetryOperation = trackOperation(
581
+ Promise.resolve().then(
582
+ function observeLoadedGpu() {
583
+ return next.arcaneTelemetry();
584
+ }
585
+ )
586
+ );
587
+ function cancelGpuObservation() {
588
+ telemetryOperation.cancel(loadController.signal.reason);
589
+ }
590
+ loadController.signal.addEventListener(
591
+ 'abort',
592
+ cancelGpuObservation,
593
+ {once: true}
594
+ );
595
+ if (loadController.signal.aborted) cancelGpuObservation();
596
+ try {
597
+ // Observe the adapter this loaded Worker already selected; do not probe
598
+ // a second adapter in the page and attribute it to the model.
599
+ const telemetry = await telemetryOperation.result;
600
+ if (telemetry?.worker?.invalid === true) {
601
+ arcaneLogging.warn("The loaded model's GPU adapter observation is inconsistent.", telemetry.worker);
602
+ } else {
603
+ adapter = telemetry?.worker?.adapter ?? null;
575
604
  }
576
- )
577
- );
578
- function cancelGpuObservation() {
579
- telemetryOperation.cancel(loadController.signal.reason);
580
- }
581
- loadController.signal.addEventListener(
582
- 'abort',
583
- cancelGpuObservation,
584
- {once: true}
585
- );
586
- if (loadController.signal.aborted) cancelGpuObservation();
587
- try {
588
- // Observe the adapter this loaded Worker already selected; do not probe
589
- // a second adapter in the page and attribute it to the model.
590
- const telemetry = await telemetryOperation.result;
591
- if (telemetry?.worker?.invalid === true) {
592
- arcaneLogging.warn("The loaded model's GPU adapter observation is inconsistent.", telemetry.worker);
593
- } else {
594
- adapter = telemetry?.worker?.adapter ?? null;
605
+ } catch (error) {
606
+ if (loadController.signal.aborted || error instanceof WllamaRuntimeError) throw error;
607
+ arcaneLogging.warn("The loaded model's GPU adapter details are unavailable.", error);
608
+ } finally {
609
+ loadController.signal.removeEventListener('abort', cancelGpuObservation);
595
610
  }
596
- } catch (error) {
597
- if (loadController.signal.aborted || error instanceof WllamaRuntimeError) throw error;
598
- arcaneLogging.warn("The loaded model's GPU adapter details are unavailable.", error);
599
- } finally {
600
- loadController.signal.removeEventListener('abort', cancelGpuObservation);
601
611
  }
602
612
  if (progressFailure) throw progressFailure;
603
613
  if (loadController.signal.aborted) throw cancellationError(loadController.signal.reason);
604
614
  if (pending?.engine !== next) throw new Error('Wllama load was cancelled.');
605
615
  const webgpu = {
606
- observed: true,
607
- apiPresent: true,
616
+ observed: webgpuRequired,
617
+ apiPresent: Boolean(globalThis.navigator?.gpu),
608
618
  adapter
609
619
  };
610
620
  pending = null;
@@ -638,6 +648,7 @@ export function createPackagedWllamaRuntime({ logger = arcaneLogging } = {}) {
638
648
  contextTokens,
639
649
  threads,
640
650
  gpuLayers,
651
+ executionDevice,
641
652
  evidence: evidenceState,
642
653
  metadata: engine.getModelMetadata?.() ?? null,
643
654
  });
@@ -80,6 +80,19 @@ GPU request, model operation, listener, or viewport-dependent reload. Its
80
80
  dependency-free entrypoint does not import the inference runtime. Profile
81
81
  selection does not alter prompts, documents, history, or output limits.
82
82
 
83
+ ## CPU selection
84
+
85
+ Set `loadDefaults:{gpuLayers:0}` when creating the provider, or pass
86
+ `gpuLayers:0` to `ai.load()` or `provider.load()`, to run the selected model on
87
+ CPU through the packaged Wllama WebAssembly runtime. CPU selection skips
88
+ WebGPU capability checks, adapter initialization, and GPU execution reporting.
89
+ It uses the same complete model sources, cache, request, streaming, and
90
+ cancellation contracts. Existing context, batch, micro-batch, and thread
91
+ settings remain independently configurable.
92
+
93
+ Omitting `gpuLayers` preserves full GPU offload (`99999`). CPU is an explicit
94
+ selection; a failed GPU load does not silently switch to CPU.
95
+
83
96
  ## Lifecycle at a glance
84
97
 
85
98
  `createArcaneAI()` owns one LLM controller. Its default `loadPolicy` is
@@ -206,15 +219,16 @@ Applications remain responsible for model selection and license compliance.
206
219
  cannot download. Source downloads use CORS, omit credentials and referrer,
207
220
  disable HTTP caching, and honor `AbortSignal`.
208
221
 
209
- After a model loads, its existing Worker reports the adapter it selected.
222
+ After a GPU model loads, its existing Worker reports the adapter it selected.
210
223
  The runtime retains that adapter in `evidence().webgpu.adapter`, including
211
224
  optional browser-reported `type` and `isFallbackAdapter` fields. This reads
212
225
  the loaded Worker's metadata once; it does not request another adapter.
213
226
  Missing fields remain unknown. Cancellation also cancels the metadata wait;
214
227
  an unavailable observation is logged without rejecting an otherwise loaded model.
215
228
 
216
- On Windows desktop Chromium browsers, an explicitly reported integrated GPU,
217
- CPU, or fallback adapter triggers one GPU-only browser alert per page session.
229
+ For GPU loads on Windows desktop Chromium browsers, an explicitly reported
230
+ integrated GPU, CPU, or fallback adapter triggers one GPU-only browser alert
231
+ per page session.
218
232
  The alert names the selected adapter and **Force High Performance GPU**
219
233
  (`#force-high-performance-gpu`), conditional on the computer also having a
220
234
  discrete GPU. It contains the address to paste manually and asks the user to
@@ -278,9 +292,10 @@ serialized; provider status exposes `busy` and `queued`. Supported request
278
292
  generation fields include temperature, top-K, top-P, min-P, repeat penalty,
279
293
  and seed. Load settings separately include
280
294
  `contextTokens`, `batchTokens`, `microBatchTokens`, `threads`, and GPU-layer
281
- count. The shipped runtime always sets `gpuLayers: 99999`: WebGPU and proved
282
- full offload are mandatory, and there is no CPU fallback. Secure context and
283
- WebGPU/full-offload availability remain browser platform requirements;
295
+ count. The shipped runtime defaults to `gpuLayers:99999` for full GPU offload;
296
+ `gpuLayers:0` explicitly selects CPU. WebGPU/full-offload availability applies
297
+ only to the GPU route, which has no automatic CPU fallback. Secure context
298
+ remains a browser platform requirement;
284
299
  cross-origin isolation and coarse hardware fields remain observations.
285
300
 
286
301
  Tool definitions, tool choice, parallel-tool-call preference, and JSON or JSON
@@ -528,11 +543,11 @@ intent. They do not activate checking in the ordinary development contract.
528
543
 
529
544
  ### Availability and normalization
530
545
 
531
- **Browser secure context with WebAssembly, OPFS/DBOPFS, WebGPU, and full-offload
532
- support.** Inference is local after a successful Wllama load.
533
- The runtime forces `gpuLayers: 99999`; callers cannot select CPU or partial
534
- offload. Status discloses the optional `secure` intent, capability state,
535
- storage/model compatibility, and lifecycle state; it does not claim that
546
+ **Browser secure context with WebAssembly and OPFS/DBOPFS.** The default GPU
547
+ route additionally requires WebGPU and full-offload support; `gpuLayers:0`
548
+ selects CPU without those GPU requirements. Inference is local after a
549
+ successful Wllama load. Status discloses the optional `secure` intent,
550
+ capability state, storage/model compatibility, and lifecycle state; it does not claim that
536
551
  hardening ran.
537
552
 
538
553
  ### Example
@@ -70,7 +70,7 @@ version; WebKitGTK availability must not be generalized to macOS.
70
70
  | Publish application events or review a complete event history | `arcane-os/event-manager` | **Node** and **Browser**; optional DOM capture needs a browser DOM or compatible host | Live listeners receive original arguments. Ordinary `secure:false` recording preserves complete URLs, public details, and captured stack text in deeply frozen `arcane-event-stack/1` snapshots while credential-named fields remain redacted. The stack format is local diagnostic data, not a host transport. |
71
71
  | Build browser UI and app-local behavior | `/arcane/modules/*.js`, shared entities, and components | **Browser**; many modules also run inside every native renderer | Pure modules own their result contracts. Modules that call `Arcane` inherit the bridge boundary described below. |
72
72
  | Select and observe independent LLM/STT/TTS roles | `/arcane/modules/AIProviderRuntime.js` and `AIRuntimeState.js` | **Cross-host** controller/state; registered providers retain their own host requirements | Required/projected provider members, route/configuration records, and status fields; per-role lifecycle, cancellation, stream cleanup, sticky state, and startup barriers are normalized. `localOnly` creates no fallback. |
73
- | Run a caller-selected local LLM entirely in a browser renderer | `arcane-os/ai/browser-wasm` through `createArcaneAI()` | **Browser** only; secure context, WebAssembly, OPFS/DBOPFS, WebGPU, and requested full offload are required; no CPU fallback | The public AI API module normalizes multi-model lifecycle, status, complete all-choice streaming, cancellation, exact ordered structural tool-call visibility, and session persistence. Model sources are canonical ordered file descriptors; licenses and model choice remain application policy. |
73
+ | Run a caller-selected local LLM entirely in a browser renderer | `arcane-os/ai/browser-wasm` through `createArcaneAI()` | **Browser** only; secure context, WebAssembly, and OPFS/DBOPFS; full WebGPU offload by default or explicit CPU with `gpuLayers:0` | The public AI API module normalizes multi-model lifecycle, status, complete all-choice streaming, cancellation, exact ordered structural tool-call visibility, and session persistence. Model sources are canonical ordered file descriptors; licenses and model choice remain application policy. |
74
74
  | Run caller-selected Whisper or Kokoro in a browser renderer | `arcane-os/ai/browser-speech` registered with `AIProviderRuntime` | **Browser** only; DBOPFS, Web Locks, Workers, Fetch/object URLs, and a caller-supplied self-contained runtime/model closure are required | STT/TTS use independent provider/2 lifecycle and status. Kokoro adds bounded Worker/session concurrency and explicit `auto`, `webgpu`, or `wasm` execution. Complete model/runtime selection, offline behavior, cancellation, Worker teardown, and request/result shapes are normalized. No runtime/model content or cloud fallback is supplied. |
75
75
  | Prepare ordered speech playback or reuse the speech-input formatting filter | `arcane-os/speech-playback` and `arcane-os/speech-text` | **Node** with injected media adapters, or **Browser / Native WebView** media; the text filter itself is **Cross-host** | Stored and caller-owned text stays exact. Only the outbound synthesis copy automatically loses repeated same formatting marks. A capacity-advertising provider receives complete segments immediately while retaining indexed playback; native/custom synthesis stays serialized. |
76
76
  | Preserve complete chat history and memory | `/arcane/modules/PersistentAIChatSession.js` | **Browser / native WebView** with ChatEntity/DBOPFS and a configured chat function | Existing DBOPFS names and memory semantics are preserved. Live-context commit is atomic; durable persistence is explicit and coherent across user/assistant turns and atomic all-ID tool-result batches. |
@@ -133,9 +133,12 @@ surface does not require an Arcane Core method grant because it does not call a
133
133
  Core host. Browser Fetch, CORS, storage policy, secure-context behavior, and
134
134
  resource limits still apply.
135
135
 
136
- The current browser runtime requires WebGPU and has no CPU fallback. A successful
137
- load requests full GPU offload (`gpuLayers: 99999`). `navigator.gpu` presence by
138
- itself is not readiness. The provider emits the instrumented
136
+ The browser runtime defaults to full GPU offload (`gpuLayers:99999`). Explicit
137
+ `loadDefaults:{gpuLayers:0}` or `load({gpuLayers:0})` selects CPU and skips
138
+ WebGPU requirements and adapter work; GPU failure never selects CPU implicitly.
139
+ Both routes wait for Wllama to report the complete model loaded.
140
+ `navigator.gpu` presence by itself is not readiness. On the GPU route, the
141
+ provider emits the instrumented
139
142
  `arcane.ai.browser-wasm.webgpu.adapter.selected` capability event after adapter
140
143
  selection.
141
144
 
@@ -2932,7 +2932,7 @@
2932
2932
  "primaryImport": "arcane-os/ai/browser-wasm",
2933
2933
  "group": "Browser-WASM local AI",
2934
2934
  "summary": "Creates the packaged Wllama browser provider with complete serialized inference, streaming, abort, unload, and probe operations.",
2935
- "availability": "Browser context with WebAssembly, OPFS/DBOPFS, and WebGPU; no CPU fallback",
2935
+ "availability": "Browser context with WebAssembly and OPFS/DBOPFS; WebGPU by default or explicit CPU with gpuLayers:0",
2936
2936
  "protocol": "arcane-ai-adapter/1 with OpenAI-like chat completion and streaming envelopes",
2937
2937
  "normalization": "Mutable complete lifecycle/status records, declaration/history validation, atomic nonblank all-ID tool-result sequencing, complete all-choice nonstructural ordinary iterator projections, all-choice terminal validation, terminal-only structural-call acceptance, and exact structured tool-call data with required user-facing message and retained extension fields; the SDK never invokes application tools"
2938
2938
  },
@@ -355,10 +355,12 @@ does not silently delete the app-owned cache. A complete whole member supersedes
355
355
  its current resumable fragments. Cleanup failure is warned without hiding the
356
356
  usable model.
357
357
 
358
- SDK `0.5.17` requires WebGPU. Load requests full offload and waits for the runtime
359
- to report a loaded model. `navigator.gpu` presence alone is
360
- not readiness. There is no CPU fallback, partial-offload success mode, or
361
- silent switch to native/Core/cloud inference.
358
+ Browser-WASM loads default to full WebGPU offload. Explicit
359
+ `loadDefaults:{gpuLayers:0}` or `load({gpuLayers:0})` selects CPU through the
360
+ same packaged Wllama runtime without GPU initialization or GPU-only
361
+ requirements. Both routes wait for the runtime to report the complete model
362
+ loaded. `navigator.gpu` presence alone is not readiness. There is no automatic
363
+ CPU fallback or silent switch to native/Core/cloud inference.
362
364
 
363
365
  ### Browser speech lifecycle
364
366
 
@@ -556,6 +558,11 @@ model or selects a fallback. `speech.html` and `voice-transcription.html` consum
556
558
  one shared `createSTTActivationController()` contract for selected, unloaded,
557
559
  loading, unloading, error, and ready presentation plus cancelable user intent.
558
560
  Both keep capture unavailable until sticky STT state is exactly ready.
561
+ On mobile browsers, `speech.html` additionally omits transcription activation,
562
+ capture, and transcription status/progress while retaining its TTS control and
563
+ voice status. Its public activation callback returns `false` without dispatch.
564
+ This uses the SDK's browser-device classification, including tablets, and does
565
+ not change the dedicated `voice-transcription.html` workflow or provider APIs.
559
566
 
560
567
  Each shared speech component owns an `AbortController` for its STT request and
561
568
  passes its signal through `AI.fetchSTT()`. `voice-transcription.html` also adds
@@ -1163,8 +1163,17 @@ Shared dependencies: [`AI.js`](runtime-modules.md#aijs),
1163
1163
  [`ComponentContracts.js`](runtime-modules.md#componentcontractsjs), and
1164
1164
  [`DBLS.js`](runtime-modules.md#dblsjs).
1165
1165
 
1166
- The Hold to talk control remains disabled unless sticky STT state is `ready`,
1167
- the role is not busy, and microphone capture is available. For an explicitly
1166
+ On mobile browsers, including tablets, the shared browser-device classifier
1167
+ selects a Speech interface for voice output. Transcription activation,
1168
+ recording, sending recorded audio, and their status/progress are unavailable;
1169
+ `requestSTTActivation()` returns `false` without requesting a provider action.
1170
+ The keyboard remains the voice-input path. The existing TTS mute/unmute control
1171
+ and voice status remain available, and Chat retains its End control. Desktop
1172
+ speech and the dedicated `voice-transcription.html` component retain their
1173
+ existing behavior.
1174
+
1175
+ On desktop, the Hold to talk control remains disabled unless sticky STT state
1176
+ is `ready`, the role is not busy, and microphone capture is available. For an explicitly
1168
1177
  selected STT route, a separate keyboard-operable control presents Start
1169
1178
  transcription while `unloaded`, Cancel loading while `loading`, a disabled
1170
1179
  Canceling state while `unloading`, and Try again with the sticky error while
@@ -122,7 +122,7 @@ browser map are cataloged separately in [Runtime modules](runtime-modules.md).
122
122
  | `createBrowserModelSource()` | function | `arcane-os/ai/browser-wasm` | Browser-WASM local AI | Browser Fetch with a readable response body |
123
123
  | `createBrowserSpeechArtifactGraph()` | function | `arcane-os/ai/browser-speech` | Browser speech providers | Browser metadata; construction starts no fetch, cache, Worker, provider, or event operation |
124
124
  | `createBrowserSpeechAuthority()` | function | `arcane-os/ai/browser-speech` | Browser speech providers | Browser descriptor construction; use requires the selected storage and provider Web APIs |
125
- | `createBrowserWasmLlmProvider()` | function | `arcane-os/ai/browser-wasm` | Browser-WASM local AI | Browser context with WebAssembly, OPFS/DBOPFS, and WebGPU; no CPU fallback |
125
+ | `createBrowserWasmLlmProvider()` | function | `arcane-os/ai/browser-wasm` | Browser-WASM local AI | Browser context with WebAssembly and OPFS/DBOPFS; WebGPU by default or explicit CPU with gpuLayers:0 |
126
126
  | `createBrowserWhisperProvider()` | function | `arcane-os/ai/browser-speech` | Browser speech providers | Browser with Workers, object URLs, caller-selected Whisper runtime/model artifacts, and an SDK DBOPFS speech store |
127
127
  | `createCanonicalUstarHeader()` | function | `arcane-os` | Packaging and release bundles | Node |
128
128
  | `createDbopfsModelStore()` | function | `arcane-os/ai/browser-wasm` | Browser-WASM local AI | Browser with a ready DBOPFS instance and OPFS |
@@ -6112,8 +6112,9 @@ exposes `protocol`, `id`, default `model`, `catalog`, `capabilities`, `status`,
6112
6112
  Direct `load()` selects a catalog model and returns `{model,status}`; the
6113
6113
  public AI API module's `ai.load()` returns the flat controller status. Load settings include
6114
6114
  offline mode, `AbortSignal`, progress, threads, and context/batch/micro-batch
6115
- tokens. The runtime always forces `gpuLayers:99999`; callers cannot request CPU
6116
- or partial offload.
6115
+ tokens. `loadDefaults:{gpuLayers:0}` or `load({gpuLayers:0})` explicitly selects
6116
+ CPU. Omission retains full GPU offload (`gpuLayers:99999`); GPU failures never
6117
+ silently switch to CPU.
6117
6118
 
6118
6119
  Chat supports OpenAI-like message/generation fields, tools, tool choice,
6119
6120
  parallel tool-call preference, and JSON/JSON-Schema structured output.
@@ -6133,11 +6134,13 @@ turn.
6133
6134
 
6134
6135
  ### Availability and normalization
6135
6136
 
6136
- **Browser context with WebAssembly, OPFS/DBOPFS, and WebGPU.** A load succeeds
6137
- after Wllama confirms the complete model is loaded. There is no CPU fallback.
6137
+ **Browser context with WebAssembly and OPFS/DBOPFS.** The default GPU route
6138
+ also requires WebGPU; explicit `gpuLayers:0` CPU loading skips GPU requirements
6139
+ and adapter work. A load succeeds after Wllama confirms the complete model is
6140
+ loaded. There is no automatic CPU fallback.
6138
6141
  Cross-origin isolation and coarse hardware fields remain observations rather
6139
6142
  than hard gates. Ordinary status reports complete catalog compatibility and
6140
- lifecycle state. Adapter selection is instrumented as
6143
+ lifecycle state. GPU adapter selection is instrumented as
6141
6144
  `arcane.ai.browser-wasm.webgpu.adapter.selected`.
6142
6145
  Cancellation normalizes to `ARCANE_AI_REQUEST_ABORTED`; load or availability failures
6143
6146
  surface stable `ARCANE_AI_*` codes such as `ARCANE_AI_WEBGPU_REQUIRED`,
@@ -6278,8 +6281,8 @@ Re-adapting the same provider returns the same adapter object.
6278
6281
  ### Availability and normalization
6279
6282
 
6280
6283
  **Browser; the wrapped provider retains its own secure-context, WebAssembly,
6281
- OPFS/DBOPFS, WebGPU, model, and lifecycle requirements.** The adapter normalizes
6282
- provider/2 selection and lifecycle without adding Node, native, cloud, network,
6284
+ OPFS/DBOPFS, selected CPU or WebGPU route, model, and lifecycle requirements.**
6285
+ The adapter normalizes provider/2 selection and lifecycle without adding Node, native, cloud, network,
6283
6286
  or CPU fallback behavior.
6284
6287
 
6285
6288
  ### Example
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -68,7 +68,7 @@
68
68
  "test:release": "node ./bin/arcane-test.mjs test/npm-release.test.mjs",
69
69
  "test:unit": "node ./bin/arcane-test.mjs test/app-descriptor.test.mjs test/app-schema.test.mjs test/contracts.test.mjs test/doctor.test.mjs test/mail-credentials.test.mjs test/mail-outbox.test.mjs test/mail-public-api.test.mjs test/mail-send.test.mjs test/mail-transport.test.mjs test/targets.test.mjs test/workspace-operation-lock.test.mjs",
70
70
  "test:functional": "node ./bin/arcane-test.mjs test/browser-speech-providers.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/cli.test.mjs test/dbopfs-document-library.test.mjs test/dev-server.test.mjs test/dev-pwa.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/import-map.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/update-check.test.mjs",
71
- "test:integration": "node ./bin/arcane-test.mjs test/integrated-shared.test.mjs test/integrated-workspace.test.mjs test/mail-browser.test.mjs test/native-plan.test.mjs test/native-provider-loader.test.mjs test/npm-release.test.mjs test/release-bundle.test.mjs test/release-capability-smoke.test.mjs test/shared-payload-batch.test.mjs test/tarball.test.mjs test/wllama-webgpu-runtime.test.mjs",
71
+ "test:integration": "node ./bin/arcane-test.mjs test/integrated-shared.test.mjs test/integrated-workspace.test.mjs test/mail-browser.test.mjs test/native-plan.test.mjs test/native-provider-loader.test.mjs test/npm-release.test.mjs test/release-bundle.test.mjs test/release-capability-smoke.test.mjs test/shared-payload-batch.test.mjs test/tarball.test.mjs test/browser-wasm-cpu.test.mjs test/wllama-webgpu-runtime.test.mjs",
72
72
  "test:regression": "node ./bin/arcane-test.mjs test/channel-workflows.test.mjs test/html-import-registration.test.mjs test/logging-regression.test.mjs test/markdown-speech.test.mjs test/prepared-speech.test.mjs test/native-provider-generation.test.mjs test/speech-queue-regression.test.mjs test/testing.test.mjs test/test-sets.test.mjs",
73
73
  "check": "node tools/check-source.mjs && npm test",
74
74
  "check:release": "node tools/check-source.mjs --package-only && npm run test:release",
@@ -137,10 +137,24 @@
137
137
  grid-column: 3;
138
138
  }
139
139
  }
140
+
141
+ :host([data-device-class="mobile"]) {
142
+ grid-template-columns: minmax(0, 1fr) auto;
143
+ }
144
+
145
+ :host([data-device-class="mobile"]) .speech_feedback {
146
+ grid-row: 1;
147
+ grid-column: 1;
148
+ }
149
+
150
+ :host([data-device-class="mobile"]) #muteButton {
151
+ grid-row: 1;
152
+ grid-column: 2;
153
+ }
140
154
  </style>
141
155
 
142
156
  <p class="hidden">Will auto send after X seconds of silence set in code.</p>
143
- <button id="recordButton" type="button" aria-label="Transcription unavailable" disabled>Hold to talk</button>
157
+ <button id="recordButton" type="button" aria-label="Transcription unavailable" hidden disabled>Hold to talk</button>
144
158
  <button id="sttActivationButton" type="button" hidden disabled>Start transcription</button>
145
159
  <button id="stopButton" type="button" class="hidden" disabled>Send</button>
146
160
  <div class="speech_feedback">
@@ -155,6 +169,7 @@
155
169
  <button id="muteButton" type="button" class="mute" aria-pressed="true" aria-label="Voice unavailable" disabled>Voice unavailable</button>
156
170
 
157
171
  <script type="module">
172
+ const browserDeviceImport = import('arcane-os/browser-device');
158
173
  const {default:Is}=await import('strong-type');
159
174
  const is=new Is(false);
160
175
 
@@ -180,8 +195,12 @@
180
195
  createSTTActivationController,
181
196
  formatAIRuntimeProgress
182
197
  } = await import('../modules/ComponentContracts.js');
198
+ const {getBrowserDeviceClass} = await browserDeviceImport;
183
199
 
184
200
  const host = this;
201
+ const deviceClass = getBrowserDeviceClass();
202
+ const transcriptionEnabled = deviceClass !== 'mobile';
203
+ host.dataset.deviceClass = deviceClass;
185
204
  const events = createArcaneEventSource(
186
205
  host,
187
206
  {
@@ -326,28 +345,36 @@
326
345
  host.configure = configure;
327
346
  host.destroy = destroy;
328
347
  host.reportTTSError = reportTTSError;
329
- host.requestSTTActivation = host.requestSTTActivation
330
- || function requestSTTActivation(intent) {
331
- return requestAIRuntimeIntent(intent);
348
+ if (transcriptionEnabled) {
349
+ host.requestSTTActivation = host.requestSTTActivation
350
+ || function requestSTTActivation(intent) {
351
+ return requestAIRuntimeIntent(intent);
352
+ };
353
+ } else {
354
+ host.requestSTTActivation = function unavailableMobileSTTActivation() {
355
+ return false;
332
356
  };
357
+ }
333
358
  host.setMuted = setMuted;
334
359
  host.availability = {
335
- microphone: Boolean(
360
+ microphone: transcriptionEnabled && Boolean(
336
361
  navigator.mediaDevices?.getUserMedia
337
362
  && globalThis.MediaRecorder
338
363
  ),
339
364
  stt: false,
340
365
  tts: false
341
366
  };
342
- const sttActivationController = createSTTActivationController(
343
- {
344
- host,
345
- button: sttActivationButton,
346
- progress: sttActivationProgress,
347
- onChange: renderSTTActivationState,
348
- eventSource: events
349
- }
350
- );
367
+ const sttActivationController = transcriptionEnabled
368
+ ? createSTTActivationController(
369
+ {
370
+ host,
371
+ button: sttActivationButton,
372
+ progress: sttActivationProgress,
373
+ onChange: renderSTTActivationState,
374
+ eventSource: events
375
+ }
376
+ )
377
+ : null;
351
378
 
352
379
  const lifecycleListenerOptions = completeValue(
353
380
  { signal: runtimeStateAbortController.signal }
@@ -516,13 +543,13 @@
516
543
  }
517
544
 
518
545
  function applyRoleTransitions(previousSTTRole, previousTTSRole) {
519
- sttActivationController.synchronize(sttRole);
546
+ sttActivationController?.synchronize(sttRole);
520
547
  const sttBecameUnready = previousSTTRole.state === 'ready'
521
548
  && sttRole.state !== 'ready';
522
549
  const ttsBecameUnready = previousTTSRole.state === 'ready'
523
550
  && ttsRole.state !== 'ready';
524
551
 
525
- host.availability.stt = sttRole.state === 'ready';
552
+ host.availability.stt = transcriptionEnabled && sttRole.state === 'ready';
526
553
  host.availability.tts = ttsRole.state === 'ready';
527
554
 
528
555
  if (sttBecameUnready) {
@@ -571,6 +598,17 @@
571
598
  }
572
599
 
573
600
  function renderControls() {
601
+ if (!transcriptionEnabled) {
602
+ recordButton.hidden = true;
603
+ recordButton.disabled = true;
604
+ sttActivationButton.hidden = true;
605
+ sttActivationButton.disabled = true;
606
+ stopButton.hidden = true;
607
+ stopButton.disabled = true;
608
+ sttActivationProgress.hidden = true;
609
+ renderTTSControl();
610
+ return;
611
+ }
574
612
  const transcriptionReady = sttRole.state === 'ready';
575
613
  const microphoneReady = host.availability.microphone
576
614
  && is.function(globalThis.MediaRecorder)
@@ -693,17 +731,19 @@
693
731
  if (localStatus?.message) {
694
732
  messages.push(localStatus.message);
695
733
  tone = localStatus.tone;
696
- if (sttActivationController.pending
697
- || ['loading', 'unloading'].includes(sttRole.state)) {
734
+ if (transcriptionEnabled
735
+ && (sttActivationController.pending
736
+ || ['loading', 'unloading'].includes(sttRole.state))) {
698
737
  messages.push(
699
738
  describeSTTRole()
700
739
  );
701
740
  }
702
- } else {
741
+ } else if (transcriptionEnabled) {
703
742
  messages.push(describeSTTRole());
704
743
  }
705
744
  messages.push(describeTTSRole());
706
- if (sttRole.state === 'error' || ttsRole.state === 'error') {
745
+ if ((transcriptionEnabled && sttRole.state === 'error')
746
+ || ttsRole.state === 'error') {
707
747
  tone = 'error';
708
748
  }
709
749
 
@@ -712,13 +752,14 @@
712
752
  speechStatus.setAttribute(
713
753
  'aria-busy',
714
754
  String(
715
- sttRole.state === 'loading'
716
- || sttRole.state === 'unloading'
755
+ (transcriptionEnabled
756
+ && (sttRole.state === 'loading'
757
+ || sttRole.state === 'unloading'
758
+ || sttRole.busy
759
+ || sttActivationController.pending
760
+ || transcriptionActive))
717
761
  || ttsRole.state === 'loading'
718
- || sttRole.busy
719
762
  || ttsRole.busy
720
- || sttActivationController.pending
721
- || transcriptionActive
722
763
  )
723
764
  );
724
765
  }
@@ -776,7 +817,9 @@
776
817
  }
777
818
 
778
819
  async function observeMicrophonePermission() {
779
- if (!navigator.permissions?.query || !navigator.mediaDevices?.getUserMedia) {
820
+ if (!transcriptionEnabled
821
+ || !navigator.permissions?.query
822
+ || !navigator.mediaDevices?.getUserMedia) {
780
823
  return;
781
824
  }
782
825
 
@@ -1226,7 +1269,8 @@
1226
1269
 
1227
1270
  async function record() {
1228
1271
  if (
1229
- destroyed
1272
+ !transcriptionEnabled
1273
+ || destroyed
1230
1274
  || sttRole.state !== 'ready'
1231
1275
  || sttRole.busy
1232
1276
  || !host.availability.microphone
@@ -1725,7 +1769,7 @@
1725
1769
 
1726
1770
  destroyed = true;
1727
1771
  runtimeStateAbortController.abort();
1728
- sttActivationController.destroy();
1772
+ sttActivationController?.destroy();
1729
1773
  window.removeEventListener('pagehide', handlePageHide);
1730
1774
  window.removeEventListener('pageshow', handlePageShow);
1731
1775
  microphonePermissionStatus?.removeEventListener?.(