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
@@ -1,3 +1,5 @@
1
+ import {createArcaneEventSource} from 'arcane-os/event-manager';
2
+
1
3
  const MAX_SPEECH_INPUT=3900;
2
4
  const MAX_SPEECH_CHUNKS=32;
3
5
  const MAX_SPEECH_CHARACTERS=MAX_SPEECH_INPUT*MAX_SPEECH_CHUNKS;
@@ -15,6 +17,20 @@ const SPEECH_VOICE_OPTIONS=Object.freeze([
15
17
  Object.freeze({value:'shimmer',label:'Shimmer'})
16
18
  ]);
17
19
  const SUPERSEDED=Object.freeze({superseded:true});
20
+ const SPEECH_PLAYBACK_STATE_EVENT='speech-playback-state';
21
+ const SPEECH_PLAYBACK_FAILURE_REASONS=Object.freeze({
22
+ ARCANE_AI_OPERATION_SUPERSEDED:'speech-synthesis-superseded',
23
+ ARCANE_AI_REQUEST_ABORTED:'speech-synthesis-cancelled',
24
+ ARCANE_SPEECH_PLAYBACK_AUDIO_PLAYBACK_REJECTED:'audio-playback-rejected',
25
+ ARCANE_SPEECH_PLAYBACK_SYNTHESIZED_AUDIO_CONTRACT_MISMATCH:'synthesized-audio-contract-mismatch',
26
+ ARCANE_SPEECH_PLAYBACK_SYNTHESIZER_UNAVAILABLE:'speech-synthesizer-unavailable'
27
+ });
28
+ const WAV_AUDIO_CONTENT_TYPES=Object.freeze(new Set([
29
+ 'audio/vnd.wave',
30
+ 'audio/wav',
31
+ 'audio/wave',
32
+ 'audio/x-wav'
33
+ ]));
18
34
  const queuesBySpeechClient=new WeakMap();
19
35
 
20
36
  class ReadonlyAliasSet{
@@ -103,7 +119,7 @@ function splitSpeechText(
103
119
  return chunks;
104
120
  }
105
121
 
106
- function normalizeParts(parts,defaultVoice='alloy',defaultSpeed=1){
122
+ function normalizeParts(parts,defaultVoice=null,defaultSpeed=1){
107
123
  if(!Array.isArray(parts)||!parts.length){
108
124
  throw new TypeError('Narration text cannot be blank. The full visual content remains available.');
109
125
  }
@@ -121,9 +137,14 @@ function normalizeParts(parts,defaultVoice='alloy',defaultSpeed=1){
121
137
  if(!Number.isFinite(pauseAfterMs)||pauseAfterMs<0||pauseAfterMs>60_000){
122
138
  throw new RangeError('Speech segment pauses must be between 0 and 60,000 milliseconds.');
123
139
  }
124
- const voice=String(candidate?.voice??defaultVoice).trim();
140
+ const voiceValue=candidate?.voice??defaultVoice;
141
+ const voice=voiceValue===null||voiceValue===undefined
142
+ ?null
143
+ :String(voiceValue).trim();
125
144
  const speed=Number(candidate?.speed??defaultSpeed);
126
- if(!voice)throw new TypeError('Every speech segment requires a voice.');
145
+ if(voiceValue!==null&&voiceValue!==undefined&&!voice){
146
+ throw new TypeError('Speech segment voice cannot be blank.');
147
+ }
127
148
  if(!Number.isFinite(speed)||speed<=0)throw new RangeError('Every speech segment requires a positive speech speed.');
128
149
  return Object.freeze({
129
150
  input,
@@ -142,6 +163,179 @@ function base64Bytes(value=''){
142
163
  return bytes;
143
164
  }
144
165
 
166
+ function synthesizedAudioContractError(cause=null){
167
+ const error=new TypeError('Arcane returned invalid synthesized audio.');
168
+ error.code='ARCANE_SPEECH_PLAYBACK_SYNTHESIZED_AUDIO_CONTRACT_MISMATCH';
169
+ if(cause!==null)error.cause=cause;
170
+ return error;
171
+ }
172
+
173
+ function normalizeAudioContentType(value,fallback=null){
174
+ const candidate=value===undefined||value===null?fallback:value;
175
+ if(typeof candidate!=='string'||!candidate.trim()){
176
+ throw synthesizedAudioContractError();
177
+ }
178
+ const contentType=candidate.split(';',1)[0].trim().toLowerCase();
179
+ if(!/^audio\/[a-z0-9][a-z0-9!#$&^_.+-]*$/u.test(contentType)){
180
+ throw synthesizedAudioContractError();
181
+ }
182
+ return contentType;
183
+ }
184
+
185
+ function audioContentTypesAgree(first,second){
186
+ return first===second
187
+ ||(WAV_AUDIO_CONTENT_TYPES.has(first)&&WAV_AUDIO_CONTENT_TYPES.has(second));
188
+ }
189
+
190
+ function speechAudioBytes(value){
191
+ if(value instanceof ArrayBuffer){
192
+ return new Uint8Array(value.slice(0));
193
+ }
194
+ if(ArrayBuffer.isView(value)){
195
+ return new Uint8Array(
196
+ value.buffer.slice(
197
+ value.byteOffset,
198
+ value.byteOffset+value.byteLength
199
+ )
200
+ );
201
+ }
202
+ throw synthesizedAudioContractError();
203
+ }
204
+
205
+ function bytesMatchASCII(bytes,offset,value){
206
+ if(offset+value.length>bytes.byteLength)return false;
207
+ for(let index=0;index<value.length;index+=1){
208
+ if(bytes[offset+index]!==value.charCodeAt(index))return false;
209
+ }
210
+ return true;
211
+ }
212
+
213
+ function assertPlayableWav(bytes){
214
+ if(bytes.byteLength<44
215
+ ||!bytesMatchASCII(bytes,0,'RIFF')
216
+ ||!bytesMatchASCII(bytes,8,'WAVE')){
217
+ throw synthesizedAudioContractError();
218
+ }
219
+ const view=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength);
220
+ if(view.getUint32(4,true)+8!==bytes.byteLength){
221
+ throw synthesizedAudioContractError();
222
+ }
223
+ let offset=12;
224
+ let formatFound=false;
225
+ let audioFound=false;
226
+ while(offset+8<=bytes.byteLength){
227
+ const chunkBytes=view.getUint32(offset+4,true);
228
+ const chunkStart=offset+8;
229
+ const chunkEnd=chunkStart+chunkBytes;
230
+ if(chunkEnd>bytes.byteLength){
231
+ throw synthesizedAudioContractError();
232
+ }
233
+ if(bytesMatchASCII(bytes,offset,'fmt ')){
234
+ if(chunkBytes<16
235
+ ||view.getUint16(chunkStart,true)===0
236
+ ||view.getUint16(chunkStart+2,true)===0
237
+ ||view.getUint32(chunkStart+4,true)===0
238
+ ||view.getUint32(chunkStart+8,true)===0
239
+ ||view.getUint16(chunkStart+12,true)===0
240
+ ||view.getUint16(chunkStart+14,true)===0){
241
+ throw synthesizedAudioContractError();
242
+ }
243
+ formatFound=true;
244
+ }else if(bytesMatchASCII(bytes,offset,'data')){
245
+ if(chunkBytes===0)throw synthesizedAudioContractError();
246
+ audioFound=true;
247
+ }
248
+ offset=chunkEnd+(chunkBytes%2);
249
+ }
250
+ if(offset!==bytes.byteLength||!formatFound||!audioFound){
251
+ throw synthesizedAudioContractError();
252
+ }
253
+ }
254
+
255
+ async function validatedSpeechBlob(blob,declaredContentType=null){
256
+ if(!(blob instanceof Blob)||blob.size===0){
257
+ throw synthesizedAudioContractError();
258
+ }
259
+ const blobContentType=blob.type
260
+ ?normalizeAudioContentType(blob.type)
261
+ :null;
262
+ const declared=declaredContentType===undefined||declaredContentType===null
263
+ ?null
264
+ :normalizeAudioContentType(declaredContentType);
265
+ if(blobContentType&&declared
266
+ &&!audioContentTypesAgree(blobContentType,declared)){
267
+ throw synthesizedAudioContractError();
268
+ }
269
+ const contentType=declared||blobContentType;
270
+ if(!contentType)throw synthesizedAudioContractError();
271
+ if(WAV_AUDIO_CONTENT_TYPES.has(contentType)){
272
+ assertPlayableWav(new Uint8Array(await blob.arrayBuffer()));
273
+ }
274
+ return blobContentType===contentType
275
+ ?blob
276
+ :new Blob([blob],{type:contentType});
277
+ }
278
+
279
+ async function validatedSpeechBytes(bytes,contentType='audio/wav'){
280
+ const normalizedContentType=normalizeAudioContentType(
281
+ contentType,
282
+ 'audio/wav'
283
+ );
284
+ if(bytes.byteLength===0)throw synthesizedAudioContractError();
285
+ if(WAV_AUDIO_CONTENT_TYPES.has(normalizedContentType)){
286
+ assertPlayableWav(bytes);
287
+ }
288
+ return new Blob([bytes],{type:normalizedContentType});
289
+ }
290
+
291
+ async function playableSpeechBlob(response){
292
+ try{
293
+ if(response instanceof Blob)return validatedSpeechBlob(response);
294
+ if(response instanceof ArrayBuffer||ArrayBuffer.isView(response)){
295
+ return validatedSpeechBytes(speechAudioBytes(response));
296
+ }
297
+ if(response&&typeof response==='object'){
298
+ if(response.audio instanceof Blob){
299
+ return validatedSpeechBlob(
300
+ response.audio,
301
+ response.contentType
302
+ );
303
+ }
304
+ if(response.audio instanceof ArrayBuffer||ArrayBuffer.isView(response.audio)){
305
+ return validatedSpeechBytes(
306
+ speechAudioBytes(response.audio),
307
+ response.contentType
308
+ );
309
+ }
310
+ if(typeof response.audioBase64==='string'&&response.audioBase64){
311
+ return validatedSpeechBytes(
312
+ base64Bytes(response.audioBase64),
313
+ response.contentType
314
+ );
315
+ }
316
+ }
317
+ }catch(error){
318
+ if(error?.code
319
+ ==='ARCANE_SPEECH_PLAYBACK_SYNTHESIZED_AUDIO_CONTRACT_MISMATCH'){
320
+ throw error;
321
+ }
322
+ throw synthesizedAudioContractError(error);
323
+ }
324
+ throw synthesizedAudioContractError();
325
+ }
326
+
327
+ function speechPlaybackFailureReason(error){
328
+ if(error?.name==='AbortError')return 'speech-synthesis-cancelled';
329
+ if(typeof error?.code==='string'
330
+ &&Object.hasOwn(SPEECH_PLAYBACK_FAILURE_REASONS,error.code)){
331
+ return SPEECH_PLAYBACK_FAILURE_REASONS[error.code];
332
+ }
333
+ if(error instanceof TypeError||error instanceof RangeError){
334
+ return 'speech-playback-request-contract-mismatch';
335
+ }
336
+ return 'speech-synthesis-rejected';
337
+ }
338
+
145
339
  class LatestSpeechQueue{
146
340
  constructor(){
147
341
  this.active=null;
@@ -224,6 +418,10 @@ class SpeechPlayback{
224
418
  constructor({
225
419
  audio,
226
420
  speech=globalThis.Arcane?.speech,
421
+ model=null,
422
+ voice=null,
423
+ responseFormat=null,
424
+ speed=1,
227
425
  onState=function noop(){},
228
426
  createObjectURL,
229
427
  revokeObjectURL,
@@ -233,13 +431,37 @@ class SpeechPlayback{
233
431
  if(!audio||typeof audio.addEventListener!=='function'){
234
432
  throw new TypeError('SpeechPlayback requires an audio element.');
235
433
  }
434
+ const normalizedSpeed=Number(speed);
435
+ if(!Number.isFinite(normalizedSpeed)||normalizedSpeed<=0){
436
+ throw new RangeError('SpeechPlayback speed must be a positive number.');
437
+ }
236
438
  this.audio=audio;
237
439
  this.speech=speech;
440
+ this.events=createArcaneEventSource(
441
+ this,
442
+ {
443
+ source:'speech-playback',
444
+ eventTypes:Object.freeze([SPEECH_PLAYBACK_STATE_EVENT])
445
+ }
446
+ );
238
447
  this.onState=onState;
239
448
  this.createObjectURL=createObjectURL||function createAudioURL(blob){return URL.createObjectURL(blob);};
240
449
  this.revokeObjectURL=revokeObjectURL||function revokeAudioURL(url){URL.revokeObjectURL(url);};
241
- this.delay=delay||function playbackDelay(duration){
242
- return new Promise(function wait(resolve){globalThis.setTimeout(resolve,duration);});
450
+ this.delay=delay||function playbackDelay(duration,signal){
451
+ return new Promise(function wait(resolve){
452
+ let timer=null;
453
+ const finish=()=>{
454
+ globalThis.clearTimeout(timer);
455
+ signal?.removeEventListener('abort',finish);
456
+ resolve();
457
+ };
458
+ signal?.addEventListener('abort',finish,{once:true});
459
+ if(signal?.aborted){
460
+ finish();
461
+ return;
462
+ }
463
+ timer=globalThis.setTimeout(finish,duration);
464
+ });
243
465
  };
244
466
  this.messages=Object.freeze({...DEFAULT_MESSAGES,...messages});
245
467
  this.urls=[];
@@ -249,10 +471,18 @@ class SpeechPlayback{
249
471
  this.pendingGenerations=new Set();
250
472
  this.lookahead=null;
251
473
  this.lookaheadError=null;
252
- this.voice='alloy';
253
- this.speed=1;
474
+ this.model=model===null||model===undefined?null:String(model).trim()||null;
475
+ this.voice=voice===null||voice===undefined?null:String(voice).trim()||null;
476
+ this.responseFormat=responseFormat===null||responseFormat===undefined
477
+ ?null
478
+ :String(responseFormat).trim()||null;
479
+ this.speed=normalizedSpeed;
254
480
  this.key='';
255
481
  this.state='idle';
482
+ this.operationSequence=0;
483
+ this.operationId=null;
484
+ this.abortControllers=new Set();
485
+ this.destroyed=false;
256
486
  this.boundEnded=this.handleEnded.bind(this);
257
487
  this.boundPlay=this.handlePlay.bind(this);
258
488
  this.boundPause=this.handlePause.bind(this);
@@ -270,9 +500,19 @@ class SpeechPlayback{
270
500
  return String(typeof value==='function'?value(details):value||'');
271
501
  }
272
502
 
273
- emit(state,message,key=this.key){
503
+ nextOperationId(){
504
+ if(this.operationSequence===Number.MAX_SAFE_INTEGER){
505
+ const error=new RangeError('SpeechPlayback operation sequence is exhausted.');
506
+ error.code='ARCANE_SPEECH_PLAYBACK_OPERATION_SEQUENCE_EXHAUSTED';
507
+ throw error;
508
+ }
509
+ this.operationSequence+=1;
510
+ return `${this.events.descriptor.instanceId}:playback:${this.operationSequence.toString(36)}`;
511
+ }
512
+
513
+ emit(state,message,key=this.key,{code=null,reason=null}={}){
274
514
  this.state=state;
275
- this.onState({
515
+ const detail=Object.freeze({
276
516
  state,
277
517
  message,
278
518
  key,
@@ -280,11 +520,49 @@ class SpeechPlayback{
280
520
  total:this.parts.length||this.urls.filter(Boolean).length,
281
521
  producing:this.synthesisInFlight,
282
522
  buffered:this.urls.filter(Boolean).length,
283
- hasAudio:this.hasAudio()
523
+ hasAudio:this.hasAudio(),
524
+ operationId:this.operationId,
525
+ code,
526
+ reason
284
527
  });
528
+ if(!this.destroyed){
529
+ this.events.dispatch(
530
+ SPEECH_PLAYBACK_STATE_EVENT,
531
+ detail,
532
+ {
533
+ operationId:this.operationId,
534
+ publicDetail:{
535
+ state,
536
+ key,
537
+ index:detail.index,
538
+ total:detail.total,
539
+ producing:detail.producing,
540
+ buffered:detail.buffered,
541
+ hasAudio:detail.hasAudio,
542
+ code,
543
+ reason
544
+ }
545
+ }
546
+ );
547
+ }
548
+ try{
549
+ this.onState(detail);
550
+ }catch(error){
551
+ if(typeof globalThis.reportError==='function')globalThis.reportError(error);
552
+ else console.error(error);
553
+ }
554
+ return detail;
285
555
  }
286
556
 
287
- available(){return Boolean(this.speech&&typeof this.speech.synthesize==='function');}
557
+ available(){
558
+ return Boolean(
559
+ this.speech
560
+ &&(
561
+ typeof this.speech.fetchTTS==='function'
562
+ ||typeof this.speech.synthesize==='function'
563
+ )
564
+ );
565
+ }
288
566
 
289
567
  hasAudio(key=this.key){return Boolean(this.urls.some(Boolean)&&(!key||key===this.key));}
290
568
 
@@ -300,9 +578,14 @@ class SpeechPlayback{
300
578
 
301
579
  releaseUrls(){this.releaseURLs();}
302
580
 
303
- cancel(message=this.message('idle')){
581
+ cancel(
582
+ message=this.message('idle'),
583
+ reason='speech-playback-cancelled'
584
+ ){
304
585
  const cancelledKey=this.key;
305
586
  this.generation+=1;
587
+ for(const controller of this.abortControllers)controller.abort();
588
+ this.abortControllers.clear();
306
589
  this.audio.pause();
307
590
  this.audio.removeAttribute?.('src');
308
591
  this.audio.load?.();
@@ -310,44 +593,68 @@ class SpeechPlayback{
310
593
  this.releaseURLs();
311
594
  this.index=0;
312
595
  this.key='';
313
- this.emit('idle',message,cancelledKey);
596
+ this.emit('idle',message,cancelledKey,{reason});
597
+ }
598
+
599
+ async requestSpeech(part,signal){
600
+ const payload={
601
+ input:part.input,
602
+ speed:part.speed,
603
+ ...(this.model?{model:this.model}:{}),
604
+ ...(part.voice?{voice:part.voice}:{}),
605
+ ...(this.responseFormat?{responseFormat:this.responseFormat}:{})
606
+ };
607
+ if(typeof this.speech?.fetchTTS==='function'){
608
+ return playableSpeechBlob(
609
+ await this.speech.fetchTTS(payload,signal)
610
+ );
611
+ }
612
+ if(typeof this.speech?.synthesize==='function'){
613
+ return playableSpeechBlob(
614
+ await this.speech.synthesize(payload,{signal})
615
+ );
616
+ }
617
+ const error=new Error(this.message('unavailable'));
618
+ error.code='ARCANE_SPEECH_PLAYBACK_SYNTHESIZER_UNAVAILABLE';
619
+ throw error;
314
620
  }
315
621
 
316
622
  async synthesizeSegment(index,generation,announce=false){
317
623
  const playback=this;
318
- const token=Object.freeze({generation,index});
624
+ const controller=new AbortController();
625
+ const token=Object.freeze({generation,index,controller});
319
626
  const queue=queueFor(this.speech);
320
627
  this.pendingGenerations.add(token);
628
+ this.abortControllers.add(controller);
321
629
  try{
322
630
  return await queue.enqueue(async function synthesizeQueuedSegment(){
323
631
  const part=playback.parts[index];
324
- if(generation!==playback.generation||!part)return SUPERSEDED;
632
+ if(generation!==playback.generation||!part){
633
+ controller.abort();
634
+ return SUPERSEDED;
635
+ }
325
636
  if(announce){
326
637
  playback.emit('synthesizing',playback.message('preparing',{count:playback.parts.length}));
327
638
  }
328
- const response=await playback.speech.synthesize({
329
- model:'kokoro',
330
- input:part.input,
331
- voice:part.voice,
332
- responseFormat:'opus',
333
- speed:part.speed
334
- });
335
- if(generation!==playback.generation)return SUPERSEDED;
336
- if(!response||typeof response.audioBase64!=='string'||!response.audioBase64){
337
- throw new TypeError(playback.message('invalidAudio'));
639
+ const audio=await playback.requestSpeech(
640
+ part,
641
+ controller.signal
642
+ );
643
+ if(generation!==playback.generation){
644
+ controller.abort();
645
+ return SUPERSEDED;
338
646
  }
339
- const contentType=typeof response.contentType==='string'&&response.contentType
340
- ?response.contentType
341
- :'audio/ogg';
342
- const url=playback.createObjectURL(new Blob([base64Bytes(response.audioBase64)],{type:contentType}));
647
+ const url=playback.createObjectURL(audio);
343
648
  if(generation!==playback.generation){
344
649
  playback.revokeObjectURL(url);
650
+ controller.abort();
345
651
  return SUPERSEDED;
346
652
  }
347
653
  return {url};
348
654
  },{speculative:!announce});
349
655
  }finally{
350
656
  this.pendingGenerations.delete(token);
657
+ this.abortControllers.delete(controller);
351
658
  }
352
659
  }
353
660
 
@@ -407,20 +714,70 @@ class SpeechPlayback{
407
714
  return generation===this.generation&&result.ready===true&&Boolean(this.urls[index]);
408
715
  }
409
716
 
410
- async prepare({key,parts,voice='alloy',speed=1,autoplay=true}={}){
411
- this.cancel();
717
+ async waitForPause(duration,generation){
718
+ const controller=new AbortController();
719
+ this.abortControllers.add(controller);
720
+ try{
721
+ await this.delay(duration,controller.signal);
722
+ return !controller.signal.aborted&&generation===this.generation;
723
+ }catch(error){
724
+ if(controller.signal.aborted||generation!==this.generation)return false;
725
+ throw error;
726
+ }finally{
727
+ this.abortControllers.delete(controller);
728
+ }
729
+ }
730
+
731
+ async prepare({
732
+ key,
733
+ parts,
734
+ model=this.model,
735
+ voice=this.voice,
736
+ responseFormat=this.responseFormat,
737
+ speed=this.speed,
738
+ autoplay=true
739
+ }={}){
740
+ if(this.destroyed){
741
+ const error=new Error('SpeechPlayback is destroyed.');
742
+ error.code='ARCANE_SPEECH_PLAYBACK_DESTROYED';
743
+ throw error;
744
+ }
745
+ this.cancel(this.message('idle'),'playback-replaced');
412
746
  const generation=this.generation;
747
+ this.operationId=this.nextOperationId();
413
748
  this.key=String(key||'narration');
414
749
  const requestKey=this.key;
415
750
  let normalized;
416
751
  try{
417
- if(!this.available())throw new Error(this.message('unavailable'));
752
+ if(!this.available()){
753
+ const error=new Error(this.message('unavailable'));
754
+ error.code='ARCANE_SPEECH_PLAYBACK_SYNTHESIZER_UNAVAILABLE';
755
+ throw error;
756
+ }
418
757
  const numericSpeed=Number(speed);
419
758
  if(!Number.isFinite(numericSpeed)||numericSpeed<=0)throw new RangeError('Speech speed must be a positive number.');
420
- const selectedVoice=String(voice).trim();
421
- if(!selectedVoice)throw new TypeError('Speech voice cannot be blank.');
759
+ const selectedModel=model===null||model===undefined
760
+ ?null
761
+ :String(model).trim();
762
+ const selectedVoice=voice===null||voice===undefined
763
+ ?null
764
+ :String(voice).trim();
765
+ const selectedResponseFormat=responseFormat===null||responseFormat===undefined
766
+ ?null
767
+ :String(responseFormat).trim();
768
+ if(model!==null&&model!==undefined&&!selectedModel){
769
+ throw new TypeError('Speech model cannot be blank.');
770
+ }
771
+ if(voice!==null&&voice!==undefined&&!selectedVoice){
772
+ throw new TypeError('Speech voice cannot be blank.');
773
+ }
774
+ if(responseFormat!==null&&responseFormat!==undefined&&!selectedResponseFormat){
775
+ throw new TypeError('Speech response format cannot be blank.');
776
+ }
422
777
  this.speed=numericSpeed;
778
+ this.model=selectedModel;
423
779
  this.voice=selectedVoice;
780
+ this.responseFormat=selectedResponseFormat;
424
781
  normalized=normalizeParts(
425
782
  typeof parts==='function'?parts():parts,
426
783
  selectedVoice,
@@ -474,8 +831,13 @@ class SpeechPlayback{
474
831
  try{
475
832
  await this.audio.play();
476
833
  return true;
477
- }catch{
478
- this.emit('ready',this.message('autoplayBlocked'));
834
+ }catch(error){
835
+ this.emit(
836
+ 'ready',
837
+ this.message('autoplayBlocked'),
838
+ this.key,
839
+ {reason:'audio-autoplay-rejected'}
840
+ );
479
841
  return false;
480
842
  }
481
843
  }
@@ -507,7 +869,10 @@ class SpeechPlayback{
507
869
  stop(){
508
870
  const hasAudio=this.hasAudio();
509
871
  if(!this.synthesisInFlight&&!hasAudio)return;
510
- this.cancel(this.message(hasAudio?'stopped':'preparationStopped'));
872
+ this.cancel(
873
+ this.message(hasAudio?'stopped':'preparationStopped'),
874
+ 'playback-stopped'
875
+ );
511
876
  }
512
877
 
513
878
  async advance(){
@@ -520,8 +885,9 @@ class SpeechPlayback{
520
885
  const pauseAfterMs=this.parts[this.index]?.pauseAfterMs||0;
521
886
  if(pauseAfterMs>0){
522
887
  this.emit('pausing',this.message('pausing',{duration:pauseAfterMs}));
523
- await this.delay(pauseAfterMs);
524
- if(generation!==this.generation||!this.hasAudio())return false;
888
+ if(!await this.waitForPause(pauseAfterMs,generation)||!this.hasAudio()){
889
+ return false;
890
+ }
525
891
  }
526
892
  const nextIndex=this.index+1;
527
893
  if(!this.urls[nextIndex]){
@@ -563,9 +929,39 @@ class SpeechPlayback{
563
929
  }
564
930
  }
565
931
 
566
- handleError(){this.fail(new Error(this.message('playbackError')));}
932
+ handleError(){
933
+ const error=new Error(this.message('playbackError'));
934
+ error.code='ARCANE_SPEECH_PLAYBACK_AUDIO_PLAYBACK_REJECTED';
935
+ this.fail(error);
936
+ }
937
+
938
+ fail(error){
939
+ const reason=speechPlaybackFailureReason(error);
940
+ const code=typeof error?.code==='string'&&error.code.trim()
941
+ ?error.code.trim()
942
+ :reason==='speech-playback-request-contract-mismatch'
943
+ ?'ARCANE_SPEECH_PLAYBACK_REQUEST_CONTRACT_MISMATCH'
944
+ :'ARCANE_SPEECH_PLAYBACK_SYNTHESIS_REQUEST_REJECTED';
945
+ return this.emit(
946
+ 'error',
947
+ String(error?.message||this.message('fallbackError')),
948
+ this.key,
949
+ {code,reason}
950
+ );
951
+ }
567
952
 
568
- fail(error){this.emit('error',String(error?.message||this.message('fallbackError')));}
953
+ destroy(){
954
+ if(this.destroyed)return false;
955
+ this.cancel(this.message('idle'),'playback-destroyed');
956
+ this.audio.removeEventListener('ended',this.boundEnded);
957
+ this.audio.removeEventListener('play',this.boundPlay);
958
+ this.audio.removeEventListener('pause',this.boundPause);
959
+ this.audio.removeEventListener('error',this.boundError);
960
+ this.destroyed=true;
961
+ this.events.dispose();
962
+ this.onState=function destroyedSpeechPlaybackStateObserver(){};
963
+ return true;
964
+ }
569
965
  }
570
966
 
571
967
  export {
@@ -575,6 +971,7 @@ export {
575
971
  PREFERRED_STREAM_SEGMENT,
576
972
  SPEECH_VOICE_ALIASES,
577
973
  SPEECH_VOICE_OPTIONS,
974
+ SPEECH_PLAYBACK_STATE_EVENT,
578
975
  SpeechPlayback,
579
976
  splitSpeechText
580
977
  };