arcane-os 0.45.0 → 0.47.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.
@@ -191,6 +191,7 @@
191
191
  }
192
192
  );
193
193
  const aiModuleImport = import('../modules/AI.js');
194
+ const providerRuntimeImport = import('../modules/AIProviderRuntime.js');
194
195
  const {
195
196
  requestAIRuntimeIntent,
196
197
  subscribeAIRuntimeState
@@ -200,6 +201,8 @@
200
201
  formatAIRuntimeProgress
201
202
  } = await import('../modules/ComponentContracts.js');
202
203
  const {getBrowserDeviceClass} = await browserDeviceImport;
204
+ const {getAIProviderRuntime} = await providerRuntimeImport;
205
+ const providerRuntime = getAIProviderRuntime();
203
206
 
204
207
  const host = this;
205
208
  const deviceClass = getBrowserDeviceClass();
@@ -237,6 +240,7 @@
237
240
  let microphonePermissionStatus = null;
238
241
  let microphonePermissionListener = null;
239
242
  let captureSession = null;
243
+ let nativeCaptureSession = null;
240
244
  let recordingRequested = false;
241
245
  let recordingGeneration = 0;
242
246
  let transcriptionGeneration = 0;
@@ -362,8 +366,8 @@
362
366
  host.setMuted = setMuted;
363
367
  host.availability = {
364
368
  microphone: transcriptionEnabled && Boolean(
365
- navigator.mediaDevices?.getUserMedia
366
- && globalThis.MediaRecorder
369
+ (navigator.mediaDevices?.getUserMedia && globalThis.MediaRecorder)
370
+ || globalThis.SpeechRecognition || globalThis.webkitSpeechRecognition
367
371
  ),
368
372
  stt: false,
369
373
  tts: false
@@ -626,24 +630,25 @@
626
630
  }
627
631
  const transcriptionReady = sttRole.state === 'ready';
628
632
  const microphoneReady = host.availability.microphone
629
- && is.function(globalThis.MediaRecorder)
630
- && is.function(navigator.mediaDevices?.getUserMedia);
633
+ && (providerRuntime.supportsTranscriptionCapture()
634
+ || (is.function(globalThis.MediaRecorder)
635
+ && is.function(navigator.mediaDevices?.getUserMedia)));
631
636
  const activationVisible = sttActivationController.visible;
632
637
  recordButton.hidden = activationVisible;
633
638
  recordButton.disabled = !transcriptionReady
634
639
  || !microphoneReady
635
- || sttRole.busy
640
+ || (sttRole.busy && !nativeCaptureSession)
636
641
  || transcriptionActive
637
642
  || destroyed;
638
643
  stopButton.disabled = destroyed
639
644
  || !transcriptionReady
640
- || sttRole.busy
641
- || captureSession?.recorder.state !== 'recording';
645
+ || (sttRole.busy && !nativeCaptureSession)
646
+ || (!nativeCaptureSession && captureSession?.recorder.state !== 'recording');
642
647
  recordButton.title = transcriptionButtonTitle();
643
648
  recordButton.setAttribute('aria-label', recordButton.title);
644
649
  recordButton.setAttribute(
645
650
  'aria-pressed',
646
- String(recordingRequested || Boolean(captureSession))
651
+ String(recordingRequested || Boolean(captureSession) || Boolean(nativeCaptureSession))
647
652
  );
648
653
  sttActivationButton.hidden = !activationVisible;
649
654
  sttActivationButton.disabled = destroyed
@@ -1288,21 +1293,23 @@
1288
1293
  }
1289
1294
  }
1290
1295
  );
1291
- transcriptionOperationId = null;
1296
+ if (transcriptionOperationId === operationId) transcriptionOperationId = null;
1292
1297
  return accepted;
1293
1298
  }
1294
1299
 
1295
1300
  async function record() {
1301
+ const liveCapture = providerRuntime.supportsTranscriptionCapture();
1296
1302
  if (
1297
1303
  !transcriptionEnabled
1298
1304
  || destroyed
1299
1305
  || sttRole.state !== 'ready'
1300
1306
  || sttRole.busy
1301
1307
  || !host.availability.microphone
1302
- || !is.function(globalThis.MediaRecorder)
1303
- || !is.function(navigator.mediaDevices?.getUserMedia)
1308
+ || (!liveCapture && (!is.function(globalThis.MediaRecorder)
1309
+ || !is.function(navigator.mediaDevices?.getUserMedia)))
1304
1310
  || recordingRequested
1305
1311
  || captureSession
1312
+ || nativeCaptureSession
1306
1313
  || transcriptionActive
1307
1314
  ) {
1308
1315
  resetRecordPress();
@@ -1322,6 +1329,8 @@
1322
1329
  renderControls();
1323
1330
  renderStatus();
1324
1331
 
1332
+ if (liveCapture) return startNativeRecognition(generation, operationId);
1333
+
1325
1334
  let stream;
1326
1335
  try {
1327
1336
  stream = await navigator.mediaDevices.getUserMedia({ audio: true });
@@ -1461,6 +1470,111 @@
1461
1470
  }
1462
1471
  }
1463
1472
 
1473
+ function startNativeRecognition(generation, operationId) {
1474
+ const session = {
1475
+ generation,
1476
+ operationId,
1477
+ controller: new AbortController(),
1478
+ capture: null,
1479
+ text: [],
1480
+ interim: '',
1481
+ cancelled: false,
1482
+ error: null
1483
+ };
1484
+ nativeCaptureSession = session;
1485
+ function currentNativeCapture() {
1486
+ return !destroyed && !session.cancelled
1487
+ && nativeCaptureSession === session
1488
+ && generation === recordingGeneration;
1489
+ }
1490
+ function displayNativeRecognitionText() {
1491
+ if (!currentNativeCapture()) return;
1492
+ localStatus = {
1493
+ message: session.text.join('') + session.interim || 'Listening. Release to finish.',
1494
+ tone: '',
1495
+ owner: 'recording',
1496
+ generation
1497
+ };
1498
+ renderStatus();
1499
+ }
1500
+ async function finishNativeRecognition(error = null) {
1501
+ if (!currentNativeCapture()) return;
1502
+ nativeCaptureSession = null;
1503
+ recordingRequested = false;
1504
+ resetRecordPress();
1505
+ clearRecordingStatus(generation);
1506
+ if (error && error !== session.error) {
1507
+ session.error = error;
1508
+ reportTranscriptionError(error);
1509
+ }
1510
+ // Keep confirmed final words after a service/cleanup failure. Only
1511
+ // explicit cancellation or supersession abandons this submission.
1512
+ if (session.text.length && !destroyed && !session.cancelled
1513
+ && generation === recordingGeneration) {
1514
+ transcriptionOperationId = operationId;
1515
+ await transcribe(session.text.join(''));
1516
+ } else if (transcriptionOperationId === operationId) {
1517
+ transcriptionOperationId = null;
1518
+ }
1519
+ renderControls();
1520
+ renderStatus();
1521
+ }
1522
+ try {
1523
+ session.capture = providerRuntime.createTranscriptionCapture(
1524
+ {
1525
+ continuous: true,
1526
+ onSegment: function retainFinalRecognition(segment) {
1527
+ if (!currentNativeCapture()) return;
1528
+ session.text.push(segment.text);
1529
+ displayNativeRecognitionText();
1530
+ },
1531
+ onInterim: function displayInterimRecognition(result) {
1532
+ if (!currentNativeCapture()) return;
1533
+ session.interim = result.text;
1534
+ displayNativeRecognitionText();
1535
+ },
1536
+ onError: function reportNativeRecognitionFailure(error) {
1537
+ if (!currentNativeCapture()) return;
1538
+ session.error = error;
1539
+ reportTranscriptionError(error);
1540
+ }
1541
+ }
1542
+ );
1543
+ if (!session.capture) throw new Error('The selected live transcription service is unavailable.');
1544
+ session.capture.done.then(
1545
+ function nativeRecognitionCompleted() {
1546
+ return finishNativeRecognition();
1547
+ },
1548
+ finishNativeRecognition
1549
+ ).catch(
1550
+ function reportNativeRecognitionSettlementFailure(error) {
1551
+ if (destroyed || generation !== recordingGeneration) return;
1552
+ reportTranscriptionError(error);
1553
+ }
1554
+ );
1555
+ // No awaited preparation precedes the browser's gesture-bound start.
1556
+ const starting = session.capture.start({signal: session.controller.signal});
1557
+ return Promise.resolve(starting).catch(
1558
+ function reportNativeRecognitionStartupFailure(error) {
1559
+ if (!currentNativeCapture()) return false;
1560
+ session.capture.cancel();
1561
+ nativeCaptureSession = null;
1562
+ recordingRequested = false;
1563
+ resetRecordPress();
1564
+ if (error !== session.error) reportTranscriptionError(error);
1565
+ return false;
1566
+ }
1567
+ );
1568
+ } catch (error) {
1569
+ session.capture?.cancel();
1570
+ nativeCaptureSession = null;
1571
+ recordingRequested = false;
1572
+ resetRecordPress();
1573
+ if (error !== session.error) reportTranscriptionError(error);
1574
+ return false;
1575
+ }
1576
+ }
1577
+
1464
1578
  function stopAfterSilence() {
1465
1579
  clearSilenceTimer();
1466
1580
  stop();
@@ -1483,6 +1597,12 @@
1483
1597
  }
1484
1598
 
1485
1599
  function stop() {
1600
+ if (nativeCaptureSession) {
1601
+ recordingRequested = false;
1602
+ nativeCaptureSession.capture.stop();
1603
+ renderControls();
1604
+ return true;
1605
+ }
1486
1606
  const pendingCaptureStart = recordingRequested && !captureSession;
1487
1607
  const generation = recordingGeneration;
1488
1608
  const operationId = transcriptionOperationId;
@@ -1703,6 +1823,7 @@
1703
1823
  function cancelSTTOperation(reason) {
1704
1824
  const hadActiveOperation = recordingRequested
1705
1825
  || Boolean(captureSession)
1826
+ || Boolean(nativeCaptureSession)
1706
1827
  || transcriptionActive;
1707
1828
  recordingGeneration += 1;
1708
1829
  transcriptionGeneration += 1;
@@ -1713,6 +1834,14 @@
1713
1834
  clearSilenceTimer();
1714
1835
  resetRecordPress();
1715
1836
 
1837
+ if (nativeCaptureSession) {
1838
+ const session = nativeCaptureSession;
1839
+ nativeCaptureSession = null;
1840
+ session.cancelled = true;
1841
+ session.controller.abort();
1842
+ session.capture?.cancel();
1843
+ }
1844
+
1716
1845
  if (captureSession) {
1717
1846
  captureSession.cancelled = true;
1718
1847
  if (captureSession.recorder.state !== 'inactive') {
@@ -1769,6 +1898,7 @@
1769
1898
 
1770
1899
  function handlePageHide(event) {
1771
1900
  if (event.persisted === true) {
1901
+ if (nativeCaptureSession) stop();
1772
1902
  return;
1773
1903
  }
1774
1904
  destroy();
@@ -82,6 +82,7 @@
82
82
  <p id="description" class="description" part="description"></p>
83
83
  <section id="transcript" class="transcript" part="transcript"></section>
84
84
  <div class="transcription-feedback">
85
+ <p id="interim" class="status" part="interim" aria-live="polite" hidden></p>
85
86
  <p id="status" class="status" part="status" role="status" aria-live="polite"></p>
86
87
  <progress
87
88
  id="sttActivationProgress"
@@ -136,16 +137,20 @@
136
137
  {default:MD},
137
138
  {appendTranscription,createSTTActivationController,normalizeVoiceOptions},
138
139
  {requestAIRuntimeIntent,subscribeAIRuntimeState},
139
- {default:ContinuousVoiceCapture}
140
+ {default:ContinuousVoiceCapture},
141
+ {getAIProviderRuntime}
140
142
  ]=await Promise.all([
141
143
  import('../modules/MD.js'),
142
144
  import('../modules/ComponentContracts.js'),
143
145
  import('../modules/AIRuntimeState.js'),
144
- import('../modules/ContinuousVoiceCapture.js')
146
+ import('../modules/ContinuousVoiceCapture.js'),
147
+ import('../modules/AIProviderRuntime.js')
145
148
  ]);
149
+ const providerRuntime=getAIProviderRuntime();
146
150
  const description=host.shadowRoot.querySelector('#description');
147
151
  const transcriptElement=host.shadowRoot.querySelector('#transcript');
148
152
  const status=host.shadowRoot.querySelector('#status');
153
+ const interimElement=host.shadowRoot.querySelector('#interim');
149
154
  const startButton=host.shadowRoot.querySelector('#start');
150
155
  const sttActivationButton=host.shadowRoot.querySelector('#sttActivation');
151
156
  const sttActivationProgress = host.shadowRoot.querySelector('#sttActivationProgress');
@@ -385,6 +390,7 @@
385
390
  flushing:false,
386
391
  processing:'idle',
387
392
  queue:[],
393
+ interim:'',
388
394
  failure:null,
389
395
  captureError:null,
390
396
  worker:null,
@@ -396,15 +402,25 @@
396
402
  if(!isCurrentContinuousSession(session)||!session.listening){
397
403
  return false;
398
404
  }
399
- session.capture=new ContinuousVoiceCapture({
405
+ const captureOptions={
400
406
  ...options.capture,
401
407
  onSegment:function capturedSegment(clip){
402
408
  if(!isCurrentContinuousSession(session)){
403
409
  return;
404
410
  }
405
- session.queue.push({...clip,stage:'transcribe',segment:null});
411
+ session.queue.push({
412
+ ...clip,
413
+ stage:is.string(clip.text)?'append':'transcribe',
414
+ segment:is.string(clip.text)?clip.text:null
415
+ });
416
+ renderInterimRecognition(session);
406
417
  processContinuousQueue(session);
407
418
  },
419
+ onInterim:function displayInterimRecognition(result){
420
+ if(!isCurrentContinuousSession(session)) return;
421
+ session.interim=result.text;
422
+ renderInterimRecognition(session);
423
+ },
408
424
  onState:function captureStateChanged(nextState){
409
425
  if(!isCurrentContinuousSession(session)){
410
426
  return;
@@ -412,8 +428,11 @@
412
428
  session.captureState=nextState;
413
429
  if(nextState==='stopped'||nextState==='interrupted'){
414
430
  session.listening=false;
431
+ session.interim='';
432
+ renderInterimRecognition(session);
415
433
  }
416
434
  renderContinuousState(session);
435
+ settleContinuousDrain(session);
417
436
  },
418
437
  onError:function captureFailed(error){
419
438
  if(!isCurrentContinuousSession(session)){
@@ -425,7 +444,29 @@
425
444
  reportContinuousError(session,'capture',error);
426
445
  settleContinuousDrain(session);
427
446
  }
428
- });
447
+ };
448
+ session.capture=!options.transcribe
449
+ ?providerRuntime.createTranscriptionCapture(captureOptions):null;
450
+ session.native=Boolean(session.capture);
451
+ if(!session.capture){
452
+ session.capture=new ContinuousVoiceCapture(captureOptions);
453
+ }else{
454
+ session.capture.done.then(function nativeRecognitionEnded(){
455
+ if(!isCurrentContinuousSession(session)) return;
456
+ session.listening=false;
457
+ session.interim='';
458
+ renderInterimRecognition(session);
459
+ if(!session.captureError) session.captureState='stopped';
460
+ renderContinuousState(session);
461
+ settleContinuousDrain(session);
462
+ }).catch(function nativeRecognitionCleanupFailed(error){
463
+ if(!isCurrentContinuousSession(session)) return;
464
+ session.captureError=error;
465
+ session.listening=false;
466
+ reportContinuousError(session,'capture',error);
467
+ settleContinuousDrain(session);
468
+ });
469
+ }
429
470
  const started=await session.capture.start({
430
471
  mediaConstraints:options.mediaConstraints,
431
472
  signal:session.controller.signal
@@ -467,6 +508,17 @@
467
508
  };
468
509
  }
469
510
 
511
+ function renderInterimRecognition(session){
512
+ if(!isCurrentContinuousSession(session)) return;
513
+ const pending=[];
514
+ for(const clip of session.queue){
515
+ if(clip.stage==='append') pending.push(clip.segment);
516
+ }
517
+ const text=pending.join(options.separator)+session.interim;
518
+ interimElement.textContent=text;
519
+ interimElement.hidden=text.length===0;
520
+ }
521
+
470
522
  function renderContinuousState(session){
471
523
  if(!isCurrentContinuousSession(session)){
472
524
  return;
@@ -598,7 +650,10 @@
598
650
  throw new TypeError('Transcription must return the complete text as a string.');
599
651
  }
600
652
  clip.segment=result;
601
- if(result.length===0){
653
+ clip.stage='append';
654
+ }
655
+ if(clip.stage==='append'){
656
+ if(clip.segment.length===0){
602
657
  session.queue.shift();
603
658
  const detail={sequence:clip.sequence,transcript};
604
659
  dispatchVoiceEvent('voice-transcription-empty',detail,{
@@ -606,10 +661,11 @@
606
661
  });
607
662
  continue;
608
663
  }
609
- clip.transcript=appendTranscription(transcript,result,options.separator);
664
+ clip.transcript=appendTranscription(transcript,clip.segment,options.separator);
610
665
  clip.stage='save';
611
666
  transcript=clip.transcript;
612
667
  renderTranscript();
668
+ renderInterimRecognition(session);
613
669
  }
614
670
  if(options.persist){
615
671
  const save=options.onSave||(is.function(host.save)?host.save.bind(host):null);
@@ -740,6 +796,8 @@
740
796
  return false;
741
797
  }
742
798
  continuousSession=null;
799
+ interimElement.textContent='';
800
+ interimElement.hidden=true;
743
801
  session.controller.abort();
744
802
  session.requestController?.abort();
745
803
  session.capture?.cancel();
@@ -766,7 +824,8 @@
766
824
  }
767
825
 
768
826
  async function startRecording(){
769
- if(options.capture.mode==='continuous'){
827
+ if(options.capture.mode==='continuous'
828
+ ||(!options.transcribe&&providerRuntime.supportsTranscriptionCapture())){
770
829
  return startContinuousRecording();
771
830
  }
772
831
  if(!canStartVoiceRecording(sttRole,state,destroyed)){
@@ -1366,6 +1425,16 @@
1366
1425
  sttActivationController.synchronize(sttRole);
1367
1426
  if(continuousSession){
1368
1427
  const session=continuousSession;
1428
+ if(session.native&&session.listening
1429
+ &&(sttRole.state!=='ready'
1430
+ ||previousRole.providerId!==sttRole.providerId
1431
+ ||previousRole.modelId!==sttRole.modelId)){
1432
+ session.capture?.cancel();
1433
+ session.listening=false;
1434
+ session.captureState='interrupted';
1435
+ session.interim='';
1436
+ renderInterimRecognition(session);
1437
+ }
1369
1438
  if(['completing','complete'].includes(state)){
1370
1439
  renderState();
1371
1440
  return;
@@ -327,3 +327,44 @@ body{
327
327
  scroll-behavior:auto !important;
328
328
  transition-duration:.01ms !important;
329
329
  }
330
+
331
+ /* Paper uses the existing base light palette regardless of the screen skin.
332
+ * Print-only importance also overrides custom-theme inline tokens; no saved
333
+ * preference, screen layout, media paint, or scrolling state changes here.
334
+ */
335
+ @media print {
336
+ @page {
337
+ margin: 15mm;
338
+ }
339
+
340
+ :root,
341
+ body,
342
+ [data-arcane-palette] {
343
+ color-scheme: light !important;
344
+ --background: rgb(255, 255, 255) !important;
345
+ --modal-background: rgb(255, 255, 255) !important;
346
+ --text-color: rgb(23, 34, 56) !important;
347
+ --primary-color: rgb(23, 34, 56) !important;
348
+ --secondary-color: rgb(229, 233, 243) !important;
349
+ --accent-color: var(--focus-color) !important;
350
+ --button-text-color: rgb(255, 255, 255) !important;
351
+ --border-color: rgb(203, 211, 226) !important;
352
+ --focus-color: rgb(118, 87, 213) !important;
353
+ --muted-text-color: rgb(95, 107, 130) !important;
354
+ --success-color: rgb(39, 122, 75) !important;
355
+ --warning-color: rgb(154, 101, 0) !important;
356
+ --danger-color: rgb(165, 56, 56) !important;
357
+ --review-color: rgb(133, 85, 165) !important;
358
+ --shadow-color: rgba(23, 34, 56, .16) !important;
359
+ --platinum: rgb(102, 112, 133) !important;
360
+ --icon-filter: none !important;
361
+ --file-icon-filter: hue-rotate(8deg) saturate(.62) brightness(.86) !important;
362
+ }
363
+
364
+ html,
365
+ body {
366
+ margin: 0;
367
+ background-color: var(--modal-background);
368
+ color: var(--text-color);
369
+ }
370
+ }
@@ -2081,8 +2081,10 @@ class AI {
2081
2081
  #assertDeviceSpeechConfiguration(configuration){
2082
2082
  for(const routeName of ['default','localOnly']){
2083
2083
  const selection=configuration?.stt?.[routeName];
2084
- if(selection&&selection.localOnly!==true){
2085
- const error=new TypeError('Speech recognition supports on-device providers only.');
2084
+ const liveCapture=selection
2085
+ &&this.#providerRuntime.supportsTranscriptionCapture(selection.providerId);
2086
+ if(selection&&selection.localOnly!==true&&!liveCapture){
2087
+ const error=new TypeError('Speech recognition requires an on-device or explicitly selected live-capture provider.');
2086
2088
  error.code='AI_STT_DEVICE_ONLY';
2087
2089
  throw error;
2088
2090
  }
@@ -3430,8 +3432,9 @@ class AI {
3430
3432
  if(provider!==null&&provider?.role!==role){
3431
3433
  throw new TypeError('The speech provider must implement the selected role.');
3432
3434
  }
3433
- if(role==='stt'&&provider&&provider.localOnly!==true){
3434
- const error=new TypeError('Speech recognition supports on-device providers only.');
3435
+ if(role==='stt'&&provider&&provider.localOnly!==true
3436
+ &&!is.function(provider.createCapture)){
3437
+ const error=new TypeError('Speech recognition requires an on-device or explicitly selected live-capture provider.');
3435
3438
  error.code='AI_STT_DEVICE_ONLY';
3436
3439
  throw error;
3437
3440
  }
@@ -6875,6 +6878,14 @@ class AI {
6875
6878
  }
6876
6879
  }
6877
6880
 
6881
+ supportsTranscriptionCapture(){
6882
+ return this.#providerRuntime.supportsTranscriptionCapture();
6883
+ }
6884
+
6885
+ createTranscriptionCapture(options={}){
6886
+ return this.#providerRuntime.createTranscriptionCapture(options);
6887
+ }
6888
+
6878
6889
  async fetchSTT(audioFile,signal=null){
6879
6890
  const callId=this.#traceSpeech('fetchSTT.call',{audioFile,aborted:signal?.aborted});
6880
6891
  try{
@@ -1366,6 +1366,9 @@ function validateProvider(provider) {
1366
1366
  for (const method of PROVIDER_METHODS) {
1367
1367
  record[method] = provider[method].bind(provider);
1368
1368
  }
1369
+ if (provider.role === 'stt' && is.function(provider.createCapture)) {
1370
+ record.createCapture = provider.createCapture.bind(provider);
1371
+ }
1369
1372
  const admitted = completeValue(record);
1370
1373
  PROVIDER_RECORDS.set(provider, admitted);
1371
1374
  return admitted;
@@ -4103,6 +4106,121 @@ export class AIProviderRuntime {
4103
4106
  return this.#roleRequestAlias('llm', 'stream', payload, options);
4104
4107
  }
4105
4108
 
4109
+ supportsTranscriptionCapture(providerId = null) {
4110
+ const slot = this.#slots.stt;
4111
+ const provider = providerId === null
4112
+ ? this.#providerFor(slot)
4113
+ : this.#providers.get(providerKey('stt', providerId));
4114
+ return is.function(provider?.createCapture);
4115
+ }
4116
+
4117
+ createTranscriptionCapture(options = {}) {
4118
+ this.#assertOpen();
4119
+ this.#assertNotConfiguring();
4120
+ const slot = this.#slots.stt;
4121
+ const provider = this.#providerFor(slot);
4122
+ if (!is.function(provider?.createCapture)) return null;
4123
+
4124
+ const runtime = this;
4125
+ const generation = slot.generation;
4126
+ const capture = provider.createCapture(options);
4127
+ const controller = new AbortController();
4128
+ let requestRecord = null;
4129
+ let detachSignal = null;
4130
+ let started = false;
4131
+ let closed = false;
4132
+ const done = Promise.resolve(capture.done).finally(
4133
+ function releaseTranscriptionCapture() {
4134
+ closed = true;
4135
+ detachSignal?.();
4136
+ if (!requestRecord) return;
4137
+ slot.activeRequests.delete(requestRecord.requestSequence);
4138
+ runtime.#drainRoleRequestQueue(slot);
4139
+ if (slot.generation !== generation || slot.unloadPromise) return;
4140
+ if (slot.activeRequests.size || slot.requestQueue.length) {
4141
+ runtime.#publishRoleRequestState(slot);
4142
+ return;
4143
+ }
4144
+ const status = validateProviderStatus(provider.status());
4145
+ slot.ready = status.state === 'ready' && status.loaded && !status.busy;
4146
+ publishAIRuntimeRoleState(
4147
+ 'stt',
4148
+ roleRecord(
4149
+ 'stt',
4150
+ slot.selection,
4151
+ {state: slot.ready ? 'ready' : 'unloaded', loaded: status.loaded}
4152
+ )
4153
+ );
4154
+ }
4155
+ );
4156
+ done.catch(
4157
+ function observeTranscriptionCaptureCleanupFailure(error) {
4158
+ arcaneLogging.error('Transcription capture cleanup failed.', error);
4159
+ }
4160
+ );
4161
+ // Capture has its own synchronous start boundary so native microphone
4162
+ // permission retains the originating user gesture. It is never queued.
4163
+ return {
4164
+ done,
4165
+ start({signal} = {}) {
4166
+ if (started || closed) return Promise.resolve(false);
4167
+ try {
4168
+ runtime.#assertOpen();
4169
+ runtime.#assertNotConfiguring();
4170
+ const status = validateProviderStatus(provider.status());
4171
+ if (slot.generation !== generation || !slot.ready
4172
+ || slot.unloadPromise || slot.disposePromise
4173
+ || slot.activeRequests.size || slot.requestQueue.length
4174
+ || status.state !== 'ready' || !status.loaded || status.busy) {
4175
+ throw operationError(
4176
+ 'The selected transcription capture provider is not ready.',
4177
+ 'ARCANE_AI_ROLE_NOT_READY'
4178
+ );
4179
+ }
4180
+ assertAbortSignal(signal);
4181
+ started = true;
4182
+ detachSignal = runtime.#forwardAbort(signal, controller);
4183
+ const requestSequence = runtime.#nextRequestSequence(slot);
4184
+ requestRecord = {
4185
+ controller,
4186
+ requestSequence,
4187
+ operationId: runtime.#nextOperationId(slot, 'capture'),
4188
+ promise: done,
4189
+ cancel: function cancelOwnedTranscriptionCapture(reason) {
4190
+ controller.abort(reason);
4191
+ capture.cancel();
4192
+ return done;
4193
+ }
4194
+ };
4195
+ slot.activeRequests.set(requestSequence, requestRecord);
4196
+ runtime.#publishRoleRequestState(slot);
4197
+ const starting = capture.start({signal: controller.signal});
4198
+ return Promise.resolve(starting).catch(
4199
+ function releaseFailedTranscriptionCaptureStart(error) {
4200
+ capture.cancel();
4201
+ throw error;
4202
+ }
4203
+ );
4204
+ } catch (error) {
4205
+ capture.cancel();
4206
+ throw error;
4207
+ }
4208
+ },
4209
+ stop() {
4210
+ capture.stop();
4211
+ return done;
4212
+ },
4213
+ cancel() {
4214
+ controller.abort();
4215
+ capture.cancel();
4216
+ },
4217
+ destroy() {
4218
+ controller.abort();
4219
+ capture.destroy();
4220
+ }
4221
+ };
4222
+ }
4223
+
4106
4224
  transcribe(payload, options = {}) {
4107
4225
  arcaneLogging.debug('[Arcane speech runtime] transcribe', payload, options);
4108
4226
  return this.#roleRequestAlias('stt', 'transcribe', payload, options);
@@ -6,6 +6,20 @@ const markdownOptions = {
6
6
  async: false,
7
7
  pedantic: false,
8
8
  gfm: true,
9
+ tokenizer: {
10
+ html(src) {
11
+ const native = marked.Tokenizer.prototype.html.call(this, src);
12
+ if (native) return native;
13
+ // Quoted image values can contain blank lines that would otherwise
14
+ // split the tag into Markdown paragraphs before inline HTML parsing.
15
+ const image = /^ {0,3}<img(?:\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*\s*\/?>(?=[ \t]*(?:\n|$))/i;
16
+ const match = image.exec(src);
17
+ if (!match) return;
18
+ const quotedValues = match[0].match(/"[^"]*"|'[^']*'/g) || [];
19
+ if (!quotedValues.some(function containsNewline(value) {return value.includes('\n');})) return;
20
+ return {type:'html', block:true, raw:match[0], text:match[0]};
21
+ }
22
+ },
9
23
  renderer: {
10
24
  link(href, title, text) {
11
25
  const link = marked.Renderer.prototype.link.call(this, href, title, text);