arcane-os 0.33.5 → 0.34.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.
@@ -9,6 +9,7 @@ import {
9
9
  projectArcaneDOMEvent
10
10
  } from 'arcane-os/event-manager';
11
11
  import {getAIPreferencesForRuntime} from './AIPreferenceRuntime.js';
12
+ import {subscribeAIRuntimeState} from './AIRuntimeState.js';
12
13
  import {
13
14
  AI_MODEL_AUTHORITY_PROTOCOL,
14
15
  AI_PROVIDER_PROTOCOL,
@@ -1171,8 +1172,11 @@ class AI {
1171
1172
  #builtInLLMProviders=new Map();
1172
1173
  #builtInLLMReadiness=Promise.resolve(null);
1173
1174
  #builtInSpeechProviders=new Map();
1175
+ #configuredSpeechProviders=new Map();
1174
1176
  #builtInSpeechReadiness=Promise.resolve(null);
1175
1177
  #speechControlGeneration=0;
1178
+ #speechActivation=null;
1179
+ #speechProviderConfiguration=null;
1176
1180
  #speechFailureSequence=0;
1177
1181
  #speechDiagnosticSequence=0;
1178
1182
  #speechJobSequence=0;
@@ -1233,6 +1237,10 @@ class AI {
1233
1237
  return this.#providerRuntime;
1234
1238
  }
1235
1239
 
1240
+ get speechActivationPending(){
1241
+ return this.#speechActivation!==null;
1242
+ }
1243
+
1236
1244
  [AI_PUBLISH_READY](){
1237
1245
  const operationId=`${this.#events.instanceId}:ready:1`;
1238
1246
  const reason=AI_INITIALIZATION_REASONS.initialized;
@@ -2056,10 +2064,6 @@ class AI {
2056
2064
  }
2057
2065
  return value;
2058
2066
  });
2059
- next[1]='LOCAL_SPEACH';
2060
- next[2]='LOCAL_SPEACH';
2061
- next[4]='LOCAL_SPEACH';
2062
- next[5]='LOCAL_SPEACH';
2063
2067
  return completeValue(next);
2064
2068
  }
2065
2069
 
@@ -2075,21 +2079,12 @@ class AI {
2075
2079
  }
2076
2080
 
2077
2081
  #assertDeviceSpeechConfiguration(configuration){
2078
- for(const role of ['stt','tts']){
2079
- for(const routeName of ['default','localOnly']){
2080
- const selection=configuration?.[role]?.[routeName];
2081
- if(selection&&selection.localOnly!==true){
2082
- const operation=role==='stt'
2083
- ?'Speech recognition'
2084
- :'Speech synthesis';
2085
- const error=new TypeError(
2086
- `${operation} supports on-device providers only.`
2087
- );
2088
- error.code=role==='stt'
2089
- ?'AI_STT_DEVICE_ONLY'
2090
- :'AI_TTS_DEVICE_ONLY';
2091
- throw error;
2092
- }
2082
+ for(const routeName of ['default','localOnly']){
2083
+ const selection=configuration?.stt?.[routeName];
2084
+ if(selection&&selection.localOnly!==true){
2085
+ const error=new TypeError('Speech recognition supports on-device providers only.');
2086
+ error.code='AI_STT_DEVICE_ONLY';
2087
+ throw error;
2093
2088
  }
2094
2089
  }
2095
2090
  }
@@ -2537,6 +2532,8 @@ class AI {
2537
2532
 
2538
2533
  #invalidateSpeechControl(){
2539
2534
  this.#speechControlGeneration+=1;
2535
+ this.#speechActivation?.controller.abort();
2536
+ this.#speechActivation=null;
2540
2537
  this.muted=true;
2541
2538
  this.stopAudio();
2542
2539
  }
@@ -2601,7 +2598,10 @@ class AI {
2601
2598
  expectedProviders[role]=null;
2602
2599
  continue;
2603
2600
  }
2604
- const record=this.#builtInSpeechProviders.get(
2601
+ const configured=this.#configuredSpeechProviders.get(role);
2602
+ const record=configured?.providerId===selection.providerId
2603
+ ?configured
2604
+ :this.#builtInSpeechProviders.get(
2605
2605
  this.#builtInSpeechProviderKey(role,selection.providerId)
2606
2606
  );
2607
2607
  if(!record
@@ -2615,7 +2615,7 @@ class AI {
2615
2615
  continue;
2616
2616
  }
2617
2617
  throw this.#browserSpeechProviderRouteOwnershipError(
2618
- `The selected ${role} route is not owned by the replaceable built-in AI speech boundary.`
2618
+ `The selected ${role} route is not owned by this AI speech configuration.`
2619
2619
  );
2620
2620
  }
2621
2621
  expectedProviders[role]=record.provider;
@@ -2649,6 +2649,9 @@ class AI {
2649
2649
  if(this.#builtInSpeechProviders.get(key)===record){
2650
2650
  this.#builtInSpeechProviders.delete(key);
2651
2651
  }
2652
+ if(this.#configuredSpeechProviders.get(record.role)===record){
2653
+ this.#configuredSpeechProviders.delete(record.role);
2654
+ }
2652
2655
  this.#browserSpeechRetiredBuiltInRecords.delete(record);
2653
2656
  }catch(error){
2654
2657
  failures.push(error);
@@ -2658,12 +2661,12 @@ class AI {
2658
2661
  throw aiBrowserSpeechError(
2659
2662
  AI_BROWSER_SPEECH_ERROR_CODES.providerDisposalRejected,
2660
2663
  AI_BROWSER_SPEECH_REASONS.providerDisposalRejected,
2661
- 'The replaced built-in speech providers could not be disposed.',
2664
+ 'The replaced speech providers could not be disposed.',
2662
2665
  failures.length===1
2663
2666
  ?failures[0]
2664
2667
  :new AggregateError(
2665
2668
  failures,
2666
- 'Multiple replaced built-in speech provider disposals were rejected.'
2669
+ 'Multiple replaced speech provider disposals were rejected.'
2667
2670
  ),
2668
2671
  {committed}
2669
2672
  );
@@ -2673,17 +2676,21 @@ class AI {
2673
2676
 
2674
2677
  #assertSynchronousBrowserSpeechSupersession(method){
2675
2678
  if(!this.#browserSpeechConfigurationRecord
2676
- &&this.#browserSpeechRetiredRecords.size===0){
2679
+ &&this.#browserSpeechRetiredRecords.size===0
2680
+ &&this.#configuredSpeechProviders.size===0){
2677
2681
  return;
2678
2682
  }
2679
2683
  throw aiBrowserSpeechError(
2680
2684
  AI_BROWSER_SPEECH_ERROR_CODES.asyncTransitionRequired,
2681
2685
  AI_BROWSER_SPEECH_REASONS.asyncTransitionRequired,
2682
- `${method} cannot replace SDK-owned browser speech providers synchronously; await AI.disposeBrowserSpeech() or use an asynchronous transition method.`
2686
+ `${method} cannot replace SDK-owned speech providers synchronously; use configureSpeechProvider, disposeBrowserSpeech, or an asynchronous transition method.`
2683
2687
  );
2684
2688
  }
2685
2689
 
2686
2690
  async #supersedeBrowserSpeechForRouteChange(){
2691
+ for(const role of [...this.#configuredSpeechProviders.keys()]){
2692
+ await this.configureSpeechProvider(role,null);
2693
+ }
2687
2694
  if(!this.#browserSpeechConfigurationRecord
2688
2695
  &&this.#browserSpeechRetiredRecords.size===0){
2689
2696
  return false;
@@ -3416,6 +3423,183 @@ class AI {
3416
3423
  return descriptor;
3417
3424
  }
3418
3425
 
3426
+ configureSpeechProvider(role,provider,options={}){
3427
+ if(role!=='stt'&&role!=='tts'){
3428
+ throw new TypeError('AI.configureSpeechProvider requires stt or tts.');
3429
+ }
3430
+ if(provider!==null&&provider?.role!==role){
3431
+ throw new TypeError('The speech provider must implement the selected role.');
3432
+ }
3433
+ if(role==='stt'&&provider&&provider.localOnly!==true){
3434
+ const error=new TypeError('Speech recognition supports on-device providers only.');
3435
+ error.code='AI_STT_DEVICE_ONLY';
3436
+ throw error;
3437
+ }
3438
+ const {modelId,signal,expectedProvider}=options;
3439
+ const runtime=this;
3440
+ const speechConfiguration=role==='tts'
3441
+ ?{generation:this.#speechControlGeneration,promise:null}
3442
+ :null;
3443
+ if(speechConfiguration)this.#speechProviderConfiguration=speechConfiguration;
3444
+ // Use the existing configuration lane. Unlike a replacement of the
3445
+ // same browser configuration, a disjoint role must not cancel its peer.
3446
+ const scheduled=this.#browserSpeechTransition.then(
3447
+ async function configureSelectedSpeechRole(){
3448
+ let committed=false;
3449
+ let candidateDisposalAttempted=false;
3450
+ const alreadyOwned=provider
3451
+ &&runtime.#providerRuntime.ownsProvider(role,provider);
3452
+ try{
3453
+ signal?.throwIfAborted();
3454
+ if(expectedProvider!==undefined
3455
+ &&(!runtime.#providerRuntime.ownsProvider(role,expectedProvider)
3456
+ ||runtime.#providerRuntime.selection(role)?.providerId
3457
+ !==expectedProvider?.id)){
3458
+ if(provider&&!alreadyOwned){
3459
+ candidateDisposalAttempted=true;
3460
+ await provider.dispose({role});
3461
+ }
3462
+ return false;
3463
+ }
3464
+ const previousBrowser=runtime.#browserSpeechConfigurationRecord;
3465
+ if(previousBrowser&&!runtime.#browserSpeechRecordIsActive(previousBrowser)){
3466
+ throw runtime.#browserSpeechProviderRouteOwnershipError(
3467
+ 'Browser speech ownership changed before single-role replacement.'
3468
+ );
3469
+ }
3470
+ const boundary=runtime.#browserSpeechReplacementBoundary(
3471
+ previousBrowser,[role]
3472
+ );
3473
+ const selectedModelId=provider
3474
+ ?modelId??provider.catalog()[0]?.id
3475
+ :null;
3476
+ if(provider&&!selectedModelId){
3477
+ throw new TypeError('A speech provider model must be selected.');
3478
+ }
3479
+ const selection=provider
3480
+ ?{providerId:provider.id,modelId:selectedModelId,localOnly:provider.localOnly}
3481
+ :null;
3482
+ const routes={
3483
+ default:selection,
3484
+ localOnly:selection?.localOnly===true?selection:null
3485
+ };
3486
+ if(provider===boundary.expectedProviders[role]
3487
+ &&runtime.#sameBrowserSpeechSelection(
3488
+ runtime.#providerRuntime.selection(role),selection
3489
+ )){
3490
+ return selection;
3491
+ }
3492
+ const priorSelection=runtime.#providerRuntime.selection(role);
3493
+ const hydratesPendingSelection=provider
3494
+ &&boundary.expectedProviders[role]===null
3495
+ &&priorSelection?.providerId===provider.id
3496
+ &&priorSelection.modelId===selectedModelId;
3497
+ const preservesNewerActivation=speechConfiguration
3498
+ &&runtime.#speechActivation?.configuration
3499
+ &&runtime.#speechActivation.generation>speechConfiguration.generation;
3500
+ if(role==='tts'&&!hydratesPendingSelection&&!preservesNewerActivation){
3501
+ runtime.#invalidateSpeechControl();
3502
+ }
3503
+ await runtime.#unloadSpeechProviderRolesForTransition(
3504
+ signal,boundary.expectedProviders,[role]
3505
+ );
3506
+ signal?.throwIfAborted();
3507
+ const replacement=runtime.#providerRuntime.replaceSpeechProvider(
3508
+ role,{
3509
+ provider,routes,
3510
+ expectedProvider:boundary.expectedProviders[role]
3511
+ }
3512
+ );
3513
+ committed=true;
3514
+ if(provider){
3515
+ runtime.#configuredSpeechProviders.set(role,{
3516
+ role,providerId:provider.id,provider,
3517
+ unregister:replacement.unregister
3518
+ });
3519
+ }else{
3520
+ runtime.#configuredSpeechProviders.delete(role);
3521
+ }
3522
+ for(const record of boundary.builtInRecords){
3523
+ if(record.provider!==provider){
3524
+ runtime.#browserSpeechRetiredBuiltInRecords.add(record);
3525
+ }
3526
+ }
3527
+ if(previousBrowser?.managedRoles.includes(role)){
3528
+ const remaining=previousBrowser.managedRoles.filter(
3529
+ function retainOtherSpeechRole(candidate){return candidate!==role;}
3530
+ );
3531
+ runtime.#retireBrowserSpeechRegistration(previousBrowser,[role]);
3532
+ if(remaining.length){
3533
+ const configuration={...previousBrowser.configuration};
3534
+ delete configuration[role];
3535
+ runtime.#browserSpeechConfigurationRecord={
3536
+ ...previousBrowser,
3537
+ configuration,
3538
+ configurationByRole:{...previousBrowser.configurationByRole,[role]:null},
3539
+ descriptor:{...previousBrowser.descriptor,[role]:null},
3540
+ providers:{...previousBrowser.providers,[role]:null},
3541
+ routes:runtime.#currentSpeechRoutes(),
3542
+ managedRoles:remaining,
3543
+ candidateRoles:remaining,
3544
+ unregisters:{...previousBrowser.unregisters,[role]:null},
3545
+ registrationState:{...previousBrowser.registrationState,[role]:false},
3546
+ retirementState:{stt:false,tts:false}
3547
+ };
3548
+ }else{
3549
+ runtime.#browserSpeechConfigurationRecord=null;
3550
+ }
3551
+ }
3552
+ runtime.#applySpeechPreferenceTuple(
3553
+ runtime.#tupleFromSpeechProviderRoutes(runtime.#currentSpeechRoutes())
3554
+ );
3555
+ await runtime.#cleanupRetiredBuiltInSpeechProviders({committed:true});
3556
+ if(previousBrowser?.retirementState[role]){
3557
+ await runtime.#cleanupBrowserSpeechRecord(previousBrowser,{committed:true});
3558
+ }
3559
+ return selection;
3560
+ }catch(error){
3561
+ if(committed){
3562
+ const committedError=error instanceof Error
3563
+ ?error
3564
+ :new Error('Speech provider cleanup failed.',{cause:error});
3565
+ committedError.committed=true;
3566
+ throw committedError;
3567
+ }
3568
+ if(role==='tts'&&provider
3569
+ &&runtime.#providerRuntime.selection('tts')?.providerId===provider.id
3570
+ &&!runtime.#providerRuntime.hasProvider('tts',provider.id)
3571
+ &&!(runtime.#speechActivation?.configuration
3572
+ &&runtime.#speechActivation.generation>speechConfiguration.generation)){
3573
+ runtime.#invalidateSpeechControl();
3574
+ }
3575
+ if(provider&&!alreadyOwned&&!candidateDisposalAttempted
3576
+ &&!runtime.#providerRuntime.ownsProvider(role,provider)){
3577
+ try{
3578
+ await provider.dispose({role});
3579
+ }catch(cleanupError){
3580
+ throw new AggregateError(
3581
+ [error,cleanupError],
3582
+ 'Speech configuration and candidate disposal failed.'
3583
+ );
3584
+ }
3585
+ }
3586
+ throw error;
3587
+ }finally{
3588
+ if(speechConfiguration
3589
+ &&runtime.#speechProviderConfiguration===speechConfiguration){
3590
+ runtime.#speechProviderConfiguration=null;
3591
+ }
3592
+ }
3593
+ }
3594
+ );
3595
+ if(speechConfiguration)speechConfiguration.promise=scheduled;
3596
+ this.#browserSpeechTransition=scheduled.then(
3597
+ function completeSingleSpeechRoleConfiguration(){},
3598
+ function retainSingleSpeechRoleConfigurationFailure(){}
3599
+ );
3600
+ return scheduled;
3601
+ }
3602
+
3419
3603
  configureBrowserSpeech(configuration,options={}){
3420
3604
  this.#traceSpeech('configureBrowserSpeech.call',{configuration,options});
3421
3605
  const normalized=normalizeBrowserSpeechConfiguration(configuration);
@@ -3719,6 +3903,8 @@ class AI {
3719
3903
  async startProviders(options){
3720
3904
  const normalized=normalizeAIStartupOptions(options);
3721
3905
  const generation=++this.#speechControlGeneration;
3906
+ this.#speechActivation?.controller.abort();
3907
+ this.#speechActivation=null;
3722
3908
  this.muted=true;
3723
3909
  if(normalized.startMuted){
3724
3910
  this.stopAudio();
@@ -3742,30 +3928,116 @@ class AI {
3742
3928
  return handle;
3743
3929
  }
3744
3930
 
3931
+ #waitForSelectedSpeechProvider(signal){
3932
+ const runtime=this;
3933
+ const selected=this.#providerRuntime.selection('tts');
3934
+ if(!selected)return Promise.resolve(false);
3935
+ return new Promise(function observeSelectedSpeechRegistration(resolve,reject){
3936
+ let unsubscribe=null;
3937
+ let settled=false;
3938
+ function finish(value,error){
3939
+ if(settled)return;
3940
+ settled=true;
3941
+ signal.removeEventListener('abort',cancel);
3942
+ unsubscribe?.();
3943
+ if(error)reject(error);
3944
+ else resolve(value);
3945
+ }
3946
+ function cancel(){
3947
+ finish(false,signal.reason??new DOMException('Speech activation cancelled.','AbortError'));
3948
+ }
3949
+ function observe(snapshot){
3950
+ const current=runtime.#providerRuntime.selection('tts');
3951
+ if(current?.providerId!==selected.providerId
3952
+ ||current?.modelId!==selected.modelId){
3953
+ finish(false);
3954
+ }else if(runtime.#providerRuntime.hasProvider('tts',current.providerId)){
3955
+ finish(true);
3956
+ }else if(snapshot.roles.tts.state==='disposed'){
3957
+ finish(false);
3958
+ }else if(snapshot.roles.tts.state==='error'){
3959
+ finish(false,snapshot.roles.tts.error
3960
+ ??new Error('Speech provider registration failed.'));
3961
+ }
3962
+ }
3963
+ signal.addEventListener('abort',cancel,{once:true});
3964
+ if(signal.aborted){cancel();return;}
3965
+ unsubscribe=subscribeAIRuntimeState(observe);
3966
+ if(settled)unsubscribe();
3967
+ });
3968
+ }
3969
+
3745
3970
  async setSpeechMuted(muted){
3746
3971
  const callId=this.#traceSpeech('setSpeechMuted.call',{requestedMuted:muted});
3747
3972
  if(!is.boolean(muted)){
3748
3973
  throw new TypeError('AI speech muted state must be a boolean.');
3749
3974
  }
3975
+ const configuration=this.#speechProviderConfiguration;
3976
+ if(!muted&&this.#speechActivation){
3977
+ if(!configuration||this.#speechActivation.configuration===configuration){
3978
+ return this.#speechActivation.promise;
3979
+ }
3980
+ this.#invalidateSpeechControl();
3981
+ }
3750
3982
  const generation=++this.#speechControlGeneration;
3751
3983
  this.muted=true;
3752
3984
  if(muted){
3985
+ this.#speechActivation?.controller.abort();
3986
+ this.#speechActivation=null;
3753
3987
  this.stopAudio();
3754
3988
  }
3755
- if(!this.#usesProviderRuntime('tts',this.ttsService)){
3989
+ if(!this.#usesProviderRuntime('tts',this.ttsService)
3990
+ &&(muted||!configuration)){
3756
3991
  if(generation===this.#speechControlGeneration)this.muted=muted;
3757
3992
  this.#traceSpeech('setSpeechMuted.result',{callId,result:true});
3758
3993
  return true;
3759
3994
  }
3760
- await this.#providerRuntime.setSpeechMuted(muted);
3761
- if(generation===this.#speechControlGeneration){
3762
- const status=this.#providerRuntime.status('tts');
3763
- this.muted=muted
3764
- ||status.state!=='ready'
3765
- ||status.loaded!==true;
3995
+ if(muted){
3996
+ await this.#providerRuntime.setSpeechMuted(true);
3997
+ return true;
3766
3998
  }
3767
- this.#traceSpeech('setSpeechMuted.result',{callId,result:true});
3768
- return true;
3999
+ const runtime=this;
4000
+ // A pending route owns the next utterance. Release old-route audio now,
4001
+ // before callers can queue text for this explicit activation.
4002
+ if(configuration)this.stopAudio();
4003
+ const activation={controller:new AbortController(),promise:null,generation,configuration};
4004
+ this.#speechActivation=activation;
4005
+ activation.promise=Promise.resolve().then(async function activateRequestedSpeech(){
4006
+ try{
4007
+ if(configuration){
4008
+ const configured=await runtime.#waitForSpeechOperation(
4009
+ configuration.promise,activation.controller.signal
4010
+ );
4011
+ activation.controller.signal.throwIfAborted();
4012
+ if(!configured)return false;
4013
+ }
4014
+ // Selection may precede registration. Observe the owner's sticky
4015
+ // state rather than treating that ordinary waiting state as failure.
4016
+ await runtime.#waitForSpeechOperation(
4017
+ runtime.#browserSpeechTransition,activation.controller.signal
4018
+ );
4019
+ if(!await runtime.#waitForSelectedSpeechProvider(activation.controller.signal)){
4020
+ return false;
4021
+ }
4022
+ // A registration event can fire before its configuration finishes.
4023
+ await runtime.#waitForSpeechOperation(
4024
+ runtime.#browserSpeechTransition,activation.controller.signal
4025
+ );
4026
+ activation.controller.signal.throwIfAborted();
4027
+ await runtime.#providerRuntime.setSpeechMuted(false);
4028
+ if(runtime.#speechActivation!==activation)return false;
4029
+ const status=runtime.#providerRuntime.status('tts');
4030
+ runtime.muted=status.state!=='ready'||status.loaded!==true;
4031
+ return !runtime.muted;
4032
+ }catch(error){
4033
+ if(activation.controller.signal.aborted)return false;
4034
+ throw error;
4035
+ }finally{
4036
+ if(runtime.#speechActivation===activation)runtime.#speechActivation=null;
4037
+ runtime.#traceSpeech('setSpeechMuted.result',{callId,result:!runtime.muted});
4038
+ }
4039
+ });
4040
+ return activation.promise;
3769
4041
  }
3770
4042
 
3771
4043
  #nativeOllama(){
@@ -5934,7 +6206,7 @@ class AI {
5934
6206
  ){
5935
6207
  const callId=this.#traceSpeech('streamTTS.call',{text,end,options});
5936
6208
  for(const playback of this.#preparedSpeechPlaybacks) playback.stop();
5937
- if(this.muted){
6209
+ if(this.muted&&!this.speechActivationPending){
5938
6210
  if(end){
5939
6211
  this.audioMessageChunks='';
5940
6212
  this.#markdownSpeech.reset();
@@ -5963,7 +6235,9 @@ class AI {
5963
6235
  }
5964
6236
 
5965
6237
  try{
5966
- this.#assertServiceConfigured(this.ttsService,'tts');
6238
+ if(!this.speechActivationPending){
6239
+ this.#assertServiceConfigured(this.ttsService,'tts');
6240
+ }
5967
6241
  }catch(error){
5968
6242
  this.#traceSpeech('streamTTS.error',{callId,error});
5969
6243
  this.#publishTTSFailure(error,{
@@ -5999,7 +6273,7 @@ class AI {
5999
6273
  }
6000
6274
 
6001
6275
  #finishStreamingSpeech(){
6002
- if(this.muted){
6276
+ if(this.muted&&!this.speechActivationPending){
6003
6277
  for(const playback of this.#preparedSpeechPlaybacks) playback.stop();
6004
6278
  this.audioMessageChunks='';
6005
6279
  this.#markdownSpeech.reset();
@@ -6163,7 +6437,7 @@ class AI {
6163
6437
  const job={
6164
6438
  diagnosticId:++this.#speechJobSequence,
6165
6439
  diagnosticCallId:options.diagnosticCallId,
6166
- abortController:null,
6440
+ abortController:new AbortController(),
6167
6441
  audioBuffer:null,
6168
6442
  audioContext:null,
6169
6443
  generation,
@@ -6203,7 +6477,31 @@ class AI {
6203
6477
  return playback||preparation;
6204
6478
  }
6205
6479
 
6480
+ #waitForSpeechOperation(operation,signal){
6481
+ return new Promise(function observeSpeechOperation(resolve,reject){
6482
+ let settled=false;
6483
+ function finish(value,error){
6484
+ if(settled)return;
6485
+ settled=true;
6486
+ signal.removeEventListener('abort',cancel);
6487
+ if(error)reject(error);
6488
+ else resolve(value);
6489
+ }
6490
+ function cancel(){finish(false);}
6491
+ signal.addEventListener('abort',cancel,{once:true});
6492
+ if(signal.aborted){cancel();return;}
6493
+ operation.then(
6494
+ function speechOperationCompleted(value){finish(value);},
6495
+ function speechOperationFailed(error){finish(false,error);}
6496
+ );
6497
+ });
6498
+ }
6499
+
6206
6500
  async #prepareSpeechJob(job){
6501
+ const activation=this.#speechActivation;
6502
+ if(activation&&job.generation===this.speechGeneration){
6503
+ await this.#waitForSpeechOperation(activation.promise,job.abortController.signal);
6504
+ }
6207
6505
  if(job.generation!==this.speechGeneration||this.muted){
6208
6506
  return this.#cancelSpeechJob(job);
6209
6507
  }
@@ -6229,7 +6527,7 @@ class AI {
6229
6527
  }
6230
6528
 
6231
6529
  async #requestSpeechAudio(job){
6232
- job.abortController=new AbortController();
6530
+ job.abortController??=new AbortController();
6233
6531
  const selection=this.#providerRuntime.selection('tts');
6234
6532
  const voice=job.voice===undefined
6235
6533
  ?(selection?this.#providerSpeechVoice():null)