arcane-os 0.48.0 → 0.50.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.
@@ -1184,6 +1184,7 @@ class AI {
1184
1184
  #ttsSegmentation={...DEFAULT_TTS_SEGMENTATION};
1185
1185
  #preparedSpeechReadiness = null;
1186
1186
  #preparedSpeechPlaybacks = new Set();
1187
+ #nativeSpeechPlaybacks = new Set();
1187
1188
  #speechUnlockContext = null;
1188
1189
 
1189
1190
  #traceSpeech(phase,detail={}){
@@ -5940,6 +5941,11 @@ class AI {
5940
5941
  }
5941
5942
 
5942
5943
  prepareTTS({parts, storage = null, identity = null, signal = null, onState = null} = {}) {
5944
+ if(this.#providerSpeechModel()?.speech?.playback==='native'){
5945
+ const error=new Error('Native browser speech has no audio file to prepare or store; use prepareTTSPlayback.');
5946
+ error.code='ARCANE_AI_TTS_AUDIO_EXPORT_UNAVAILABLE';
5947
+ throw error;
5948
+ }
5943
5949
  if(!is.array(parts)) {
5944
5950
  throw new TypeError('AI.prepareTTS parts must be an array.');
5945
5951
  }
@@ -6460,6 +6466,8 @@ class AI {
6460
6466
  scheduledStart:null,
6461
6467
  sourceNode:null,
6462
6468
  state:'queued',
6469
+ nativePlayback:null,
6470
+ nativeControl:null,
6463
6471
  text,
6464
6472
  voice:options.voice,
6465
6473
  speed:options.speed,
@@ -6530,6 +6538,13 @@ class AI {
6530
6538
  return this.#cancelSpeechJob(job);
6531
6539
  }
6532
6540
 
6541
+ if(audio?.kind==='native-speech'){
6542
+ job.nativePlayback=audio;
6543
+ job.state='ready';
6544
+ this.#requestSpeechPlayback();
6545
+ return true;
6546
+ }
6547
+
6533
6548
  job.state='decoding';
6534
6549
  const audioContext=this.#getSpeechAudioContext();
6535
6550
  return this.playAudio(
@@ -6544,6 +6559,7 @@ class AI {
6544
6559
  async #requestSpeechAudio(job){
6545
6560
  job.abortController??=new AbortController();
6546
6561
  const selection=this.#providerRuntime.selection('tts');
6562
+ const native=this.#providerSpeechModel()?.speech?.playback==='native';
6547
6563
  const voice=job.voice===undefined
6548
6564
  ?(selection?this.#providerSpeechVoice():null)
6549
6565
  :job.voice;
@@ -6551,21 +6567,23 @@ class AI {
6551
6567
  model:selection?.modelId||this.modelTTS,
6552
6568
  input:job.text,
6553
6569
  ...(job.voice!==undefined||voice?{voice}:{}),
6554
- responseFormat:selection
6570
+ ...(!native?{responseFormat:selection
6555
6571
  ?this.#providerSpeechResponseFormat()
6556
- :this.audioFormat,
6572
+ :this.audioFormat}:{}),
6557
6573
  speed:job.speed===undefined?this.voiceSpeed:job.speed
6558
6574
  };
6559
6575
  this.#traceSpeechJob('generation.request',job,{payload,selection});
6560
- const response=await this.fetchTTS(
6576
+ const response=await this.prepareTTSPlayback(
6561
6577
  payload,
6562
6578
  job.abortController.signal,
6563
6579
  {speechInputPrepared:true}
6564
6580
  );
6565
- return this.#normalizeProviderSpeechAudio(response);
6581
+ return response?.kind==='native-speech'
6582
+ ?response
6583
+ :this.#normalizeProviderSpeechAudio(response);
6566
6584
  }
6567
6585
 
6568
- #providerSpeechVoice(){
6586
+ #providerSpeechModel(){
6569
6587
  const selection=this.#providerRuntime.selection('tts');
6570
6588
  if(!selection){
6571
6589
  return null;
@@ -6573,9 +6591,13 @@ class AI {
6573
6591
  const provider=this.#providerRuntime.catalog('tts').find(
6574
6592
  entry=>entry.providerId===selection.providerId
6575
6593
  );
6576
- const model=provider?.models.find(entry=>entry?.id===selection.modelId);
6594
+ return provider?.models.find(entry=>entry?.id===selection.modelId)??null;
6595
+ }
6596
+
6597
+ #providerSpeechVoice(){
6598
+ const model=this.#providerSpeechModel();
6577
6599
  return is.string(model?.defaultVoice)&&model.defaultVoice.trim()
6578
- ?model.defaultVoice.trim()
6600
+ ?model.speech?.playback==='native'?model.defaultVoice:model.defaultVoice.trim()
6579
6601
  :null;
6580
6602
  }
6581
6603
 
@@ -6709,7 +6731,38 @@ class AI {
6709
6731
  return this.audioContext;
6710
6732
  }
6711
6733
 
6734
+ async prepareTTSPlayback(payload={},signal=null,preparation={}){
6735
+ const model=this.#providerSpeechModel();
6736
+ if(model?.speech?.playback!=='native'){
6737
+ return this.fetchTTS(payload,signal,preparation);
6738
+ }
6739
+ this.#assertServiceConfigured(this.ttsService,'tts');
6740
+ if(signal?.aborted)throw normalizeAIRequestAbort(signal.reason);
6741
+ const selection=this.#providerRuntime.selection('tts');
6742
+ const request={
6743
+ ...payload,
6744
+ model:payload.model??selection.modelId,
6745
+ voice:payload.voice??this.#providerSpeechVoice(),
6746
+ speed:payload.speed??this.voiceSpeed
6747
+ };
6748
+ const playback=await this.#providerRuntime.request('tts',{
6749
+ operation:'synthesize',payload:request,localOnly:false,signal
6750
+ },{speechInputPrepared:true});
6751
+ if(signal?.aborted)throw normalizeAIRequestAbort(signal.reason);
6752
+ return {
6753
+ ...playback,
6754
+ play(options={}){
6755
+ return playback.play({...options,signal:options.signal??signal});
6756
+ }
6757
+ };
6758
+ }
6759
+
6712
6760
  async fetchTTS(payload={},signal=null,{speechInputPrepared=false}={}){
6761
+ if(this.#providerSpeechModel()?.speech?.playback==='native'){
6762
+ const error=new Error('Native browser speech plays through prepareTTSPlayback and does not export audio.');
6763
+ error.code='ARCANE_AI_TTS_AUDIO_EXPORT_UNAVAILABLE';
6764
+ throw error;
6765
+ }
6713
6766
  const callId=this.#traceSpeech('fetchTTS.call',{payload,aborted:signal?.aborted});
6714
6767
  try{
6715
6768
  this.#assertServiceConfigured(this.ttsService,'tts');
@@ -6958,6 +7011,7 @@ class AI {
6958
7011
  remainder:this.audioMessageChunks,queuedJobs:this.speechJobs.length
6959
7012
  });
6960
7013
  for(const playback of this.#preparedSpeechPlaybacks) playback.stop();
7014
+ for(const playback of this.#nativeSpeechPlaybacks) playback.stop();
6961
7015
  this.speechGeneration+=1;
6962
7016
  this.speechResumeAttempt+=1;
6963
7017
  this.speechResumePending=false;
@@ -6970,6 +7024,7 @@ class AI {
6970
7024
 
6971
7025
  for(const job of this.speechJobs){
6972
7026
  job.abortController?.abort();
7027
+ job.nativeControl?.stop();
6973
7028
  job.state='cancelled';
6974
7029
  this.#traceSpeechJob('queue.cancelled',job,{reason:'stopAudio',callId});
6975
7030
  job.resolvePlayback?.(false);
@@ -6997,7 +7052,7 @@ class AI {
6997
7052
  this.speechJobs.splice(0);
6998
7053
  this.sourceNodes.splice(0);
6999
7054
  this.currentSpeechJob=null;
7000
- this.isSpeaking=false;
7055
+ this.isSpeaking=this.#nativeSpeechPlaybacks.size>0;
7001
7056
  this.#traceSpeech('stopAudio.result',{callId,result:true});
7002
7057
  return true;
7003
7058
  }
@@ -7011,6 +7066,13 @@ class AI {
7011
7066
  return false;
7012
7067
  }
7013
7068
 
7069
+ if(!audioContext&&this.#providerSpeechModel()?.speech?.playback==='native'){
7070
+ const control=this.currentSpeechJob?.nativeControl;
7071
+ if(control&&control.resume()===false)return false;
7072
+ this.#requestSpeechPlayback();
7073
+ return true;
7074
+ }
7075
+
7014
7076
  if(fromUserGesture){
7015
7077
  this.#clearSpeechUnlock();
7016
7078
  }
@@ -7207,8 +7269,77 @@ class AI {
7207
7269
  );
7208
7270
  }
7209
7271
 
7272
+ async #completeNativeSpeechJob(job,played){
7273
+ if(job.generation!==this.speechGeneration||job.state!=='scheduled')return;
7274
+ if(!played){
7275
+ this.#cancelSpeechJob(job);
7276
+ this.#requestSpeechPlayback();
7277
+ return;
7278
+ }
7279
+ // Playback completion excludes the trailing gap, just as it does for buffers.
7280
+ job.resolvePlayback?.(true);
7281
+ job.resolvePlayback=null;
7282
+ if(job.pauseAfterMs>0){
7283
+ const signal=job.abortController.signal;
7284
+ await new Promise(function waitForNativeSpeechPause(resolve){
7285
+ let timer=null;
7286
+ function finish(){
7287
+ globalThis.clearTimeout(timer);
7288
+ signal.removeEventListener('abort',finish);
7289
+ resolve();
7290
+ }
7291
+ signal.addEventListener('abort',finish,{once:true});
7292
+ if(signal.aborted){finish();return;}
7293
+ timer=globalThis.setTimeout(finish,job.pauseAfterMs);
7294
+ });
7295
+ }
7296
+ if(job.generation!==this.speechGeneration||job.state!=='scheduled')return;
7297
+ this.nextSentance(job);
7298
+ }
7299
+
7300
+ #waitForPrecedingSpeechClock(job){
7301
+ const context=this.speechScheduleContext;
7302
+ const deadline=this.speechScheduleTime;
7303
+ if(!context||deadline<=context.currentTime)return Promise.resolve(true);
7304
+ const signal=job.abortController.signal;
7305
+ return new Promise(function waitForScheduledSpeechGap(resolve,reject){
7306
+ let timer=null;
7307
+ function finish(value,error=null){
7308
+ globalThis.clearTimeout(timer);
7309
+ signal.removeEventListener('abort',cancel);
7310
+ context.removeEventListener('statechange',schedule);
7311
+ if(error)reject(error);
7312
+ else resolve(value);
7313
+ }
7314
+ function cancel(){finish(false);}
7315
+ function schedule(){
7316
+ globalThis.clearTimeout(timer);
7317
+ if(signal.aborted){cancel();return;}
7318
+ const remaining=deadline-context.currentTime;
7319
+ if(remaining<=0){finish(true);return;}
7320
+ if(context.state==='closed'){
7321
+ finish(false,new Error('The preceding speech audio context closed before its queued pause ended.'));
7322
+ return;
7323
+ }
7324
+ // A suspended audio clock resumes through its owning state event.
7325
+ if(context.state==='running')timer=globalThis.setTimeout(schedule,remaining*1000);
7326
+ }
7327
+ signal.addEventListener('abort',cancel,{once:true});
7328
+ context.addEventListener('statechange',schedule);
7329
+ schedule();
7330
+ });
7331
+ }
7332
+
7333
+ #releaseNativeSpeechPlayback(control){
7334
+ this.#nativeSpeechPlaybacks.delete(control);
7335
+ this.isSpeaking=this.#nativeSpeechPlaybacks.size>0||this.speechJobs.some(
7336
+ function hasPlayingSpeech(job){return job.state==='scheduled';}
7337
+ );
7338
+ this.#requestSpeechPlayback();
7339
+ }
7340
+
7210
7341
  async #pumpSpeechPlayback(){
7211
- if(this.speechPlaybackStarting||this.muted){
7342
+ if(this.speechPlaybackStarting||this.muted||this.#nativeSpeechPlaybacks.size){
7212
7343
  return false;
7213
7344
  }
7214
7345
 
@@ -7238,6 +7369,32 @@ class AI {
7238
7369
  continue;
7239
7370
  }
7240
7371
 
7372
+ if(job.nativePlayback){
7373
+ // Native speech has no buffer duration: actual completion owns
7374
+ // the next turn, including when it follows buffered audio.
7375
+ if(index!==0||job.state!=='ready')break;
7376
+ if(!await this.#waitForPrecedingSpeechClock(job)
7377
+ ||job.generation!==this.speechGeneration||this.muted){
7378
+ return this.#cancelSpeechJob(job);
7379
+ }
7380
+ job.state='scheduled';
7381
+ this.currentSpeechJob=job;
7382
+ this.isSpeaking=true;
7383
+ job.nativeControl=job.nativePlayback.play({signal:job.abortController.signal});
7384
+ const control=job.nativeControl;
7385
+ this.#nativeSpeechPlaybacks.add(control);
7386
+ // A reported native control error need not mean the browser
7387
+ // released its utterance. Keep Stop ownership until it does.
7388
+ Promise.resolve(control.released??control.finished).then(
7389
+ ()=>this.#releaseNativeSpeechPlayback(control),
7390
+ ()=>this.#releaseNativeSpeechPlayback(control)
7391
+ );
7392
+ job.nativeControl.finished.then(
7393
+ played=>this.#completeNativeSpeechJob(job,played)
7394
+ ).catch(error=>this.#failSpeechJob(job,error,'playback-start'));
7395
+ return true;
7396
+ }
7397
+
7241
7398
  if(!['ready','scheduled'].includes(job.state)||!job.sourceNode?.buffer){
7242
7399
  break;
7243
7400
  }
@@ -7333,6 +7490,9 @@ class AI {
7333
7490
  return false;
7334
7491
  }finally{
7335
7492
  this.speechPlaybackStarting=false;
7493
+ const nextJob=this.speechJobs.find(function findFirstUnscheduledSpeechJob(job){
7494
+ return job.state!=='scheduled';
7495
+ });
7336
7496
 
7337
7497
  if(
7338
7498
  !this.muted
@@ -7347,11 +7507,8 @@ class AI {
7347
7507
  &&job.scheduledEnd===null;
7348
7508
  }
7349
7509
  )
7350
- &&this.speechJobs.find(
7351
- function findFirstUnscheduledSpeechJob(job){
7352
- return job.state!=='scheduled';
7353
- }
7354
- )?.state==='ready'
7510
+ &&nextJob?.state==='ready'
7511
+ &&(!nextJob.nativePlayback||this.speechJobs[0]===nextJob)
7355
7512
  ){
7356
7513
  this.#requestSpeechPlayback();
7357
7514
  }
@@ -7370,7 +7527,7 @@ class AI {
7370
7527
  job.sourceNode.onended=null;
7371
7528
  }
7372
7529
 
7373
- if(job.scheduledEnd===null&&job.pauseAfterMs>0){
7530
+ if(!job.nativePlayback&&job.scheduledEnd===null&&job.pauseAfterMs>0){
7374
7531
  this.speechScheduleContext=job.audioContext;
7375
7532
  this.speechScheduleTime=
7376
7533
  (Number(job.audioContext?.currentTime)||0)+job.pauseAfterMs/1000;
@@ -7386,7 +7543,7 @@ class AI {
7386
7543
  }
7387
7544
  )||null;
7388
7545
  }
7389
- this.isSpeaking=this.speechJobs.some(
7546
+ this.isSpeaking=this.#nativeSpeechPlaybacks.size>0||this.speechJobs.some(
7390
7547
  function hasScheduledSpeechJob(candidate){
7391
7548
  return candidate.state==='scheduled';
7392
7549
  }
@@ -7398,9 +7555,14 @@ class AI {
7398
7555
 
7399
7556
  #cancelSpeechJob(job){
7400
7557
  job.abortController?.abort();
7558
+ job.nativeControl?.stop();
7401
7559
  job.state='cancelled';
7402
7560
  this.#traceSpeechJob('queue.cancelled',job);
7403
7561
  this.#removeSpeechJob(job);
7562
+ if(this.currentSpeechJob===job)this.currentSpeechJob=null;
7563
+ this.isSpeaking=this.#nativeSpeechPlaybacks.size>0||this.speechJobs.some(function hasActiveSpeech(candidate){
7564
+ return candidate.state==='scheduled';
7565
+ });
7404
7566
  return false;
7405
7567
  }
7406
7568
 
@@ -7489,7 +7651,7 @@ class AI {
7489
7651
  }
7490
7652
  )||null;
7491
7653
  }
7492
- this.isSpeaking=this.speechJobs.some(
7654
+ this.isSpeaking=this.#nativeSpeechPlaybacks.size>0||this.speechJobs.some(
7493
7655
  function hasRemainingScheduledSpeechJob(candidate){
7494
7656
  return candidate.state==='scheduled';
7495
7657
  }