arcane-os 0.2.1 → 0.2.2

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.
@@ -63,6 +63,13 @@
63
63
  text-align: center;
64
64
  }
65
65
 
66
+ #sttActivationButton {
67
+ grid-column: 1;
68
+ max-width: 8rem;
69
+ background: var(--arcane-action, var(--primary-color));
70
+ color: var(--arcane-action-text, var(--button-text-color, var(--text-color)));
71
+ }
72
+
66
73
  #recordButton:active,
67
74
  #recordButton.pressed {
68
75
  opacity: .82;
@@ -108,7 +115,8 @@
108
115
  grid-template-columns: auto minmax(0, 1fr) auto;
109
116
  }
110
117
 
111
- #recordButton {
118
+ #recordButton,
119
+ #sttActivationButton {
112
120
  grid-row: 1 / span 2;
113
121
  }
114
122
 
@@ -131,6 +139,7 @@
131
139
 
132
140
  <p class="hidden">Will auto send after X seconds of silence set in code.</p>
133
141
  <button id="recordButton" type="button" aria-label="Transcription unavailable" disabled>Hold to talk</button>
142
+ <button id="sttActivationButton" type="button" hidden disabled>Start transcription</button>
134
143
  <button id="stopButton" type="button" class="hidden" disabled>Send</button>
135
144
  <p id="speechStatus" class="speech_status" role="status" aria-live="polite"></p>
136
145
  <button id="endButton" type="button" class="end">End</button>
@@ -151,6 +160,7 @@
151
160
  const host = this;
152
161
  const shadowRoot = host.shadowRoot;
153
162
  const recordButton = shadowRoot.querySelector('#recordButton');
163
+ const sttActivationButton = shadowRoot.querySelector('#sttActivationButton');
154
164
  const stopButton = shadowRoot.querySelector('#stopButton');
155
165
  const endButton = shadowRoot.querySelector('#endButton');
156
166
  const muteButton = shadowRoot.querySelector('#muteButton');
@@ -167,11 +177,13 @@
167
177
  let recordingGeneration = 0;
168
178
  let transcriptionGeneration = 0;
169
179
  let transcriptionActive = false;
180
+ let transcriptionAbortController = null;
170
181
  let silenceTimer = null;
171
182
  let activePointerId = null;
172
183
  let keyboardRecording = false;
173
184
  let localStatus = null;
174
185
  let pendingTTSIntent = null;
186
+ let ttsIntentGeneration = 0;
175
187
  let destroyed = false;
176
188
 
177
189
  const initialMutedWasHydrated = typeof host.initialMuted === 'boolean';
@@ -180,6 +192,10 @@
180
192
  host.configure = configure;
181
193
  host.destroy = destroy;
182
194
  host.reportTTSError = reportTTSError;
195
+ host.requestSTTActivation = host.requestSTTActivation
196
+ || function requestSTTActivation(intent) {
197
+ return requestAIRuntimeIntent(intent);
198
+ };
183
199
  host.setAvailability = setAvailability;
184
200
  host.setMuted = setMuted;
185
201
  host.availability = {
@@ -190,6 +206,13 @@
190
206
  stt: false,
191
207
  tts: false
192
208
  };
209
+ const sttActivationController = createSTTActivationController(
210
+ {
211
+ host,
212
+ button: sttActivationButton,
213
+ onChange: renderSTTActivationState
214
+ }
215
+ );
193
216
 
194
217
  recordButton.addEventListener('pointerdown', handleRecordPointerDown);
195
218
  recordButton.addEventListener('pointerup', handleRecordPointerUp);
@@ -231,6 +254,191 @@
231
254
  )
232
255
  );
233
256
 
257
+ function createSTTActivationController({
258
+ host,
259
+ button,
260
+ onChange,
261
+ EventClass = CustomEvent
262
+ }) {
263
+ let role = null;
264
+ let requestPending = false;
265
+ let requestError = '';
266
+ let requestGeneration = 0;
267
+ let destroyed = false;
268
+
269
+ function visibleError(error, fallback) {
270
+ const message = typeof error?.message === 'string'
271
+ ? error.message.trim()
272
+ : '';
273
+ return (message || fallback).slice(0, 240);
274
+ }
275
+
276
+ function cancellation(error) {
277
+ return error?.name === 'AbortError'
278
+ || [
279
+ 'ARCANE_AI_REQUEST_ABORTED',
280
+ 'ARCANE_AI_OPERATION_SUPERSEDED'
281
+ ]
282
+ .includes(error?.code);
283
+ }
284
+
285
+ function selected() {
286
+ return typeof role?.providerId === 'string'
287
+ && role.providerId.length > 0
288
+ && typeof role?.modelId === 'string'
289
+ && role.modelId.length > 0;
290
+ }
291
+
292
+ function action() {
293
+ if (!selected()) {
294
+ return null;
295
+ }
296
+ if (role.state === 'loading') {
297
+ return 'unload';
298
+ }
299
+ if (!role.busy && ['unloaded', 'error'].includes(role.state)) {
300
+ return 'load';
301
+ }
302
+ return null;
303
+ }
304
+
305
+ async function request(nextAction) {
306
+ if (destroyed || requestPending || action() !== nextAction) {
307
+ return false;
308
+ }
309
+ const generation = ++requestGeneration;
310
+ requestError = '';
311
+ const intent = Object.freeze(
312
+ {
313
+ role: 'stt',
314
+ action: nextAction,
315
+ reason: 'user'
316
+ }
317
+ );
318
+ const activationRequest = Object.freeze({intent, state: role});
319
+ const activationEvent = new EventClass(
320
+ 'speech-stt-activation-request',
321
+ {
322
+ bubbles: true,
323
+ composed: true,
324
+ cancelable: true,
325
+ detail: activationRequest
326
+ }
327
+ );
328
+ requestPending = true;
329
+ const accepted = host.dispatchEvent(activationEvent);
330
+ if (!accepted
331
+ || destroyed
332
+ || generation !== requestGeneration
333
+ || action() !== nextAction) {
334
+ if (!destroyed && generation === requestGeneration) {
335
+ requestPending = false;
336
+ onChange();
337
+ }
338
+ return false;
339
+ }
340
+ onChange();
341
+ try {
342
+ await host.requestSTTActivation(intent);
343
+ if (destroyed || generation !== requestGeneration) {
344
+ return false;
345
+ }
346
+ return true;
347
+ } catch (error) {
348
+ if (destroyed || generation !== requestGeneration) {
349
+ return false;
350
+ }
351
+ if (nextAction === 'load'
352
+ && cancellation(error)
353
+ && ['unloaded', 'unloading'].includes(role?.state)) {
354
+ return false;
355
+ }
356
+ const message = visibleError(
357
+ error,
358
+ `The transcription ${nextAction} request failed.`
359
+ );
360
+ requestError = message;
361
+ host.dispatchEvent(
362
+ new EventClass(
363
+ 'speech-stt-activation-error',
364
+ {
365
+ bubbles: true,
366
+ composed: true,
367
+ detail: Object.freeze(
368
+ {
369
+ request: activationRequest,
370
+ error,
371
+ message
372
+ }
373
+ )
374
+ }
375
+ )
376
+ );
377
+ return false;
378
+ } finally {
379
+ if (!destroyed && generation === requestGeneration) {
380
+ requestPending = false;
381
+ onChange();
382
+ }
383
+ }
384
+ }
385
+
386
+ function activateSelectedSTT() {
387
+ const nextAction = action();
388
+ if (nextAction) {
389
+ void request(nextAction);
390
+ }
391
+ }
392
+
393
+ function synchronize(nextRole) {
394
+ if (destroyed) {
395
+ return;
396
+ }
397
+ if (role?.state !== nextRole.state
398
+ || role?.providerId !== nextRole.providerId
399
+ || role?.modelId !== nextRole.modelId
400
+ || role?.loaded !== nextRole.loaded
401
+ || role?.busy !== nextRole.busy
402
+ || role?.operationId !== nextRole.operationId) {
403
+ requestGeneration += 1;
404
+ requestError = '';
405
+ requestPending = false;
406
+ }
407
+ role = nextRole;
408
+ }
409
+
410
+ function destroy() {
411
+ if (destroyed) {
412
+ return;
413
+ }
414
+ destroyed = true;
415
+ requestGeneration += 1;
416
+ requestPending = false;
417
+ button.removeEventListener('click', activateSelectedSTT);
418
+ }
419
+
420
+ button.addEventListener('click', activateSelectedSTT);
421
+ return Object.freeze(
422
+ {
423
+ get action() {
424
+ return action();
425
+ },
426
+ get error() {
427
+ return requestError;
428
+ },
429
+ get pending() {
430
+ return requestPending;
431
+ },
432
+ get selected() {
433
+ return selected();
434
+ },
435
+ request,
436
+ synchronize,
437
+ destroy
438
+ }
439
+ );
440
+ }
441
+
234
442
  function unavailableRole(role) {
235
443
  return Object.freeze(
236
444
  {
@@ -248,14 +456,11 @@
248
456
  );
249
457
  }
250
458
 
251
- function compatibilityRole(role, ready) {
252
- return Object.freeze(
253
- {
254
- ...unavailableRole(role),
255
- state: ready ? 'ready' : 'unavailable',
256
- loaded: ready
257
- }
258
- );
459
+ function selectedRole(role) {
460
+ return typeof role?.providerId === 'string'
461
+ && role.providerId.length > 0
462
+ && typeof role?.modelId === 'string'
463
+ && role.modelId.length > 0;
259
464
  }
260
465
 
261
466
  function configure(options = {}) {
@@ -288,11 +493,15 @@
288
493
  if (Object.prototype.hasOwnProperty.call(input, 'microphone')) {
289
494
  host.availability.microphone = Boolean(input.microphone);
290
495
  }
291
- if (Object.prototype.hasOwnProperty.call(input, 'stt')) {
292
- sttRole = compatibilityRole('stt', Boolean(input.stt));
496
+ if (Object.prototype.hasOwnProperty.call(input, 'stt')
497
+ && !Boolean(input.stt)
498
+ && !selectedRole(sttRole)) {
499
+ sttRole = unavailableRole('stt');
293
500
  }
294
- if (Object.prototype.hasOwnProperty.call(input, 'tts')) {
295
- ttsRole = compatibilityRole('tts', Boolean(input.tts));
501
+ if (Object.prototype.hasOwnProperty.call(input, 'tts')
502
+ && !Boolean(input.tts)
503
+ && !selectedRole(ttsRole)) {
504
+ ttsRole = unavailableRole('tts');
296
505
  }
297
506
 
298
507
  if (microphoneWasAvailable && !host.availability.microphone) {
@@ -337,6 +546,7 @@
337
546
  }
338
547
 
339
548
  function applyRoleTransitions(previousSTTRole, previousTTSRole) {
549
+ sttActivationController.synchronize(sttRole);
340
550
  const sttBecameUnready = previousSTTRole.state === 'ready'
341
551
  && sttRole.state !== 'ready';
342
552
  const ttsBecameUnready = previousTTSRole.state === 'ready'
@@ -362,6 +572,11 @@
362
572
  renderStatus();
363
573
  }
364
574
 
575
+ function renderSTTActivationState() {
576
+ renderControls();
577
+ renderStatus();
578
+ }
579
+
365
580
  function clearSettledTTSIntent() {
366
581
  if (!pendingTTSIntent) {
367
582
  return;
@@ -383,6 +598,11 @@
383
598
  function renderControls() {
384
599
  const transcriptionReady = sttRole.state === 'ready';
385
600
  const microphoneReady = host.availability.microphone;
601
+ const activationVisible = sttActivationController.selected
602
+ && sttRole.state !== 'ready'
603
+ && ['unloaded', 'loading', 'unloading', 'error']
604
+ .includes(sttRole.state);
605
+ recordButton.hidden = activationVisible;
386
606
  recordButton.disabled = !transcriptionReady
387
607
  || !microphoneReady
388
608
  || sttRole.busy
@@ -395,9 +615,62 @@
395
615
  'aria-pressed',
396
616
  String(recordingRequested || Boolean(captureSession))
397
617
  );
618
+ sttActivationButton.hidden = !activationVisible;
619
+ sttActivationButton.disabled = destroyed
620
+ || sttActivationController.pending
621
+ || sttRole.state === 'unloading'
622
+ || sttActivationController.action === null;
623
+ sttActivationButton.textContent = transcriptionActivationLabel();
624
+ sttActivationButton.title = transcriptionActivationTitle();
625
+ sttActivationButton.setAttribute(
626
+ 'aria-label',
627
+ sttActivationButton.title
628
+ );
629
+ sttActivationButton.setAttribute(
630
+ 'aria-busy',
631
+ String(
632
+ sttActivationController.pending
633
+ || ['loading', 'unloading'].includes(sttRole.state)
634
+ )
635
+ );
398
636
  renderTTSControl();
399
637
  }
400
638
 
639
+ function transcriptionActivationLabel() {
640
+ if (sttActivationController.pending) {
641
+ return 'Requesting…';
642
+ }
643
+ if (sttRole.state === 'loading') {
644
+ return 'Cancel loading';
645
+ }
646
+ if (sttRole.state === 'unloading') {
647
+ return 'Canceling…';
648
+ }
649
+ if (sttRole.state === 'error') {
650
+ return 'Try again';
651
+ }
652
+ return 'Start transcription';
653
+ }
654
+
655
+ function transcriptionActivationTitle() {
656
+ if (sttActivationController.pending) {
657
+ return 'Requesting transcription activation.';
658
+ }
659
+ if (sttRole.state === 'loading') {
660
+ return 'Cancel loading the selected transcription service.';
661
+ }
662
+ if (sttRole.state === 'unloading') {
663
+ return 'The selected transcription service is being released.';
664
+ }
665
+ if (sttRole.state === 'error') {
666
+ return sttActivationController.error || visibleErrorMessage(
667
+ sttRole.error,
668
+ 'Retry loading the selected transcription service.'
669
+ );
670
+ }
671
+ return 'Start the selected transcription service.';
672
+ }
673
+
401
674
  function transcriptionButtonTitle() {
402
675
  if (destroyed || sttRole.state === 'disposed') {
403
676
  return 'Transcription is unavailable.';
@@ -492,15 +765,23 @@
492
765
  'aria-busy',
493
766
  String(
494
767
  sttRole.state === 'loading'
768
+ || sttRole.state === 'unloading'
495
769
  || ttsRole.state === 'loading'
496
770
  || sttRole.busy
497
771
  || ttsRole.busy
772
+ || sttActivationController.pending
498
773
  || transcriptionActive
499
774
  )
500
775
  );
501
776
  }
502
777
 
503
778
  function describeSTTRole() {
779
+ if (sttActivationController.error) {
780
+ return `Transcription activation error: ${sttActivationController.error}.`;
781
+ }
782
+ if (sttActivationController.pending) {
783
+ return 'Transcription activation requested.';
784
+ }
504
785
  if (sttRole.state === 'ready') {
505
786
  if (!host.availability.microphone) {
506
787
  return 'Microphone unavailable.';
@@ -508,7 +789,10 @@
508
789
  return sttRole.busy ? 'Transcription busy.' : 'Transcription ready.';
509
790
  }
510
791
  if (sttRole.state === 'loading') {
511
- return `Transcription ${formatRoleProgress(sttRole.progress, 'loading')}.`;
792
+ return `Transcription ${formatRoleProgress(sttRole.progress, 'loading')}; Cancel is available.`;
793
+ }
794
+ if (sttRole.state === 'unloading') {
795
+ return `Transcription ${formatRoleProgress(sttRole.progress, 'releasing')}.`;
512
796
  }
513
797
  if (sttRole.state === 'error') {
514
798
  return `Transcription error: ${visibleErrorMessage(sttRole.error, 'Unknown error')}.`;
@@ -516,6 +800,9 @@
516
800
  if (sttRole.state === 'disposed') {
517
801
  return 'Transcription disposed.';
518
802
  }
803
+ if (sttRole.state === 'unloaded' && sttActivationController.selected) {
804
+ return 'Transcription selected and waiting to load.';
805
+ }
519
806
  return 'Transcription unavailable.';
520
807
  }
521
808
 
@@ -748,10 +1035,15 @@
748
1035
  }
749
1036
 
750
1037
  function requestTTSIntent(action, reason) {
1038
+ const generation = ++ttsIntentGeneration;
751
1039
  pendingTTSIntent = action === 'load' && ttsRole.state === 'ready'
752
1040
  ? null
753
1041
  : action;
1042
+ let lifecycle = null;
754
1043
  try {
1044
+ if (typeof globalThis.ai?.setSpeechMuted === 'function') {
1045
+ lifecycle = globalThis.ai.setSpeechMuted(action === 'unload');
1046
+ }
755
1047
  requestAIRuntimeIntent(
756
1048
  {
757
1049
  role: 'tts',
@@ -761,8 +1053,27 @@
761
1053
  );
762
1054
  } catch (error) {
763
1055
  pendingTTSIntent = null;
1056
+ if (action === 'load') {
1057
+ host.muted = true;
1058
+ stopTTSPlayback();
1059
+ }
764
1060
  reportTTSLifecycleError(error, action);
1061
+ return false;
765
1062
  }
1063
+ void Promise.resolve(lifecycle).catch(
1064
+ function reportCurrentTTSSpeechLifecycleFailure(error) {
1065
+ if (destroyed || generation !== ttsIntentGeneration) {
1066
+ return;
1067
+ }
1068
+ pendingTTSIntent = null;
1069
+ if (action === 'load') {
1070
+ host.muted = true;
1071
+ stopTTSPlayback();
1072
+ }
1073
+ reportTTSLifecycleError(error, action);
1074
+ }
1075
+ );
1076
+ return true;
766
1077
  }
767
1078
 
768
1079
  function reportTTSLifecycleError(error, action) {
@@ -1051,6 +1362,9 @@
1051
1362
 
1052
1363
  async function transcribeAudio(audioFile) {
1053
1364
  const generation = ++transcriptionGeneration;
1365
+ const controller = new AbortController();
1366
+ transcriptionAbortController?.abort();
1367
+ transcriptionAbortController = controller;
1054
1368
  transcriptionActive = true;
1055
1369
  localStatus = {
1056
1370
  message: 'Transcribing speech.',
@@ -1065,7 +1379,11 @@
1065
1379
  throw new Error('The selected transcription service is unavailable.');
1066
1380
  }
1067
1381
 
1068
- const transcription = await aiRuntime.fetchSTT(audioFile);
1382
+ const transcription = await aiRuntime.fetchSTT(
1383
+ audioFile,
1384
+ undefined,
1385
+ controller.signal
1386
+ );
1069
1387
  if (
1070
1388
  generation !== transcriptionGeneration
1071
1389
  || sttRole.state !== 'ready'
@@ -1086,6 +1404,9 @@
1086
1404
  }
1087
1405
  return false;
1088
1406
  } finally {
1407
+ if (transcriptionAbortController === controller) {
1408
+ transcriptionAbortController = null;
1409
+ }
1089
1410
  if (generation === transcriptionGeneration) {
1090
1411
  transcriptionActive = false;
1091
1412
  renderControls();
@@ -1126,6 +1447,8 @@
1126
1447
  || transcriptionActive;
1127
1448
  recordingGeneration += 1;
1128
1449
  transcriptionGeneration += 1;
1450
+ transcriptionAbortController?.abort();
1451
+ transcriptionAbortController = null;
1129
1452
  recordingRequested = false;
1130
1453
  transcriptionActive = false;
1131
1454
  clearSilenceTimer();
@@ -1199,6 +1522,7 @@
1199
1522
 
1200
1523
  destroyed = true;
1201
1524
  runtimeStateAbortController.abort();
1525
+ sttActivationController.destroy();
1202
1526
  window.removeEventListener('pagehide', destroy);
1203
1527
  microphonePermissionStatus?.removeEventListener?.(
1204
1528
  'change',