arcane-os 0.49.0 → 0.51.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 +41 -0
- package/README.md +1 -1
- package/browser-runtime/ai/browser-speech-synthesis.mjs +587 -0
- package/browser-runtime/ai/browser-speech.mjs +1 -0
- package/browser-runtime/ai/twin-cloud.mjs +67 -0
- package/docs/reference/ai/browser-speech.md +92 -1
- package/docs/reference/ai/twin-cloud.md +63 -2
- package/docs/reference/availability-and-normalization.md +1 -0
- package/docs/reference/inventory/package-api.json +15 -1
- package/docs/reference/runtime-components.md +13 -2
- package/docs/reference/runtime-modules.md +34 -5
- package/docs/reference/sdk-api.md +54 -0
- package/package.json +1 -1
- package/runtime/arcane/components/speech.html +223 -26
- package/runtime/arcane/modules/AI.js +179 -17
- package/runtime/arcane/modules/SpeechPlayback.js +242 -36
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.51.0
|
|
4
|
+
|
|
5
|
+
- Add synchronous native speech `provider.setDefaultVoice(voiceURI|null)`.
|
|
6
|
+
Update the live catalog and future preparations without replacing the
|
|
7
|
+
provider or altering explicit voices and prepared, queued or active speech.
|
|
8
|
+
Catalog subscriptions can apply an application-owned choice after voice
|
|
9
|
+
discovery; repeated values are silent and reentrant changes avoid stale
|
|
10
|
+
catalog delivery. Voice preference policy remains application-owned.
|
|
11
|
+
- Add `fetchSystemOneRequest` through `arcane-os/ai/twin-cloud` for explicit
|
|
12
|
+
model, complete caller-owned state and questions, and the complete native
|
|
13
|
+
System One response. Reuse the existing JSON transport, retry observers,
|
|
14
|
+
diagnostics and cancellation. Applications retain question definitions,
|
|
15
|
+
scoring policy, model selection and answer interpretation; no history,
|
|
16
|
+
fabricated tool calls, prompt conversion or new dependency is introduced.
|
|
17
|
+
- Keep all native recognition-language suggestions visible when the editable
|
|
18
|
+
picker opens or reopens. Filter only after text changes, show native language
|
|
19
|
+
and region labels, preserve exact custom BCP47 tags, and keep keyboard and
|
|
20
|
+
focus interaction in the shared speech control. The picker is available in
|
|
21
|
+
live-transcription mode before capture and disabled during capture or outside
|
|
22
|
+
that mode. Conversation language, Chat controls and capture payloads remain
|
|
23
|
+
unchanged.
|
|
24
|
+
|
|
25
|
+
## 0.50.0
|
|
26
|
+
|
|
27
|
+
- Add `createBrowserSpeechSynthesisProvider` through `arcane-os/ai/browser-speech`.
|
|
28
|
+
Expose actual browser voices and language metadata through the ordinary model
|
|
29
|
+
catalog and replaying `voiceschanged` subscription. Construction, catalog
|
|
30
|
+
discovery and preparation remain silent; explicit playback owns native
|
|
31
|
+
completion, pause, resume, stop and resource release. Applications select
|
|
32
|
+
defaults and personalize previews. Browser voices may use remote services.
|
|
33
|
+
- Add silent `AI.prepareTTSPlayback` for native descriptors or real Blob audio.
|
|
34
|
+
Shared AI streaming and SpeechPlayback preserve original order, mixed-audio
|
|
35
|
+
pauses, cancellation and ownership through actual native release. Native
|
|
36
|
+
descriptors never become fake audio files; `fetchTTS` and durable `prepareTTS`
|
|
37
|
+
remain real-audio APIs and report native export unavailability explicitly.
|
|
38
|
+
Existing saved audio, provider selections and defaults stay supported.
|
|
39
|
+
- Preserve the original STT capture operation ID when cancellation follows a
|
|
40
|
+
recognition error, so the existing Chat composer can retire its unedited
|
|
41
|
+
interim range while retaining confirmed text and user edits. No app-local
|
|
42
|
+
repair, saved-data migration, new dependency or automatic provider switch.
|
|
43
|
+
|
|
3
44
|
## 0.49.0
|
|
4
45
|
|
|
5
46
|
- Queue complete TWiN Cloud/FAL synthesis jobs through credential readiness,
|
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.
|
|
22
|
+
This checkout defines the `0.51.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
|
|
|
@@ -0,0 +1,587 @@
|
|
|
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
|
+
let defaultVoice = model.defaultVoice || null;
|
|
105
|
+
const playbacks = new Set();
|
|
106
|
+
const catalogObservers = new Set();
|
|
107
|
+
let catalogSource = null;
|
|
108
|
+
let catalogRevision = 0;
|
|
109
|
+
let state = 'unloaded';
|
|
110
|
+
let generation = 0;
|
|
111
|
+
let unloadOperation = null;
|
|
112
|
+
let disposeOperation = null;
|
|
113
|
+
|
|
114
|
+
function assertOpen() {
|
|
115
|
+
if (state === 'disposed' || disposeOperation) {
|
|
116
|
+
throw synthesisError('The browser speech provider is disposed.', 'ARCANE_AI_PROVIDER_DISPOSED');
|
|
117
|
+
}
|
|
118
|
+
if (state === 'unloading' || unloadOperation) {
|
|
119
|
+
throw synthesisError('The browser speech provider is unloading.', 'ARCANE_AI_OPERATION_SUPERSEDED');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function assertSelection(selection) {
|
|
124
|
+
if (selection && (selection.providerId !== id || selection.modelId !== modelId
|
|
125
|
+
|| selection.localOnly === true
|
|
126
|
+
|| (selection.role !== undefined && selection.role !== 'tts'))) {
|
|
127
|
+
throw synthesisError(
|
|
128
|
+
'The selected browser speech model does not match this provider.',
|
|
129
|
+
'ARCANE_AI_MODEL_AUTHORITY_REQUIRED'
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function assertContext(context) {
|
|
135
|
+
if (context.role !== undefined && context.role !== 'tts') {
|
|
136
|
+
throw synthesisError('This provider supports only TTS.', 'ARCANE_AI_INVALID_REQUEST');
|
|
137
|
+
}
|
|
138
|
+
assertSelection(context.selection);
|
|
139
|
+
if (context.signal?.aborted) throw cancelledSpeech(context.signal);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function nativeVoices() {
|
|
143
|
+
const synthesis = globalThis.speechSynthesis;
|
|
144
|
+
return is.function(synthesis?.getVoices) ? Array.from(synthesis.getVoices()) : [];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function catalog() {
|
|
148
|
+
return [{
|
|
149
|
+
id: modelId,
|
|
150
|
+
...(modelName === undefined ? {} : {name: modelName}),
|
|
151
|
+
providerId: id,
|
|
152
|
+
role: 'tts',
|
|
153
|
+
localOnly: false,
|
|
154
|
+
defaultVoice,
|
|
155
|
+
voices: nativeVoices().map(function describeNativeVoice(voice) {
|
|
156
|
+
return {
|
|
157
|
+
id: voice.voiceURI,
|
|
158
|
+
name: voice.name,
|
|
159
|
+
lang: voice.lang,
|
|
160
|
+
default: voice.default,
|
|
161
|
+
localService: voice.localService
|
|
162
|
+
};
|
|
163
|
+
}),
|
|
164
|
+
speech: {playback: 'native'}
|
|
165
|
+
}];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function publishCatalog() {
|
|
169
|
+
const revision = ++catalogRevision;
|
|
170
|
+
const current = catalog();
|
|
171
|
+
for (const observer of catalogObservers) {
|
|
172
|
+
// A catalog observer can select a new default and publish its replacement.
|
|
173
|
+
if (revision !== catalogRevision) return;
|
|
174
|
+
observeCallback(observer, current);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function setDefaultVoice(voice = null) {
|
|
179
|
+
assertOpen();
|
|
180
|
+
if (voice !== null && !is.string(voice)) {
|
|
181
|
+
throw new TypeError('Browser speech synthesis default voice must be a string.');
|
|
182
|
+
}
|
|
183
|
+
const nextVoice = voice || null;
|
|
184
|
+
if (defaultVoice === nextVoice) return defaultVoice;
|
|
185
|
+
defaultVoice = nextVoice;
|
|
186
|
+
publishCatalog();
|
|
187
|
+
return defaultVoice;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function detachCatalog() {
|
|
191
|
+
catalogSource?.removeEventListener('voiceschanged', publishCatalog);
|
|
192
|
+
catalogSource = null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function subscribeCatalog(callback) {
|
|
196
|
+
assertOpen();
|
|
197
|
+
if (!is.function(callback)) throw new TypeError('Speech catalog subscription requires a function.');
|
|
198
|
+
catalogObservers.add(callback);
|
|
199
|
+
const synthesis = globalThis.speechSynthesis;
|
|
200
|
+
if (!catalogSource && is.function(synthesis?.addEventListener)
|
|
201
|
+
&& is.function(synthesis?.removeEventListener)) {
|
|
202
|
+
catalogSource = synthesis;
|
|
203
|
+
catalogSource.addEventListener('voiceschanged', publishCatalog);
|
|
204
|
+
}
|
|
205
|
+
observeCallback(callback, catalog());
|
|
206
|
+
let subscribed = true;
|
|
207
|
+
return function unsubscribeCatalog() {
|
|
208
|
+
if (!subscribed) return false;
|
|
209
|
+
subscribed = false;
|
|
210
|
+
catalogObservers.delete(callback);
|
|
211
|
+
if (!catalogObservers.size) detachCatalog();
|
|
212
|
+
return true;
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function status() {
|
|
217
|
+
const loaded = state === 'ready';
|
|
218
|
+
return {
|
|
219
|
+
providerId: id,
|
|
220
|
+
role: 'tts',
|
|
221
|
+
modelId,
|
|
222
|
+
localOnly: false,
|
|
223
|
+
state,
|
|
224
|
+
loaded,
|
|
225
|
+
// Preparation is immediate and remains available while native playback is active.
|
|
226
|
+
busy: false,
|
|
227
|
+
execution: {
|
|
228
|
+
requestedDevice: 'browser-native',
|
|
229
|
+
selectedDevice: loaded ? 'browser-native' : null,
|
|
230
|
+
maxConcurrentRequests: 1,
|
|
231
|
+
activeRequestCount: 0
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function prepare(payload = {}, {signal = null} = {}) {
|
|
237
|
+
assertOpen();
|
|
238
|
+
assertSignal(signal);
|
|
239
|
+
if (signal?.aborted) throw cancelledSpeech(signal);
|
|
240
|
+
if (!is.string(payload.input) || !payload.input.trim()) {
|
|
241
|
+
throw new TypeError('Browser speech synthesis requires nonempty input text.');
|
|
242
|
+
}
|
|
243
|
+
if (payload.model !== undefined && payload.model !== modelId) {
|
|
244
|
+
throw synthesisError('The requested speech model does not match this provider.', 'ARCANE_AI_MODEL_AUTHORITY_REQUIRED');
|
|
245
|
+
}
|
|
246
|
+
const requestedVoice = payload.voice ?? defaultVoice;
|
|
247
|
+
if (requestedVoice !== null && requestedVoice !== undefined && !is.string(requestedVoice)) {
|
|
248
|
+
throw new TypeError('Browser speech voice must be a voiceURI string.');
|
|
249
|
+
}
|
|
250
|
+
const requestedLanguage = payload.language;
|
|
251
|
+
if (requestedLanguage !== undefined && !is.string(requestedLanguage)) {
|
|
252
|
+
throw new TypeError('Browser speech language must be a string.');
|
|
253
|
+
}
|
|
254
|
+
const speed = payload.speed ?? 1;
|
|
255
|
+
if (!is.finite(speed) || speed <= 0) {
|
|
256
|
+
throw new RangeError('Browser speech speed must be a positive finite number.');
|
|
257
|
+
}
|
|
258
|
+
const capability = synthesisCapability();
|
|
259
|
+
if (!capability) throw unavailableSynthesis();
|
|
260
|
+
const voices = nativeVoices();
|
|
261
|
+
const defaultLanguage = requestedLanguage ?? language;
|
|
262
|
+
const voice = requestedVoice
|
|
263
|
+
? voices.find(function findRequestedVoice(candidate) {
|
|
264
|
+
return candidate.voiceURI === requestedVoice
|
|
265
|
+
&& (!requestedLanguage || candidate.lang === requestedLanguage);
|
|
266
|
+
})
|
|
267
|
+
: voices.find(function findDefaultVoice(candidate) {
|
|
268
|
+
return candidate.default && (!defaultLanguage || candidate.lang === defaultLanguage);
|
|
269
|
+
});
|
|
270
|
+
if (requestedVoice && !voice && voices.length) {
|
|
271
|
+
throw synthesisError('The selected browser speech voice is unavailable.', 'ARCANE_AI_SPEECH_VOICE_UNAVAILABLE');
|
|
272
|
+
}
|
|
273
|
+
const preparedGeneration = generation;
|
|
274
|
+
const input = payload.input;
|
|
275
|
+
const selectedLanguage = requestedLanguage ?? voice?.lang ?? (requestedVoice ? '' : language) ?? '';
|
|
276
|
+
const selectedVoice = requestedVoice || voice?.voiceURI || null;
|
|
277
|
+
const descriptor = {
|
|
278
|
+
kind: 'native-speech',
|
|
279
|
+
input,
|
|
280
|
+
voice: selectedVoice,
|
|
281
|
+
language: selectedLanguage,
|
|
282
|
+
speed,
|
|
283
|
+
play(options = {}) {
|
|
284
|
+
assertOpen();
|
|
285
|
+
if (preparedGeneration !== generation) {
|
|
286
|
+
throw synthesisError('The prepared speech selection changed.', 'ARCANE_AI_OPERATION_SUPERSEDED');
|
|
287
|
+
}
|
|
288
|
+
return startPlayback(descriptor, capability, preparedGeneration, signal, options);
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
return descriptor;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function startPlayback(descriptor, capability, preparedGeneration, preparationSignal, {signal = null, onState = null} = {}) {
|
|
295
|
+
assertSignal(signal);
|
|
296
|
+
if (onState !== null && !is.function(onState)) {
|
|
297
|
+
throw new TypeError('Speech playback onState must be a function.');
|
|
298
|
+
}
|
|
299
|
+
const {synthesis, Utterance} = capability;
|
|
300
|
+
const queue = queueFor(synthesis);
|
|
301
|
+
const signals = new Set([preparationSignal, signal].filter(function suppliedSignal(value) { return value !== null; }));
|
|
302
|
+
let playbackState = 'queued';
|
|
303
|
+
let playbackError = null;
|
|
304
|
+
let utterance = null;
|
|
305
|
+
let voice = null;
|
|
306
|
+
let waitingForVoices = false;
|
|
307
|
+
let pausedByPlayback = false;
|
|
308
|
+
let stopping = false;
|
|
309
|
+
let completionSettled = false;
|
|
310
|
+
let resolveFinished;
|
|
311
|
+
let rejectFinished;
|
|
312
|
+
let resolveReleased;
|
|
313
|
+
const finished = new Promise(function capturePlaybackCompletion(resolve, reject) {
|
|
314
|
+
resolveFinished = resolve;
|
|
315
|
+
rejectFinished = reject;
|
|
316
|
+
});
|
|
317
|
+
finished.catch(function observePlaybackFailure() {});
|
|
318
|
+
const released = new Promise(function capturePlaybackRelease(resolve) { resolveReleased = resolve; });
|
|
319
|
+
const record = {settled: false, failure: null, released, start, stop};
|
|
320
|
+
|
|
321
|
+
function publish(nextState, error = null) {
|
|
322
|
+
playbackState = nextState;
|
|
323
|
+
playbackError = error;
|
|
324
|
+
observeCallback(onState, {state: nextState, error});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function finish(completed, error = null) {
|
|
328
|
+
if (record.settled) return;
|
|
329
|
+
record.settled = true;
|
|
330
|
+
for (const requestSignal of signals) requestSignal.removeEventListener('abort', stop);
|
|
331
|
+
if (waitingForVoices) synthesis.removeEventListener('voiceschanged', admitPlayback);
|
|
332
|
+
waitingForVoices = false;
|
|
333
|
+
if (utterance) {
|
|
334
|
+
utterance.onstart = null;
|
|
335
|
+
utterance.onend = null;
|
|
336
|
+
utterance.onerror = null;
|
|
337
|
+
utterance.onpause = null;
|
|
338
|
+
utterance.onresume = null;
|
|
339
|
+
}
|
|
340
|
+
const pendingIndex = queue.pending.indexOf(record);
|
|
341
|
+
if (pendingIndex >= 0) queue.pending.splice(pendingIndex, 1);
|
|
342
|
+
if (queue.active === record) queue.active = null;
|
|
343
|
+
playbacks.delete(record);
|
|
344
|
+
const failure = error || record.failure;
|
|
345
|
+
record.failure = failure;
|
|
346
|
+
publish(failure ? 'error' : completed ? 'complete' : 'stopped', failure);
|
|
347
|
+
if (!completionSettled) {
|
|
348
|
+
completionSettled = true;
|
|
349
|
+
if (failure) rejectFinished(failure);
|
|
350
|
+
else resolveFinished(completed);
|
|
351
|
+
}
|
|
352
|
+
resolveReleased();
|
|
353
|
+
advanceQueue(queue);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function retainPlaybackFailure(error) {
|
|
357
|
+
record.failure = error;
|
|
358
|
+
publish('error', error);
|
|
359
|
+
if (!completionSettled) {
|
|
360
|
+
completionSettled = true;
|
|
361
|
+
rejectFinished(error);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function stop() {
|
|
366
|
+
if (record.settled || stopping) return false;
|
|
367
|
+
if (queue.active === record && utterance) {
|
|
368
|
+
stopping = true;
|
|
369
|
+
// Web Speech cancellation is global; the SDK submits only its active utterance.
|
|
370
|
+
const onend = utterance.onend;
|
|
371
|
+
const onerror = utterance.onerror;
|
|
372
|
+
utterance.onend = null;
|
|
373
|
+
utterance.onerror = null;
|
|
374
|
+
try {
|
|
375
|
+
synthesis.cancel();
|
|
376
|
+
} catch (error) {
|
|
377
|
+
utterance.onend = onend;
|
|
378
|
+
utterance.onerror = onerror;
|
|
379
|
+
retainPlaybackFailure(synthesisError('Browser speech could not be stopped.', 'ARCANE_AI_SPEECH_SYNTHESIS_FAILED', error));
|
|
380
|
+
stopping = false;
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
if (pausedByPlayback) {
|
|
384
|
+
pausedByPlayback = false;
|
|
385
|
+
try {
|
|
386
|
+
synthesis.resume();
|
|
387
|
+
} catch (error) {
|
|
388
|
+
finish(false, synthesisError('Browser speech could not resume after stopping.', 'ARCANE_AI_SPEECH_SYNTHESIS_FAILED', error));
|
|
389
|
+
stopping = false;
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
finish(false);
|
|
395
|
+
stopping = false;
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function start() {
|
|
400
|
+
if (record.settled) return;
|
|
401
|
+
if (preparedGeneration !== generation || Array.from(signals).some(function signalAborted(requestSignal) { return requestSignal.aborted; })) {
|
|
402
|
+
finish(false);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
utterance = new Utterance(descriptor.input);
|
|
407
|
+
utterance.voice = voice ?? null;
|
|
408
|
+
utterance.lang = descriptor.language || voice?.lang || '';
|
|
409
|
+
utterance.rate = descriptor.speed;
|
|
410
|
+
utterance.onstart = function speechStarted() { if (!record.settled) publish('playing'); };
|
|
411
|
+
utterance.onend = function speechEnded() { finish(true); };
|
|
412
|
+
utterance.onerror = function speechFailed(event) {
|
|
413
|
+
if (record.settled) return;
|
|
414
|
+
const error = synthesisError('Browser speech playback failed.', 'ARCANE_AI_SPEECH_SYNTHESIS_FAILED', event);
|
|
415
|
+
error.reason = event.error;
|
|
416
|
+
finish(false, error);
|
|
417
|
+
};
|
|
418
|
+
utterance.onpause = function speechPaused() { if (!record.settled) publish('paused'); };
|
|
419
|
+
utterance.onresume = function speechResumed() { if (!record.settled) publish('playing'); };
|
|
420
|
+
synthesis.speak(utterance);
|
|
421
|
+
} catch (error) {
|
|
422
|
+
finish(false, synthesisError('Browser speech playback could not start.', 'ARCANE_AI_SPEECH_SYNTHESIS_FAILED', error));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function admitPlayback() {
|
|
427
|
+
if (record.settled) return;
|
|
428
|
+
try {
|
|
429
|
+
const voices = nativeVoices();
|
|
430
|
+
voice = descriptor.voice
|
|
431
|
+
? voices.find(function findPlaybackVoice(candidate) {
|
|
432
|
+
return candidate.voiceURI === descriptor.voice
|
|
433
|
+
&& (!descriptor.language || candidate.lang === descriptor.language);
|
|
434
|
+
})
|
|
435
|
+
: voices.find(function findPlaybackDefault(candidate) {
|
|
436
|
+
return candidate.default && (!descriptor.language || candidate.lang === descriptor.language);
|
|
437
|
+
});
|
|
438
|
+
if (descriptor.voice && !voice) {
|
|
439
|
+
if (voices.length) {
|
|
440
|
+
throw synthesisError('The selected browser speech voice is unavailable.', 'ARCANE_AI_SPEECH_VOICE_UNAVAILABLE');
|
|
441
|
+
}
|
|
442
|
+
if (!waitingForVoices) {
|
|
443
|
+
waitingForVoices = true;
|
|
444
|
+
synthesis.addEventListener('voiceschanged', admitPlayback);
|
|
445
|
+
publish('waiting-for-voices');
|
|
446
|
+
}
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (waitingForVoices) synthesis.removeEventListener('voiceschanged', admitPlayback);
|
|
450
|
+
waitingForVoices = false;
|
|
451
|
+
queue.pending.push(record);
|
|
452
|
+
advanceQueue(queue);
|
|
453
|
+
} catch (error) {
|
|
454
|
+
finish(false, error);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const control = {
|
|
459
|
+
finished,
|
|
460
|
+
released,
|
|
461
|
+
get state() { return playbackState; },
|
|
462
|
+
get error() { return playbackError; },
|
|
463
|
+
pause() {
|
|
464
|
+
if (record.settled || queue.active !== record || !utterance) return false;
|
|
465
|
+
try {
|
|
466
|
+
pausedByPlayback = true;
|
|
467
|
+
synthesis.pause();
|
|
468
|
+
return true;
|
|
469
|
+
} catch (error) {
|
|
470
|
+
pausedByPlayback = false;
|
|
471
|
+
retainPlaybackFailure(synthesisError('Browser speech could not be paused.', 'ARCANE_AI_SPEECH_SYNTHESIS_FAILED', error));
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
},
|
|
475
|
+
resume() {
|
|
476
|
+
if (record.settled || queue.active !== record || !utterance) return false;
|
|
477
|
+
const wasPaused = pausedByPlayback;
|
|
478
|
+
try {
|
|
479
|
+
pausedByPlayback = false;
|
|
480
|
+
synthesis.resume();
|
|
481
|
+
return true;
|
|
482
|
+
} catch (error) {
|
|
483
|
+
pausedByPlayback = wasPaused;
|
|
484
|
+
retainPlaybackFailure(synthesisError('Browser speech could not resume.', 'ARCANE_AI_SPEECH_SYNTHESIS_FAILED', error));
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
},
|
|
488
|
+
stop
|
|
489
|
+
};
|
|
490
|
+
playbacks.add(record);
|
|
491
|
+
for (const requestSignal of signals) requestSignal.addEventListener('abort', stop, {once: true});
|
|
492
|
+
publish('queued');
|
|
493
|
+
if (Array.from(signals).some(function signalAborted(requestSignal) { return requestSignal.aborted; })) stop();
|
|
494
|
+
if (!record.settled) admitPlayback();
|
|
495
|
+
return control;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const provider = {
|
|
499
|
+
protocol: 'arcane-ai-provider/2',
|
|
500
|
+
role: 'tts',
|
|
501
|
+
id,
|
|
502
|
+
localOnly: false,
|
|
503
|
+
maxConcurrentRequests: 1,
|
|
504
|
+
catalog,
|
|
505
|
+
subscribeCatalog,
|
|
506
|
+
setDefaultVoice,
|
|
507
|
+
status,
|
|
508
|
+
prepare,
|
|
509
|
+
play(payload, options = {}) {
|
|
510
|
+
return prepare(payload).play(options);
|
|
511
|
+
},
|
|
512
|
+
async inspect(selection, context = {}) {
|
|
513
|
+
assertContext(context);
|
|
514
|
+
assertSelection(selection);
|
|
515
|
+
if (state === 'disposed' || disposeOperation) {
|
|
516
|
+
return {available: false, code: 'ARCANE_AI_PROVIDER_DISPOSED', message: 'The browser speech provider is disposed.'};
|
|
517
|
+
}
|
|
518
|
+
if (!synthesisCapability()) {
|
|
519
|
+
const error = unavailableSynthesis();
|
|
520
|
+
return {available: false, code: error.code, message: error.message};
|
|
521
|
+
}
|
|
522
|
+
return {available: true, authority: {
|
|
523
|
+
protocol: 'arcane-ai-model-authority/1',
|
|
524
|
+
providerId: id,
|
|
525
|
+
modelId,
|
|
526
|
+
role: 'tts',
|
|
527
|
+
localOnly: false
|
|
528
|
+
}};
|
|
529
|
+
},
|
|
530
|
+
async load(context = {}) {
|
|
531
|
+
assertContext(context);
|
|
532
|
+
assertOpen();
|
|
533
|
+
if (!synthesisCapability()) {
|
|
534
|
+
state = 'error';
|
|
535
|
+
throw unavailableSynthesis();
|
|
536
|
+
}
|
|
537
|
+
state = 'ready';
|
|
538
|
+
return status();
|
|
539
|
+
},
|
|
540
|
+
async request(context = {}) {
|
|
541
|
+
assertContext(context);
|
|
542
|
+
assertOpen();
|
|
543
|
+
if (state !== 'ready') {
|
|
544
|
+
throw synthesisError('The browser speech provider is not loaded.', 'ARCANE_AI_ROLE_NOT_READY');
|
|
545
|
+
}
|
|
546
|
+
if (context.operation !== 'synthesize') {
|
|
547
|
+
throw synthesisError('Browser speech synthesis requires the synthesize operation.', 'ARCANE_AI_INVALID_REQUEST');
|
|
548
|
+
}
|
|
549
|
+
return prepare(context.payload, {signal: context.signal ?? null});
|
|
550
|
+
},
|
|
551
|
+
unload(context = {}) {
|
|
552
|
+
assertContext(context);
|
|
553
|
+
if (state === 'disposed') return Promise.resolve(status());
|
|
554
|
+
if (unloadOperation) return unloadOperation;
|
|
555
|
+
state = 'unloading';
|
|
556
|
+
generation += 1;
|
|
557
|
+
const owned = Array.from(playbacks);
|
|
558
|
+
unloadOperation = Promise.all(owned.map(function waitForOwnedPlayback(playback) { return playback.released; })).then(function finishSynthesisUnload() {
|
|
559
|
+
const failures = owned.map(function playbackFailure(playback) { return playback.failure; }).filter(Boolean);
|
|
560
|
+
state = failures.length ? 'error' : 'unloaded';
|
|
561
|
+
unloadOperation = null;
|
|
562
|
+
if (failures.length === 1) throw failures[0];
|
|
563
|
+
if (failures.length > 1) throw new AggregateError(failures, 'Browser speech cleanup failed.');
|
|
564
|
+
return status();
|
|
565
|
+
});
|
|
566
|
+
for (const playback of owned) playback.stop();
|
|
567
|
+
return unloadOperation;
|
|
568
|
+
},
|
|
569
|
+
dispose(context = {}) {
|
|
570
|
+
assertContext(context);
|
|
571
|
+
if (state === 'disposed') return Promise.resolve(status());
|
|
572
|
+
if (disposeOperation) return disposeOperation;
|
|
573
|
+
detachCatalog();
|
|
574
|
+
catalogObservers.clear();
|
|
575
|
+
disposeOperation = provider.unload(context).then(function finishSynthesisDispose() {
|
|
576
|
+
state = 'disposed';
|
|
577
|
+
disposeOperation = null;
|
|
578
|
+
return status();
|
|
579
|
+
}, function failSynthesisDispose(error) {
|
|
580
|
+
disposeOperation = null;
|
|
581
|
+
throw error;
|
|
582
|
+
});
|
|
583
|
+
return disposeOperation;
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
return provider;
|
|
587
|
+
}
|
|
@@ -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,
|