arcane-os 0.2.3 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +8 -8
  3. package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +29 -22
  4. package/browser-runtime/ai/ARCANE_AI_BROWSER_SPEECH_COMPONENTS.json +203 -0
  5. package/browser-runtime/ai/browser-kokoro-worker.mjs +11 -2
  6. package/browser-runtime/ai/browser-speech-artifacts.mjs +3242 -391
  7. package/browser-runtime/ai/browser-speech-providers.mjs +1209 -157
  8. package/browser-runtime/ai/browser-speech.mjs +2 -0
  9. package/browser-runtime/ai/browser-whisper-worker.mjs +11 -2
  10. package/browser-runtime/ai/model-controller.mjs +285 -95
  11. package/browser-runtime/ai/speech-worker-client.mjs +249 -32
  12. package/browser-runtime/ai/speech-worker-runtime.mjs +2322 -167
  13. package/browser-runtime/event-manager.mjs +1097 -1
  14. package/docs/architecture.md +2 -2
  15. package/docs/event-manager.md +155 -27
  16. package/docs/reference/README.md +18 -20
  17. package/docs/reference/ai/browser-speech-package-authority.json +835 -0
  18. package/docs/reference/ai/browser-speech.md +1205 -246
  19. package/docs/reference/ai/browser-wasm.md +18 -7
  20. package/docs/reference/availability-and-normalization.md +2 -2
  21. package/docs/reference/behavioral-testing.md +28 -5
  22. package/docs/reference/cli.md +115 -7
  23. package/docs/reference/core/arcane-ai-contracts.md +1 -1
  24. package/docs/reference/event-manager.md +577 -32
  25. package/docs/reference/inventory/package-api.json +478 -2
  26. package/docs/reference/inventory/runtime-components.json +86 -37
  27. package/docs/reference/inventory/runtime-modules.json +126 -49
  28. package/docs/reference/mail.md +316 -0
  29. package/docs/reference/protocols.md +185 -33
  30. package/docs/reference/runtime-components.md +192 -80
  31. package/docs/reference/runtime-modules.md +589 -70
  32. package/docs/reference/sdk-api.md +1017 -24
  33. package/package.json +5 -4
  34. package/runtime/ARCANE_RUNTIME_RELEASE.json +145 -140
  35. package/runtime/arcane/components/app-bar.html +34 -13
  36. package/runtime/arcane/components/assistant-panel.html +110 -57
  37. package/runtime/arcane/components/calculator.html +7 -4
  38. package/runtime/arcane/components/chart.html +58 -17
  39. package/runtime/arcane/components/chat.html +606 -136
  40. package/runtime/arcane/components/conversation-view.html +13 -6
  41. package/runtime/arcane/components/dashboard-config.html +96 -59
  42. package/runtime/arcane/components/data-maintenance.html +69 -14
  43. package/runtime/arcane/components/data-view.html +53 -7
  44. package/runtime/arcane/components/directory-picker.html +118 -32
  45. package/runtime/arcane/components/document-inspector.html +47 -10
  46. package/runtime/arcane/components/file-drop.html +81 -35
  47. package/runtime/arcane/components/file-inspector.html +72 -22
  48. package/runtime/arcane/components/file-manager.html +374 -79
  49. package/runtime/arcane/components/integration-settings.html +12 -5
  50. package/runtime/arcane/components/local-ai-status.html +48 -19
  51. package/runtime/arcane/components/markdown-document.html +161 -68
  52. package/runtime/arcane/components/markdown-editor.html +110 -33
  53. package/runtime/arcane/components/media-embed.html +8 -5
  54. package/runtime/arcane/components/modal.html +15 -5
  55. package/runtime/arcane/components/output-panel.html +28 -23
  56. package/runtime/arcane/components/preferences-form.html +22 -4
  57. package/runtime/arcane/components/record-timeline.html +18 -2
  58. package/runtime/arcane/components/relationship-board.html +23 -3
  59. package/runtime/arcane/components/screen-capture.html +10 -4
  60. package/runtime/arcane/components/source-code-viewer.html +76 -9
  61. package/runtime/arcane/components/source-explanation.html +23 -3
  62. package/runtime/arcane/components/speech.html +453 -131
  63. package/runtime/arcane/components/summary-strip.html +22 -11
  64. package/runtime/arcane/components/table.html +39 -21
  65. package/runtime/arcane/components/task-progress.html +79 -21
  66. package/runtime/arcane/components/terminal-workspace.html +7 -4
  67. package/runtime/arcane/components/theme-editor.html +7 -3
  68. package/runtime/arcane/components/unified-inbox.html +9 -4
  69. package/runtime/arcane/components/voice-transcription.html +244 -72
  70. package/runtime/arcane/components/weather-widget.html +5 -3
  71. package/runtime/arcane/components/web-navigator.html +48 -8
  72. package/runtime/arcane/entities/Chat.js +1 -1
  73. package/runtime/arcane/entities/User.js +110 -23
  74. package/runtime/arcane/modules/AI.js +2117 -130
  75. package/runtime/arcane/modules/AIProviderRuntime.js +756 -13
  76. package/runtime/arcane/modules/AIRuntimeState.js +109 -52
  77. package/runtime/arcane/modules/ApiModelDatabase.js +390 -17
  78. package/runtime/arcane/modules/BrowserTestSuite.js +205 -28
  79. package/runtime/arcane/modules/CalculatorEngine.js +63 -3
  80. package/runtime/arcane/modules/CommunicationAppController.js +588 -28
  81. package/runtime/arcane/modules/CommunicationHub.js +590 -10
  82. package/runtime/arcane/modules/ComponentContracts.js +235 -37
  83. package/runtime/arcane/modules/ConversationTimebox.js +152 -33
  84. package/runtime/arcane/modules/DBLS.js +40 -7
  85. package/runtime/arcane/modules/DBOPFS.js +35 -11
  86. package/runtime/arcane/modules/DataMaintenance.js +12 -2
  87. package/runtime/arcane/modules/Errors.js +65 -7
  88. package/runtime/arcane/modules/HTMLImport.js +198 -14
  89. package/runtime/arcane/modules/LocalAIReadinessController.js +208 -29
  90. package/runtime/arcane/modules/Mail.js +738 -115
  91. package/runtime/arcane/modules/MailOutbox.mjs +1395 -0
  92. package/runtime/arcane/modules/MailTransport.mjs +197 -39
  93. package/runtime/arcane/modules/Ollama.js +36 -1
  94. package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +583 -7
  95. package/runtime/arcane/modules/PreferenceStore.js +367 -33
  96. package/runtime/arcane/modules/RecordReviewStore.js +322 -23
  97. package/runtime/arcane/modules/ScreenCapture.js +1397 -15
  98. package/runtime/arcane/modules/SpeechPlayback.js +438 -41
  99. package/runtime/arcane/modules/TerminalClient.js +277 -12
  100. package/runtime/arcane/modules/ThemeBootstrap.js +80 -6
  101. package/runtime/arcane/modules/ThemeManager.js +39 -7
  102. package/runtime/arcane/modules/TimeGuard.js +131 -20
  103. package/runtime/arcane/modules/WaitForComponent.js +386 -33
  104. package/schemas/arcane-lock.schema.json +2 -2
  105. package/src/cli/main.mjs +435 -9
  106. package/src/event-manager.mjs +1097 -1
  107. package/src/import-map.mjs +21 -3
  108. package/src/index.mjs +13 -0
  109. package/src/installed-sdk-runtime.mjs +130 -0
  110. package/src/mail-api.mjs +22 -0
  111. package/src/mail-credentials.mjs +667 -0
  112. package/src/mail-server.mjs +1769 -0
  113. package/src/mail.mjs +261 -0
  114. package/src/sdk-browser-runtime.mjs +85 -41
  115. package/src/testing-loader.mjs +7 -0
  116. package/src/toolchain.mjs +3 -0
  117. package/src/workspace-runtime.mjs +804 -22
  118. package/src/workspace.mjs +1 -1
@@ -1,5 +1,10 @@
1
1
  import './DBOPFS.js';
2
2
  import UserEntity from '../entities/User.js';
3
+ import {
4
+ arcaneEvents,
5
+ createArcaneEventSource,
6
+ projectArcaneDOMEvent
7
+ } from 'arcane-os/event-manager';
3
8
  import {getAIPreferencesForRuntime} from './AIPreferenceRuntime.js';
4
9
  import {
5
10
  AI_MODEL_AUTHORITY_PROTOCOL,
@@ -13,6 +18,84 @@ const LEGACY_TTS_RESPONSE_FORMAT='opus';
13
18
  credentials='omit';
14
19
 
15
20
  const LEGACY_AI_SERVICES=new Set(['OPENAI','OLLAMA','LOCAL_SPEACH']);
21
+ export const AI_READY_EVENT='ai-ready';
22
+ export const AI_INITIALIZATION_ERROR_CODES=Object.freeze({
23
+ userReadyRegistrationCollision:
24
+ 'ARCANE_AI_USER_READY_REGISTRATION_COLLISION'
25
+ });
26
+ export const AI_INITIALIZATION_REASONS=Object.freeze({
27
+ initialized:'ai-initialized',
28
+ userReadyRegistrationCollision:'ai-user-ready-registration-collision'
29
+ });
30
+ export const AI_BROWSER_SPEECH_CONFIGURATION_PROTOCOL=
31
+ 'arcane-ai-browser-speech-configuration/1';
32
+ export const AI_BROWSER_SPEECH_EVENT_TYPES=Object.freeze({
33
+ configurationCancelled:'ai-browser-speech-configuration-cancelled',
34
+ configurationError:'ai-browser-speech-configuration-error',
35
+ configurationStarted:'ai-browser-speech-configuration-started',
36
+ configured:'ai-browser-speech-configured',
37
+ disposed:'ai-browser-speech-disposed'
38
+ });
39
+ export const AI_BROWSER_SPEECH_ERROR_CODES=Object.freeze({
40
+ artifactStoreConstructionRejected:
41
+ 'ARCANE_AI_BROWSER_SPEECH_ARTIFACT_STORE_CONSTRUCTION_REJECTED',
42
+ asyncTransitionRequired:
43
+ 'ARCANE_AI_BROWSER_SPEECH_ASYNC_TRANSITION_REQUIRED',
44
+ configurationCancelled:'ARCANE_AI_BROWSER_SPEECH_CONFIGURATION_CANCELLED',
45
+ configurationContractMismatch:
46
+ 'ARCANE_AI_BROWSER_SPEECH_CONFIGURATION_CONTRACT_MISMATCH',
47
+ configurationSuperseded:
48
+ 'ARCANE_AI_BROWSER_SPEECH_CONFIGURATION_SUPERSEDED',
49
+ operationOptionsContractMismatch:
50
+ 'ARCANE_AI_BROWSER_SPEECH_OPERATION_OPTIONS_CONTRACT_MISMATCH',
51
+ operationSequenceExhausted:
52
+ 'ARCANE_AI_BROWSER_SPEECH_OPERATION_SEQUENCE_EXHAUSTED',
53
+ moduleImportRejected:'ARCANE_AI_BROWSER_SPEECH_MODULE_IMPORT_REJECTED',
54
+ providerConstructionRejected:
55
+ 'ARCANE_AI_BROWSER_SPEECH_PROVIDER_CONSTRUCTION_REJECTED',
56
+ providerDisposalRejected:
57
+ 'ARCANE_AI_BROWSER_SPEECH_PROVIDER_DISPOSAL_REJECTED',
58
+ providerRouteOwnershipMismatch:
59
+ 'ARCANE_AI_BROWSER_SPEECH_PROVIDER_ROUTE_OWNERSHIP_MISMATCH',
60
+ providerUnregistrationRejected:
61
+ 'ARCANE_AI_BROWSER_SPEECH_PROVIDER_UNREGISTRATION_REJECTED',
62
+ routeCommitRejected:'ARCANE_AI_BROWSER_SPEECH_ROUTE_COMMIT_REJECTED',
63
+ routeRollbackRejected:'ARCANE_AI_BROWSER_SPEECH_ROUTE_ROLLBACK_REJECTED',
64
+ routeViewUpdateRejected:
65
+ 'ARCANE_AI_BROWSER_SPEECH_ROUTE_VIEW_UPDATE_REJECTED'
66
+ });
67
+ export const AI_BROWSER_SPEECH_REASONS=Object.freeze({
68
+ artifactStoreConstructionRejected:'speech-artifact-store-construction-rejected',
69
+ asyncTransitionRequired:'speech-configuration-async-transition-required',
70
+ configurationAdded:'speech-configuration-added',
71
+ configurationCancelled:'speech-configuration-cancelled',
72
+ configurationContractMismatch:'speech-configuration-contract-mismatch',
73
+ configurationDisposed:'speech-configuration-disposed',
74
+ configurationReplaced:'speech-configuration-replaced',
75
+ moduleImportRejected:'speech-module-import-rejected',
76
+ operationOptionsContractMismatch:'speech-operation-options-contract-mismatch',
77
+ operationSequenceExhausted:'speech-operation-sequence-exhausted',
78
+ providerConstructionRejected:'speech-provider-construction-rejected',
79
+ providerDisposalRejected:'speech-provider-disposal-rejected',
80
+ providerRouteOwnershipMismatch:'speech-provider-route-ownership-mismatch',
81
+ providerUnregistrationRejected:'speech-provider-unregistration-rejected',
82
+ routeCommitRejected:'speech-route-commit-rejected',
83
+ routeRollbackRejected:'speech-route-rollback-rejected',
84
+ routeViewUpdateRejected:'speech-route-view-update-rejected'
85
+ });
86
+ const AI_PUBLISH_READY=Symbol('publish-ai-ready');
87
+ const AI_USER_READY_REGISTRATION_KEY=Symbol.for(
88
+ 'arcane.ai.user-ready-registration'
89
+ );
90
+ const AI_USER_READY_REGISTRATION_PROTOCOL=
91
+ 'arcane-ai-user-ready-registration/1';
92
+
93
+ function aiInitializationError(code,reason,message){
94
+ const error=new Error(message);
95
+ error.code=code;
96
+ error.reason=reason;
97
+ return error;
98
+ }
16
99
 
17
100
  function isAIRequestAbort(error,signal){
18
101
  return signal?.aborted
@@ -228,6 +311,255 @@ function normalizeAIStartupOptions(options){
228
311
  return Object.freeze({startMuted,startTranscription,signal});
229
312
  }
230
313
 
314
+ function aiBrowserSpeechError(code,reason,message,cause,{committed=false,name='Error'}={}){
315
+ const error=cause===undefined
316
+ ?new Error(message)
317
+ :new Error(message,{cause});
318
+ error.name=name;
319
+ error.code=code;
320
+ error.reason=reason;
321
+ if(committed)error.committed=true;
322
+ return error;
323
+ }
324
+
325
+ function isAbortSignal(value){
326
+ return Boolean(value)
327
+ &&typeof value==='object'
328
+ &&typeof value.aborted==='boolean'
329
+ &&typeof value.addEventListener==='function'
330
+ &&typeof value.removeEventListener==='function';
331
+ }
332
+
333
+ function frozenClosedRecord(value,keys,required,label){
334
+ if(!value
335
+ ||typeof value!=='object'
336
+ ||Array.isArray(value)
337
+ ||![Object.prototype,null].includes(Object.getPrototypeOf(value))
338
+ ||!Object.isFrozen(value)){
339
+ throw aiBrowserSpeechError(
340
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationContractMismatch,
341
+ AI_BROWSER_SPEECH_REASONS.configurationContractMismatch,
342
+ `${label} must be a frozen plain data record.`
343
+ );
344
+ }
345
+ const descriptors=Object.getOwnPropertyDescriptors(value);
346
+ for(const key of Reflect.ownKeys(descriptors)){
347
+ if(typeof key!=='string'
348
+ ||!keys.includes(key)
349
+ ||!Object.hasOwn(descriptors[key],'value')){
350
+ throw aiBrowserSpeechError(
351
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationContractMismatch,
352
+ AI_BROWSER_SPEECH_REASONS.configurationContractMismatch,
353
+ `${label} contains an unsupported or accessor field.`
354
+ );
355
+ }
356
+ }
357
+ for(const key of required){
358
+ if(!Object.hasOwn(descriptors,key)){
359
+ throw aiBrowserSpeechError(
360
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationContractMismatch,
361
+ AI_BROWSER_SPEECH_REASONS.configurationContractMismatch,
362
+ `${label}.${key} is required.`
363
+ );
364
+ }
365
+ }
366
+ return descriptors;
367
+ }
368
+
369
+ function browserSpeechIdentifier(value,label){
370
+ if(typeof value!=='string'
371
+ ||value.trim()!==value
372
+ ||value.length<1
373
+ ||value.length>128){
374
+ throw aiBrowserSpeechError(
375
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationContractMismatch,
376
+ AI_BROWSER_SPEECH_REASONS.configurationContractMismatch,
377
+ `${label} must be a trimmed 1-128 character string.`
378
+ );
379
+ }
380
+ return value;
381
+ }
382
+
383
+ function normalizeBrowserSpeechRole(value,role){
384
+ const label=`AI browser speech ${role}`;
385
+ const descriptors=frozenClosedRecord(
386
+ value,
387
+ ['providerId','graph','model','runtime','security','offline'],
388
+ ['providerId','offline'],
389
+ label
390
+ );
391
+ const providerId=browserSpeechIdentifier(
392
+ descriptors.providerId.value,
393
+ `${label}.providerId`
394
+ );
395
+ const hasGraph=Object.hasOwn(descriptors,'graph');
396
+ const hasModel=Object.hasOwn(descriptors,'model');
397
+ const hasRuntime=Object.hasOwn(descriptors,'runtime');
398
+ const hasSecurity=Object.hasOwn(descriptors,'security');
399
+ if(hasGraph&&(hasModel||hasRuntime)){
400
+ throw aiBrowserSpeechError(
401
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationContractMismatch,
402
+ AI_BROWSER_SPEECH_REASONS.configurationContractMismatch,
403
+ `${label}.graph is mutually exclusive with model and runtime.`
404
+ );
405
+ }
406
+ if(!hasGraph&&(!hasModel||!hasRuntime)){
407
+ throw aiBrowserSpeechError(
408
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationContractMismatch,
409
+ AI_BROWSER_SPEECH_REASONS.configurationContractMismatch,
410
+ `${label} must provide graph or both model and runtime.`
411
+ );
412
+ }
413
+ if(hasGraph){
414
+ const graph=descriptors.graph.value;
415
+ if(!graph||typeof graph!=='object'||!Object.isFrozen(graph)){
416
+ throw aiBrowserSpeechError(
417
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationContractMismatch,
418
+ AI_BROWSER_SPEECH_REASONS.configurationContractMismatch,
419
+ `${label}.graph must be an SDK-created frozen artifact graph.`
420
+ );
421
+ }
422
+ if(!hasSecurity){
423
+ throw aiBrowserSpeechError(
424
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationContractMismatch,
425
+ AI_BROWSER_SPEECH_REASONS.configurationContractMismatch,
426
+ `${label}.security with secure:true is required for an artifact graph.`
427
+ );
428
+ }
429
+ const security=frozenClosedRecord(
430
+ descriptors.security.value,
431
+ ['secure','checks'],
432
+ ['secure'],
433
+ `${label}.security`
434
+ );
435
+ if(security.secure.value!==true){
436
+ throw aiBrowserSpeechError(
437
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationContractMismatch,
438
+ AI_BROWSER_SPEECH_REASONS.configurationContractMismatch,
439
+ `${label}.security.secure must be true for an artifact graph.`
440
+ );
441
+ }
442
+ }else{
443
+ for(const key of ['model','runtime']){
444
+ const descriptor=descriptors[key].value;
445
+ if(!descriptor||typeof descriptor!=='object'||Array.isArray(descriptor)){
446
+ throw aiBrowserSpeechError(
447
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationContractMismatch,
448
+ AI_BROWSER_SPEECH_REASONS.configurationContractMismatch,
449
+ `${label}.${key} must be a browser speech authority descriptor.`
450
+ );
451
+ }
452
+ }
453
+ }
454
+ if(typeof descriptors.offline.value!=='boolean'){
455
+ throw aiBrowserSpeechError(
456
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationContractMismatch,
457
+ AI_BROWSER_SPEECH_REASONS.configurationContractMismatch,
458
+ `${label}.offline must be a boolean.`
459
+ );
460
+ }
461
+ return Object.freeze({
462
+ providerId,
463
+ ...(hasGraph
464
+ ?{
465
+ graph:descriptors.graph.value,
466
+ security:descriptors.security.value
467
+ }
468
+ :{
469
+ model:descriptors.model.value,
470
+ runtime:descriptors.runtime.value,
471
+ ...(hasSecurity?{security:descriptors.security.value}:{})
472
+ }),
473
+ offline:descriptors.offline.value
474
+ });
475
+ }
476
+
477
+ function normalizeBrowserSpeechConfiguration(value){
478
+ const descriptors=frozenClosedRecord(
479
+ value,
480
+ ['protocol','id','dbopfs','tableName','stt','tts'],
481
+ ['protocol','id','dbopfs'],
482
+ 'AI browser speech configuration'
483
+ );
484
+ if(descriptors.protocol.value!==AI_BROWSER_SPEECH_CONFIGURATION_PROTOCOL){
485
+ throw aiBrowserSpeechError(
486
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationContractMismatch,
487
+ AI_BROWSER_SPEECH_REASONS.configurationContractMismatch,
488
+ `AI browser speech configuration.protocol must be ${AI_BROWSER_SPEECH_CONFIGURATION_PROTOCOL}.`
489
+ );
490
+ }
491
+ const id=browserSpeechIdentifier(
492
+ descriptors.id.value,
493
+ 'AI browser speech configuration.id'
494
+ );
495
+ const dbopfs=descriptors.dbopfs.value;
496
+ if(!dbopfs||typeof dbopfs!=='object'){
497
+ throw aiBrowserSpeechError(
498
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationContractMismatch,
499
+ AI_BROWSER_SPEECH_REASONS.configurationContractMismatch,
500
+ 'AI browser speech configuration.dbopfs must be an existing DBOPFS instance.'
501
+ );
502
+ }
503
+ const tableName=descriptors.tableName
504
+ ?browserSpeechIdentifier(
505
+ descriptors.tableName.value,
506
+ 'AI browser speech configuration.tableName'
507
+ )
508
+ :undefined;
509
+ const roles=['stt','tts'].filter(role=>Object.hasOwn(descriptors,role));
510
+ if(roles.length===0){
511
+ throw aiBrowserSpeechError(
512
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationContractMismatch,
513
+ AI_BROWSER_SPEECH_REASONS.configurationContractMismatch,
514
+ 'AI browser speech configuration must provide stt, tts, or both.'
515
+ );
516
+ }
517
+ return Object.freeze({
518
+ configuration:value,
519
+ id,
520
+ dbopfs,
521
+ ...(tableName?{tableName}:{}),
522
+ roles:Object.freeze(roles),
523
+ ...Object.fromEntries(roles.map(role=>[
524
+ role,
525
+ normalizeBrowserSpeechRole(descriptors[role].value,role)
526
+ ]))
527
+ });
528
+ }
529
+
530
+ function normalizeBrowserSpeechOperationOptions(value,label){
531
+ if(value===undefined)return Object.freeze({signal:null});
532
+ if(!value
533
+ ||typeof value!=='object'
534
+ ||Array.isArray(value)
535
+ ||![Object.prototype,null].includes(Object.getPrototypeOf(value))){
536
+ throw aiBrowserSpeechError(
537
+ AI_BROWSER_SPEECH_ERROR_CODES.operationOptionsContractMismatch,
538
+ AI_BROWSER_SPEECH_REASONS.operationOptionsContractMismatch,
539
+ `${label} options must be a plain object.`
540
+ );
541
+ }
542
+ const descriptors=Object.getOwnPropertyDescriptors(value);
543
+ for(const key of Reflect.ownKeys(descriptors)){
544
+ if(key!=='signal'||!Object.hasOwn(descriptors[key],'value')){
545
+ throw aiBrowserSpeechError(
546
+ AI_BROWSER_SPEECH_ERROR_CODES.operationOptionsContractMismatch,
547
+ AI_BROWSER_SPEECH_REASONS.operationOptionsContractMismatch,
548
+ `${label} options support only a signal data property.`
549
+ );
550
+ }
551
+ }
552
+ const signal=descriptors.signal?.value??null;
553
+ if(signal!==null&&!isAbortSignal(signal)){
554
+ throw aiBrowserSpeechError(
555
+ AI_BROWSER_SPEECH_ERROR_CODES.operationOptionsContractMismatch,
556
+ AI_BROWSER_SPEECH_REASONS.operationOptionsContractMismatch,
557
+ `${label} signal must be an AbortSignal.`
558
+ );
559
+ }
560
+ return Object.freeze({signal});
561
+ }
562
+
231
563
  class AI {
232
564
  // This is the enum section for inference configuration
233
565
  #service = {
@@ -340,6 +672,19 @@ class AI {
340
672
  return window.ai;
341
673
  }
342
674
 
675
+ this.#events=createArcaneEventSource(
676
+ this,
677
+ {
678
+ source:'ai',
679
+ eventTypes:Object.freeze(
680
+ [
681
+ AI_READY_EVENT,
682
+ ...Object.values(AI_BROWSER_SPEECH_EVENT_TYPES)
683
+ ]
684
+ )
685
+ }
686
+ );
687
+
343
688
  const preferences=[
344
689
  llmService||'OPENAI',
345
690
  sttService||'OPENAI',
@@ -353,7 +698,7 @@ class AI {
353
698
  );
354
699
 
355
700
  const runtime=this;
356
- globalThis.addEventListener?.(
701
+ this.#stopOllamaReady=arcaneEvents.subscribe(
357
702
  'arcane-ollama-ready',
358
703
  function reconcileLegacyOllamaReadiness(){
359
704
  runtime.#retainLegacyLLMReadiness(
@@ -364,10 +709,22 @@ class AI {
364
709
  }
365
710
 
366
711
  #providerRuntime=getAIProviderRuntime();
712
+ #events=null;
713
+ #browserSpeechConfigurationRecord=null;
714
+ #browserSpeechController=null;
715
+ #browserSpeechControllerRoles=Object.freeze([]);
716
+ #browserSpeechGeneration=0;
717
+ #browserSpeechModulePromise=null;
718
+ #browserSpeechOperationSequence=0;
719
+ #browserSpeechRetiredRecords=new Set();
720
+ #browserSpeechRetiredLegacyRecords=new Set();
721
+ #browserSpeechTransition=Promise.resolve();
367
722
  #legacyLLMProviders=new Map();
368
723
  #legacyLLMReadiness=Promise.resolve(null);
369
724
  #legacySpeechProviders=new Map();
370
725
  #legacySpeechReadiness=Promise.resolve(null);
726
+ #speechControlGeneration=0;
727
+ #stopOllamaReady=null;
371
728
  #preferenceTuple=Object.freeze([
372
729
  'OPENAI',
373
730
  'OPENAI',
@@ -381,6 +738,40 @@ class AI {
381
738
  return this.#providerRuntime;
382
739
  }
383
740
 
741
+ [AI_PUBLISH_READY](){
742
+ const operationId=`${this.#events.instanceId}:ready:1`;
743
+ const reason=AI_INITIALIZATION_REASONS.initialized;
744
+ const {occurrence}=this.#events.dispatch(
745
+ AI_READY_EVENT,
746
+ Object.freeze({db:this,operationId,reason}),
747
+ {
748
+ operationId,
749
+ publicDetail:Object.freeze({
750
+ ready:true,
751
+ reason
752
+ })
753
+ }
754
+ );
755
+ projectArcaneDOMEvent(window,occurrence);
756
+ return occurrence;
757
+ }
758
+
759
+ get browserSpeechConfiguration(){
760
+ return this.#browserSpeechRecordIsActive(
761
+ this.#browserSpeechConfigurationRecord
762
+ )
763
+ ?this.#browserSpeechConfigurationRecord.configuration
764
+ :null;
765
+ }
766
+
767
+ get browserSpeechDescriptor(){
768
+ return this.#browserSpeechRecordIsActive(
769
+ this.#browserSpeechConfigurationRecord
770
+ )
771
+ ?this.#browserSpeechConfigurationRecord.descriptor
772
+ :null;
773
+ }
774
+
384
775
  get url() {
385
776
  return `${this.#service.baseURL[this.llmService]}${this.#paths.chat[this.llmService]}`
386
777
  }
@@ -1228,6 +1619,14 @@ class AI {
1228
1619
  this.#preferenceTuple=Object.freeze(tuple.slice());
1229
1620
  }
1230
1621
 
1622
+ #applySpeechPreferenceTuple(tuple){
1623
+ this.sttService=tuple[1];
1624
+ this.ttsService=tuple[2];
1625
+ this.modelTTS=this.#ttsModels[tuple[4]]||tuple[4];
1626
+ this.modelSTT=this.#sttModels[tuple[5]]||tuple[5];
1627
+ this.#preferenceTuple=Object.freeze(tuple.slice());
1628
+ }
1629
+
1231
1630
  #tupleFromProviderRoutes(selections){
1232
1631
  const llm=selections.llm.default;
1233
1632
  const stt=selections.stt.default;
@@ -1242,6 +1641,20 @@ class AI {
1242
1641
  ]);
1243
1642
  }
1244
1643
 
1644
+ #tupleFromSpeechProviderRoutes(selections){
1645
+ const current=this.#preferenceTuple;
1646
+ const stt=selections.stt.default;
1647
+ const tts=selections.tts.default;
1648
+ return Object.freeze([
1649
+ current[0],
1650
+ stt?.providerId||'',
1651
+ tts?.providerId||'',
1652
+ current[3],
1653
+ tts?.modelId||'',
1654
+ stt?.modelId||''
1655
+ ]);
1656
+ }
1657
+
1245
1658
  #routesFromPreferenceTuple(tuple){
1246
1659
  const roles={
1247
1660
  llm:[
@@ -1312,6 +1725,36 @@ class AI {
1312
1725
  }
1313
1726
  }
1314
1727
 
1728
+ async #unloadSpeechProviderRolesForTransition(
1729
+ signal=null,
1730
+ expectedProviders=null,
1731
+ roles=['stt','tts']
1732
+ ){
1733
+ const runtime=this;
1734
+ const settlements=await Promise.allSettled(
1735
+ roles.map(async function unloadSpeechProviderRole(role){
1736
+ if(expectedProviders?.[role]
1737
+ &&!runtime.#providerRuntime.ownsProvider(
1738
+ role,
1739
+ expectedProviders[role]
1740
+ )){
1741
+ throw runtime.#browserSpeechProviderRouteOwnershipError(
1742
+ `The ${role} browser speech provider identity changed before unload.`
1743
+ );
1744
+ }
1745
+ return role==='tts'
1746
+ ?runtime.#providerRuntime.setSpeechMuted(true)
1747
+ :runtime.#providerRuntime.unload(role,{signal});
1748
+ })
1749
+ );
1750
+ const failure=settlements.find(function findAISpeechTransitionCleanupFailure(result){
1751
+ return result.status==='rejected';
1752
+ });
1753
+ if(failure){
1754
+ throw failure.reason;
1755
+ }
1756
+ }
1757
+
1315
1758
  // Set models to be used by the AI.
1316
1759
  // Note: Only those that are defined are set.
1317
1760
  setAI(
@@ -1343,10 +1786,12 @@ class AI {
1343
1786
  modelSTT
1344
1787
  ]);
1345
1788
  this.#assertValidProviderTuple(tuple);
1789
+ this.#assertSynchronousBrowserSpeechSupersession('AI.setAI');
1346
1790
  this.#ensureLegacyLLMProvider(tuple[0]);
1347
1791
  this.#ensureLegacySpeechProvider('stt',tuple[1]);
1348
1792
  this.#ensureLegacySpeechProvider('tts',tuple[2]);
1349
1793
  this.#providerRuntime.configure(this.#routesFromPreferenceTuple(tuple));
1794
+ this.#invalidateSpeechControl();
1350
1795
  this.#applyPreferenceTuple(tuple);
1351
1796
  this.#releaseInactiveLegacyLLMProviders(tuple[0]);
1352
1797
  this.#releaseInactiveLegacySpeechProviders({
@@ -1364,6 +1809,7 @@ class AI {
1364
1809
 
1365
1810
  configureProviders(selections){
1366
1811
  const prepared=this.#providerRuntime.validateConfiguration(selections);
1812
+ this.#assertSynchronousBrowserSpeechSupersession('AI.configureProviders');
1367
1813
  this.#ensureLegacyLLMProvider(
1368
1814
  prepared.llm.default?.providerId
1369
1815
  );
@@ -1377,6 +1823,7 @@ class AI {
1377
1823
  );
1378
1824
  this.#assertRegisteredLegacyRoutes(prepared);
1379
1825
  const configured=this.#providerRuntime.configure(prepared);
1826
+ this.#invalidateSpeechControl();
1380
1827
  this.#applyPreferenceTuple(this.#tupleFromProviderRoutes(configured));
1381
1828
  this.#releaseInactiveLegacyLLMProviders(
1382
1829
  configured.llm.default?.providerId
@@ -1388,97 +1835,1411 @@ class AI {
1388
1835
  this.#retainLegacyLLMReadiness(
1389
1836
  this.#reconcileLegacyLLMReadiness()
1390
1837
  );
1391
- this.#retainLegacySpeechReadiness(
1392
- this.#reconcileLegacySpeechReadiness()
1838
+ this.#retainLegacySpeechReadiness(
1839
+ this.#reconcileLegacySpeechReadiness()
1840
+ );
1841
+ return configured;
1842
+ }
1843
+
1844
+ configureSpeechProviders(selections){
1845
+ const prepared=this.#providerRuntime.validateSpeechConfiguration(selections);
1846
+ this.#assertSynchronousBrowserSpeechSupersession(
1847
+ 'AI.configureSpeechProviders'
1848
+ );
1849
+ this.#ensureLegacySpeechProvider(
1850
+ 'stt',
1851
+ prepared.stt.default?.providerId
1852
+ );
1853
+ this.#ensureLegacySpeechProvider(
1854
+ 'tts',
1855
+ prepared.tts.default?.providerId
1856
+ );
1857
+ this.#assertRegisteredLegacyRoutes(prepared);
1858
+ const configured=this.#providerRuntime.configureSpeech(prepared);
1859
+ this.#invalidateSpeechControl();
1860
+ this.#applySpeechPreferenceTuple(
1861
+ this.#tupleFromSpeechProviderRoutes(configured)
1862
+ );
1863
+ this.#releaseInactiveLegacySpeechProviders({
1864
+ stt:configured.stt.default?.providerId,
1865
+ tts:configured.tts.default?.providerId
1866
+ });
1867
+ this.#retainLegacySpeechReadiness(
1868
+ this.#reconcileLegacySpeechReadiness()
1869
+ );
1870
+ this.muted=true;
1871
+ this.stopAudio();
1872
+ return configured;
1873
+ }
1874
+
1875
+ async transitionAI(
1876
+ llmService,
1877
+ sttService,
1878
+ ttsService,
1879
+ model,
1880
+ modelTTS,
1881
+ modelSTT
1882
+ ){
1883
+ const tuple=this.#nextPreferenceTuple([
1884
+ llmService,
1885
+ sttService,
1886
+ ttsService,
1887
+ model,
1888
+ modelTTS,
1889
+ modelSTT
1890
+ ]);
1891
+ this.#assertValidProviderTuple(tuple);
1892
+ await this.#supersedeBrowserSpeechForRouteChange();
1893
+ this.#invalidateSpeechControl();
1894
+ await this.#unloadProviderRolesForTransition();
1895
+ this.#ensureLegacyLLMProvider(tuple[0]);
1896
+ this.#ensureLegacySpeechProvider('stt',tuple[1]);
1897
+ this.#ensureLegacySpeechProvider('tts',tuple[2]);
1898
+ this.#providerRuntime.configure(this.#routesFromPreferenceTuple(tuple));
1899
+ this.#applyPreferenceTuple(tuple);
1900
+ this.#releaseInactiveLegacyLLMProviders(tuple[0]);
1901
+ this.#releaseInactiveLegacySpeechProviders({
1902
+ stt:tuple[1],
1903
+ tts:tuple[2]
1904
+ });
1905
+ await this.#reconcileLegacyLLMReadiness();
1906
+ await this.#reconcileLegacySpeechReadiness();
1907
+ return this.#providerRuntime.status();
1908
+ }
1909
+
1910
+ async transitionProviders(selections){
1911
+ const prepared=this.#providerRuntime.validateConfiguration(selections);
1912
+ await this.#supersedeBrowserSpeechForRouteChange();
1913
+ this.#ensureLegacyLLMProvider(
1914
+ prepared.llm.default?.providerId
1915
+ );
1916
+ this.#ensureLegacySpeechProvider(
1917
+ 'stt',
1918
+ prepared.stt.default?.providerId
1919
+ );
1920
+ this.#ensureLegacySpeechProvider(
1921
+ 'tts',
1922
+ prepared.tts.default?.providerId
1923
+ );
1924
+ this.#assertRegisteredLegacyRoutes(prepared);
1925
+ this.#invalidateSpeechControl();
1926
+ await this.#unloadProviderRolesForTransition();
1927
+ const configured=this.#providerRuntime.configure(prepared);
1928
+ this.#applyPreferenceTuple(this.#tupleFromProviderRoutes(configured));
1929
+ this.#releaseInactiveLegacyLLMProviders(
1930
+ configured.llm.default?.providerId
1931
+ );
1932
+ this.#releaseInactiveLegacySpeechProviders({
1933
+ stt:configured.stt.default?.providerId,
1934
+ tts:configured.tts.default?.providerId
1935
+ });
1936
+ await this.#reconcileLegacyLLMReadiness();
1937
+ await this.#reconcileLegacySpeechReadiness();
1938
+ return configured;
1939
+ }
1940
+
1941
+ async transitionSpeechProviders(selections){
1942
+ const prepared=this.#providerRuntime.validateSpeechConfiguration(selections);
1943
+ await this.#supersedeBrowserSpeechForRouteChange();
1944
+ this.#invalidateSpeechControl();
1945
+ await this.#unloadSpeechProviderRolesForTransition();
1946
+ this.#ensureLegacySpeechProvider(
1947
+ 'stt',
1948
+ prepared.stt.default?.providerId
1949
+ );
1950
+ this.#ensureLegacySpeechProvider(
1951
+ 'tts',
1952
+ prepared.tts.default?.providerId
1953
+ );
1954
+ this.#assertRegisteredLegacyRoutes(prepared);
1955
+ const configured=this.#providerRuntime.configureSpeech(prepared);
1956
+ this.#applySpeechPreferenceTuple(
1957
+ this.#tupleFromSpeechProviderRoutes(configured)
1958
+ );
1959
+ this.#releaseInactiveLegacySpeechProviders({
1960
+ stt:configured.stt.default?.providerId,
1961
+ tts:configured.tts.default?.providerId
1962
+ });
1963
+ await this.#reconcileLegacySpeechReadiness();
1964
+ this.muted=true;
1965
+ return configured;
1966
+ }
1967
+
1968
+ #browserSpeechOperationId(action){
1969
+ if(this.#browserSpeechOperationSequence===Number.MAX_SAFE_INTEGER){
1970
+ throw aiBrowserSpeechError(
1971
+ AI_BROWSER_SPEECH_ERROR_CODES.operationSequenceExhausted,
1972
+ AI_BROWSER_SPEECH_REASONS.operationSequenceExhausted,
1973
+ 'The browser speech operation sequence is exhausted.'
1974
+ );
1975
+ }
1976
+ this.#browserSpeechOperationSequence+=1;
1977
+ return `${this.#events.instanceId}:${action}:${this.#browserSpeechOperationSequence.toString(36)}`;
1978
+ }
1979
+
1980
+ #browserSpeechAbortError(controller,generation,{committed=false}={}){
1981
+ const reason=controller.signal.reason;
1982
+ if(reason?.code===AI_BROWSER_SPEECH_ERROR_CODES.configurationSuperseded){
1983
+ if(Boolean(reason.committed)===committed)return reason;
1984
+ return aiBrowserSpeechError(
1985
+ reason.code,
1986
+ reason.reason||AI_BROWSER_SPEECH_REASONS.configurationReplaced,
1987
+ reason.message
1988
+ ||'The browser speech configuration was superseded.',
1989
+ reason,
1990
+ {committed,name:'AbortError'}
1991
+ );
1992
+ }
1993
+ if(generation!==this.#browserSpeechGeneration){
1994
+ return aiBrowserSpeechError(
1995
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationSuperseded,
1996
+ AI_BROWSER_SPEECH_REASONS.configurationReplaced,
1997
+ 'The browser speech configuration was replaced by a newer configuration.',
1998
+ controller.signal.reason,
1999
+ {committed,name:'AbortError'}
2000
+ );
2001
+ }
2002
+ return aiBrowserSpeechError(
2003
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationCancelled,
2004
+ AI_BROWSER_SPEECH_REASONS.configurationCancelled,
2005
+ 'The browser speech configuration was cancelled.',
2006
+ reason,
2007
+ {committed,name:'AbortError'}
2008
+ );
2009
+ }
2010
+
2011
+ #assertBrowserSpeechOperation(controller,generation,{committed=false}={}){
2012
+ if(generation!==this.#browserSpeechGeneration||controller.signal.aborted){
2013
+ throw this.#browserSpeechAbortError(
2014
+ controller,
2015
+ generation,
2016
+ {committed}
2017
+ );
2018
+ }
2019
+ }
2020
+
2021
+ #invalidateSpeechControl(){
2022
+ this.#speechControlGeneration+=1;
2023
+ this.muted=true;
2024
+ this.stopAudio();
2025
+ }
2026
+
2027
+ #sameBrowserSpeechSelection(left,right){
2028
+ if(left===null||right===null)return left===right;
2029
+ return Boolean(left&&right)
2030
+ &&left.providerId===right.providerId
2031
+ &&left.modelId===right.modelId
2032
+ &&left.localOnly===right.localOnly;
2033
+ }
2034
+
2035
+ #sameBrowserSpeechRoutes(left,right,roles=['stt','tts']){
2036
+ return roles.every(role=>
2037
+ ['default','localOnly'].every(routeName=>
2038
+ this.#sameBrowserSpeechSelection(
2039
+ left?.[role]?.[routeName]??null,
2040
+ right?.[role]?.[routeName]??null
2041
+ )
2042
+ )
2043
+ );
2044
+ }
2045
+
2046
+ #browserSpeechRecordOwnsProviders(record){
2047
+ if(!record||record.managedRoles.length===0)return false;
2048
+ return record.managedRoles.every(role=>{
2049
+ if(record.registrationState?.[role]===false)return false;
2050
+ return this.#providerRuntime.ownsProvider(
2051
+ role,
2052
+ record.providers[role]
2053
+ );
2054
+ });
2055
+ }
2056
+
2057
+ #browserSpeechRecordIsActive(record){
2058
+ return this.#browserSpeechRecordOwnsProviders(record)
2059
+ &&this.#sameBrowserSpeechRoutes(
2060
+ this.#currentSpeechRoutes(),
2061
+ record.routes,
2062
+ record.managedRoles
2063
+ );
2064
+ }
2065
+
2066
+ #browserSpeechProviderRouteOwnershipError(message){
2067
+ return aiBrowserSpeechError(
2068
+ AI_BROWSER_SPEECH_ERROR_CODES.providerRouteOwnershipMismatch,
2069
+ AI_BROWSER_SPEECH_REASONS.providerRouteOwnershipMismatch,
2070
+ message
2071
+ );
2072
+ }
2073
+
2074
+ #browserSpeechReplacementBoundary(previousRecord,roles){
2075
+ const expectedProviders={stt:null,tts:null};
2076
+ const legacyRecords=[];
2077
+ for(const role of roles){
2078
+ if(previousRecord?.managedRoles.includes(role)){
2079
+ expectedProviders[role]=previousRecord.providers[role];
2080
+ continue;
2081
+ }
2082
+ const selection=this.#providerRuntime.selection(role);
2083
+ if(!selection){
2084
+ expectedProviders[role]=null;
2085
+ continue;
2086
+ }
2087
+ const record=this.#legacySpeechProviders.get(
2088
+ this.#legacySpeechProviderKey(role,selection.providerId)
2089
+ );
2090
+ if(!record
2091
+ ||!this.#providerRuntime.ownsProvider(role,record.provider)){
2092
+ const pendingIdentity=this.#providerRuntime.providerIdentity(
2093
+ role,
2094
+ selection.providerId
2095
+ );
2096
+ if(pendingIdentity===null&&selection.localOnly===null){
2097
+ expectedProviders[role]=null;
2098
+ continue;
2099
+ }
2100
+ throw this.#browserSpeechProviderRouteOwnershipError(
2101
+ `The selected ${role} route is not owned by the replaceable AI legacy speech boundary.`
2102
+ );
2103
+ }
2104
+ expectedProviders[role]=record.provider;
2105
+ legacyRecords.push(record);
2106
+ }
2107
+ return Object.freeze({
2108
+ expectedProviders:Object.freeze(expectedProviders),
2109
+ legacyRecords:Object.freeze(legacyRecords)
2110
+ });
2111
+ }
2112
+
2113
+ async #cleanupRetiredLegacySpeechProviders(
2114
+ {signal=null,committed=false}={}
2115
+ ){
2116
+ const failures=[];
2117
+ for(const record of [...this.#browserSpeechRetiredLegacyRecords]){
2118
+ try{
2119
+ if(this.#providerRuntime.ownsProvider(
2120
+ record.role,
2121
+ record.provider
2122
+ )){
2123
+ throw this.#browserSpeechProviderRouteOwnershipError(
2124
+ `The retired ${record.role} legacy speech provider still owns its registry entry.`
2125
+ );
2126
+ }
2127
+ await record.provider.dispose({role:record.role,signal});
2128
+ const key=this.#legacySpeechProviderKey(
2129
+ record.role,
2130
+ record.providerId
2131
+ );
2132
+ if(this.#legacySpeechProviders.get(key)===record){
2133
+ this.#legacySpeechProviders.delete(key);
2134
+ }
2135
+ this.#browserSpeechRetiredLegacyRecords.delete(record);
2136
+ }catch(error){
2137
+ failures.push(error);
2138
+ }
2139
+ }
2140
+ if(failures.length){
2141
+ throw aiBrowserSpeechError(
2142
+ AI_BROWSER_SPEECH_ERROR_CODES.providerDisposalRejected,
2143
+ AI_BROWSER_SPEECH_REASONS.providerDisposalRejected,
2144
+ 'The replaced legacy speech providers could not be disposed.',
2145
+ failures.length===1
2146
+ ?failures[0]
2147
+ :new AggregateError(
2148
+ failures,
2149
+ 'Multiple replaced legacy speech provider disposals were rejected.'
2150
+ ),
2151
+ {committed}
2152
+ );
2153
+ }
2154
+ return true;
2155
+ }
2156
+
2157
+ #assertSynchronousBrowserSpeechSupersession(method){
2158
+ if(!this.#browserSpeechConfigurationRecord
2159
+ &&this.#browserSpeechRetiredRecords.size===0){
2160
+ return;
2161
+ }
2162
+ throw aiBrowserSpeechError(
2163
+ AI_BROWSER_SPEECH_ERROR_CODES.asyncTransitionRequired,
2164
+ AI_BROWSER_SPEECH_REASONS.asyncTransitionRequired,
2165
+ `${method} cannot replace SDK-owned browser speech providers synchronously; await AI.disposeBrowserSpeech() or use an asynchronous transition method.`
2166
+ );
2167
+ }
2168
+
2169
+ async #supersedeBrowserSpeechForRouteChange(){
2170
+ if(!this.#browserSpeechConfigurationRecord
2171
+ &&this.#browserSpeechRetiredRecords.size===0){
2172
+ return false;
2173
+ }
2174
+ return this.disposeBrowserSpeech();
2175
+ }
2176
+
2177
+ #publishBrowserSpeechEvent(type,normalized,operationId,reason,{descriptor=null,error=null}={}){
2178
+ const compatibilityDetail=Object.freeze({
2179
+ configuration:normalized.configuration,
2180
+ configurationId:normalized.id,
2181
+ ...(descriptor?{descriptor}:{}),
2182
+ ...(error?{error}:{}),
2183
+ reason
2184
+ });
2185
+ return this.#events.dispatch(
2186
+ type,
2187
+ compatibilityDetail,
2188
+ {
2189
+ operationId,
2190
+ publicDetail:Object.freeze({
2191
+ configurationId:normalized.id,
2192
+ ...(descriptor?{descriptor}:{}),
2193
+ ...(typeof error?.code==='string'?{code:error.code}:{}),
2194
+ reason
2195
+ })
2196
+ }
2197
+ );
2198
+ }
2199
+
2200
+ #browserSpeechRoutes(normalized,providers,previousRecord){
2201
+ function roleRoutes(provider,catalog){
2202
+ const selection=Object.freeze({
2203
+ providerId:provider.id,
2204
+ modelId:catalog.id,
2205
+ localOnly:true
2206
+ });
2207
+ return Object.freeze({default:selection,localOnly:selection});
2208
+ }
2209
+ const currentRoutes=this.#currentSpeechRoutes();
2210
+ const routes={};
2211
+ const catalogs={};
2212
+ for(const role of ['stt','tts']){
2213
+ if(!normalized.roles.includes(role)){
2214
+ routes[role]=previousRecord?.managedRoles.includes(role)
2215
+ ?previousRecord.routes[role]
2216
+ :currentRoutes[role];
2217
+ catalogs[role]=null;
2218
+ continue;
2219
+ }
2220
+ const catalog=providers[role].catalog();
2221
+ if(catalog.length!==1||typeof catalog[0]?.id!=='string'){
2222
+ throw aiBrowserSpeechError(
2223
+ AI_BROWSER_SPEECH_ERROR_CODES.providerConstructionRejected,
2224
+ AI_BROWSER_SPEECH_REASONS.providerConstructionRejected,
2225
+ `The browser speech ${role} provider must expose one admitted model.`
2226
+ );
2227
+ }
2228
+ catalogs[role]=catalog[0];
2229
+ routes[role]=roleRoutes(providers[role],catalog[0]);
2230
+ }
2231
+ return Object.freeze({
2232
+ routes:Object.freeze(routes),
2233
+ catalogs:Object.freeze(catalogs)
2234
+ });
2235
+ }
2236
+
2237
+ #browserSpeechDescriptor(normalized,catalogs,previousRecord){
2238
+ function roleDescriptor(role,configured,catalog){
2239
+ return Object.freeze({
2240
+ role,
2241
+ providerId:configured.providerId,
2242
+ modelId:catalog.id,
2243
+ ...(configured.graph
2244
+ ?{artifactGraphId:catalog.artifactGraphId}
2245
+ :{}),
2246
+ offline:configured.offline,
2247
+ ...(role==='tts'?{defaultVoice:catalog.defaultVoice}:{})
2248
+ });
2249
+ }
2250
+ const roles={};
2251
+ for(const role of ['stt','tts']){
2252
+ roles[role]=normalized.roles.includes(role)
2253
+ ?roleDescriptor(role,normalized[role],catalogs[role])
2254
+ :previousRecord?.descriptor[role]??null;
2255
+ }
2256
+ return Object.freeze({
2257
+ protocol:AI_BROWSER_SPEECH_CONFIGURATION_PROTOCOL,
2258
+ configurationId:normalized.id,
2259
+ ...roles
2260
+ });
2261
+ }
2262
+
2263
+ #browserSpeechConfiguration(normalized,previousRecord){
2264
+ if(!previousRecord)return normalized.configuration;
2265
+ if(normalized.roles.length===2)return normalized.configuration;
2266
+ if(normalized.dbopfs!==previousRecord.dbopfs
2267
+ ||normalized.tableName!==previousRecord.tableName){
2268
+ throw aiBrowserSpeechError(
2269
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationContractMismatch,
2270
+ AI_BROWSER_SPEECH_REASONS.configurationContractMismatch,
2271
+ 'A partial browser speech replacement must retain the active DBOPFS store and tableName.'
2272
+ );
2273
+ }
2274
+ const carriedRoles=previousRecord.managedRoles.filter(
2275
+ role=>!normalized.roles.includes(role)
2276
+ );
2277
+ if(carriedRoles.length===0)return normalized.configuration;
2278
+ return Object.freeze({
2279
+ protocol:AI_BROWSER_SPEECH_CONFIGURATION_PROTOCOL,
2280
+ id:normalized.id,
2281
+ dbopfs:normalized.dbopfs,
2282
+ ...(normalized.tableName?{tableName:normalized.tableName}:{}),
2283
+ ...(normalized.roles.includes('stt')
2284
+ ?{stt:normalized.configuration.stt}
2285
+ :previousRecord.managedRoles.includes('stt')
2286
+ ?{stt:previousRecord.configuration.stt}
2287
+ :{}),
2288
+ ...(normalized.roles.includes('tts')
2289
+ ?{tts:normalized.configuration.tts}
2290
+ :previousRecord.managedRoles.includes('tts')
2291
+ ?{tts:previousRecord.configuration.tts}
2292
+ :{})
2293
+ });
2294
+ }
2295
+
2296
+ #currentSpeechRoutes(){
2297
+ return Object.freeze({
2298
+ stt:Object.freeze({
2299
+ default:this.#providerRuntime.selection('stt'),
2300
+ localOnly:this.#providerRuntime.selection('stt',{localOnly:true})
2301
+ }),
2302
+ tts:Object.freeze({
2303
+ default:this.#providerRuntime.selection('tts'),
2304
+ localOnly:this.#providerRuntime.selection('tts',{localOnly:true})
2305
+ })
2306
+ });
2307
+ }
2308
+
2309
+ #emptySpeechRoutes(){
2310
+ return Object.freeze({
2311
+ stt:Object.freeze({default:null,localOnly:null}),
2312
+ tts:Object.freeze({default:null,localOnly:null})
2313
+ });
2314
+ }
2315
+
2316
+ async #browserSpeechModule(){
2317
+ if(!this.#browserSpeechModulePromise){
2318
+ const runtime=this;
2319
+ this.#browserSpeechModulePromise=import(
2320
+ 'arcane-os/ai/browser-speech'
2321
+ ).catch(function clearRejectedBrowserSpeechImport(error){
2322
+ runtime.#browserSpeechModulePromise=null;
2323
+ throw error;
2324
+ });
2325
+ }
2326
+ return this.#browserSpeechModulePromise;
2327
+ }
2328
+
2329
+ #assertBrowserSpeechGraphs(normalized,module){
2330
+ for(const role of normalized.roles){
2331
+ const configured=normalized[role];
2332
+ const graph=configured.graph;
2333
+ if(!graph)continue;
2334
+ if(graph.protocol!==module.BROWSER_SPEECH_ARTIFACT_GRAPH_PROTOCOL
2335
+ ||graph.role!==role
2336
+ ||(graph.providerId!==null
2337
+ &&graph.providerId!==undefined
2338
+ &&graph.providerId!==configured.providerId)){
2339
+ throw aiBrowserSpeechError(
2340
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationContractMismatch,
2341
+ AI_BROWSER_SPEECH_REASONS.configurationContractMismatch,
2342
+ `AI browser speech ${role}.graph does not match its role, provider, or graph protocol.`
2343
+ );
2344
+ }
2345
+ }
2346
+ }
2347
+
2348
+ #browserSpeechCandidate(
2349
+ normalized,
2350
+ configuration,
2351
+ descriptor,
2352
+ providers,
2353
+ routes,
2354
+ previousRecord
2355
+ ){
2356
+ const configurationByRole={};
2357
+ const managedRoles=Object.freeze(['stt','tts'].filter(role=>
2358
+ normalized.roles.includes(role)
2359
+ ||previousRecord?.managedRoles.includes(role)
2360
+ ));
2361
+ for(const role of ['stt','tts']){
2362
+ configurationByRole[role]=normalized.roles.includes(role)
2363
+ ?normalized.configuration
2364
+ :previousRecord?.configurationByRole[role]??null;
2365
+ }
2366
+ return {
2367
+ configuration,
2368
+ configurationByRole:Object.freeze(configurationByRole),
2369
+ dbopfs:normalized.dbopfs,
2370
+ tableName:normalized.tableName,
2371
+ descriptor,
2372
+ providers,
2373
+ routes,
2374
+ managedRoles,
2375
+ candidateRoles:normalized.roles,
2376
+ unregisters:{stt:null,tts:null},
2377
+ registrationState:{stt:false,tts:false},
2378
+ retirementState:{stt:false,tts:false}
2379
+ };
2380
+ }
2381
+
2382
+ #freezeBrowserSpeechRecord(record){
2383
+ record.unregisters=Object.freeze({...record.unregisters});
2384
+ Object.seal(record.registrationState);
2385
+ Object.seal(record.retirementState);
2386
+ return Object.freeze(record);
2387
+ }
2388
+
2389
+ #browserSpeechRecordFromReplacement(record,replacement){
2390
+ return this.#freezeBrowserSpeechRecord({
2391
+ configuration:record.configuration,
2392
+ configurationByRole:record.configurationByRole,
2393
+ dbopfs:record.dbopfs,
2394
+ tableName:record.tableName,
2395
+ descriptor:record.descriptor,
2396
+ providers:record.providers,
2397
+ routes:replacement.routes,
2398
+ managedRoles:record.managedRoles,
2399
+ candidateRoles:record.candidateRoles,
2400
+ unregisters:{
2401
+ stt:replacement.unregisters.stt,
2402
+ tts:replacement.unregisters.tts
2403
+ },
2404
+ registrationState:{
2405
+ stt:record.managedRoles.includes('stt'),
2406
+ tts:record.managedRoles.includes('tts')
2407
+ },
2408
+ retirementState:{stt:false,tts:false}
2409
+ });
2410
+ }
2411
+
2412
+ #retireBrowserSpeechRegistration(record,roles=['stt','tts']){
2413
+ if(!record)return;
2414
+ let retired=false;
2415
+ for(const role of roles){
2416
+ if(record.registrationState[role]===false)continue;
2417
+ record.registrationState[role]=false;
2418
+ record.retirementState[role]=true;
2419
+ retired=true;
2420
+ }
2421
+ if(retired)this.#browserSpeechRetiredRecords.add(record);
2422
+ }
2423
+
2424
+ #unregisterBrowserSpeechRecord(
2425
+ record,
2426
+ {roles=['stt','tts'],committed=false}={}
2427
+ ){
2428
+ const failures=[];
2429
+ for(const role of ['tts','stt'].filter(role=>roles.includes(role))){
2430
+ if(record.registrationState?.[role]===false)continue;
2431
+ const unregister=record.unregisters?.[role];
2432
+ if(typeof unregister!=='function'){
2433
+ failures.push(aiBrowserSpeechError(
2434
+ AI_BROWSER_SPEECH_ERROR_CODES.providerUnregistrationRejected,
2435
+ AI_BROWSER_SPEECH_REASONS.providerUnregistrationRejected,
2436
+ `The ${role} browser speech provider ${record.providers[role].id} has no unregister handle.`
2437
+ ));
2438
+ continue;
2439
+ }
2440
+ try{
2441
+ const removed=unregister();
2442
+ if(removed!==true){
2443
+ failures.push(aiBrowserSpeechError(
2444
+ AI_BROWSER_SPEECH_ERROR_CODES.providerUnregistrationRejected,
2445
+ AI_BROWSER_SPEECH_REASONS.providerUnregistrationRejected,
2446
+ `The ${role} browser speech provider ${record.providers[role].id} no longer owns its registry entry.`
2447
+ ));
2448
+ continue;
2449
+ }
2450
+ if(record.registrationState)record.registrationState[role]=false;
2451
+ }catch(error){
2452
+ failures.push(error);
2453
+ }
2454
+ }
2455
+ if(failures.length){
2456
+ throw aiBrowserSpeechError(
2457
+ AI_BROWSER_SPEECH_ERROR_CODES.providerUnregistrationRejected,
2458
+ AI_BROWSER_SPEECH_REASONS.providerUnregistrationRejected,
2459
+ 'The browser speech providers could not be unregistered.',
2460
+ failures.length===1
2461
+ ?failures[0]
2462
+ :new AggregateError(
2463
+ failures,
2464
+ 'Multiple browser speech provider unregistrations were rejected.'
2465
+ ),
2466
+ {committed}
2467
+ );
2468
+ }
2469
+ }
2470
+
2471
+ async #disposeBrowserSpeechProviders(
2472
+ record,
2473
+ {roles=['stt','tts'],signal=null}={}
2474
+ ){
2475
+ if(!record)return;
2476
+ const disposableRoles=roles.filter(role=>record.providers?.[role]);
2477
+ const settlements=await Promise.allSettled(
2478
+ disposableRoles.map(function disposeBrowserSpeechProvider(role){
2479
+ return record.providers[role].dispose({
2480
+ role,
2481
+ selection:record.routes[role].default,
2482
+ signal
2483
+ });
2484
+ })
2485
+ );
2486
+ const failures=settlements
2487
+ .filter(result=>result.status==='rejected')
2488
+ .map(result=>result.reason);
2489
+ if(failures.length){
2490
+ throw failures.length===1
2491
+ ?failures[0]
2492
+ :new AggregateError(
2493
+ failures,
2494
+ 'Multiple browser speech provider disposals were rejected.'
2495
+ );
2496
+ }
2497
+ }
2498
+
2499
+ async #cleanupBrowserSpeechRecord(
2500
+ record,
2501
+ {signal=null,committed=false}={}
2502
+ ){
2503
+ if(!record)return false;
2504
+ const retiredRoles=['stt','tts'].filter(
2505
+ role=>record.retirementState?.[role]===true
2506
+ );
2507
+ const roles=retiredRoles.length?retiredRoles:record.candidateRoles;
2508
+ this.#browserSpeechRetiredRecords.add(record);
2509
+ this.#unregisterBrowserSpeechRecord(record,{roles,committed});
2510
+ try{
2511
+ await this.#disposeBrowserSpeechProviders(record,{roles,signal});
2512
+ }catch(error){
2513
+ throw aiBrowserSpeechError(
2514
+ AI_BROWSER_SPEECH_ERROR_CODES.providerDisposalRejected,
2515
+ AI_BROWSER_SPEECH_REASONS.providerDisposalRejected,
2516
+ 'The browser speech providers could not be disposed.',
2517
+ error,
2518
+ {committed}
2519
+ );
2520
+ }
2521
+ this.#browserSpeechRetiredRecords.delete(record);
2522
+ return true;
2523
+ }
2524
+
2525
+ async #throwAfterBrowserSpeechCandidateCleanup(record,failure){
2526
+ try{
2527
+ await this.#cleanupBrowserSpeechRecord(record);
2528
+ }catch(cleanupError){
2529
+ throw aiBrowserSpeechError(
2530
+ cleanupError.code
2531
+ ||AI_BROWSER_SPEECH_ERROR_CODES.providerDisposalRejected,
2532
+ cleanupError.reason
2533
+ ||AI_BROWSER_SPEECH_REASONS.providerDisposalRejected,
2534
+ 'Browser speech candidate cleanup was rejected after configuration rejection.',
2535
+ new AggregateError(
2536
+ [failure,cleanupError],
2537
+ 'Browser speech configuration and candidate cleanup were both rejected.'
2538
+ )
2539
+ );
2540
+ }
2541
+ throw failure;
2542
+ }
2543
+
2544
+ async #configureBrowserSpeechOperation(normalized,controller,generation,operationId){
2545
+ this.#assertBrowserSpeechOperation(controller,generation);
2546
+ const previousRecord=this.#browserSpeechConfigurationRecord;
2547
+ if(previousRecord&&!this.#browserSpeechRecordIsActive(previousRecord)){
2548
+ throw this.#browserSpeechProviderRouteOwnershipError(
2549
+ 'The prior browser speech provider or route ownership changed before replacement.'
2550
+ );
2551
+ }
2552
+ const configuration=this.#browserSpeechConfiguration(
2553
+ normalized,
2554
+ previousRecord
2555
+ );
2556
+ this.#publishBrowserSpeechEvent(
2557
+ AI_BROWSER_SPEECH_EVENT_TYPES.configurationStarted,
2558
+ normalized,
2559
+ operationId,
2560
+ this.#browserSpeechConfigurationRecord
2561
+ ?AI_BROWSER_SPEECH_REASONS.configurationReplaced
2562
+ :AI_BROWSER_SPEECH_REASONS.configurationAdded
2563
+ );
2564
+ this.#assertBrowserSpeechOperation(controller,generation);
2565
+
2566
+ let module;
2567
+ try{
2568
+ module=await this.#browserSpeechModule();
2569
+ }catch(error){
2570
+ this.#assertBrowserSpeechOperation(controller,generation);
2571
+ throw aiBrowserSpeechError(
2572
+ AI_BROWSER_SPEECH_ERROR_CODES.moduleImportRejected,
2573
+ AI_BROWSER_SPEECH_REASONS.moduleImportRejected,
2574
+ 'The browser speech SDK module could not be imported.',
2575
+ error
2576
+ );
2577
+ }
2578
+ this.#assertBrowserSpeechOperation(controller,generation);
2579
+ this.#assertBrowserSpeechGraphs(normalized,module);
2580
+
2581
+ let store;
2582
+ try{
2583
+ store=module.createDbopfsSpeechArtifactStore({
2584
+ dbopfs:normalized.dbopfs,
2585
+ ...(normalized.tableName?{tableName:normalized.tableName}:{})
2586
+ });
2587
+ }catch(error){
2588
+ throw aiBrowserSpeechError(
2589
+ AI_BROWSER_SPEECH_ERROR_CODES.artifactStoreConstructionRejected,
2590
+ AI_BROWSER_SPEECH_REASONS.artifactStoreConstructionRejected,
2591
+ 'The browser speech artifact store could not be constructed.',
2592
+ error
2593
+ );
2594
+ }
2595
+
2596
+ const candidateProviders={
2597
+ stt:previousRecord?.providers.stt??null,
2598
+ tts:previousRecord?.providers.tts??null
2599
+ };
2600
+ for(const role of normalized.roles)candidateProviders[role]=null;
2601
+ let providers=null;
2602
+ let prepared=null;
2603
+ try{
2604
+ for(const role of normalized.roles){
2605
+ const factory=role==='stt'
2606
+ ?module.createBrowserWhisperProvider
2607
+ :module.createBrowserKokoroProvider;
2608
+ const configured=normalized[role];
2609
+ candidateProviders[role]=factory({
2610
+ id:configured.providerId,
2611
+ ...(configured.graph
2612
+ ?{
2613
+ graph:configured.graph,
2614
+ security:configured.security
2615
+ }
2616
+ :{
2617
+ model:configured.model,
2618
+ runtime:configured.runtime,
2619
+ ...(Object.hasOwn(configured,'security')
2620
+ ?{security:configured.security}
2621
+ :{})
2622
+ }),
2623
+ store,
2624
+ offline:configured.offline
2625
+ });
2626
+ }
2627
+ providers=Object.freeze({...candidateProviders});
2628
+ prepared=this.#browserSpeechRoutes(
2629
+ normalized,
2630
+ providers,
2631
+ previousRecord
2632
+ );
2633
+ }catch(error){
2634
+ const failure=error?.code===AI_BROWSER_SPEECH_ERROR_CODES.providerConstructionRejected
2635
+ ?error
2636
+ :aiBrowserSpeechError(
2637
+ AI_BROWSER_SPEECH_ERROR_CODES.providerConstructionRejected,
2638
+ AI_BROWSER_SPEECH_REASONS.providerConstructionRejected,
2639
+ 'The browser speech providers could not be constructed.',
2640
+ error
2641
+ );
2642
+ if(normalized.roles.some(role=>candidateProviders[role])){
2643
+ const currentRoutes=this.#currentSpeechRoutes();
2644
+ const partialRoutes={};
2645
+ for(const role of ['stt','tts']){
2646
+ const provider=candidateProviders[role];
2647
+ const changed=normalized.roles.includes(role);
2648
+ const selection=changed&&provider
2649
+ ?Object.freeze({
2650
+ providerId:provider.id,
2651
+ modelId:normalized[role].graph?.model.id
2652
+ ??normalized[role].model.id,
2653
+ localOnly:true
2654
+ })
2655
+ :null;
2656
+ partialRoutes[role]=changed
2657
+ ?Object.freeze({default:selection,localOnly:selection})
2658
+ :previousRecord?.managedRoles.includes(role)
2659
+ ?previousRecord.routes[role]
2660
+ :currentRoutes[role];
2661
+ }
2662
+ const partial=this.#browserSpeechCandidate(
2663
+ normalized,
2664
+ configuration,
2665
+ null,
2666
+ Object.freeze({...candidateProviders}),
2667
+ Object.freeze(partialRoutes),
2668
+ previousRecord
2669
+ );
2670
+ return this.#throwAfterBrowserSpeechCandidateCleanup(
2671
+ partial,
2672
+ failure
2673
+ );
2674
+ }
2675
+ throw failure;
2676
+ }
2677
+ const descriptor=this.#browserSpeechDescriptor(
2678
+ normalized,
2679
+ prepared.catalogs,
2680
+ previousRecord
2681
+ );
2682
+ const candidate=this.#browserSpeechCandidate(
2683
+ normalized,
2684
+ configuration,
2685
+ descriptor,
2686
+ providers,
2687
+ prepared.routes,
2688
+ previousRecord
2689
+ );
2690
+ let replacementBoundary;
2691
+ try{
2692
+ replacementBoundary=this.#browserSpeechReplacementBoundary(
2693
+ previousRecord,
2694
+ normalized.roles
2695
+ );
2696
+ }catch(error){
2697
+ return this.#throwAfterBrowserSpeechCandidateCleanup(
2698
+ candidate,
2699
+ error
2700
+ );
2701
+ }
2702
+ try{
2703
+ await this.#unloadSpeechProviderRolesForTransition(
2704
+ controller.signal,
2705
+ replacementBoundary.expectedProviders,
2706
+ normalized.roles
2707
+ );
2708
+ this.#assertBrowserSpeechOperation(controller,generation);
2709
+ }catch(error){
2710
+ return this.#throwAfterBrowserSpeechCandidateCleanup(candidate,error);
2711
+ }
2712
+
2713
+ let replacement;
2714
+ try{
2715
+ if(normalized.roles.length===2){
2716
+ replacement=this.#providerRuntime.replaceSpeechProviders({
2717
+ providers,
2718
+ routes:prepared.routes,
2719
+ expectedProviders:replacementBoundary.expectedProviders
2720
+ });
2721
+ }else{
2722
+ const role=normalized.roles[0];
2723
+ const roleReplacement=this.#providerRuntime.replaceSpeechProvider(
2724
+ role,
2725
+ {
2726
+ provider:providers[role],
2727
+ routes:prepared.routes[role],
2728
+ expectedProvider:replacementBoundary.expectedProviders[role]
2729
+ }
2730
+ );
2731
+ replacement=Object.freeze({
2732
+ routes:prepared.routes,
2733
+ unregisters:Object.freeze({
2734
+ stt:role==='stt'
2735
+ ?roleReplacement.unregister
2736
+ :previousRecord?.unregisters.stt??null,
2737
+ tts:role==='tts'
2738
+ ?roleReplacement.unregister
2739
+ :previousRecord?.unregisters.tts??null
2740
+ })
2741
+ });
2742
+ }
2743
+ }catch(error){
2744
+ const commitFailure=aiBrowserSpeechError(
2745
+ AI_BROWSER_SPEECH_ERROR_CODES.routeCommitRejected,
2746
+ AI_BROWSER_SPEECH_REASONS.routeCommitRejected,
2747
+ 'The browser speech provider and route replacement could not be committed.',
2748
+ error
2749
+ );
2750
+ return this.#throwAfterBrowserSpeechCandidateCleanup(
2751
+ candidate,
2752
+ commitFailure
2753
+ );
2754
+ }
2755
+
2756
+ const record=this.#browserSpeechRecordFromReplacement(
2757
+ candidate,
2758
+ replacement
2759
+ );
2760
+ for(const legacyRecord of replacementBoundary.legacyRecords){
2761
+ this.#browserSpeechRetiredLegacyRecords.add(legacyRecord);
2762
+ }
2763
+ if(previousRecord){
2764
+ this.#retireBrowserSpeechRegistration(
2765
+ previousRecord,
2766
+ normalized.roles
2767
+ );
2768
+ }
2769
+ this.#browserSpeechConfigurationRecord=record;
2770
+ try{
2771
+ this.#applySpeechPreferenceTuple(
2772
+ this.#tupleFromSpeechProviderRoutes(replacement.routes)
2773
+ );
2774
+ }catch(error){
2775
+ const finalizationFailure=aiBrowserSpeechError(
2776
+ AI_BROWSER_SPEECH_ERROR_CODES.routeViewUpdateRejected,
2777
+ AI_BROWSER_SPEECH_REASONS.routeViewUpdateRejected,
2778
+ 'The committed browser speech replacement could not update the AI speech route view.',
2779
+ error
2780
+ );
2781
+ if(!previousRecord
2782
+ ||normalized.roles.some(
2783
+ role=>!previousRecord.managedRoles.includes(role)
2784
+ )){
2785
+ finalizationFailure.committed=true;
2786
+ throw finalizationFailure;
2787
+ }
2788
+
2789
+ let rollback;
2790
+ try{
2791
+ if(normalized.roles.length===2){
2792
+ rollback=this.#providerRuntime.replaceSpeechProviders({
2793
+ providers:previousRecord.providers,
2794
+ routes:previousRecord.routes,
2795
+ expectedProviders:record.providers
2796
+ });
2797
+ }else{
2798
+ const role=normalized.roles[0];
2799
+ const roleRollback=this.#providerRuntime.replaceSpeechProvider(
2800
+ role,
2801
+ {
2802
+ provider:previousRecord.providers[role],
2803
+ routes:previousRecord.routes[role],
2804
+ expectedProvider:record.providers[role]
2805
+ }
2806
+ );
2807
+ rollback=Object.freeze({
2808
+ routes:previousRecord.routes,
2809
+ unregisters:Object.freeze({
2810
+ stt:role==='stt'
2811
+ ?roleRollback.unregister
2812
+ :previousRecord.unregisters.stt,
2813
+ tts:role==='tts'
2814
+ ?roleRollback.unregister
2815
+ :previousRecord.unregisters.tts
2816
+ })
2817
+ });
2818
+ }
2819
+ }catch(rollbackError){
2820
+ const candidateCommitted=this.#browserSpeechRecordIsActive(record);
2821
+ if(!candidateCommitted){
2822
+ this.#browserSpeechConfigurationRecord=null;
2823
+ this.#browserSpeechRetiredRecords.add(record);
2824
+ }
2825
+ throw aiBrowserSpeechError(
2826
+ AI_BROWSER_SPEECH_ERROR_CODES.routeRollbackRejected,
2827
+ AI_BROWSER_SPEECH_REASONS.routeRollbackRejected,
2828
+ 'The prior browser speech providers and routes could not be restored after AI speech route view rejection.',
2829
+ new AggregateError(
2830
+ [finalizationFailure,rollbackError],
2831
+ 'Browser speech replacement finalization and rollback were both rejected.'
2832
+ ),
2833
+ {committed:candidateCommitted}
2834
+ );
2835
+ }
2836
+
2837
+ this.#retireBrowserSpeechRegistration(record,normalized.roles);
2838
+ const restoredRecord=this.#browserSpeechRecordFromReplacement(
2839
+ previousRecord,
2840
+ rollback
2841
+ );
2842
+ this.#browserSpeechRetiredRecords.delete(previousRecord);
2843
+ this.#browserSpeechConfigurationRecord=restoredRecord;
2844
+ this.#applySpeechPreferenceTuple(
2845
+ this.#tupleFromSpeechProviderRoutes(rollback.routes)
2846
+ );
2847
+ return this.#throwAfterBrowserSpeechCandidateCleanup(
2848
+ record,
2849
+ finalizationFailure
2850
+ );
2851
+ }
2852
+ this.#assertBrowserSpeechOperation(
2853
+ controller,
2854
+ generation,
2855
+ {committed:true}
2856
+ );
2857
+ await this.#cleanupRetiredLegacySpeechProviders({
2858
+ signal:controller.signal,
2859
+ committed:true
2860
+ });
2861
+ this.#assertBrowserSpeechOperation(
2862
+ controller,
2863
+ generation,
2864
+ {committed:true}
2865
+ );
2866
+ this.#publishBrowserSpeechEvent(
2867
+ AI_BROWSER_SPEECH_EVENT_TYPES.configured,
2868
+ normalized,
2869
+ operationId,
2870
+ previousRecord
2871
+ ?AI_BROWSER_SPEECH_REASONS.configurationReplaced
2872
+ :AI_BROWSER_SPEECH_REASONS.configurationAdded,
2873
+ {descriptor}
2874
+ );
2875
+
2876
+ for(const retiredRecord of [...this.#browserSpeechRetiredRecords]){
2877
+ try{
2878
+ await this.#cleanupBrowserSpeechRecord(
2879
+ retiredRecord,
2880
+ {signal:controller.signal,committed:true}
2881
+ );
2882
+ }catch(error){
2883
+ if(error?.committed===true)throw error;
2884
+ if(generation!==this.#browserSpeechGeneration
2885
+ ||controller.signal.aborted){
2886
+ throw this.#browserSpeechAbortError(
2887
+ controller,
2888
+ generation,
2889
+ {committed:true}
2890
+ );
2891
+ }
2892
+ throw error;
2893
+ }
2894
+ }
2895
+ this.#assertBrowserSpeechOperation(
2896
+ controller,
2897
+ generation,
2898
+ {committed:true}
1393
2899
  );
1394
- return configured;
2900
+ return descriptor;
1395
2901
  }
1396
2902
 
1397
- async transitionAI(
1398
- llmService,
1399
- sttService,
1400
- ttsService,
1401
- model,
1402
- modelTTS,
1403
- modelSTT
1404
- ){
1405
- const tuple=this.#nextPreferenceTuple([
1406
- llmService,
1407
- sttService,
1408
- ttsService,
1409
- model,
1410
- modelTTS,
1411
- modelSTT
1412
- ]);
1413
- this.#assertValidProviderTuple(tuple);
1414
- this.stopAudio();
1415
- await this.#unloadProviderRolesForTransition();
1416
- this.#ensureLegacyLLMProvider(tuple[0]);
1417
- this.#ensureLegacySpeechProvider('stt',tuple[1]);
1418
- this.#ensureLegacySpeechProvider('tts',tuple[2]);
1419
- this.#providerRuntime.configure(this.#routesFromPreferenceTuple(tuple));
1420
- this.#applyPreferenceTuple(tuple);
1421
- this.#releaseInactiveLegacyLLMProviders(tuple[0]);
1422
- this.#releaseInactiveLegacySpeechProviders({
1423
- stt:tuple[1],
1424
- tts:tuple[2]
1425
- });
1426
- await this.#reconcileLegacyLLMReadiness();
1427
- await this.#reconcileLegacySpeechReadiness();
1428
- return this.#providerRuntime.status();
2903
+ configureBrowserSpeech(configuration,options={}){
2904
+ const normalized=normalizeBrowserSpeechConfiguration(configuration);
2905
+ const operation=normalizeBrowserSpeechOperationOptions(
2906
+ options,
2907
+ 'AI.configureBrowserSpeech'
2908
+ );
2909
+ if(operation.signal?.aborted){
2910
+ return Promise.reject(aiBrowserSpeechError(
2911
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationCancelled,
2912
+ AI_BROWSER_SPEECH_REASONS.configurationCancelled,
2913
+ 'The browser speech configuration was cancelled before admission.',
2914
+ operation.signal.reason,
2915
+ {name:'AbortError'}
2916
+ ));
2917
+ }
2918
+ if(this.#browserSpeechConfigurationRecord
2919
+ &&normalized.roles.every(role=>
2920
+ this.#browserSpeechConfigurationRecord.configurationByRole[role]
2921
+ ===configuration
2922
+ )
2923
+ &&this.#browserSpeechRecordIsActive(
2924
+ this.#browserSpeechConfigurationRecord
2925
+ )
2926
+ &&this.#browserSpeechRetiredRecords.size===0
2927
+ &&!this.#browserSpeechController){
2928
+ return Promise.resolve(this.#browserSpeechConfigurationRecord.descriptor);
2929
+ }
2930
+ const operationId=this.#browserSpeechOperationId('configure-browser-speech');
2931
+ const generation=++this.#browserSpeechGeneration;
2932
+ const superseded=aiBrowserSpeechError(
2933
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationSuperseded,
2934
+ AI_BROWSER_SPEECH_REASONS.configurationReplaced,
2935
+ 'The browser speech configuration was replaced by a newer configuration.',
2936
+ undefined,
2937
+ {name:'AbortError'}
2938
+ );
2939
+ this.#browserSpeechController?.abort(superseded);
2940
+ if(normalized.roles.includes('tts'))this.#invalidateSpeechControl();
2941
+ const controller=new AbortController();
2942
+ this.#browserSpeechController=controller;
2943
+ this.#browserSpeechControllerRoles=normalized.roles;
2944
+ const forwardAbort=function cancelBrowserSpeechConfiguration(){
2945
+ if(!controller.signal.aborted)controller.abort(operation.signal.reason);
2946
+ };
2947
+ if(operation.signal?.aborted)forwardAbort();
2948
+ else operation.signal?.addEventListener('abort',forwardAbort,{once:true});
2949
+ const runtime=this;
2950
+ const scheduled=this.#browserSpeechTransition.then(
2951
+ async function runBrowserSpeechConfiguration(){
2952
+ try{
2953
+ return await runtime.#configureBrowserSpeechOperation(
2954
+ normalized,
2955
+ controller,
2956
+ generation,
2957
+ operationId
2958
+ );
2959
+ }catch(error){
2960
+ const failure=error?.committed===true
2961
+ ?error
2962
+ :(generation!==runtime.#browserSpeechGeneration
2963
+ ||controller.signal.aborted)
2964
+ ?runtime.#browserSpeechAbortError(controller,generation)
2965
+ :error;
2966
+ runtime.#publishBrowserSpeechEvent(
2967
+ failure.name==='AbortError'
2968
+ ?AI_BROWSER_SPEECH_EVENT_TYPES.configurationCancelled
2969
+ :AI_BROWSER_SPEECH_EVENT_TYPES.configurationError,
2970
+ normalized,
2971
+ operationId,
2972
+ failure.reason
2973
+ ||AI_BROWSER_SPEECH_REASONS.routeCommitRejected,
2974
+ {error:failure}
2975
+ );
2976
+ throw failure;
2977
+ }finally{
2978
+ operation.signal?.removeEventListener('abort',forwardAbort);
2979
+ if(runtime.#browserSpeechController===controller){
2980
+ runtime.#browserSpeechController=null;
2981
+ runtime.#browserSpeechControllerRoles=Object.freeze([]);
2982
+ }
2983
+ }
2984
+ }
2985
+ );
2986
+ this.#browserSpeechTransition=scheduled.then(
2987
+ function completeBrowserSpeechConfigurationLane(){},
2988
+ function retainBrowserSpeechConfigurationFailure(){}
2989
+ );
2990
+ return scheduled;
1429
2991
  }
1430
2992
 
1431
- async transitionProviders(selections){
1432
- const prepared=this.#providerRuntime.validateConfiguration(selections);
1433
- this.#ensureLegacyLLMProvider(
1434
- prepared.llm.default?.providerId
2993
+ disposeBrowserSpeech(options={}){
2994
+ const operation=normalizeBrowserSpeechOperationOptions(
2995
+ options,
2996
+ 'AI.disposeBrowserSpeech'
1435
2997
  );
1436
- this.#ensureLegacySpeechProvider(
1437
- 'stt',
1438
- prepared.stt.default?.providerId
2998
+ if(operation.signal?.aborted){
2999
+ return Promise.reject(aiBrowserSpeechError(
3000
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationCancelled,
3001
+ AI_BROWSER_SPEECH_REASONS.configurationCancelled,
3002
+ 'Browser speech disposal was cancelled before admission.',
3003
+ operation.signal.reason,
3004
+ {name:'AbortError'}
3005
+ ));
3006
+ }
3007
+ const operationId=this.#browserSpeechOperationId('dispose-browser-speech');
3008
+ const generation=++this.#browserSpeechGeneration;
3009
+ const superseded=aiBrowserSpeechError(
3010
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationSuperseded,
3011
+ AI_BROWSER_SPEECH_REASONS.configurationDisposed,
3012
+ 'The browser speech configuration was superseded by disposal.',
3013
+ undefined,
3014
+ {name:'AbortError'}
1439
3015
  );
1440
- this.#ensureLegacySpeechProvider(
1441
- 'tts',
1442
- prepared.tts.default?.providerId
3016
+ const invalidatesSpeech=Boolean(
3017
+ this.#browserSpeechControllerRoles.includes('tts')
3018
+ ||this.#browserSpeechConfigurationRecord?.managedRoles.includes('tts')
3019
+ ||[...this.#browserSpeechRetiredRecords].some(record=>
3020
+ record.managedRoles.includes('tts')
3021
+ ||record.candidateRoles.includes('tts')
3022
+ )
3023
+ ||[...this.#browserSpeechRetiredLegacyRecords].some(
3024
+ record=>record.role==='tts'
3025
+ )
1443
3026
  );
1444
- this.#assertRegisteredLegacyRoutes(prepared);
1445
- this.stopAudio();
1446
- await this.#unloadProviderRolesForTransition();
1447
- const configured=this.#providerRuntime.configure(prepared);
1448
- this.#applyPreferenceTuple(this.#tupleFromProviderRoutes(configured));
1449
- this.#releaseInactiveLegacyLLMProviders(
1450
- configured.llm.default?.providerId
3027
+ this.#browserSpeechController?.abort(superseded);
3028
+ if(invalidatesSpeech)this.#invalidateSpeechControl();
3029
+ const controller=new AbortController();
3030
+ this.#browserSpeechController=controller;
3031
+ this.#browserSpeechControllerRoles=Object.freeze([]);
3032
+ const forwardAbort=function cancelBrowserSpeechDisposal(){
3033
+ if(!controller.signal.aborted)controller.abort(operation.signal.reason);
3034
+ };
3035
+ if(operation.signal?.aborted)forwardAbort();
3036
+ else operation.signal?.addEventListener('abort',forwardAbort,{once:true});
3037
+ const runtime=this;
3038
+ const scheduled=this.#browserSpeechTransition.then(
3039
+ async function runBrowserSpeechDisposal(){
3040
+ let normalized=null;
3041
+ let descriptor=null;
3042
+ let changed=false;
3043
+ let committed=false;
3044
+ try{
3045
+ runtime.#assertBrowserSpeechOperation(controller,generation);
3046
+ const activeRecord=runtime.#browserSpeechConfigurationRecord;
3047
+ const records=new Set(runtime.#browserSpeechRetiredRecords);
3048
+ if(activeRecord)records.add(activeRecord);
3049
+ const eventRecord=activeRecord||records.values().next().value||null;
3050
+ if(!eventRecord
3051
+ &&runtime.#browserSpeechRetiredLegacyRecords.size===0){
3052
+ return false;
3053
+ }
3054
+ if(eventRecord){
3055
+ normalized=normalizeBrowserSpeechConfiguration(
3056
+ eventRecord.configuration
3057
+ );
3058
+ descriptor=eventRecord.descriptor;
3059
+ }
3060
+ if(activeRecord){
3061
+ if(!runtime.#browserSpeechRecordIsActive(activeRecord)){
3062
+ throw runtime.#browserSpeechProviderRouteOwnershipError(
3063
+ 'The configured browser speech provider or route ownership changed before disposal.'
3064
+ );
3065
+ }
3066
+ await runtime.#unloadSpeechProviderRolesForTransition(
3067
+ controller.signal,
3068
+ activeRecord.providers,
3069
+ activeRecord.managedRoles
3070
+ );
3071
+ runtime.#assertBrowserSpeechOperation(controller,generation);
3072
+ let removed;
3073
+ if(activeRecord.managedRoles.length===2){
3074
+ removed=runtime.#providerRuntime.replaceSpeechProviders({
3075
+ providers:{stt:null,tts:null},
3076
+ routes:runtime.#emptySpeechRoutes(),
3077
+ expectedProviders:activeRecord.providers
3078
+ });
3079
+ }else{
3080
+ const role=activeRecord.managedRoles[0];
3081
+ const currentRoutes=runtime.#currentSpeechRoutes();
3082
+ const emptyRoleRoutes=Object.freeze({
3083
+ default:null,
3084
+ localOnly:null
3085
+ });
3086
+ runtime.#providerRuntime.replaceSpeechProvider(
3087
+ role,
3088
+ {
3089
+ provider:null,
3090
+ routes:emptyRoleRoutes,
3091
+ expectedProvider:activeRecord.providers[role]
3092
+ }
3093
+ );
3094
+ removed=Object.freeze({
3095
+ routes:Object.freeze({
3096
+ stt:role==='stt'
3097
+ ?emptyRoleRoutes
3098
+ :currentRoutes.stt,
3099
+ tts:role==='tts'
3100
+ ?emptyRoleRoutes
3101
+ :currentRoutes.tts
3102
+ })
3103
+ });
3104
+ }
3105
+ runtime.#retireBrowserSpeechRegistration(
3106
+ activeRecord,
3107
+ activeRecord.managedRoles
3108
+ );
3109
+ runtime.#browserSpeechConfigurationRecord=null;
3110
+ changed=true;
3111
+ committed=true;
3112
+ try{
3113
+ runtime.#applySpeechPreferenceTuple(
3114
+ runtime.#tupleFromSpeechProviderRoutes(removed.routes)
3115
+ );
3116
+ }catch(error){
3117
+ throw aiBrowserSpeechError(
3118
+ AI_BROWSER_SPEECH_ERROR_CODES.routeViewUpdateRejected,
3119
+ AI_BROWSER_SPEECH_REASONS.routeViewUpdateRejected,
3120
+ 'The committed browser speech removal could not update the AI speech route view.',
3121
+ error,
3122
+ {committed:true}
3123
+ );
3124
+ }
3125
+ }
3126
+
3127
+ if(records.size)committed=true;
3128
+ for(const record of records){
3129
+ await runtime.#cleanupBrowserSpeechRecord(
3130
+ record,
3131
+ {signal:controller.signal,committed:true}
3132
+ );
3133
+ changed=true;
3134
+ }
3135
+ if(runtime.#browserSpeechRetiredLegacyRecords.size){
3136
+ await runtime.#cleanupRetiredLegacySpeechProviders({
3137
+ signal:controller.signal,
3138
+ committed:true
3139
+ });
3140
+ changed=true;
3141
+ }
3142
+ runtime.#assertBrowserSpeechOperation(
3143
+ controller,
3144
+ generation,
3145
+ {committed}
3146
+ );
3147
+ if(normalized){
3148
+ runtime.#publishBrowserSpeechEvent(
3149
+ AI_BROWSER_SPEECH_EVENT_TYPES.disposed,
3150
+ normalized,
3151
+ operationId,
3152
+ AI_BROWSER_SPEECH_REASONS.configurationDisposed,
3153
+ {descriptor}
3154
+ );
3155
+ }
3156
+ return changed;
3157
+ }catch(error){
3158
+ const failure=error?.committed===true
3159
+ ?error
3160
+ :(generation!==runtime.#browserSpeechGeneration
3161
+ ||controller.signal.aborted)
3162
+ ?runtime.#browserSpeechAbortError(
3163
+ controller,
3164
+ generation,
3165
+ {committed}
3166
+ )
3167
+ :error;
3168
+ if(normalized){
3169
+ runtime.#publishBrowserSpeechEvent(
3170
+ failure.name==='AbortError'
3171
+ ?AI_BROWSER_SPEECH_EVENT_TYPES.configurationCancelled
3172
+ :AI_BROWSER_SPEECH_EVENT_TYPES.configurationError,
3173
+ normalized,
3174
+ operationId,
3175
+ failure.reason
3176
+ ||AI_BROWSER_SPEECH_REASONS.providerDisposalRejected,
3177
+ {error:failure}
3178
+ );
3179
+ }
3180
+ throw failure;
3181
+ }finally{
3182
+ operation.signal?.removeEventListener('abort',forwardAbort);
3183
+ if(runtime.#browserSpeechController===controller){
3184
+ runtime.#browserSpeechController=null;
3185
+ runtime.#browserSpeechControllerRoles=Object.freeze([]);
3186
+ }
3187
+ }
3188
+ }
1451
3189
  );
1452
- this.#releaseInactiveLegacySpeechProviders({
1453
- stt:configured.stt.default?.providerId,
1454
- tts:configured.tts.default?.providerId
1455
- });
1456
- await this.#reconcileLegacyLLMReadiness();
1457
- await this.#reconcileLegacySpeechReadiness();
1458
- return configured;
3190
+ this.#browserSpeechTransition=scheduled.then(
3191
+ function completeBrowserSpeechDisposalLane(){},
3192
+ function retainBrowserSpeechDisposalFailure(){}
3193
+ );
3194
+ return scheduled;
1459
3195
  }
1460
3196
 
1461
3197
  async startProviders(options){
1462
3198
  const normalized=normalizeAIStartupOptions(options);
1463
- this.muted=normalized.startMuted;
3199
+ const generation=++this.#speechControlGeneration;
3200
+ this.muted=true;
1464
3201
  if(normalized.startMuted){
1465
3202
  this.stopAudio();
1466
3203
  }
1467
- return this.#providerRuntime.start(normalized);
3204
+ const handle=await this.#providerRuntime.start(normalized);
3205
+ if(!normalized.startMuted){
3206
+ const runtime=this;
3207
+ handle.settled.then(
3208
+ function admitReadyStartupSpeech(){
3209
+ if(generation!==runtime.#speechControlGeneration)return;
3210
+ const status=runtime.#providerRuntime.status('tts');
3211
+ runtime.muted=!(status.state==='ready'&&status.loaded===true);
3212
+ },
3213
+ function retainFailClosedStartupSpeech(){
3214
+ if(generation===runtime.#speechControlGeneration){
3215
+ runtime.muted=true;
3216
+ }
3217
+ }
3218
+ );
3219
+ }
3220
+ return handle;
1468
3221
  }
1469
3222
 
1470
3223
  async setSpeechMuted(muted){
1471
3224
  if(typeof muted!=='boolean'){
1472
3225
  throw new TypeError('AI speech muted state must be a boolean.');
1473
3226
  }
1474
- this.muted=muted;
3227
+ const generation=++this.#speechControlGeneration;
3228
+ this.muted=true;
1475
3229
  if(muted){
1476
3230
  this.stopAudio();
1477
3231
  }
1478
3232
  if(!this.#usesProviderRuntime('tts',this.ttsService)){
3233
+ if(generation===this.#speechControlGeneration)this.muted=muted;
1479
3234
  return true;
1480
3235
  }
1481
3236
  await this.#providerRuntime.setSpeechMuted(muted);
3237
+ if(generation===this.#speechControlGeneration){
3238
+ const status=this.#providerRuntime.status('tts');
3239
+ this.muted=muted
3240
+ ||status.state!=='ready'
3241
+ ||status.loaded!==true;
3242
+ }
1482
3243
  return true;
1483
3244
  }
1484
3245
 
@@ -1624,7 +3385,11 @@ class AI {
1624
3385
  throw normalizeAIRequestAbort(signal.reason);
1625
3386
  }
1626
3387
  if(!response||typeof response.audioBase64!=='string'){
1627
- throw new TypeError('Arcane returned an invalid local speech response.');
3388
+ const error=new TypeError(
3389
+ 'The Arcane speech bridge returned invalid TTS audio.'
3390
+ );
3391
+ error.code='ARCANE_AI_TTS_NATIVE_AUDIO_INVALID';
3392
+ throw error;
1628
3393
  }
1629
3394
  return new Blob(
1630
3395
  [this.#base64ToBytes(response.audioBase64)],
@@ -1637,28 +3402,41 @@ class AI {
1637
3402
  }
1638
3403
 
1639
3404
  await this.#assertAndroidSpeechBridge(this.ttsService);
1640
- const personality=await window.user?.personality
1641
- ||'A behavioral health technician with a slight veteran feel on occasion.';
1642
- const religion=await window.user?.religion||'caring';
1643
- const response=await fetch(
1644
- this.urlTTS,
1645
- {
1646
- method:'POST',
1647
- credentials,
1648
- headers:this.#ttsHeaders[this.ttsService],
1649
- body:JSON.stringify({
1650
- model,
1651
- voice,
1652
- input,
1653
- speed,
1654
- instructions:`${personality} and sounding a bit ${religion}`,
1655
- response_format:responseFormat
1656
- }),
1657
- signal
3405
+ let response;
3406
+ try{
3407
+ response=await fetch(
3408
+ this.urlTTS,
3409
+ {
3410
+ method:'POST',
3411
+ credentials,
3412
+ headers:this.#ttsHeaders[this.ttsService],
3413
+ body:JSON.stringify({
3414
+ model,
3415
+ voice,
3416
+ input,
3417
+ speed,
3418
+ response_format:responseFormat
3419
+ }),
3420
+ signal
3421
+ }
3422
+ );
3423
+ }catch(error){
3424
+ if(isAIRequestAbort(error,signal)){
3425
+ throw normalizeAIRequestAbort(error);
1658
3426
  }
1659
- );
3427
+ throw legacyAIProviderError(
3428
+ 'The configured TTS HTTP request failed.',
3429
+ 'ARCANE_AI_TTS_HTTP_REQUEST_FAILED',
3430
+ error
3431
+ );
3432
+ }
1660
3433
  if(!response.ok){
1661
- throw new Error(`Speech synthesis failed with status ${response.status}.`);
3434
+ const error=legacyAIProviderError(
3435
+ `The configured TTS HTTP response was rejected with status ${response.status}.`,
3436
+ 'ARCANE_AI_TTS_HTTP_RESPONSE_REJECTED'
3437
+ );
3438
+ error.status=response.status;
3439
+ throw error;
1662
3440
  }
1663
3441
  const contentType=response.headers.get('content-type')||this.audioType;
1664
3442
  return new Blob([await response.arrayBuffer()],{type:contentType});
@@ -3085,34 +4863,17 @@ class AI {
3085
4863
  }
3086
4864
 
3087
4865
  async #requestSpeechAudio(job){
3088
- if(this.#usesProviderRuntime('tts',this.ttsService)){
3089
- job.abortController=new AbortController();
3090
- const responseFormat=this.#providerSpeechResponseFormat();
3091
- const voice=this.#providerSpeechVoice();
3092
- const response=await this.#providerRuntime.request(
3093
- 'tts',
3094
- {
3095
- operation:'synthesize',
3096
- payload:{
3097
- model:this.#providerRuntime.selection('tts')?.modelId,
3098
- input:job.text,
3099
- responseFormat,
3100
- ...(voice?{voice}:{}),
3101
- speed:this.voiceSpeed
3102
- },
3103
- localOnly:false,
3104
- signal:job.abortController.signal
3105
- }
3106
- );
3107
- return this.#normalizeProviderSpeechAudio(response);
3108
- }
3109
-
3110
4866
  job.abortController=new AbortController();
3111
- const response=await this.#requestLegacySpeechSynthesis(
4867
+ const selection=this.#providerRuntime.selection('tts');
4868
+ const voice=selection?this.#providerSpeechVoice():null;
4869
+ const response=await this.fetchTTS(
3112
4870
  {
3113
- model:this.modelTTS,
4871
+ model:selection?.modelId||this.modelTTS,
3114
4872
  input:job.text,
3115
- responseFormat:this.audioFormat,
4873
+ ...(voice?{voice}:{}),
4874
+ responseFormat:selection
4875
+ ?this.#providerSpeechResponseFormat()
4876
+ :this.audioFormat,
3116
4877
  speed:this.voiceSpeed
3117
4878
  },
3118
4879
  job.abortController.signal
@@ -3234,7 +4995,19 @@ class AI {
3234
4995
  }
3235
4996
  }
3236
4997
 
3237
- throw new TypeError('Arcane returned an invalid provider speech response.');
4998
+ const error=new TypeError(
4999
+ 'The selected TTS provider returned invalid playable audio.'
5000
+ );
5001
+ error.code='ARCANE_AI_TTS_PROVIDER_AUDIO_INVALID';
5002
+ throw error;
5003
+ }
5004
+
5005
+ async #normalizeProviderSpeechBlob(response){
5006
+ if(response instanceof Blob){
5007
+ return response;
5008
+ }
5009
+ const normalized=await this.#normalizeProviderSpeechAudio(response);
5010
+ return new Blob(normalized.chunks,{type:normalized.type||this.audioType});
3238
5011
  }
3239
5012
 
3240
5013
  #getSpeechAudioContext(){
@@ -3252,33 +5025,190 @@ class AI {
3252
5025
  return this.audioContext;
3253
5026
  }
3254
5027
 
5028
+ async fetchTTS(payload={},signal=null){
5029
+ this.#assertServiceConfigured(this.ttsService,'tts');
5030
+ if(!payload
5031
+ ||typeof payload!=='object'
5032
+ ||Array.isArray(payload)
5033
+ ||![Object.prototype,null].includes(Object.getPrototypeOf(payload))){
5034
+ const error=new TypeError('AI.fetchTTS requires a speech request object.');
5035
+ error.code='ARCANE_AI_TTS_REQUEST_INVALID';
5036
+ throw error;
5037
+ }
5038
+ const descriptors=Object.getOwnPropertyDescriptors(payload);
5039
+ const acceptedKeys=new Set([
5040
+ 'model',
5041
+ 'voice',
5042
+ 'input',
5043
+ 'responseFormat',
5044
+ 'speed'
5045
+ ]);
5046
+ for(const key of Reflect.ownKeys(descriptors)){
5047
+ if(typeof key==='symbol'
5048
+ ||!acceptedKeys.has(key)
5049
+ ||!Object.hasOwn(descriptors[key],'value')){
5050
+ const error=new TypeError(
5051
+ 'AI.fetchTTS accepts only model, voice, input, responseFormat, and speed data properties.'
5052
+ );
5053
+ error.code='ARCANE_AI_TTS_REQUEST_INVALID';
5054
+ throw error;
5055
+ }
5056
+ }
5057
+ if(signal&&(
5058
+ typeof signal.aborted!=='boolean'
5059
+ ||typeof signal.addEventListener!=='function'
5060
+ ||typeof signal.removeEventListener!=='function'
5061
+ )){
5062
+ const error=new TypeError('AI.fetchTTS signal must be an AbortSignal.');
5063
+ error.code='ARCANE_AI_TTS_SIGNAL_INVALID';
5064
+ throw error;
5065
+ }
5066
+ if(signal?.aborted){
5067
+ throw normalizeAIRequestAbort(signal.reason);
5068
+ }
5069
+
5070
+ const input=descriptors.input?.value;
5071
+ if(typeof input!=='string'||!input.trim()){
5072
+ const error=new TypeError('AI.fetchTTS input must be nonempty text.');
5073
+ error.code='ARCANE_AI_TTS_INPUT_INVALID';
5074
+ throw error;
5075
+ }
5076
+ const selection=this.#providerRuntime.selection('tts');
5077
+ const requestedModel=descriptors.model?.value;
5078
+ if(requestedModel!==undefined
5079
+ &&(typeof requestedModel!=='string'
5080
+ ||requestedModel.trim()!==requestedModel
5081
+ ||!requestedModel)){
5082
+ const error=new TypeError(
5083
+ 'AI.fetchTTS model must be a nonempty trimmed string when provided.'
5084
+ );
5085
+ error.code='ARCANE_AI_TTS_MODEL_INVALID';
5086
+ throw error;
5087
+ }
5088
+ const model=requestedModel
5089
+ ||selection?.modelId
5090
+ ||this.modelTTS
5091
+ ||'';
5092
+ if(!model){
5093
+ const error=new TypeError('AI.fetchTTS model must be selected explicitly.');
5094
+ error.code='ARCANE_AI_TTS_MODEL_REQUIRED';
5095
+ throw error;
5096
+ }
5097
+ if(selection&&model!==selection.modelId){
5098
+ const error=new TypeError(
5099
+ 'AI.fetchTTS model must match the admitted TTS route.'
5100
+ );
5101
+ error.code='ARCANE_AI_TTS_MODEL_SELECTION_MISMATCH';
5102
+ throw error;
5103
+ }
5104
+ const requestedVoice=descriptors.voice?.value;
5105
+ if(requestedVoice!==undefined
5106
+ &&(typeof requestedVoice!=='string'
5107
+ ||requestedVoice.trim()!==requestedVoice
5108
+ ||!requestedVoice)){
5109
+ const error=new TypeError(
5110
+ 'AI.fetchTTS voice must be a nonempty trimmed string when provided.'
5111
+ );
5112
+ error.code='ARCANE_AI_TTS_VOICE_INVALID';
5113
+ throw error;
5114
+ }
5115
+ const voice=requestedVoice
5116
+ ?requestedVoice
5117
+ :selection
5118
+ ?this.#providerSpeechVoice()
5119
+ :this.#legacySpeechDefaultVoice('tts',this.ttsService);
5120
+ if(!voice){
5121
+ const error=new TypeError(
5122
+ 'AI.fetchTTS requires a caller- or model-catalog-admitted voice.'
5123
+ );
5124
+ error.code='ARCANE_AI_TTS_VOICE_REQUIRED';
5125
+ throw error;
5126
+ }
5127
+ const requestedResponseFormat=descriptors.responseFormat?.value;
5128
+ if(requestedResponseFormat!==undefined
5129
+ &&(typeof requestedResponseFormat!=='string'
5130
+ ||requestedResponseFormat.trim()!==requestedResponseFormat
5131
+ ||!requestedResponseFormat)){
5132
+ const error=new TypeError(
5133
+ 'AI.fetchTTS responseFormat must be a nonempty trimmed string when provided.'
5134
+ );
5135
+ error.code='ARCANE_AI_TTS_RESPONSE_FORMAT_INVALID';
5136
+ throw error;
5137
+ }
5138
+ const responseFormat=requestedResponseFormat
5139
+ ||(selection?this.#providerSpeechResponseFormat():this.audioFormat)
5140
+ ||'';
5141
+ if(!responseFormat){
5142
+ const error=new TypeError('AI.fetchTTS responseFormat must be nonempty.');
5143
+ error.code='ARCANE_AI_TTS_RESPONSE_FORMAT_INVALID';
5144
+ throw error;
5145
+ }
5146
+ const speed=descriptors.speed
5147
+ ?Number(descriptors.speed.value)
5148
+ :this.voiceSpeed;
5149
+ if(!Number.isFinite(speed)||speed<=0){
5150
+ const error=new RangeError('AI.fetchTTS speed must be a positive number.');
5151
+ error.code='ARCANE_AI_TTS_SPEED_INVALID';
5152
+ throw error;
5153
+ }
5154
+
5155
+ if(selection){
5156
+ const response=await this.#providerRuntime.request(
5157
+ 'tts',
5158
+ {
5159
+ operation:'synthesize',
5160
+ payload:{model,voice,input,responseFormat,speed},
5161
+ localOnly:false,
5162
+ signal
5163
+ }
5164
+ );
5165
+ if(signal?.aborted)throw normalizeAIRequestAbort(signal.reason);
5166
+ const audio=await this.#normalizeProviderSpeechBlob(response);
5167
+ if(signal?.aborted)throw normalizeAIRequestAbort(signal.reason);
5168
+ return audio;
5169
+ }
5170
+
5171
+ return this.#requestLegacySpeechSynthesis(
5172
+ {model,voice,input,responseFormat,speed},
5173
+ signal
5174
+ );
5175
+ }
5176
+
3255
5177
  async fetchSTT(
3256
5178
  audioFile,
3257
5179
  responseHandler=(text='')=>{},
3258
5180
  signal=null
3259
5181
  ){
3260
5182
  this.#assertServiceConfigured(this.sttService,'stt');
5183
+ if(typeof responseHandler!=='function'){
5184
+ const error=new TypeError('AI.fetchSTT responseHandler must be a function.');
5185
+ error.code='ARCANE_AI_STT_RESPONSE_HANDLER_INVALID';
5186
+ throw error;
5187
+ }
3261
5188
  if(signal&&(
3262
5189
  typeof signal.aborted!=='boolean'
3263
5190
  ||typeof signal.addEventListener!=='function'
5191
+ ||typeof signal.removeEventListener!=='function'
3264
5192
  )){
3265
- throw new TypeError('AI request signal must be an AbortSignal.');
5193
+ const error=new TypeError('AI.fetchSTT signal must be an AbortSignal.');
5194
+ error.code='ARCANE_AI_STT_SIGNAL_INVALID';
5195
+ throw error;
3266
5196
  }
3267
5197
  if(signal?.aborted){
3268
- throw normalizeAIRequestAbort();
5198
+ throw normalizeAIRequestAbort(signal.reason);
3269
5199
  }
3270
5200
 
3271
5201
  if(this.#usesProviderRuntime('stt',this.sttService)){
3272
- if(!audioFile||typeof audioFile.arrayBuffer!=='function'){
3273
- throw new TypeError('Speech transcription requires an audio Blob or File.');
3274
- }
3275
5202
  const response=await this.#providerRuntime.request(
3276
5203
  'stt',
3277
5204
  {
3278
5205
  operation:'transcribe',
3279
5206
  payload:{
3280
5207
  audio:audioFile,
3281
- mimeType:String(audioFile.type||'audio/webm'),
5208
+ mimeType:typeof Blob==='function'
5209
+ &&audioFile instanceof Blob
5210
+ ?String(audioFile.type||'audio/webm')
5211
+ :'audio/webm',
3282
5212
  model:this.#providerRuntime.selection('stt')?.modelId
3283
5213
  },
3284
5214
  localOnly:false,
@@ -3289,11 +5219,15 @@ class AI {
3289
5219
  ?response
3290
5220
  :response?.text;
3291
5221
  if(typeof text!=='string'){
3292
- throw new TypeError(
5222
+ const error=new TypeError(
3293
5223
  'Arcane returned an invalid provider speech transcription.'
3294
5224
  );
5225
+ error.code='ARCANE_AI_STT_PROVIDER_TRANSCRIPT_INVALID';
5226
+ throw error;
3295
5227
  }
5228
+ if(signal?.aborted)throw normalizeAIRequestAbort(signal.reason);
3296
5229
  await responseHandler(text);
5230
+ if(signal?.aborted)throw normalizeAIRequestAbort(signal.reason);
3297
5231
  return text;
3298
5232
  }
3299
5233
 
@@ -3305,7 +5239,9 @@ class AI {
3305
5239
  },
3306
5240
  signal
3307
5241
  );
5242
+ if(signal?.aborted)throw normalizeAIRequestAbort(signal.reason);
3308
5243
  await responseHandler(text);
5244
+ if(signal?.aborted)throw normalizeAIRequestAbort(signal.reason);
3309
5245
  return text;
3310
5246
  }
3311
5247
 
@@ -3659,13 +5595,70 @@ class AI {
3659
5595
  }
3660
5596
  }
3661
5597
 
3662
- window.addEventListener(
3663
- 'user-entity-loaded',
3664
- instantiateAI
3665
- );
5598
+ installAIUserReadyRegistration();
5599
+
5600
+ function installAIUserReadyRegistration(){
5601
+ if(window.user?.ready){
5602
+ instantiateAI();
5603
+ return null;
5604
+ }
3666
5605
 
3667
- if(window.user?.ready){
3668
- instantiateAI();
5606
+ const existingDescriptor=Object.getOwnPropertyDescriptor(
5607
+ globalThis,
5608
+ AI_USER_READY_REGISTRATION_KEY
5609
+ );
5610
+ if(existingDescriptor){
5611
+ const existing=Object.hasOwn(existingDescriptor,'value')
5612
+ ?existingDescriptor.value
5613
+ :null;
5614
+ if(existing?.protocol!==AI_USER_READY_REGISTRATION_PROTOCOL
5615
+ ||typeof existing.dispose!=='function'){
5616
+ throw aiInitializationError(
5617
+ AI_INITIALIZATION_ERROR_CODES.userReadyRegistrationCollision,
5618
+ AI_INITIALIZATION_REASONS.userReadyRegistrationCollision,
5619
+ 'The AI user-readiness registration collides with an incompatible realm owner.'
5620
+ );
5621
+ }
5622
+ return existing;
5623
+ }
5624
+
5625
+ let registration;
5626
+ let unsubscribe=null;
5627
+ function disposeAIUserReadyRegistration(){
5628
+ unsubscribe?.();
5629
+ unsubscribe=null;
5630
+ const descriptor=Object.getOwnPropertyDescriptor(
5631
+ globalThis,
5632
+ AI_USER_READY_REGISTRATION_KEY
5633
+ );
5634
+ if(descriptor?.value===registration){
5635
+ delete globalThis[AI_USER_READY_REGISTRATION_KEY];
5636
+ }
5637
+ }
5638
+ registration=Object.freeze({
5639
+ protocol:AI_USER_READY_REGISTRATION_PROTOCOL,
5640
+ dispose:disposeAIUserReadyRegistration
5641
+ });
5642
+ unsubscribe=arcaneEvents.subscribe(
5643
+ 'user-entity-loaded',
5644
+ function initializeAIFromCanonicalUser(event){
5645
+ if(event?.detail?.user&&event.detail.user!==window.user)return;
5646
+ if(!window.user?.ready)return;
5647
+ registration.dispose();
5648
+ instantiateAI(event);
5649
+ }
5650
+ );
5651
+ Object.defineProperty(
5652
+ globalThis,
5653
+ AI_USER_READY_REGISTRATION_KEY,
5654
+ {
5655
+ value:registration,
5656
+ configurable:true,
5657
+ enumerable:false,
5658
+ writable:false
5659
+ }
5660
+ );
5661
+ return registration;
3669
5662
  }
3670
5663
 
3671
5664
  function instantiateAI(event) {
@@ -3694,13 +5687,7 @@ function instantiateAI(event) {
3694
5687
 
3695
5688
  window.ai.ready=true;
3696
5689
 
3697
- const aiReady=new CustomEvent(
3698
- 'ai-ready', {
3699
- detail: { db: window.ai }
3700
- }
3701
- );
3702
-
3703
- window.dispatchEvent(aiReady);
5690
+ window.ai[AI_PUBLISH_READY]();
3704
5691
 
3705
5692
  }
3706
5693
  }