arcane-os 0.2.1 → 0.2.2

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.
@@ -176,7 +176,11 @@ function createLegacyAIStreamBridge(execute,sourceSignal){
176
176
 
177
177
  function normalizeAIStartupOptions(options){
178
178
  if(options===undefined){
179
- return Object.freeze({startMuted:true,signal:null});
179
+ return Object.freeze({
180
+ startMuted:true,
181
+ startTranscription:false,
182
+ signal:null
183
+ });
180
184
  }
181
185
  if(!options||typeof options!=='object'||Array.isArray(options)){
182
186
  throw new TypeError('AI startup options must be a plain object.');
@@ -187,7 +191,11 @@ function normalizeAIStartupOptions(options){
187
191
  }
188
192
  const descriptors=Object.getOwnPropertyDescriptors(options);
189
193
  for(const key of Reflect.ownKeys(descriptors)){
190
- if(typeof key==='symbol'||(key!=='startMuted'&&key!=='signal')){
194
+ if(typeof key==='symbol'||(
195
+ key!=='startMuted'
196
+ &&key!=='startTranscription'
197
+ &&key!=='signal'
198
+ )){
191
199
  throw new TypeError('AI startup options contain an unknown option.');
192
200
  }
193
201
  if(!Object.hasOwn(descriptors[key],'value')){
@@ -197,12 +205,18 @@ function normalizeAIStartupOptions(options){
197
205
  const startMuted=Object.hasOwn(descriptors,'startMuted')
198
206
  ?descriptors.startMuted.value
199
207
  :true;
208
+ const startTranscription=Object.hasOwn(descriptors,'startTranscription')
209
+ ?descriptors.startTranscription.value
210
+ :false;
200
211
  const signal=Object.hasOwn(descriptors,'signal')
201
212
  ?descriptors.signal.value
202
213
  :null;
203
214
  if(typeof startMuted!=='boolean'){
204
215
  throw new TypeError('AI startup startMuted must be a boolean.');
205
216
  }
217
+ if(typeof startTranscription!=='boolean'){
218
+ throw new TypeError('AI startup startTranscription must be a boolean.');
219
+ }
206
220
  if(signal!==null&&signal!==undefined&&(
207
221
  typeof signal!=='object'
208
222
  ||typeof signal.aborted!=='boolean'
@@ -211,7 +225,7 @@ function normalizeAIStartupOptions(options){
211
225
  )){
212
226
  throw new TypeError('AI startup signal must be an AbortSignal.');
213
227
  }
214
- return Object.freeze({startMuted,signal});
228
+ return Object.freeze({startMuted,startTranscription,signal});
215
229
  }
216
230
 
217
231
  class AI {
@@ -352,6 +366,8 @@ class AI {
352
366
  #providerRuntime=getAIProviderRuntime();
353
367
  #legacyLLMProviders=new Map();
354
368
  #legacyLLMReadiness=Promise.resolve(null);
369
+ #legacySpeechProviders=new Map();
370
+ #legacySpeechReadiness=Promise.resolve(null);
355
371
  #preferenceTuple=Object.freeze([
356
372
  'OPENAI',
357
373
  'OPENAI',
@@ -402,6 +418,9 @@ class AI {
402
418
  this.#retainLegacyLLMReadiness(
403
419
  this.#reconcileLegacyLLMReadiness()
404
420
  );
421
+ this.#retainLegacySpeechReadiness(
422
+ this.#reconcileLegacySpeechReadiness()
423
+ );
405
424
  return this.#license;
406
425
  }
407
426
 
@@ -627,6 +646,263 @@ class AI {
627
646
  });
628
647
  }
629
648
 
649
+ #legacySpeechService(role){
650
+ return role==='stt'?this.sttService:this.ttsService;
651
+ }
652
+
653
+ #legacySpeechModel(role){
654
+ return role==='stt'?this.modelSTT:this.modelTTS;
655
+ }
656
+
657
+ #legacySpeechProviderKey(role,providerId){
658
+ return `${role}:${providerId}`;
659
+ }
660
+
661
+ #legacySpeechDefaultVoice(role,providerId){
662
+ if(role!=='tts'){
663
+ return null;
664
+ }
665
+ if(providerId==='OPENAI'){
666
+ const selected=typeof globalThis.window?.user?.AI_voice==='string'
667
+ ?globalThis.window.user.AI_voice.trim()
668
+ :'';
669
+ return selected||'alloy';
670
+ }
671
+ if(providerId==='LOCAL_SPEACH'){
672
+ return 'af_heart';
673
+ }
674
+ return null;
675
+ }
676
+
677
+ #legacySpeechCapability(role,providerId){
678
+ const service=this.#legacySpeechService(role);
679
+ const model=this.#legacySpeechModel(role);
680
+ if(service!==providerId||!model){
681
+ return false;
682
+ }
683
+ if(providerId==='OPENAI'){
684
+ return Boolean(this.license)&&typeof globalThis.fetch==='function';
685
+ }
686
+ if(providerId==='LOCAL_SPEACH'){
687
+ return Boolean(this.#nativeSpeech(service,role));
688
+ }
689
+ return false;
690
+ }
691
+
692
+ #legacySpeechInspection(role,providerId,selection){
693
+ const localOnly=providerId==='LOCAL_SPEACH';
694
+ if(!selection
695
+ ||selection.providerId!==providerId
696
+ ||selection.modelId!==this.#legacySpeechModel(role)
697
+ ||selection.localOnly!==localOnly
698
+ ||this.#legacySpeechService(role)!==providerId){
699
+ return Object.freeze({
700
+ available:false,
701
+ code:'ARCANE_AI_MODEL_AUTHORITY_REQUIRED',
702
+ message:`The selected legacy ${role.toUpperCase()} route does not match the active AI configuration.`
703
+ });
704
+ }
705
+ if(!this.#legacySpeechCapability(role,providerId)){
706
+ return Object.freeze({
707
+ available:false,
708
+ code:providerId==='LOCAL_SPEACH'
709
+ ?'AI_NATIVE_LOCAL_REQUIRED'
710
+ :'AI_PROVIDER_NOT_CONFIGURED',
711
+ message:providerId==='LOCAL_SPEACH'
712
+ ?`Local ${role.toUpperCase()} requires the capability-gated Arcane API.`
713
+ :'AI provider is not configured.'
714
+ });
715
+ }
716
+ return Object.freeze({
717
+ available:true,
718
+ authority:Object.freeze({
719
+ protocol:AI_MODEL_AUTHORITY_PROTOCOL,
720
+ providerId,
721
+ modelId:selection.modelId,
722
+ admitted:true
723
+ })
724
+ });
725
+ }
726
+
727
+ #createLegacySpeechProvider(role,providerId){
728
+ const runtime=this;
729
+ const localOnly=providerId==='LOCAL_SPEACH';
730
+ const expectedOperation=role==='stt'?'transcribe':'synthesize';
731
+ let state='unloaded';
732
+ let busy=false;
733
+
734
+ function statusLegacySpeechProvider(){
735
+ if(state==='ready'
736
+ &&!busy
737
+ &&!runtime.#legacySpeechCapability(role,providerId)){
738
+ state='unloaded';
739
+ }
740
+ return Object.freeze({
741
+ state,
742
+ loaded:state==='ready',
743
+ busy
744
+ });
745
+ }
746
+
747
+ function assertLegacySpeechSelection(selection){
748
+ const inspection=runtime.#legacySpeechInspection(
749
+ role,
750
+ providerId,
751
+ selection
752
+ );
753
+ if(!inspection.available){
754
+ throw legacyAIProviderError(
755
+ inspection.message,
756
+ inspection.code
757
+ );
758
+ }
759
+ return inspection;
760
+ }
761
+
762
+ function releaseLegacySpeechRequest(){
763
+ busy=false;
764
+ }
765
+
766
+ return Object.freeze({
767
+ protocol:AI_PROVIDER_PROTOCOL,
768
+ role,
769
+ id:providerId,
770
+ localOnly,
771
+ catalog:function catalogLegacySpeechProvider(){
772
+ const model=runtime.#legacySpeechModel(role);
773
+ if(runtime.#legacySpeechService(role)!==providerId||!model){
774
+ return Object.freeze([]);
775
+ }
776
+ const defaultVoice=runtime.#legacySpeechDefaultVoice(
777
+ role,
778
+ providerId
779
+ );
780
+ return Object.freeze([
781
+ Object.freeze({
782
+ id:model,
783
+ ...(defaultVoice?{defaultVoice}:{})
784
+ })
785
+ ]);
786
+ },
787
+ inspect:function inspectLegacySpeechProvider(selection,{signal}={}){
788
+ if(signal?.aborted){
789
+ throw normalizeAIRequestAbort(signal.reason);
790
+ }
791
+ return runtime.#legacySpeechInspection(role,providerId,selection);
792
+ },
793
+ status:statusLegacySpeechProvider,
794
+ load:function loadLegacySpeechProvider(context={}){
795
+ if(context.signal?.aborted){
796
+ throw normalizeAIRequestAbort(context.signal.reason);
797
+ }
798
+ if(state==='disposed'){
799
+ throw legacyAIProviderError(
800
+ `The legacy ${role.toUpperCase()} provider is disposed.`,
801
+ 'ARCANE_AI_PROVIDER_DISPOSED'
802
+ );
803
+ }
804
+ if(busy){
805
+ throw legacyAIProviderError(
806
+ `The legacy ${role.toUpperCase()} provider owns an active request.`,
807
+ 'ARCANE_AI_ROLE_BUSY'
808
+ );
809
+ }
810
+ if(typeof context.progress!=='function'){
811
+ throw new TypeError(
812
+ `Legacy ${role.toUpperCase()} provider load progress must be a function.`
813
+ );
814
+ }
815
+ const inspection=assertLegacySpeechSelection(context.selection);
816
+ state='loading';
817
+ context.progress({
818
+ phase:'capability',
819
+ completed:0,
820
+ total:1,
821
+ unit:'items',
822
+ heartbeat:false
823
+ });
824
+ if(context.signal?.aborted){
825
+ state='unloaded';
826
+ throw normalizeAIRequestAbort(context.signal.reason);
827
+ }
828
+ state='ready';
829
+ context.progress({
830
+ phase:'capability',
831
+ completed:1,
832
+ total:1,
833
+ unit:'items',
834
+ heartbeat:false
835
+ });
836
+ return Object.freeze({
837
+ authority:inspection.authority,
838
+ status:statusLegacySpeechProvider()
839
+ });
840
+ },
841
+ request:function requestLegacySpeechProvider(context={}){
842
+ if(context.signal?.aborted){
843
+ throw normalizeAIRequestAbort(context.signal.reason);
844
+ }
845
+ assertLegacySpeechSelection(context.selection);
846
+ const current=statusLegacySpeechProvider();
847
+ if(current.state!=='ready'||!current.loaded){
848
+ throw legacyAIProviderError(
849
+ `The legacy ${role.toUpperCase()} provider is not ready.`,
850
+ 'ARCANE_AI_ROLE_NOT_READY'
851
+ );
852
+ }
853
+ if(busy){
854
+ throw legacyAIProviderError(
855
+ `The legacy ${role.toUpperCase()} provider owns an active request.`,
856
+ 'ARCANE_AI_ROLE_BUSY'
857
+ );
858
+ }
859
+ if(context.operation!==expectedOperation){
860
+ throw legacyAIProviderError(
861
+ `The legacy ${role.toUpperCase()} provider operation is unsupported.`,
862
+ 'ARCANE_AI_PROVIDER_RUNTIME_INVALID'
863
+ );
864
+ }
865
+ busy=true;
866
+ const request=role==='stt'
867
+ ?runtime.#requestLegacySpeechTranscription(
868
+ context.payload,
869
+ context.signal
870
+ )
871
+ :runtime.#requestLegacySpeechSynthesis(
872
+ context.payload,
873
+ context.signal
874
+ );
875
+ return Promise.resolve(request).finally(releaseLegacySpeechRequest);
876
+ },
877
+ unload:function unloadLegacySpeechProvider(context={}){
878
+ if(context.signal?.aborted){
879
+ throw normalizeAIRequestAbort(context.signal.reason);
880
+ }
881
+ if(busy){
882
+ throw legacyAIProviderError(
883
+ `The legacy ${role.toUpperCase()} provider still owns an active request.`,
884
+ 'ARCANE_AI_ROLE_BUSY'
885
+ );
886
+ }
887
+ state='unloaded';
888
+ return statusLegacySpeechProvider();
889
+ },
890
+ dispose:function disposeLegacySpeechProvider(context={}){
891
+ if(context.signal?.aborted){
892
+ throw normalizeAIRequestAbort(context.signal.reason);
893
+ }
894
+ if(busy){
895
+ throw legacyAIProviderError(
896
+ `The legacy ${role.toUpperCase()} provider still owns an active request.`,
897
+ 'ARCANE_AI_ROLE_BUSY'
898
+ );
899
+ }
900
+ state='disposed';
901
+ return statusLegacySpeechProvider();
902
+ }
903
+ });
904
+ }
905
+
630
906
  #ensureLegacyLLMProvider(providerId){
631
907
  if(providerId!=='OPENAI'&&providerId!=='OLLAMA'){
632
908
  return false;
@@ -643,6 +919,23 @@ class AI {
643
919
  return true;
644
920
  }
645
921
 
922
+ #ensureLegacySpeechProvider(role,providerId){
923
+ if(!['stt','tts'].includes(role)
924
+ ||!['OPENAI','LOCAL_SPEACH'].includes(providerId)){
925
+ return false;
926
+ }
927
+ if(this.#providerRuntime.hasProvider(role,providerId)){
928
+ return false;
929
+ }
930
+ const provider=this.#createLegacySpeechProvider(role,providerId);
931
+ const unregister=this.#providerRuntime.register(provider);
932
+ this.#legacySpeechProviders.set(
933
+ this.#legacySpeechProviderKey(role,providerId),
934
+ Object.freeze({role,providerId,provider,unregister})
935
+ );
936
+ return true;
937
+ }
938
+
646
939
  #releaseInactiveLegacyLLMProviders(activeProviderId){
647
940
  for(const [providerId,record] of this.#legacyLLMProviders){
648
941
  if(providerId===activeProviderId){
@@ -654,6 +947,17 @@ class AI {
654
947
  }
655
948
  }
656
949
 
950
+ #releaseInactiveLegacySpeechProviders(activeProviders){
951
+ for(const [key,record] of this.#legacySpeechProviders){
952
+ if(activeProviders[record.role]===record.providerId){
953
+ continue;
954
+ }
955
+ if(record.unregister()){
956
+ this.#legacySpeechProviders.delete(key);
957
+ }
958
+ }
959
+ }
960
+
657
961
  #internalLegacyLLMSelection(localOnly=false){
658
962
  const selection=this.#providerRuntime.selection(
659
963
  'llm',
@@ -668,6 +972,19 @@ class AI {
668
972
  return selection;
669
973
  }
670
974
 
975
+ #internalLegacySpeechSelection(role,localOnly=false){
976
+ const selection=this.#providerRuntime.selection(role,{localOnly});
977
+ if(!selection
978
+ ||!this.#legacySpeechProviders.has(
979
+ this.#legacySpeechProviderKey(role,selection.providerId)
980
+ )
981
+ ||selection.providerId!==this.#legacySpeechService(role)
982
+ ||selection.modelId!==this.#legacySpeechModel(role)){
983
+ return null;
984
+ }
985
+ return selection;
986
+ }
987
+
671
988
  #retainLegacyLLMReadiness(operation){
672
989
  this.#legacyLLMReadiness=Promise.resolve(operation).catch(
673
990
  function retainLegacyLLMReadinessFailure(){
@@ -677,6 +994,15 @@ class AI {
677
994
  return this.#legacyLLMReadiness;
678
995
  }
679
996
 
997
+ #retainLegacySpeechReadiness(operation){
998
+ this.#legacySpeechReadiness=Promise.resolve(operation).catch(
999
+ function retainLegacySpeechReadinessFailure(){
1000
+ return null;
1001
+ }
1002
+ );
1003
+ return this.#legacySpeechReadiness;
1004
+ }
1005
+
680
1006
  #reconcileLegacyLLMReadiness(){
681
1007
  const selection=this.#internalLegacyLLMSelection(false);
682
1008
  if(!selection){
@@ -698,6 +1024,27 @@ class AI {
698
1024
  return Promise.resolve(status);
699
1025
  }
700
1026
 
1027
+ #reconcileLegacySpeechReadiness(){
1028
+ const runtime=this;
1029
+ return Promise.all(['stt','tts'].map(function reconcileLegacySpeechRole(role){
1030
+ const selection=runtime.#internalLegacySpeechSelection(role,false);
1031
+ if(!selection){
1032
+ return runtime.#providerRuntime.status(role);
1033
+ }
1034
+ const status=runtime.#providerRuntime.status(role);
1035
+ if(runtime.#legacySpeechCapability(role,selection.providerId)){
1036
+ return status;
1037
+ }
1038
+ if(status.loaded===true
1039
+ ||status.busy===true
1040
+ ||status.state==='loading'
1041
+ ||status.state==='unloading'){
1042
+ return runtime.#providerRuntime.unload(role);
1043
+ }
1044
+ return status;
1045
+ }));
1046
+ }
1047
+
701
1048
  get configured(){
702
1049
  if(this.#usesProviderRuntime('llm',this.llmService)){
703
1050
  if(this.#internalLegacyLLMSelection(false)
@@ -720,12 +1067,19 @@ class AI {
720
1067
  if(this.#usesProviderRuntime(role,service)){
721
1068
  const internal=role==='llm'
722
1069
  ?this.#internalLegacyLLMSelection(false)
723
- :null;
724
- if(internal&&!this.#legacyLLMCapability(internal.providerId)){
725
- const inspection=this.#legacyLLMInspection(
726
- internal.providerId,
727
- internal
728
- );
1070
+ :this.#internalLegacySpeechSelection(role,false);
1071
+ const internalAvailable=!internal
1072
+ ||(role==='llm'
1073
+ ?this.#legacyLLMCapability(internal.providerId)
1074
+ :this.#legacySpeechCapability(role,internal.providerId));
1075
+ if(!internalAvailable){
1076
+ const inspection=role==='llm'
1077
+ ?this.#legacyLLMInspection(internal.providerId,internal)
1078
+ :this.#legacySpeechInspection(
1079
+ role,
1080
+ internal.providerId,
1081
+ internal
1082
+ );
729
1083
  throw legacyAIProviderError(
730
1084
  inspection.message,
731
1085
  inspection.code
@@ -894,8 +1248,8 @@ class AI {
894
1248
  tuple[0],
895
1249
  this.#normalizedLLMModel(tuple[0],tuple[3])
896
1250
  ],
897
- stt:[tuple[1],tuple[5]],
898
- tts:[tuple[2],tuple[4]]
1251
+ stt:[tuple[1],this.#sttModels[tuple[5]]||tuple[5]],
1252
+ tts:[tuple[2],this.#ttsModels[tuple[4]]||tuple[4]]
899
1253
  };
900
1254
  const selections={};
901
1255
  for(const role of ['llm','stt','tts']){
@@ -990,12 +1344,21 @@ class AI {
990
1344
  ]);
991
1345
  this.#assertValidProviderTuple(tuple);
992
1346
  this.#ensureLegacyLLMProvider(tuple[0]);
1347
+ this.#ensureLegacySpeechProvider('stt',tuple[1]);
1348
+ this.#ensureLegacySpeechProvider('tts',tuple[2]);
993
1349
  this.#providerRuntime.configure(this.#routesFromPreferenceTuple(tuple));
994
1350
  this.#applyPreferenceTuple(tuple);
995
1351
  this.#releaseInactiveLegacyLLMProviders(tuple[0]);
1352
+ this.#releaseInactiveLegacySpeechProviders({
1353
+ stt:tuple[1],
1354
+ tts:tuple[2]
1355
+ });
996
1356
  this.#retainLegacyLLMReadiness(
997
1357
  this.#reconcileLegacyLLMReadiness()
998
1358
  );
1359
+ this.#retainLegacySpeechReadiness(
1360
+ this.#reconcileLegacySpeechReadiness()
1361
+ );
999
1362
  return true;
1000
1363
  }
1001
1364
 
@@ -1004,15 +1367,30 @@ class AI {
1004
1367
  this.#ensureLegacyLLMProvider(
1005
1368
  prepared.llm.default?.providerId
1006
1369
  );
1370
+ this.#ensureLegacySpeechProvider(
1371
+ 'stt',
1372
+ prepared.stt.default?.providerId
1373
+ );
1374
+ this.#ensureLegacySpeechProvider(
1375
+ 'tts',
1376
+ prepared.tts.default?.providerId
1377
+ );
1007
1378
  this.#assertRegisteredLegacyRoutes(prepared);
1008
1379
  const configured=this.#providerRuntime.configure(prepared);
1009
1380
  this.#applyPreferenceTuple(this.#tupleFromProviderRoutes(configured));
1010
1381
  this.#releaseInactiveLegacyLLMProviders(
1011
1382
  configured.llm.default?.providerId
1012
1383
  );
1384
+ this.#releaseInactiveLegacySpeechProviders({
1385
+ stt:configured.stt.default?.providerId,
1386
+ tts:configured.tts.default?.providerId
1387
+ });
1013
1388
  this.#retainLegacyLLMReadiness(
1014
1389
  this.#reconcileLegacyLLMReadiness()
1015
1390
  );
1391
+ this.#retainLegacySpeechReadiness(
1392
+ this.#reconcileLegacySpeechReadiness()
1393
+ );
1016
1394
  return configured;
1017
1395
  }
1018
1396
 
@@ -1036,10 +1414,17 @@ class AI {
1036
1414
  this.stopAudio();
1037
1415
  await this.#unloadProviderRolesForTransition();
1038
1416
  this.#ensureLegacyLLMProvider(tuple[0]);
1417
+ this.#ensureLegacySpeechProvider('stt',tuple[1]);
1418
+ this.#ensureLegacySpeechProvider('tts',tuple[2]);
1039
1419
  this.#providerRuntime.configure(this.#routesFromPreferenceTuple(tuple));
1040
1420
  this.#applyPreferenceTuple(tuple);
1041
1421
  this.#releaseInactiveLegacyLLMProviders(tuple[0]);
1422
+ this.#releaseInactiveLegacySpeechProviders({
1423
+ stt:tuple[1],
1424
+ tts:tuple[2]
1425
+ });
1042
1426
  await this.#reconcileLegacyLLMReadiness();
1427
+ await this.#reconcileLegacySpeechReadiness();
1043
1428
  return this.#providerRuntime.status();
1044
1429
  }
1045
1430
 
@@ -1048,6 +1433,14 @@ class AI {
1048
1433
  this.#ensureLegacyLLMProvider(
1049
1434
  prepared.llm.default?.providerId
1050
1435
  );
1436
+ this.#ensureLegacySpeechProvider(
1437
+ 'stt',
1438
+ prepared.stt.default?.providerId
1439
+ );
1440
+ this.#ensureLegacySpeechProvider(
1441
+ 'tts',
1442
+ prepared.tts.default?.providerId
1443
+ );
1051
1444
  this.#assertRegisteredLegacyRoutes(prepared);
1052
1445
  this.stopAudio();
1053
1446
  await this.#unloadProviderRolesForTransition();
@@ -1056,7 +1449,12 @@ class AI {
1056
1449
  this.#releaseInactiveLegacyLLMProviders(
1057
1450
  configured.llm.default?.providerId
1058
1451
  );
1452
+ this.#releaseInactiveLegacySpeechProviders({
1453
+ stt:configured.stt.default?.providerId,
1454
+ tts:configured.tts.default?.providerId
1455
+ });
1059
1456
  await this.#reconcileLegacyLLMReadiness();
1457
+ await this.#reconcileLegacySpeechReadiness();
1060
1458
  return configured;
1061
1459
  }
1062
1460
 
@@ -1145,6 +1543,127 @@ class AI {
1145
1543
  return null;
1146
1544
  }
1147
1545
 
1546
+ async #requestLegacySpeechTranscription(payload={},signal=null){
1547
+ const audio=payload?.audio;
1548
+ if(!audio||typeof audio.arrayBuffer!=='function'){
1549
+ throw new TypeError('Speech transcription requires an audio Blob or File.');
1550
+ }
1551
+ if(signal?.aborted){
1552
+ throw normalizeAIRequestAbort(signal.reason);
1553
+ }
1554
+ const mimeType=String(payload.mimeType||audio.type||'audio/webm');
1555
+ const model=String(payload.model||this.modelSTT);
1556
+ const nativeSpeech=this.#nativeSpeech(this.sttService,'stt');
1557
+ if(nativeSpeech){
1558
+ const audioBytes=await audio.arrayBuffer();
1559
+ if(signal?.aborted){
1560
+ throw normalizeAIRequestAbort(signal.reason);
1561
+ }
1562
+ const response=await nativeSpeech.transcribe({
1563
+ audioBase64:this.#arrayBufferToBase64(audioBytes),
1564
+ mimeType,
1565
+ model
1566
+ });
1567
+ if(signal?.aborted){
1568
+ throw normalizeAIRequestAbort(signal.reason);
1569
+ }
1570
+ if(!response||typeof response.text!=='string'){
1571
+ throw new TypeError('Arcane returned an invalid local speech transcription.');
1572
+ }
1573
+ return response.text;
1574
+ }
1575
+
1576
+ await this.#assertAndroidSpeechBridge(this.sttService);
1577
+ const formData=new FormData();
1578
+ formData.append('file',audio);
1579
+ formData.append('model',model);
1580
+ formData.append('response_format','text');
1581
+ const response=await fetch(
1582
+ this.urlSTT,
1583
+ {
1584
+ method:'POST',
1585
+ credentials,
1586
+ headers:this.#sttHeaders[this.sttService],
1587
+ body:formData,
1588
+ signal
1589
+ }
1590
+ );
1591
+ if(!response.ok){
1592
+ throw new Error(`Speech transcription failed with status ${response.status}.`);
1593
+ }
1594
+ return response.text();
1595
+ }
1596
+
1597
+ async #requestLegacySpeechSynthesis(payload={},signal=null){
1598
+ const input=typeof payload?.input==='string'?payload.input:'';
1599
+ if(!input){
1600
+ throw new TypeError('Speech synthesis requires nonempty input.');
1601
+ }
1602
+ if(signal?.aborted){
1603
+ throw normalizeAIRequestAbort(signal.reason);
1604
+ }
1605
+ const model=String(payload.model||this.modelTTS);
1606
+ const voice=typeof payload.voice==='string'&&payload.voice.trim()
1607
+ ?payload.voice.trim()
1608
+ :this.#legacySpeechDefaultVoice('tts',this.ttsService);
1609
+ if(!voice){
1610
+ throw new TypeError('The selected speech provider requires a voice.');
1611
+ }
1612
+ const responseFormat=String(payload.responseFormat||this.audioFormat);
1613
+ const speed=Number.isFinite(payload.speed)?payload.speed:this.voiceSpeed;
1614
+ const nativeSpeech=this.#nativeSpeech(this.ttsService,'tts');
1615
+ if(nativeSpeech){
1616
+ const response=await nativeSpeech.synthesize({
1617
+ model,
1618
+ voice,
1619
+ input,
1620
+ responseFormat,
1621
+ speed
1622
+ });
1623
+ if(signal?.aborted){
1624
+ throw normalizeAIRequestAbort(signal.reason);
1625
+ }
1626
+ if(!response||typeof response.audioBase64!=='string'){
1627
+ throw new TypeError('Arcane returned an invalid local speech response.');
1628
+ }
1629
+ return new Blob(
1630
+ [this.#base64ToBytes(response.audioBase64)],
1631
+ {
1632
+ type:typeof response.contentType==='string'
1633
+ ?response.contentType
1634
+ :this.audioType
1635
+ }
1636
+ );
1637
+ }
1638
+
1639
+ 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
1658
+ }
1659
+ );
1660
+ if(!response.ok){
1661
+ throw new Error(`Speech synthesis failed with status ${response.status}.`);
1662
+ }
1663
+ const contentType=response.headers.get('content-type')||this.audioType;
1664
+ return new Blob([await response.arrayBuffer()],{type:contentType});
1665
+ }
1666
+
1148
1667
  async #androidNativeHost(){
1149
1668
  if(typeof globalThis.arcaneAndroid?.postMessage==='function'){
1150
1669
  return true;
@@ -2569,15 +3088,16 @@ class AI {
2569
3088
  if(this.#usesProviderRuntime('tts',this.ttsService)){
2570
3089
  job.abortController=new AbortController();
2571
3090
  const responseFormat=this.#providerSpeechResponseFormat();
3091
+ const voice=this.#providerSpeechVoice();
2572
3092
  const response=await this.#providerRuntime.request(
2573
3093
  'tts',
2574
3094
  {
2575
3095
  operation:'synthesize',
2576
3096
  payload:{
2577
3097
  model:this.#providerRuntime.selection('tts')?.modelId,
2578
- voice:String(window.user?.AI_voice||'af_heart'),
2579
3098
  input:job.text,
2580
3099
  responseFormat,
3100
+ ...(voice?{voice}:{}),
2581
3101
  speed:this.voiceSpeed
2582
3102
  },
2583
3103
  localOnly:false,
@@ -2587,83 +3107,31 @@ class AI {
2587
3107
  return this.#normalizeProviderSpeechAudio(response);
2588
3108
  }
2589
3109
 
2590
- const nativeSpeech=this.#nativeSpeech(this.ttsService,'tts');
2591
-
2592
- if(nativeSpeech){
2593
- const response=await nativeSpeech.synthesize({
3110
+ job.abortController=new AbortController();
3111
+ const response=await this.#requestLegacySpeechSynthesis(
3112
+ {
2594
3113
  model:this.modelTTS,
2595
- voice:String(window.user?.AI_voice||'af_heart'),
2596
3114
  input:job.text,
2597
3115
  responseFormat:this.audioFormat,
2598
3116
  speed:this.voiceSpeed
2599
- });
2600
-
2601
- if(!response||typeof response.audioBase64!=='string'){
2602
- throw new TypeError('Arcane returned an invalid local speech response.');
2603
- }
2604
-
2605
- return {
2606
- chunks:[this.#base64ToBytes(response.audioBase64)],
2607
- type:typeof response.contentType==='string'
2608
- ?response.contentType
2609
- :this.audioType
2610
- };
2611
- }
2612
-
2613
- await this.#assertAndroidSpeechBridge(this.ttsService);
2614
-
2615
- job.abortController=new AbortController();
2616
- const personality=await window.user?.personality
2617
- ||'A behavioral health technician with a slight veteran feel on occasion.';
2618
- const religion=await window.user?.religion||'caring';
2619
- const request={
2620
- model:this.modelTTS,
2621
- voice:window.user?.AI_voice,
2622
- input:job.text,
2623
- speed:this.voiceSpeed,
2624
- instructions:`${personality} and sounding a bit ${religion}`,
2625
- response_format:this.audioFormat
2626
- };
2627
- const response=await fetch(
2628
- this.urlTTS,
2629
- {
2630
- method:'POST',
2631
- credentials,
2632
- headers:this.#ttsHeaders[this.ttsService],
2633
- body:JSON.stringify(request),
2634
- signal:job.abortController.signal
2635
- }
3117
+ },
3118
+ job.abortController.signal
2636
3119
  );
3120
+ return this.#normalizeProviderSpeechAudio(response);
3121
+ }
2637
3122
 
2638
- if(!response.ok){
2639
- throw new Error(`Speech synthesis failed with status ${response.status}.`);
2640
- }
2641
-
2642
- const reader=response.body?.getReader?.();
2643
-
2644
- if(!reader){
2645
- throw new TypeError('Speech synthesis response body is not readable.');
2646
- }
2647
-
2648
- const chunks=[];
2649
-
2650
- try{
2651
- while(true){
2652
- const {done,value}=await reader.read();
2653
-
2654
- if(done){
2655
- break;
2656
- }
2657
-
2658
- if(value){
2659
- chunks.push(value);
2660
- }
2661
- }
2662
- }finally{
2663
- reader.releaseLock?.();
3123
+ #providerSpeechVoice(){
3124
+ const selection=this.#providerRuntime.selection('tts');
3125
+ if(!selection){
3126
+ return null;
2664
3127
  }
2665
-
2666
- return {chunks,type:this.audioType};
3128
+ const provider=this.#providerRuntime.catalog('tts').find(
3129
+ entry=>entry.providerId===selection.providerId
3130
+ );
3131
+ const model=provider?.models.find(entry=>entry?.id===selection.modelId);
3132
+ return typeof model?.defaultVoice==='string'&&model.defaultVoice.trim()
3133
+ ?model.defaultVoice.trim()
3134
+ :null;
2667
3135
  }
2668
3136
 
2669
3137
  #providerSpeechResponseFormat(){
@@ -2829,55 +3297,15 @@ class AI {
2829
3297
  return text;
2830
3298
  }
2831
3299
 
2832
- const nativeSpeech=this.#nativeSpeech(this.sttService,'stt');
2833
-
2834
- if(nativeSpeech){
2835
- if(!audioFile||typeof audioFile.arrayBuffer!=='function'){
2836
- throw new TypeError('Speech transcription requires an audio Blob or File.');
2837
- }
2838
-
2839
- const response=await nativeSpeech.transcribe({
2840
- audioBase64:this.#arrayBufferToBase64(await audioFile.arrayBuffer()),
2841
- mimeType:String(audioFile.type||'audio/webm'),
2842
- model:this.modelSTT
2843
- });
2844
-
2845
- if(!response||typeof response.text!=='string'){
2846
- throw new TypeError('Arcane returned an invalid local speech transcription.');
2847
- }
2848
-
2849
- await responseHandler(response.text);
2850
- return response.text;
2851
- }
2852
-
2853
- await this.#assertAndroidSpeechBridge(this.sttService);
2854
-
2855
- const formData = new FormData();
2856
- formData.append('file', audioFile);
2857
- formData.append('model', this.modelSTT);
2858
- formData.append('response_format', 'text');
2859
-
2860
- const response = await fetch(
2861
- this.urlSTT,
3300
+ const text=await this.#requestLegacySpeechTranscription(
2862
3301
  {
2863
- method: 'POST',
2864
- credentials: credentials,
2865
- headers: this.#sttHeaders[this.sttService],
2866
- body: formData,
2867
- signal
2868
- }
3302
+ audio:audioFile,
3303
+ mimeType:String(audioFile?.type||'audio/webm'),
3304
+ model:this.modelSTT
3305
+ },
3306
+ signal
2869
3307
  );
2870
-
2871
- if(!response.ok){
2872
- throw new Error(`Speech transcription failed with status ${response.status}.`);
2873
- }
2874
-
2875
- const text = await response.text();
2876
-
2877
- //async
2878
3308
  await responseHandler(text);
2879
-
2880
- //sync
2881
3309
  return text;
2882
3310
  }
2883
3311