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 +37 -0
- package/README.md +1 -1
- package/browser-runtime/ai/browser-speech-artifacts.mjs +4 -0
- package/browser-runtime/ai/browser-speech-providers.mjs +1 -1
- package/browser-runtime/ai/browser-speech-synthesis.mjs +568 -0
- package/browser-runtime/ai/browser-speech.mjs +1 -0
- package/browser-runtime/ai/twin-cloud-speech.mjs +148 -55
- package/docs/reference/ai/browser-speech.md +97 -12
- package/docs/reference/runtime-modules.md +34 -5
- package/package.json +1 -1
- package/runtime/arcane/components/speech.html +11 -4
- package/runtime/arcane/modules/AI.js +179 -17
- package/runtime/arcane/modules/SpeechPlayback.js +242 -36
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.
|
|
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,
|