arcane-os 0.2.3 → 0.3.1

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.
Files changed (118) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +8 -8
  3. package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +29 -22
  4. package/browser-runtime/ai/ARCANE_AI_BROWSER_SPEECH_COMPONENTS.json +203 -0
  5. package/browser-runtime/ai/browser-kokoro-worker.mjs +11 -2
  6. package/browser-runtime/ai/browser-speech-artifacts.mjs +3242 -391
  7. package/browser-runtime/ai/browser-speech-providers.mjs +1209 -157
  8. package/browser-runtime/ai/browser-speech.mjs +2 -0
  9. package/browser-runtime/ai/browser-whisper-worker.mjs +11 -2
  10. package/browser-runtime/ai/model-controller.mjs +285 -95
  11. package/browser-runtime/ai/speech-worker-client.mjs +249 -32
  12. package/browser-runtime/ai/speech-worker-runtime.mjs +2322 -167
  13. package/browser-runtime/event-manager.mjs +1097 -1
  14. package/docs/architecture.md +2 -2
  15. package/docs/event-manager.md +155 -27
  16. package/docs/reference/README.md +18 -20
  17. package/docs/reference/ai/browser-speech-package-authority.json +835 -0
  18. package/docs/reference/ai/browser-speech.md +1205 -246
  19. package/docs/reference/ai/browser-wasm.md +18 -7
  20. package/docs/reference/availability-and-normalization.md +2 -2
  21. package/docs/reference/behavioral-testing.md +28 -5
  22. package/docs/reference/cli.md +115 -7
  23. package/docs/reference/core/arcane-ai-contracts.md +1 -1
  24. package/docs/reference/event-manager.md +577 -32
  25. package/docs/reference/inventory/package-api.json +478 -2
  26. package/docs/reference/inventory/runtime-components.json +86 -37
  27. package/docs/reference/inventory/runtime-modules.json +126 -49
  28. package/docs/reference/mail.md +316 -0
  29. package/docs/reference/protocols.md +185 -33
  30. package/docs/reference/runtime-components.md +192 -80
  31. package/docs/reference/runtime-modules.md +589 -70
  32. package/docs/reference/sdk-api.md +1017 -24
  33. package/package.json +5 -4
  34. package/runtime/ARCANE_RUNTIME_RELEASE.json +145 -140
  35. package/runtime/arcane/components/app-bar.html +34 -13
  36. package/runtime/arcane/components/assistant-panel.html +110 -57
  37. package/runtime/arcane/components/calculator.html +7 -4
  38. package/runtime/arcane/components/chart.html +58 -17
  39. package/runtime/arcane/components/chat.html +606 -136
  40. package/runtime/arcane/components/conversation-view.html +13 -6
  41. package/runtime/arcane/components/dashboard-config.html +96 -59
  42. package/runtime/arcane/components/data-maintenance.html +69 -14
  43. package/runtime/arcane/components/data-view.html +53 -7
  44. package/runtime/arcane/components/directory-picker.html +118 -32
  45. package/runtime/arcane/components/document-inspector.html +47 -10
  46. package/runtime/arcane/components/file-drop.html +81 -35
  47. package/runtime/arcane/components/file-inspector.html +72 -22
  48. package/runtime/arcane/components/file-manager.html +374 -79
  49. package/runtime/arcane/components/integration-settings.html +12 -5
  50. package/runtime/arcane/components/local-ai-status.html +48 -19
  51. package/runtime/arcane/components/markdown-document.html +161 -68
  52. package/runtime/arcane/components/markdown-editor.html +110 -33
  53. package/runtime/arcane/components/media-embed.html +8 -5
  54. package/runtime/arcane/components/modal.html +15 -5
  55. package/runtime/arcane/components/output-panel.html +28 -23
  56. package/runtime/arcane/components/preferences-form.html +22 -4
  57. package/runtime/arcane/components/record-timeline.html +18 -2
  58. package/runtime/arcane/components/relationship-board.html +23 -3
  59. package/runtime/arcane/components/screen-capture.html +10 -4
  60. package/runtime/arcane/components/source-code-viewer.html +76 -9
  61. package/runtime/arcane/components/source-explanation.html +23 -3
  62. package/runtime/arcane/components/speech.html +453 -131
  63. package/runtime/arcane/components/summary-strip.html +22 -11
  64. package/runtime/arcane/components/table.html +39 -21
  65. package/runtime/arcane/components/task-progress.html +79 -21
  66. package/runtime/arcane/components/terminal-workspace.html +7 -4
  67. package/runtime/arcane/components/theme-editor.html +7 -3
  68. package/runtime/arcane/components/unified-inbox.html +9 -4
  69. package/runtime/arcane/components/voice-transcription.html +244 -72
  70. package/runtime/arcane/components/weather-widget.html +5 -3
  71. package/runtime/arcane/components/web-navigator.html +48 -8
  72. package/runtime/arcane/entities/Chat.js +1 -1
  73. package/runtime/arcane/entities/User.js +110 -23
  74. package/runtime/arcane/modules/AI.js +2117 -130
  75. package/runtime/arcane/modules/AIProviderRuntime.js +756 -13
  76. package/runtime/arcane/modules/AIRuntimeState.js +109 -52
  77. package/runtime/arcane/modules/ApiModelDatabase.js +390 -17
  78. package/runtime/arcane/modules/BrowserTestSuite.js +205 -28
  79. package/runtime/arcane/modules/CalculatorEngine.js +63 -3
  80. package/runtime/arcane/modules/CommunicationAppController.js +588 -28
  81. package/runtime/arcane/modules/CommunicationHub.js +590 -10
  82. package/runtime/arcane/modules/ComponentContracts.js +235 -37
  83. package/runtime/arcane/modules/ConversationTimebox.js +152 -33
  84. package/runtime/arcane/modules/DBLS.js +40 -7
  85. package/runtime/arcane/modules/DBOPFS.js +35 -11
  86. package/runtime/arcane/modules/DataMaintenance.js +12 -2
  87. package/runtime/arcane/modules/Errors.js +65 -7
  88. package/runtime/arcane/modules/HTMLImport.js +198 -14
  89. package/runtime/arcane/modules/LocalAIReadinessController.js +208 -29
  90. package/runtime/arcane/modules/Mail.js +738 -115
  91. package/runtime/arcane/modules/MailOutbox.mjs +1395 -0
  92. package/runtime/arcane/modules/MailTransport.mjs +197 -39
  93. package/runtime/arcane/modules/Ollama.js +36 -1
  94. package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +583 -7
  95. package/runtime/arcane/modules/PreferenceStore.js +367 -33
  96. package/runtime/arcane/modules/RecordReviewStore.js +322 -23
  97. package/runtime/arcane/modules/ScreenCapture.js +1397 -15
  98. package/runtime/arcane/modules/SpeechPlayback.js +438 -41
  99. package/runtime/arcane/modules/TerminalClient.js +277 -12
  100. package/runtime/arcane/modules/ThemeBootstrap.js +80 -6
  101. package/runtime/arcane/modules/ThemeManager.js +39 -7
  102. package/runtime/arcane/modules/TimeGuard.js +131 -20
  103. package/runtime/arcane/modules/WaitForComponent.js +386 -33
  104. package/schemas/arcane-lock.schema.json +2 -2
  105. package/src/cli/main.mjs +435 -9
  106. package/src/event-manager.mjs +1097 -1
  107. package/src/import-map.mjs +21 -3
  108. package/src/index.mjs +13 -0
  109. package/src/installed-sdk-runtime.mjs +130 -0
  110. package/src/mail-api.mjs +22 -0
  111. package/src/mail-credentials.mjs +667 -0
  112. package/src/mail-server.mjs +1769 -0
  113. package/src/mail.mjs +261 -0
  114. package/src/sdk-browser-runtime.mjs +85 -41
  115. package/src/testing-loader.mjs +7 -0
  116. package/src/toolchain.mjs +3 -0
  117. package/src/workspace-runtime.mjs +804 -22
  118. package/src/workspace.mjs +1 -1
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  createBrowserSpeechAuthority,
3
+ isBrowserSpeechArtifactGraph,
4
+ isBrowserSpeechArtifactError,
3
5
  isBrowserSpeechAuthority,
4
6
  isDbopfsSpeechArtifactStore,
5
7
  } from "./browser-speech-artifacts.mjs";
@@ -11,20 +13,32 @@ import {
11
13
  import {
12
14
  createSpeechWorkerClient,
13
15
  isSpeechWorkerClient,
16
+ isSpeechWorkerClientError,
14
17
  } from "./speech-worker-client.mjs";
15
18
 
16
19
  const AI_PROVIDER_PROTOCOL = "arcane-ai-provider/2";
20
+ const AI_MODEL_AUTHORITY_PROTOCOL = "arcane-ai-model-authority/1";
17
21
  const ROLE_OPERATION = Object.freeze({ stt: "transcribe", tts: "synthesize" });
18
22
  const STT_SAMPLE_RATE = 16_000;
19
23
  const TTS_SAMPLE_RATE = 24_000;
20
24
  const TTS_RESPONSE_FORMAT = "wav";
25
+ const ROLE_REQUEST_REASON = Object.freeze({
26
+ stt: "stt-transcription-cancelled",
27
+ tts: "tts-synthesis-cancelled",
28
+ });
29
+ const UNMAPPED_PROVIDER_REASON = "browser-speech-provider-error-reason-unmapped";
30
+ const WARN_FIRST_WARNING = "browser-speech-warn-first-secure-mode-disabled";
31
+ const NO_PROVIDER_WARNINGS = Object.freeze([]);
32
+ const WARN_FIRST_PROVIDER_WARNINGS = Object.freeze([WARN_FIRST_WARNING]);
21
33
  const WORKER_FAILURE_CODES = new Set([
22
34
  "ARCANE_AI_WORKER_CRASHED",
23
35
  "ARCANE_AI_WORKER_MESSAGE_ERROR",
36
+ "ARCANE_AI_WORKER_MESSAGE_REJECTED",
24
37
  "ARCANE_AI_ADAPTER_PROTOCOL_MISMATCH",
25
38
  ]);
39
+ const PROVIDER_ERRORS = new WeakSet();
26
40
 
27
- function providerError(code, message, cause) {
41
+ function providerError(code, message, cause, reason) {
28
42
  const error = cause === undefined
29
43
  ? new Error(message)
30
44
  : new Error(message, { cause });
@@ -32,19 +46,83 @@ function providerError(code, message, cause) {
32
46
  ? "AbortError"
33
47
  : "ArcaneBrowserSpeechProviderError";
34
48
  error.code = code;
49
+ error.reason = typeof reason === "string" && reason
50
+ ? reason
51
+ : UNMAPPED_PROVIDER_REASON;
52
+ PROVIDER_ERRORS.add(error);
35
53
  return error;
36
54
  }
37
55
 
38
- function abortError(signal) {
56
+ function isBrowserSpeechProviderError(value) {
57
+ return typeof value === "object"
58
+ && value !== null
59
+ && PROVIDER_ERRORS.has(value);
60
+ }
61
+
62
+ function isTrustedSpeechError(value) {
63
+ return isBrowserSpeechProviderError(value)
64
+ || isBrowserSpeechArtifactError(value)
65
+ || isSpeechWorkerClientError(value);
66
+ }
67
+
68
+ function trustedLoadFailure(error, role) {
69
+ if (isTrustedSpeechError(error)) return error;
70
+ return providerError(
71
+ "ARCANE_AI_PROVIDER_LOAD_FAILED",
72
+ `The browser ${role} provider load was rejected.`,
73
+ error,
74
+ `${role}-provider-load-rejected`,
75
+ );
76
+ }
77
+
78
+ function trustedRequestFailure(error, role) {
79
+ if (isTrustedSpeechError(error)) return error;
80
+ return providerError(
81
+ "ARCANE_AI_PROVIDER_REQUEST_FAILED",
82
+ `The browser ${role} engine operation was rejected.`,
83
+ error,
84
+ role === "stt"
85
+ ? "stt-transcription-engine-operation-rejected"
86
+ : "tts-synthesis-engine-operation-rejected",
87
+ );
88
+ }
89
+
90
+ function trustedWorkerFailure(error, role) {
91
+ if (isTrustedSpeechError(error)) return error;
92
+ return providerError(
93
+ "ARCANE_AI_WORKER_MESSAGE_REJECTED",
94
+ `The browser ${role} Worker returned an untrusted error envelope.`,
95
+ error,
96
+ `${role}-worker-error-envelope-rejected`,
97
+ );
98
+ }
99
+
100
+ function trustedLifecycleFailure(error, role, operation) {
101
+ if (isTrustedSpeechError(error)) return error;
102
+ return providerError(
103
+ "ARCANE_AI_INVALID_REQUEST",
104
+ `The browser ${role} ${operation} context could not be read.`,
105
+ error,
106
+ `${role}-provider-${operation}-context-read-rejected`,
107
+ );
108
+ }
109
+
110
+ function resolveReason(reason, fallback = UNMAPPED_PROVIDER_REASON) {
111
+ const resolved = typeof reason === "function" ? reason() : reason;
112
+ return typeof resolved === "string" && resolved ? resolved : fallback;
113
+ }
114
+
115
+ function abortError(signal, reason = UNMAPPED_PROVIDER_REASON) {
39
116
  return providerError(
40
117
  "ARCANE_AI_REQUEST_ABORTED",
41
118
  "The browser speech operation was cancelled.",
42
119
  signal?.reason,
120
+ resolveReason(reason),
43
121
  );
44
122
  }
45
123
 
46
- function throwIfAborted(signal) {
47
- if (signal?.aborted) throw abortError(signal);
124
+ function throwIfAborted(signal, reason) {
125
+ if (signal?.aborted) throw abortError(signal, reason);
48
126
  }
49
127
 
50
128
  function workerFailureCode(error) {
@@ -60,50 +138,266 @@ function requiredIdentifier(value, label) {
60
138
  return value.trim();
61
139
  }
62
140
 
141
+ function createProviderAuthority({
142
+ providerId,
143
+ role,
144
+ graph,
145
+ model,
146
+ runtime,
147
+ security,
148
+ }) {
149
+ if (graph !== undefined) {
150
+ if (model !== undefined || runtime !== undefined) {
151
+ throw new TypeError("Browser speech graph is mutually exclusive with legacy model and runtime descriptors.");
152
+ }
153
+ if (!isBrowserSpeechArtifactGraph(graph)) {
154
+ throw new TypeError("Browser speech graph must be created by createBrowserSpeechArtifactGraph().");
155
+ }
156
+ if (graph.role !== role) {
157
+ throw new TypeError(`Browser speech graph role must be ${role}.`);
158
+ }
159
+ if (graph.providerId !== null
160
+ && graph.providerId !== undefined
161
+ && graph.providerId !== providerId) {
162
+ throw new TypeError("Browser speech graph providerId must match the provider id.");
163
+ }
164
+ return Object.freeze({
165
+ protocol: AI_MODEL_AUTHORITY_PROTOCOL,
166
+ admitted: true,
167
+ graph,
168
+ artifactGraphProtocol: graph.protocol,
169
+ providerId,
170
+ role,
171
+ modelId: graph.model.id,
172
+ repository: graph.model.repository,
173
+ revision: graph.model.revision,
174
+ dtype: graph.model.dtype,
175
+ defaultVoice: graph.model.defaultVoice,
176
+ voices: graph.model.voices ?? Object.freeze([]),
177
+ inputSampleRate: graph.model.inputSampleRate,
178
+ outputSampleRate: graph.model.outputSampleRate,
179
+ runtime: graph.runtime,
180
+ files: graph.files,
181
+ security,
182
+ artifactGraphId: graph.identitySha256,
183
+ });
184
+ }
185
+ const authority = createBrowserSpeechAuthority({
186
+ providerId,
187
+ role,
188
+ model,
189
+ runtime,
190
+ security,
191
+ });
192
+ if (!isBrowserSpeechAuthority(authority)) {
193
+ throw new TypeError("Browser speech authority construction did not return an SDK authority.");
194
+ }
195
+ return authority;
196
+ }
197
+
198
+ function inputSampleRate(authority) {
199
+ return authority.graph ? authority.inputSampleRate : STT_SAMPLE_RATE;
200
+ }
201
+
202
+ function outputSampleRate(authority) {
203
+ return authority.graph ? authority.outputSampleRate : TTS_SAMPLE_RATE;
204
+ }
205
+
63
206
  function linkSignal(signal) {
64
207
  const controller = new AbortController();
65
- const abort = () => controller.abort(signal?.reason);
66
- if (signal?.aborted) abort();
67
- else signal?.addEventListener?.("abort", abort, { once: true });
208
+ let internalReason = null;
209
+ let internalCode = null;
210
+ const forwardExternalAbort = () => controller.abort(signal?.reason);
211
+ if (signal?.aborted) forwardExternalAbort();
212
+ else signal?.addEventListener?.("abort", forwardExternalAbort, { once: true });
68
213
  return Object.freeze({
69
214
  controller,
70
- release: () => signal?.removeEventListener?.("abort", abort),
215
+ abort(reason, code = "ARCANE_AI_REQUEST_ABORTED") {
216
+ if (controller.signal.aborted) return;
217
+ internalReason = resolveReason(reason);
218
+ internalCode = code;
219
+ controller.abort(internalReason);
220
+ },
221
+ code() {
222
+ return internalCode;
223
+ },
224
+ reason(fallback) {
225
+ return internalReason ?? resolveReason(fallback);
226
+ },
227
+ release: () => signal?.removeEventListener?.("abort", forwardExternalAbort),
71
228
  });
72
229
  }
73
230
 
74
- function publicStatus({ role, id, authority, state, busy, generation, errorCode, cache }) {
231
+ function linkedAbortFailure(linked, fallbackReason) {
232
+ const reason = linked.reason(fallbackReason);
233
+ const code = linked.code();
234
+ if (code === "ARCANE_AI_OPERATION_SUPERSEDED") {
235
+ return providerError(
236
+ "ARCANE_AI_OPERATION_SUPERSEDED",
237
+ "The browser speech operation was superseded.",
238
+ linked.controller.signal.reason,
239
+ reason,
240
+ );
241
+ }
242
+ if (code === "ARCANE_AI_LOAD_PROGRESS_CALLBACK_THREW") {
243
+ return providerError(
244
+ code,
245
+ "The browser speech load progress callback was rejected.",
246
+ linked.controller.signal.reason,
247
+ reason,
248
+ );
249
+ }
250
+ if (code === "ARCANE_AI_PROVIDER_LOAD_FAILED") {
251
+ return providerError(
252
+ code,
253
+ "The browser speech provider load was rejected.",
254
+ linked.controller.signal.reason,
255
+ reason,
256
+ );
257
+ }
258
+ return abortError(linked.controller.signal, reason);
259
+ }
260
+
261
+ function isAbortSignal(value) {
262
+ return value === null
263
+ || value === undefined
264
+ || (typeof value === "object"
265
+ && typeof value.aborted === "boolean"
266
+ && typeof value.addEventListener === "function"
267
+ && typeof value.removeEventListener === "function");
268
+ }
269
+
270
+ function providerContext(context, role, operation) {
271
+ if (context === undefined) return Object.freeze({ signal: null });
272
+ if (!context || typeof context !== "object" || Array.isArray(context)) {
273
+ throw providerError(
274
+ "ARCANE_AI_INVALID_REQUEST",
275
+ `Browser ${role} ${operation} context must be an object.`,
276
+ undefined,
277
+ `${role}-provider-${operation}-context-not-object`,
278
+ );
279
+ }
280
+ if (Object.hasOwn(context, "role") && context.role !== role) {
281
+ throw providerError(
282
+ "ARCANE_AI_INVALID_REQUEST",
283
+ `Browser ${role} ${operation} context role does not match the provider.`,
284
+ undefined,
285
+ `${role}-provider-${operation}-role-mismatch`,
286
+ );
287
+ }
288
+ const signal = context.signal ?? null;
289
+ if (!isAbortSignal(signal)) {
290
+ throw providerError(
291
+ "ARCANE_AI_INVALID_REQUEST",
292
+ `Browser ${role} ${operation} signal must be an AbortSignal.`,
293
+ undefined,
294
+ `${role}-provider-${operation}-signal-not-abort-signal`,
295
+ );
296
+ }
297
+ return Object.freeze({ context, signal });
298
+ }
299
+
300
+ function graphSecurity(scope, label) {
301
+ return normalizeModelSecurity(scope, label);
302
+ }
303
+
304
+ function providerIntegrity(security, outcome = "pending") {
305
+ const enabledCount = Number(security.checks.byteLength)
306
+ + Number(security.checks.sha256);
307
+ const state = enabledCount === 0 ? "unchecked" : outcome;
308
+ if (!["unchecked", "pending", "verified", "failed"].includes(state)) {
309
+ throw new TypeError("Browser speech integrity outcome is invalid.");
310
+ }
311
+ return Object.freeze({
312
+ state,
313
+ byteLength: Object.freeze({
314
+ enabled: security.checks.byteLength,
315
+ state: security.checks.byteLength ? state : "unchecked",
316
+ }),
317
+ sha256: Object.freeze({
318
+ enabled: security.checks.sha256,
319
+ state: security.checks.sha256 ? state : "unchecked",
320
+ }),
321
+ });
322
+ }
323
+
324
+ function providerWarnings(security, runtimeWarnings = NO_PROVIDER_WARNINGS) {
325
+ const warnings = security.secure === true
326
+ ? runtimeWarnings
327
+ : [...WARN_FIRST_PROVIDER_WARNINGS, ...runtimeWarnings];
328
+ if (warnings.length === 0) return NO_PROVIDER_WARNINGS;
329
+ return Object.freeze([...new Set(warnings)]);
330
+ }
331
+
332
+ function publicStatus({
333
+ role,
334
+ id,
335
+ authority,
336
+ state,
337
+ busy,
338
+ generation,
339
+ errorCode,
340
+ cache,
341
+ lifecycleReason,
342
+ activeOperation,
343
+ artifactGraphAdmission,
344
+ security,
345
+ integrity,
346
+ warnings,
347
+ }) {
75
348
  return Object.freeze({
76
349
  role,
77
350
  providerId: id,
78
351
  modelId: authority.modelId,
79
352
  state,
353
+ lifecycleStatus: `${role}-provider-${state}`,
354
+ lifecycleReason,
355
+ activeOperation,
80
356
  loaded: state === "ready",
81
357
  busy,
82
358
  generation,
83
359
  errorCode,
84
360
  cache,
361
+ security,
362
+ integrity,
363
+ warnings,
364
+ artifactGraphId: authority.artifactGraphId ?? null,
365
+ artifactGraphAdmission,
85
366
  });
86
367
  }
87
368
 
88
- function assertRequestAuthority(context, authority, providerId) {
369
+ function assertRequestAuthority(
370
+ context,
371
+ authority,
372
+ providerId,
373
+ reason = `${authority.role}-provider-request-selection-authority-mismatch`,
374
+ ) {
89
375
  if (!Object.hasOwn(context, "selection")) return;
90
376
  const selection = context.selection;
91
377
  if (selection?.providerId !== providerId
92
378
  || selection?.modelId !== authority.modelId
379
+ || (Object.hasOwn(selection ?? {}, "role") && selection.role !== authority.role)
93
380
  || selection?.localOnly === false) {
94
381
  throw providerError(
95
382
  "ARCANE_AI_MODEL_AUTHORITY_REQUIRED",
96
383
  "The browser speech request selection changed from its immutable provider authority.",
384
+ undefined,
385
+ reason,
97
386
  );
98
387
  }
99
388
  }
100
389
 
101
- function assertPayloadModel(payload, authority, { required = false } = {}) {
390
+ function assertPayloadModel(payload, authority, {
391
+ required = false,
392
+ operationSubject = authority.role === "stt" ? "stt-transcription" : "tts-synthesis",
393
+ } = {}) {
102
394
  if (!Object.hasOwn(payload, "model")) {
103
395
  if (!required) return;
104
396
  throw providerError(
105
397
  "ARCANE_AI_MODEL_AUTHORITY_REQUIRED",
106
398
  "The shared speech request must identify the provider's immutable model authority.",
399
+ undefined,
400
+ `${operationSubject}-model-authority-missing`,
107
401
  );
108
402
  }
109
403
  const descriptor = Object.getOwnPropertyDescriptor(payload, "model");
@@ -111,51 +405,89 @@ function assertPayloadModel(payload, authority, { required = false } = {}) {
111
405
  throw providerError(
112
406
  "ARCANE_AI_MODEL_AUTHORITY_REQUIRED",
113
407
  "The shared speech request model does not match this provider authority.",
408
+ undefined,
409
+ `${operationSubject}-model-authority-mismatch`,
114
410
  );
115
411
  }
116
412
  }
117
413
 
118
- function genericPayloadDescriptors(payload, allowedKeys, requiredKeys, label) {
414
+ function genericPayloadDescriptors(
415
+ payload,
416
+ allowedKeys,
417
+ requiredKeys,
418
+ label,
419
+ operationSubject,
420
+ ) {
119
421
  const prototype = Object.getPrototypeOf(payload);
120
422
  if (prototype !== Object.prototype && prototype !== null) {
121
- throw providerError("ARCANE_AI_INVALID_REQUEST", `${label} must be a plain object.`);
423
+ throw providerError(
424
+ "ARCANE_AI_INVALID_REQUEST",
425
+ `${label} must be a plain object.`,
426
+ undefined,
427
+ `${operationSubject}-payload-not-plain-object`,
428
+ );
122
429
  }
123
430
  const descriptors = Object.getOwnPropertyDescriptors(payload);
124
431
  for (const key of Reflect.ownKeys(descriptors)) {
125
432
  if (typeof key === "symbol" || !allowedKeys.includes(key)) {
126
- throw providerError("ARCANE_AI_INVALID_REQUEST", `${label} contains an unknown field.`);
433
+ throw providerError(
434
+ "ARCANE_AI_INVALID_REQUEST",
435
+ `${label} contains an unknown field.`,
436
+ undefined,
437
+ `${operationSubject}-payload-field-unknown`,
438
+ );
127
439
  }
128
440
  if (!Object.hasOwn(descriptors[key], "value")) {
129
- throw providerError("ARCANE_AI_INVALID_REQUEST", `${label}.${key} must be a data property.`);
441
+ throw providerError(
442
+ "ARCANE_AI_INVALID_REQUEST",
443
+ `${label}.${key} must be a data property.`,
444
+ undefined,
445
+ `${operationSubject}-payload-accessor-rejected`,
446
+ );
130
447
  }
131
448
  }
132
449
  for (const key of requiredKeys) {
133
450
  if (!Object.hasOwn(descriptors, key)) {
134
- throw providerError("ARCANE_AI_INVALID_REQUEST", `${label}.${key} is required.`);
451
+ throw providerError(
452
+ "ARCANE_AI_INVALID_REQUEST",
453
+ `${label}.${key} is required.`,
454
+ undefined,
455
+ `${operationSubject}-payload-required-field-missing`,
456
+ );
135
457
  }
136
458
  }
137
459
  return descriptors;
138
460
  }
139
461
 
140
- function audioMimeEssence(value, label) {
462
+ function audioMimeEssence(value, label, reasonPrefix) {
141
463
  if (typeof value !== "string") {
142
- throw providerError("ARCANE_AI_INVALID_REQUEST", `${label} must be an audio MIME type.`);
464
+ throw providerError(
465
+ "ARCANE_AI_INVALID_REQUEST",
466
+ `${label} must be an audio MIME type.`,
467
+ undefined,
468
+ `${reasonPrefix}-not-string`,
469
+ );
143
470
  }
144
471
  const essence = value.split(";", 1)[0].trim().toLowerCase();
145
472
  if (!/^audio\/[a-z0-9!#$%&'*+.^_`|~-]+$/u.test(essence)) {
146
- throw providerError("ARCANE_AI_INVALID_REQUEST", `${label} must be an audio MIME type.`);
473
+ throw providerError(
474
+ "ARCANE_AI_INVALID_REQUEST",
475
+ `${label} must be an audio MIME type.`,
476
+ undefined,
477
+ `${reasonPrefix}-malformed`,
478
+ );
147
479
  }
148
480
  return essence;
149
481
  }
150
482
 
151
- function awaitAbortableSpeechOperation(operation, signal) {
483
+ function awaitAbortableSpeechOperation(operation, signal, reason) {
152
484
  const observed = Promise.resolve(operation);
153
485
  if (!signal) return observed;
154
486
  if (signal.aborted) {
155
487
  // Blob and Web Audio operations cannot be preempted; retain their eventual
156
488
  // rejection after cancellation instead of leaving an unobserved promise.
157
489
  observed.catch(function retainCancelledSpeechOperation() {});
158
- return Promise.reject(abortError(signal));
490
+ return Promise.reject(abortError(signal, reason));
159
491
  }
160
492
  return new Promise((resolve, reject) => {
161
493
  let settled = false;
@@ -166,7 +498,7 @@ function awaitAbortableSpeechOperation(operation, signal) {
166
498
  release();
167
499
  callback(value);
168
500
  };
169
- const cancel = () => settle(reject, abortError(signal));
501
+ const cancel = () => settle(reject, abortError(signal, reason));
170
502
  signal.addEventListener("abort", cancel, { once: true });
171
503
  observed.then(
172
504
  (value) => settle(resolve, value),
@@ -175,26 +507,139 @@ function awaitAbortableSpeechOperation(operation, signal) {
175
507
  });
176
508
  }
177
509
 
178
- async function decodeSharedTranscriptionPayload(payload, authority, signal) {
510
+ function observeLoadOperation(record, { signal, progress }, role) {
511
+ if (signal?.aborted) {
512
+ return Promise.reject(abortError(signal, `${role}-load-cancelled`));
513
+ }
514
+ return new Promise((resolve, reject) => {
515
+ let settled = false;
516
+ const release = () => {
517
+ try {
518
+ signal?.removeEventListener?.("abort", cancel);
519
+ return null;
520
+ } catch (error) {
521
+ return error;
522
+ }
523
+ };
524
+ const settle = (callback, value) => {
525
+ if (settled) return;
526
+ settled = true;
527
+ record.observers.delete(observer);
528
+ const releaseError = release();
529
+ if (releaseError) {
530
+ reject(trustedLoadFailure(releaseError, role));
531
+ return;
532
+ }
533
+ callback(value);
534
+ };
535
+ const abandonIfUnobserved = (
536
+ reason,
537
+ code = "ARCANE_AI_REQUEST_ABORTED",
538
+ ) => {
539
+ if (!record.settled && record.observers.size === 0) record.abort(reason, code);
540
+ };
541
+ const cancel = () => {
542
+ settle(reject, abortError(signal, `${role}-load-cancelled`));
543
+ abandonIfUnobserved(`${role}-load-cancelled`);
544
+ };
545
+ const observer = Object.freeze({
546
+ progress(value) {
547
+ if (settled) return;
548
+ try {
549
+ progress(value);
550
+ } catch (error) {
551
+ settle(reject, providerError(
552
+ "ARCANE_AI_LOAD_PROGRESS_CALLBACK_THREW",
553
+ `Browser ${role} load progress callback threw.`,
554
+ error,
555
+ `${role}-load-progress-callback-threw`,
556
+ ));
557
+ abandonIfUnobserved(
558
+ `${role}-load-progress-callback-threw`,
559
+ "ARCANE_AI_LOAD_PROGRESS_CALLBACK_THREW",
560
+ );
561
+ }
562
+ },
563
+ });
564
+ record.observers.add(observer);
565
+ record.promise.then(
566
+ (value) => settle(resolve, value),
567
+ (error) => settle(reject, error),
568
+ );
569
+ try {
570
+ signal?.addEventListener?.("abort", cancel, { once: true });
571
+ } catch (error) {
572
+ settled = true;
573
+ record.observers.delete(observer);
574
+ reject(trustedLoadFailure(error, role));
575
+ abandonIfUnobserved(
576
+ `${role}-provider-load-rejected`,
577
+ "ARCANE_AI_PROVIDER_LOAD_FAILED",
578
+ );
579
+ return;
580
+ }
581
+ if (record.hasProgress) observer.progress(record.latestProgress);
582
+ });
583
+ }
584
+
585
+ async function decodeSharedTranscriptionPayload(
586
+ payload,
587
+ authority,
588
+ signal,
589
+ cancellationReason = "stt-transcription-cancelled",
590
+ ) {
591
+ const sampleRate = inputSampleRate(authority);
179
592
  const descriptors = genericPayloadDescriptors(
180
593
  payload,
181
594
  ["audio", "mimeType", "model"],
182
595
  ["audio", "mimeType", "model"],
183
596
  "Shared speech transcription payload",
597
+ "stt-transcription",
184
598
  );
185
- assertPayloadModel(payload, authority, { required: true });
599
+ assertPayloadModel(payload, authority, {
600
+ required: true,
601
+ operationSubject: "stt-transcription",
602
+ });
186
603
  const audio = descriptors.audio.value;
187
- if (typeof Blob !== "function" || !(audio instanceof Blob) || audio.size < 1) {
604
+ if (typeof Blob !== "function") {
605
+ throw providerError(
606
+ "ARCANE_AI_INVALID_REQUEST",
607
+ "Shared speech transcription requires the browser Blob constructor.",
608
+ undefined,
609
+ "stt-transcription-blob-constructor-unavailable",
610
+ );
611
+ }
612
+ if (!(audio instanceof Blob)) {
613
+ throw providerError(
614
+ "ARCANE_AI_INVALID_REQUEST",
615
+ "Shared speech transcription audio must be a Blob or File.",
616
+ undefined,
617
+ "stt-transcription-audio-not-blob-or-file",
618
+ );
619
+ }
620
+ if (audio.size < 1) {
188
621
  throw providerError(
189
622
  "ARCANE_AI_INVALID_REQUEST",
190
- "Shared speech transcription requires a nonempty audio Blob or File.",
623
+ "Shared speech transcription audio Blob or File must be nonempty.",
624
+ undefined,
625
+ "stt-transcription-audio-blob-empty",
191
626
  );
192
627
  }
193
- const mimeType = audioMimeEssence(descriptors.mimeType.value, "Speech transcription mimeType");
194
- if (audio.type && audioMimeEssence(audio.type, "Speech transcription Blob.type") !== mimeType) {
628
+ const mimeType = audioMimeEssence(
629
+ descriptors.mimeType.value,
630
+ "Speech transcription mimeType",
631
+ "stt-transcription-mime-type",
632
+ );
633
+ if (audio.type && audioMimeEssence(
634
+ audio.type,
635
+ "Speech transcription Blob.type",
636
+ "stt-transcription-audio-blob-mime-type",
637
+ ) !== mimeType) {
195
638
  throw providerError(
196
639
  "ARCANE_AI_INVALID_REQUEST",
197
640
  "Speech transcription mimeType does not match the audio Blob type.",
641
+ undefined,
642
+ "stt-transcription-audio-blob-mime-type-mismatch",
198
643
  );
199
644
  }
200
645
  const OfflineAudioContext = globalThis.OfflineAudioContext
@@ -202,57 +647,136 @@ async function decodeSharedTranscriptionPayload(payload, authority, signal) {
202
647
  if (typeof OfflineAudioContext !== "function") {
203
648
  throw providerError(
204
649
  "ARCANE_AI_AUDIO_DECODE_UNAVAILABLE",
205
- "Shared Blob transcription requires OfflineAudioContext decoding at 16000 Hz.",
650
+ `Shared Blob transcription requires OfflineAudioContext decoding at ${sampleRate} Hz.`,
651
+ undefined,
652
+ "stt-browser-offline-audio-context-unavailable",
206
653
  );
207
654
  }
208
655
  let decoder;
209
656
  try {
210
- decoder = new OfflineAudioContext(1, 1, STT_SAMPLE_RATE);
657
+ decoder = new OfflineAudioContext(1, 1, sampleRate);
211
658
  } catch (error) {
212
659
  throw providerError(
213
660
  "ARCANE_AI_AUDIO_DECODE_UNAVAILABLE",
214
- "Unable to create the required 16000 Hz speech decoder.",
661
+ `Unable to create the required ${sampleRate} Hz speech decoder.`,
215
662
  error,
663
+ "stt-browser-offline-audio-context-construction-rejected",
216
664
  );
217
665
  }
218
666
  if (typeof decoder.decodeAudioData !== "function") {
219
667
  throw providerError(
220
668
  "ARCANE_AI_AUDIO_DECODE_UNAVAILABLE",
221
669
  "Shared Blob transcription requires browser audio decoding.",
670
+ undefined,
671
+ "stt-browser-audio-decode-method-unavailable",
222
672
  );
223
673
  }
224
674
  let decoded;
225
675
  try {
226
- const encoded = await awaitAbortableSpeechOperation(audio.arrayBuffer(), signal);
227
- throwIfAborted(signal);
228
- decoded = await awaitAbortableSpeechOperation(decoder.decodeAudioData(encoded), signal);
229
- throwIfAborted(signal);
676
+ const encoded = await awaitAbortableSpeechOperation(
677
+ audio.arrayBuffer(),
678
+ signal,
679
+ cancellationReason,
680
+ );
681
+ throwIfAborted(signal, cancellationReason);
682
+ decoded = await awaitAbortableSpeechOperation(
683
+ decoder.decodeAudioData(encoded),
684
+ signal,
685
+ cancellationReason,
686
+ );
687
+ throwIfAborted(signal, cancellationReason);
230
688
  } catch (error) {
231
689
  if (signal?.aborted && error?.code === "ARCANE_AI_REQUEST_ABORTED") throw error;
232
690
  throw providerError(
233
691
  "ARCANE_AI_AUDIO_DECODE_FAILED",
234
692
  "The browser could not decode the supplied speech audio.",
235
693
  error,
694
+ "stt-browser-audio-decode-operation-rejected",
695
+ );
696
+ }
697
+ if (!decoded || typeof decoded !== "object") {
698
+ throw providerError(
699
+ "ARCANE_AI_AUDIO_DECODE_FAILED",
700
+ "Decoded speech audio must be an AudioBuffer-like object.",
701
+ undefined,
702
+ "stt-browser-decoded-audio-not-object",
236
703
  );
237
704
  }
238
- if (decoded?.sampleRate !== STT_SAMPLE_RATE
239
- || !Number.isSafeInteger(decoded.length)
240
- || decoded.length < 1
241
- || !Number.isSafeInteger(decoded.numberOfChannels)
242
- || decoded.numberOfChannels < 1
243
- || typeof decoded.getChannelData !== "function") {
705
+ if (decoded.sampleRate !== sampleRate) {
244
706
  throw providerError(
245
707
  "ARCANE_AI_AUDIO_DECODE_FAILED",
246
- "Decoded speech audio must provide nonempty 16000 Hz channel data.",
708
+ `Decoded speech audio must be sampled at ${sampleRate} Hz.`,
709
+ undefined,
710
+ "stt-browser-decoded-audio-sample-rate-mismatch",
711
+ );
712
+ }
713
+ if (!Number.isSafeInteger(decoded.length)) {
714
+ throw providerError(
715
+ "ARCANE_AI_AUDIO_DECODE_FAILED",
716
+ "Decoded speech audio frame length must be a safe integer.",
717
+ undefined,
718
+ "stt-browser-decoded-audio-frame-length-not-safe-integer",
719
+ );
720
+ }
721
+ if (decoded.length < 1) {
722
+ throw providerError(
723
+ "ARCANE_AI_AUDIO_DECODE_FAILED",
724
+ "Decoded speech audio must contain at least one frame.",
725
+ undefined,
726
+ "stt-browser-decoded-audio-empty",
727
+ );
728
+ }
729
+ if (!Number.isSafeInteger(decoded.numberOfChannels)) {
730
+ throw providerError(
731
+ "ARCANE_AI_AUDIO_DECODE_FAILED",
732
+ "Decoded speech audio channel count must be a safe integer.",
733
+ undefined,
734
+ "stt-browser-decoded-audio-channel-count-not-safe-integer",
735
+ );
736
+ }
737
+ if (decoded.numberOfChannels < 1) {
738
+ throw providerError(
739
+ "ARCANE_AI_AUDIO_DECODE_FAILED",
740
+ "Decoded speech audio must contain at least one channel.",
741
+ undefined,
742
+ "stt-browser-decoded-audio-channel-count-zero",
743
+ );
744
+ }
745
+ if (typeof decoded.getChannelData !== "function") {
746
+ throw providerError(
747
+ "ARCANE_AI_AUDIO_DECODE_FAILED",
748
+ "Decoded speech audio must expose getChannelData().",
749
+ undefined,
750
+ "stt-browser-decoded-audio-get-channel-data-not-function",
247
751
  );
248
752
  }
249
753
  const channels = [];
250
754
  for (let index = 0; index < decoded.numberOfChannels; index += 1) {
251
- const channel = decoded.getChannelData(index);
252
- if (!(channel instanceof Float32Array) || channel.length !== decoded.length) {
755
+ let channel;
756
+ try {
757
+ channel = decoded.getChannelData(index);
758
+ } catch (error) {
253
759
  throw providerError(
254
760
  "ARCANE_AI_AUDIO_DECODE_FAILED",
255
- "Decoded speech audio returned invalid channel data.",
761
+ "Decoded speech audio channel access was rejected.",
762
+ error,
763
+ "stt-browser-decoded-audio-channel-read-rejected",
764
+ );
765
+ }
766
+ if (!(channel instanceof Float32Array)) {
767
+ throw providerError(
768
+ "ARCANE_AI_AUDIO_DECODE_FAILED",
769
+ "Decoded speech audio channels must be Float32Array values.",
770
+ undefined,
771
+ "stt-browser-decoded-audio-channel-not-float32-array",
772
+ );
773
+ }
774
+ if (channel.length !== decoded.length) {
775
+ throw providerError(
776
+ "ARCANE_AI_AUDIO_DECODE_FAILED",
777
+ "Decoded speech audio channel length must match the decoded frame length.",
778
+ undefined,
779
+ "stt-browser-decoded-audio-channel-length-mismatch",
256
780
  );
257
781
  }
258
782
  channels.push(channel);
@@ -266,29 +790,67 @@ async function decodeSharedTranscriptionPayload(payload, authority, signal) {
266
790
  throw providerError(
267
791
  "ARCANE_AI_AUDIO_DECODE_FAILED",
268
792
  "Decoded speech audio contains a non-finite sample.",
793
+ undefined,
794
+ "stt-browser-decoded-audio-sample-non-finite",
269
795
  );
270
796
  }
271
797
  mono[index] = sample;
272
798
  }
273
- throwIfAborted(signal);
799
+ throwIfAborted(signal, cancellationReason);
274
800
  return Object.freeze({
275
- payload: Object.freeze({ audio: mono, sampleRate: STT_SAMPLE_RATE }),
801
+ payload: Object.freeze({ audio: mono, sampleRate }),
276
802
  shared: true,
277
803
  });
278
804
  }
279
805
 
280
806
  function cloneNativeTranscriptionPayload(payload, authority) {
281
- assertPayloadModel(payload, authority);
282
- if (!(payload.audio instanceof Float32Array) || payload.sampleRate !== STT_SAMPLE_RATE) {
807
+ const descriptors = genericPayloadDescriptors(
808
+ payload,
809
+ ["audio", "sampleRate", "model"],
810
+ ["audio", "sampleRate"],
811
+ "Browser Whisper transcription payload",
812
+ "stt-transcription",
813
+ );
814
+ assertPayloadModel(payload, authority, { operationSubject: "stt-transcription" });
815
+ const sampleRate = inputSampleRate(authority);
816
+ if (!(descriptors.audio.value instanceof Float32Array)) {
817
+ throw providerError(
818
+ "ARCANE_AI_INVALID_REQUEST",
819
+ "Whisper requires Float32Array audio.",
820
+ undefined,
821
+ "stt-transcription-audio-not-float32-array",
822
+ );
823
+ }
824
+ if (descriptors.audio.value.length < 1) {
283
825
  throw providerError(
284
826
  "ARCANE_AI_INVALID_REQUEST",
285
- "Whisper requires Float32Array audio sampled at exactly 16000 Hz.",
827
+ "Whisper requires nonempty Float32 PCM audio.",
828
+ undefined,
829
+ "stt-transcription-pcm-input-empty",
286
830
  );
287
831
  }
832
+ if (descriptors.sampleRate.value !== sampleRate) {
833
+ throw providerError(
834
+ "ARCANE_AI_INVALID_REQUEST",
835
+ `Whisper requires audio sampled at exactly ${sampleRate} Hz.`,
836
+ undefined,
837
+ "stt-transcription-sample-rate-mismatch",
838
+ );
839
+ }
840
+ for (const sample of descriptors.audio.value) {
841
+ if (!Number.isFinite(sample)) {
842
+ throw providerError(
843
+ "ARCANE_AI_INVALID_REQUEST",
844
+ "Whisper audio must contain only finite Float32 PCM samples.",
845
+ undefined,
846
+ "stt-transcription-pcm-sample-non-finite",
847
+ );
848
+ }
849
+ }
288
850
  return Object.freeze({
289
851
  payload: Object.freeze({
290
- audio: new Float32Array(payload.audio),
291
- sampleRate: STT_SAMPLE_RATE,
852
+ audio: new Float32Array(descriptors.audio.value),
853
+ sampleRate,
292
854
  }),
293
855
  shared: false,
294
856
  });
@@ -296,34 +858,79 @@ function cloneNativeTranscriptionPayload(payload, authority) {
296
858
 
297
859
  function normalizeSynthesisPayload(payload, authority) {
298
860
  const shared = Object.hasOwn(payload, "input");
299
- let textValue = payload.text;
861
+ let descriptors;
862
+ let textValue;
300
863
  if (shared) {
301
- const descriptors = genericPayloadDescriptors(
864
+ descriptors = genericPayloadDescriptors(
302
865
  payload,
303
866
  ["model", "voice", "input", "responseFormat", "speed"],
304
867
  ["model", "input", "responseFormat"],
305
868
  "Shared speech synthesis payload",
869
+ "tts-synthesis",
306
870
  );
307
- assertPayloadModel(payload, authority, { required: true });
871
+ assertPayloadModel(payload, authority, {
872
+ required: true,
873
+ operationSubject: "tts-synthesis",
874
+ });
308
875
  if (descriptors.responseFormat.value !== TTS_RESPONSE_FORMAT) {
309
876
  throw providerError(
310
877
  "ARCANE_AI_UNSUPPORTED_RESPONSE_FORMAT",
311
878
  "Browser Kokoro supports only wav responses for shared speech requests.",
879
+ undefined,
880
+ "tts-synthesis-response-format-not-wav",
312
881
  );
313
882
  }
314
883
  textValue = descriptors.input.value;
315
884
  } else {
316
- assertPayloadModel(payload, authority);
885
+ descriptors = genericPayloadDescriptors(
886
+ payload,
887
+ ["model", "voice", "text", "speed"],
888
+ ["text"],
889
+ "Browser Kokoro synthesis payload",
890
+ "tts-synthesis",
891
+ );
892
+ assertPayloadModel(payload, authority, { operationSubject: "tts-synthesis" });
893
+ textValue = descriptors.text.value;
317
894
  }
318
895
  const text = typeof textValue === "string" ? textValue.trim() : "";
319
- const voice = typeof (payload.voice ?? authority.defaultVoice) === "string"
320
- ? (payload.voice ?? authority.defaultVoice).trim()
896
+ const voiceValue = Object.hasOwn(descriptors, "voice")
897
+ ? descriptors.voice.value
898
+ : authority.defaultVoice;
899
+ const voice = typeof voiceValue === "string"
900
+ ? voiceValue.trim()
321
901
  : "";
322
- const speed = payload.speed ?? 1;
323
- if (!text || !voice || !Number.isFinite(speed) || speed <= 0 || speed > 4) {
902
+ const speed = Object.hasOwn(descriptors, "speed") ? descriptors.speed.value : 1;
903
+ if (!text) {
904
+ throw providerError(
905
+ "ARCANE_AI_INVALID_REQUEST",
906
+ "Kokoro requires nonempty text.",
907
+ undefined,
908
+ "tts-synthesis-text-empty",
909
+ );
910
+ }
911
+ if (!voice) {
912
+ throw providerError(
913
+ "ARCANE_AI_INVALID_REQUEST",
914
+ "Kokoro requires a nonempty voice id.",
915
+ undefined,
916
+ "tts-synthesis-voice-empty",
917
+ );
918
+ }
919
+ if (!Number.isFinite(speed) || speed <= 0 || speed > 4) {
324
920
  throw providerError(
325
921
  "ARCANE_AI_INVALID_REQUEST",
326
- "Kokoro requires text, a voice, and speed greater than 0 and at most 4.",
922
+ "Kokoro speed must be greater than 0 and at most 4.",
923
+ undefined,
924
+ "tts-synthesis-speed-out-of-range",
925
+ );
926
+ }
927
+ if (authority.graph
928
+ && !authority.voices.some((candidate) => candidate.id === voice)) {
929
+ throw providerError(
930
+ "ARCANE_AI_MODEL_AUTHORITY_REQUIRED",
931
+ "Kokoro voice must match a caller-declared authenticated voice artifact.",
932
+ undefined,
933
+ "tts-synthesis-voice-not-declared",
327
934
  );
328
935
  }
329
936
  return Object.freeze({
@@ -332,30 +939,81 @@ function normalizeSynthesisPayload(payload, authority) {
332
939
  });
333
940
  }
334
941
 
335
- async function normalizeRequestPayload(role, payload, authority, signal) {
942
+ async function normalizeRequestPayload(
943
+ role,
944
+ payload,
945
+ authority,
946
+ signal,
947
+ cancellationReason,
948
+ ) {
336
949
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
337
- throw providerError("ARCANE_AI_INVALID_REQUEST", "Speech request payload must be an object.");
950
+ throw providerError(
951
+ "ARCANE_AI_INVALID_REQUEST",
952
+ "Speech request payload must be an object.",
953
+ undefined,
954
+ role === "stt"
955
+ ? "stt-transcription-payload-not-object"
956
+ : "tts-synthesis-payload-not-object",
957
+ );
338
958
  }
339
959
  if (role === "stt") {
340
960
  const audio = Object.getOwnPropertyDescriptor(payload, "audio");
341
- if (Object.hasOwn(audio ?? {}, "value")
342
- && typeof Blob === "function"
343
- && audio.value instanceof Blob) {
344
- return decodeSharedTranscriptionPayload(payload, authority, signal);
961
+ if (Object.hasOwn(payload, "mimeType")
962
+ || (Object.hasOwn(audio ?? {}, "value")
963
+ && typeof Blob === "function"
964
+ && audio.value instanceof Blob)) {
965
+ return decodeSharedTranscriptionPayload(
966
+ payload,
967
+ authority,
968
+ signal,
969
+ cancellationReason,
970
+ );
345
971
  }
346
972
  return cloneNativeTranscriptionPayload(payload, authority);
347
973
  }
348
974
  return normalizeSynthesisPayload(payload, authority);
349
975
  }
350
976
 
351
- function encodeSharedSynthesisResult(result) {
352
- if (!(result?.audio instanceof Float32Array)
353
- || result.sampleRate !== TTS_SAMPLE_RATE
354
- || result.audio.length < 1
355
- || result.audio.length > (0xffffffff - 44) / 2) {
977
+ function encodeSharedSynthesisResult(result, authority) {
978
+ const sampleRate = outputSampleRate(authority);
979
+ if (!result || typeof result !== "object") {
980
+ throw providerError(
981
+ "ARCANE_AI_INVALID_PROVIDER_RESULT",
982
+ "Browser Kokoro must return a synthesis result object.",
983
+ undefined,
984
+ "tts-synthesis-result-not-object",
985
+ );
986
+ }
987
+ if (!(result.audio instanceof Float32Array)) {
988
+ throw providerError(
989
+ "ARCANE_AI_INVALID_PROVIDER_RESULT",
990
+ "Browser Kokoro must return Float32 PCM audio.",
991
+ undefined,
992
+ "tts-synthesis-result-audio-not-float32-array",
993
+ );
994
+ }
995
+ if (result.sampleRate !== sampleRate) {
996
+ throw providerError(
997
+ "ARCANE_AI_INVALID_PROVIDER_RESULT",
998
+ `Browser Kokoro must return audio sampled at ${sampleRate} Hz.`,
999
+ undefined,
1000
+ "tts-synthesis-result-sample-rate-mismatch",
1001
+ );
1002
+ }
1003
+ if (result.audio.length < 1) {
1004
+ throw providerError(
1005
+ "ARCANE_AI_INVALID_PROVIDER_RESULT",
1006
+ "Browser Kokoro must return nonempty PCM audio.",
1007
+ undefined,
1008
+ "tts-synthesis-result-audio-empty",
1009
+ );
1010
+ }
1011
+ if (result.audio.length > (0xffffffff - 44) / 2) {
356
1012
  throw providerError(
357
1013
  "ARCANE_AI_INVALID_PROVIDER_RESULT",
358
- "Browser Kokoro must return nonempty 24000 Hz Float32 PCM.",
1014
+ "Browser Kokoro PCM audio exceeds the WAV byte-length boundary.",
1015
+ undefined,
1016
+ "tts-synthesis-result-wav-byte-length-overflow",
359
1017
  );
360
1018
  }
361
1019
  const buffer = new ArrayBuffer(44 + result.audio.length * 2);
@@ -373,8 +1031,8 @@ function encodeSharedSynthesisResult(result) {
373
1031
  view.setUint32(16, 16, true);
374
1032
  view.setUint16(20, 1, true);
375
1033
  view.setUint16(22, 1, true);
376
- view.setUint32(24, TTS_SAMPLE_RATE, true);
377
- view.setUint32(28, TTS_SAMPLE_RATE * 2, true);
1034
+ view.setUint32(24, sampleRate, true);
1035
+ view.setUint32(28, sampleRate * 2, true);
378
1036
  view.setUint16(32, 2, true);
379
1037
  view.setUint16(34, 16, true);
380
1038
  writeText(36, "data");
@@ -385,6 +1043,8 @@ function encodeSharedSynthesisResult(result) {
385
1043
  throw providerError(
386
1044
  "ARCANE_AI_INVALID_PROVIDER_RESULT",
387
1045
  "Browser Kokoro returned a non-finite PCM sample.",
1046
+ undefined,
1047
+ "tts-synthesis-pcm-sample-non-finite",
388
1048
  );
389
1049
  }
390
1050
  const clamped = Math.max(-1, Math.min(1, sample));
@@ -401,6 +1061,7 @@ function createBrowserSpeechProvider({
401
1061
  role,
402
1062
  id,
403
1063
  localOnly = true,
1064
+ graph,
404
1065
  model,
405
1066
  runtime,
406
1067
  appSecurity,
@@ -426,21 +1087,23 @@ function createBrowserSpeechProvider({
426
1087
  security,
427
1088
  "Browser speech provider security",
428
1089
  );
429
- const authority = createBrowserSpeechAuthority({
1090
+ const authority = createProviderAuthority({
430
1091
  providerId,
431
1092
  role,
1093
+ graph,
432
1094
  model,
433
1095
  runtime,
434
1096
  security: configuredProviderSecurity,
435
1097
  });
436
- if (!isBrowserSpeechAuthority(authority)) {
437
- throw new TypeError("Browser speech authority construction failed.");
438
- }
1098
+ const defaultSecurity = resolveModelSecurity({
1099
+ app: configuredAppSecurity,
1100
+ binding: configuredProviderSecurity,
1101
+ });
439
1102
  const operation = ROLE_OPERATION[role];
440
1103
  const speech = role === "stt"
441
- ? Object.freeze({ inputSampleRate: STT_SAMPLE_RATE })
1104
+ ? Object.freeze({ inputSampleRate: inputSampleRate(authority) })
442
1105
  : Object.freeze({
443
- outputSampleRate: TTS_SAMPLE_RATE,
1106
+ outputSampleRate: outputSampleRate(authority),
444
1107
  responseFormats: Object.freeze([TTS_RESPONSE_FORMAT]),
445
1108
  defaultResponseFormat: TTS_RESPONSE_FORMAT,
446
1109
  });
@@ -452,21 +1115,43 @@ function createBrowserSpeechProvider({
452
1115
  ...(role === "tts" ? { defaultVoice: authority.defaultVoice } : {}),
453
1116
  repository: authority.repository,
454
1117
  revision: authority.revision,
1118
+ ...(authority.dtype ? { dtype: authority.dtype } : {}),
455
1119
  runtime: authority.runtime,
456
1120
  files: authority.files,
1121
+ ...(authority.graph ? {
1122
+ artifactGraphId: authority.artifactGraphId,
1123
+ voices: authority.voices,
1124
+ } : {}),
457
1125
  speech,
458
1126
  });
459
1127
  let state = "unloaded";
460
1128
  let errorCode = null;
461
1129
  let cache = null;
1130
+ let artifactGraphAdmission = null;
1131
+ let lifecycleReason = `${role}-provider-created`;
1132
+ let activeOperation = null;
462
1133
  let generation = 0;
463
1134
  let active = null;
464
1135
  let loadOperation = null;
465
1136
  let unloadOperation = null;
466
1137
  let disposeOperation = null;
467
1138
  let requestOperation = null;
1139
+ let lastWarnings = NO_PROVIDER_WARNINGS;
1140
+ let lastIntegrity = Object.freeze({
1141
+ security: defaultSecurity,
1142
+ integrity: providerIntegrity(defaultSecurity),
1143
+ });
468
1144
 
469
1145
  function status() {
1146
+ const effectiveSecurity = active?.security
1147
+ ?? loadOperation?.security
1148
+ ?? lastIntegrity.security;
1149
+ const integrity = active?.integrity
1150
+ ?? loadOperation?.integrity
1151
+ ?? lastIntegrity.integrity;
1152
+ const runtimeWarnings = active?.warnings
1153
+ ?? loadOperation?.warnings
1154
+ ?? lastWarnings;
470
1155
  return publicStatus({
471
1156
  role,
472
1157
  id: providerId,
@@ -476,13 +1161,19 @@ function createBrowserSpeechProvider({
476
1161
  generation,
477
1162
  errorCode,
478
1163
  cache,
1164
+ lifecycleReason,
1165
+ activeOperation,
1166
+ artifactGraphAdmission,
1167
+ security: effectiveSecurity,
1168
+ integrity,
1169
+ warnings: providerWarnings(effectiveSecurity, runtimeWarnings),
479
1170
  });
480
1171
  }
481
1172
 
482
1173
  function releaseSlot(slot) {
483
1174
  if (!slot || slot.released) return;
484
- slot.released = true;
485
1175
  slot.prepared.release();
1176
+ slot.released = true;
486
1177
  }
487
1178
 
488
1179
  async function terminateSlot(slot, reason, { intentional = true } = {}) {
@@ -491,10 +1182,22 @@ function createBrowserSpeechProvider({
491
1182
  throw providerError(
492
1183
  "ARCANE_AI_ADAPTER_PROTOCOL_MISMATCH",
493
1184
  "The browser speech Worker client is not SDK-owned.",
1185
+ undefined,
1186
+ `${role}-worker-client-authority-mismatch`,
494
1187
  );
495
1188
  }
496
- await slot.client.terminate(reason, { intentional });
497
- releaseSlot(slot);
1189
+ const trustedReason = trustedWorkerFailure(reason, role);
1190
+ try {
1191
+ await slot.client.terminate(trustedReason, { intentional });
1192
+ } catch (error) {
1193
+ throw trustedWorkerFailure(error, role);
1194
+ } finally {
1195
+ try {
1196
+ releaseSlot(slot);
1197
+ } catch (error) {
1198
+ throw trustedWorkerFailure(error, role);
1199
+ }
1200
+ }
498
1201
  }
499
1202
 
500
1203
  const provider = {
@@ -507,16 +1210,20 @@ function createBrowserSpeechProvider({
507
1210
  return Object.freeze([catalogEntry]);
508
1211
  },
509
1212
 
510
- inspect(selection, { signal } = {}) {
511
- throwIfAborted(signal);
1213
+ inspect(selection, context = {}) {
1214
+ const inspection = providerContext(context, role, "inspection");
1215
+ const { signal } = inspection;
1216
+ throwIfAborted(signal, `${role}-provider-inspection-cancelled`);
512
1217
  const available = selection?.providerId === providerId
513
1218
  && selection?.modelId === authority.modelId
1219
+ && (!Object.hasOwn(selection ?? {}, "role") || selection.role === role)
514
1220
  && selection?.localOnly !== false;
515
1221
  if (!available) {
516
1222
  return Object.freeze({
517
1223
  available: false,
518
1224
  code: "ARCANE_AI_MODEL_AUTHORITY_REQUIRED",
519
1225
  message: "The selected browser speech model does not match this provider authority.",
1226
+ reason: `${role}-provider-inspection-selection-authority-mismatch`,
520
1227
  });
521
1228
  }
522
1229
  return Object.freeze({ available: true, authority });
@@ -525,83 +1232,232 @@ function createBrowserSpeechProvider({
525
1232
  status,
526
1233
 
527
1234
  load(context = {}) {
1235
+ let loadContext;
1236
+ try {
1237
+ loadContext = providerContext(context, role, "load");
1238
+ } catch (error) {
1239
+ return Promise.reject(trustedLoadFailure(error, role));
1240
+ }
1241
+ const loadSignal = loadContext.signal;
1242
+ let contextRole;
1243
+ try {
1244
+ contextRole = context.role;
1245
+ } catch (error) {
1246
+ return Promise.reject(trustedLoadFailure(error, role));
1247
+ }
1248
+ if (contextRole !== role) {
1249
+ return Promise.reject(providerError(
1250
+ "ARCANE_AI_INVALID_REQUEST",
1251
+ `Browser ${role} load role does not match the provider.`,
1252
+ undefined,
1253
+ `${role}-provider-load-role-mismatch`,
1254
+ ));
1255
+ }
528
1256
  if (state === "disposed" || disposeOperation) {
529
- return Promise.reject(providerError("ARCANE_AI_PROVIDER_DISPOSED", "The browser speech provider is disposed."));
1257
+ return Promise.reject(providerError(
1258
+ "ARCANE_AI_PROVIDER_DISPOSED",
1259
+ "The browser speech provider is disposed.",
1260
+ undefined,
1261
+ `${role}-provider-load-rejected-after-dispose`,
1262
+ ));
530
1263
  }
531
1264
  if (unloadOperation || state === "unloading") {
532
1265
  return Promise.reject(providerError(
533
1266
  "ARCANE_AI_OPERATION_SUPERSEDED",
534
1267
  "The browser speech provider is unloading.",
1268
+ undefined,
1269
+ `${role}-load-rejected-during-unload`,
535
1270
  ));
536
1271
  }
537
- if (typeof context.progress !== "function") {
538
- return Promise.reject(new TypeError("Browser speech load progress must be a function."));
1272
+ let loadProgress;
1273
+ let selectionProviderId;
1274
+ let selectionModelId;
1275
+ let selectionLocalOnly;
1276
+ let selectionRoleMismatch;
1277
+ try {
1278
+ loadProgress = context.progress;
1279
+ const selection = context.selection;
1280
+ selectionProviderId = selection?.providerId;
1281
+ selectionModelId = selection?.modelId;
1282
+ selectionLocalOnly = selection?.localOnly;
1283
+ selectionRoleMismatch = Object.hasOwn(selection ?? {}, "role")
1284
+ && selection.role !== role;
1285
+ } catch (error) {
1286
+ return Promise.reject(trustedLoadFailure(error, role));
1287
+ }
1288
+ if (typeof loadProgress !== "function") {
1289
+ return Promise.reject(providerError(
1290
+ "ARCANE_AI_INVALID_REQUEST",
1291
+ "Browser speech load progress must be a function.",
1292
+ undefined,
1293
+ `${role}-load-progress-callback-not-function`,
1294
+ ));
539
1295
  }
540
- if (context.role !== role
541
- || context.selection?.providerId !== providerId
542
- || context.selection?.modelId !== authority.modelId
543
- || context.selection?.localOnly === false) {
544
- return Promise.reject(providerError("ARCANE_AI_MODEL_AUTHORITY_REQUIRED", "Browser speech load selection changed."));
1296
+ if (selectionProviderId !== providerId
1297
+ || selectionModelId !== authority.modelId
1298
+ || selectionRoleMismatch
1299
+ || selectionLocalOnly === false) {
1300
+ return Promise.reject(providerError(
1301
+ "ARCANE_AI_MODEL_AUTHORITY_REQUIRED",
1302
+ "Browser speech load selection changed.",
1303
+ undefined,
1304
+ `${role}-load-selection-authority-mismatch`,
1305
+ ));
545
1306
  }
546
1307
  let effectiveSecurity;
547
1308
  try {
548
- effectiveSecurity = resolveModelSecurity({
549
- app: configuredAppSecurity,
550
- binding: configuredProviderSecurity,
551
- load: context.security,
552
- });
553
- throwIfAborted(context.signal);
1309
+ if (authority.graph) {
1310
+ effectiveSecurity = resolveModelSecurity({
1311
+ app: graphSecurity(
1312
+ configuredAppSecurity,
1313
+ "Browser speech graph app security",
1314
+ ),
1315
+ binding: graphSecurity(
1316
+ configuredProviderSecurity,
1317
+ "Browser speech graph provider security",
1318
+ ),
1319
+ load: graphSecurity(
1320
+ context.security,
1321
+ "Browser speech graph load security",
1322
+ ),
1323
+ });
1324
+ } else {
1325
+ effectiveSecurity = resolveModelSecurity({
1326
+ app: configuredAppSecurity,
1327
+ binding: configuredProviderSecurity,
1328
+ load: context.security,
1329
+ });
1330
+ }
1331
+ throwIfAborted(loadSignal, `${role}-load-cancelled`);
554
1332
  } catch (error) {
555
- return Promise.reject(error);
1333
+ return Promise.reject(trustedLoadFailure(error, role));
556
1334
  }
557
1335
  if (state === "ready" && active) {
558
1336
  if (sameModelSecurity(active.security, effectiveSecurity)) {
559
1337
  return Promise.resolve(status());
560
1338
  }
1339
+ lifecycleReason = `${role}-load-superseded-by-security-change`;
561
1340
  return provider.unload().then(() => provider.load(context));
562
1341
  }
563
1342
  if (loadOperation) {
564
1343
  if (sameModelSecurity(loadOperation.security, effectiveSecurity)) {
565
- return loadOperation.promise;
1344
+ return observeLoadOperation(loadOperation, {
1345
+ signal: loadSignal,
1346
+ progress: loadProgress,
1347
+ }, role);
566
1348
  }
567
- loadOperation.abort();
1349
+ lifecycleReason = `${role}-load-superseded-by-security-change`;
1350
+ loadOperation.abort(
1351
+ `${role}-load-superseded-by-security-change`,
1352
+ "ARCANE_AI_OPERATION_SUPERSEDED",
1353
+ );
568
1354
  return loadOperation.promise.catch(() => undefined).then(() => provider.load(context));
569
1355
  }
570
1356
  generation += 1;
571
1357
  const operationGeneration = generation;
572
- const linked = linkSignal(context.signal);
1358
+ const linked = linkSignal(null);
573
1359
  state = "loading";
1360
+ lifecycleReason = `${role}-load-started`;
1361
+ activeOperation = `${role}-provider-load`;
574
1362
  errorCode = null;
575
- const promise = (async () => {
1363
+ const record = {
1364
+ promise: null,
1365
+ security: effectiveSecurity,
1366
+ integrity: providerIntegrity(effectiveSecurity),
1367
+ warnings: NO_PROVIDER_WARNINGS,
1368
+ observers: new Set(),
1369
+ settled: false,
1370
+ hasProgress: false,
1371
+ latestProgress: null,
1372
+ abort: (
1373
+ reason = `${role}-load-cancelled`,
1374
+ code = "ARCANE_AI_REQUEST_ABORTED",
1375
+ ) => linked.abort(reason, code),
1376
+ publishProgress(progress) {
1377
+ record.latestProgress = progress;
1378
+ record.hasProgress = true;
1379
+ for (const observer of [...record.observers]) observer.progress(progress);
1380
+ },
1381
+ };
1382
+ lastIntegrity = Object.freeze({
1383
+ security: effectiveSecurity,
1384
+ integrity: record.integrity,
1385
+ });
1386
+ lastWarnings = NO_PROVIDER_WARNINGS;
1387
+ const promise = Promise.resolve().then(async () => {
576
1388
  let prepared = null;
577
1389
  let slot = null;
578
1390
  try {
579
- prepared = await store.prepare(authority, {
1391
+ prepared = await store.prepare(authority.graph ?? authority, {
580
1392
  signal: linked.controller.signal,
581
- onProgress: context.progress,
1393
+ onProgress: record.publishProgress,
582
1394
  offline,
583
1395
  security: effectiveSecurity,
584
1396
  });
585
- throwIfAborted(linked.controller.signal);
1397
+ record.integrity = providerIntegrity(effectiveSecurity, "verified");
1398
+ record.warnings = Array.isArray(prepared.warnings)
1399
+ ? Object.freeze([...prepared.warnings])
1400
+ : NO_PROVIDER_WARNINGS;
1401
+ lastIntegrity = Object.freeze({
1402
+ security: effectiveSecurity,
1403
+ integrity: record.integrity,
1404
+ });
1405
+ lastWarnings = record.warnings;
1406
+ throwIfAborted(
1407
+ linked.controller.signal,
1408
+ () => linked.reason(`${role}-load-cancelled`),
1409
+ );
586
1410
  if (operationGeneration !== generation) {
587
- throw providerError("ARCANE_AI_OPERATION_SUPERSEDED", "Browser speech loading was superseded.");
1411
+ throw providerError(
1412
+ "ARCANE_AI_OPERATION_SUPERSEDED",
1413
+ "Browser speech loading was superseded.",
1414
+ undefined,
1415
+ lifecycleReason === `${role}-load-superseded-by-security-change`
1416
+ ? lifecycleReason
1417
+ : `${role}-load-superseded-by-unload`,
1418
+ );
588
1419
  }
589
1420
  slot = {
590
1421
  prepared,
591
1422
  released: false,
592
1423
  client: null,
593
1424
  security: effectiveSecurity,
1425
+ integrity: record.integrity,
1426
+ warnings: record.warnings,
594
1427
  };
595
1428
  slot.client = createSpeechWorkerClient({
596
1429
  role,
597
1430
  onTermination({ reason, intentional }) {
1431
+ const trustedReason = trustedWorkerFailure(reason, role);
598
1432
  if (active === slot) {
599
1433
  active = null;
600
1434
  if (state !== "disposed" && state !== "unloading") {
601
1435
  state = intentional ? "unloaded" : "error";
602
- errorCode = intentional ? null : workerFailureCode(reason);
1436
+ errorCode = intentional ? null : workerFailureCode(trustedReason);
1437
+ lifecycleReason = intentional
1438
+ ? `${role}-worker-terminated`
1439
+ : trustedReason.reason ?? (errorCode === "ARCANE_AI_WORKER_MESSAGE_ERROR"
1440
+ || errorCode === "ARCANE_AI_WORKER_MESSAGE_REJECTED"
1441
+ ? `${role}-worker-message-rejected`
1442
+ : errorCode === "ARCANE_AI_ADAPTER_PROTOCOL_MISMATCH"
1443
+ ? `${role}-worker-protocol-mismatch`
1444
+ : `${role}-worker-crashed`);
1445
+ activeOperation = null;
1446
+ artifactGraphAdmission = null;
1447
+ }
1448
+ try {
1449
+ releaseSlot(slot);
1450
+ } catch (error) {
1451
+ const releaseFailure = trustedWorkerFailure(error, role);
1452
+ if (state !== "disposed" && state !== "unloading") {
1453
+ state = "error";
1454
+ errorCode = releaseFailure.code;
1455
+ lifecycleReason = releaseFailure.reason;
1456
+ activeOperation = null;
1457
+ artifactGraphAdmission = null;
1458
+ }
1459
+ throw releaseFailure;
603
1460
  }
604
- releaseSlot(slot);
605
1461
  }
606
1462
  },
607
1463
  });
@@ -609,146 +1465,338 @@ function createBrowserSpeechProvider({
609
1465
  throw providerError(
610
1466
  "ARCANE_AI_ADAPTER_PROTOCOL_MISMATCH",
611
1467
  "The browser speech Worker client is not SDK-owned.",
1468
+ undefined,
1469
+ `${role}-worker-client-authority-mismatch`,
612
1470
  );
613
1471
  }
614
1472
  const configuration = Object.freeze({
615
1473
  role,
616
1474
  runtime: prepared.runtime,
617
1475
  model: prepared.model,
1476
+ security: effectiveSecurity,
1477
+ ...(authority.graph ? {
1478
+ artifactGraphId: authority.artifactGraphId,
1479
+ artifactGraphProtocol: authority.graph.protocol,
1480
+ } : {}),
618
1481
  });
619
1482
  await slot.client.request("load", { configuration }, {
620
1483
  signal: linked.controller.signal,
621
- progress: context.progress,
1484
+ progress: record.publishProgress,
622
1485
  });
623
- throwIfAborted(linked.controller.signal);
1486
+ throwIfAborted(
1487
+ linked.controller.signal,
1488
+ () => linked.reason(`${role}-load-cancelled`),
1489
+ );
624
1490
  if (operationGeneration !== generation) {
625
- throw providerError("ARCANE_AI_OPERATION_SUPERSEDED", "Browser speech loading was superseded.");
1491
+ throw providerError(
1492
+ "ARCANE_AI_OPERATION_SUPERSEDED",
1493
+ "Browser speech loading was superseded.",
1494
+ undefined,
1495
+ lifecycleReason === `${role}-load-superseded-by-security-change`
1496
+ ? lifecycleReason
1497
+ : `${role}-load-superseded-by-unload`,
1498
+ );
626
1499
  }
627
1500
  active = slot;
628
1501
  cache = prepared.cache;
1502
+ artifactGraphAdmission = prepared.artifactGraphAdmission ?? null;
629
1503
  state = "ready";
1504
+ lifecycleReason = `${role}-load-completed`;
1505
+ activeOperation = null;
630
1506
  return status();
631
1507
  } catch (error) {
632
- if (slot) await terminateSlot(slot, error).catch(() => undefined);
633
- else prepared?.release();
1508
+ let failure = linked.controller.signal.aborted
1509
+ ? linkedAbortFailure(linked, `${role}-load-cancelled`)
1510
+ : trustedLoadFailure(error, role);
1511
+ if (!linked.controller.signal.aborted && prepared === null) {
1512
+ record.integrity = providerIntegrity(effectiveSecurity, "failed");
1513
+ lastIntegrity = Object.freeze({
1514
+ security: effectiveSecurity,
1515
+ integrity: record.integrity,
1516
+ });
1517
+ }
1518
+ try {
1519
+ if (slot) await terminateSlot(slot, failure);
1520
+ else prepared?.release();
1521
+ } catch (cleanupError) {
1522
+ failure = slot
1523
+ ? trustedWorkerFailure(cleanupError, role)
1524
+ : trustedLoadFailure(cleanupError, role);
1525
+ }
634
1526
  if (operationGeneration === generation && state !== "unloading" && state !== "disposed") {
635
- state = error?.code === "ARCANE_AI_REQUEST_ABORTED"
636
- || error?.code === "ARCANE_AI_OPERATION_SUPERSEDED"
1527
+ state = failure?.code === "ARCANE_AI_REQUEST_ABORTED"
1528
+ || failure?.code === "ARCANE_AI_OPERATION_SUPERSEDED"
637
1529
  ? "unloaded"
638
1530
  : "error";
639
- errorCode = error?.code ?? "ARCANE_AI_PROVIDER_LOAD_FAILED";
1531
+ errorCode = failure?.code ?? "ARCANE_AI_PROVIDER_LOAD_FAILED";
1532
+ lifecycleReason = failure?.reason
1533
+ ?? (state === "unloaded" ? `${role}-load-cancelled` : `${role}-load-rejected`);
1534
+ activeOperation = null;
640
1535
  }
641
- throw error;
1536
+ throw failure;
642
1537
  } finally {
1538
+ record.settled = true;
643
1539
  linked.release();
644
- if (loadOperation?.promise === promise) loadOperation = null;
1540
+ if (loadOperation === record) loadOperation = null;
645
1541
  }
646
- })();
647
- loadOperation = Object.freeze({
648
- promise,
649
- security: effectiveSecurity,
650
- abort: () => linked.controller.abort(),
651
1542
  });
652
- return promise;
1543
+ record.promise = promise;
1544
+ loadOperation = record;
1545
+ return observeLoadOperation(record, {
1546
+ signal: loadSignal,
1547
+ progress: loadProgress,
1548
+ }, role);
653
1549
  },
654
1550
 
655
1551
  async request(context = {}) {
656
- if (context.role !== role || context.operation !== operation) {
657
- throw providerError("ARCANE_AI_INVALID_REQUEST", `Browser ${role} supports only ${operation}.`);
1552
+ let requestContext;
1553
+ try {
1554
+ requestContext = providerContext(context, role, "request");
1555
+ if (context.role !== role) {
1556
+ throw providerError(
1557
+ "ARCANE_AI_INVALID_REQUEST",
1558
+ `Browser ${role} request role does not match the provider.`,
1559
+ undefined,
1560
+ `${role}-provider-request-role-mismatch`,
1561
+ );
1562
+ }
1563
+ if (context.operation !== operation) {
1564
+ throw providerError(
1565
+ "ARCANE_AI_INVALID_REQUEST",
1566
+ `Browser ${role} supports only ${operation}.`,
1567
+ undefined,
1568
+ `${role}-provider-operation-mismatch`,
1569
+ );
1570
+ }
1571
+ assertRequestAuthority(
1572
+ context,
1573
+ authority,
1574
+ providerId,
1575
+ `${role}-provider-request-selection-authority-mismatch`,
1576
+ );
1577
+ throwIfAborted(requestContext.signal, ROLE_REQUEST_REASON[role]);
1578
+ } catch (error) {
1579
+ throw trustedRequestFailure(error, role);
658
1580
  }
659
- assertRequestAuthority(context, authority, providerId);
660
- const externalSignal = context.signal ?? null;
661
- throwIfAborted(externalSignal);
1581
+ const externalSignal = requestContext.signal;
662
1582
  if (state !== "ready" || !active) {
663
- throw providerError("ARCANE_AI_NOT_READY", "The browser speech provider is not ready.");
1583
+ throw providerError(
1584
+ "ARCANE_AI_NOT_READY",
1585
+ "The browser speech provider is not ready.",
1586
+ undefined,
1587
+ `${role}-provider-request-not-ready`,
1588
+ );
664
1589
  }
665
1590
  if (requestOperation) {
666
- throw providerError("ARCANE_AI_PROVIDER_BUSY", "The browser speech provider is already processing a request.");
1591
+ throw providerError(
1592
+ "ARCANE_AI_PROVIDER_BUSY",
1593
+ "The browser speech provider is already processing a request.",
1594
+ undefined,
1595
+ `${role}-provider-request-already-active`,
1596
+ );
1597
+ }
1598
+ let linked;
1599
+ try {
1600
+ linked = linkSignal(externalSignal);
1601
+ } catch (error) {
1602
+ throw trustedRequestFailure(error, role);
667
1603
  }
668
- const linked = linkSignal(externalSignal);
669
1604
  if (linked.controller.signal.aborted) {
670
1605
  linked.release();
671
- throw abortError(externalSignal);
1606
+ throw linkedAbortFailure(linked, ROLE_REQUEST_REASON[role]);
672
1607
  }
673
1608
  const slot = active;
674
1609
  const requestGeneration = generation;
675
1610
  let workerRequestStarted = false;
1611
+ activeOperation = role === "stt"
1612
+ ? "stt-provider-transcription"
1613
+ : "tts-provider-synthesis";
1614
+ lifecycleReason = role === "stt"
1615
+ ? "stt-transcription-started"
1616
+ : "tts-synthesis-started";
676
1617
  const promise = (async () => {
677
1618
  const normalized = await normalizeRequestPayload(
678
1619
  role,
679
1620
  context.payload,
680
1621
  authority,
681
1622
  linked.controller.signal,
1623
+ () => linked.reason(ROLE_REQUEST_REASON[role]),
1624
+ );
1625
+ throwIfAborted(
1626
+ linked.controller.signal,
1627
+ () => linked.reason(ROLE_REQUEST_REASON[role]),
682
1628
  );
683
- throwIfAborted(linked.controller.signal);
684
1629
  workerRequestStarted = true;
685
1630
  const result = await slot.client.request("use", normalized.payload, {
686
1631
  signal: linked.controller.signal,
687
1632
  });
688
1633
  if (requestGeneration !== generation || active !== slot) {
689
- throw providerError("ARCANE_AI_OPERATION_SUPERSEDED", "The browser speech result was superseded.");
1634
+ throw providerError(
1635
+ "ARCANE_AI_OPERATION_SUPERSEDED",
1636
+ "The browser speech result was superseded.",
1637
+ undefined,
1638
+ role === "stt"
1639
+ ? "stt-transcription-superseded-by-unload"
1640
+ : "tts-synthesis-superseded-by-unload",
1641
+ );
690
1642
  }
691
1643
  return role === "tts" && normalized.shared
692
- ? encodeSharedSynthesisResult(result)
1644
+ ? encodeSharedSynthesisResult(result, authority)
693
1645
  : result;
694
1646
  })();
695
1647
  requestOperation = Object.freeze({
696
1648
  promise,
697
- abort: () => linked.controller.abort(),
1649
+ abort: (
1650
+ reason = ROLE_REQUEST_REASON[role],
1651
+ code = "ARCANE_AI_REQUEST_ABORTED",
1652
+ ) => linked.abort(reason, code),
698
1653
  });
699
1654
  try {
700
- return await promise;
1655
+ const result = await promise;
1656
+ lifecycleReason = role === "stt"
1657
+ ? "stt-transcription-completed"
1658
+ : "tts-synthesis-completed";
1659
+ return result;
701
1660
  } catch (error) {
702
- if (error?.code === "ARCANE_AI_REQUEST_ABORTED"
1661
+ let failure = linked.controller.signal.aborted
1662
+ && (error?.code === "ARCANE_AI_REQUEST_ABORTED"
1663
+ || linked.code() === "ARCANE_AI_OPERATION_SUPERSEDED")
1664
+ ? linkedAbortFailure(linked, ROLE_REQUEST_REASON[role])
1665
+ : trustedRequestFailure(error, role);
1666
+ if (failure?.code === "ARCANE_AI_REQUEST_ABORTED"
703
1667
  && workerRequestStarted
704
1668
  && active === slot) {
705
1669
  active = null;
706
- releaseSlot(slot);
1670
+ try {
1671
+ releaseSlot(slot);
1672
+ } catch (cleanupError) {
1673
+ failure = trustedWorkerFailure(cleanupError, role);
1674
+ }
707
1675
  state = "unloaded";
1676
+ artifactGraphAdmission = null;
708
1677
  }
709
- throw error;
1678
+ lifecycleReason = failure?.reason
1679
+ ?? (failure?.code === "ARCANE_AI_REQUEST_ABORTED"
1680
+ ? ROLE_REQUEST_REASON[role]
1681
+ : role === "stt"
1682
+ ? "stt-transcription-engine-operation-rejected"
1683
+ : "tts-synthesis-engine-operation-rejected");
1684
+ throw failure;
710
1685
  } finally {
711
1686
  linked.release();
712
1687
  if (requestOperation?.promise === promise) requestOperation = null;
1688
+ activeOperation = null;
713
1689
  }
714
1690
  },
715
1691
 
716
- unload() {
1692
+ unload(context = {}) {
1693
+ let unloadContext;
1694
+ try {
1695
+ unloadContext = providerContext(context, role, "unload");
1696
+ assertRequestAuthority(
1697
+ context,
1698
+ authority,
1699
+ providerId,
1700
+ `${role}-provider-unload-selection-authority-mismatch`,
1701
+ );
1702
+ throwIfAborted(unloadContext.signal, `${role}-unload-cancelled`);
1703
+ } catch (error) {
1704
+ return Promise.reject(trustedLifecycleFailure(error, role, "unload"));
1705
+ }
1706
+ const unloadSignal = unloadContext.signal;
717
1707
  if (state === "disposed") return Promise.resolve(status());
718
- if (unloadOperation) return unloadOperation;
1708
+ if (unloadOperation) {
1709
+ return awaitAbortableSpeechOperation(
1710
+ unloadOperation,
1711
+ unloadSignal,
1712
+ `${role}-unload-cancelled`,
1713
+ );
1714
+ }
719
1715
  generation += 1;
720
1716
  state = "unloading";
721
1717
  errorCode = null;
722
- loadOperation?.abort();
723
- requestOperation?.abort();
1718
+ activeOperation = `${role}-provider-unload`;
1719
+ const capturedLoad = loadOperation?.promise ?? null;
1720
+ const capturedRequest = requestOperation?.promise ?? null;
1721
+ if (loadOperation) {
1722
+ lifecycleReason = `${role}-load-superseded-by-unload`;
1723
+ loadOperation.abort(
1724
+ `${role}-load-superseded-by-unload`,
1725
+ "ARCANE_AI_OPERATION_SUPERSEDED",
1726
+ );
1727
+ } else if (requestOperation) {
1728
+ lifecycleReason = role === "stt"
1729
+ ? "stt-transcription-superseded-by-unload"
1730
+ : "tts-synthesis-superseded-by-unload";
1731
+ requestOperation.abort(lifecycleReason, "ARCANE_AI_OPERATION_SUPERSEDED");
1732
+ } else {
1733
+ lifecycleReason = `${role}-unload-started`;
1734
+ }
724
1735
  const promise = (async () => {
725
1736
  await Promise.allSettled([
726
- loadOperation?.promise,
727
- requestOperation?.promise,
1737
+ capturedLoad,
1738
+ capturedRequest,
728
1739
  ].filter(Boolean));
729
1740
  const slot = active;
730
1741
  active = null;
731
1742
  await terminateSlot(slot, providerError(
732
1743
  "ARCANE_AI_OPERATION_SUPERSEDED",
733
1744
  "The browser speech Worker was terminated by unload().",
1745
+ undefined,
1746
+ `${role}-worker-terminated-by-unload`,
734
1747
  ));
735
1748
  cache = null;
1749
+ artifactGraphAdmission = null;
736
1750
  state = "unloaded";
1751
+ lifecycleReason = `${role}-unload-completed`;
1752
+ activeOperation = null;
737
1753
  return status();
738
1754
  })();
739
- unloadOperation = promise.finally(() => {
740
- if (unloadOperation === promise || unloadOperation === wrapped) unloadOperation = null;
1755
+ const tracked = promise.then((value) => {
1756
+ if (unloadOperation === tracked) unloadOperation = null;
1757
+ return value;
1758
+ }, (error) => {
1759
+ if (unloadOperation === tracked) unloadOperation = null;
1760
+ throw error;
741
1761
  });
742
- const wrapped = unloadOperation;
743
- return wrapped;
1762
+ unloadOperation = tracked;
1763
+ return awaitAbortableSpeechOperation(
1764
+ tracked,
1765
+ unloadSignal,
1766
+ `${role}-unload-cancelled`,
1767
+ );
744
1768
  },
745
1769
 
746
- dispose() {
1770
+ dispose(context = {}) {
1771
+ let disposalContext;
1772
+ try {
1773
+ disposalContext = providerContext(context, role, "dispose");
1774
+ assertRequestAuthority(
1775
+ context,
1776
+ authority,
1777
+ providerId,
1778
+ `${role}-provider-dispose-selection-authority-mismatch`,
1779
+ );
1780
+ throwIfAborted(disposalContext.signal, `${role}-dispose-cancelled`);
1781
+ } catch (error) {
1782
+ return Promise.reject(trustedLifecycleFailure(error, role, "dispose"));
1783
+ }
1784
+ const disposeSignal = disposalContext.signal;
747
1785
  if (state === "disposed") return Promise.resolve(status());
748
- if (disposeOperation) return disposeOperation;
1786
+ if (disposeOperation) {
1787
+ return awaitAbortableSpeechOperation(
1788
+ disposeOperation,
1789
+ disposeSignal,
1790
+ `${role}-dispose-cancelled`,
1791
+ );
1792
+ }
749
1793
  const promise = (async () => {
1794
+ activeOperation = `${role}-provider-dispose`;
1795
+ lifecycleReason = `${role}-dispose-started`;
750
1796
  await provider.unload();
751
1797
  state = "disposed";
1798
+ lifecycleReason = `${role}-dispose-completed`;
1799
+ activeOperation = null;
752
1800
  return status();
753
1801
  })();
754
1802
  disposeOperation = promise.then((value) => {
@@ -758,7 +1806,11 @@ function createBrowserSpeechProvider({
758
1806
  disposeOperation = null;
759
1807
  throw error;
760
1808
  });
761
- return disposeOperation;
1809
+ return awaitAbortableSpeechOperation(
1810
+ disposeOperation,
1811
+ disposeSignal,
1812
+ `${role}-dispose-cancelled`,
1813
+ );
762
1814
  },
763
1815
  };
764
1816
  return Object.freeze(provider);