arcane-os 0.2.3 → 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 +17 -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 +20 -20
  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 +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 +143 -33
  30. package/docs/reference/runtime-components.md +192 -80
  31. package/docs/reference/runtime-modules.md +584 -70
  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 +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 +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 +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 +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
@@ -146,12 +146,17 @@
146
146
  <button id="muteButton" type="button" class="mute" aria-pressed="true" aria-label="Voice unavailable" disabled>Voice unavailable</button>
147
147
 
148
148
  <script type="module">
149
+ const {
150
+ createArcaneEventSource,
151
+ projectArcaneDOMEvent
152
+ } = await import('arcane-os/event-manager');
153
+
149
154
  void import('../modules/DBLS.js').catch(
150
155
  function reportDBLSImportFailure(error) {
151
156
  console.error('The shared database runtime could not load.');
152
157
  }
153
158
  );
154
- const aiModuleImport = import('../modules/AI.js?v=7');
159
+ const aiModuleImport = import('../modules/AI.js');
155
160
  const {
156
161
  requestAIRuntimeIntent,
157
162
  subscribeAIRuntimeState
@@ -161,6 +166,23 @@
161
166
  } = await import('../modules/ComponentContracts.js');
162
167
 
163
168
  const host = this;
169
+ const events = createArcaneEventSource(
170
+ host,
171
+ {
172
+ source: 'arcane.component.speech',
173
+ eventTypes: [
174
+ 'speech-ready',
175
+ 'speech-transcription-complete',
176
+ 'speech-transcription-error',
177
+ 'speech-transcription-cancelled',
178
+ 'speech-microphone-unavailable',
179
+ 'speech-stt-activation-request',
180
+ 'speech-stt-activation-error',
181
+ 'speech-tts-lifecycle-error',
182
+ 'speech-synthesis-error'
183
+ ]
184
+ }
185
+ );
164
186
  const shadowRoot = host.shadowRoot;
165
187
  const recordButton = shadowRoot.querySelector('#recordButton');
166
188
  const sttActivationButton = shadowRoot.querySelector('#sttActivationButton');
@@ -186,12 +208,99 @@
186
208
  let keyboardRecording = false;
187
209
  let localStatus = null;
188
210
  let pendingTTSIntent = null;
211
+ let activeTTSIntent = null;
212
+ let pendingUnmute = false;
189
213
  let ttsIntentGeneration = 0;
214
+ let eventOperationSequence = 0;
215
+ let transcriptionOperationId = null;
216
+ let ttsOperationId = null;
190
217
  let destroyed = false;
191
218
 
219
+ function nextSpeechOperationId(kind) {
220
+ eventOperationSequence += 1;
221
+ return `${events.descriptor.instanceId}:${kind}:${eventOperationSequence}`;
222
+ }
223
+
224
+ function dispatchSpeechEvent(
225
+ type,
226
+ compatibilityDetail = {},
227
+ {
228
+ publicDetail = {},
229
+ operationId = null,
230
+ bubbles = false,
231
+ composed = false,
232
+ cancelable = false
233
+ } = {}
234
+ ) {
235
+ if (destroyed && type !== 'speech-transcription-cancelled') {
236
+ return false;
237
+ }
238
+ const publication = events.dispatch(
239
+ type,
240
+ compatibilityDetail,
241
+ {
242
+ publicDetail,
243
+ operationId,
244
+ cancelable
245
+ }
246
+ );
247
+ if (!publication.accepted) {
248
+ return false;
249
+ }
250
+ return projectArcaneDOMEvent(
251
+ host,
252
+ publication.occurrence,
253
+ {
254
+ bubbles,
255
+ composed,
256
+ cancelable
257
+ }
258
+ );
259
+ }
260
+
261
+ function publicSpeechErrorFields(error, boundaryCode) {
262
+ let causeCode = '';
263
+ try {
264
+ causeCode = typeof error?.code === 'string'
265
+ ? error.code.trim()
266
+ : '';
267
+ } catch {}
268
+ return Object.freeze(
269
+ {
270
+ code: boundaryCode,
271
+ ...(causeCode && causeCode !== boundaryCode
272
+ ? { causeCode }
273
+ : {})
274
+ }
275
+ );
276
+ }
277
+
278
+ function microphoneFailureReason(error) {
279
+ if (error?.name === 'NotFoundError') {
280
+ return 'microphone-input-missing';
281
+ }
282
+ if (error?.name === 'NotReadableError') {
283
+ return 'microphone-input-unavailable';
284
+ }
285
+ if (['NotAllowedError', 'SecurityError'].includes(error?.name)) {
286
+ return 'microphone-capture-denied';
287
+ }
288
+ return 'microphone-capture-rejected';
289
+ }
290
+
291
+ function canonicalSTTCancellationReason(reason) {
292
+ if (reason === 'runtime-unready') {
293
+ return 'stt-role-unready';
294
+ }
295
+ if (reason === 'stt-provider-request-cancelled') {
296
+ return 'stt-provider-transcription-cancelled';
297
+ }
298
+ return String(reason || 'stt-transcription-cancelled');
299
+ }
300
+
192
301
  const initialMutedWasHydrated = typeof host.initialMuted === 'boolean';
193
302
  host.initialMuted = initialMutedWasHydrated ? host.initialMuted : true;
194
- host.muted = host.initialMuted;
303
+ host.muted = true;
195
304
  host.configure = configure;
196
305
  host.destroy = destroy;
197
306
  host.reportTTSError = reportTTSError;
@@ -213,20 +322,29 @@
213
322
  {
214
323
  host,
215
324
  button: sttActivationButton,
216
- onChange: renderSTTActivationState
325
+ onChange: renderSTTActivationState,
326
+ eventSource: events
217
327
  }
218
328
  );
219
329
 
220
- recordButton.addEventListener('pointerdown', handleRecordPointerDown);
221
- recordButton.addEventListener('pointerup', handleRecordPointerUp);
222
- recordButton.addEventListener('pointercancel', handleRecordPointerCancel);
223
- recordButton.addEventListener('lostpointercapture', handleLostPointerCapture);
224
- recordButton.addEventListener('keydown', handleRecordKeyDown);
225
- recordButton.addEventListener('keyup', handleRecordKeyUp);
226
- recordButton.addEventListener('blur', handleRecordBlur);
227
- muteButton.addEventListener('click', muteToggle);
228
- endButton.addEventListener('click', submitCompletionCommand);
229
- window.addEventListener('pagehide', destroy, { once: true });
330
+ const lifecycleListenerOptions = Object.freeze(
331
+ { signal: runtimeStateAbortController.signal }
332
+ );
333
+ recordButton.addEventListener('pointerdown', handleRecordPointerDown, lifecycleListenerOptions);
334
+ recordButton.addEventListener('pointerup', handleRecordPointerUp, lifecycleListenerOptions);
335
+ recordButton.addEventListener('pointercancel', handleRecordPointerCancel, lifecycleListenerOptions);
336
+ recordButton.addEventListener('lostpointercapture', handleLostPointerCapture, lifecycleListenerOptions);
337
+ recordButton.addEventListener('keydown', handleRecordKeyDown, lifecycleListenerOptions);
338
+ recordButton.addEventListener('keyup', handleRecordKeyUp, lifecycleListenerOptions);
339
+ recordButton.addEventListener('blur', handleRecordBlur, lifecycleListenerOptions);
340
+ stopButton.addEventListener('click', stop, lifecycleListenerOptions);
341
+ muteButton.addEventListener('click', muteToggle, lifecycleListenerOptions);
342
+ endButton.addEventListener('click', submitCompletionCommand, lifecycleListenerOptions);
343
+ window.addEventListener(
344
+ 'pagehide',
345
+ destroy,
346
+ { once: true, signal: runtimeStateAbortController.signal }
347
+ );
230
348
 
231
349
  subscribeAIRuntimeState(
232
350
  synchronizeAIRuntimeState,
@@ -234,27 +352,36 @@
234
352
  signal: runtimeStateAbortController.signal
235
353
  }
236
354
  );
355
+ if (!host.initialMuted && ttsRole.state === 'ready') {
356
+ host.muted = false;
357
+ }
237
358
  synchronizeAIMutedState();
238
359
  renderControls();
239
360
  renderStatus();
240
361
  void observeMicrophonePermission();
241
362
  void aiModuleImport.then(
242
- synchronizeAIMutedState,
363
+ function synchronizeMutedStateAfterAIImport() {
364
+ if (!destroyed) {
365
+ synchronizeAIMutedState();
366
+ }
367
+ },
243
368
  function reportAIModuleImportFailure(error) {
369
+ if (destroyed) {
370
+ return;
371
+ }
244
372
  console.error('The shared AI runtime could not load.');
245
373
  }
246
374
  );
247
375
 
248
376
  host.componentReady = true;
249
- host.dispatchEvent(
250
- new CustomEvent(
251
- 'speech-ready',
252
- {
253
- bubbles: true,
254
- composed: true,
255
- detail: { speech: host }
256
- }
257
- )
377
+ dispatchSpeechEvent(
378
+ 'speech-ready',
379
+ { speech: host },
380
+ {
381
+ bubbles: true,
382
+ composed: true,
383
+ publicDetail: { ready: true }
384
+ }
258
385
  );
259
386
 
260
387
  function unavailableRole(role) {
@@ -282,6 +409,9 @@
282
409
  }
283
410
 
284
411
  function configure(options = {}) {
412
+ if (destroyed) {
413
+ return host;
414
+ }
285
415
  if (Object.prototype.hasOwnProperty.call(options, 'initialMuted')) {
286
416
  const configuredMuted = typeof options.initialMuted === 'boolean'
287
417
  ? options.initialMuted
@@ -302,6 +432,9 @@
302
432
  }
303
433
 
304
434
  function setAvailability(input = {}) {
435
+ if (destroyed) {
436
+ return { ...host.availability };
437
+ }
305
438
  // Compatibility-only explicit adapter report. Canonical consumers use
306
439
  // the sticky AIRuntimeState role records instead of this wrapper.
307
440
  const previousSTTRole = sttRole;
@@ -330,6 +463,9 @@
330
463
  }
331
464
 
332
465
  function setMuted(muted) {
466
+ if (destroyed) {
467
+ return host;
468
+ }
333
469
  if (typeof muted !== 'boolean') {
334
470
  throw new TypeError('muted must be boolean');
335
471
  }
@@ -343,11 +479,11 @@
343
479
  }
344
480
 
345
481
  function applyConfiguredMutedState(muted) {
346
- host.muted = muted;
347
- pendingTTSIntent = null;
348
482
  if (muted) {
349
- stopTTSPlayback();
483
+ requestUserMute();
350
484
  } else {
485
+ pendingUnmute = false;
486
+ host.muted = ttsRole.state !== 'ready';
351
487
  synchronizeAIMutedState();
352
488
  }
353
489
  renderControls();
@@ -377,14 +513,18 @@
377
513
  cancelSTTOperation('runtime-unready');
378
514
  }
379
515
  if (ttsBecameUnready) {
516
+ pendingUnmute = false;
380
517
  host.muted = true;
381
518
  stopTTSPlayback();
382
519
  }
383
- if (ttsRole.state === 'error' && !host.muted) {
520
+ if (ttsRole.state === 'error' && (pendingUnmute || !host.muted)) {
521
+ pendingUnmute = false;
384
522
  host.muted = true;
385
523
  stopTTSPlayback();
386
524
  }
387
525
 
526
+ settleSuccessfulUnmute();
527
+
388
528
  synchronizeAIMutedState();
389
529
  renderControls();
390
530
  renderStatus();
@@ -415,7 +555,9 @@
415
555
 
416
556
  function renderControls() {
417
557
  const transcriptionReady = sttRole.state === 'ready';
418
- const microphoneReady = host.availability.microphone;
558
+ const microphoneReady = host.availability.microphone
559
+ && typeof globalThis.MediaRecorder === 'function'
560
+ && typeof navigator.mediaDevices?.getUserMedia === 'function';
419
561
  const activationVisible = sttActivationController.visible;
420
562
  recordButton.hidden = activationVisible;
421
563
  recordButton.disabled = !transcriptionReady
@@ -423,7 +565,10 @@
423
565
  || sttRole.busy
424
566
  || transcriptionActive
425
567
  || destroyed;
426
- stopButton.disabled = recordButton.disabled;
568
+ stopButton.disabled = destroyed
569
+ || !transcriptionReady
570
+ || sttRole.busy
571
+ || captureSession?.recorder.state !== 'recording';
427
572
  recordButton.title = transcriptionButtonTitle();
428
573
  recordButton.setAttribute('aria-label', recordButton.title);
429
574
  recordButton.setAttribute(
@@ -618,13 +763,18 @@
618
763
  }
619
764
 
620
765
  try {
621
- microphonePermissionStatus = await navigator.permissions.query(
766
+ const permissionStatus = await navigator.permissions.query(
622
767
  { name: 'microphone' }
623
768
  );
769
+ if (destroyed) {
770
+ return;
771
+ }
772
+ microphonePermissionStatus = permissionStatus;
624
773
  microphonePermissionListener = synchronizeMicrophonePermission;
625
774
  microphonePermissionStatus.addEventListener?.(
626
775
  'change',
627
- microphonePermissionListener
776
+ microphonePermissionListener,
777
+ { signal: runtimeStateAbortController.signal }
628
778
  );
629
779
  synchronizeMicrophonePermission();
630
780
  } catch (error) {
@@ -634,6 +784,9 @@
634
784
  }
635
785
 
636
786
  function synchronizeMicrophonePermission() {
787
+ if (destroyed) {
788
+ return;
789
+ }
637
790
  setAvailability(
638
791
  {
639
792
  microphone: microphonePermissionStatus?.state !== 'denied'
@@ -739,27 +892,49 @@
739
892
  }
740
893
 
741
894
  if (host.muted) {
742
- requestUserUnmute();
895
+ void requestUserUnmute();
743
896
  } else {
744
897
  requestUserMute();
745
898
  }
746
899
  }
747
900
 
748
901
  function requestUserMute() {
902
+ if (destroyed) {
903
+ return false;
904
+ }
905
+ if (host.muted && activeTTSIntent?.action === 'unload') {
906
+ return false;
907
+ }
749
908
  const wasMuted = host.muted;
909
+ pendingUnmute = false;
750
910
  const shouldUnload = !wasMuted
751
911
  || pendingTTSIntent === 'load'
752
912
  || ['loading', 'ready', 'error'].includes(ttsRole.state);
753
913
  host.muted = true;
754
914
  stopTTSPlayback();
755
915
  if (shouldUnload) {
756
- requestTTSIntent('unload', 'user');
916
+ void requestTTSIntent('unload', 'user');
757
917
  }
758
918
  renderControls();
759
919
  renderStatus();
760
920
  }
761
921
 
762
- function requestUserUnmute() {
922
+ async function requestUserUnmute() {
923
+ if (destroyed) {
924
+ return false;
925
+ }
926
+ if (!host.muted && ttsRole.state === 'ready') {
927
+ return true;
928
+ }
929
+ if (activeTTSIntent?.action === 'load') {
930
+ return false;
931
+ }
932
+ if (
933
+ pendingUnmute
934
+ && (pendingTTSIntent === 'load' || ttsRole.state === 'loading')
935
+ ) {
936
+ return false;
937
+ }
763
938
  const providerSelected = typeof ttsRole.providerId === 'string'
764
939
  && ttsRole.providerId.length > 0
765
940
  && typeof ttsRole.modelId === 'string'
@@ -779,60 +954,93 @@
779
954
  return false;
780
955
  }
781
956
 
782
- host.muted = false;
783
- synchronizeAIMutedState();
784
- requestTTSIntent('load', 'user');
785
- if (ttsRole.state === 'ready') {
786
- resumeTTSPlayback();
957
+ pendingUnmute = true;
958
+ const requested = await requestTTSIntent('load', 'user');
959
+ if (!requested || destroyed) {
960
+ pendingUnmute = false;
961
+ host.muted = true;
962
+ stopTTSPlayback();
963
+ renderControls();
964
+ renderStatus();
965
+ return false;
787
966
  }
967
+ settleSuccessfulUnmute();
788
968
  renderControls();
789
969
  renderStatus();
970
+ return host.muted === false;
971
+ }
972
+
973
+ function settleSuccessfulUnmute() {
974
+ if (!pendingUnmute || ttsRole.state !== 'ready' || destroyed) {
975
+ return false;
976
+ }
977
+ pendingUnmute = false;
978
+ pendingTTSIntent = null;
979
+ host.muted = false;
980
+ synchronizeAIMutedState();
981
+ resumeTTSPlayback();
790
982
  return true;
791
983
  }
792
984
 
793
- function requestTTSIntent(action, reason) {
985
+ async function requestTTSIntent(action, reason) {
986
+ if (destroyed) {
987
+ return false;
988
+ }
989
+ if (activeTTSIntent?.action === action) {
990
+ return false;
991
+ }
794
992
  const generation = ++ttsIntentGeneration;
993
+ const operationId = nextSpeechOperationId(`tts-${action}`);
994
+ const intent = Object.freeze({ action, generation, operationId });
995
+ activeTTSIntent = intent;
996
+ ttsOperationId = operationId;
795
997
  pendingTTSIntent = action === 'load' && ttsRole.state === 'ready'
796
998
  ? null
797
999
  : action;
798
- let lifecycle = null;
1000
+ let lifecycle;
799
1001
  try {
800
1002
  if (typeof globalThis.ai?.setSpeechMuted === 'function') {
801
1003
  lifecycle = globalThis.ai.setSpeechMuted(action === 'unload');
1004
+ } else {
1005
+ lifecycle = requestAIRuntimeIntent(
1006
+ {
1007
+ role: 'tts',
1008
+ action,
1009
+ reason
1010
+ }
1011
+ );
802
1012
  }
803
- requestAIRuntimeIntent(
804
- {
805
- role: 'tts',
806
- action,
807
- reason
808
- }
809
- );
1013
+ await lifecycle;
810
1014
  } catch (error) {
1015
+ if (destroyed || generation !== ttsIntentGeneration) {
1016
+ return false;
1017
+ }
811
1018
  pendingTTSIntent = null;
812
1019
  if (action === 'load') {
1020
+ pendingUnmute = false;
813
1021
  host.muted = true;
814
1022
  stopTTSPlayback();
815
1023
  }
816
- reportTTSLifecycleError(error, action);
1024
+ reportTTSLifecycleError(error, action, operationId);
817
1025
  return false;
818
- }
819
- void Promise.resolve(lifecycle).catch(
820
- function reportCurrentTTSSpeechLifecycleFailure(error) {
821
- if (destroyed || generation !== ttsIntentGeneration) {
822
- return;
823
- }
824
- pendingTTSIntent = null;
825
- if (action === 'load') {
826
- host.muted = true;
827
- stopTTSPlayback();
828
- }
829
- reportTTSLifecycleError(error, action);
1026
+ } finally {
1027
+ if (activeTTSIntent === intent) {
1028
+ activeTTSIntent = null;
830
1029
  }
831
- );
1030
+ }
1031
+ if (destroyed || generation !== ttsIntentGeneration) {
1032
+ return false;
1033
+ }
1034
+ if (action === 'load') {
1035
+ settleSuccessfulUnmute();
1036
+ }
832
1037
  return true;
833
1038
  }
834
1039
 
835
- function reportTTSLifecycleError(error, action) {
1040
+ function reportTTSLifecycleError(error, action, operationId = null) {
1041
+ if (destroyed) {
1042
+ return false;
1043
+ }
836
1044
  const message = visibleErrorMessage(
837
1045
  error,
838
1046
  `The voice ${action} request failed.`
@@ -841,47 +1049,80 @@
841
1049
  message,
842
1050
  tone: 'error'
843
1051
  };
844
- host.dispatchEvent(
845
- new CustomEvent(
846
- 'speech-tts-lifecycle-error',
847
- {
848
- bubbles: true,
849
- composed: true,
850
- detail: {
851
- action,
852
- error,
853
- message
854
- }
1052
+ const errorFields = publicSpeechErrorFields(
1053
+ error,
1054
+ `ARCANE_SPEECH_TTS_${action.toUpperCase()}_REJECTED`
1055
+ );
1056
+ const reason = `tts-${action}-rejected`;
1057
+ dispatchSpeechEvent(
1058
+ 'speech-tts-lifecycle-error',
1059
+ {
1060
+ action,
1061
+ ...errorFields,
1062
+ error,
1063
+ message,
1064
+ reason
1065
+ },
1066
+ {
1067
+ bubbles: true,
1068
+ composed: true,
1069
+ operationId: operationId || ttsOperationId
1070
+ || nextSpeechOperationId(`tts-${action}`),
1071
+ publicDetail: {
1072
+ action,
1073
+ ...errorFields,
1074
+ reason
855
1075
  }
856
- )
1076
+ }
857
1077
  );
858
1078
  renderControls();
859
1079
  renderStatus();
1080
+ return false;
860
1081
  }
861
1082
 
862
- function reportTTSError(error) {
1083
+ function reportTTSError(error, boundary = 'synthesis') {
1084
+ if (destroyed) {
1085
+ return false;
1086
+ }
1087
+ const playbackRejected = boundary === 'playback';
863
1088
  const message = visibleErrorMessage(
864
1089
  error,
865
- 'Speech synthesis failed.'
1090
+ playbackRejected
1091
+ ? 'Speech playback failed.'
1092
+ : 'Speech synthesis failed.'
866
1093
  );
867
1094
  host.muted = true;
868
1095
  stopTTSPlayback();
869
1096
  localStatus = {
870
- message: `Speech synthesis failed: ${message}`,
1097
+ message: `${playbackRejected ? 'Speech playback' : 'Speech synthesis'} failed: ${message}`,
871
1098
  tone: 'error'
872
1099
  };
873
- host.dispatchEvent(
874
- new CustomEvent(
875
- 'speech-synthesis-error',
876
- {
877
- bubbles: true,
878
- composed: true,
879
- detail: {
880
- error,
881
- message
882
- }
1100
+ const errorFields = publicSpeechErrorFields(
1101
+ error,
1102
+ playbackRejected
1103
+ ? 'ARCANE_SPEECH_TTS_PLAYBACK_RESUME_REJECTED'
1104
+ : 'ARCANE_SPEECH_TTS_SYNTHESIS_REQUEST_REJECTED'
1105
+ );
1106
+ const reason = playbackRejected
1107
+ ? 'tts-playback-resume-rejected'
1108
+ : 'tts-synthesis-rejected';
1109
+ dispatchSpeechEvent(
1110
+ 'speech-synthesis-error',
1111
+ {
1112
+ ...errorFields,
1113
+ error,
1114
+ message,
1115
+ reason
1116
+ },
1117
+ {
1118
+ bubbles: true,
1119
+ composed: true,
1120
+ operationId: nextSpeechOperationId('synthesis'),
1121
+ publicDetail: {
1122
+ ...errorFields,
1123
+ reason
883
1124
  }
884
- )
1125
+ }
885
1126
  );
886
1127
  renderControls();
887
1128
  renderStatus();
@@ -915,20 +1156,26 @@
915
1156
 
916
1157
  void Promise.resolve(aiRuntime.resumeAudio()).catch(
917
1158
  function reportTTSResumeFailure(error) {
918
- reportTTSError(error);
1159
+ reportTTSError(error, 'playback');
919
1160
  }
920
1161
  );
921
1162
  }
922
1163
 
923
1164
  async function transcribe(transcription = '') {
924
- host.dispatchEvent(
925
- new CustomEvent(
926
- 'speech-transcription-complete',
927
- {
928
- detail: { text: transcription }
1165
+ const operationId = transcriptionOperationId
1166
+ || nextSpeechOperationId('transcription');
1167
+ const accepted = dispatchSpeechEvent(
1168
+ 'speech-transcription-complete',
1169
+ { text: transcription },
1170
+ {
1171
+ operationId,
1172
+ publicDetail: {
1173
+ characterCount: transcription.length
929
1174
  }
930
- )
1175
+ }
931
1176
  );
1177
+ transcriptionOperationId = null;
1178
+ return accepted;
932
1179
  }
933
1180
 
934
1181
  async function record() {
@@ -937,6 +1184,8 @@
937
1184
  || sttRole.state !== 'ready'
938
1185
  || sttRole.busy
939
1186
  || !host.availability.microphone
1187
+ || typeof globalThis.MediaRecorder !== 'function'
1188
+ || typeof navigator.mediaDevices?.getUserMedia !== 'function'
940
1189
  || recordingRequested
941
1190
  || captureSession
942
1191
  || transcriptionActive
@@ -946,6 +1195,7 @@
946
1195
  }
947
1196
 
948
1197
  const generation = ++recordingGeneration;
1198
+ transcriptionOperationId = nextSpeechOperationId('transcription');
949
1199
  recordingRequested = true;
950
1200
  localStatus = {
951
1201
  message: 'Recording. Release to transcribe.',
@@ -969,16 +1219,23 @@
969
1219
  message: 'Microphone access is unavailable. Text chat is still available.',
970
1220
  tone: 'error'
971
1221
  };
972
- host.dispatchEvent(
973
- new CustomEvent(
974
- 'speech-microphone-unavailable',
975
- {
976
- bubbles: true,
977
- composed: true,
978
- detail: { reason: error.name }
1222
+ dispatchSpeechEvent(
1223
+ 'speech-microphone-unavailable',
1224
+ { reason: error.name },
1225
+ {
1226
+ bubbles: true,
1227
+ composed: true,
1228
+ operationId: transcriptionOperationId,
1229
+ publicDetail: {
1230
+ ...publicSpeechErrorFields(
1231
+ error,
1232
+ 'ARCANE_SPEECH_MICROPHONE_CAPTURE_REJECTED'
1233
+ ),
1234
+ reason: microphoneFailureReason(error)
979
1235
  }
980
- )
1236
+ }
981
1237
  );
1238
+ transcriptionOperationId = null;
982
1239
  } else {
983
1240
  reportTranscriptionError(error);
984
1241
  }
@@ -991,6 +1248,7 @@
991
1248
  !recordingRequested
992
1249
  || generation !== recordingGeneration
993
1250
  || sttRole.state !== 'ready'
1251
+ || sttRole.busy
994
1252
  || destroyed
995
1253
  ) {
996
1254
  stopMediaStream(stream);
@@ -1097,6 +1355,7 @@
1097
1355
  session.cancelled
1098
1356
  || session.generation !== recordingGeneration
1099
1357
  || sttRole.state !== 'ready'
1358
+ || sttRole.busy
1100
1359
  || destroyed
1101
1360
  ) {
1102
1361
  renderControls();
@@ -1118,6 +1377,8 @@
1118
1377
 
1119
1378
  async function transcribeAudio(audioFile) {
1120
1379
  const generation = ++transcriptionGeneration;
1380
+ transcriptionOperationId = transcriptionOperationId
1381
+ || nextSpeechOperationId('transcription');
1121
1382
  const controller = new AbortController();
1122
1383
  transcriptionAbortController?.abort();
1123
1384
  transcriptionAbortController = controller;
@@ -1131,8 +1392,19 @@
1131
1392
 
1132
1393
  try {
1133
1394
  const aiRuntime = globalThis.ai;
1134
- if (sttRole.state !== 'ready' || typeof aiRuntime?.fetchSTT !== 'function') {
1135
- throw new Error('The selected transcription service is unavailable.');
1395
+ if (sttRole.state !== 'ready' || sttRole.busy) {
1396
+ reportTranscriptionCancellation(
1397
+ sttRole.busy ? 'stt-role-busy' : 'runtime-unready',
1398
+ sttRole.busy
1399
+ ? 'Transcription stopped because the selected service is busy.'
1400
+ : 'Transcription stopped because the selected service became unavailable.'
1401
+ );
1402
+ return false;
1403
+ }
1404
+ if (typeof aiRuntime?.fetchSTT !== 'function') {
1405
+ const error = new Error('The selected transcription service is unavailable.');
1406
+ error.code = 'ARCANE_SPEECH_STT_RUNTIME_METHOD_UNAVAILABLE';
1407
+ throw error;
1136
1408
  }
1137
1409
 
1138
1410
  const transcription = await aiRuntime.fetchSTT(
@@ -1156,7 +1428,14 @@
1156
1428
  return true;
1157
1429
  } catch (error) {
1158
1430
  if (generation === transcriptionGeneration && !destroyed) {
1159
- reportTranscriptionError(error);
1431
+ if (isTranscriptionCancellation(error, controller)) {
1432
+ reportTranscriptionCancellation(
1433
+ 'stt-provider-request-cancelled',
1434
+ 'The selected transcription request was cancelled.'
1435
+ );
1436
+ } else {
1437
+ reportTranscriptionError(error);
1438
+ }
1160
1439
  }
1161
1440
  return false;
1162
1441
  } finally {
@@ -1180,23 +1459,72 @@
1180
1459
  message: `Transcription failed: ${message}`,
1181
1460
  tone: 'error'
1182
1461
  };
1183
- host.dispatchEvent(
1184
- new CustomEvent(
1185
- 'speech-transcription-error',
1186
- {
1187
- bubbles: true,
1188
- composed: true,
1189
- detail: {
1190
- error,
1191
- message
1192
- }
1462
+ const operationId = transcriptionOperationId
1463
+ || nextSpeechOperationId('transcription');
1464
+ const errorFields = publicSpeechErrorFields(
1465
+ error,
1466
+ 'ARCANE_SPEECH_STT_TRANSCRIPTION_REQUEST_REJECTED'
1467
+ );
1468
+ const reason = 'stt-transcription-rejected';
1469
+ dispatchSpeechEvent(
1470
+ 'speech-transcription-error',
1471
+ {
1472
+ ...errorFields,
1473
+ error,
1474
+ message,
1475
+ reason
1476
+ },
1477
+ {
1478
+ bubbles: true,
1479
+ composed: true,
1480
+ operationId,
1481
+ publicDetail: {
1482
+ ...errorFields,
1483
+ reason
1193
1484
  }
1194
- )
1485
+ }
1195
1486
  );
1487
+ transcriptionOperationId = null;
1196
1488
  renderControls();
1197
1489
  renderStatus();
1198
1490
  }
1199
1491
 
1492
+ function isTranscriptionCancellation(error, controller) {
1493
+ return controller.signal.aborted
1494
+ || error?.name === 'AbortError'
1495
+ || [
1496
+ 'ARCANE_AI_REQUEST_ABORTED',
1497
+ 'ARCANE_AI_OPERATION_SUPERSEDED'
1498
+ ].includes(error?.code);
1499
+ }
1500
+
1501
+ function reportTranscriptionCancellation(reason, message) {
1502
+ const operationId = transcriptionOperationId
1503
+ || nextSpeechOperationId('transcription');
1504
+ localStatus = {
1505
+ message,
1506
+ tone: ''
1507
+ };
1508
+ const accepted = dispatchSpeechEvent(
1509
+ 'speech-transcription-cancelled',
1510
+ { reason },
1511
+ {
1512
+ bubbles: true,
1513
+ composed: true,
1514
+ operationId,
1515
+ publicDetail: {
1516
+ reason: canonicalSTTCancellationReason(reason)
1517
+ }
1518
+ }
1519
+ );
1520
+ transcriptionOperationId = null;
1521
+ if (!destroyed) {
1522
+ renderControls();
1523
+ renderStatus();
1524
+ }
1525
+ return accepted;
1526
+ }
1527
+
1200
1528
  function cancelSTTOperation(reason) {
1201
1529
  const hadActiveOperation = recordingRequested
1202
1530
  || Boolean(captureSession)
@@ -1221,21 +1549,14 @@
1221
1549
  }
1222
1550
 
1223
1551
  if (hadActiveOperation) {
1224
- localStatus = {
1225
- message: 'Transcription stopped because the selected service became unavailable.',
1226
- tone: 'error'
1227
- };
1228
- host.dispatchEvent(
1229
- new CustomEvent(
1230
- 'speech-transcription-cancelled',
1231
- {
1232
- bubbles: true,
1233
- composed: true,
1234
- detail: { reason }
1235
- }
1236
- )
1552
+ reportTranscriptionCancellation(
1553
+ String(reason || 'stt-transcription-cancelled'),
1554
+ reason === 'component-destroyed'
1555
+ ? 'Transcription stopped because the speech component was destroyed.'
1556
+ : 'Transcription stopped because the selected service became unavailable.'
1237
1557
  );
1238
1558
  }
1559
+ transcriptionOperationId = null;
1239
1560
  }
1240
1561
 
1241
1562
  function releaseCaptureSession(session) {
@@ -1286,6 +1607,7 @@
1286
1607
  );
1287
1608
  cancelSTTOperation('component-destroyed');
1288
1609
  stopTTSPlayback();
1610
+ events.dispose();
1289
1611
  host.componentReady = false;
1290
1612
  renderControls();
1291
1613
  renderStatus();