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
@@ -146,18 +146,43 @@
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
158
163
  } = await import('../modules/AIRuntimeState.js');
164
+ const {
165
+ createSTTActivationController
166
+ } = await import('../modules/ComponentContracts.js');
159
167
 
160
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
+ );
161
186
  const shadowRoot = host.shadowRoot;
162
187
  const recordButton = shadowRoot.querySelector('#recordButton');
163
188
  const sttActivationButton = shadowRoot.querySelector('#sttActivationButton');
@@ -183,12 +208,99 @@
183
208
  let keyboardRecording = false;
184
209
  let localStatus = null;
185
210
  let pendingTTSIntent = null;
211
+ let activeTTSIntent = null;
212
+ let pendingUnmute = false;
186
213
  let ttsIntentGeneration = 0;
214
+ let eventOperationSequence = 0;
215
+ let transcriptionOperationId = null;
216
+ let ttsOperationId = null;
187
217
  let destroyed = false;
188
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
+
189
301
  const initialMutedWasHydrated = typeof host.initialMuted === 'boolean';
190
302
  host.initialMuted = initialMutedWasHydrated ? host.initialMuted : true;
191
- host.muted = host.initialMuted;
303
+ host.muted = true;
192
304
  host.configure = configure;
193
305
  host.destroy = destroy;
194
306
  host.reportTTSError = reportTTSError;
@@ -210,20 +322,29 @@
210
322
  {
211
323
  host,
212
324
  button: sttActivationButton,
213
- onChange: renderSTTActivationState
325
+ onChange: renderSTTActivationState,
326
+ eventSource: events
214
327
  }
215
328
  );
216
329
 
217
- recordButton.addEventListener('pointerdown', handleRecordPointerDown);
218
- recordButton.addEventListener('pointerup', handleRecordPointerUp);
219
- recordButton.addEventListener('pointercancel', handleRecordPointerCancel);
220
- recordButton.addEventListener('lostpointercapture', handleLostPointerCapture);
221
- recordButton.addEventListener('keydown', handleRecordKeyDown);
222
- recordButton.addEventListener('keyup', handleRecordKeyUp);
223
- recordButton.addEventListener('blur', handleRecordBlur);
224
- muteButton.addEventListener('click', muteToggle);
225
- endButton.addEventListener('click', submitCompletionCommand);
226
- 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
+ );
227
348
 
228
349
  subscribeAIRuntimeState(
229
350
  synchronizeAIRuntimeState,
@@ -231,213 +352,37 @@
231
352
  signal: runtimeStateAbortController.signal
232
353
  }
233
354
  );
355
+ if (!host.initialMuted && ttsRole.state === 'ready') {
356
+ host.muted = false;
357
+ }
234
358
  synchronizeAIMutedState();
235
359
  renderControls();
236
360
  renderStatus();
237
361
  void observeMicrophonePermission();
238
362
  void aiModuleImport.then(
239
- synchronizeAIMutedState,
240
- function reportAIModuleImportFailure(error) {
241
- console.error('The shared AI runtime could not load.');
242
- }
243
- );
244
-
245
- host.componentReady = true;
246
- host.dispatchEvent(
247
- new CustomEvent(
248
- 'speech-ready',
249
- {
250
- bubbles: true,
251
- composed: true,
252
- detail: { speech: host }
253
- }
254
- )
255
- );
256
-
257
- function createSTTActivationController({
258
- host,
259
- button,
260
- onChange,
261
- EventClass = CustomEvent
262
- }) {
263
- let role = null;
264
- let requestPending = false;
265
- let requestError = '';
266
- let requestGeneration = 0;
267
- let destroyed = false;
268
-
269
- function visibleError(error, fallback) {
270
- const message = typeof error?.message === 'string'
271
- ? error.message.trim()
272
- : '';
273
- return (message || fallback).slice(0, 240);
274
- }
275
-
276
- function cancellation(error) {
277
- return error?.name === 'AbortError'
278
- || [
279
- 'ARCANE_AI_REQUEST_ABORTED',
280
- 'ARCANE_AI_OPERATION_SUPERSEDED'
281
- ]
282
- .includes(error?.code);
283
- }
284
-
285
- function selected() {
286
- return typeof role?.providerId === 'string'
287
- && role.providerId.length > 0
288
- && typeof role?.modelId === 'string'
289
- && role.modelId.length > 0;
290
- }
291
-
292
- function action() {
293
- if (!selected()) {
294
- return null;
295
- }
296
- if (role.state === 'loading') {
297
- return 'unload';
298
- }
299
- if (!role.busy && ['unloaded', 'error'].includes(role.state)) {
300
- return 'load';
301
- }
302
- return null;
303
- }
304
-
305
- async function request(nextAction) {
306
- if (destroyed || requestPending || action() !== nextAction) {
307
- return false;
363
+ function synchronizeMutedStateAfterAIImport() {
364
+ if (!destroyed) {
365
+ synchronizeAIMutedState();
308
366
  }
309
- const generation = ++requestGeneration;
310
- requestError = '';
311
- const intent = Object.freeze(
312
- {
313
- role: 'stt',
314
- action: nextAction,
315
- reason: 'user'
316
- }
317
- );
318
- const activationRequest = Object.freeze({intent, state: role});
319
- const activationEvent = new EventClass(
320
- 'speech-stt-activation-request',
321
- {
322
- bubbles: true,
323
- composed: true,
324
- cancelable: true,
325
- detail: activationRequest
326
- }
327
- );
328
- requestPending = true;
329
- const accepted = host.dispatchEvent(activationEvent);
330
- if (!accepted
331
- || destroyed
332
- || generation !== requestGeneration
333
- || action() !== nextAction) {
334
- if (!destroyed && generation === requestGeneration) {
335
- requestPending = false;
336
- onChange();
337
- }
338
- return false;
339
- }
340
- onChange();
341
- try {
342
- await host.requestSTTActivation(intent);
343
- if (destroyed || generation !== requestGeneration) {
344
- return false;
345
- }
346
- return true;
347
- } catch (error) {
348
- if (destroyed || generation !== requestGeneration) {
349
- return false;
350
- }
351
- if (nextAction === 'load'
352
- && cancellation(error)
353
- && ['unloaded', 'unloading'].includes(role?.state)) {
354
- return false;
355
- }
356
- const message = visibleError(
357
- error,
358
- `The transcription ${nextAction} request failed.`
359
- );
360
- requestError = message;
361
- host.dispatchEvent(
362
- new EventClass(
363
- 'speech-stt-activation-error',
364
- {
365
- bubbles: true,
366
- composed: true,
367
- detail: Object.freeze(
368
- {
369
- request: activationRequest,
370
- error,
371
- message
372
- }
373
- )
374
- }
375
- )
376
- );
377
- return false;
378
- } finally {
379
- if (!destroyed && generation === requestGeneration) {
380
- requestPending = false;
381
- onChange();
382
- }
383
- }
384
- }
385
-
386
- function activateSelectedSTT() {
387
- const nextAction = action();
388
- if (nextAction) {
389
- void request(nextAction);
390
- }
391
- }
392
-
393
- function synchronize(nextRole) {
367
+ },
368
+ function reportAIModuleImportFailure(error) {
394
369
  if (destroyed) {
395
370
  return;
396
371
  }
397
- if (role?.state !== nextRole.state
398
- || role?.providerId !== nextRole.providerId
399
- || role?.modelId !== nextRole.modelId
400
- || role?.loaded !== nextRole.loaded
401
- || role?.busy !== nextRole.busy
402
- || role?.operationId !== nextRole.operationId) {
403
- requestGeneration += 1;
404
- requestError = '';
405
- requestPending = false;
406
- }
407
- role = nextRole;
372
+ console.error('The shared AI runtime could not load.');
408
373
  }
374
+ );
409
375
 
410
- function destroy() {
411
- if (destroyed) {
412
- return;
413
- }
414
- destroyed = true;
415
- requestGeneration += 1;
416
- requestPending = false;
417
- button.removeEventListener('click', activateSelectedSTT);
376
+ host.componentReady = true;
377
+ dispatchSpeechEvent(
378
+ 'speech-ready',
379
+ { speech: host },
380
+ {
381
+ bubbles: true,
382
+ composed: true,
383
+ publicDetail: { ready: true }
418
384
  }
419
-
420
- button.addEventListener('click', activateSelectedSTT);
421
- return Object.freeze(
422
- {
423
- get action() {
424
- return action();
425
- },
426
- get error() {
427
- return requestError;
428
- },
429
- get pending() {
430
- return requestPending;
431
- },
432
- get selected() {
433
- return selected();
434
- },
435
- request,
436
- synchronize,
437
- destroy
438
- }
439
- );
440
- }
385
+ );
441
386
 
442
387
  function unavailableRole(role) {
443
388
  return Object.freeze(
@@ -464,6 +409,9 @@
464
409
  }
465
410
 
466
411
  function configure(options = {}) {
412
+ if (destroyed) {
413
+ return host;
414
+ }
467
415
  if (Object.prototype.hasOwnProperty.call(options, 'initialMuted')) {
468
416
  const configuredMuted = typeof options.initialMuted === 'boolean'
469
417
  ? options.initialMuted
@@ -484,6 +432,9 @@
484
432
  }
485
433
 
486
434
  function setAvailability(input = {}) {
435
+ if (destroyed) {
436
+ return { ...host.availability };
437
+ }
487
438
  // Compatibility-only explicit adapter report. Canonical consumers use
488
439
  // the sticky AIRuntimeState role records instead of this wrapper.
489
440
  const previousSTTRole = sttRole;
@@ -512,6 +463,9 @@
512
463
  }
513
464
 
514
465
  function setMuted(muted) {
466
+ if (destroyed) {
467
+ return host;
468
+ }
515
469
  if (typeof muted !== 'boolean') {
516
470
  throw new TypeError('muted must be boolean');
517
471
  }
@@ -525,11 +479,11 @@
525
479
  }
526
480
 
527
481
  function applyConfiguredMutedState(muted) {
528
- host.muted = muted;
529
- pendingTTSIntent = null;
530
482
  if (muted) {
531
- stopTTSPlayback();
483
+ requestUserMute();
532
484
  } else {
485
+ pendingUnmute = false;
486
+ host.muted = ttsRole.state !== 'ready';
533
487
  synchronizeAIMutedState();
534
488
  }
535
489
  renderControls();
@@ -559,14 +513,18 @@
559
513
  cancelSTTOperation('runtime-unready');
560
514
  }
561
515
  if (ttsBecameUnready) {
516
+ pendingUnmute = false;
562
517
  host.muted = true;
563
518
  stopTTSPlayback();
564
519
  }
565
- if (ttsRole.state === 'error' && !host.muted) {
520
+ if (ttsRole.state === 'error' && (pendingUnmute || !host.muted)) {
521
+ pendingUnmute = false;
566
522
  host.muted = true;
567
523
  stopTTSPlayback();
568
524
  }
569
525
 
526
+ settleSuccessfulUnmute();
527
+
570
528
  synchronizeAIMutedState();
571
529
  renderControls();
572
530
  renderStatus();
@@ -597,18 +555,20 @@
597
555
 
598
556
  function renderControls() {
599
557
  const transcriptionReady = sttRole.state === 'ready';
600
- const microphoneReady = host.availability.microphone;
601
- const activationVisible = sttActivationController.selected
602
- && sttRole.state !== 'ready'
603
- && ['unloaded', 'loading', 'unloading', 'error']
604
- .includes(sttRole.state);
558
+ const microphoneReady = host.availability.microphone
559
+ && typeof globalThis.MediaRecorder === 'function'
560
+ && typeof navigator.mediaDevices?.getUserMedia === 'function';
561
+ const activationVisible = sttActivationController.visible;
605
562
  recordButton.hidden = activationVisible;
606
563
  recordButton.disabled = !transcriptionReady
607
564
  || !microphoneReady
608
565
  || sttRole.busy
609
566
  || transcriptionActive
610
567
  || destroyed;
611
- stopButton.disabled = recordButton.disabled;
568
+ stopButton.disabled = destroyed
569
+ || !transcriptionReady
570
+ || sttRole.busy
571
+ || captureSession?.recorder.state !== 'recording';
612
572
  recordButton.title = transcriptionButtonTitle();
613
573
  recordButton.setAttribute('aria-label', recordButton.title);
614
574
  recordButton.setAttribute(
@@ -620,8 +580,8 @@
620
580
  || sttActivationController.pending
621
581
  || sttRole.state === 'unloading'
622
582
  || sttActivationController.action === null;
623
- sttActivationButton.textContent = transcriptionActivationLabel();
624
- sttActivationButton.title = transcriptionActivationTitle();
583
+ sttActivationButton.textContent = sttActivationController.label;
584
+ sttActivationButton.title = sttActivationController.title;
625
585
  sttActivationButton.setAttribute(
626
586
  'aria-label',
627
587
  sttActivationButton.title
@@ -636,41 +596,6 @@
636
596
  renderTTSControl();
637
597
  }
638
598
 
639
- function transcriptionActivationLabel() {
640
- if (sttActivationController.pending) {
641
- return 'Requesting…';
642
- }
643
- if (sttRole.state === 'loading') {
644
- return 'Cancel loading';
645
- }
646
- if (sttRole.state === 'unloading') {
647
- return 'Canceling…';
648
- }
649
- if (sttRole.state === 'error') {
650
- return 'Try again';
651
- }
652
- return 'Start transcription';
653
- }
654
-
655
- function transcriptionActivationTitle() {
656
- if (sttActivationController.pending) {
657
- return 'Requesting transcription activation.';
658
- }
659
- if (sttRole.state === 'loading') {
660
- return 'Cancel loading the selected transcription service.';
661
- }
662
- if (sttRole.state === 'unloading') {
663
- return 'The selected transcription service is being released.';
664
- }
665
- if (sttRole.state === 'error') {
666
- return sttActivationController.error || visibleErrorMessage(
667
- sttRole.error,
668
- 'Retry loading the selected transcription service.'
669
- );
670
- }
671
- return 'Start the selected transcription service.';
672
- }
673
-
674
599
  function transcriptionButtonTitle() {
675
600
  if (destroyed || sttRole.state === 'disposed') {
676
601
  return 'Transcription is unavailable.';
@@ -776,34 +701,10 @@
776
701
  }
777
702
 
778
703
  function describeSTTRole() {
779
- if (sttActivationController.error) {
780
- return `Transcription activation error: ${sttActivationController.error}.`;
781
- }
782
- if (sttActivationController.pending) {
783
- return 'Transcription activation requested.';
784
- }
785
- if (sttRole.state === 'ready') {
786
- if (!host.availability.microphone) {
787
- return 'Microphone unavailable.';
788
- }
789
- return sttRole.busy ? 'Transcription busy.' : 'Transcription ready.';
790
- }
791
- if (sttRole.state === 'loading') {
792
- return `Transcription ${formatRoleProgress(sttRole.progress, 'loading')}; Cancel is available.`;
704
+ if (sttRole.state === 'ready' && !host.availability.microphone) {
705
+ return 'Microphone unavailable.';
793
706
  }
794
- if (sttRole.state === 'unloading') {
795
- return `Transcription ${formatRoleProgress(sttRole.progress, 'releasing')}.`;
796
- }
797
- if (sttRole.state === 'error') {
798
- return `Transcription error: ${visibleErrorMessage(sttRole.error, 'Unknown error')}.`;
799
- }
800
- if (sttRole.state === 'disposed') {
801
- return 'Transcription disposed.';
802
- }
803
- if (sttRole.state === 'unloaded' && sttActivationController.selected) {
804
- return 'Transcription selected and waiting to load.';
805
- }
806
- return 'Transcription unavailable.';
707
+ return sttActivationController.status;
807
708
  }
808
709
 
809
710
  function describeTTSRole() {
@@ -862,13 +763,18 @@
862
763
  }
863
764
 
864
765
  try {
865
- microphonePermissionStatus = await navigator.permissions.query(
766
+ const permissionStatus = await navigator.permissions.query(
866
767
  { name: 'microphone' }
867
768
  );
769
+ if (destroyed) {
770
+ return;
771
+ }
772
+ microphonePermissionStatus = permissionStatus;
868
773
  microphonePermissionListener = synchronizeMicrophonePermission;
869
774
  microphonePermissionStatus.addEventListener?.(
870
775
  'change',
871
- microphonePermissionListener
776
+ microphonePermissionListener,
777
+ { signal: runtimeStateAbortController.signal }
872
778
  );
873
779
  synchronizeMicrophonePermission();
874
780
  } catch (error) {
@@ -878,6 +784,9 @@
878
784
  }
879
785
 
880
786
  function synchronizeMicrophonePermission() {
787
+ if (destroyed) {
788
+ return;
789
+ }
881
790
  setAvailability(
882
791
  {
883
792
  microphone: microphonePermissionStatus?.state !== 'denied'
@@ -983,27 +892,49 @@
983
892
  }
984
893
 
985
894
  if (host.muted) {
986
- requestUserUnmute();
895
+ void requestUserUnmute();
987
896
  } else {
988
897
  requestUserMute();
989
898
  }
990
899
  }
991
900
 
992
901
  function requestUserMute() {
902
+ if (destroyed) {
903
+ return false;
904
+ }
905
+ if (host.muted && activeTTSIntent?.action === 'unload') {
906
+ return false;
907
+ }
993
908
  const wasMuted = host.muted;
909
+ pendingUnmute = false;
994
910
  const shouldUnload = !wasMuted
995
911
  || pendingTTSIntent === 'load'
996
912
  || ['loading', 'ready', 'error'].includes(ttsRole.state);
997
913
  host.muted = true;
998
914
  stopTTSPlayback();
999
915
  if (shouldUnload) {
1000
- requestTTSIntent('unload', 'user');
916
+ void requestTTSIntent('unload', 'user');
1001
917
  }
1002
918
  renderControls();
1003
919
  renderStatus();
1004
920
  }
1005
921
 
1006
- 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
+ }
1007
938
  const providerSelected = typeof ttsRole.providerId === 'string'
1008
939
  && ttsRole.providerId.length > 0
1009
940
  && typeof ttsRole.modelId === 'string'
@@ -1023,60 +954,93 @@
1023
954
  return false;
1024
955
  }
1025
956
 
1026
- host.muted = false;
1027
- synchronizeAIMutedState();
1028
- requestTTSIntent('load', 'user');
1029
- if (ttsRole.state === 'ready') {
1030
- 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;
1031
966
  }
967
+ settleSuccessfulUnmute();
1032
968
  renderControls();
1033
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();
1034
982
  return true;
1035
983
  }
1036
984
 
1037
- 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
+ }
1038
992
  const generation = ++ttsIntentGeneration;
993
+ const operationId = nextSpeechOperationId(`tts-${action}`);
994
+ const intent = Object.freeze({ action, generation, operationId });
995
+ activeTTSIntent = intent;
996
+ ttsOperationId = operationId;
1039
997
  pendingTTSIntent = action === 'load' && ttsRole.state === 'ready'
1040
998
  ? null
1041
999
  : action;
1042
- let lifecycle = null;
1000
+ let lifecycle;
1043
1001
  try {
1044
1002
  if (typeof globalThis.ai?.setSpeechMuted === 'function') {
1045
1003
  lifecycle = globalThis.ai.setSpeechMuted(action === 'unload');
1004
+ } else {
1005
+ lifecycle = requestAIRuntimeIntent(
1006
+ {
1007
+ role: 'tts',
1008
+ action,
1009
+ reason
1010
+ }
1011
+ );
1046
1012
  }
1047
- requestAIRuntimeIntent(
1048
- {
1049
- role: 'tts',
1050
- action,
1051
- reason
1052
- }
1053
- );
1013
+ await lifecycle;
1054
1014
  } catch (error) {
1015
+ if (destroyed || generation !== ttsIntentGeneration) {
1016
+ return false;
1017
+ }
1055
1018
  pendingTTSIntent = null;
1056
1019
  if (action === 'load') {
1020
+ pendingUnmute = false;
1057
1021
  host.muted = true;
1058
1022
  stopTTSPlayback();
1059
1023
  }
1060
- reportTTSLifecycleError(error, action);
1024
+ reportTTSLifecycleError(error, action, operationId);
1061
1025
  return false;
1062
- }
1063
- void Promise.resolve(lifecycle).catch(
1064
- function reportCurrentTTSSpeechLifecycleFailure(error) {
1065
- if (destroyed || generation !== ttsIntentGeneration) {
1066
- return;
1067
- }
1068
- pendingTTSIntent = null;
1069
- if (action === 'load') {
1070
- host.muted = true;
1071
- stopTTSPlayback();
1072
- }
1073
- reportTTSLifecycleError(error, action);
1026
+ } finally {
1027
+ if (activeTTSIntent === intent) {
1028
+ activeTTSIntent = null;
1074
1029
  }
1075
- );
1030
+ }
1031
+ if (destroyed || generation !== ttsIntentGeneration) {
1032
+ return false;
1033
+ }
1034
+ if (action === 'load') {
1035
+ settleSuccessfulUnmute();
1036
+ }
1076
1037
  return true;
1077
1038
  }
1078
1039
 
1079
- function reportTTSLifecycleError(error, action) {
1040
+ function reportTTSLifecycleError(error, action, operationId = null) {
1041
+ if (destroyed) {
1042
+ return false;
1043
+ }
1080
1044
  const message = visibleErrorMessage(
1081
1045
  error,
1082
1046
  `The voice ${action} request failed.`
@@ -1085,47 +1049,80 @@
1085
1049
  message,
1086
1050
  tone: 'error'
1087
1051
  };
1088
- host.dispatchEvent(
1089
- new CustomEvent(
1090
- 'speech-tts-lifecycle-error',
1091
- {
1092
- bubbles: true,
1093
- composed: true,
1094
- detail: {
1095
- action,
1096
- error,
1097
- message
1098
- }
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
1099
1075
  }
1100
- )
1076
+ }
1101
1077
  );
1102
1078
  renderControls();
1103
1079
  renderStatus();
1080
+ return false;
1104
1081
  }
1105
1082
 
1106
- function reportTTSError(error) {
1083
+ function reportTTSError(error, boundary = 'synthesis') {
1084
+ if (destroyed) {
1085
+ return false;
1086
+ }
1087
+ const playbackRejected = boundary === 'playback';
1107
1088
  const message = visibleErrorMessage(
1108
1089
  error,
1109
- 'Speech synthesis failed.'
1090
+ playbackRejected
1091
+ ? 'Speech playback failed.'
1092
+ : 'Speech synthesis failed.'
1110
1093
  );
1111
1094
  host.muted = true;
1112
1095
  stopTTSPlayback();
1113
1096
  localStatus = {
1114
- message: `Speech synthesis failed: ${message}`,
1097
+ message: `${playbackRejected ? 'Speech playback' : 'Speech synthesis'} failed: ${message}`,
1115
1098
  tone: 'error'
1116
1099
  };
1117
- host.dispatchEvent(
1118
- new CustomEvent(
1119
- 'speech-synthesis-error',
1120
- {
1121
- bubbles: true,
1122
- composed: true,
1123
- detail: {
1124
- error,
1125
- message
1126
- }
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
1127
1124
  }
1128
- )
1125
+ }
1129
1126
  );
1130
1127
  renderControls();
1131
1128
  renderStatus();
@@ -1159,20 +1156,26 @@
1159
1156
 
1160
1157
  void Promise.resolve(aiRuntime.resumeAudio()).catch(
1161
1158
  function reportTTSResumeFailure(error) {
1162
- reportTTSError(error);
1159
+ reportTTSError(error, 'playback');
1163
1160
  }
1164
1161
  );
1165
1162
  }
1166
1163
 
1167
1164
  async function transcribe(transcription = '') {
1168
- host.dispatchEvent(
1169
- new CustomEvent(
1170
- 'speech-transcription-complete',
1171
- {
1172
- 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
1173
1174
  }
1174
- )
1175
+ }
1175
1176
  );
1177
+ transcriptionOperationId = null;
1178
+ return accepted;
1176
1179
  }
1177
1180
 
1178
1181
  async function record() {
@@ -1181,6 +1184,8 @@
1181
1184
  || sttRole.state !== 'ready'
1182
1185
  || sttRole.busy
1183
1186
  || !host.availability.microphone
1187
+ || typeof globalThis.MediaRecorder !== 'function'
1188
+ || typeof navigator.mediaDevices?.getUserMedia !== 'function'
1184
1189
  || recordingRequested
1185
1190
  || captureSession
1186
1191
  || transcriptionActive
@@ -1190,6 +1195,7 @@
1190
1195
  }
1191
1196
 
1192
1197
  const generation = ++recordingGeneration;
1198
+ transcriptionOperationId = nextSpeechOperationId('transcription');
1193
1199
  recordingRequested = true;
1194
1200
  localStatus = {
1195
1201
  message: 'Recording. Release to transcribe.',
@@ -1213,16 +1219,23 @@
1213
1219
  message: 'Microphone access is unavailable. Text chat is still available.',
1214
1220
  tone: 'error'
1215
1221
  };
1216
- host.dispatchEvent(
1217
- new CustomEvent(
1218
- 'speech-microphone-unavailable',
1219
- {
1220
- bubbles: true,
1221
- composed: true,
1222
- 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)
1223
1235
  }
1224
- )
1236
+ }
1225
1237
  );
1238
+ transcriptionOperationId = null;
1226
1239
  } else {
1227
1240
  reportTranscriptionError(error);
1228
1241
  }
@@ -1235,6 +1248,7 @@
1235
1248
  !recordingRequested
1236
1249
  || generation !== recordingGeneration
1237
1250
  || sttRole.state !== 'ready'
1251
+ || sttRole.busy
1238
1252
  || destroyed
1239
1253
  ) {
1240
1254
  stopMediaStream(stream);
@@ -1341,6 +1355,7 @@
1341
1355
  session.cancelled
1342
1356
  || session.generation !== recordingGeneration
1343
1357
  || sttRole.state !== 'ready'
1358
+ || sttRole.busy
1344
1359
  || destroyed
1345
1360
  ) {
1346
1361
  renderControls();
@@ -1362,6 +1377,8 @@
1362
1377
 
1363
1378
  async function transcribeAudio(audioFile) {
1364
1379
  const generation = ++transcriptionGeneration;
1380
+ transcriptionOperationId = transcriptionOperationId
1381
+ || nextSpeechOperationId('transcription');
1365
1382
  const controller = new AbortController();
1366
1383
  transcriptionAbortController?.abort();
1367
1384
  transcriptionAbortController = controller;
@@ -1375,8 +1392,19 @@
1375
1392
 
1376
1393
  try {
1377
1394
  const aiRuntime = globalThis.ai;
1378
- if (sttRole.state !== 'ready' || typeof aiRuntime?.fetchSTT !== 'function') {
1379
- 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;
1380
1408
  }
1381
1409
 
1382
1410
  const transcription = await aiRuntime.fetchSTT(
@@ -1400,7 +1428,14 @@
1400
1428
  return true;
1401
1429
  } catch (error) {
1402
1430
  if (generation === transcriptionGeneration && !destroyed) {
1403
- 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
+ }
1404
1439
  }
1405
1440
  return false;
1406
1441
  } finally {
@@ -1424,23 +1459,72 @@
1424
1459
  message: `Transcription failed: ${message}`,
1425
1460
  tone: 'error'
1426
1461
  };
1427
- host.dispatchEvent(
1428
- new CustomEvent(
1429
- 'speech-transcription-error',
1430
- {
1431
- bubbles: true,
1432
- composed: true,
1433
- detail: {
1434
- error,
1435
- message
1436
- }
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
1437
1484
  }
1438
- )
1485
+ }
1439
1486
  );
1487
+ transcriptionOperationId = null;
1440
1488
  renderControls();
1441
1489
  renderStatus();
1442
1490
  }
1443
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
+
1444
1528
  function cancelSTTOperation(reason) {
1445
1529
  const hadActiveOperation = recordingRequested
1446
1530
  || Boolean(captureSession)
@@ -1465,21 +1549,14 @@
1465
1549
  }
1466
1550
 
1467
1551
  if (hadActiveOperation) {
1468
- localStatus = {
1469
- message: 'Transcription stopped because the selected service became unavailable.',
1470
- tone: 'error'
1471
- };
1472
- host.dispatchEvent(
1473
- new CustomEvent(
1474
- 'speech-transcription-cancelled',
1475
- {
1476
- bubbles: true,
1477
- composed: true,
1478
- detail: { reason }
1479
- }
1480
- )
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.'
1481
1557
  );
1482
1558
  }
1559
+ transcriptionOperationId = null;
1483
1560
  }
1484
1561
 
1485
1562
  function releaseCaptureSession(session) {
@@ -1530,6 +1607,7 @@
1530
1607
  );
1531
1608
  cancelSTTOperation('component-destroyed');
1532
1609
  stopTTSPlayback();
1610
+ events.dispose();
1533
1611
  host.componentReady = false;
1534
1612
  renderControls();
1535
1613
  renderStatus();