arcane-os 0.48.0 → 0.50.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.50.0
4
+
5
+ - Add `createBrowserSpeechSynthesisProvider` through `arcane-os/ai/browser-speech`.
6
+ Expose actual browser voices and language metadata through the ordinary model
7
+ catalog and replaying `voiceschanged` subscription. Construction, catalog
8
+ discovery and preparation remain silent; explicit playback owns native
9
+ completion, pause, resume, stop and resource release. Applications select
10
+ defaults and personalize previews. Browser voices may use remote services.
11
+ - Add silent `AI.prepareTTSPlayback` for native descriptors or real Blob audio.
12
+ Shared AI streaming and SpeechPlayback preserve original order, mixed-audio
13
+ pauses, cancellation and ownership through actual native release. Native
14
+ descriptors never become fake audio files; `fetchTTS` and durable `prepareTTS`
15
+ remain real-audio APIs and report native export unavailability explicitly.
16
+ Existing saved audio, provider selections and defaults stay supported.
17
+ - Preserve the original STT capture operation ID when cancellation follows a
18
+ recognition error, so the existing Chat composer can retire its unedited
19
+ interim range while retaining confirmed text and user edits. No app-local
20
+ repair, saved-data migration, new dependency or automatic provider switch.
21
+
22
+ ## 0.49.0
23
+
24
+ - Queue complete TWiN Cloud/FAL synthesis jobs through credential readiness,
25
+ submission, remote completion and the complete audio response. Excess jobs
26
+ wait in order; cancellation removes waiting work before submission. Preserve
27
+ independent follow-up HTTP concurrency and dispatch cadence, full input,
28
+ caller cancellation and original-order playback.
29
+ - Queue initial POST attempts and eligible retries with a shared HTTP 429
30
+ cooldown. Preserve the bounded readable-rejection retry and never replay an
31
+ ambiguous POST network, JSON or error-body failure. Existing provider factory,
32
+ compatibility alias and configuration remain supported.
33
+ - Expose optional ordinary Kokoro `model.voices` display records through the
34
+ synchronous provider catalog without loading or fetching a model. Applications
35
+ own their selected inventory and previews; metadata does not limit synthesis
36
+ voices or claim playback availability. Existing artifact-graph voice paths
37
+ retain their meaning. No new dependency, saved-data migration or default
38
+ provider change; native browser TTS is a separate subsequent increment.
39
+
3
40
  ## 0.48.0
4
41
 
5
42
  - Add opt-in Chat `transcriptionMode='draft'` and public `appendDraft(text)`.
package/README.md CHANGED
@@ -19,7 +19,7 @@ version-locked SDK runtime, while an integrated Arcane checkout uses its live
19
19
  `arcane/` runtime. Both profiles share the theme, packaging, event, cancellation,
20
20
  and browser run contracts while retaining their selected application layout.
21
21
 
22
- This checkout defines the `0.48.0` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.50.0` SDK contract. Applications pin one exact npm
23
23
  version and lockfile; registry state is deliberately not baked into application
24
24
  artifacts.
25
25
 
@@ -899,6 +899,9 @@ export function createBrowserSpeechAuthority({
899
899
  const dtype = model.dtype === undefined
900
900
  ? null
901
901
  : identifier(model.dtype, "Browser speech model dtype");
902
+ if (role === "tts" && model.voices !== undefined && !is.array(model.voices)) {
903
+ throw new TypeError("Browser Kokoro model voices must be an array.");
904
+ }
902
905
  const modelFiles = uniqueFiles(
903
906
  model.files ?? [],
904
907
  "Browser speech model",
@@ -967,6 +970,7 @@ export function createBrowserSpeechAuthority({
967
970
  revision: modelRevision,
968
971
  ...(dtype === null ? {} : { dtype }),
969
972
  defaultVoice: normalizedModel.defaultVoice,
973
+ ...(role === "tts" && model.voices !== undefined ? { voices: model.voices } : {}),
970
974
  runtime: completeValue({
971
975
  adapter: normalizedRuntime.adapter,
972
976
  version: normalizedRuntime.version,
@@ -1151,7 +1151,7 @@ function createBrowserSpeechProvider({
1151
1151
  ...(authority.dtype ? { dtype: authority.dtype } : {}),
1152
1152
  runtime: authority.runtime,
1153
1153
  files: authority.files,
1154
- ...(authority.graph ? {
1154
+ ...(authority.graph || authority.voices !== undefined ? {
1155
1155
  voices: authority.voices,
1156
1156
  } : {}),
1157
1157
  speech,
@@ -0,0 +1,568 @@
1
+ import Is from '../dependencies/strong-type/index.js';
2
+ import {arcaneLogging} from '../logging.mjs';
3
+
4
+ const is = new Is(false);
5
+ const synthesisQueues = new WeakMap();
6
+
7
+ function synthesisError(message, code, cause) {
8
+ const error = cause === undefined ? new Error(message) : new Error(message, {cause});
9
+ error.code = code;
10
+ return error;
11
+ }
12
+
13
+ function cancelledSpeech(signal) {
14
+ const error = synthesisError(
15
+ 'The browser speech operation was cancelled.',
16
+ 'ARCANE_AI_REQUEST_ABORTED',
17
+ signal?.reason
18
+ );
19
+ error.name = 'AbortError';
20
+ return error;
21
+ }
22
+
23
+ function synthesisCapability() {
24
+ const synthesis = globalThis.speechSynthesis;
25
+ const Utterance = globalThis.SpeechSynthesisUtterance;
26
+ if (!synthesis || !is.function(Utterance)
27
+ || !is.function(synthesis.getVoices) || !is.function(synthesis.speak)
28
+ || !is.function(synthesis.cancel) || !is.function(synthesis.pause)
29
+ || !is.function(synthesis.resume) || !is.function(synthesis.addEventListener)
30
+ || !is.function(synthesis.removeEventListener)) return null;
31
+ return {synthesis, Utterance};
32
+ }
33
+
34
+ function unavailableSynthesis() {
35
+ return synthesisError(
36
+ 'Native browser speech synthesis is unavailable in this browser.',
37
+ 'ARCANE_AI_SPEECH_SYNTHESIS_UNAVAILABLE'
38
+ );
39
+ }
40
+
41
+ function assertIdentifier(value, label) {
42
+ if (!is.string(value) || !value || value.trim() !== value) {
43
+ throw new TypeError(`${label} must be a nonempty string without surrounding whitespace.`);
44
+ }
45
+ }
46
+
47
+ function assertSignal(signal) {
48
+ if (signal !== null && (!is.boolean(signal?.aborted)
49
+ || !is.function(signal?.addEventListener) || !is.function(signal?.removeEventListener))) {
50
+ throw new TypeError('Browser speech signal must be an AbortSignal.');
51
+ }
52
+ }
53
+
54
+ function observeCallback(callback, value) {
55
+ if (!is.function(callback)) return;
56
+ function reportObserverFailure(error) {
57
+ arcaneLogging.error('Browser speech observer failed.', error);
58
+ }
59
+ try {
60
+ Promise.resolve(callback(value)).catch(reportObserverFailure);
61
+ } catch (error) {
62
+ reportObserverFailure(error);
63
+ }
64
+ }
65
+
66
+ function queueFor(synthesis) {
67
+ let queue = synthesisQueues.get(synthesis);
68
+ if (!queue) {
69
+ queue = {active: null, pending: [], advancing: false};
70
+ synthesisQueues.set(synthesis, queue);
71
+ }
72
+ return queue;
73
+ }
74
+
75
+ function advanceQueue(queue) {
76
+ if (queue.advancing) return;
77
+ queue.advancing = true;
78
+ try {
79
+ while (!queue.active && queue.pending.length) {
80
+ const record = queue.pending.shift();
81
+ if (record.settled) continue;
82
+ queue.active = record;
83
+ record.start();
84
+ }
85
+ } finally {
86
+ queue.advancing = false;
87
+ }
88
+ }
89
+
90
+ /** Browser-owned utterance playback. Preparing a request never speaks or creates audio. */
91
+ export function createBrowserSpeechSynthesisProvider({id, model, language} = {}) {
92
+ assertIdentifier(id, 'Browser speech synthesis provider id');
93
+ assertIdentifier(model?.id, 'Browser speech synthesis model id');
94
+ if (language !== undefined && !is.string(language)) {
95
+ throw new TypeError('Browser speech synthesis language must be a string.');
96
+ }
97
+ if (model.defaultVoice !== undefined && model.defaultVoice !== null
98
+ && !is.string(model.defaultVoice)) {
99
+ throw new TypeError('Browser speech synthesis default voice must be a string.');
100
+ }
101
+
102
+ const modelId = model.id;
103
+ const modelName = model.name;
104
+ const defaultVoice = model.defaultVoice || null;
105
+ const playbacks = new Set();
106
+ const catalogObservers = new Set();
107
+ let catalogSource = null;
108
+ let state = 'unloaded';
109
+ let generation = 0;
110
+ let unloadOperation = null;
111
+ let disposeOperation = null;
112
+
113
+ function assertOpen() {
114
+ if (state === 'disposed' || disposeOperation) {
115
+ throw synthesisError('The browser speech provider is disposed.', 'ARCANE_AI_PROVIDER_DISPOSED');
116
+ }
117
+ if (state === 'unloading' || unloadOperation) {
118
+ throw synthesisError('The browser speech provider is unloading.', 'ARCANE_AI_OPERATION_SUPERSEDED');
119
+ }
120
+ }
121
+
122
+ function assertSelection(selection) {
123
+ if (selection && (selection.providerId !== id || selection.modelId !== modelId
124
+ || selection.localOnly === true
125
+ || (selection.role !== undefined && selection.role !== 'tts'))) {
126
+ throw synthesisError(
127
+ 'The selected browser speech model does not match this provider.',
128
+ 'ARCANE_AI_MODEL_AUTHORITY_REQUIRED'
129
+ );
130
+ }
131
+ }
132
+
133
+ function assertContext(context) {
134
+ if (context.role !== undefined && context.role !== 'tts') {
135
+ throw synthesisError('This provider supports only TTS.', 'ARCANE_AI_INVALID_REQUEST');
136
+ }
137
+ assertSelection(context.selection);
138
+ if (context.signal?.aborted) throw cancelledSpeech(context.signal);
139
+ }
140
+
141
+ function nativeVoices() {
142
+ const synthesis = globalThis.speechSynthesis;
143
+ return is.function(synthesis?.getVoices) ? Array.from(synthesis.getVoices()) : [];
144
+ }
145
+
146
+ function catalog() {
147
+ return [{
148
+ id: modelId,
149
+ ...(modelName === undefined ? {} : {name: modelName}),
150
+ providerId: id,
151
+ role: 'tts',
152
+ localOnly: false,
153
+ defaultVoice,
154
+ voices: nativeVoices().map(function describeNativeVoice(voice) {
155
+ return {
156
+ id: voice.voiceURI,
157
+ name: voice.name,
158
+ lang: voice.lang,
159
+ default: voice.default,
160
+ localService: voice.localService
161
+ };
162
+ }),
163
+ speech: {playback: 'native'}
164
+ }];
165
+ }
166
+
167
+ function publishCatalog() {
168
+ const current = catalog();
169
+ for (const observer of catalogObservers) observeCallback(observer, current);
170
+ }
171
+
172
+ function detachCatalog() {
173
+ catalogSource?.removeEventListener('voiceschanged', publishCatalog);
174
+ catalogSource = null;
175
+ }
176
+
177
+ function subscribeCatalog(callback) {
178
+ assertOpen();
179
+ if (!is.function(callback)) throw new TypeError('Speech catalog subscription requires a function.');
180
+ catalogObservers.add(callback);
181
+ const synthesis = globalThis.speechSynthesis;
182
+ if (!catalogSource && is.function(synthesis?.addEventListener)
183
+ && is.function(synthesis?.removeEventListener)) {
184
+ catalogSource = synthesis;
185
+ catalogSource.addEventListener('voiceschanged', publishCatalog);
186
+ }
187
+ observeCallback(callback, catalog());
188
+ let subscribed = true;
189
+ return function unsubscribeCatalog() {
190
+ if (!subscribed) return false;
191
+ subscribed = false;
192
+ catalogObservers.delete(callback);
193
+ if (!catalogObservers.size) detachCatalog();
194
+ return true;
195
+ };
196
+ }
197
+
198
+ function status() {
199
+ const loaded = state === 'ready';
200
+ return {
201
+ providerId: id,
202
+ role: 'tts',
203
+ modelId,
204
+ localOnly: false,
205
+ state,
206
+ loaded,
207
+ // Preparation is immediate and remains available while native playback is active.
208
+ busy: false,
209
+ execution: {
210
+ requestedDevice: 'browser-native',
211
+ selectedDevice: loaded ? 'browser-native' : null,
212
+ maxConcurrentRequests: 1,
213
+ activeRequestCount: 0
214
+ }
215
+ };
216
+ }
217
+
218
+ function prepare(payload = {}, {signal = null} = {}) {
219
+ assertOpen();
220
+ assertSignal(signal);
221
+ if (signal?.aborted) throw cancelledSpeech(signal);
222
+ if (!is.string(payload.input) || !payload.input.trim()) {
223
+ throw new TypeError('Browser speech synthesis requires nonempty input text.');
224
+ }
225
+ if (payload.model !== undefined && payload.model !== modelId) {
226
+ throw synthesisError('The requested speech model does not match this provider.', 'ARCANE_AI_MODEL_AUTHORITY_REQUIRED');
227
+ }
228
+ const requestedVoice = payload.voice ?? defaultVoice;
229
+ if (requestedVoice !== null && requestedVoice !== undefined && !is.string(requestedVoice)) {
230
+ throw new TypeError('Browser speech voice must be a voiceURI string.');
231
+ }
232
+ const requestedLanguage = payload.language;
233
+ if (requestedLanguage !== undefined && !is.string(requestedLanguage)) {
234
+ throw new TypeError('Browser speech language must be a string.');
235
+ }
236
+ const speed = payload.speed ?? 1;
237
+ if (!is.finite(speed) || speed <= 0) {
238
+ throw new RangeError('Browser speech speed must be a positive finite number.');
239
+ }
240
+ const capability = synthesisCapability();
241
+ if (!capability) throw unavailableSynthesis();
242
+ const voices = nativeVoices();
243
+ const defaultLanguage = requestedLanguage ?? language;
244
+ const voice = requestedVoice
245
+ ? voices.find(function findRequestedVoice(candidate) {
246
+ return candidate.voiceURI === requestedVoice
247
+ && (!requestedLanguage || candidate.lang === requestedLanguage);
248
+ })
249
+ : voices.find(function findDefaultVoice(candidate) {
250
+ return candidate.default && (!defaultLanguage || candidate.lang === defaultLanguage);
251
+ });
252
+ if (requestedVoice && !voice && voices.length) {
253
+ throw synthesisError('The selected browser speech voice is unavailable.', 'ARCANE_AI_SPEECH_VOICE_UNAVAILABLE');
254
+ }
255
+ const preparedGeneration = generation;
256
+ const input = payload.input;
257
+ const selectedLanguage = requestedLanguage ?? voice?.lang ?? (requestedVoice ? '' : language) ?? '';
258
+ const selectedVoice = requestedVoice || voice?.voiceURI || null;
259
+ const descriptor = {
260
+ kind: 'native-speech',
261
+ input,
262
+ voice: selectedVoice,
263
+ language: selectedLanguage,
264
+ speed,
265
+ play(options = {}) {
266
+ assertOpen();
267
+ if (preparedGeneration !== generation) {
268
+ throw synthesisError('The prepared speech selection changed.', 'ARCANE_AI_OPERATION_SUPERSEDED');
269
+ }
270
+ return startPlayback(descriptor, capability, preparedGeneration, signal, options);
271
+ }
272
+ };
273
+ return descriptor;
274
+ }
275
+
276
+ function startPlayback(descriptor, capability, preparedGeneration, preparationSignal, {signal = null, onState = null} = {}) {
277
+ assertSignal(signal);
278
+ if (onState !== null && !is.function(onState)) {
279
+ throw new TypeError('Speech playback onState must be a function.');
280
+ }
281
+ const {synthesis, Utterance} = capability;
282
+ const queue = queueFor(synthesis);
283
+ const signals = new Set([preparationSignal, signal].filter(function suppliedSignal(value) { return value !== null; }));
284
+ let playbackState = 'queued';
285
+ let playbackError = null;
286
+ let utterance = null;
287
+ let voice = null;
288
+ let waitingForVoices = false;
289
+ let pausedByPlayback = false;
290
+ let stopping = false;
291
+ let completionSettled = false;
292
+ let resolveFinished;
293
+ let rejectFinished;
294
+ let resolveReleased;
295
+ const finished = new Promise(function capturePlaybackCompletion(resolve, reject) {
296
+ resolveFinished = resolve;
297
+ rejectFinished = reject;
298
+ });
299
+ finished.catch(function observePlaybackFailure() {});
300
+ const released = new Promise(function capturePlaybackRelease(resolve) { resolveReleased = resolve; });
301
+ const record = {settled: false, failure: null, released, start, stop};
302
+
303
+ function publish(nextState, error = null) {
304
+ playbackState = nextState;
305
+ playbackError = error;
306
+ observeCallback(onState, {state: nextState, error});
307
+ }
308
+
309
+ function finish(completed, error = null) {
310
+ if (record.settled) return;
311
+ record.settled = true;
312
+ for (const requestSignal of signals) requestSignal.removeEventListener('abort', stop);
313
+ if (waitingForVoices) synthesis.removeEventListener('voiceschanged', admitPlayback);
314
+ waitingForVoices = false;
315
+ if (utterance) {
316
+ utterance.onstart = null;
317
+ utterance.onend = null;
318
+ utterance.onerror = null;
319
+ utterance.onpause = null;
320
+ utterance.onresume = null;
321
+ }
322
+ const pendingIndex = queue.pending.indexOf(record);
323
+ if (pendingIndex >= 0) queue.pending.splice(pendingIndex, 1);
324
+ if (queue.active === record) queue.active = null;
325
+ playbacks.delete(record);
326
+ const failure = error || record.failure;
327
+ record.failure = failure;
328
+ publish(failure ? 'error' : completed ? 'complete' : 'stopped', failure);
329
+ if (!completionSettled) {
330
+ completionSettled = true;
331
+ if (failure) rejectFinished(failure);
332
+ else resolveFinished(completed);
333
+ }
334
+ resolveReleased();
335
+ advanceQueue(queue);
336
+ }
337
+
338
+ function retainPlaybackFailure(error) {
339
+ record.failure = error;
340
+ publish('error', error);
341
+ if (!completionSettled) {
342
+ completionSettled = true;
343
+ rejectFinished(error);
344
+ }
345
+ }
346
+
347
+ function stop() {
348
+ if (record.settled || stopping) return false;
349
+ if (queue.active === record && utterance) {
350
+ stopping = true;
351
+ // Web Speech cancellation is global; the SDK submits only its active utterance.
352
+ const onend = utterance.onend;
353
+ const onerror = utterance.onerror;
354
+ utterance.onend = null;
355
+ utterance.onerror = null;
356
+ try {
357
+ synthesis.cancel();
358
+ } catch (error) {
359
+ utterance.onend = onend;
360
+ utterance.onerror = onerror;
361
+ retainPlaybackFailure(synthesisError('Browser speech could not be stopped.', 'ARCANE_AI_SPEECH_SYNTHESIS_FAILED', error));
362
+ stopping = false;
363
+ return false;
364
+ }
365
+ if (pausedByPlayback) {
366
+ pausedByPlayback = false;
367
+ try {
368
+ synthesis.resume();
369
+ } catch (error) {
370
+ finish(false, synthesisError('Browser speech could not resume after stopping.', 'ARCANE_AI_SPEECH_SYNTHESIS_FAILED', error));
371
+ stopping = false;
372
+ return false;
373
+ }
374
+ }
375
+ }
376
+ finish(false);
377
+ stopping = false;
378
+ return true;
379
+ }
380
+
381
+ function start() {
382
+ if (record.settled) return;
383
+ if (preparedGeneration !== generation || Array.from(signals).some(function signalAborted(requestSignal) { return requestSignal.aborted; })) {
384
+ finish(false);
385
+ return;
386
+ }
387
+ try {
388
+ utterance = new Utterance(descriptor.input);
389
+ utterance.voice = voice ?? null;
390
+ utterance.lang = descriptor.language || voice?.lang || '';
391
+ utterance.rate = descriptor.speed;
392
+ utterance.onstart = function speechStarted() { if (!record.settled) publish('playing'); };
393
+ utterance.onend = function speechEnded() { finish(true); };
394
+ utterance.onerror = function speechFailed(event) {
395
+ if (record.settled) return;
396
+ const error = synthesisError('Browser speech playback failed.', 'ARCANE_AI_SPEECH_SYNTHESIS_FAILED', event);
397
+ error.reason = event.error;
398
+ finish(false, error);
399
+ };
400
+ utterance.onpause = function speechPaused() { if (!record.settled) publish('paused'); };
401
+ utterance.onresume = function speechResumed() { if (!record.settled) publish('playing'); };
402
+ synthesis.speak(utterance);
403
+ } catch (error) {
404
+ finish(false, synthesisError('Browser speech playback could not start.', 'ARCANE_AI_SPEECH_SYNTHESIS_FAILED', error));
405
+ }
406
+ }
407
+
408
+ function admitPlayback() {
409
+ if (record.settled) return;
410
+ try {
411
+ const voices = nativeVoices();
412
+ voice = descriptor.voice
413
+ ? voices.find(function findPlaybackVoice(candidate) {
414
+ return candidate.voiceURI === descriptor.voice
415
+ && (!descriptor.language || candidate.lang === descriptor.language);
416
+ })
417
+ : voices.find(function findPlaybackDefault(candidate) {
418
+ return candidate.default && (!descriptor.language || candidate.lang === descriptor.language);
419
+ });
420
+ if (descriptor.voice && !voice) {
421
+ if (voices.length) {
422
+ throw synthesisError('The selected browser speech voice is unavailable.', 'ARCANE_AI_SPEECH_VOICE_UNAVAILABLE');
423
+ }
424
+ if (!waitingForVoices) {
425
+ waitingForVoices = true;
426
+ synthesis.addEventListener('voiceschanged', admitPlayback);
427
+ publish('waiting-for-voices');
428
+ }
429
+ return;
430
+ }
431
+ if (waitingForVoices) synthesis.removeEventListener('voiceschanged', admitPlayback);
432
+ waitingForVoices = false;
433
+ queue.pending.push(record);
434
+ advanceQueue(queue);
435
+ } catch (error) {
436
+ finish(false, error);
437
+ }
438
+ }
439
+
440
+ const control = {
441
+ finished,
442
+ released,
443
+ get state() { return playbackState; },
444
+ get error() { return playbackError; },
445
+ pause() {
446
+ if (record.settled || queue.active !== record || !utterance) return false;
447
+ try {
448
+ pausedByPlayback = true;
449
+ synthesis.pause();
450
+ return true;
451
+ } catch (error) {
452
+ pausedByPlayback = false;
453
+ retainPlaybackFailure(synthesisError('Browser speech could not be paused.', 'ARCANE_AI_SPEECH_SYNTHESIS_FAILED', error));
454
+ return false;
455
+ }
456
+ },
457
+ resume() {
458
+ if (record.settled || queue.active !== record || !utterance) return false;
459
+ const wasPaused = pausedByPlayback;
460
+ try {
461
+ pausedByPlayback = false;
462
+ synthesis.resume();
463
+ return true;
464
+ } catch (error) {
465
+ pausedByPlayback = wasPaused;
466
+ retainPlaybackFailure(synthesisError('Browser speech could not resume.', 'ARCANE_AI_SPEECH_SYNTHESIS_FAILED', error));
467
+ return false;
468
+ }
469
+ },
470
+ stop
471
+ };
472
+ playbacks.add(record);
473
+ for (const requestSignal of signals) requestSignal.addEventListener('abort', stop, {once: true});
474
+ publish('queued');
475
+ if (Array.from(signals).some(function signalAborted(requestSignal) { return requestSignal.aborted; })) stop();
476
+ if (!record.settled) admitPlayback();
477
+ return control;
478
+ }
479
+
480
+ const provider = {
481
+ protocol: 'arcane-ai-provider/2',
482
+ role: 'tts',
483
+ id,
484
+ localOnly: false,
485
+ maxConcurrentRequests: 1,
486
+ catalog,
487
+ subscribeCatalog,
488
+ status,
489
+ prepare,
490
+ play(payload, options = {}) {
491
+ return prepare(payload).play(options);
492
+ },
493
+ async inspect(selection, context = {}) {
494
+ assertContext(context);
495
+ assertSelection(selection);
496
+ if (state === 'disposed' || disposeOperation) {
497
+ return {available: false, code: 'ARCANE_AI_PROVIDER_DISPOSED', message: 'The browser speech provider is disposed.'};
498
+ }
499
+ if (!synthesisCapability()) {
500
+ const error = unavailableSynthesis();
501
+ return {available: false, code: error.code, message: error.message};
502
+ }
503
+ return {available: true, authority: {
504
+ protocol: 'arcane-ai-model-authority/1',
505
+ providerId: id,
506
+ modelId,
507
+ role: 'tts',
508
+ localOnly: false
509
+ }};
510
+ },
511
+ async load(context = {}) {
512
+ assertContext(context);
513
+ assertOpen();
514
+ if (!synthesisCapability()) {
515
+ state = 'error';
516
+ throw unavailableSynthesis();
517
+ }
518
+ state = 'ready';
519
+ return status();
520
+ },
521
+ async request(context = {}) {
522
+ assertContext(context);
523
+ assertOpen();
524
+ if (state !== 'ready') {
525
+ throw synthesisError('The browser speech provider is not loaded.', 'ARCANE_AI_ROLE_NOT_READY');
526
+ }
527
+ if (context.operation !== 'synthesize') {
528
+ throw synthesisError('Browser speech synthesis requires the synthesize operation.', 'ARCANE_AI_INVALID_REQUEST');
529
+ }
530
+ return prepare(context.payload, {signal: context.signal ?? null});
531
+ },
532
+ unload(context = {}) {
533
+ assertContext(context);
534
+ if (state === 'disposed') return Promise.resolve(status());
535
+ if (unloadOperation) return unloadOperation;
536
+ state = 'unloading';
537
+ generation += 1;
538
+ const owned = Array.from(playbacks);
539
+ unloadOperation = Promise.all(owned.map(function waitForOwnedPlayback(playback) { return playback.released; })).then(function finishSynthesisUnload() {
540
+ const failures = owned.map(function playbackFailure(playback) { return playback.failure; }).filter(Boolean);
541
+ state = failures.length ? 'error' : 'unloaded';
542
+ unloadOperation = null;
543
+ if (failures.length === 1) throw failures[0];
544
+ if (failures.length > 1) throw new AggregateError(failures, 'Browser speech cleanup failed.');
545
+ return status();
546
+ });
547
+ for (const playback of owned) playback.stop();
548
+ return unloadOperation;
549
+ },
550
+ dispose(context = {}) {
551
+ assertContext(context);
552
+ if (state === 'disposed') return Promise.resolve(status());
553
+ if (disposeOperation) return disposeOperation;
554
+ detachCatalog();
555
+ catalogObservers.clear();
556
+ disposeOperation = provider.unload(context).then(function finishSynthesisDispose() {
557
+ state = 'disposed';
558
+ disposeOperation = null;
559
+ return status();
560
+ }, function failSynthesisDispose(error) {
561
+ disposeOperation = null;
562
+ throw error;
563
+ });
564
+ return disposeOperation;
565
+ }
566
+ };
567
+ return provider;
568
+ }
@@ -11,6 +11,7 @@ export {
11
11
  createBrowserWhisperProvider,
12
12
  } from "./browser-speech-providers.mjs";
13
13
  export {createBrowserSpeechRecognitionProvider} from './browser-speech-recognition.mjs';
14
+ export {createBrowserSpeechSynthesisProvider} from './browser-speech-synthesis.mjs';
14
15
  export {
15
16
  createTwinCloudTTSProvider,
16
17
  createTwinCloudTTSProvider as createDigitalOceanFalTTSProvider,