arcane-os 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +8 -8
  3. package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +29 -22
  4. package/browser-runtime/ai/ARCANE_AI_BROWSER_SPEECH_COMPONENTS.json +203 -0
  5. package/browser-runtime/ai/browser-kokoro-worker.mjs +11 -2
  6. package/browser-runtime/ai/browser-speech-artifacts.mjs +3230 -397
  7. package/browser-runtime/ai/browser-speech-providers.mjs +1141 -157
  8. package/browser-runtime/ai/browser-speech.mjs +2 -0
  9. package/browser-runtime/ai/browser-whisper-worker.mjs +11 -2
  10. package/browser-runtime/ai/model-controller.mjs +285 -95
  11. package/browser-runtime/ai/speech-worker-client.mjs +247 -32
  12. package/browser-runtime/ai/speech-worker-runtime.mjs +2310 -167
  13. package/browser-runtime/event-manager.mjs +1097 -1
  14. package/docs/architecture.md +2 -2
  15. package/docs/event-manager.md +155 -27
  16. package/docs/reference/README.md +27 -27
  17. package/docs/reference/ai/browser-speech-package-authority.json +835 -0
  18. package/docs/reference/ai/browser-speech.md +1162 -246
  19. package/docs/reference/ai/browser-wasm.md +18 -7
  20. package/docs/reference/availability-and-normalization.md +6 -3
  21. package/docs/reference/behavioral-testing.md +29 -6
  22. package/docs/reference/cli.md +117 -9
  23. package/docs/reference/core/arcane-ai-contracts.md +1 -1
  24. package/docs/reference/event-manager.md +577 -32
  25. package/docs/reference/inventory/package-api.json +478 -2
  26. package/docs/reference/inventory/runtime-components.json +108 -44
  27. package/docs/reference/inventory/runtime-modules.json +131 -53
  28. package/docs/reference/mail.md +316 -0
  29. package/docs/reference/protocols.md +157 -43
  30. package/docs/reference/runtime-components.md +258 -83
  31. package/docs/reference/runtime-modules.md +613 -77
  32. package/docs/reference/sdk-api.md +1014 -25
  33. package/package.json +5 -4
  34. package/runtime/ARCANE_RUNTIME_RELEASE.json +145 -140
  35. package/runtime/arcane/components/app-bar.html +34 -13
  36. package/runtime/arcane/components/assistant-panel.html +110 -57
  37. package/runtime/arcane/components/calculator.html +7 -4
  38. package/runtime/arcane/components/chart.html +58 -17
  39. package/runtime/arcane/components/chat.html +606 -136
  40. package/runtime/arcane/components/conversation-view.html +13 -6
  41. package/runtime/arcane/components/dashboard-config.html +96 -59
  42. package/runtime/arcane/components/data-maintenance.html +69 -14
  43. package/runtime/arcane/components/data-view.html +53 -7
  44. package/runtime/arcane/components/directory-picker.html +118 -32
  45. package/runtime/arcane/components/document-inspector.html +47 -10
  46. package/runtime/arcane/components/file-drop.html +81 -35
  47. package/runtime/arcane/components/file-inspector.html +72 -22
  48. package/runtime/arcane/components/file-manager.html +374 -79
  49. package/runtime/arcane/components/integration-settings.html +12 -5
  50. package/runtime/arcane/components/local-ai-status.html +48 -19
  51. package/runtime/arcane/components/markdown-document.html +161 -68
  52. package/runtime/arcane/components/markdown-editor.html +110 -33
  53. package/runtime/arcane/components/media-embed.html +8 -5
  54. package/runtime/arcane/components/modal.html +15 -5
  55. package/runtime/arcane/components/output-panel.html +28 -23
  56. package/runtime/arcane/components/preferences-form.html +22 -4
  57. package/runtime/arcane/components/record-timeline.html +18 -2
  58. package/runtime/arcane/components/relationship-board.html +23 -3
  59. package/runtime/arcane/components/screen-capture.html +10 -4
  60. package/runtime/arcane/components/source-code-viewer.html +76 -9
  61. package/runtime/arcane/components/source-explanation.html +23 -3
  62. package/runtime/arcane/components/speech.html +462 -384
  63. package/runtime/arcane/components/summary-strip.html +22 -11
  64. package/runtime/arcane/components/table.html +39 -21
  65. package/runtime/arcane/components/task-progress.html +79 -21
  66. package/runtime/arcane/components/terminal-workspace.html +7 -4
  67. package/runtime/arcane/components/theme-editor.html +7 -3
  68. package/runtime/arcane/components/unified-inbox.html +9 -4
  69. package/runtime/arcane/components/voice-transcription.html +639 -98
  70. package/runtime/arcane/components/weather-widget.html +5 -3
  71. package/runtime/arcane/components/web-navigator.html +48 -8
  72. package/runtime/arcane/entities/Chat.js +1 -1
  73. package/runtime/arcane/entities/User.js +110 -23
  74. package/runtime/arcane/modules/AI.js +2109 -130
  75. package/runtime/arcane/modules/AIProviderRuntime.js +720 -13
  76. package/runtime/arcane/modules/AIRuntimeState.js +109 -52
  77. package/runtime/arcane/modules/ApiModelDatabase.js +390 -17
  78. package/runtime/arcane/modules/BrowserTestSuite.js +205 -28
  79. package/runtime/arcane/modules/CalculatorEngine.js +63 -3
  80. package/runtime/arcane/modules/CommunicationAppController.js +588 -28
  81. package/runtime/arcane/modules/CommunicationHub.js +590 -10
  82. package/runtime/arcane/modules/ComponentContracts.js +470 -0
  83. package/runtime/arcane/modules/ConversationTimebox.js +152 -33
  84. package/runtime/arcane/modules/DBLS.js +40 -7
  85. package/runtime/arcane/modules/DBOPFS.js +35 -11
  86. package/runtime/arcane/modules/DataMaintenance.js +12 -2
  87. package/runtime/arcane/modules/Errors.js +65 -7
  88. package/runtime/arcane/modules/HTMLImport.js +198 -14
  89. package/runtime/arcane/modules/LocalAIReadinessController.js +208 -29
  90. package/runtime/arcane/modules/Mail.js +738 -115
  91. package/runtime/arcane/modules/MailOutbox.mjs +1395 -0
  92. package/runtime/arcane/modules/MailTransport.mjs +197 -39
  93. package/runtime/arcane/modules/Ollama.js +36 -1
  94. package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +583 -7
  95. package/runtime/arcane/modules/PreferenceStore.js +367 -33
  96. package/runtime/arcane/modules/RecordReviewStore.js +322 -23
  97. package/runtime/arcane/modules/ScreenCapture.js +1397 -15
  98. package/runtime/arcane/modules/SpeechPlayback.js +438 -41
  99. package/runtime/arcane/modules/TerminalClient.js +277 -12
  100. package/runtime/arcane/modules/ThemeBootstrap.js +80 -6
  101. package/runtime/arcane/modules/ThemeManager.js +39 -7
  102. package/runtime/arcane/modules/TimeGuard.js +131 -20
  103. package/runtime/arcane/modules/WaitForComponent.js +386 -33
  104. package/schemas/arcane-lock.schema.json +2 -2
  105. package/src/cli/main.mjs +435 -9
  106. package/src/event-manager.mjs +1097 -1
  107. package/src/import-map.mjs +21 -3
  108. package/src/index.mjs +13 -0
  109. package/src/installed-sdk-runtime.mjs +112 -0
  110. package/src/mail-api.mjs +22 -0
  111. package/src/mail-credentials.mjs +667 -0
  112. package/src/mail-server.mjs +1769 -0
  113. package/src/mail.mjs +261 -0
  114. package/src/sdk-browser-runtime.mjs +85 -41
  115. package/src/testing-loader.mjs +7 -0
  116. package/src/toolchain.mjs +3 -0
  117. package/src/workspace.mjs +1 -1
@@ -1,5 +1,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,1403 @@ 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
+ throw this.#browserSpeechProviderRouteOwnershipError(
2093
+ `The selected ${role} route is not owned by the replaceable AI legacy speech boundary.`
2094
+ );
2095
+ }
2096
+ expectedProviders[role]=record.provider;
2097
+ legacyRecords.push(record);
2098
+ }
2099
+ return Object.freeze({
2100
+ expectedProviders:Object.freeze(expectedProviders),
2101
+ legacyRecords:Object.freeze(legacyRecords)
2102
+ });
2103
+ }
2104
+
2105
+ async #cleanupRetiredLegacySpeechProviders(
2106
+ {signal=null,committed=false}={}
2107
+ ){
2108
+ const failures=[];
2109
+ for(const record of [...this.#browserSpeechRetiredLegacyRecords]){
2110
+ try{
2111
+ if(this.#providerRuntime.ownsProvider(
2112
+ record.role,
2113
+ record.provider
2114
+ )){
2115
+ throw this.#browserSpeechProviderRouteOwnershipError(
2116
+ `The retired ${record.role} legacy speech provider still owns its registry entry.`
2117
+ );
2118
+ }
2119
+ await record.provider.dispose({role:record.role,signal});
2120
+ const key=this.#legacySpeechProviderKey(
2121
+ record.role,
2122
+ record.providerId
2123
+ );
2124
+ if(this.#legacySpeechProviders.get(key)===record){
2125
+ this.#legacySpeechProviders.delete(key);
2126
+ }
2127
+ this.#browserSpeechRetiredLegacyRecords.delete(record);
2128
+ }catch(error){
2129
+ failures.push(error);
2130
+ }
2131
+ }
2132
+ if(failures.length){
2133
+ throw aiBrowserSpeechError(
2134
+ AI_BROWSER_SPEECH_ERROR_CODES.providerDisposalRejected,
2135
+ AI_BROWSER_SPEECH_REASONS.providerDisposalRejected,
2136
+ 'The replaced legacy speech providers could not be disposed.',
2137
+ failures.length===1
2138
+ ?failures[0]
2139
+ :new AggregateError(
2140
+ failures,
2141
+ 'Multiple replaced legacy speech provider disposals were rejected.'
2142
+ ),
2143
+ {committed}
2144
+ );
2145
+ }
2146
+ return true;
2147
+ }
2148
+
2149
+ #assertSynchronousBrowserSpeechSupersession(method){
2150
+ if(!this.#browserSpeechConfigurationRecord
2151
+ &&this.#browserSpeechRetiredRecords.size===0){
2152
+ return;
2153
+ }
2154
+ throw aiBrowserSpeechError(
2155
+ AI_BROWSER_SPEECH_ERROR_CODES.asyncTransitionRequired,
2156
+ AI_BROWSER_SPEECH_REASONS.asyncTransitionRequired,
2157
+ `${method} cannot replace SDK-owned browser speech providers synchronously; await AI.disposeBrowserSpeech() or use an asynchronous transition method.`
2158
+ );
2159
+ }
2160
+
2161
+ async #supersedeBrowserSpeechForRouteChange(){
2162
+ if(!this.#browserSpeechConfigurationRecord
2163
+ &&this.#browserSpeechRetiredRecords.size===0){
2164
+ return false;
2165
+ }
2166
+ return this.disposeBrowserSpeech();
2167
+ }
2168
+
2169
+ #publishBrowserSpeechEvent(type,normalized,operationId,reason,{descriptor=null,error=null}={}){
2170
+ const compatibilityDetail=Object.freeze({
2171
+ configuration:normalized.configuration,
2172
+ configurationId:normalized.id,
2173
+ ...(descriptor?{descriptor}:{}),
2174
+ ...(error?{error}:{}),
2175
+ reason
2176
+ });
2177
+ return this.#events.dispatch(
2178
+ type,
2179
+ compatibilityDetail,
2180
+ {
2181
+ operationId,
2182
+ publicDetail:Object.freeze({
2183
+ configurationId:normalized.id,
2184
+ ...(descriptor?{descriptor}:{}),
2185
+ ...(typeof error?.code==='string'?{code:error.code}:{}),
2186
+ reason
2187
+ })
2188
+ }
2189
+ );
2190
+ }
2191
+
2192
+ #browserSpeechRoutes(normalized,providers,previousRecord){
2193
+ function roleRoutes(provider,catalog){
2194
+ const selection=Object.freeze({
2195
+ providerId:provider.id,
2196
+ modelId:catalog.id,
2197
+ localOnly:true
2198
+ });
2199
+ return Object.freeze({default:selection,localOnly:selection});
2200
+ }
2201
+ const currentRoutes=this.#currentSpeechRoutes();
2202
+ const routes={};
2203
+ const catalogs={};
2204
+ for(const role of ['stt','tts']){
2205
+ if(!normalized.roles.includes(role)){
2206
+ routes[role]=previousRecord?.managedRoles.includes(role)
2207
+ ?previousRecord.routes[role]
2208
+ :currentRoutes[role];
2209
+ catalogs[role]=null;
2210
+ continue;
2211
+ }
2212
+ const catalog=providers[role].catalog();
2213
+ if(catalog.length!==1||typeof catalog[0]?.id!=='string'){
2214
+ throw aiBrowserSpeechError(
2215
+ AI_BROWSER_SPEECH_ERROR_CODES.providerConstructionRejected,
2216
+ AI_BROWSER_SPEECH_REASONS.providerConstructionRejected,
2217
+ `The browser speech ${role} provider must expose one admitted model.`
2218
+ );
2219
+ }
2220
+ catalogs[role]=catalog[0];
2221
+ routes[role]=roleRoutes(providers[role],catalog[0]);
2222
+ }
2223
+ return Object.freeze({
2224
+ routes:Object.freeze(routes),
2225
+ catalogs:Object.freeze(catalogs)
2226
+ });
2227
+ }
2228
+
2229
+ #browserSpeechDescriptor(normalized,catalogs,previousRecord){
2230
+ function roleDescriptor(role,configured,catalog){
2231
+ return Object.freeze({
2232
+ role,
2233
+ providerId:configured.providerId,
2234
+ modelId:catalog.id,
2235
+ ...(configured.graph
2236
+ ?{artifactGraphId:catalog.artifactGraphId}
2237
+ :{}),
2238
+ offline:configured.offline,
2239
+ ...(role==='tts'?{defaultVoice:catalog.defaultVoice}:{})
2240
+ });
2241
+ }
2242
+ const roles={};
2243
+ for(const role of ['stt','tts']){
2244
+ roles[role]=normalized.roles.includes(role)
2245
+ ?roleDescriptor(role,normalized[role],catalogs[role])
2246
+ :previousRecord?.descriptor[role]??null;
2247
+ }
2248
+ return Object.freeze({
2249
+ protocol:AI_BROWSER_SPEECH_CONFIGURATION_PROTOCOL,
2250
+ configurationId:normalized.id,
2251
+ ...roles
2252
+ });
2253
+ }
2254
+
2255
+ #browserSpeechConfiguration(normalized,previousRecord){
2256
+ if(!previousRecord)return normalized.configuration;
2257
+ if(normalized.roles.length===2)return normalized.configuration;
2258
+ if(normalized.dbopfs!==previousRecord.dbopfs
2259
+ ||normalized.tableName!==previousRecord.tableName){
2260
+ throw aiBrowserSpeechError(
2261
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationContractMismatch,
2262
+ AI_BROWSER_SPEECH_REASONS.configurationContractMismatch,
2263
+ 'A partial browser speech replacement must retain the active DBOPFS store and tableName.'
2264
+ );
2265
+ }
2266
+ const carriedRoles=previousRecord.managedRoles.filter(
2267
+ role=>!normalized.roles.includes(role)
2268
+ );
2269
+ if(carriedRoles.length===0)return normalized.configuration;
2270
+ return Object.freeze({
2271
+ protocol:AI_BROWSER_SPEECH_CONFIGURATION_PROTOCOL,
2272
+ id:normalized.id,
2273
+ dbopfs:normalized.dbopfs,
2274
+ ...(normalized.tableName?{tableName:normalized.tableName}:{}),
2275
+ ...(normalized.roles.includes('stt')
2276
+ ?{stt:normalized.configuration.stt}
2277
+ :previousRecord.managedRoles.includes('stt')
2278
+ ?{stt:previousRecord.configuration.stt}
2279
+ :{}),
2280
+ ...(normalized.roles.includes('tts')
2281
+ ?{tts:normalized.configuration.tts}
2282
+ :previousRecord.managedRoles.includes('tts')
2283
+ ?{tts:previousRecord.configuration.tts}
2284
+ :{})
2285
+ });
2286
+ }
2287
+
2288
+ #currentSpeechRoutes(){
2289
+ return Object.freeze({
2290
+ stt:Object.freeze({
2291
+ default:this.#providerRuntime.selection('stt'),
2292
+ localOnly:this.#providerRuntime.selection('stt',{localOnly:true})
2293
+ }),
2294
+ tts:Object.freeze({
2295
+ default:this.#providerRuntime.selection('tts'),
2296
+ localOnly:this.#providerRuntime.selection('tts',{localOnly:true})
2297
+ })
2298
+ });
2299
+ }
2300
+
2301
+ #emptySpeechRoutes(){
2302
+ return Object.freeze({
2303
+ stt:Object.freeze({default:null,localOnly:null}),
2304
+ tts:Object.freeze({default:null,localOnly:null})
2305
+ });
2306
+ }
2307
+
2308
+ async #browserSpeechModule(){
2309
+ if(!this.#browserSpeechModulePromise){
2310
+ const runtime=this;
2311
+ this.#browserSpeechModulePromise=import(
2312
+ 'arcane-os/ai/browser-speech'
2313
+ ).catch(function clearRejectedBrowserSpeechImport(error){
2314
+ runtime.#browserSpeechModulePromise=null;
2315
+ throw error;
2316
+ });
2317
+ }
2318
+ return this.#browserSpeechModulePromise;
2319
+ }
2320
+
2321
+ #assertBrowserSpeechGraphs(normalized,module){
2322
+ for(const role of normalized.roles){
2323
+ const configured=normalized[role];
2324
+ const graph=configured.graph;
2325
+ if(!graph)continue;
2326
+ if(graph.protocol!==module.BROWSER_SPEECH_ARTIFACT_GRAPH_PROTOCOL
2327
+ ||graph.role!==role
2328
+ ||(graph.providerId!==null
2329
+ &&graph.providerId!==undefined
2330
+ &&graph.providerId!==configured.providerId)){
2331
+ throw aiBrowserSpeechError(
2332
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationContractMismatch,
2333
+ AI_BROWSER_SPEECH_REASONS.configurationContractMismatch,
2334
+ `AI browser speech ${role}.graph does not match its role, provider, or graph protocol.`
2335
+ );
2336
+ }
2337
+ }
2338
+ }
2339
+
2340
+ #browserSpeechCandidate(
2341
+ normalized,
2342
+ configuration,
2343
+ descriptor,
2344
+ providers,
2345
+ routes,
2346
+ previousRecord
2347
+ ){
2348
+ const configurationByRole={};
2349
+ const managedRoles=Object.freeze(['stt','tts'].filter(role=>
2350
+ normalized.roles.includes(role)
2351
+ ||previousRecord?.managedRoles.includes(role)
2352
+ ));
2353
+ for(const role of ['stt','tts']){
2354
+ configurationByRole[role]=normalized.roles.includes(role)
2355
+ ?normalized.configuration
2356
+ :previousRecord?.configurationByRole[role]??null;
2357
+ }
2358
+ return {
2359
+ configuration,
2360
+ configurationByRole:Object.freeze(configurationByRole),
2361
+ dbopfs:normalized.dbopfs,
2362
+ tableName:normalized.tableName,
2363
+ descriptor,
2364
+ providers,
2365
+ routes,
2366
+ managedRoles,
2367
+ candidateRoles:normalized.roles,
2368
+ unregisters:{stt:null,tts:null},
2369
+ registrationState:{stt:false,tts:false},
2370
+ retirementState:{stt:false,tts:false}
2371
+ };
2372
+ }
2373
+
2374
+ #freezeBrowserSpeechRecord(record){
2375
+ record.unregisters=Object.freeze({...record.unregisters});
2376
+ Object.seal(record.registrationState);
2377
+ Object.seal(record.retirementState);
2378
+ return Object.freeze(record);
2379
+ }
2380
+
2381
+ #browserSpeechRecordFromReplacement(record,replacement){
2382
+ return this.#freezeBrowserSpeechRecord({
2383
+ configuration:record.configuration,
2384
+ configurationByRole:record.configurationByRole,
2385
+ dbopfs:record.dbopfs,
2386
+ tableName:record.tableName,
2387
+ descriptor:record.descriptor,
2388
+ providers:record.providers,
2389
+ routes:replacement.routes,
2390
+ managedRoles:record.managedRoles,
2391
+ candidateRoles:record.candidateRoles,
2392
+ unregisters:{
2393
+ stt:replacement.unregisters.stt,
2394
+ tts:replacement.unregisters.tts
2395
+ },
2396
+ registrationState:{
2397
+ stt:record.managedRoles.includes('stt'),
2398
+ tts:record.managedRoles.includes('tts')
2399
+ },
2400
+ retirementState:{stt:false,tts:false}
2401
+ });
2402
+ }
2403
+
2404
+ #retireBrowserSpeechRegistration(record,roles=['stt','tts']){
2405
+ if(!record)return;
2406
+ let retired=false;
2407
+ for(const role of roles){
2408
+ if(record.registrationState[role]===false)continue;
2409
+ record.registrationState[role]=false;
2410
+ record.retirementState[role]=true;
2411
+ retired=true;
2412
+ }
2413
+ if(retired)this.#browserSpeechRetiredRecords.add(record);
2414
+ }
2415
+
2416
+ #unregisterBrowserSpeechRecord(
2417
+ record,
2418
+ {roles=['stt','tts'],committed=false}={}
2419
+ ){
2420
+ const failures=[];
2421
+ for(const role of ['tts','stt'].filter(role=>roles.includes(role))){
2422
+ if(record.registrationState?.[role]===false)continue;
2423
+ const unregister=record.unregisters?.[role];
2424
+ if(typeof unregister!=='function'){
2425
+ failures.push(aiBrowserSpeechError(
2426
+ AI_BROWSER_SPEECH_ERROR_CODES.providerUnregistrationRejected,
2427
+ AI_BROWSER_SPEECH_REASONS.providerUnregistrationRejected,
2428
+ `The ${role} browser speech provider ${record.providers[role].id} has no unregister handle.`
2429
+ ));
2430
+ continue;
2431
+ }
2432
+ try{
2433
+ const removed=unregister();
2434
+ if(removed!==true){
2435
+ failures.push(aiBrowserSpeechError(
2436
+ AI_BROWSER_SPEECH_ERROR_CODES.providerUnregistrationRejected,
2437
+ AI_BROWSER_SPEECH_REASONS.providerUnregistrationRejected,
2438
+ `The ${role} browser speech provider ${record.providers[role].id} no longer owns its registry entry.`
2439
+ ));
2440
+ continue;
2441
+ }
2442
+ if(record.registrationState)record.registrationState[role]=false;
2443
+ }catch(error){
2444
+ failures.push(error);
2445
+ }
2446
+ }
2447
+ if(failures.length){
2448
+ throw aiBrowserSpeechError(
2449
+ AI_BROWSER_SPEECH_ERROR_CODES.providerUnregistrationRejected,
2450
+ AI_BROWSER_SPEECH_REASONS.providerUnregistrationRejected,
2451
+ 'The browser speech providers could not be unregistered.',
2452
+ failures.length===1
2453
+ ?failures[0]
2454
+ :new AggregateError(
2455
+ failures,
2456
+ 'Multiple browser speech provider unregistrations were rejected.'
2457
+ ),
2458
+ {committed}
2459
+ );
2460
+ }
2461
+ }
2462
+
2463
+ async #disposeBrowserSpeechProviders(
2464
+ record,
2465
+ {roles=['stt','tts'],signal=null}={}
2466
+ ){
2467
+ if(!record)return;
2468
+ const disposableRoles=roles.filter(role=>record.providers?.[role]);
2469
+ const settlements=await Promise.allSettled(
2470
+ disposableRoles.map(function disposeBrowserSpeechProvider(role){
2471
+ return record.providers[role].dispose({
2472
+ role,
2473
+ selection:record.routes[role].default,
2474
+ signal
2475
+ });
2476
+ })
2477
+ );
2478
+ const failures=settlements
2479
+ .filter(result=>result.status==='rejected')
2480
+ .map(result=>result.reason);
2481
+ if(failures.length){
2482
+ throw failures.length===1
2483
+ ?failures[0]
2484
+ :new AggregateError(
2485
+ failures,
2486
+ 'Multiple browser speech provider disposals were rejected.'
2487
+ );
2488
+ }
2489
+ }
2490
+
2491
+ async #cleanupBrowserSpeechRecord(
2492
+ record,
2493
+ {signal=null,committed=false}={}
2494
+ ){
2495
+ if(!record)return false;
2496
+ const retiredRoles=['stt','tts'].filter(
2497
+ role=>record.retirementState?.[role]===true
2498
+ );
2499
+ const roles=retiredRoles.length?retiredRoles:record.candidateRoles;
2500
+ this.#browserSpeechRetiredRecords.add(record);
2501
+ this.#unregisterBrowserSpeechRecord(record,{roles,committed});
2502
+ try{
2503
+ await this.#disposeBrowserSpeechProviders(record,{roles,signal});
2504
+ }catch(error){
2505
+ throw aiBrowserSpeechError(
2506
+ AI_BROWSER_SPEECH_ERROR_CODES.providerDisposalRejected,
2507
+ AI_BROWSER_SPEECH_REASONS.providerDisposalRejected,
2508
+ 'The browser speech providers could not be disposed.',
2509
+ error,
2510
+ {committed}
2511
+ );
2512
+ }
2513
+ this.#browserSpeechRetiredRecords.delete(record);
2514
+ return true;
2515
+ }
2516
+
2517
+ async #throwAfterBrowserSpeechCandidateCleanup(record,failure){
2518
+ try{
2519
+ await this.#cleanupBrowserSpeechRecord(record);
2520
+ }catch(cleanupError){
2521
+ throw aiBrowserSpeechError(
2522
+ cleanupError.code
2523
+ ||AI_BROWSER_SPEECH_ERROR_CODES.providerDisposalRejected,
2524
+ cleanupError.reason
2525
+ ||AI_BROWSER_SPEECH_REASONS.providerDisposalRejected,
2526
+ 'Browser speech candidate cleanup was rejected after configuration rejection.',
2527
+ new AggregateError(
2528
+ [failure,cleanupError],
2529
+ 'Browser speech configuration and candidate cleanup were both rejected.'
2530
+ )
2531
+ );
2532
+ }
2533
+ throw failure;
2534
+ }
2535
+
2536
+ async #configureBrowserSpeechOperation(normalized,controller,generation,operationId){
2537
+ this.#assertBrowserSpeechOperation(controller,generation);
2538
+ const previousRecord=this.#browserSpeechConfigurationRecord;
2539
+ if(previousRecord&&!this.#browserSpeechRecordIsActive(previousRecord)){
2540
+ throw this.#browserSpeechProviderRouteOwnershipError(
2541
+ 'The prior browser speech provider or route ownership changed before replacement.'
2542
+ );
2543
+ }
2544
+ const configuration=this.#browserSpeechConfiguration(
2545
+ normalized,
2546
+ previousRecord
2547
+ );
2548
+ this.#publishBrowserSpeechEvent(
2549
+ AI_BROWSER_SPEECH_EVENT_TYPES.configurationStarted,
2550
+ normalized,
2551
+ operationId,
2552
+ this.#browserSpeechConfigurationRecord
2553
+ ?AI_BROWSER_SPEECH_REASONS.configurationReplaced
2554
+ :AI_BROWSER_SPEECH_REASONS.configurationAdded
2555
+ );
2556
+ this.#assertBrowserSpeechOperation(controller,generation);
2557
+
2558
+ let module;
2559
+ try{
2560
+ module=await this.#browserSpeechModule();
2561
+ }catch(error){
2562
+ this.#assertBrowserSpeechOperation(controller,generation);
2563
+ throw aiBrowserSpeechError(
2564
+ AI_BROWSER_SPEECH_ERROR_CODES.moduleImportRejected,
2565
+ AI_BROWSER_SPEECH_REASONS.moduleImportRejected,
2566
+ 'The browser speech SDK module could not be imported.',
2567
+ error
2568
+ );
2569
+ }
2570
+ this.#assertBrowserSpeechOperation(controller,generation);
2571
+ this.#assertBrowserSpeechGraphs(normalized,module);
2572
+
2573
+ let store;
2574
+ try{
2575
+ store=module.createDbopfsSpeechArtifactStore({
2576
+ dbopfs:normalized.dbopfs,
2577
+ ...(normalized.tableName?{tableName:normalized.tableName}:{})
2578
+ });
2579
+ }catch(error){
2580
+ throw aiBrowserSpeechError(
2581
+ AI_BROWSER_SPEECH_ERROR_CODES.artifactStoreConstructionRejected,
2582
+ AI_BROWSER_SPEECH_REASONS.artifactStoreConstructionRejected,
2583
+ 'The browser speech artifact store could not be constructed.',
2584
+ error
2585
+ );
2586
+ }
2587
+
2588
+ const candidateProviders={
2589
+ stt:previousRecord?.providers.stt??null,
2590
+ tts:previousRecord?.providers.tts??null
2591
+ };
2592
+ for(const role of normalized.roles)candidateProviders[role]=null;
2593
+ let providers=null;
2594
+ let prepared=null;
2595
+ try{
2596
+ for(const role of normalized.roles){
2597
+ const factory=role==='stt'
2598
+ ?module.createBrowserWhisperProvider
2599
+ :module.createBrowserKokoroProvider;
2600
+ const configured=normalized[role];
2601
+ candidateProviders[role]=factory({
2602
+ id:configured.providerId,
2603
+ ...(configured.graph
2604
+ ?{
2605
+ graph:configured.graph,
2606
+ security:configured.security
2607
+ }
2608
+ :{
2609
+ model:configured.model,
2610
+ runtime:configured.runtime,
2611
+ ...(Object.hasOwn(configured,'security')
2612
+ ?{security:configured.security}
2613
+ :{})
2614
+ }),
2615
+ store,
2616
+ offline:configured.offline
2617
+ });
2618
+ }
2619
+ providers=Object.freeze({...candidateProviders});
2620
+ prepared=this.#browserSpeechRoutes(
2621
+ normalized,
2622
+ providers,
2623
+ previousRecord
2624
+ );
2625
+ }catch(error){
2626
+ const failure=error?.code===AI_BROWSER_SPEECH_ERROR_CODES.providerConstructionRejected
2627
+ ?error
2628
+ :aiBrowserSpeechError(
2629
+ AI_BROWSER_SPEECH_ERROR_CODES.providerConstructionRejected,
2630
+ AI_BROWSER_SPEECH_REASONS.providerConstructionRejected,
2631
+ 'The browser speech providers could not be constructed.',
2632
+ error
2633
+ );
2634
+ if(normalized.roles.some(role=>candidateProviders[role])){
2635
+ const currentRoutes=this.#currentSpeechRoutes();
2636
+ const partialRoutes={};
2637
+ for(const role of ['stt','tts']){
2638
+ const provider=candidateProviders[role];
2639
+ const changed=normalized.roles.includes(role);
2640
+ const selection=changed&&provider
2641
+ ?Object.freeze({
2642
+ providerId:provider.id,
2643
+ modelId:normalized[role].graph?.model.id
2644
+ ??normalized[role].model.id,
2645
+ localOnly:true
2646
+ })
2647
+ :null;
2648
+ partialRoutes[role]=changed
2649
+ ?Object.freeze({default:selection,localOnly:selection})
2650
+ :previousRecord?.managedRoles.includes(role)
2651
+ ?previousRecord.routes[role]
2652
+ :currentRoutes[role];
2653
+ }
2654
+ const partial=this.#browserSpeechCandidate(
2655
+ normalized,
2656
+ configuration,
2657
+ null,
2658
+ Object.freeze({...candidateProviders}),
2659
+ Object.freeze(partialRoutes),
2660
+ previousRecord
2661
+ );
2662
+ return this.#throwAfterBrowserSpeechCandidateCleanup(
2663
+ partial,
2664
+ failure
2665
+ );
2666
+ }
2667
+ throw failure;
2668
+ }
2669
+ const descriptor=this.#browserSpeechDescriptor(
2670
+ normalized,
2671
+ prepared.catalogs,
2672
+ previousRecord
2673
+ );
2674
+ const candidate=this.#browserSpeechCandidate(
2675
+ normalized,
2676
+ configuration,
2677
+ descriptor,
2678
+ providers,
2679
+ prepared.routes,
2680
+ previousRecord
2681
+ );
2682
+ let replacementBoundary;
2683
+ try{
2684
+ replacementBoundary=this.#browserSpeechReplacementBoundary(
2685
+ previousRecord,
2686
+ normalized.roles
2687
+ );
2688
+ }catch(error){
2689
+ return this.#throwAfterBrowserSpeechCandidateCleanup(
2690
+ candidate,
2691
+ error
2692
+ );
2693
+ }
2694
+ try{
2695
+ await this.#unloadSpeechProviderRolesForTransition(
2696
+ controller.signal,
2697
+ replacementBoundary.expectedProviders,
2698
+ normalized.roles
2699
+ );
2700
+ this.#assertBrowserSpeechOperation(controller,generation);
2701
+ }catch(error){
2702
+ return this.#throwAfterBrowserSpeechCandidateCleanup(candidate,error);
2703
+ }
2704
+
2705
+ let replacement;
2706
+ try{
2707
+ if(normalized.roles.length===2){
2708
+ replacement=this.#providerRuntime.replaceSpeechProviders({
2709
+ providers,
2710
+ routes:prepared.routes,
2711
+ expectedProviders:replacementBoundary.expectedProviders
2712
+ });
2713
+ }else{
2714
+ const role=normalized.roles[0];
2715
+ const roleReplacement=this.#providerRuntime.replaceSpeechProvider(
2716
+ role,
2717
+ {
2718
+ provider:providers[role],
2719
+ routes:prepared.routes[role],
2720
+ expectedProvider:replacementBoundary.expectedProviders[role]
2721
+ }
2722
+ );
2723
+ replacement=Object.freeze({
2724
+ routes:prepared.routes,
2725
+ unregisters:Object.freeze({
2726
+ stt:role==='stt'
2727
+ ?roleReplacement.unregister
2728
+ :previousRecord?.unregisters.stt??null,
2729
+ tts:role==='tts'
2730
+ ?roleReplacement.unregister
2731
+ :previousRecord?.unregisters.tts??null
2732
+ })
2733
+ });
2734
+ }
2735
+ }catch(error){
2736
+ const commitFailure=aiBrowserSpeechError(
2737
+ AI_BROWSER_SPEECH_ERROR_CODES.routeCommitRejected,
2738
+ AI_BROWSER_SPEECH_REASONS.routeCommitRejected,
2739
+ 'The browser speech provider and route replacement could not be committed.',
2740
+ error
2741
+ );
2742
+ return this.#throwAfterBrowserSpeechCandidateCleanup(
2743
+ candidate,
2744
+ commitFailure
2745
+ );
2746
+ }
2747
+
2748
+ const record=this.#browserSpeechRecordFromReplacement(
2749
+ candidate,
2750
+ replacement
2751
+ );
2752
+ for(const legacyRecord of replacementBoundary.legacyRecords){
2753
+ this.#browserSpeechRetiredLegacyRecords.add(legacyRecord);
2754
+ }
2755
+ if(previousRecord){
2756
+ this.#retireBrowserSpeechRegistration(
2757
+ previousRecord,
2758
+ normalized.roles
2759
+ );
2760
+ }
2761
+ this.#browserSpeechConfigurationRecord=record;
2762
+ try{
2763
+ this.#applySpeechPreferenceTuple(
2764
+ this.#tupleFromSpeechProviderRoutes(replacement.routes)
2765
+ );
2766
+ }catch(error){
2767
+ const finalizationFailure=aiBrowserSpeechError(
2768
+ AI_BROWSER_SPEECH_ERROR_CODES.routeViewUpdateRejected,
2769
+ AI_BROWSER_SPEECH_REASONS.routeViewUpdateRejected,
2770
+ 'The committed browser speech replacement could not update the AI speech route view.',
2771
+ error
2772
+ );
2773
+ if(!previousRecord
2774
+ ||normalized.roles.some(
2775
+ role=>!previousRecord.managedRoles.includes(role)
2776
+ )){
2777
+ finalizationFailure.committed=true;
2778
+ throw finalizationFailure;
2779
+ }
2780
+
2781
+ let rollback;
2782
+ try{
2783
+ if(normalized.roles.length===2){
2784
+ rollback=this.#providerRuntime.replaceSpeechProviders({
2785
+ providers:previousRecord.providers,
2786
+ routes:previousRecord.routes,
2787
+ expectedProviders:record.providers
2788
+ });
2789
+ }else{
2790
+ const role=normalized.roles[0];
2791
+ const roleRollback=this.#providerRuntime.replaceSpeechProvider(
2792
+ role,
2793
+ {
2794
+ provider:previousRecord.providers[role],
2795
+ routes:previousRecord.routes[role],
2796
+ expectedProvider:record.providers[role]
2797
+ }
2798
+ );
2799
+ rollback=Object.freeze({
2800
+ routes:previousRecord.routes,
2801
+ unregisters:Object.freeze({
2802
+ stt:role==='stt'
2803
+ ?roleRollback.unregister
2804
+ :previousRecord.unregisters.stt,
2805
+ tts:role==='tts'
2806
+ ?roleRollback.unregister
2807
+ :previousRecord.unregisters.tts
2808
+ })
2809
+ });
2810
+ }
2811
+ }catch(rollbackError){
2812
+ const candidateCommitted=this.#browserSpeechRecordIsActive(record);
2813
+ if(!candidateCommitted){
2814
+ this.#browserSpeechConfigurationRecord=null;
2815
+ this.#browserSpeechRetiredRecords.add(record);
2816
+ }
2817
+ throw aiBrowserSpeechError(
2818
+ AI_BROWSER_SPEECH_ERROR_CODES.routeRollbackRejected,
2819
+ AI_BROWSER_SPEECH_REASONS.routeRollbackRejected,
2820
+ 'The prior browser speech providers and routes could not be restored after AI speech route view rejection.',
2821
+ new AggregateError(
2822
+ [finalizationFailure,rollbackError],
2823
+ 'Browser speech replacement finalization and rollback were both rejected.'
2824
+ ),
2825
+ {committed:candidateCommitted}
2826
+ );
2827
+ }
2828
+
2829
+ this.#retireBrowserSpeechRegistration(record,normalized.roles);
2830
+ const restoredRecord=this.#browserSpeechRecordFromReplacement(
2831
+ previousRecord,
2832
+ rollback
2833
+ );
2834
+ this.#browserSpeechRetiredRecords.delete(previousRecord);
2835
+ this.#browserSpeechConfigurationRecord=restoredRecord;
2836
+ this.#applySpeechPreferenceTuple(
2837
+ this.#tupleFromSpeechProviderRoutes(rollback.routes)
2838
+ );
2839
+ return this.#throwAfterBrowserSpeechCandidateCleanup(
2840
+ record,
2841
+ finalizationFailure
2842
+ );
2843
+ }
2844
+ this.#assertBrowserSpeechOperation(
2845
+ controller,
2846
+ generation,
2847
+ {committed:true}
2848
+ );
2849
+ await this.#cleanupRetiredLegacySpeechProviders({
2850
+ signal:controller.signal,
2851
+ committed:true
2852
+ });
2853
+ this.#assertBrowserSpeechOperation(
2854
+ controller,
2855
+ generation,
2856
+ {committed:true}
2857
+ );
2858
+ this.#publishBrowserSpeechEvent(
2859
+ AI_BROWSER_SPEECH_EVENT_TYPES.configured,
2860
+ normalized,
2861
+ operationId,
2862
+ previousRecord
2863
+ ?AI_BROWSER_SPEECH_REASONS.configurationReplaced
2864
+ :AI_BROWSER_SPEECH_REASONS.configurationAdded,
2865
+ {descriptor}
2866
+ );
2867
+
2868
+ for(const retiredRecord of [...this.#browserSpeechRetiredRecords]){
2869
+ try{
2870
+ await this.#cleanupBrowserSpeechRecord(
2871
+ retiredRecord,
2872
+ {signal:controller.signal,committed:true}
2873
+ );
2874
+ }catch(error){
2875
+ if(error?.committed===true)throw error;
2876
+ if(generation!==this.#browserSpeechGeneration
2877
+ ||controller.signal.aborted){
2878
+ throw this.#browserSpeechAbortError(
2879
+ controller,
2880
+ generation,
2881
+ {committed:true}
2882
+ );
2883
+ }
2884
+ throw error;
2885
+ }
2886
+ }
2887
+ this.#assertBrowserSpeechOperation(
2888
+ controller,
2889
+ generation,
2890
+ {committed:true}
1393
2891
  );
1394
- return configured;
2892
+ return descriptor;
1395
2893
  }
1396
2894
 
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();
2895
+ configureBrowserSpeech(configuration,options={}){
2896
+ const normalized=normalizeBrowserSpeechConfiguration(configuration);
2897
+ const operation=normalizeBrowserSpeechOperationOptions(
2898
+ options,
2899
+ 'AI.configureBrowserSpeech'
2900
+ );
2901
+ if(operation.signal?.aborted){
2902
+ return Promise.reject(aiBrowserSpeechError(
2903
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationCancelled,
2904
+ AI_BROWSER_SPEECH_REASONS.configurationCancelled,
2905
+ 'The browser speech configuration was cancelled before admission.',
2906
+ operation.signal.reason,
2907
+ {name:'AbortError'}
2908
+ ));
2909
+ }
2910
+ if(this.#browserSpeechConfigurationRecord
2911
+ &&normalized.roles.every(role=>
2912
+ this.#browserSpeechConfigurationRecord.configurationByRole[role]
2913
+ ===configuration
2914
+ )
2915
+ &&this.#browserSpeechRecordIsActive(
2916
+ this.#browserSpeechConfigurationRecord
2917
+ )
2918
+ &&this.#browserSpeechRetiredRecords.size===0
2919
+ &&!this.#browserSpeechController){
2920
+ return Promise.resolve(this.#browserSpeechConfigurationRecord.descriptor);
2921
+ }
2922
+ const operationId=this.#browserSpeechOperationId('configure-browser-speech');
2923
+ const generation=++this.#browserSpeechGeneration;
2924
+ const superseded=aiBrowserSpeechError(
2925
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationSuperseded,
2926
+ AI_BROWSER_SPEECH_REASONS.configurationReplaced,
2927
+ 'The browser speech configuration was replaced by a newer configuration.',
2928
+ undefined,
2929
+ {name:'AbortError'}
2930
+ );
2931
+ this.#browserSpeechController?.abort(superseded);
2932
+ if(normalized.roles.includes('tts'))this.#invalidateSpeechControl();
2933
+ const controller=new AbortController();
2934
+ this.#browserSpeechController=controller;
2935
+ this.#browserSpeechControllerRoles=normalized.roles;
2936
+ const forwardAbort=function cancelBrowserSpeechConfiguration(){
2937
+ if(!controller.signal.aborted)controller.abort(operation.signal.reason);
2938
+ };
2939
+ if(operation.signal?.aborted)forwardAbort();
2940
+ else operation.signal?.addEventListener('abort',forwardAbort,{once:true});
2941
+ const runtime=this;
2942
+ const scheduled=this.#browserSpeechTransition.then(
2943
+ async function runBrowserSpeechConfiguration(){
2944
+ try{
2945
+ return await runtime.#configureBrowserSpeechOperation(
2946
+ normalized,
2947
+ controller,
2948
+ generation,
2949
+ operationId
2950
+ );
2951
+ }catch(error){
2952
+ const failure=error?.committed===true
2953
+ ?error
2954
+ :(generation!==runtime.#browserSpeechGeneration
2955
+ ||controller.signal.aborted)
2956
+ ?runtime.#browserSpeechAbortError(controller,generation)
2957
+ :error;
2958
+ runtime.#publishBrowserSpeechEvent(
2959
+ failure.name==='AbortError'
2960
+ ?AI_BROWSER_SPEECH_EVENT_TYPES.configurationCancelled
2961
+ :AI_BROWSER_SPEECH_EVENT_TYPES.configurationError,
2962
+ normalized,
2963
+ operationId,
2964
+ failure.reason
2965
+ ||AI_BROWSER_SPEECH_REASONS.routeCommitRejected,
2966
+ {error:failure}
2967
+ );
2968
+ throw failure;
2969
+ }finally{
2970
+ operation.signal?.removeEventListener('abort',forwardAbort);
2971
+ if(runtime.#browserSpeechController===controller){
2972
+ runtime.#browserSpeechController=null;
2973
+ runtime.#browserSpeechControllerRoles=Object.freeze([]);
2974
+ }
2975
+ }
2976
+ }
2977
+ );
2978
+ this.#browserSpeechTransition=scheduled.then(
2979
+ function completeBrowserSpeechConfigurationLane(){},
2980
+ function retainBrowserSpeechConfigurationFailure(){}
2981
+ );
2982
+ return scheduled;
1429
2983
  }
1430
2984
 
1431
- async transitionProviders(selections){
1432
- const prepared=this.#providerRuntime.validateConfiguration(selections);
1433
- this.#ensureLegacyLLMProvider(
1434
- prepared.llm.default?.providerId
2985
+ disposeBrowserSpeech(options={}){
2986
+ const operation=normalizeBrowserSpeechOperationOptions(
2987
+ options,
2988
+ 'AI.disposeBrowserSpeech'
1435
2989
  );
1436
- this.#ensureLegacySpeechProvider(
1437
- 'stt',
1438
- prepared.stt.default?.providerId
2990
+ if(operation.signal?.aborted){
2991
+ return Promise.reject(aiBrowserSpeechError(
2992
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationCancelled,
2993
+ AI_BROWSER_SPEECH_REASONS.configurationCancelled,
2994
+ 'Browser speech disposal was cancelled before admission.',
2995
+ operation.signal.reason,
2996
+ {name:'AbortError'}
2997
+ ));
2998
+ }
2999
+ const operationId=this.#browserSpeechOperationId('dispose-browser-speech');
3000
+ const generation=++this.#browserSpeechGeneration;
3001
+ const superseded=aiBrowserSpeechError(
3002
+ AI_BROWSER_SPEECH_ERROR_CODES.configurationSuperseded,
3003
+ AI_BROWSER_SPEECH_REASONS.configurationDisposed,
3004
+ 'The browser speech configuration was superseded by disposal.',
3005
+ undefined,
3006
+ {name:'AbortError'}
1439
3007
  );
1440
- this.#ensureLegacySpeechProvider(
1441
- 'tts',
1442
- prepared.tts.default?.providerId
3008
+ const invalidatesSpeech=Boolean(
3009
+ this.#browserSpeechControllerRoles.includes('tts')
3010
+ ||this.#browserSpeechConfigurationRecord?.managedRoles.includes('tts')
3011
+ ||[...this.#browserSpeechRetiredRecords].some(record=>
3012
+ record.managedRoles.includes('tts')
3013
+ ||record.candidateRoles.includes('tts')
3014
+ )
3015
+ ||[...this.#browserSpeechRetiredLegacyRecords].some(
3016
+ record=>record.role==='tts'
3017
+ )
1443
3018
  );
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
3019
+ this.#browserSpeechController?.abort(superseded);
3020
+ if(invalidatesSpeech)this.#invalidateSpeechControl();
3021
+ const controller=new AbortController();
3022
+ this.#browserSpeechController=controller;
3023
+ this.#browserSpeechControllerRoles=Object.freeze([]);
3024
+ const forwardAbort=function cancelBrowserSpeechDisposal(){
3025
+ if(!controller.signal.aborted)controller.abort(operation.signal.reason);
3026
+ };
3027
+ if(operation.signal?.aborted)forwardAbort();
3028
+ else operation.signal?.addEventListener('abort',forwardAbort,{once:true});
3029
+ const runtime=this;
3030
+ const scheduled=this.#browserSpeechTransition.then(
3031
+ async function runBrowserSpeechDisposal(){
3032
+ let normalized=null;
3033
+ let descriptor=null;
3034
+ let changed=false;
3035
+ let committed=false;
3036
+ try{
3037
+ runtime.#assertBrowserSpeechOperation(controller,generation);
3038
+ const activeRecord=runtime.#browserSpeechConfigurationRecord;
3039
+ const records=new Set(runtime.#browserSpeechRetiredRecords);
3040
+ if(activeRecord)records.add(activeRecord);
3041
+ const eventRecord=activeRecord||records.values().next().value||null;
3042
+ if(!eventRecord
3043
+ &&runtime.#browserSpeechRetiredLegacyRecords.size===0){
3044
+ return false;
3045
+ }
3046
+ if(eventRecord){
3047
+ normalized=normalizeBrowserSpeechConfiguration(
3048
+ eventRecord.configuration
3049
+ );
3050
+ descriptor=eventRecord.descriptor;
3051
+ }
3052
+ if(activeRecord){
3053
+ if(!runtime.#browserSpeechRecordIsActive(activeRecord)){
3054
+ throw runtime.#browserSpeechProviderRouteOwnershipError(
3055
+ 'The configured browser speech provider or route ownership changed before disposal.'
3056
+ );
3057
+ }
3058
+ await runtime.#unloadSpeechProviderRolesForTransition(
3059
+ controller.signal,
3060
+ activeRecord.providers,
3061
+ activeRecord.managedRoles
3062
+ );
3063
+ runtime.#assertBrowserSpeechOperation(controller,generation);
3064
+ let removed;
3065
+ if(activeRecord.managedRoles.length===2){
3066
+ removed=runtime.#providerRuntime.replaceSpeechProviders({
3067
+ providers:{stt:null,tts:null},
3068
+ routes:runtime.#emptySpeechRoutes(),
3069
+ expectedProviders:activeRecord.providers
3070
+ });
3071
+ }else{
3072
+ const role=activeRecord.managedRoles[0];
3073
+ const currentRoutes=runtime.#currentSpeechRoutes();
3074
+ const emptyRoleRoutes=Object.freeze({
3075
+ default:null,
3076
+ localOnly:null
3077
+ });
3078
+ runtime.#providerRuntime.replaceSpeechProvider(
3079
+ role,
3080
+ {
3081
+ provider:null,
3082
+ routes:emptyRoleRoutes,
3083
+ expectedProvider:activeRecord.providers[role]
3084
+ }
3085
+ );
3086
+ removed=Object.freeze({
3087
+ routes:Object.freeze({
3088
+ stt:role==='stt'
3089
+ ?emptyRoleRoutes
3090
+ :currentRoutes.stt,
3091
+ tts:role==='tts'
3092
+ ?emptyRoleRoutes
3093
+ :currentRoutes.tts
3094
+ })
3095
+ });
3096
+ }
3097
+ runtime.#retireBrowserSpeechRegistration(
3098
+ activeRecord,
3099
+ activeRecord.managedRoles
3100
+ );
3101
+ runtime.#browserSpeechConfigurationRecord=null;
3102
+ changed=true;
3103
+ committed=true;
3104
+ try{
3105
+ runtime.#applySpeechPreferenceTuple(
3106
+ runtime.#tupleFromSpeechProviderRoutes(removed.routes)
3107
+ );
3108
+ }catch(error){
3109
+ throw aiBrowserSpeechError(
3110
+ AI_BROWSER_SPEECH_ERROR_CODES.routeViewUpdateRejected,
3111
+ AI_BROWSER_SPEECH_REASONS.routeViewUpdateRejected,
3112
+ 'The committed browser speech removal could not update the AI speech route view.',
3113
+ error,
3114
+ {committed:true}
3115
+ );
3116
+ }
3117
+ }
3118
+
3119
+ if(records.size)committed=true;
3120
+ for(const record of records){
3121
+ await runtime.#cleanupBrowserSpeechRecord(
3122
+ record,
3123
+ {signal:controller.signal,committed:true}
3124
+ );
3125
+ changed=true;
3126
+ }
3127
+ if(runtime.#browserSpeechRetiredLegacyRecords.size){
3128
+ await runtime.#cleanupRetiredLegacySpeechProviders({
3129
+ signal:controller.signal,
3130
+ committed:true
3131
+ });
3132
+ changed=true;
3133
+ }
3134
+ runtime.#assertBrowserSpeechOperation(
3135
+ controller,
3136
+ generation,
3137
+ {committed}
3138
+ );
3139
+ if(normalized){
3140
+ runtime.#publishBrowserSpeechEvent(
3141
+ AI_BROWSER_SPEECH_EVENT_TYPES.disposed,
3142
+ normalized,
3143
+ operationId,
3144
+ AI_BROWSER_SPEECH_REASONS.configurationDisposed,
3145
+ {descriptor}
3146
+ );
3147
+ }
3148
+ return changed;
3149
+ }catch(error){
3150
+ const failure=error?.committed===true
3151
+ ?error
3152
+ :(generation!==runtime.#browserSpeechGeneration
3153
+ ||controller.signal.aborted)
3154
+ ?runtime.#browserSpeechAbortError(
3155
+ controller,
3156
+ generation,
3157
+ {committed}
3158
+ )
3159
+ :error;
3160
+ if(normalized){
3161
+ runtime.#publishBrowserSpeechEvent(
3162
+ failure.name==='AbortError'
3163
+ ?AI_BROWSER_SPEECH_EVENT_TYPES.configurationCancelled
3164
+ :AI_BROWSER_SPEECH_EVENT_TYPES.configurationError,
3165
+ normalized,
3166
+ operationId,
3167
+ failure.reason
3168
+ ||AI_BROWSER_SPEECH_REASONS.providerDisposalRejected,
3169
+ {error:failure}
3170
+ );
3171
+ }
3172
+ throw failure;
3173
+ }finally{
3174
+ operation.signal?.removeEventListener('abort',forwardAbort);
3175
+ if(runtime.#browserSpeechController===controller){
3176
+ runtime.#browserSpeechController=null;
3177
+ runtime.#browserSpeechControllerRoles=Object.freeze([]);
3178
+ }
3179
+ }
3180
+ }
1451
3181
  );
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;
3182
+ this.#browserSpeechTransition=scheduled.then(
3183
+ function completeBrowserSpeechDisposalLane(){},
3184
+ function retainBrowserSpeechDisposalFailure(){}
3185
+ );
3186
+ return scheduled;
1459
3187
  }
1460
3188
 
1461
3189
  async startProviders(options){
1462
3190
  const normalized=normalizeAIStartupOptions(options);
1463
- this.muted=normalized.startMuted;
3191
+ const generation=++this.#speechControlGeneration;
3192
+ this.muted=true;
1464
3193
  if(normalized.startMuted){
1465
3194
  this.stopAudio();
1466
3195
  }
1467
- return this.#providerRuntime.start(normalized);
3196
+ const handle=await this.#providerRuntime.start(normalized);
3197
+ if(!normalized.startMuted){
3198
+ const runtime=this;
3199
+ handle.settled.then(
3200
+ function admitReadyStartupSpeech(){
3201
+ if(generation!==runtime.#speechControlGeneration)return;
3202
+ const status=runtime.#providerRuntime.status('tts');
3203
+ runtime.muted=!(status.state==='ready'&&status.loaded===true);
3204
+ },
3205
+ function retainFailClosedStartupSpeech(){
3206
+ if(generation===runtime.#speechControlGeneration){
3207
+ runtime.muted=true;
3208
+ }
3209
+ }
3210
+ );
3211
+ }
3212
+ return handle;
1468
3213
  }
1469
3214
 
1470
3215
  async setSpeechMuted(muted){
1471
3216
  if(typeof muted!=='boolean'){
1472
3217
  throw new TypeError('AI speech muted state must be a boolean.');
1473
3218
  }
1474
- this.muted=muted;
3219
+ const generation=++this.#speechControlGeneration;
3220
+ this.muted=true;
1475
3221
  if(muted){
1476
3222
  this.stopAudio();
1477
3223
  }
1478
3224
  if(!this.#usesProviderRuntime('tts',this.ttsService)){
3225
+ if(generation===this.#speechControlGeneration)this.muted=muted;
1479
3226
  return true;
1480
3227
  }
1481
3228
  await this.#providerRuntime.setSpeechMuted(muted);
3229
+ if(generation===this.#speechControlGeneration){
3230
+ const status=this.#providerRuntime.status('tts');
3231
+ this.muted=muted
3232
+ ||status.state!=='ready'
3233
+ ||status.loaded!==true;
3234
+ }
1482
3235
  return true;
1483
3236
  }
1484
3237
 
@@ -1624,7 +3377,11 @@ class AI {
1624
3377
  throw normalizeAIRequestAbort(signal.reason);
1625
3378
  }
1626
3379
  if(!response||typeof response.audioBase64!=='string'){
1627
- throw new TypeError('Arcane returned an invalid local speech response.');
3380
+ const error=new TypeError(
3381
+ 'The Arcane speech bridge returned invalid TTS audio.'
3382
+ );
3383
+ error.code='ARCANE_AI_TTS_NATIVE_AUDIO_INVALID';
3384
+ throw error;
1628
3385
  }
1629
3386
  return new Blob(
1630
3387
  [this.#base64ToBytes(response.audioBase64)],
@@ -1637,28 +3394,41 @@ class AI {
1637
3394
  }
1638
3395
 
1639
3396
  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
3397
+ let response;
3398
+ try{
3399
+ response=await fetch(
3400
+ this.urlTTS,
3401
+ {
3402
+ method:'POST',
3403
+ credentials,
3404
+ headers:this.#ttsHeaders[this.ttsService],
3405
+ body:JSON.stringify({
3406
+ model,
3407
+ voice,
3408
+ input,
3409
+ speed,
3410
+ response_format:responseFormat
3411
+ }),
3412
+ signal
3413
+ }
3414
+ );
3415
+ }catch(error){
3416
+ if(isAIRequestAbort(error,signal)){
3417
+ throw normalizeAIRequestAbort(error);
1658
3418
  }
1659
- );
3419
+ throw legacyAIProviderError(
3420
+ 'The configured TTS HTTP request failed.',
3421
+ 'ARCANE_AI_TTS_HTTP_REQUEST_FAILED',
3422
+ error
3423
+ );
3424
+ }
1660
3425
  if(!response.ok){
1661
- throw new Error(`Speech synthesis failed with status ${response.status}.`);
3426
+ const error=legacyAIProviderError(
3427
+ `The configured TTS HTTP response was rejected with status ${response.status}.`,
3428
+ 'ARCANE_AI_TTS_HTTP_RESPONSE_REJECTED'
3429
+ );
3430
+ error.status=response.status;
3431
+ throw error;
1662
3432
  }
1663
3433
  const contentType=response.headers.get('content-type')||this.audioType;
1664
3434
  return new Blob([await response.arrayBuffer()],{type:contentType});
@@ -3085,34 +4855,17 @@ class AI {
3085
4855
  }
3086
4856
 
3087
4857
  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
4858
  job.abortController=new AbortController();
3111
- const response=await this.#requestLegacySpeechSynthesis(
4859
+ const selection=this.#providerRuntime.selection('tts');
4860
+ const voice=selection?this.#providerSpeechVoice():null;
4861
+ const response=await this.fetchTTS(
3112
4862
  {
3113
- model:this.modelTTS,
4863
+ model:selection?.modelId||this.modelTTS,
3114
4864
  input:job.text,
3115
- responseFormat:this.audioFormat,
4865
+ ...(voice?{voice}:{}),
4866
+ responseFormat:selection
4867
+ ?this.#providerSpeechResponseFormat()
4868
+ :this.audioFormat,
3116
4869
  speed:this.voiceSpeed
3117
4870
  },
3118
4871
  job.abortController.signal
@@ -3234,7 +4987,19 @@ class AI {
3234
4987
  }
3235
4988
  }
3236
4989
 
3237
- throw new TypeError('Arcane returned an invalid provider speech response.');
4990
+ const error=new TypeError(
4991
+ 'The selected TTS provider returned invalid playable audio.'
4992
+ );
4993
+ error.code='ARCANE_AI_TTS_PROVIDER_AUDIO_INVALID';
4994
+ throw error;
4995
+ }
4996
+
4997
+ async #normalizeProviderSpeechBlob(response){
4998
+ if(response instanceof Blob){
4999
+ return response;
5000
+ }
5001
+ const normalized=await this.#normalizeProviderSpeechAudio(response);
5002
+ return new Blob(normalized.chunks,{type:normalized.type||this.audioType});
3238
5003
  }
3239
5004
 
3240
5005
  #getSpeechAudioContext(){
@@ -3252,33 +5017,190 @@ class AI {
3252
5017
  return this.audioContext;
3253
5018
  }
3254
5019
 
5020
+ async fetchTTS(payload={},signal=null){
5021
+ this.#assertServiceConfigured(this.ttsService,'tts');
5022
+ if(!payload
5023
+ ||typeof payload!=='object'
5024
+ ||Array.isArray(payload)
5025
+ ||![Object.prototype,null].includes(Object.getPrototypeOf(payload))){
5026
+ const error=new TypeError('AI.fetchTTS requires a speech request object.');
5027
+ error.code='ARCANE_AI_TTS_REQUEST_INVALID';
5028
+ throw error;
5029
+ }
5030
+ const descriptors=Object.getOwnPropertyDescriptors(payload);
5031
+ const acceptedKeys=new Set([
5032
+ 'model',
5033
+ 'voice',
5034
+ 'input',
5035
+ 'responseFormat',
5036
+ 'speed'
5037
+ ]);
5038
+ for(const key of Reflect.ownKeys(descriptors)){
5039
+ if(typeof key==='symbol'
5040
+ ||!acceptedKeys.has(key)
5041
+ ||!Object.hasOwn(descriptors[key],'value')){
5042
+ const error=new TypeError(
5043
+ 'AI.fetchTTS accepts only model, voice, input, responseFormat, and speed data properties.'
5044
+ );
5045
+ error.code='ARCANE_AI_TTS_REQUEST_INVALID';
5046
+ throw error;
5047
+ }
5048
+ }
5049
+ if(signal&&(
5050
+ typeof signal.aborted!=='boolean'
5051
+ ||typeof signal.addEventListener!=='function'
5052
+ ||typeof signal.removeEventListener!=='function'
5053
+ )){
5054
+ const error=new TypeError('AI.fetchTTS signal must be an AbortSignal.');
5055
+ error.code='ARCANE_AI_TTS_SIGNAL_INVALID';
5056
+ throw error;
5057
+ }
5058
+ if(signal?.aborted){
5059
+ throw normalizeAIRequestAbort(signal.reason);
5060
+ }
5061
+
5062
+ const input=descriptors.input?.value;
5063
+ if(typeof input!=='string'||!input.trim()){
5064
+ const error=new TypeError('AI.fetchTTS input must be nonempty text.');
5065
+ error.code='ARCANE_AI_TTS_INPUT_INVALID';
5066
+ throw error;
5067
+ }
5068
+ const selection=this.#providerRuntime.selection('tts');
5069
+ const requestedModel=descriptors.model?.value;
5070
+ if(requestedModel!==undefined
5071
+ &&(typeof requestedModel!=='string'
5072
+ ||requestedModel.trim()!==requestedModel
5073
+ ||!requestedModel)){
5074
+ const error=new TypeError(
5075
+ 'AI.fetchTTS model must be a nonempty trimmed string when provided.'
5076
+ );
5077
+ error.code='ARCANE_AI_TTS_MODEL_INVALID';
5078
+ throw error;
5079
+ }
5080
+ const model=requestedModel
5081
+ ||selection?.modelId
5082
+ ||this.modelTTS
5083
+ ||'';
5084
+ if(!model){
5085
+ const error=new TypeError('AI.fetchTTS model must be selected explicitly.');
5086
+ error.code='ARCANE_AI_TTS_MODEL_REQUIRED';
5087
+ throw error;
5088
+ }
5089
+ if(selection&&model!==selection.modelId){
5090
+ const error=new TypeError(
5091
+ 'AI.fetchTTS model must match the admitted TTS route.'
5092
+ );
5093
+ error.code='ARCANE_AI_TTS_MODEL_SELECTION_MISMATCH';
5094
+ throw error;
5095
+ }
5096
+ const requestedVoice=descriptors.voice?.value;
5097
+ if(requestedVoice!==undefined
5098
+ &&(typeof requestedVoice!=='string'
5099
+ ||requestedVoice.trim()!==requestedVoice
5100
+ ||!requestedVoice)){
5101
+ const error=new TypeError(
5102
+ 'AI.fetchTTS voice must be a nonempty trimmed string when provided.'
5103
+ );
5104
+ error.code='ARCANE_AI_TTS_VOICE_INVALID';
5105
+ throw error;
5106
+ }
5107
+ const voice=requestedVoice
5108
+ ?requestedVoice
5109
+ :selection
5110
+ ?this.#providerSpeechVoice()
5111
+ :this.#legacySpeechDefaultVoice('tts',this.ttsService);
5112
+ if(!voice){
5113
+ const error=new TypeError(
5114
+ 'AI.fetchTTS requires a caller- or model-catalog-admitted voice.'
5115
+ );
5116
+ error.code='ARCANE_AI_TTS_VOICE_REQUIRED';
5117
+ throw error;
5118
+ }
5119
+ const requestedResponseFormat=descriptors.responseFormat?.value;
5120
+ if(requestedResponseFormat!==undefined
5121
+ &&(typeof requestedResponseFormat!=='string'
5122
+ ||requestedResponseFormat.trim()!==requestedResponseFormat
5123
+ ||!requestedResponseFormat)){
5124
+ const error=new TypeError(
5125
+ 'AI.fetchTTS responseFormat must be a nonempty trimmed string when provided.'
5126
+ );
5127
+ error.code='ARCANE_AI_TTS_RESPONSE_FORMAT_INVALID';
5128
+ throw error;
5129
+ }
5130
+ const responseFormat=requestedResponseFormat
5131
+ ||(selection?this.#providerSpeechResponseFormat():this.audioFormat)
5132
+ ||'';
5133
+ if(!responseFormat){
5134
+ const error=new TypeError('AI.fetchTTS responseFormat must be nonempty.');
5135
+ error.code='ARCANE_AI_TTS_RESPONSE_FORMAT_INVALID';
5136
+ throw error;
5137
+ }
5138
+ const speed=descriptors.speed
5139
+ ?Number(descriptors.speed.value)
5140
+ :this.voiceSpeed;
5141
+ if(!Number.isFinite(speed)||speed<=0){
5142
+ const error=new RangeError('AI.fetchTTS speed must be a positive number.');
5143
+ error.code='ARCANE_AI_TTS_SPEED_INVALID';
5144
+ throw error;
5145
+ }
5146
+
5147
+ if(selection){
5148
+ const response=await this.#providerRuntime.request(
5149
+ 'tts',
5150
+ {
5151
+ operation:'synthesize',
5152
+ payload:{model,voice,input,responseFormat,speed},
5153
+ localOnly:false,
5154
+ signal
5155
+ }
5156
+ );
5157
+ if(signal?.aborted)throw normalizeAIRequestAbort(signal.reason);
5158
+ const audio=await this.#normalizeProviderSpeechBlob(response);
5159
+ if(signal?.aborted)throw normalizeAIRequestAbort(signal.reason);
5160
+ return audio;
5161
+ }
5162
+
5163
+ return this.#requestLegacySpeechSynthesis(
5164
+ {model,voice,input,responseFormat,speed},
5165
+ signal
5166
+ );
5167
+ }
5168
+
3255
5169
  async fetchSTT(
3256
5170
  audioFile,
3257
5171
  responseHandler=(text='')=>{},
3258
5172
  signal=null
3259
5173
  ){
3260
5174
  this.#assertServiceConfigured(this.sttService,'stt');
5175
+ if(typeof responseHandler!=='function'){
5176
+ const error=new TypeError('AI.fetchSTT responseHandler must be a function.');
5177
+ error.code='ARCANE_AI_STT_RESPONSE_HANDLER_INVALID';
5178
+ throw error;
5179
+ }
3261
5180
  if(signal&&(
3262
5181
  typeof signal.aborted!=='boolean'
3263
5182
  ||typeof signal.addEventListener!=='function'
5183
+ ||typeof signal.removeEventListener!=='function'
3264
5184
  )){
3265
- throw new TypeError('AI request signal must be an AbortSignal.');
5185
+ const error=new TypeError('AI.fetchSTT signal must be an AbortSignal.');
5186
+ error.code='ARCANE_AI_STT_SIGNAL_INVALID';
5187
+ throw error;
3266
5188
  }
3267
5189
  if(signal?.aborted){
3268
- throw normalizeAIRequestAbort();
5190
+ throw normalizeAIRequestAbort(signal.reason);
3269
5191
  }
3270
5192
 
3271
5193
  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
5194
  const response=await this.#providerRuntime.request(
3276
5195
  'stt',
3277
5196
  {
3278
5197
  operation:'transcribe',
3279
5198
  payload:{
3280
5199
  audio:audioFile,
3281
- mimeType:String(audioFile.type||'audio/webm'),
5200
+ mimeType:typeof Blob==='function'
5201
+ &&audioFile instanceof Blob
5202
+ ?String(audioFile.type||'audio/webm')
5203
+ :'audio/webm',
3282
5204
  model:this.#providerRuntime.selection('stt')?.modelId
3283
5205
  },
3284
5206
  localOnly:false,
@@ -3289,11 +5211,15 @@ class AI {
3289
5211
  ?response
3290
5212
  :response?.text;
3291
5213
  if(typeof text!=='string'){
3292
- throw new TypeError(
5214
+ const error=new TypeError(
3293
5215
  'Arcane returned an invalid provider speech transcription.'
3294
5216
  );
5217
+ error.code='ARCANE_AI_STT_PROVIDER_TRANSCRIPT_INVALID';
5218
+ throw error;
3295
5219
  }
5220
+ if(signal?.aborted)throw normalizeAIRequestAbort(signal.reason);
3296
5221
  await responseHandler(text);
5222
+ if(signal?.aborted)throw normalizeAIRequestAbort(signal.reason);
3297
5223
  return text;
3298
5224
  }
3299
5225
 
@@ -3305,7 +5231,9 @@ class AI {
3305
5231
  },
3306
5232
  signal
3307
5233
  );
5234
+ if(signal?.aborted)throw normalizeAIRequestAbort(signal.reason);
3308
5235
  await responseHandler(text);
5236
+ if(signal?.aborted)throw normalizeAIRequestAbort(signal.reason);
3309
5237
  return text;
3310
5238
  }
3311
5239
 
@@ -3659,13 +5587,70 @@ class AI {
3659
5587
  }
3660
5588
  }
3661
5589
 
3662
- window.addEventListener(
3663
- 'user-entity-loaded',
3664
- instantiateAI
3665
- );
5590
+ installAIUserReadyRegistration();
5591
+
5592
+ function installAIUserReadyRegistration(){
5593
+ if(window.user?.ready){
5594
+ instantiateAI();
5595
+ return null;
5596
+ }
3666
5597
 
3667
- if(window.user?.ready){
3668
- instantiateAI();
5598
+ const existingDescriptor=Object.getOwnPropertyDescriptor(
5599
+ globalThis,
5600
+ AI_USER_READY_REGISTRATION_KEY
5601
+ );
5602
+ if(existingDescriptor){
5603
+ const existing=Object.hasOwn(existingDescriptor,'value')
5604
+ ?existingDescriptor.value
5605
+ :null;
5606
+ if(existing?.protocol!==AI_USER_READY_REGISTRATION_PROTOCOL
5607
+ ||typeof existing.dispose!=='function'){
5608
+ throw aiInitializationError(
5609
+ AI_INITIALIZATION_ERROR_CODES.userReadyRegistrationCollision,
5610
+ AI_INITIALIZATION_REASONS.userReadyRegistrationCollision,
5611
+ 'The AI user-readiness registration collides with an incompatible realm owner.'
5612
+ );
5613
+ }
5614
+ return existing;
5615
+ }
5616
+
5617
+ let registration;
5618
+ let unsubscribe=null;
5619
+ function disposeAIUserReadyRegistration(){
5620
+ unsubscribe?.();
5621
+ unsubscribe=null;
5622
+ const descriptor=Object.getOwnPropertyDescriptor(
5623
+ globalThis,
5624
+ AI_USER_READY_REGISTRATION_KEY
5625
+ );
5626
+ if(descriptor?.value===registration){
5627
+ delete globalThis[AI_USER_READY_REGISTRATION_KEY];
5628
+ }
5629
+ }
5630
+ registration=Object.freeze({
5631
+ protocol:AI_USER_READY_REGISTRATION_PROTOCOL,
5632
+ dispose:disposeAIUserReadyRegistration
5633
+ });
5634
+ unsubscribe=arcaneEvents.subscribe(
5635
+ 'user-entity-loaded',
5636
+ function initializeAIFromCanonicalUser(event){
5637
+ if(event?.detail?.user&&event.detail.user!==window.user)return;
5638
+ if(!window.user?.ready)return;
5639
+ registration.dispose();
5640
+ instantiateAI(event);
5641
+ }
5642
+ );
5643
+ Object.defineProperty(
5644
+ globalThis,
5645
+ AI_USER_READY_REGISTRATION_KEY,
5646
+ {
5647
+ value:registration,
5648
+ configurable:true,
5649
+ enumerable:false,
5650
+ writable:false
5651
+ }
5652
+ );
5653
+ return registration;
3669
5654
  }
3670
5655
 
3671
5656
  function instantiateAI(event) {
@@ -3694,13 +5679,7 @@ function instantiateAI(event) {
3694
5679
 
3695
5680
  window.ai.ready=true;
3696
5681
 
3697
- const aiReady=new CustomEvent(
3698
- 'ai-ready', {
3699
- detail: { db: window.ai }
3700
- }
3701
- );
3702
-
3703
- window.dispatchEvent(aiReady);
5682
+ window.ai[AI_PUBLISH_READY]();
3704
5683
 
3705
5684
  }
3706
5685
  }