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.
@@ -239,6 +239,10 @@ async function playableSpeechBlob(response){
239
239
  throw synthesizedAudioContractError();
240
240
  }
241
241
 
242
+ function isNativeSpeechPlayback(value){
243
+ return value?.kind==='native-speech'&&is.function(value.play);
244
+ }
245
+
242
246
  function speechPlaybackFailureReason(error){
243
247
  if(error?.name==='AbortError')return 'speech-synthesis-cancelled';
244
248
  if(is.string(error?.code)
@@ -310,7 +314,7 @@ function queueFor(speech){
310
314
  }
311
315
 
312
316
  function providerSpeechCapacity(speech){
313
- if(!is.function(speech?.fetchTTS))return null;
317
+ if(!is.function(speech?.prepareTTSPlayback)&&!is.function(speech?.fetchTTS))return null;
314
318
  try{
315
319
  const providerRuntime=speech.providerRuntime;
316
320
  if(!providerRuntime||!is.function(providerRuntime.status))return null;
@@ -330,7 +334,7 @@ function startProviderSegment(playback,index,providerSynthesisBatch){
330
334
  providerSynthesisBatch!==playback.providerSynthesisBatch
331
335
  ||index<0
332
336
  ||index>=playback.parts.length
333
- ||playback.urls[index]
337
+ ||playback.hasSegment(index)
334
338
  )return null;
335
339
  const existing=playback.segmentRequests.get(index);
336
340
  if(existing?.providerSynthesisBatch===providerSynthesisBatch){
@@ -348,10 +352,10 @@ function startProviderSegment(playback,index,providerSynthesisBatch){
348
352
  function storeProviderSynthesizedSegment(outcome){
349
353
  if(outcome===SUPERSEDED)return {ready:false,superseded:true};
350
354
  if(providerSynthesisBatch!==playback.providerSynthesisBatch){
351
- playback.revokeObjectURL(outcome.url);
355
+ playback.discardSegment(outcome);
352
356
  return {ready:false,superseded:true};
353
357
  }
354
- playback.urls[index]=outcome.url;
358
+ playback.retainSegment(index,outcome);
355
359
  return {ready:true};
356
360
  },
357
361
  function handleProviderSegmentFailure(error){
@@ -401,6 +405,7 @@ const DEFAULT_MESSAGES={
401
405
  autoplayBlocked:'Speech is ready. Browser autoplay was blocked; press Play.',
402
406
  playing:'Reading the visible content aloud.',
403
407
  buffering:'Preparing the next speech segment.',
408
+ waitingForVoices:'Waiting for the browser voice list.',
404
409
  paused:'Narration paused.',
405
410
  pausing:'Pausing between narration sections.',
406
411
  stopped:'Speech stopped. Start narration again to replay.',
@@ -464,6 +469,8 @@ class SpeechPlayback{
464
469
  };
465
470
  this.messages={...DEFAULT_MESSAGES,...messages};
466
471
  this.urls=[];
472
+ this.nativeSegments=new Map();
473
+ this.nativePlayback=null;
467
474
  this.parts=[];
468
475
  this.index=0;
469
476
  this.generation=0;
@@ -519,9 +526,9 @@ class SpeechPlayback{
519
526
  message,
520
527
  key,
521
528
  index:this.index,
522
- total:this.parts.length||this.urls.filter(Boolean).length,
529
+ total:this.parts.length||this.urls.filter(Boolean).length+this.nativeSegments.size,
523
530
  producing:this.synthesisInFlight,
524
- buffered:this.urls.filter(Boolean).length,
531
+ buffered:this.urls.filter(Boolean).length+this.nativeSegments.size,
525
532
  hasAudio:this.hasAudio(),
526
533
  operationId:this.operationId,
527
534
  code,
@@ -551,6 +558,8 @@ class SpeechPlayback{
551
558
  this.speech
552
559
  &&(
553
560
  is.function(this.speech.fetchTTS)
561
+ ||is.function(this.speech.prepareTTSPlayback)
562
+ ||is.function(this.speech.prepare)
554
563
  ||is.function(this.speech.synthesize)
555
564
  )
556
565
  );
@@ -558,22 +567,38 @@ class SpeechPlayback{
558
567
 
559
568
  hasAudio(key=this.key){
560
569
  const playable=this.providerSynthesisBatch
561
- ?Boolean(this.urls[0])
562
- :this.urls.some(Boolean);
570
+ ?this.hasSegment(0)
571
+ :this.urls.some(Boolean)||this.nativeSegments.size>0;
563
572
  return Boolean(playable&&(!key||key===this.key));
564
573
  }
565
574
 
575
+ hasSegment(index){
576
+ return Boolean(this.urls[index])||this.nativeSegments.has(index);
577
+ }
578
+
579
+ retainSegment(index,outcome){
580
+ if(outcome.nativeSpeech)this.nativeSegments.set(index,outcome.nativeSpeech);
581
+ else this.urls[index]=outcome.url;
582
+ }
583
+
584
+ discardSegment(outcome){
585
+ if(outcome?.url)this.revokeObjectURL(outcome.url);
586
+ }
587
+
566
588
  releaseURLs(){
589
+ const stopped=this.stopNativePlayback();
567
590
  for(const url of this.urls){
568
591
  if(url)this.revokeObjectURL(url);
569
592
  }
570
593
  this.urls=[];
594
+ this.nativeSegments.clear();
571
595
  this.parts=[];
572
596
  this.lookahead=null;
573
597
  this.lookaheadError=null;
574
598
  this.providerSynthesisBatch=null;
575
599
  this.segmentRequests.clear();
576
600
  this.segmentErrors.clear();
601
+ return stopped;
577
602
  }
578
603
 
579
604
  releaseUrls(){this.releaseURLs();}
@@ -590,10 +615,10 @@ class SpeechPlayback{
590
615
  this.audio.removeAttribute?.('src');
591
616
  this.audio.load?.();
592
617
  this.audio.hidden=true;
593
- this.releaseURLs();
618
+ const stopped=this.releaseURLs();
594
619
  this.index=0;
595
620
  this.key='';
596
- this.emit('idle',message,cancelledKey,{reason});
621
+ if(stopped)this.emit('idle',message,cancelledKey,{reason});
597
622
  }
598
623
 
599
624
  async requestSpeech(part,signal){
@@ -606,11 +631,19 @@ class SpeechPlayback{
606
631
  ...(part.voice?{voice:part.voice}:{}),
607
632
  ...(this.responseFormat?{responseFormat:this.responseFormat}:{})
608
633
  };
634
+ if(is.function(this.speech?.prepareTTSPlayback)){
635
+ const prepared=await this.speech.prepareTTSPlayback(payload,signal,preparation);
636
+ return isNativeSpeechPlayback(prepared)?prepared:playableSpeechBlob(prepared);
637
+ }
609
638
  if(is.function(this.speech?.fetchTTS)){
610
639
  return playableSpeechBlob(
611
640
  await this.speech.fetchTTS(payload,signal,preparation)
612
641
  );
613
642
  }
643
+ if(is.function(this.speech?.prepare)){
644
+ const prepared=await this.speech.prepare(payload,{signal},preparation);
645
+ return isNativeSpeechPlayback(prepared)?prepared:playableSpeechBlob(prepared);
646
+ }
614
647
  if(is.function(this.speech?.synthesize)){
615
648
  return playableSpeechBlob(
616
649
  await this.speech.synthesize(payload,{signal},preparation)
@@ -647,7 +680,7 @@ class SpeechPlayback{
647
680
  if(announce){
648
681
  playback.emit('synthesizing',playback.message('preparing',{count:playback.parts.length}));
649
682
  }
650
- const audio=await playback.requestSpeech(
683
+ const prepared=await playback.requestSpeech(
651
684
  part,
652
685
  controller.signal
653
686
  );
@@ -655,7 +688,8 @@ class SpeechPlayback{
655
688
  controller.abort();
656
689
  return SUPERSEDED;
657
690
  }
658
- const url=playback.createObjectURL(audio);
691
+ if(isNativeSpeechPlayback(prepared))return {nativeSpeech:prepared};
692
+ const url=playback.createObjectURL(prepared);
659
693
  if(!segmentRequestIsCurrent()){
660
694
  playback.revokeObjectURL(url);
661
695
  controller.abort();
@@ -681,7 +715,7 @@ class SpeechPlayback{
681
715
  generation!==this.generation
682
716
  ||index<0
683
717
  ||index>=this.parts.length
684
- ||this.urls[index]
718
+ ||this.hasSegment(index)
685
719
  )return null;
686
720
  if(
687
721
  this.lookahead
@@ -695,10 +729,10 @@ class SpeechPlayback{
695
729
  function storeSynthesizedSegment(outcome){
696
730
  if(outcome===SUPERSEDED)return {ready:false,superseded:true};
697
731
  if(generation!==playback.generation){
698
- playback.revokeObjectURL(outcome.url);
732
+ playback.discardSegment(outcome);
699
733
  return {ready:false,superseded:true};
700
734
  }
701
- playback.urls[index]=outcome.url;
735
+ playback.retainSegment(index,outcome);
702
736
  return {ready:true};
703
737
  },
704
738
  function handleLookaheadFailure(error){
@@ -716,19 +750,19 @@ class SpeechPlayback{
716
750
 
717
751
  async waitForSegment(index,generation){
718
752
  if(generation!==this.generation)return false;
719
- if(this.urls[index])return true;
753
+ if(this.hasSegment(index))return true;
720
754
  if(this.providerSynthesisBatch){
721
755
  const segmentError=this.segmentErrors.get(index);
722
756
  if(segmentError?.providerSynthesisBatch===this.providerSynthesisBatch){
723
757
  return false;
724
758
  }
725
759
  const record=this.segmentRequests.get(index);
726
- if(!record)return Boolean(this.urls[index]);
760
+ if(!record)return this.hasSegment(index);
727
761
  const result=await record.promise;
728
762
  return generation===this.generation
729
763
  &&record.providerSynthesisBatch===this.providerSynthesisBatch
730
764
  &&result.ready===true
731
- &&Boolean(this.urls[index]);
765
+ &&this.hasSegment(index);
732
766
  }
733
767
  if(
734
768
  this.lookaheadError
@@ -740,9 +774,9 @@ class SpeechPlayback{
740
774
  &&this.lookahead.generation===generation
741
775
  &&this.lookahead.index===index
742
776
  )?this.lookahead:this.startLookahead(index,generation);
743
- if(!record)return Boolean(this.urls[index]);
777
+ if(!record)return this.hasSegment(index);
744
778
  const result=await record.promise;
745
- return generation===this.generation&&result.ready===true&&Boolean(this.urls[index]);
779
+ return generation===this.generation&&result.ready===true&&this.hasSegment(index);
746
780
  }
747
781
 
748
782
  async waitForPause(duration,generation){
@@ -836,7 +870,11 @@ class SpeechPlayback{
836
870
  );
837
871
  const result=await first.promise;
838
872
  if(Object.hasOwn(result,'error'))throw result.error;
839
- outcome=result.ready?{url:this.urls[0]}:SUPERSEDED;
873
+ outcome=result.ready
874
+ ?this.nativeSegments.has(0)
875
+ ?{nativeSpeech:this.nativeSegments.get(0)}
876
+ :{url:this.urls[0]}
877
+ :SUPERSEDED;
840
878
  }
841
879
  }catch(error){
842
880
  if(generation!==this.generation)return {ready:false,played:false};
@@ -847,7 +885,7 @@ class SpeechPlayback{
847
885
  throw error;
848
886
  }
849
887
  if(outcome===SUPERSEDED||generation!==this.generation){
850
- if(outcome?.url)this.revokeObjectURL(outcome.url);
888
+ this.discardSegment(outcome);
851
889
  if(generation===this.generation){
852
890
  this.releaseURLs();
853
891
  this.key='';
@@ -856,12 +894,11 @@ class SpeechPlayback{
856
894
  return {ready:false,played:false};
857
895
  }
858
896
 
859
- this.urls[0]=outcome.url;
897
+ this.retainSegment(0,outcome);
860
898
  this.index=0;
861
- this.audio.hidden=false;
862
- this.loadCurrent();
863
- this.emit('ready',this.message('ready'));
864
- const played=autoplay?await this.play():false;
899
+ const loaded=this.loadCurrent();
900
+ if(loaded)this.emit('ready',this.message('ready'));
901
+ const played=autoplay&&loaded?await this.play():false;
865
902
  if(generation===this.generation&&!this.providerSynthesisBatch){
866
903
  this.startLookahead(1,generation);
867
904
  }
@@ -869,13 +906,26 @@ class SpeechPlayback{
869
906
  }
870
907
 
871
908
  loadCurrent(){
872
- if(!this.urls[this.index])return;
909
+ if(!this.hasSegment(this.index))return false;
910
+ if(this.nativePlayback&&!this.stopNativePlayback())return false;
911
+ if(this.nativeSegments.has(this.index)){
912
+ this.audio.pause();
913
+ this.audio.removeAttribute?.('src');
914
+ this.audio.load?.();
915
+ this.audio.hidden=true;
916
+ return true;
917
+ }
918
+ this.audio.hidden=false;
873
919
  this.audio.src=this.urls[this.index];
874
920
  this.audio.load?.();
921
+ return true;
875
922
  }
876
923
 
877
924
  async play(){
878
925
  if(!this.hasAudio())return false;
926
+ if(this.nativeSegments.has(this.index))return this.playNative();
927
+ if(this.nativePlayback&&!this.stopNativePlayback())return false;
928
+ if(this.audio.src!==this.urls[this.index]&&!this.loadCurrent())return false;
879
929
  try{
880
930
  await this.audio.play();
881
931
  return true;
@@ -890,17 +940,156 @@ class SpeechPlayback{
890
940
  }
891
941
  }
892
942
 
943
+ stopNativePlayback(){
944
+ const active=this.nativePlayback;
945
+ if(!active)return true;
946
+ active.cancelled=true;
947
+ let stopped=false;
948
+ let failure=null;
949
+ try{
950
+ stopped=active.control?.stop()===true;
951
+ }catch(error){
952
+ failure=error;
953
+ }finally{
954
+ active.controller.abort();
955
+ }
956
+ const released=active.released||!active.control||stopped
957
+ ||active.control.state==='complete'||active.control.state==='stopped';
958
+ if(!released){
959
+ failure=failure??active.control.error;
960
+ if(failure){
961
+ active.failed=true;
962
+ console.error('[Arcane speech playback] Native speech stop failed.',failure);
963
+ this.fail(failure);
964
+ }
965
+ return false;
966
+ }
967
+ if(this.nativePlayback===active)this.nativePlayback=null;
968
+ this.abortControllers.delete(active.controller);
969
+ return true;
970
+ }
971
+
972
+ playNative(){
973
+ const prepared=this.nativeSegments.get(this.index);
974
+ if(!prepared)return false;
975
+ const previous=this.nativePlayback;
976
+ if(previous&&previous.generation===this.generation&&previous.index===this.index){
977
+ try{
978
+ if(previous.failed)return false;
979
+ return previous.control?.state==='paused'
980
+ ?previous.control.resume()
981
+ :true;
982
+ }catch(error){
983
+ this.stopNativePlayback();
984
+ this.fail(error);
985
+ return false;
986
+ }
987
+ }
988
+ if(!this.stopNativePlayback())return false;
989
+ const playback=this;
990
+ const active={
991
+ generation:this.generation,
992
+ index:this.index,
993
+ controller:new AbortController(),
994
+ control:null,
995
+ completion:null,
996
+ releaseCompletion:null,
997
+ failed:false,
998
+ cancelled:false,
999
+ released:false
1000
+ };
1001
+ this.nativePlayback=active;
1002
+ this.abortControllers.add(active.controller);
1003
+ function currentNativePlayback(){
1004
+ return playback.nativePlayback===active
1005
+ &&playback.generation===active.generation
1006
+ &&playback.index===active.index
1007
+ &&!active.cancelled;
1008
+ }
1009
+ function releaseNativePlayback(){
1010
+ active.released=true;
1011
+ playback.abortControllers.delete(active.controller);
1012
+ if((active.failed||active.cancelled)&&playback.nativePlayback===active){
1013
+ playback.nativePlayback=null;
1014
+ }
1015
+ }
1016
+ function observeNativeState(detail){
1017
+ if(playback.nativePlayback===active
1018
+ &&(active.failed||active.cancelled)
1019
+ &&(detail.state==='complete'||detail.state==='stopped')){
1020
+ releaseNativePlayback();
1021
+ return;
1022
+ }
1023
+ if(!currentNativePlayback())return;
1024
+ if(active.failed)return;
1025
+ if(detail.state==='playing')playback.emit('playing',playback.message('playing'));
1026
+ else if(detail.state==='paused')playback.emit('paused',playback.message('paused'));
1027
+ else if(detail.state==='waiting-for-voices'){
1028
+ playback.emit('buffering',playback.message('waitingForVoices'));
1029
+ }
1030
+ }
1031
+ async function observeNativeCompletion(){
1032
+ try{
1033
+ const completed=await active.control.finished;
1034
+ if(!currentNativePlayback())return false;
1035
+ playback.nativePlayback=null;
1036
+ playback.abortControllers.delete(active.controller);
1037
+ if(completed===true)return await playback.advance();
1038
+ if(active.control.error)playback.fail(active.control.error);
1039
+ else playback.emit('ready',playback.message('stopped'));
1040
+ return false;
1041
+ }catch(error){
1042
+ console.error('[Arcane speech playback] Native speech failed.',error);
1043
+ // A native control failure may leave the utterance running until stop or end.
1044
+ active.failed=true;
1045
+ if(playback.generation===active.generation
1046
+ &&(playback.nativePlayback===active||playback.nativePlayback===null)){
1047
+ playback.fail(error);
1048
+ }
1049
+ if(active.released)releaseNativePlayback();
1050
+ return false;
1051
+ }
1052
+ }
1053
+ async function observeNativeRelease(){
1054
+ try{
1055
+ await active.control.released;
1056
+ releaseNativePlayback();
1057
+ }catch(error){
1058
+ console.error('[Arcane speech playback] Native speech release failed.',error);
1059
+ if(currentNativePlayback())playback.fail(error);
1060
+ }
1061
+ }
1062
+ try{
1063
+ active.control=prepared.play({
1064
+ signal:active.controller.signal,
1065
+ onState:observeNativeState
1066
+ });
1067
+ active.completion=observeNativeCompletion();
1068
+ if(is.function(active.control.released?.then)){
1069
+ active.releaseCompletion=observeNativeRelease();
1070
+ }
1071
+ return currentNativePlayback()
1072
+ &&active.control.state!=='error'
1073
+ &&active.control.state!=='stopped';
1074
+ }catch(error){
1075
+ this.stopNativePlayback();
1076
+ this.fail(error);
1077
+ return false;
1078
+ }
1079
+ }
1080
+
893
1081
  async restart(){
894
1082
  if(!this.hasAudio())return false;
895
1083
  this.generation+=1;
896
1084
  const generation=this.generation;
1085
+ if(!this.stopNativePlayback())return false;
897
1086
  if(this.providerSynthesisBatch){
898
1087
  const providerSynthesisBatch=this.providerSynthesisBatch;
899
1088
  for(const [index,segmentError] of this.segmentErrors){
900
1089
  if(
901
1090
  segmentError?.providerSynthesisBatch
902
1091
  ===providerSynthesisBatch
903
- &&!this.urls[index]
1092
+ &&!this.hasSegment(index)
904
1093
  &&!this.segmentRequests.has(index)
905
1094
  ){
906
1095
  startProviderSegment(
@@ -916,9 +1105,9 @@ class SpeechPlayback{
916
1105
  }
917
1106
  this.audio.pause();
918
1107
  this.index=0;
919
- this.loadCurrent();
1108
+ const loaded=this.loadCurrent();
920
1109
  this.audio.currentTime=0;
921
- const played=await this.play();
1110
+ const played=loaded?await this.play():false;
922
1111
  if(generation===this.generation&&!this.providerSynthesisBatch){
923
1112
  this.startLookahead(1,generation);
924
1113
  }
@@ -929,6 +1118,18 @@ class SpeechPlayback{
929
1118
 
930
1119
  async togglePause(){
931
1120
  if(!this.hasAudio())return false;
1121
+ if(this.nativeSegments.has(this.index)){
1122
+ if(this.nativePlayback?.failed)return false;
1123
+ const control=this.nativePlayback?.control;
1124
+ if(!control||control.state==='paused')return this.play();
1125
+ try{
1126
+ return control.pause();
1127
+ }catch(error){
1128
+ this.stopNativePlayback();
1129
+ this.fail(error);
1130
+ return false;
1131
+ }
1132
+ }
932
1133
  if(this.audio.paused)return this.play();
933
1134
  this.audio.pause();
934
1135
  return true;
@@ -936,7 +1137,7 @@ class SpeechPlayback{
936
1137
 
937
1138
  stop(){
938
1139
  const hasAudio=this.hasAudio();
939
- if(!this.synthesisInFlight&&!hasAudio)return;
1140
+ if(!this.synthesisInFlight&&!hasAudio&&!this.nativePlayback)return;
940
1141
  this.cancel(
941
1142
  this.message(hasAudio?'stopped':'preparationStopped'),
942
1143
  'playback-stopped'
@@ -958,7 +1159,7 @@ class SpeechPlayback{
958
1159
  }
959
1160
  }
960
1161
  const nextIndex=this.index+1;
961
- if(!this.urls[nextIndex]){
1162
+ if(!this.hasSegment(nextIndex)){
962
1163
  this.emit('buffering',this.message('buffering'));
963
1164
  const ready=await this.waitForSegment(nextIndex,generation);
964
1165
  if(!ready||generation!==this.generation){
@@ -978,8 +1179,8 @@ class SpeechPlayback{
978
1179
  }
979
1180
  }
980
1181
  this.index=nextIndex;
981
- this.loadCurrent();
982
- const played=await this.play();
1182
+ const loaded=this.loadCurrent();
1183
+ const played=loaded?await this.play():false;
983
1184
  if(generation===this.generation&&!this.providerSynthesisBatch){
984
1185
  this.startLookahead(nextIndex+1,generation);
985
1186
  }
@@ -987,6 +1188,7 @@ class SpeechPlayback{
987
1188
  }
988
1189
 
989
1190
  async handleEnded(){
1191
+ if(this.nativeSegments.has(this.index))return;
990
1192
  try{
991
1193
  await this.advance();
992
1194
  }catch(error){
@@ -994,15 +1196,19 @@ class SpeechPlayback{
994
1196
  }
995
1197
  }
996
1198
 
997
- handlePlay(){this.emit('playing',this.message('playing'));}
1199
+ handlePlay(){
1200
+ if(!this.nativeSegments.has(this.index))this.emit('playing',this.message('playing'));
1201
+ }
998
1202
 
999
1203
  handlePause(){
1204
+ if(this.nativeSegments.has(this.index))return;
1000
1205
  if(this.hasAudio()&&this.audio.currentTime>0&&!this.audio.ended){
1001
1206
  this.emit('paused',this.message('paused'));
1002
1207
  }
1003
1208
  }
1004
1209
 
1005
1210
  handleError(){
1211
+ if(this.nativeSegments.has(this.index))return;
1006
1212
  const error=new Error(this.message('playbackError'));
1007
1213
  error.code='ARCANE_SPEECH_PLAYBACK_AUDIO_PLAYBACK_REJECTED';
1008
1214
  this.fail(error);