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.
- package/CHANGELOG.md +12 -0
- package/README.md +8 -8
- package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +1 -1
- package/docs/architecture.md +2 -2
- package/docs/reference/README.md +24 -24
- package/docs/reference/ai/browser-speech.md +1 -1
- package/docs/reference/ai/browser-wasm.md +1 -1
- package/docs/reference/availability-and-normalization.md +6 -3
- package/docs/reference/behavioral-testing.md +1 -1
- package/docs/reference/cli.md +3 -3
- package/docs/reference/core/arcane-ai-contracts.md +1 -1
- package/docs/reference/inventory/package-api.json +1 -1
- package/docs/reference/inventory/runtime-components.json +25 -10
- package/docs/reference/inventory/runtime-modules.json +8 -7
- package/docs/reference/protocols.md +27 -23
- package/docs/reference/runtime-components.md +70 -7
- package/docs/reference/runtime-modules.md +33 -11
- package/docs/reference/sdk-api.md +8 -8
- package/package.json +1 -1
- package/runtime/ARCANE_RUNTIME_RELEASE.json +9 -9
- package/runtime/arcane/components/speech.html +9 -253
- package/runtime/arcane/components/voice-transcription.html +437 -68
- package/runtime/arcane/modules/ComponentContracts.js +272 -0
- package/schemas/arcane-lock.schema.json +2 -2
|
@@ -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
|
-
|
|
187
|
+
renderState();
|
|
139
188
|
}
|
|
140
189
|
return getOptions();
|
|
141
190
|
}
|
|
142
191
|
|
|
143
192
|
async function startRecording(){
|
|
144
|
-
if(!
|
|
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
|
|
163
|
-
|
|
164
|
-
track.stop();
|
|
165
|
-
}
|
|
166
|
-
return false;
|
|
214
|
+
if(!recordingStartIsCurrent(generation,'starting')){
|
|
215
|
+
return rejectRecordingStart(generation,stream);
|
|
167
216
|
}
|
|
168
217
|
mediaStream=stream;
|
|
169
|
-
|
|
170
|
-
recorder
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
538
|
+
return isCurrentVoiceOperation(generation,'complete');
|
|
356
539
|
}catch(error){
|
|
357
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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>
|