arcane-os 0.2.2 → 0.2.3

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.
@@ -50,9 +50,10 @@
50
50
  <section class="voice-transcription">
51
51
  <p id="description" class="description"></p>
52
52
  <section id="transcript" class="transcript"></section>
53
- <p id="status" class="status" aria-live="polite"></p>
53
+ <p id="status" class="status" role="status" aria-live="polite"></p>
54
54
  <section class="controls">
55
55
  <button id="start" type="button" disabled></button>
56
+ <button id="sttActivation" type="button" hidden disabled>Start transcription</button>
56
57
  <button id="stop" type="button" disabled></button>
57
58
  <button id="complete" type="button" disabled></button>
58
59
  </section>
@@ -68,21 +69,32 @@
68
69
  const {default:MD}=await import(moduleURL('MD.js?v=2'));
69
70
  const {
70
71
  appendTranscription,
72
+ createSTTActivationController,
71
73
  normalizeVoiceOptions
72
74
  }=await import(moduleURL('ComponentContracts.js'));
75
+ const {
76
+ requestAIRuntimeIntent,
77
+ subscribeAIRuntimeState
78
+ }=await import(moduleURL('AIRuntimeState.js'));
73
79
  const description=host.shadowRoot.querySelector('#description');
74
80
  const transcriptElement=host.shadowRoot.querySelector('#transcript');
75
81
  const status=host.shadowRoot.querySelector('#status');
76
82
  const startButton=host.shadowRoot.querySelector('#start');
83
+ const sttActivationButton=host.shadowRoot.querySelector('#sttActivation');
77
84
  const stopButton=host.shadowRoot.querySelector('#stop');
78
85
  const completeButton=host.shadowRoot.querySelector('#complete');
86
+ const runtimeStateAbortController=new AbortController();
79
87
 
80
88
  let chunks=[];
81
89
  let mediaStream=null;
82
90
  let recorder=null;
83
91
  let state='idle';
92
+ let stateMessage='';
93
+ let sttRole=unavailableRole('stt');
84
94
  let transcript='';
85
95
  let sessionGeneration=0;
96
+ let transcriptionAbortController=null;
97
+ let destroyed=false;
86
98
  let options=normalizeVoiceOptions(optionsFromDataset());
87
99
 
88
100
  host.clear=clear;
@@ -92,6 +104,10 @@
92
104
  host.reset=clear;
93
105
  host.startRecording=startRecording;
94
106
  host.stopRecording=stopRecording;
107
+ host.requestSTTActivation=host.requestSTTActivation
108
+ ||function requestSTTActivation(intent){
109
+ return requestAIRuntimeIntent(intent);
110
+ };
95
111
 
96
112
  Object.defineProperties(
97
113
  host,
@@ -105,6 +121,39 @@
105
121
  startButton.addEventListener('click',startRecording);
106
122
  stopButton.addEventListener('click',stopRecording);
107
123
  completeButton.addEventListener('click',completeStream);
124
+ window.addEventListener('pagehide',destroy,{once:true});
125
+
126
+ const sttActivationController=createSTTActivationController(
127
+ {
128
+ host,
129
+ button:sttActivationButton,
130
+ onChange:renderState
131
+ }
132
+ );
133
+
134
+ function unavailableRole(role){
135
+ return Object.freeze(
136
+ {
137
+ role,
138
+ state:'unavailable',
139
+ providerId:null,
140
+ modelId:null,
141
+ localOnly:null,
142
+ loaded:false,
143
+ busy:false,
144
+ operationId:null,
145
+ progress:null,
146
+ error:null
147
+ }
148
+ );
149
+ }
150
+
151
+ function canStartVoiceRecording(role,workflowState,componentDestroyed){
152
+ return !componentDestroyed
153
+ &&role?.state==='ready'
154
+ &&!role.busy
155
+ &&['idle','error'].includes(workflowState);
156
+ }
108
157
 
109
158
  function optionsFromDataset(){
110
159
  const dataset=host.dataset||{};
@@ -135,13 +184,13 @@
135
184
  if(setInitial){
136
185
  setValue(options.initialValue);
137
186
  }else{
138
- setState(state,status.innerText);
187
+ renderState();
139
188
  }
140
189
  return getOptions();
141
190
  }
142
191
 
143
192
  async function startRecording(){
144
- if(!['idle','error'].includes(state)){
193
+ if(!canStartVoiceRecording(sttRole,state,destroyed)){
145
194
  return false;
146
195
  }
147
196
  if(
@@ -152,41 +201,82 @@
152
201
  return false;
153
202
  }
154
203
 
155
- setState('starting',options.messages.requesting);
156
- chunks=[];
157
204
  const generation=++sessionGeneration;
205
+ chunks=[];
206
+ setState('starting',options.messages.requesting);
207
+ if(!recordingStartIsCurrent(generation,'starting')){
208
+ return rejectRecordingStart(generation);
209
+ }
158
210
  try{
159
211
  const stream=await navigator.mediaDevices.getUserMedia(
160
212
  options.mediaConstraints
161
213
  );
162
- if(generation!==sessionGeneration){
163
- for(const track of stream.getTracks()){
164
- track.stop();
165
- }
166
- return false;
214
+ if(!recordingStartIsCurrent(generation,'starting')){
215
+ return rejectRecordingStart(generation,stream);
167
216
  }
168
217
  mediaStream=stream;
169
- recorder=createRecorder(mediaStream);
170
- recorder.addEventListener('dataavailable',collectAudio);
171
- recorder.addEventListener('stop',finishRecording,{once:true});
172
- recorder.addEventListener('error',recordingError,{once:true});
218
+ const activeRecorder=createRecorder(mediaStream);
219
+ recorder=activeRecorder;
220
+ activeRecorder.addEventListener('dataavailable',collectAudio);
221
+ activeRecorder.addEventListener('stop',finishRecording,{once:true});
222
+ activeRecorder.addEventListener('error',recordingError,{once:true});
173
223
  for(const track of mediaStream.getAudioTracks()){
174
224
  track.addEventListener('ended',recordingInterrupted,{once:true});
175
225
  }
176
- recorder.start();
226
+ activeRecorder.start();
227
+ if(!recordingStartIsCurrent(generation,'starting')
228
+ ||recorder!==activeRecorder){
229
+ return rejectAssignedRecordingStart(generation,stream);
230
+ }
177
231
  setState('recording',options.messages.recording);
232
+ if(!recordingStartIsCurrent(generation,'recording')
233
+ ||recorder!==activeRecorder){
234
+ return rejectAssignedRecordingStart(generation,stream);
235
+ }
178
236
  return true;
179
237
  }catch(error){
180
- if(generation!==sessionGeneration){
238
+ if(generation!==sessionGeneration||destroyed){
181
239
  return false;
182
240
  }
183
241
  releaseMicrophone();
184
- setState('error',options.messages.startError);
185
242
  console.error('Unable to start recording:',error);
243
+ setState('error',options.messages.startError);
186
244
  return false;
187
245
  }
188
246
  }
189
247
 
248
+ function recordingStartIsCurrent(generation,expectedState){
249
+ return isCurrentVoiceOperation(generation,expectedState)
250
+ &&sttRole.state==='ready'
251
+ &&!sttRole.busy;
252
+ }
253
+
254
+ function isCurrentVoiceOperation(generation,expectedState){
255
+ return !destroyed
256
+ &&generation===sessionGeneration
257
+ &&state===expectedState;
258
+ }
259
+
260
+ function rejectRecordingStart(generation,stream=null){
261
+ if(mediaStream===stream){
262
+ releaseMicrophone();
263
+ }else{
264
+ stopMediaStream(stream);
265
+ }
266
+ if(generation===sessionGeneration
267
+ &&!destroyed
268
+ &&['starting','recording'].includes(state)){
269
+ setState('idle',options.messages.ready);
270
+ }
271
+ return false;
272
+ }
273
+
274
+ function rejectAssignedRecordingStart(generation,stream){
275
+ return mediaStream===stream
276
+ ?rejectRecordingStart(generation,stream)
277
+ :false;
278
+ }
279
+
190
280
  function createRecorder(stream){
191
281
  for(const mimeType of options.mimeTypes){
192
282
  if(MediaRecorder.isTypeSupported?.(mimeType)){
@@ -206,14 +296,26 @@
206
296
  if(state!=='recording'||recorder?.state!=='recording'){
207
297
  return false;
208
298
  }
299
+ const generation=sessionGeneration;
300
+ const activeRecorder=recorder;
209
301
  setState('transcribing',options.messages.transcribing);
210
- recorder.stop();
211
- return true;
302
+ if(!isCurrentVoiceOperation(generation,'transcribing')
303
+ ||recorder!==activeRecorder
304
+ ||activeRecorder.state!=='recording'){
305
+ return false;
306
+ }
307
+ activeRecorder.stop();
308
+ return isCurrentVoiceOperation(generation,'transcribing');
212
309
  }
213
310
 
214
311
  async function finishRecording(){
312
+ const generation=sessionGeneration;
313
+ if(!isCurrentVoiceOperation(generation,'transcribing')){
314
+ return false;
315
+ }
215
316
  const mimeType=recorder?.mimeType||chunks[0]?.type||'audio/webm';
216
317
  const audio=new Blob(chunks,{type:mimeType});
318
+ let controller=null;
217
319
  releaseMicrophone();
218
320
  if(!audio.size){
219
321
  setState('error',options.messages.emptyAudio);
@@ -221,20 +323,31 @@
221
323
  }
222
324
 
223
325
  try{
326
+ controller=new AbortController();
327
+ transcriptionAbortController?.abort();
328
+ transcriptionAbortController=controller;
224
329
  const file=new File(
225
330
  [audio],
226
331
  `audio.${fileExtension(mimeType)}`,
227
332
  {type:mimeType}
228
333
  );
229
- const transcribe=await getTranscriber();
230
- const result=await transcribe(
334
+ const result=await transcribeAudio(
231
335
  file,
232
336
  {
233
337
  audio,
234
338
  mimeType,
235
- transcript
236
- }
339
+ transcript,
340
+ signal:controller.signal
341
+ },
342
+ controller.signal
237
343
  );
344
+ releaseTranscriptionController(controller);
345
+ if(controller.signal.aborted
346
+ ||generation!==sessionGeneration
347
+ ||sttRole.state!=='ready'
348
+ ||destroyed){
349
+ return false;
350
+ }
238
351
  const segment=typeof result==='string'?result.trim():'';
239
352
  if(!segment){
240
353
  setState('idle',options.messages.noSpeech);
@@ -257,43 +370,93 @@
257
370
  return false;
258
371
  }
259
372
  setState('saving',options.messages.saving);
373
+ if(!isCurrentVoiceOperation(generation,'saving')){
374
+ return false;
375
+ }
260
376
  try{
261
377
  await save({transcript,segment});
262
378
  }catch(error){
263
- setState('error',options.messages.saveError);
379
+ if(!isCurrentVoiceOperation(generation,'saving')){
380
+ return false;
381
+ }
264
382
  console.error('Unable to save transcription:',error);
383
+ setState('error',options.messages.saveError);
265
384
  return false;
266
385
  }
267
386
  }
268
387
 
388
+ const successState=options.persist?'saving':'transcribing';
269
389
  const detail={text:segment,transcript};
270
- host.dispatchEvent(
271
- new CustomEvent(
272
- 'speech-transcription-complete',
273
- {bubbles:true,composed:true,detail}
274
- )
390
+ return reportTranscriptionSuccess(
391
+ generation,
392
+ successState,
393
+ detail,
394
+ options.persist
395
+ ?options.messages.saved
396
+ :options.messages.transcribed
275
397
  );
276
- host.dispatchEvent(
277
- new CustomEvent(
278
- 'voice-transcription-segment',
279
- {bubbles:true,composed:true,detail}
280
- )
281
- );
282
- setState(
283
- 'idle',
284
- options.persist?options.messages.saved:options.messages.transcribed
285
- );
286
- return true;
287
398
  }catch(error){
288
- setState('error',options.messages.transcribeError);
399
+ if(generation!==sessionGeneration||destroyed){
400
+ return false;
401
+ }
402
+ if(isTranscriptionCancellation(error,controller)){
403
+ releaseTranscriptionController(controller);
404
+ reportSTTCancellation(
405
+ 'stt-provider-request-cancelled',
406
+ options.messages.cancelled
407
+ );
408
+ return false;
409
+ }
289
410
  console.error('Unable to transcribe recording:',error);
411
+ setState('error',options.messages.transcribeError);
290
412
  return false;
413
+ }finally{
414
+ releaseTranscriptionController(controller);
291
415
  }
292
416
  }
293
417
 
294
- async function getTranscriber(){
418
+ function reportTranscriptionSuccess(
419
+ generation,
420
+ expectedState,
421
+ detail,
422
+ message
423
+ ){
424
+ if(!isCurrentVoiceOperation(generation,expectedState)){
425
+ return false;
426
+ }
427
+ host.dispatchEvent(
428
+ new CustomEvent(
429
+ 'speech-transcription-complete',
430
+ {bubbles:true,composed:true,detail}
431
+ )
432
+ );
433
+ if(!isCurrentVoiceOperation(generation,expectedState)){
434
+ return false;
435
+ }
436
+ host.dispatchEvent(
437
+ new CustomEvent(
438
+ 'voice-transcription-segment',
439
+ {bubbles:true,composed:true,detail}
440
+ )
441
+ );
442
+ if(!isCurrentVoiceOperation(generation,expectedState)){
443
+ return false;
444
+ }
445
+ setState('idle',message);
446
+ return isCurrentVoiceOperation(generation,'idle');
447
+ }
448
+
449
+ function releaseTranscriptionController(controller){
450
+ if(transcriptionAbortController!==controller){
451
+ return false;
452
+ }
453
+ transcriptionAbortController=null;
454
+ return true;
455
+ }
456
+
457
+ async function transcribeAudio(file,context,signal){
295
458
  if(options.transcribe){
296
- return options.transcribe;
459
+ return options.transcribe(file,context);
297
460
  }
298
461
  if(typeof globalThis.ai?.fetchSTT!=='function'){
299
462
  await import(moduleURL('AI.js?v=7'));
@@ -301,7 +464,16 @@
301
464
  if(typeof globalThis.ai?.fetchSTT!=='function'){
302
465
  throw new Error('Speech transcription is not configured.');
303
466
  }
304
- return globalThis.ai.fetchSTT.bind(globalThis.ai);
467
+ return globalThis.ai.fetchSTT(file,undefined,signal);
468
+ }
469
+
470
+ function isTranscriptionCancellation(error,controller){
471
+ return controller?.signal.aborted
472
+ ||error?.name==='AbortError'
473
+ ||[
474
+ 'ARCANE_AI_REQUEST_ABORTED',
475
+ 'ARCANE_AI_OPERATION_SUPERSEDED'
476
+ ].includes(error?.code);
305
477
  }
306
478
 
307
479
  function fileExtension(mimeType=''){
@@ -330,16 +502,24 @@
330
502
  }
331
503
 
332
504
  async function completeStream(){
333
- if(!transcript.trim()||!['idle','error'].includes(state)){
505
+ if(destroyed||!transcript.trim()||!['idle','error'].includes(state)){
334
506
  return false;
335
507
  }
508
+ const generation=sessionGeneration;
509
+ const completionTranscript=transcript;
510
+ const complete=options.onComplete||(
511
+ typeof host.complete==='function'?host.complete.bind(host):null
512
+ );
336
513
  setState('completing',options.messages.completing);
514
+ if(!isCurrentVoiceOperation(generation,'completing')){
515
+ return false;
516
+ }
337
517
  try{
338
- const complete=options.onComplete||(
339
- typeof host.complete==='function'?host.complete.bind(host):null
340
- );
341
518
  if(complete){
342
- await complete({transcript});
519
+ await complete({transcript:completionTranscript});
520
+ }
521
+ if(!isCurrentVoiceOperation(generation,'completing')){
522
+ return false;
343
523
  }
344
524
  host.dispatchEvent(
345
525
  new CustomEvent(
@@ -347,25 +527,58 @@
347
527
  {
348
528
  bubbles:true,
349
529
  composed:true,
350
- detail:{transcript}
530
+ detail:{transcript:completionTranscript}
351
531
  }
352
532
  )
353
533
  );
534
+ if(!isCurrentVoiceOperation(generation,'completing')){
535
+ return false;
536
+ }
354
537
  setState('complete',options.messages.complete);
355
- return true;
538
+ return isCurrentVoiceOperation(generation,'complete');
356
539
  }catch(error){
357
- setState('error',options.messages.completeError);
540
+ if(!isCurrentVoiceOperation(generation,'completing')){
541
+ return false;
542
+ }
358
543
  console.error('Unable to complete transcription:',error);
544
+ setState('error',options.messages.completeError);
545
+ return false;
546
+ }
547
+ }
548
+
549
+ function supersedeForTranscriptReplacement(){
550
+ const hadActiveOperation=[
551
+ 'starting',
552
+ 'transcribing',
553
+ 'saving',
554
+ 'completing'
555
+ ].includes(state)||Boolean(transcriptionAbortController);
556
+ if(!hadActiveOperation){
359
557
  return false;
360
558
  }
559
+ sessionGeneration+=1;
560
+ transcriptionAbortController?.abort();
561
+ transcriptionAbortController=null;
562
+ releaseMicrophone();
563
+ return true;
361
564
  }
362
565
 
363
566
  function setValue(value=''){
567
+ const operationSuperseded=
568
+ supersedeForTranscriptReplacement();
364
569
  transcript=`${value??''}`;
365
570
  renderTranscript();
366
- if(state!=='recording'){
571
+ if(operationSuperseded){
572
+ reportSTTCancellation(
573
+ 'transcript-replaced',
574
+ options.messages.transcriptReplaced
575
+ );
576
+ }else if(state!=='recording'){
367
577
  setState('idle',options.messages.ready);
368
578
  }
579
+ if(destroyed){
580
+ return transcript;
581
+ }
369
582
  host.dispatchEvent(
370
583
  new CustomEvent(
371
584
  'voice-transcription-change',
@@ -412,13 +625,21 @@
412
625
  }
413
626
 
414
627
  function releaseMicrophone(){
415
- const tracks=mediaStream?.getTracks?.()||[];
628
+ const stream=mediaStream;
416
629
  recorder?.removeEventListener('dataavailable',collectAudio);
417
630
  recorder?.removeEventListener('stop',finishRecording);
418
631
  recorder?.removeEventListener('error',recordingError);
632
+ if(recorder?.state==='recording'){
633
+ recorder.stop();
634
+ }
419
635
  mediaStream=null;
420
636
  recorder=null;
421
637
  chunks=[];
638
+ stopMediaStream(stream);
639
+ }
640
+
641
+ function stopMediaStream(stream){
642
+ const tracks=stream?.getTracks?.()||[];
422
643
  for(const track of tracks){
423
644
  track.removeEventListener('ended',recordingInterrupted);
424
645
  track.stop();
@@ -427,24 +648,153 @@
427
648
 
428
649
  function setState(nextState,message=''){
429
650
  state=nextState;
430
- status.innerText=message||nextState;
651
+ stateMessage=message||nextState;
652
+ renderState();
653
+ }
654
+
655
+ function synchronizeAIRuntimeState(snapshot){
656
+ const previousRole=sttRole;
657
+ sttRole=snapshot.roles.stt;
658
+ sttActivationController.synchronize(sttRole);
659
+ if(previousRole.state==='ready'&&sttRole.state!=='ready'){
660
+ if(cancelSTTOperation('runtime-unready')){
661
+ return;
662
+ }
663
+ }
664
+ renderState();
665
+ }
666
+
667
+ function cancelSTTOperation(reason){
668
+ const hadActiveOperation=['starting','recording','transcribing'].includes(state)
669
+ ||Boolean(mediaStream)
670
+ ||Boolean(recorder)
671
+ ||Boolean(transcriptionAbortController);
672
+ if(!hadActiveOperation){
673
+ return false;
674
+ }
675
+ sessionGeneration+=1;
676
+ transcriptionAbortController?.abort();
677
+ transcriptionAbortController=null;
678
+ releaseMicrophone();
679
+ reportSTTCancellation(
680
+ reason,
681
+ 'Transcription stopped because the selected service became unavailable.'
682
+ );
683
+ return true;
684
+ }
685
+
686
+ function reportSTTCancellation(reason,message){
687
+ state='idle';
688
+ stateMessage=message;
689
+ host.dispatchEvent(
690
+ new CustomEvent(
691
+ 'speech-transcription-cancelled',
692
+ {
693
+ bubbles:true,
694
+ composed:true,
695
+ detail:Object.freeze({reason})
696
+ }
697
+ )
698
+ );
699
+ if(!destroyed){
700
+ renderState();
701
+ }
702
+ }
703
+
704
+ function renderState(){
705
+ status.innerText=runtimeStatusMessage();
431
706
  status.classList.toggle('recording',state==='recording');
432
- startButton.disabled=!['idle','error'].includes(state);
707
+ status.setAttribute(
708
+ 'aria-busy',
709
+ String(
710
+ !destroyed
711
+ &&(
712
+ sttActivationController.pending
713
+ ||['loading','unloading'].includes(sttRole.state)
714
+ ||sttRole.busy
715
+ ||['starting','transcribing','saving','completing'].includes(state)
716
+ )
717
+ )
718
+ );
719
+ startButton.disabled=!canStartVoiceRecording(sttRole,state,destroyed);
720
+ startButton.title=recordingButtonTitle();
721
+ startButton.setAttribute('aria-label',startButton.title);
433
722
  stopButton.disabled=state!=='recording';
434
- completeButton.disabled=!transcript.trim()
723
+ completeButton.disabled=destroyed
724
+ ||!transcript.trim()
435
725
  ||!['idle','error'].includes(state);
726
+ sttActivationButton.hidden=destroyed||!sttActivationController.visible;
727
+ sttActivationButton.disabled=destroyed
728
+ ||sttActivationController.pending
729
+ ||sttRole.state==='unloading'
730
+ ||sttActivationController.action===null;
731
+ sttActivationButton.textContent=sttActivationController.label;
732
+ sttActivationButton.title=sttActivationController.title;
733
+ sttActivationButton.setAttribute('aria-label',sttActivationButton.title);
734
+ sttActivationButton.setAttribute(
735
+ 'aria-busy',
736
+ String(
737
+ sttActivationController.pending
738
+ ||['loading','unloading'].includes(sttRole.state)
739
+ )
740
+ );
436
741
  host.dispatchEvent(
437
742
  new CustomEvent(
438
743
  'voice-transcription-state',
439
744
  {
440
745
  bubbles:true,
441
746
  composed:true,
442
- detail:{message:status.innerText,state}
747
+ detail:Object.freeze(
748
+ {
749
+ message:status.innerText,
750
+ state,
751
+ stt:sttRole
752
+ }
753
+ )
443
754
  }
444
755
  )
445
756
  );
446
757
  }
447
758
 
759
+ function recordingButtonTitle(){
760
+ if(destroyed||sttRole.state==='disposed'){
761
+ return 'Transcription is unavailable.';
762
+ }
763
+ if(sttRole.state==='loading'){
764
+ return 'The selected transcription service is loading.';
765
+ }
766
+ if(sttRole.state==='error'){
767
+ return visibleErrorMessage(
768
+ sttRole.error,
769
+ 'The selected transcription service reported an error.'
770
+ );
771
+ }
772
+ if(sttRole.state!=='ready'){
773
+ return 'The selected transcription service is unavailable.';
774
+ }
775
+ if(sttRole.busy){
776
+ return 'The selected transcription service is busy.';
777
+ }
778
+ return options.labels.start;
779
+ }
780
+
781
+ function runtimeStatusMessage(){
782
+ if(destroyed){
783
+ return 'Transcription unavailable.';
784
+ }
785
+ return sttRole.state==='ready'
786
+ &&!sttRole.busy
787
+ &&!sttActivationController.pending
788
+ &&!sttActivationController.error
789
+ ?stateMessage
790
+ :sttActivationController.status;
791
+ }
792
+
793
+ function visibleErrorMessage(error,fallback){
794
+ const message=typeof error?.message==='string'?error.message.trim():'';
795
+ return (message||fallback).slice(0,240);
796
+ }
797
+
448
798
  function getOptions(){
449
799
  return {
450
800
  ...options,
@@ -456,9 +806,20 @@
456
806
  }
457
807
 
458
808
  function destroy(){
459
- sessionGeneration++;
460
- releaseMicrophone();
461
- setState('idle',options.messages.ready);
809
+ if(destroyed){
810
+ return true;
811
+ }
812
+ destroyed=true;
813
+ runtimeStateAbortController.abort();
814
+ sttActivationController.destroy();
815
+ window.removeEventListener('pagehide',destroy);
816
+ if(!cancelSTTOperation('component-destroyed')){
817
+ sessionGeneration++;
818
+ }
819
+ state='idle';
820
+ stateMessage='Transcription unavailable.';
821
+ host.ready=false;
822
+ renderState();
462
823
  return true;
463
824
  }
464
825
 
@@ -466,11 +827,19 @@
466
827
  transcript=options.initialValue;
467
828
  renderTranscript();
468
829
  setState('idle',options.messages.ready);
469
- host.ready=true;
470
- host.dispatchEvent(
471
- new CustomEvent(
472
- 'voice-transcription-ready',
473
- {bubbles:true,composed:true}
474
- )
475
- );
830
+ if(!destroyed){
831
+ subscribeAIRuntimeState(
832
+ synchronizeAIRuntimeState,
833
+ {signal:runtimeStateAbortController.signal}
834
+ );
835
+ }
836
+ if(!destroyed){
837
+ host.ready=true;
838
+ host.dispatchEvent(
839
+ new CustomEvent(
840
+ 'voice-transcription-ready',
841
+ {bubbles:true,composed:true}
842
+ )
843
+ );
844
+ }
476
845
  </script>