arcane-os 0.2.2 → 0.3.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.
Files changed (117) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +8 -8
  3. package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +29 -22
  4. package/browser-runtime/ai/ARCANE_AI_BROWSER_SPEECH_COMPONENTS.json +203 -0
  5. package/browser-runtime/ai/browser-kokoro-worker.mjs +11 -2
  6. package/browser-runtime/ai/browser-speech-artifacts.mjs +3230 -397
  7. package/browser-runtime/ai/browser-speech-providers.mjs +1141 -157
  8. package/browser-runtime/ai/browser-speech.mjs +2 -0
  9. package/browser-runtime/ai/browser-whisper-worker.mjs +11 -2
  10. package/browser-runtime/ai/model-controller.mjs +285 -95
  11. package/browser-runtime/ai/speech-worker-client.mjs +247 -32
  12. package/browser-runtime/ai/speech-worker-runtime.mjs +2310 -167
  13. package/browser-runtime/event-manager.mjs +1097 -1
  14. package/docs/architecture.md +2 -2
  15. package/docs/event-manager.md +155 -27
  16. package/docs/reference/README.md +27 -27
  17. package/docs/reference/ai/browser-speech-package-authority.json +835 -0
  18. package/docs/reference/ai/browser-speech.md +1162 -246
  19. package/docs/reference/ai/browser-wasm.md +18 -7
  20. package/docs/reference/availability-and-normalization.md +6 -3
  21. package/docs/reference/behavioral-testing.md +29 -6
  22. package/docs/reference/cli.md +117 -9
  23. package/docs/reference/core/arcane-ai-contracts.md +1 -1
  24. package/docs/reference/event-manager.md +577 -32
  25. package/docs/reference/inventory/package-api.json +478 -2
  26. package/docs/reference/inventory/runtime-components.json +108 -44
  27. package/docs/reference/inventory/runtime-modules.json +131 -53
  28. package/docs/reference/mail.md +316 -0
  29. package/docs/reference/protocols.md +157 -43
  30. package/docs/reference/runtime-components.md +258 -83
  31. package/docs/reference/runtime-modules.md +613 -77
  32. package/docs/reference/sdk-api.md +1014 -25
  33. package/package.json +5 -4
  34. package/runtime/ARCANE_RUNTIME_RELEASE.json +145 -140
  35. package/runtime/arcane/components/app-bar.html +34 -13
  36. package/runtime/arcane/components/assistant-panel.html +110 -57
  37. package/runtime/arcane/components/calculator.html +7 -4
  38. package/runtime/arcane/components/chart.html +58 -17
  39. package/runtime/arcane/components/chat.html +606 -136
  40. package/runtime/arcane/components/conversation-view.html +13 -6
  41. package/runtime/arcane/components/dashboard-config.html +96 -59
  42. package/runtime/arcane/components/data-maintenance.html +69 -14
  43. package/runtime/arcane/components/data-view.html +53 -7
  44. package/runtime/arcane/components/directory-picker.html +118 -32
  45. package/runtime/arcane/components/document-inspector.html +47 -10
  46. package/runtime/arcane/components/file-drop.html +81 -35
  47. package/runtime/arcane/components/file-inspector.html +72 -22
  48. package/runtime/arcane/components/file-manager.html +374 -79
  49. package/runtime/arcane/components/integration-settings.html +12 -5
  50. package/runtime/arcane/components/local-ai-status.html +48 -19
  51. package/runtime/arcane/components/markdown-document.html +161 -68
  52. package/runtime/arcane/components/markdown-editor.html +110 -33
  53. package/runtime/arcane/components/media-embed.html +8 -5
  54. package/runtime/arcane/components/modal.html +15 -5
  55. package/runtime/arcane/components/output-panel.html +28 -23
  56. package/runtime/arcane/components/preferences-form.html +22 -4
  57. package/runtime/arcane/components/record-timeline.html +18 -2
  58. package/runtime/arcane/components/relationship-board.html +23 -3
  59. package/runtime/arcane/components/screen-capture.html +10 -4
  60. package/runtime/arcane/components/source-code-viewer.html +76 -9
  61. package/runtime/arcane/components/source-explanation.html +23 -3
  62. package/runtime/arcane/components/speech.html +462 -384
  63. package/runtime/arcane/components/summary-strip.html +22 -11
  64. package/runtime/arcane/components/table.html +39 -21
  65. package/runtime/arcane/components/task-progress.html +79 -21
  66. package/runtime/arcane/components/terminal-workspace.html +7 -4
  67. package/runtime/arcane/components/theme-editor.html +7 -3
  68. package/runtime/arcane/components/unified-inbox.html +9 -4
  69. package/runtime/arcane/components/voice-transcription.html +639 -98
  70. package/runtime/arcane/components/weather-widget.html +5 -3
  71. package/runtime/arcane/components/web-navigator.html +48 -8
  72. package/runtime/arcane/entities/Chat.js +1 -1
  73. package/runtime/arcane/entities/User.js +110 -23
  74. package/runtime/arcane/modules/AI.js +2109 -130
  75. package/runtime/arcane/modules/AIProviderRuntime.js +720 -13
  76. package/runtime/arcane/modules/AIRuntimeState.js +109 -52
  77. package/runtime/arcane/modules/ApiModelDatabase.js +390 -17
  78. package/runtime/arcane/modules/BrowserTestSuite.js +205 -28
  79. package/runtime/arcane/modules/CalculatorEngine.js +63 -3
  80. package/runtime/arcane/modules/CommunicationAppController.js +588 -28
  81. package/runtime/arcane/modules/CommunicationHub.js +590 -10
  82. package/runtime/arcane/modules/ComponentContracts.js +470 -0
  83. package/runtime/arcane/modules/ConversationTimebox.js +152 -33
  84. package/runtime/arcane/modules/DBLS.js +40 -7
  85. package/runtime/arcane/modules/DBOPFS.js +35 -11
  86. package/runtime/arcane/modules/DataMaintenance.js +12 -2
  87. package/runtime/arcane/modules/Errors.js +65 -7
  88. package/runtime/arcane/modules/HTMLImport.js +198 -14
  89. package/runtime/arcane/modules/LocalAIReadinessController.js +208 -29
  90. package/runtime/arcane/modules/Mail.js +738 -115
  91. package/runtime/arcane/modules/MailOutbox.mjs +1395 -0
  92. package/runtime/arcane/modules/MailTransport.mjs +197 -39
  93. package/runtime/arcane/modules/Ollama.js +36 -1
  94. package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +583 -7
  95. package/runtime/arcane/modules/PreferenceStore.js +367 -33
  96. package/runtime/arcane/modules/RecordReviewStore.js +322 -23
  97. package/runtime/arcane/modules/ScreenCapture.js +1397 -15
  98. package/runtime/arcane/modules/SpeechPlayback.js +438 -41
  99. package/runtime/arcane/modules/TerminalClient.js +277 -12
  100. package/runtime/arcane/modules/ThemeBootstrap.js +80 -6
  101. package/runtime/arcane/modules/ThemeManager.js +39 -7
  102. package/runtime/arcane/modules/TimeGuard.js +131 -20
  103. package/runtime/arcane/modules/WaitForComponent.js +386 -33
  104. package/schemas/arcane-lock.schema.json +2 -2
  105. package/src/cli/main.mjs +435 -9
  106. package/src/event-manager.mjs +1097 -1
  107. package/src/import-map.mjs +21 -3
  108. package/src/index.mjs +13 -0
  109. package/src/installed-sdk-runtime.mjs +112 -0
  110. package/src/mail-api.mjs +22 -0
  111. package/src/mail-credentials.mjs +667 -0
  112. package/src/mail-server.mjs +1769 -0
  113. package/src/mail.mjs +261 -0
  114. package/src/sdk-browser-runtime.mjs +85 -41
  115. package/src/testing-loader.mjs +7 -0
  116. package/src/toolchain.mjs +3 -0
  117. package/src/workspace.mjs +1 -1
@@ -50,16 +50,39 @@
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>
59
60
  </section>
60
61
 
61
62
  <script type="module">
63
+ const {
64
+ createArcaneEventSource,
65
+ projectArcaneDOMEvent
66
+ }=await import('arcane-os/event-manager');
67
+
62
68
  const host=this;
69
+ const events=createArcaneEventSource(
70
+ host,
71
+ {
72
+ source:'arcane.component.voice-transcription',
73
+ eventTypes:[
74
+ 'voice-transcription-ready',
75
+ 'voice-transcription-state',
76
+ 'voice-transcription-segment',
77
+ 'voice-transcription-change',
78
+ 'voice-transcription-complete',
79
+ 'speech-transcription-complete',
80
+ 'speech-transcription-cancelled',
81
+ 'speech-stt-activation-request',
82
+ 'speech-stt-activation-error'
83
+ ]
84
+ }
85
+ );
63
86
  const componentURL=new URL(
64
87
  host.getAttribute('href')||'./arcane/components/voice-transcription.html',
65
88
  document.baseURI
@@ -68,23 +91,97 @@
68
91
  const {default:MD}=await import(moduleURL('MD.js?v=2'));
69
92
  const {
70
93
  appendTranscription,
94
+ createSTTActivationController,
71
95
  normalizeVoiceOptions
72
96
  }=await import(moduleURL('ComponentContracts.js'));
97
+ const {
98
+ requestAIRuntimeIntent,
99
+ subscribeAIRuntimeState
100
+ }=await import(moduleURL('AIRuntimeState.js'));
73
101
  const description=host.shadowRoot.querySelector('#description');
74
102
  const transcriptElement=host.shadowRoot.querySelector('#transcript');
75
103
  const status=host.shadowRoot.querySelector('#status');
76
104
  const startButton=host.shadowRoot.querySelector('#start');
105
+ const sttActivationButton=host.shadowRoot.querySelector('#sttActivation');
77
106
  const stopButton=host.shadowRoot.querySelector('#stop');
78
107
  const completeButton=host.shadowRoot.querySelector('#complete');
108
+ const runtimeStateAbortController=new AbortController();
79
109
 
80
110
  let chunks=[];
81
111
  let mediaStream=null;
82
112
  let recorder=null;
83
113
  let state='idle';
114
+ let stateMessage='';
115
+ let sttRole=unavailableRole('stt');
84
116
  let transcript='';
85
117
  let sessionGeneration=0;
118
+ let transcriptionAbortController=null;
119
+ let eventOperationSequence=0;
120
+ let voiceOperationId=null;
121
+ let destroyed=false;
86
122
  let options=normalizeVoiceOptions(optionsFromDataset());
87
123
 
124
+ function nextVoiceOperationId(kind){
125
+ eventOperationSequence+=1;
126
+ return `${events.descriptor.instanceId}:${kind}:${eventOperationSequence}`;
127
+ }
128
+
129
+ function currentVoiceOperationId(kind='transcription'){
130
+ voiceOperationId=voiceOperationId||nextVoiceOperationId(kind);
131
+ return voiceOperationId;
132
+ }
133
+
134
+ function canonicalSTTCancellationReason(reason){
135
+ if(reason==='runtime-unready'){
136
+ return 'stt-role-unready';
137
+ }
138
+ if(reason==='stt-provider-request-cancelled'){
139
+ return 'stt-provider-transcription-cancelled';
140
+ }
141
+ return String(reason||'stt-transcription-cancelled');
142
+ }
143
+
144
+ function dispatchVoiceEvent(
145
+ type,
146
+ compatibilityDetail={},
147
+ {
148
+ publicDetail={},
149
+ operationId=null,
150
+ beginOperation=null,
151
+ bubbles=false,
152
+ composed=false,
153
+ cancelable=false
154
+ }={}
155
+ ){
156
+ if(destroyed&&type!=='speech-transcription-cancelled'){
157
+ return false;
158
+ }
159
+ if(beginOperation!==null){
160
+ voiceOperationId=nextVoiceOperationId(beginOperation);
161
+ }
162
+ const publication=events.dispatch(
163
+ type,
164
+ compatibilityDetail,
165
+ {
166
+ publicDetail,
167
+ operationId:operationId||currentVoiceOperationId(),
168
+ cancelable
169
+ }
170
+ );
171
+ if(!publication.accepted){
172
+ return false;
173
+ }
174
+ return projectArcaneDOMEvent(
175
+ host,
176
+ publication.occurrence,
177
+ {
178
+ bubbles,
179
+ composed,
180
+ cancelable
181
+ }
182
+ );
183
+ }
184
+
88
185
  host.clear=clear;
89
186
  host.completeTranscription=completeStream;
90
187
  host.configure=configure;
@@ -92,6 +189,10 @@
92
189
  host.reset=clear;
93
190
  host.startRecording=startRecording;
94
191
  host.stopRecording=stopRecording;
192
+ host.requestSTTActivation=host.requestSTTActivation
193
+ ||function requestSTTActivation(intent){
194
+ return requestAIRuntimeIntent(intent);
195
+ };
95
196
 
96
197
  Object.defineProperties(
97
198
  host,
@@ -102,9 +203,50 @@
102
203
  }
103
204
  );
104
205
 
105
- startButton.addEventListener('click',startRecording);
106
- stopButton.addEventListener('click',stopRecording);
107
- completeButton.addEventListener('click',completeStream);
206
+ const lifecycleListenerOptions=Object.freeze(
207
+ {signal:runtimeStateAbortController.signal}
208
+ );
209
+ startButton.addEventListener('click',startRecording,lifecycleListenerOptions);
210
+ stopButton.addEventListener('click',stopRecording,lifecycleListenerOptions);
211
+ completeButton.addEventListener('click',completeStream,lifecycleListenerOptions);
212
+ window.addEventListener(
213
+ 'pagehide',
214
+ destroy,
215
+ {once:true,signal:runtimeStateAbortController.signal}
216
+ );
217
+
218
+ const sttActivationController=createSTTActivationController(
219
+ {
220
+ host,
221
+ button:sttActivationButton,
222
+ onChange:renderState,
223
+ eventSource:events
224
+ }
225
+ );
226
+
227
+ function unavailableRole(role){
228
+ return Object.freeze(
229
+ {
230
+ role,
231
+ state:'unavailable',
232
+ providerId:null,
233
+ modelId:null,
234
+ localOnly:null,
235
+ loaded:false,
236
+ busy:false,
237
+ operationId:null,
238
+ progress:null,
239
+ error:null
240
+ }
241
+ );
242
+ }
243
+
244
+ function canStartVoiceRecording(role,workflowState,componentDestroyed){
245
+ return !componentDestroyed
246
+ &&role?.state==='ready'
247
+ &&!role.busy
248
+ &&['idle','error'].includes(workflowState);
249
+ }
108
250
 
109
251
  function optionsFromDataset(){
110
252
  const dataset=host.dataset||{};
@@ -129,19 +271,31 @@
129
271
  }
130
272
 
131
273
  function configure(input={}){
274
+ if(destroyed){
275
+ return getOptions();
276
+ }
132
277
  const setInitial=Object.prototype.hasOwnProperty.call(input||{},'initialValue');
133
- options=normalizeVoiceOptions(input,options);
278
+ const nextOptions=normalizeVoiceOptions(input,options);
279
+ if(!setInitial){
280
+ if(supersedeForConfigurationReplacement()){
281
+ reportSTTCancellation(
282
+ 'configuration-replaced',
283
+ 'Transcription configuration replaced.'
284
+ );
285
+ }
286
+ }
287
+ options=nextOptions;
134
288
  renderOptions();
135
289
  if(setInitial){
136
290
  setValue(options.initialValue);
137
291
  }else{
138
- setState(state,status.innerText);
292
+ renderState();
139
293
  }
140
294
  return getOptions();
141
295
  }
142
296
 
143
297
  async function startRecording(){
144
- if(!['idle','error'].includes(state)){
298
+ if(!canStartVoiceRecording(sttRole,state,destroyed)){
145
299
  return false;
146
300
  }
147
301
  if(
@@ -152,41 +306,84 @@
152
306
  return false;
153
307
  }
154
308
 
155
- setState('starting',options.messages.requesting);
156
- chunks=[];
157
309
  const generation=++sessionGeneration;
310
+ chunks=[];
311
+ setState('starting',options.messages.requesting,{
312
+ beginOperation:'transcription'
313
+ });
314
+ if(!recordingStartIsCurrent(generation,'starting')){
315
+ return rejectRecordingStart(generation);
316
+ }
158
317
  try{
159
318
  const stream=await navigator.mediaDevices.getUserMedia(
160
319
  options.mediaConstraints
161
320
  );
162
- if(generation!==sessionGeneration){
163
- for(const track of stream.getTracks()){
164
- track.stop();
165
- }
166
- return false;
321
+ if(!recordingStartIsCurrent(generation,'starting')){
322
+ return rejectRecordingStart(generation,stream);
167
323
  }
168
324
  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});
325
+ const activeRecorder=createRecorder(mediaStream);
326
+ recorder=activeRecorder;
327
+ activeRecorder.addEventListener('dataavailable',collectAudio);
328
+ activeRecorder.addEventListener('stop',finishRecording,{once:true});
329
+ activeRecorder.addEventListener('error',recordingError,{once:true});
173
330
  for(const track of mediaStream.getAudioTracks()){
174
331
  track.addEventListener('ended',recordingInterrupted,{once:true});
175
332
  }
176
- recorder.start();
333
+ activeRecorder.start();
334
+ if(!recordingStartIsCurrent(generation,'starting')
335
+ ||recorder!==activeRecorder){
336
+ return rejectAssignedRecordingStart(generation,stream);
337
+ }
177
338
  setState('recording',options.messages.recording);
339
+ if(!recordingStartIsCurrent(generation,'recording')
340
+ ||recorder!==activeRecorder){
341
+ return rejectAssignedRecordingStart(generation,stream);
342
+ }
178
343
  return true;
179
344
  }catch(error){
180
- if(generation!==sessionGeneration){
345
+ if(generation!==sessionGeneration||destroyed){
181
346
  return false;
182
347
  }
183
348
  releaseMicrophone();
184
- setState('error',options.messages.startError);
185
349
  console.error('Unable to start recording:',error);
350
+ setState('error',options.messages.startError);
186
351
  return false;
187
352
  }
188
353
  }
189
354
 
355
+ function recordingStartIsCurrent(generation,expectedState){
356
+ return isCurrentVoiceOperation(generation,expectedState)
357
+ &&sttRole.state==='ready'
358
+ &&!sttRole.busy;
359
+ }
360
+
361
+ function isCurrentVoiceOperation(generation,expectedState){
362
+ return !destroyed
363
+ &&generation===sessionGeneration
364
+ &&state===expectedState;
365
+ }
366
+
367
+ function rejectRecordingStart(generation,stream=null){
368
+ if(mediaStream===stream){
369
+ releaseMicrophone();
370
+ }else{
371
+ stopMediaStream(stream);
372
+ }
373
+ if(generation===sessionGeneration
374
+ &&!destroyed
375
+ &&['starting','recording'].includes(state)){
376
+ setState('idle',options.messages.ready);
377
+ }
378
+ return false;
379
+ }
380
+
381
+ function rejectAssignedRecordingStart(generation,stream){
382
+ return mediaStream===stream
383
+ ?rejectRecordingStart(generation,stream)
384
+ :false;
385
+ }
386
+
190
387
  function createRecorder(stream){
191
388
  for(const mimeType of options.mimeTypes){
192
389
  if(MediaRecorder.isTypeSupported?.(mimeType)){
@@ -203,38 +400,71 @@
203
400
  }
204
401
 
205
402
  function stopRecording(){
206
- if(state!=='recording'||recorder?.state!=='recording'){
403
+ if(destroyed||state!=='recording'||recorder?.state!=='recording'){
207
404
  return false;
208
405
  }
406
+ const generation=sessionGeneration;
407
+ const activeRecorder=recorder;
209
408
  setState('transcribing',options.messages.transcribing);
210
- recorder.stop();
211
- return true;
409
+ if(!isCurrentVoiceOperation(generation,'transcribing')
410
+ ||recorder!==activeRecorder
411
+ ||activeRecorder.state!=='recording'){
412
+ return false;
413
+ }
414
+ activeRecorder.stop();
415
+ return isCurrentVoiceOperation(generation,'transcribing');
212
416
  }
213
417
 
214
418
  async function finishRecording(){
419
+ const generation=sessionGeneration;
420
+ if(!isCurrentVoiceOperation(generation,'transcribing')){
421
+ return false;
422
+ }
215
423
  const mimeType=recorder?.mimeType||chunks[0]?.type||'audio/webm';
216
424
  const audio=new Blob(chunks,{type:mimeType});
425
+ let controller=null;
217
426
  releaseMicrophone();
218
427
  if(!audio.size){
219
428
  setState('error',options.messages.emptyAudio);
220
429
  return false;
221
430
  }
431
+ if(sttRole.state!=='ready'||sttRole.busy){
432
+ reportSTTCancellation(
433
+ sttRole.busy?'stt-role-busy':'runtime-unready',
434
+ sttRole.busy
435
+ ?'Transcription stopped because the selected service is busy.'
436
+ :'Transcription stopped because the selected service became unavailable.'
437
+ );
438
+ return false;
439
+ }
222
440
 
223
441
  try{
442
+ controller=new AbortController();
443
+ transcriptionAbortController?.abort();
444
+ transcriptionAbortController=controller;
224
445
  const file=new File(
225
446
  [audio],
226
447
  `audio.${fileExtension(mimeType)}`,
227
448
  {type:mimeType}
228
449
  );
229
- const transcribe=await getTranscriber();
230
- const result=await transcribe(
450
+ const result=await transcribeAudio(
231
451
  file,
232
452
  {
233
453
  audio,
234
454
  mimeType,
235
- transcript
236
- }
455
+ transcript,
456
+ signal:controller.signal
457
+ },
458
+ controller.signal
237
459
  );
460
+ releaseTranscriptionController(controller);
461
+ if(controller.signal.aborted
462
+ ||generation!==sessionGeneration
463
+ ||sttRole.state!=='ready'
464
+ ||sttRole.busy
465
+ ||destroyed){
466
+ return false;
467
+ }
238
468
  const segment=typeof result==='string'?result.trim():'';
239
469
  if(!segment){
240
470
  setState('idle',options.messages.noSpeech);
@@ -257,51 +487,128 @@
257
487
  return false;
258
488
  }
259
489
  setState('saving',options.messages.saving);
490
+ if(!isCurrentVoiceOperation(generation,'saving')){
491
+ return false;
492
+ }
260
493
  try{
261
494
  await save({transcript,segment});
262
495
  }catch(error){
263
- setState('error',options.messages.saveError);
496
+ if(!isCurrentVoiceOperation(generation,'saving')){
497
+ return false;
498
+ }
264
499
  console.error('Unable to save transcription:',error);
500
+ setState('error',options.messages.saveError);
265
501
  return false;
266
502
  }
267
503
  }
268
504
 
505
+ const successState=options.persist?'saving':'transcribing';
269
506
  const detail={text:segment,transcript};
270
- host.dispatchEvent(
271
- new CustomEvent(
272
- 'speech-transcription-complete',
273
- {bubbles:true,composed:true,detail}
274
- )
275
- );
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
507
+ return reportTranscriptionSuccess(
508
+ generation,
509
+ successState,
510
+ detail,
511
+ options.persist
512
+ ?options.messages.saved
513
+ :options.messages.transcribed,
514
+ currentVoiceOperationId()
285
515
  );
286
- return true;
287
516
  }catch(error){
288
- setState('error',options.messages.transcribeError);
517
+ if(generation!==sessionGeneration||destroyed){
518
+ return false;
519
+ }
520
+ if(isTranscriptionCancellation(error,controller)){
521
+ releaseTranscriptionController(controller);
522
+ reportSTTCancellation(
523
+ 'stt-provider-request-cancelled',
524
+ options.messages.cancelled
525
+ );
526
+ return false;
527
+ }
289
528
  console.error('Unable to transcribe recording:',error);
529
+ setState('error',options.messages.transcribeError);
290
530
  return false;
531
+ }finally{
532
+ releaseTranscriptionController(controller);
291
533
  }
292
534
  }
293
535
 
294
- async function getTranscriber(){
536
+ function reportTranscriptionSuccess(
537
+ generation,
538
+ expectedState,
539
+ detail,
540
+ message,
541
+ operationId=null
542
+ ){
543
+ if(!isCurrentVoiceOperation(generation,expectedState)){
544
+ return false;
545
+ }
546
+ dispatchVoiceEvent(
547
+ 'speech-transcription-complete',
548
+ detail,
549
+ {
550
+ bubbles:true,
551
+ composed:true,
552
+ operationId,
553
+ publicDetail:{
554
+ segmentCharacterCount:detail.text.length,
555
+ transcriptCharacterCount:detail.transcript.length
556
+ }
557
+ }
558
+ );
559
+ if(!isCurrentVoiceOperation(generation,expectedState)){
560
+ return false;
561
+ }
562
+ dispatchVoiceEvent(
563
+ 'voice-transcription-segment',
564
+ detail,
565
+ {
566
+ bubbles:true,
567
+ composed:true,
568
+ operationId,
569
+ publicDetail:{
570
+ segmentCharacterCount:detail.text.length,
571
+ transcriptCharacterCount:detail.transcript.length
572
+ }
573
+ }
574
+ );
575
+ if(!isCurrentVoiceOperation(generation,expectedState)){
576
+ return false;
577
+ }
578
+ setState('idle',message);
579
+ return isCurrentVoiceOperation(generation,'idle');
580
+ }
581
+
582
+ function releaseTranscriptionController(controller){
583
+ if(transcriptionAbortController!==controller){
584
+ return false;
585
+ }
586
+ transcriptionAbortController=null;
587
+ return true;
588
+ }
589
+
590
+ async function transcribeAudio(file,context,signal){
295
591
  if(options.transcribe){
296
- return options.transcribe;
592
+ return options.transcribe(file,context);
297
593
  }
298
594
  if(typeof globalThis.ai?.fetchSTT!=='function'){
299
- await import(moduleURL('AI.js?v=7'));
595
+ await import(moduleURL('AI.js'));
300
596
  }
301
597
  if(typeof globalThis.ai?.fetchSTT!=='function'){
302
- throw new Error('Speech transcription is not configured.');
598
+ const error=new Error('Speech transcription is not configured.');
599
+ error.code='ARCANE_VOICE_TRANSCRIPTION_STT_RUNTIME_METHOD_UNAVAILABLE';
600
+ throw error;
303
601
  }
304
- return globalThis.ai.fetchSTT.bind(globalThis.ai);
602
+ return globalThis.ai.fetchSTT(file,undefined,signal);
603
+ }
604
+
605
+ function isTranscriptionCancellation(error,controller){
606
+ return controller?.signal.aborted
607
+ ||error?.name==='AbortError'
608
+ ||[
609
+ 'ARCANE_AI_REQUEST_ABORTED',
610
+ 'ARCANE_AI_OPERATION_SUPERSEDED'
611
+ ].includes(error?.code);
305
612
  }
306
613
 
307
614
  function fileExtension(mimeType=''){
@@ -330,57 +637,121 @@
330
637
  }
331
638
 
332
639
  async function completeStream(){
333
- if(!transcript.trim()||!['idle','error'].includes(state)){
640
+ if(destroyed||!transcript.trim()||!['idle','error'].includes(state)){
641
+ return false;
642
+ }
643
+ const generation=sessionGeneration;
644
+ const completionTranscript=transcript;
645
+ const complete=options.onComplete||(
646
+ typeof host.complete==='function'?host.complete.bind(host):null
647
+ );
648
+ setState('completing',options.messages.completing,{
649
+ beginOperation:'completion'
650
+ });
651
+ if(!isCurrentVoiceOperation(generation,'completing')){
334
652
  return false;
335
653
  }
336
- setState('completing',options.messages.completing);
337
654
  try{
338
- const complete=options.onComplete||(
339
- typeof host.complete==='function'?host.complete.bind(host):null
340
- );
341
655
  if(complete){
342
- await complete({transcript});
656
+ await complete({transcript:completionTranscript});
343
657
  }
344
- host.dispatchEvent(
345
- new CustomEvent(
346
- 'voice-transcription-complete',
347
- {
348
- bubbles:true,
349
- composed:true,
350
- detail:{transcript}
658
+ if(!isCurrentVoiceOperation(generation,'completing')){
659
+ return false;
660
+ }
661
+ dispatchVoiceEvent(
662
+ 'voice-transcription-complete',
663
+ {transcript:completionTranscript},
664
+ {
665
+ bubbles:true,
666
+ composed:true,
667
+ publicDetail:{
668
+ transcriptCharacterCount:completionTranscript.length
351
669
  }
352
- )
670
+ }
353
671
  );
672
+ if(!isCurrentVoiceOperation(generation,'completing')){
673
+ return false;
674
+ }
354
675
  setState('complete',options.messages.complete);
355
- return true;
676
+ return isCurrentVoiceOperation(generation,'complete');
356
677
  }catch(error){
357
- setState('error',options.messages.completeError);
678
+ if(!isCurrentVoiceOperation(generation,'completing')){
679
+ return false;
680
+ }
358
681
  console.error('Unable to complete transcription:',error);
682
+ setState('error',options.messages.completeError);
359
683
  return false;
360
684
  }
361
685
  }
362
686
 
687
+ function supersedeForTranscriptReplacement(){
688
+ const hadActiveOperation=[
689
+ 'starting',
690
+ 'transcribing',
691
+ 'saving',
692
+ 'completing'
693
+ ].includes(state)||Boolean(transcriptionAbortController);
694
+ if(!hadActiveOperation){
695
+ return false;
696
+ }
697
+ sessionGeneration+=1;
698
+ transcriptionAbortController?.abort();
699
+ transcriptionAbortController=null;
700
+ releaseMicrophone();
701
+ return true;
702
+ }
703
+
704
+ function supersedeForConfigurationReplacement(){
705
+ const hadActiveOperation=[
706
+ 'starting',
707
+ 'recording',
708
+ 'transcribing',
709
+ 'saving',
710
+ 'completing'
711
+ ].includes(state)||Boolean(transcriptionAbortController);
712
+ if(!hadActiveOperation){
713
+ return false;
714
+ }
715
+ sessionGeneration+=1;
716
+ transcriptionAbortController?.abort();
717
+ transcriptionAbortController=null;
718
+ releaseMicrophone();
719
+ return true;
720
+ }
721
+
363
722
  function setValue(value=''){
723
+ if(destroyed){
724
+ return transcript;
725
+ }
726
+ const operationSuperseded=
727
+ supersedeForTranscriptReplacement();
364
728
  transcript=`${value??''}`;
365
729
  renderTranscript();
366
- if(state!=='recording'){
730
+ if(operationSuperseded){
731
+ reportSTTCancellation(
732
+ 'transcript-replaced',
733
+ options.messages.transcriptReplaced
734
+ );
735
+ }else if(state!=='recording'){
367
736
  setState('idle',options.messages.ready);
368
737
  }
369
- host.dispatchEvent(
370
- new CustomEvent(
371
- 'voice-transcription-change',
372
- {
373
- bubbles:true,
374
- composed:true,
375
- detail:{transcript}
738
+ dispatchVoiceEvent(
739
+ 'voice-transcription-change',
740
+ {transcript},
741
+ {
742
+ beginOperation:'transcript-change',
743
+ bubbles:true,
744
+ composed:true,
745
+ publicDetail:{
746
+ transcriptCharacterCount:transcript.length
376
747
  }
377
- )
748
+ }
378
749
  );
379
750
  return transcript;
380
751
  }
381
752
 
382
753
  function clear(){
383
- if(!['complete','error','idle'].includes(state)){
754
+ if(destroyed||!['complete','error','idle'].includes(state)){
384
755
  return false;
385
756
  }
386
757
  transcript='';
@@ -412,39 +783,185 @@
412
783
  }
413
784
 
414
785
  function releaseMicrophone(){
415
- const tracks=mediaStream?.getTracks?.()||[];
786
+ const stream=mediaStream;
416
787
  recorder?.removeEventListener('dataavailable',collectAudio);
417
788
  recorder?.removeEventListener('stop',finishRecording);
418
789
  recorder?.removeEventListener('error',recordingError);
790
+ if(recorder?.state==='recording'){
791
+ recorder.stop();
792
+ }
419
793
  mediaStream=null;
420
794
  recorder=null;
421
795
  chunks=[];
796
+ stopMediaStream(stream);
797
+ }
798
+
799
+ function stopMediaStream(stream){
800
+ const tracks=stream?.getTracks?.()||[];
422
801
  for(const track of tracks){
423
802
  track.removeEventListener('ended',recordingInterrupted);
424
803
  track.stop();
425
804
  }
426
805
  }
427
806
 
428
- function setState(nextState,message=''){
807
+ function setState(nextState,message='',{beginOperation=null}={}){
808
+ if(destroyed){
809
+ return false;
810
+ }
811
+ if(beginOperation!==null){
812
+ voiceOperationId=nextVoiceOperationId(beginOperation);
813
+ }
429
814
  state=nextState;
430
- status.innerText=message||nextState;
815
+ stateMessage=message||nextState;
816
+ renderState();
817
+ return true;
818
+ }
819
+
820
+ function synchronizeAIRuntimeState(snapshot){
821
+ const previousRole=sttRole;
822
+ sttRole=snapshot.roles.stt;
823
+ sttActivationController.synchronize(sttRole);
824
+ if(previousRole.state==='ready'&&sttRole.state!=='ready'){
825
+ if(cancelSTTOperation('runtime-unready')){
826
+ return;
827
+ }
828
+ }
829
+ renderState();
830
+ }
831
+
832
+ function cancelSTTOperation(reason){
833
+ const hadActiveOperation=['starting','recording','transcribing'].includes(state)
834
+ ||Boolean(mediaStream)
835
+ ||Boolean(recorder)
836
+ ||Boolean(transcriptionAbortController);
837
+ if(!hadActiveOperation){
838
+ return false;
839
+ }
840
+ sessionGeneration+=1;
841
+ transcriptionAbortController?.abort();
842
+ transcriptionAbortController=null;
843
+ releaseMicrophone();
844
+ reportSTTCancellation(
845
+ reason,
846
+ 'Transcription stopped because the selected service became unavailable.'
847
+ );
848
+ return true;
849
+ }
850
+
851
+ function reportSTTCancellation(reason,message,operationId=null){
852
+ state='idle';
853
+ stateMessage=message;
854
+ dispatchVoiceEvent(
855
+ 'speech-transcription-cancelled',
856
+ Object.freeze({reason}),
857
+ {
858
+ bubbles:true,
859
+ composed:true,
860
+ operationId:operationId||currentVoiceOperationId(),
861
+ publicDetail:{reason:canonicalSTTCancellationReason(reason)}
862
+ }
863
+ );
864
+ if(!destroyed){
865
+ renderState();
866
+ }
867
+ }
868
+
869
+ function renderState(){
870
+ status.innerText=runtimeStatusMessage();
431
871
  status.classList.toggle('recording',state==='recording');
432
- startButton.disabled=!['idle','error'].includes(state);
872
+ status.setAttribute(
873
+ 'aria-busy',
874
+ String(
875
+ !destroyed
876
+ &&(
877
+ sttActivationController.pending
878
+ ||['loading','unloading'].includes(sttRole.state)
879
+ ||sttRole.busy
880
+ ||['starting','transcribing','saving','completing'].includes(state)
881
+ )
882
+ )
883
+ );
884
+ startButton.disabled=!canStartVoiceRecording(sttRole,state,destroyed);
885
+ startButton.title=recordingButtonTitle();
886
+ startButton.setAttribute('aria-label',startButton.title);
433
887
  stopButton.disabled=state!=='recording';
434
- completeButton.disabled=!transcript.trim()
888
+ completeButton.disabled=destroyed
889
+ ||!transcript.trim()
435
890
  ||!['idle','error'].includes(state);
436
- host.dispatchEvent(
437
- new CustomEvent(
438
- 'voice-transcription-state',
891
+ sttActivationButton.hidden=destroyed||!sttActivationController.visible;
892
+ sttActivationButton.disabled=destroyed
893
+ ||sttActivationController.pending
894
+ ||sttRole.state==='unloading'
895
+ ||sttActivationController.action===null;
896
+ sttActivationButton.textContent=sttActivationController.label;
897
+ sttActivationButton.title=sttActivationController.title;
898
+ sttActivationButton.setAttribute('aria-label',sttActivationButton.title);
899
+ sttActivationButton.setAttribute(
900
+ 'aria-busy',
901
+ String(
902
+ sttActivationController.pending
903
+ ||['loading','unloading'].includes(sttRole.state)
904
+ )
905
+ );
906
+ dispatchVoiceEvent(
907
+ 'voice-transcription-state',
908
+ Object.freeze(
439
909
  {
440
- bubbles:true,
441
- composed:true,
442
- detail:{message:status.innerText,state}
910
+ message:status.innerText,
911
+ state,
912
+ stt:sttRole
443
913
  }
444
- )
914
+ ),
915
+ {
916
+ bubbles:true,
917
+ composed:true,
918
+ publicDetail:{
919
+ state,
920
+ sttState:sttRole.state
921
+ }
922
+ }
445
923
  );
446
924
  }
447
925
 
926
+ function recordingButtonTitle(){
927
+ if(destroyed||sttRole.state==='disposed'){
928
+ return 'Transcription is unavailable.';
929
+ }
930
+ if(sttRole.state==='loading'){
931
+ return 'The selected transcription service is loading.';
932
+ }
933
+ if(sttRole.state==='error'){
934
+ return visibleErrorMessage(
935
+ sttRole.error,
936
+ 'The selected transcription service reported an error.'
937
+ );
938
+ }
939
+ if(sttRole.state!=='ready'){
940
+ return 'The selected transcription service is unavailable.';
941
+ }
942
+ if(sttRole.busy){
943
+ return 'The selected transcription service is busy.';
944
+ }
945
+ return options.labels.start;
946
+ }
947
+
948
+ function runtimeStatusMessage(){
949
+ if(destroyed){
950
+ return 'Transcription unavailable.';
951
+ }
952
+ return sttRole.state==='ready'
953
+ &&!sttRole.busy
954
+ &&!sttActivationController.pending
955
+ &&!sttActivationController.error
956
+ ?stateMessage
957
+ :sttActivationController.status;
958
+ }
959
+
960
+ function visibleErrorMessage(error,fallback){
961
+ const message=typeof error?.message==='string'?error.message.trim():'';
962
+ return (message||fallback).slice(0,240);
963
+ }
964
+
448
965
  function getOptions(){
449
966
  return {
450
967
  ...options,
@@ -456,9 +973,21 @@
456
973
  }
457
974
 
458
975
  function destroy(){
459
- sessionGeneration++;
460
- releaseMicrophone();
461
- setState('idle',options.messages.ready);
976
+ if(destroyed){
977
+ return true;
978
+ }
979
+ destroyed=true;
980
+ runtimeStateAbortController.abort();
981
+ sttActivationController.destroy();
982
+ window.removeEventListener('pagehide',destroy);
983
+ if(!cancelSTTOperation('component-destroyed')){
984
+ sessionGeneration++;
985
+ }
986
+ state='idle';
987
+ stateMessage='Transcription unavailable.';
988
+ host.ready=false;
989
+ renderState();
990
+ events.dispose();
462
991
  return true;
463
992
  }
464
993
 
@@ -466,11 +995,23 @@
466
995
  transcript=options.initialValue;
467
996
  renderTranscript();
468
997
  setState('idle',options.messages.ready);
469
- host.ready=true;
470
- host.dispatchEvent(
471
- new CustomEvent(
998
+ if(!destroyed){
999
+ subscribeAIRuntimeState(
1000
+ synchronizeAIRuntimeState,
1001
+ {signal:runtimeStateAbortController.signal}
1002
+ );
1003
+ }
1004
+ if(!destroyed){
1005
+ host.ready=true;
1006
+ dispatchVoiceEvent(
472
1007
  'voice-transcription-ready',
473
- {bubbles:true,composed:true}
474
- )
475
- );
1008
+ {},
1009
+ {
1010
+ beginOperation:'ready',
1011
+ bubbles:true,
1012
+ composed:true,
1013
+ publicDetail:{ready:true}
1014
+ }
1015
+ );
1016
+ }
476
1017
  </script>