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.
- package/CHANGELOG.md +37 -0
- package/README.md +1 -1
- package/browser-runtime/ai/browser-speech-artifacts.mjs +4 -0
- package/browser-runtime/ai/browser-speech-providers.mjs +1 -1
- package/browser-runtime/ai/browser-speech-synthesis.mjs +568 -0
- package/browser-runtime/ai/browser-speech.mjs +1 -0
- package/browser-runtime/ai/twin-cloud-speech.mjs +148 -55
- package/docs/reference/ai/browser-speech.md +97 -12
- package/docs/reference/runtime-modules.md +34 -5
- package/package.json +1 -1
- package/runtime/arcane/components/speech.html +11 -4
- package/runtime/arcane/modules/AI.js +179 -17
- package/runtime/arcane/modules/SpeechPlayback.js +242 -36
|
@@ -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.
|
|
6576
|
+
const response=await this.prepareTTSPlayback(
|
|
6561
6577
|
payload,
|
|
6562
6578
|
job.abortController.signal,
|
|
6563
6579
|
{speechInputPrepared:true}
|
|
6564
6580
|
);
|
|
6565
|
-
return
|
|
6581
|
+
return response?.kind==='native-speech'
|
|
6582
|
+
?response
|
|
6583
|
+
:this.#normalizeProviderSpeechAudio(response);
|
|
6566
6584
|
}
|
|
6567
6585
|
|
|
6568
|
-
#
|
|
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
|
-
|
|
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=
|
|
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
|
-
&&
|
|
7351
|
-
|
|
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
|
}
|