arcane-os 0.35.0 → 0.36.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.
@@ -37,6 +37,20 @@
37
37
  margin:0;
38
38
  }
39
39
 
40
+ .controls[data-simple='true'] #start{
41
+ flex:1 1 100%;
42
+ min-height:4rem;
43
+ font-size:1.25em;
44
+ }
45
+
46
+ .voice-transcription[data-simple='true']{
47
+ grid-template-rows:auto auto auto minmax(12em,1fr);
48
+ }
49
+
50
+ [hidden]{
51
+ display:none!important;
52
+ }
53
+
40
54
  .recording{
41
55
  color:var(--danger-color);
42
56
  }
@@ -65,10 +79,10 @@
65
79
  </style>
66
80
 
67
81
  <section class="voice-transcription">
68
- <p id="description" class="description"></p>
69
- <section id="transcript" class="transcript"></section>
82
+ <p id="description" class="description" part="description"></p>
83
+ <section id="transcript" class="transcript" part="transcript"></section>
70
84
  <div class="transcription-feedback">
71
- <p id="status" class="status" role="status" aria-live="polite"></p>
85
+ <p id="status" class="status" part="status" role="status" aria-live="polite"></p>
72
86
  <progress
73
87
  id="sttActivationProgress"
74
88
  class="stt-activation-progress"
@@ -76,11 +90,13 @@
76
90
  hidden
77
91
  ></progress>
78
92
  </div>
79
- <section class="controls">
80
- <button id="start" type="button" disabled></button>
93
+ <section class="controls" part="controls">
94
+ <button id="start" part="primary-action" type="button" disabled></button>
81
95
  <button id="sttActivation" type="button" hidden disabled>Start transcription</button>
82
- <button id="stop" type="button" disabled></button>
83
- <button id="complete" type="button" disabled></button>
96
+ <button id="stop" part="stop-action" type="button" disabled></button>
97
+ <button id="retry" part="retry-action" type="button" hidden></button>
98
+ <button id="cancel" part="cancel-action" type="button" hidden></button>
99
+ <button id="complete" part="complete-action" type="button" disabled></button>
84
100
  </section>
85
101
  </section>
86
102
 
@@ -105,6 +121,8 @@
105
121
  'voice-transcription-ready',
106
122
  'voice-transcription-state',
107
123
  'voice-transcription-segment',
124
+ 'voice-transcription-empty',
125
+ 'voice-transcription-error',
108
126
  'voice-transcription-change',
109
127
  'voice-transcription-complete',
110
128
  'speech-transcription-complete',
@@ -114,16 +132,17 @@
114
132
  ]
115
133
  }
116
134
  );
117
- const {default:MD}=await import('../modules/MD.js?v=2');
118
- const {
119
- appendTranscription,
120
- createSTTActivationController,
121
- normalizeVoiceOptions
122
- }=await import('../modules/ComponentContracts.js');
123
- const {
124
- requestAIRuntimeIntent,
125
- subscribeAIRuntimeState
126
- }=await import('../modules/AIRuntimeState.js');
135
+ const [
136
+ {default:MD},
137
+ {appendTranscription,createSTTActivationController,normalizeVoiceOptions},
138
+ {requestAIRuntimeIntent,subscribeAIRuntimeState},
139
+ {default:ContinuousVoiceCapture}
140
+ ]=await Promise.all([
141
+ import('../modules/MD.js?v=2'),
142
+ import('../modules/ComponentContracts.js'),
143
+ import('../modules/AIRuntimeState.js'),
144
+ import('../modules/ContinuousVoiceCapture.js')
145
+ ]);
127
146
  const description=host.shadowRoot.querySelector('#description');
128
147
  const transcriptElement=host.shadowRoot.querySelector('#transcript');
129
148
  const status=host.shadowRoot.querySelector('#status');
@@ -131,6 +150,10 @@
131
150
  const sttActivationButton=host.shadowRoot.querySelector('#sttActivation');
132
151
  const sttActivationProgress = host.shadowRoot.querySelector('#sttActivationProgress');
133
152
  const stopButton=host.shadowRoot.querySelector('#stop');
153
+ const retryButton=host.shadowRoot.querySelector('#retry');
154
+ const cancelButton=host.shadowRoot.querySelector('#cancel');
155
+ const controlsElement=host.shadowRoot.querySelector('.controls');
156
+ const feedbackElement=host.shadowRoot.querySelector('.transcription-feedback');
134
157
  const completeButton=host.shadowRoot.querySelector('#complete');
135
158
  const runtimeStateAbortController=new AbortController();
136
159
 
@@ -146,6 +169,8 @@
146
169
  let eventOperationSequence=0;
147
170
  let voiceOperationId=null;
148
171
  let destroyed=false;
172
+ let continuousSession=null;
173
+ let completionAbortController=null;
149
174
  let options=normalizeVoiceOptions(optionsFromDataset());
150
175
 
151
176
  function nextVoiceOperationId(kind){
@@ -216,6 +241,8 @@
216
241
  host.reset=clear;
217
242
  host.startRecording=startRecording;
218
243
  host.stopRecording=stopRecording;
244
+ host.retryTranscription=retryTranscription;
245
+ host.cancelRecording=cancelRecording;
219
246
  host.requestSTTActivation=host.requestSTTActivation
220
247
  ||function requestSTTActivation(intent){
221
248
  return requestAIRuntimeIntent(intent);
@@ -233,8 +260,10 @@
233
260
  const lifecycleListenerOptions=completeValue(
234
261
  {signal:runtimeStateAbortController.signal}
235
262
  );
236
- startButton.addEventListener('click',startRecording,lifecycleListenerOptions);
263
+ startButton.addEventListener('click',handlePrimaryClick,lifecycleListenerOptions);
237
264
  stopButton.addEventListener('click',stopRecording,lifecycleListenerOptions);
265
+ retryButton.addEventListener('click',retryTranscription,lifecycleListenerOptions);
266
+ cancelButton.addEventListener('click',cancelRecording,lifecycleListenerOptions);
238
267
  completeButton.addEventListener('click',completeStream,lifecycleListenerOptions);
239
268
  window.addEventListener(
240
269
  'pagehide',
@@ -327,7 +356,419 @@
327
356
  return getOptions();
328
357
  }
329
358
 
359
+ function handlePrimaryClick(){
360
+ if(options.controls==='simple'&&continuousSession?.listening){
361
+ return stopRecording();
362
+ }
363
+ return startRecording();
364
+ }
365
+
366
+ function isCurrentContinuousSession(session){
367
+ return !destroyed&&continuousSession===session
368
+ &&session.generation===sessionGeneration
369
+ &&!session.controller.signal.aborted;
370
+ }
371
+
372
+ async function startContinuousRecording(){
373
+ if(!canStartVoiceRecording(sttRole,state,destroyed)
374
+ ||continuousSession?.queue.length){
375
+ return false;
376
+ }
377
+ releaseContinuousSession();
378
+ const session={
379
+ generation:++sessionGeneration,
380
+ controller:new AbortController(),
381
+ requestController:null,
382
+ capture:null,
383
+ captureState:'starting',
384
+ listening:true,
385
+ flushing:false,
386
+ processing:'idle',
387
+ queue:[],
388
+ failure:null,
389
+ captureError:null,
390
+ worker:null,
391
+ waiters:[]
392
+ };
393
+ continuousSession=session;
394
+ setState('starting',options.messages.requesting,{beginOperation:'transcription'});
395
+ try{
396
+ if(!isCurrentContinuousSession(session)||!session.listening){
397
+ return false;
398
+ }
399
+ session.capture=new ContinuousVoiceCapture({
400
+ ...options.capture,
401
+ onSegment:function capturedSegment(clip){
402
+ if(!isCurrentContinuousSession(session)){
403
+ return;
404
+ }
405
+ session.queue.push({...clip,stage:'transcribe',segment:null});
406
+ processContinuousQueue(session);
407
+ },
408
+ onState:function captureStateChanged(nextState){
409
+ if(!isCurrentContinuousSession(session)){
410
+ return;
411
+ }
412
+ session.captureState=nextState;
413
+ if(nextState==='stopped'||nextState==='interrupted'){
414
+ session.listening=false;
415
+ }
416
+ renderContinuousState(session);
417
+ },
418
+ onError:function captureFailed(error){
419
+ if(!isCurrentContinuousSession(session)){
420
+ return;
421
+ }
422
+ session.captureError=error;
423
+ session.captureState='interrupted';
424
+ session.listening=false;
425
+ reportContinuousError(session,'capture',error);
426
+ settleContinuousDrain(session);
427
+ }
428
+ });
429
+ const started=await session.capture.start({
430
+ mediaConstraints:options.mediaConstraints,
431
+ signal:session.controller.signal
432
+ });
433
+ if(!isCurrentContinuousSession(session)){
434
+ session.capture.cancel();
435
+ return false;
436
+ }
437
+ if(!started){
438
+ session.listening=false;
439
+ if(!session.captureError){
440
+ session.captureState='stopped';
441
+ }
442
+ }
443
+ renderContinuousState(session);
444
+ settleContinuousDrain(session);
445
+ return started;
446
+ }catch(error){
447
+ if(!isCurrentContinuousSession(session)){
448
+ return false;
449
+ }
450
+ session.listening=false;
451
+ session.captureError=error;
452
+ session.captureState='interrupted';
453
+ session.capture?.cancel();
454
+ reportContinuousError(session,'capture',error);
455
+ settleContinuousDrain(session);
456
+ return false;
457
+ }
458
+ }
459
+
460
+ function continuousDetails(session){
461
+ return {
462
+ capture:session.captureState,
463
+ processing:session.processing,
464
+ queued:session.queue.length,
465
+ errorPhase:session.failure?.phase||(session.captureError?'capture':null),
466
+ error:session.failure?.error||session.captureError||null
467
+ };
468
+ }
469
+
470
+ function renderContinuousState(session){
471
+ if(!isCurrentContinuousSession(session)){
472
+ return;
473
+ }
474
+ const messages=[];
475
+ if(session.captureError){
476
+ messages.push(options.messages.interrupted);
477
+ }else if(session.listening){
478
+ messages.push(session.captureState==='starting'
479
+ ?options.messages.requesting:options.messages.listening);
480
+ }else if(session.flushing||session.queue.length){
481
+ messages.push(options.messages.draining);
482
+ }
483
+ const processingMessages={
484
+ transcribing:options.messages.transcribing,
485
+ saving:options.messages.saving,
486
+ waiting:options.messages.waiting
487
+ };
488
+ if(session.failure){
489
+ messages.push(session.failure.phase==='save'
490
+ ?options.messages.saveError:options.messages.transcribeError);
491
+ }else if(processingMessages[session.processing]){
492
+ messages.push(processingMessages[session.processing]);
493
+ }
494
+ const nextState=session.listening
495
+ ?(session.captureState==='starting'?'starting':'recording')
496
+ :session.failure||session.captureError?'error'
497
+ :session.flushing||session.queue.length?'transcribing':'idle';
498
+ setState(nextState,messages.join(' ')||options.messages.ready);
499
+ }
500
+
501
+ function reportContinuousError(session,phase,error){
502
+ arcaneLogging.error(`Continuous voice ${phase} failed:`,error);
503
+ renderContinuousState(session);
504
+ if(!isCurrentContinuousSession(session)){
505
+ return;
506
+ }
507
+ const detail={phase,error,sequence:session.queue[0]?.sequence??null};
508
+ dispatchVoiceEvent('voice-transcription-error',detail,{
509
+ bubbles:true,composed:true,publicDetail:detail
510
+ });
511
+ }
512
+
513
+ function waitForContinuousOperation(result,signal){
514
+ return new Promise(function observeContinuousOperation(resolve,reject){
515
+ function aborted(){
516
+ reject(signal.reason||new DOMException('Transcription canceled.','AbortError'));
517
+ }
518
+ if(signal.aborted){
519
+ aborted();
520
+ }else{
521
+ signal.addEventListener('abort',aborted,{once:true});
522
+ }
523
+ Promise.resolve(result).then(function completed(value){
524
+ signal.removeEventListener('abort',aborted);
525
+ resolve(value);
526
+ },function failed(error){
527
+ signal.removeEventListener('abort',aborted);
528
+ reject(error);
529
+ });
530
+ });
531
+ }
532
+
533
+ function processContinuousQueue(session){
534
+ if(!isCurrentContinuousSession(session)||session.worker||session.failure
535
+ ||!session.queue.length){
536
+ return;
537
+ }
538
+ // One ordered transcription/save consumer; microphone capture never waits for it.
539
+ const work=Promise.resolve().then(function beginOrderedDrain(){
540
+ return drainContinuousQueue(session);
541
+ });
542
+ session.worker=work;
543
+ work.then(function queueSettled(){
544
+ if(session.worker===work){
545
+ session.worker=null;
546
+ }
547
+ if(!isCurrentContinuousSession(session)){
548
+ return;
549
+ }
550
+ renderContinuousState(session);
551
+ settleContinuousDrain(session);
552
+ if(session.queue.length&&!session.failure
553
+ &&sttRole.state==='ready'&&!sttRole.busy){
554
+ processContinuousQueue(session);
555
+ }
556
+ },function unexpectedQueueFailure(error){
557
+ if(session.worker===work){
558
+ session.worker=null;
559
+ }
560
+ if(isCurrentContinuousSession(session)){
561
+ session.failure={phase:session.queue[0]?.stage==='save'?'save':'transcribe',error};
562
+ session.processing='error';
563
+ reportContinuousError(session,session.failure.phase,error);
564
+ settleContinuousDrain(session);
565
+ }
566
+ });
567
+ }
568
+
569
+ async function drainContinuousQueue(session){
570
+ while(isCurrentContinuousSession(session)&&session.queue.length&&!session.failure){
571
+ const clip=session.queue[0];
572
+ if(clip.stage==='transcribe'&&(sttRole.state!=='ready'||sttRole.busy)){
573
+ session.processing='waiting';
574
+ renderContinuousState(session);
575
+ return;
576
+ }
577
+ const controller=new AbortController();
578
+ session.requestController=controller;
579
+ try{
580
+ if(clip.stage==='transcribe'){
581
+ session.processing='transcribing';
582
+ renderContinuousState(session);
583
+ if(!isCurrentContinuousSession(session)||controller.signal.aborted){
584
+ return;
585
+ }
586
+ const file=new File([clip.audio],`segment-${clip.sequence}.wav`,{type:clip.audio.type});
587
+ const result=await waitForContinuousOperation(transcribeAudio(file,{
588
+ audio:clip.audio,mimeType:clip.audio.type,transcript,
589
+ sequence:clip.sequence,signal:controller.signal
590
+ },controller.signal),controller.signal);
591
+ if(!isCurrentContinuousSession(session)){
592
+ return;
593
+ }
594
+ if(controller.signal.aborted){
595
+ throw controller.signal.reason||new DOMException('Transcription canceled.','AbortError');
596
+ }
597
+ if(!is.string(result)){
598
+ throw new TypeError('Transcription must return the complete text as a string.');
599
+ }
600
+ clip.segment=result;
601
+ if(result.length===0){
602
+ session.queue.shift();
603
+ const detail={sequence:clip.sequence,transcript};
604
+ dispatchVoiceEvent('voice-transcription-empty',detail,{
605
+ bubbles:true,composed:true,publicDetail:detail
606
+ });
607
+ continue;
608
+ }
609
+ clip.transcript=appendTranscription(transcript,result,options.separator);
610
+ clip.stage='save';
611
+ transcript=clip.transcript;
612
+ renderTranscript();
613
+ }
614
+ if(options.persist){
615
+ const save=options.onSave||(is.function(host.save)?host.save.bind(host):null);
616
+ if(!save){
617
+ throw new Error('Transcription saving is not configured.');
618
+ }
619
+ session.processing='saving';
620
+ renderContinuousState(session);
621
+ if(!isCurrentContinuousSession(session)||controller.signal.aborted){
622
+ return;
623
+ }
624
+ const result=await waitForContinuousOperation(save({
625
+ transcript:clip.transcript,segment:clip.segment,
626
+ sequence:clip.sequence,signal:controller.signal
627
+ }),controller.signal);
628
+ if(result===false){
629
+ throw new Error('The transcription save was not accepted.');
630
+ }
631
+ }
632
+ if(!isCurrentContinuousSession(session)){
633
+ return;
634
+ }
635
+ if(controller.signal.aborted){
636
+ throw controller.signal.reason||new DOMException('Transcription canceled.','AbortError');
637
+ }
638
+ session.queue.shift();
639
+ const detail={text:clip.segment,transcript:clip.transcript,sequence:clip.sequence};
640
+ dispatchVoiceEvent('speech-transcription-complete',detail,{
641
+ bubbles:true,composed:true,publicDetail:detail
642
+ });
643
+ if(!isCurrentContinuousSession(session)){
644
+ return;
645
+ }
646
+ dispatchVoiceEvent('voice-transcription-segment',detail,{
647
+ bubbles:true,composed:true,publicDetail:detail
648
+ });
649
+ }catch(error){
650
+ if(!isCurrentContinuousSession(session)){
651
+ return;
652
+ }
653
+ session.failure={phase:clip.stage==='save'?'save':'transcribe',error};
654
+ session.processing='error';
655
+ reportContinuousError(session,session.failure.phase,error);
656
+ return;
657
+ }finally{
658
+ if(session.requestController===controller){
659
+ session.requestController=null;
660
+ }
661
+ }
662
+ }
663
+ session.processing='idle';
664
+ }
665
+
666
+ function settleContinuousDrain(session){
667
+ if(session.listening||session.flushing||session.worker){
668
+ return;
669
+ }
670
+ if(session.queue.length&&!session.failure){
671
+ return;
672
+ }
673
+ const success=isCurrentContinuousSession(session)
674
+ &&!session.failure&&!session.captureError;
675
+ for(const resolve of session.waiters.splice(0)){
676
+ resolve(success);
677
+ }
678
+ }
679
+
680
+ function continuousDrain(session){
681
+ return new Promise(function awaitContinuousDrain(resolve){
682
+ if(!isCurrentContinuousSession(session)){
683
+ resolve(false);
684
+ return;
685
+ }
686
+ session.waiters.push(resolve);
687
+ settleContinuousDrain(session);
688
+ });
689
+ }
690
+
691
+ async function stopContinuousRecording(session){
692
+ if(!isCurrentContinuousSession(session)){
693
+ return false;
694
+ }
695
+ if((completionAbortController||state==='complete')
696
+ &&!session.listening&&!session.flushing&&!session.queue.length){
697
+ return true;
698
+ }
699
+ if(session.listening){
700
+ session.listening=false;
701
+ session.flushing=true;
702
+ session.captureState='stopped';
703
+ // stop() releases microphone tracks before waiting for its final worklet message.
704
+ const stopping=session.capture?.stop();
705
+ renderContinuousState(session);
706
+ try{
707
+ await stopping;
708
+ }catch(error){
709
+ if(isCurrentContinuousSession(session)){
710
+ session.captureError=error;
711
+ reportContinuousError(session,'capture',error);
712
+ }
713
+ }finally{
714
+ session.flushing=false;
715
+ }
716
+ }
717
+ if(!isCurrentContinuousSession(session)){
718
+ return false;
719
+ }
720
+ processContinuousQueue(session);
721
+ renderContinuousState(session);
722
+ return continuousDrain(session);
723
+ }
724
+
725
+ function retryTranscription(){
726
+ const session=continuousSession;
727
+ if(!session||!isCurrentContinuousSession(session)||!session.failure){
728
+ return false;
729
+ }
730
+ session.failure=null;
731
+ session.processing='idle';
732
+ processContinuousQueue(session);
733
+ renderContinuousState(session);
734
+ return true;
735
+ }
736
+
737
+ function releaseContinuousSession(){
738
+ const session=continuousSession;
739
+ if(!session){
740
+ return false;
741
+ }
742
+ continuousSession=null;
743
+ session.controller.abort();
744
+ session.requestController?.abort();
745
+ session.capture?.cancel();
746
+ session.queue=[];
747
+ for(const resolve of session.waiters.splice(0)){
748
+ resolve(false);
749
+ }
750
+ return true;
751
+ }
752
+
753
+ function cancelRecording(){
754
+ if(destroyed){
755
+ return false;
756
+ }
757
+ const hadContinuous=releaseContinuousSession();
758
+ completionAbortController?.abort();
759
+ completionAbortController=null;
760
+ if(hadContinuous){
761
+ sessionGeneration+=1;
762
+ reportSTTCancellation('user-cancelled',options.messages.cancelled);
763
+ return true;
764
+ }
765
+ return cancelSTTOperation('user-cancelled');
766
+ }
767
+
330
768
  async function startRecording(){
769
+ if(options.capture.mode==='continuous'){
770
+ return startContinuousRecording();
771
+ }
331
772
  if(!canStartVoiceRecording(sttRole,state,destroyed)){
332
773
  return false;
333
774
  }
@@ -433,6 +874,9 @@
433
874
  }
434
875
 
435
876
  function stopRecording(){
877
+ if(continuousSession){
878
+ return stopContinuousRecording(continuousSession);
879
+ }
436
880
  if(destroyed||state!=='recording'||recorder?.state!=='recording'){
437
881
  return false;
438
882
  }
@@ -670,54 +1114,94 @@
670
1114
  }
671
1115
 
672
1116
  async function completeStream(){
673
- if(destroyed||transcript.length===0||!['idle','error'].includes(state)){
1117
+ if(destroyed||completionAbortController||state==='complete'){
674
1118
  return false;
675
1119
  }
676
- const generation=sessionGeneration;
677
- const completionTranscript=transcript;
678
- const complete=options.onComplete||(
679
- is.function(host.complete)?host.complete.bind(host):null
680
- );
681
- setState('completing',options.messages.completing,{
682
- beginOperation:'completion'
683
- });
684
- if(!isCurrentVoiceOperation(generation,'completing')){
1120
+ const session=continuousSession;
1121
+ if(!session&&(transcript.length===0||!['idle','error'].includes(state))){
685
1122
  return false;
686
1123
  }
1124
+ const generation=sessionGeneration;
1125
+ const completionController=new AbortController();
1126
+ // Reserve completion before stopping capture: drain callbacks may reenter.
1127
+ completionAbortController=completionController;
687
1128
  try{
688
- if(complete){
689
- await complete({transcript:completionTranscript});
1129
+ if(session){
1130
+ await stopContinuousRecording(session);
1131
+ if(!isCurrentContinuousSession(session)||session.queue.length){
1132
+ return false;
1133
+ }
690
1134
  }
691
- if(!isCurrentVoiceOperation(generation,'completing')){
1135
+ if(destroyed||completionController.signal.aborted
1136
+ ||completionAbortController!==completionController
1137
+ ||generation!==sessionGeneration||transcript.length===0
1138
+ ||!['idle','error'].includes(state)){
692
1139
  return false;
693
1140
  }
694
- dispatchVoiceEvent(
695
- 'voice-transcription-complete',
696
- {transcript:completionTranscript},
697
- {
698
- bubbles:true,
699
- composed:true,
700
- publicDetail:{
701
- transcript:completionTranscript
702
- }
703
- }
1141
+ const completionTranscript=transcript;
1142
+ const complete=options.onComplete||(
1143
+ is.function(host.complete)?host.complete.bind(host):null
704
1144
  );
1145
+ setState('completing',options.messages.completing,{
1146
+ beginOperation:'completion'
1147
+ });
705
1148
  if(!isCurrentVoiceOperation(generation,'completing')){
706
1149
  return false;
707
1150
  }
708
- setState('complete',options.messages.complete);
709
- return isCurrentVoiceOperation(generation,'complete');
710
- }catch(error){
711
- if(!isCurrentVoiceOperation(generation,'completing')){
1151
+ try{
1152
+ if(complete){
1153
+ const input={transcript:completionTranscript};
1154
+ if(session){
1155
+ input.signal=completionController.signal;
1156
+ }
1157
+ const result=await waitForContinuousOperation(
1158
+ complete(input),completionController.signal
1159
+ );
1160
+ if(result===false&&session){
1161
+ throw new Error('Transcription completion was not accepted.');
1162
+ }
1163
+ }
1164
+ if(!isCurrentVoiceOperation(generation,'completing')){
1165
+ return false;
1166
+ }
1167
+ dispatchVoiceEvent(
1168
+ 'voice-transcription-complete',
1169
+ {transcript:completionTranscript},
1170
+ {
1171
+ bubbles:true,
1172
+ composed:true,
1173
+ publicDetail:{
1174
+ transcript:completionTranscript
1175
+ }
1176
+ }
1177
+ );
1178
+ if(!isCurrentVoiceOperation(generation,'completing')){
1179
+ return false;
1180
+ }
1181
+ setState('complete',options.messages.complete);
1182
+ return isCurrentVoiceOperation(generation,'complete');
1183
+ }catch(error){
1184
+ if(!isCurrentVoiceOperation(generation,'completing')){
1185
+ return false;
1186
+ }
1187
+ arcaneLogging.error('Unable to complete transcription:',error);
1188
+ setState('error',options.messages.completeError);
712
1189
  return false;
713
1190
  }
714
- arcaneLogging.error('Unable to complete transcription:',error);
715
- setState('error',options.messages.completeError);
716
- return false;
1191
+ }finally{
1192
+ if(completionAbortController===completionController){
1193
+ completionAbortController=null;
1194
+ }
717
1195
  }
718
1196
  }
719
1197
 
720
1198
  function supersedeForTranscriptReplacement(){
1199
+ if(releaseContinuousSession()){
1200
+ sessionGeneration+=1;
1201
+ completionAbortController?.abort();
1202
+ completionAbortController=null;
1203
+ return true;
1204
+ }
721
1205
  const hadActiveOperation=[
722
1206
  'starting',
723
1207
  'transcribing',
@@ -728,6 +1212,8 @@
728
1212
  return false;
729
1213
  }
730
1214
  sessionGeneration+=1;
1215
+ completionAbortController?.abort();
1216
+ completionAbortController=null;
731
1217
  transcriptionAbortController?.abort();
732
1218
  transcriptionAbortController=null;
733
1219
  releaseMicrophone();
@@ -735,6 +1221,12 @@
735
1221
  }
736
1222
 
737
1223
  function supersedeForConfigurationReplacement(){
1224
+ if(releaseContinuousSession()){
1225
+ sessionGeneration+=1;
1226
+ completionAbortController?.abort();
1227
+ completionAbortController=null;
1228
+ return true;
1229
+ }
738
1230
  const hadActiveOperation=[
739
1231
  'starting',
740
1232
  'recording',
@@ -746,6 +1238,8 @@
746
1238
  return false;
747
1239
  }
748
1240
  sessionGeneration+=1;
1241
+ completionAbortController?.abort();
1242
+ completionAbortController=null;
749
1243
  transcriptionAbortController?.abort();
750
1244
  transcriptionAbortController=null;
751
1245
  releaseMicrophone();
@@ -784,9 +1278,11 @@
784
1278
  }
785
1279
 
786
1280
  function clear(){
787
- if(destroyed||!['complete','error','idle'].includes(state)){
1281
+ if(destroyed||!['complete','error','idle'].includes(state)
1282
+ ||continuousSession?.queue.length){
788
1283
  return false;
789
1284
  }
1285
+ releaseContinuousSession();
790
1286
  transcript='';
791
1287
  renderTranscript();
792
1288
  setState('idle',options.messages.ready);
@@ -813,6 +1309,17 @@
813
1309
  startButton.innerText=options.labels.start;
814
1310
  stopButton.innerText=options.labels.stop;
815
1311
  completeButton.innerText=options.labels.complete;
1312
+ retryButton.innerText=options.labels.retry;
1313
+ cancelButton.innerText=options.labels.cancel;
1314
+ completeButton.hidden=!options.showComplete;
1315
+ controlsElement.dataset.simple=String(options.controls==='simple');
1316
+ host.shadowRoot.querySelector('.voice-transcription').dataset.simple=
1317
+ String(options.controls==='simple');
1318
+ if(options.controls==='simple'){
1319
+ description.after(controlsElement,feedbackElement,transcriptElement);
1320
+ }else{
1321
+ description.after(transcriptElement,feedbackElement,controlsElement);
1322
+ }
816
1323
  }
817
1324
 
818
1325
  function releaseMicrophone(){
@@ -857,6 +1364,22 @@
857
1364
  arcaneLogging.error('Arcane transcription runtime failed.',sttRole.error);
858
1365
  }
859
1366
  sttActivationController.synchronize(sttRole);
1367
+ if(continuousSession){
1368
+ const session=continuousSession;
1369
+ if(['completing','complete'].includes(state)){
1370
+ renderState();
1371
+ return;
1372
+ }
1373
+ if(session.processing==='transcribing'
1374
+ &&(sttRole.state!=='ready'
1375
+ ||previousRole.providerId!==sttRole.providerId
1376
+ ||previousRole.modelId!==sttRole.modelId)){
1377
+ session.requestController?.abort();
1378
+ }
1379
+ processContinuousQueue(session);
1380
+ renderContinuousState(session);
1381
+ return;
1382
+ }
860
1383
  if(previousRole.state==='ready'&&sttRole.state!=='ready'){
861
1384
  if(cancelSTTOperation('runtime-unready')){
862
1385
  return;
@@ -866,6 +1389,13 @@
866
1389
  }
867
1390
 
868
1391
  function cancelSTTOperation(reason){
1392
+ if(releaseContinuousSession()){
1393
+ sessionGeneration+=1;
1394
+ completionAbortController?.abort();
1395
+ completionAbortController=null;
1396
+ reportSTTCancellation(reason,options.messages.cancelled);
1397
+ return true;
1398
+ }
869
1399
  const hadActiveOperation=['starting','recording','transcribing'].includes(state)
870
1400
  ||Boolean(mediaStream)
871
1401
  ||Boolean(recorder)
@@ -917,13 +1447,26 @@
917
1447
  )
918
1448
  )
919
1449
  );
920
- startButton.disabled=!canStartVoiceRecording(sttRole,state,destroyed);
921
- startButton.title=recordingButtonTitle();
1450
+ const session=continuousSession;
1451
+ const continuousListening=Boolean(session?.listening);
1452
+ const simpleStop=options.controls==='simple'&&continuousListening;
1453
+ startButton.disabled=simpleStop?destroyed
1454
+ :!canStartVoiceRecording(sttRole,state,destroyed)||Boolean(session?.queue.length);
1455
+ startButton.innerText=simpleStop?options.labels.stop:options.labels.start;
1456
+ startButton.title=simpleStop?options.labels.stop:recordingButtonTitle();
922
1457
  startButton.setAttribute('aria-label',startButton.title);
923
- stopButton.disabled=state!=='recording';
1458
+ startButton.setAttribute('aria-pressed',String(continuousListening));
1459
+ stopButton.hidden=options.controls==='simple'&&options.capture.mode==='continuous';
1460
+ stopButton.disabled=destroyed||(session?!continuousListening:state!=='recording');
1461
+ retryButton.hidden=!session?.failure;
1462
+ retryButton.disabled=destroyed;
1463
+ cancelButton.hidden=!session||session.listening
1464
+ ||(!session.flushing&&!session.queue.length&&!completionAbortController);
1465
+ cancelButton.disabled=destroyed;
924
1466
  completeButton.disabled=destroyed
925
1467
  ||transcript.length===0
926
- ||!['idle','error'].includes(state);
1468
+ ||!['idle','error'].includes(state)
1469
+ ||Boolean(session?.queue.length||session?.flushing||session?.listening);
927
1470
  sttActivationButton.hidden=destroyed||!sttActivationController.visible;
928
1471
  sttActivationButton.disabled=destroyed
929
1472
  ||sttActivationController.pending
@@ -945,13 +1488,15 @@
945
1488
  {
946
1489
  message:status.innerText,
947
1490
  state,
948
- stt:sttRole
1491
+ stt:sttRole,
1492
+ ...(session?continuousDetails(session):{})
949
1493
  }
950
1494
  ),
951
1495
  {
952
1496
  bubbles:true,
953
1497
  composed:true,
954
- publicDetail:{message:status.innerText,state,stt:sttRole}
1498
+ publicDetail:{message:status.innerText,state,stt:sttRole,
1499
+ ...(session?continuousDetails(session):{})}
955
1500
  }
956
1501
  );
957
1502
  }
@@ -982,6 +1527,9 @@
982
1527
  if(destroyed){
983
1528
  return 'Transcription unavailable.';
984
1529
  }
1530
+ if(continuousSession){
1531
+ return stateMessage;
1532
+ }
985
1533
  return sttRole.state==='ready'
986
1534
  &&!sttRole.busy
987
1535
  &&!sttActivationController.pending
@@ -1010,12 +1558,18 @@
1010
1558
  labels:{...options.labels},
1011
1559
  messages:{...options.messages},
1012
1560
  mediaConstraints:{...options.mediaConstraints},
1561
+ capture:{...options.capture},
1013
1562
  mimeTypes:options.mimeTypes.slice()
1014
1563
  };
1015
1564
  }
1016
1565
 
1017
1566
  function handlePageHide(event){
1018
1567
  if(event.persisted===true){
1568
+ if(continuousSession?.listening){
1569
+ stopContinuousRecording(continuousSession).catch(function pageCaptureStopFailed(error){
1570
+ arcaneLogging.error('Unable to finish captured speech before page caching:',error);
1571
+ });
1572
+ }
1019
1573
  return;
1020
1574
  }
1021
1575
  destroy();
@@ -1037,6 +1591,8 @@
1037
1591
  return true;
1038
1592
  }
1039
1593
  destroyed=true;
1594
+ completionAbortController?.abort();
1595
+ completionAbortController=null;
1040
1596
  runtimeStateAbortController.abort();
1041
1597
  sttActivationController.destroy();
1042
1598
  window.removeEventListener('pagehide',handlePageHide);