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