arcane-os 0.33.6 → 0.34.1
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 +33 -0
- package/README.md +2 -2
- package/browser-runtime/ai/browser-speech.mjs +1 -0
- package/browser-runtime/ai/digitalocean-speech.mjs +468 -0
- package/docs/reference/ai/browser-speech.md +86 -5
- package/docs/reference/ai/twin-cloud.md +32 -0
- package/docs/reference/core/arcane-entities.md +1 -1
- package/docs/reference/inventory/package-api.json +15 -1
- package/docs/reference/inventory/runtime-entities.json +1 -1
- package/docs/reference/runtime-entities.md +40 -0
- package/docs/reference/runtime-modules.md +54 -9
- package/docs/reference/sdk-api.md +66 -2
- package/package.json +2 -2
- package/runtime/arcane/components/chat.html +8 -6
- package/runtime/arcane/components/speech.html +40 -6
- package/runtime/arcane/entities/User.js +30 -7
- package/runtime/arcane/modules/AI.js +364 -54
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.34.1
|
|
4
|
+
|
|
5
|
+
- Honor an optional request-local `model` in `AI.fetchRequest()` and
|
|
6
|
+
`AI.streamRequest()`. Direct and registered built-in TWiN/native Ollama
|
|
7
|
+
requests carry the supplied model without changing the shared selection.
|
|
8
|
+
Calls that omit the option retain the selected default.
|
|
9
|
+
- Preserve complete messages, streaming, tools, cancellation and request
|
|
10
|
+
observers. Provider-reported stream model metadata remains authoritative;
|
|
11
|
+
generated stream completion metadata falls back to the request's model.
|
|
12
|
+
The option does not load or switch a browser-WASM model.
|
|
13
|
+
|
|
14
|
+
## 0.34.0
|
|
15
|
+
|
|
16
|
+
- Add `createDigitalOceanFalTTSProvider` through `arcane-os/ai/browser-speech`
|
|
17
|
+
for DigitalOcean's asynchronous FAL speech service. Applications select the
|
|
18
|
+
model and voice and supply a current credential getter, which may return a
|
|
19
|
+
promise. Configuration and activation make no provider request; synthesis
|
|
20
|
+
preserves complete input and fetches returned audio without forwarding the
|
|
21
|
+
inference credential. Status polling never retries a paid submission.
|
|
22
|
+
- Add `AI.configureSpeechProvider(role, provider, options)` for independent
|
|
23
|
+
STT or TTS replacement and removal, preserving the other selected role.
|
|
24
|
+
Credential waits, synthesis, and local polling follow cancellation and
|
|
25
|
+
provider teardown. Cancelling local work does not establish cancellation of
|
|
26
|
+
an already-submitted remote job.
|
|
27
|
+
- Preserve explicit unmute intent while the canonical AI owner, selected
|
|
28
|
+
provider registration, or speech configuration is still becoming ready.
|
|
29
|
+
Muting and teardown cancel that pending activation.
|
|
30
|
+
- Allow `user.updateExplicit(current => partialOrNull)` to make a conditional
|
|
31
|
+
profile update against fresh durable fields inside the existing serialized
|
|
32
|
+
write owner. Async callbacks are supported; `null` returns the fresh profile
|
|
33
|
+
without saving. Existing object and JSON updates remain supported. Timestamp
|
|
34
|
+
setters save only their changed fields so they preserve newer profile edits.
|
|
35
|
+
|
|
3
36
|
## 0.33.6
|
|
4
37
|
|
|
5
38
|
- Resume transcript bottom-follow when an accepted non-synthetic user message
|
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.34.1` 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
|
|
|
@@ -85,7 +85,7 @@ Create one browser application, install its pinned SDK, and start its source
|
|
|
85
85
|
server:
|
|
86
86
|
|
|
87
87
|
```bash
|
|
88
|
-
npx arcane-os@0.
|
|
88
|
+
npx arcane-os@0.34.1 new hello-speech --path ./hello-speech --target browser
|
|
89
89
|
cd hello-speech
|
|
90
90
|
npm install
|
|
91
91
|
```
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import Is from '../dependencies/strong-type/index.js';
|
|
2
|
+
|
|
3
|
+
const is = new Is(false);
|
|
4
|
+
const INVOKE_URL = 'https://inference.do-ai.run/v1/async-invoke';
|
|
5
|
+
const POLL_INTERVAL_MS = 1000;
|
|
6
|
+
|
|
7
|
+
function speechError(message, code, cause) {
|
|
8
|
+
const error = cause === undefined
|
|
9
|
+
? new Error(message)
|
|
10
|
+
: new Error(
|
|
11
|
+
message,
|
|
12
|
+
{cause}
|
|
13
|
+
);
|
|
14
|
+
error.code = code;
|
|
15
|
+
return error;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function abortedSpeech(signal) {
|
|
19
|
+
const error = speechError(
|
|
20
|
+
'The cloud speech request was cancelled.',
|
|
21
|
+
'ARCANE_AI_REQUEST_ABORTED',
|
|
22
|
+
signal?.reason
|
|
23
|
+
);
|
|
24
|
+
error.name = 'AbortError';
|
|
25
|
+
return error;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function assertNotAborted(signal) {
|
|
29
|
+
if (signal?.aborted) throw abortedSpeech(signal);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function assertIdentifier(value, label) {
|
|
33
|
+
if (!is.string(value) || !value || value.trim() !== value) {
|
|
34
|
+
throw new TypeError(`${label} must be a nonempty string without surrounding whitespace.`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function waitForStatus(delay, signal) {
|
|
39
|
+
assertNotAborted(signal);
|
|
40
|
+
return new Promise(
|
|
41
|
+
function waitForCloudSpeechStatus(resolve, reject) {
|
|
42
|
+
const timer = setTimeout(finishStatusWait, delay);
|
|
43
|
+
function finishStatusWait() {
|
|
44
|
+
signal.removeEventListener('abort', cancelStatusWait);
|
|
45
|
+
resolve();
|
|
46
|
+
}
|
|
47
|
+
function cancelStatusWait() {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
signal.removeEventListener('abort', cancelStatusWait);
|
|
50
|
+
reject(abortedSpeech(signal));
|
|
51
|
+
}
|
|
52
|
+
signal.addEventListener(
|
|
53
|
+
'abort',
|
|
54
|
+
cancelStatusWait,
|
|
55
|
+
{once: true}
|
|
56
|
+
);
|
|
57
|
+
if (signal.aborted) cancelStatusWait();
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function retryAfterDelay(response) {
|
|
63
|
+
const value = response.headers.get('Retry-After');
|
|
64
|
+
if (!value?.trim()) return null;
|
|
65
|
+
const seconds = Number(value);
|
|
66
|
+
if (is.finite(seconds) && seconds >= 0) return seconds * 1000;
|
|
67
|
+
const date = Date.parse(value);
|
|
68
|
+
return is.finite(date) ? Math.max(0, date - Date.now()) : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function requireSuccessfulResponse(response, signal, operation) {
|
|
72
|
+
assertNotAborted(signal);
|
|
73
|
+
if (response.ok) return response;
|
|
74
|
+
const detail = await response.text();
|
|
75
|
+
assertNotAborted(signal);
|
|
76
|
+
throw speechError(
|
|
77
|
+
`DigitalOcean speech ${operation} failed with HTTP ${response.status}${detail ? `: ${detail}` : '.'}`,
|
|
78
|
+
'ARCANE_AI_CLOUD_SPEECH_HTTP_ERROR'
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A remote provider/2 TTS adapter. The application selects the model and voice,
|
|
84
|
+
* and getApiKey returns its current credential or the application's existing
|
|
85
|
+
* refresh promise. Credentials never enter status or configuration. The runtime
|
|
86
|
+
* owns the FIFO queue; cancellation stops only this adapter's credential wait.
|
|
87
|
+
*/
|
|
88
|
+
export function createDigitalOceanFalTTSProvider({
|
|
89
|
+
id,
|
|
90
|
+
model,
|
|
91
|
+
getApiKey,
|
|
92
|
+
maxConcurrentRequests = 4,
|
|
93
|
+
fetch: fetchImpl = globalThis.fetch
|
|
94
|
+
} = {}) {
|
|
95
|
+
assertIdentifier(id, 'DigitalOcean speech provider id');
|
|
96
|
+
assertIdentifier(model?.id, 'DigitalOcean speech model id');
|
|
97
|
+
assertIdentifier(model?.defaultVoice, 'DigitalOcean speech default voice');
|
|
98
|
+
if (!is.function(getApiKey)) {
|
|
99
|
+
throw new TypeError('DigitalOcean speech getApiKey must be a function.');
|
|
100
|
+
}
|
|
101
|
+
if (!is.function(fetchImpl)) {
|
|
102
|
+
throw new TypeError('DigitalOcean speech requires Fetch.');
|
|
103
|
+
}
|
|
104
|
+
if (!is.safeInteger(maxConcurrentRequests) || maxConcurrentRequests < 1) {
|
|
105
|
+
throw new RangeError('DigitalOcean speech maxConcurrentRequests must be a positive safe integer.');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const modelId = model.id;
|
|
109
|
+
const defaultVoice = model.defaultVoice;
|
|
110
|
+
const activeRequests = new Set();
|
|
111
|
+
const credentialWaits = new Set();
|
|
112
|
+
let state = 'unloaded';
|
|
113
|
+
let generation = 0;
|
|
114
|
+
let unloadOperation = null;
|
|
115
|
+
let disposeOperation = null;
|
|
116
|
+
|
|
117
|
+
function authority() {
|
|
118
|
+
return {
|
|
119
|
+
protocol: 'arcane-ai-model-authority/1',
|
|
120
|
+
providerId: id,
|
|
121
|
+
modelId,
|
|
122
|
+
role: 'tts',
|
|
123
|
+
localOnly: false
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function readApiKey(signal) {
|
|
128
|
+
assertNotAborted(signal);
|
|
129
|
+
return new Promise(
|
|
130
|
+
function waitForCloudSpeechCredential(resolve, reject) {
|
|
131
|
+
let settled = false;
|
|
132
|
+
function finishCredentialWait() {
|
|
133
|
+
settled = true;
|
|
134
|
+
credentialWaits.delete(cancelCredentialWait);
|
|
135
|
+
signal?.removeEventListener('abort', cancelCredentialWait);
|
|
136
|
+
}
|
|
137
|
+
function rejectCredential(error) {
|
|
138
|
+
if (settled) return;
|
|
139
|
+
finishCredentialWait();
|
|
140
|
+
reject(error);
|
|
141
|
+
}
|
|
142
|
+
function acceptCredential(apiKey) {
|
|
143
|
+
if (settled) return;
|
|
144
|
+
if (!is.string(apiKey) || !apiKey.trim()) {
|
|
145
|
+
rejectCredential(
|
|
146
|
+
speechError(
|
|
147
|
+
'A DigitalOcean inference access key is required for cloud speech.',
|
|
148
|
+
'ARCANE_AI_CLOUD_SPEECH_KEY_REQUIRED'
|
|
149
|
+
)
|
|
150
|
+
);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
finishCredentialWait();
|
|
154
|
+
resolve(apiKey);
|
|
155
|
+
}
|
|
156
|
+
function cancelCredentialWait() {
|
|
157
|
+
rejectCredential(abortedSpeech(signal));
|
|
158
|
+
}
|
|
159
|
+
credentialWaits.add(cancelCredentialWait);
|
|
160
|
+
signal?.addEventListener(
|
|
161
|
+
'abort',
|
|
162
|
+
cancelCredentialWait,
|
|
163
|
+
{once: true}
|
|
164
|
+
);
|
|
165
|
+
if (signal?.aborted) {
|
|
166
|
+
cancelCredentialWait();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
Promise.resolve(getApiKey()).then(acceptCredential, rejectCredential);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
rejectCredential(error);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function assertSelection(selection) {
|
|
179
|
+
if (selection && (selection.providerId !== id
|
|
180
|
+
|| selection.modelId !== modelId
|
|
181
|
+
|| selection.localOnly === true
|
|
182
|
+
|| (selection.role !== undefined && selection.role !== 'tts'))) {
|
|
183
|
+
throw speechError(
|
|
184
|
+
'The selected cloud speech model does not match this provider.',
|
|
185
|
+
'ARCANE_AI_MODEL_AUTHORITY_REQUIRED'
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function assertContext(context) {
|
|
191
|
+
if (context.role !== undefined && context.role !== 'tts') {
|
|
192
|
+
throw speechError('This provider supports only TTS.', 'ARCANE_AI_INVALID_REQUEST');
|
|
193
|
+
}
|
|
194
|
+
assertSelection(context.selection);
|
|
195
|
+
assertNotAborted(context.signal);
|
|
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
|
+
busy: activeRequests.size > 0,
|
|
208
|
+
execution: {
|
|
209
|
+
requestedDevice: 'remote',
|
|
210
|
+
selectedDevice: loaded ? 'remote' : null,
|
|
211
|
+
maxConcurrentRequests,
|
|
212
|
+
activeRequestCount: activeRequests.size
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function synthesize(payload, controller, requestGeneration) {
|
|
218
|
+
const signal = controller.signal;
|
|
219
|
+
const apiKey = await readApiKey(signal);
|
|
220
|
+
assertNotAborted(signal);
|
|
221
|
+
const headers = {Authorization: `Bearer ${apiKey}`};
|
|
222
|
+
const response = await fetchImpl(
|
|
223
|
+
INVOKE_URL,
|
|
224
|
+
{
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers: {...headers, 'Content-Type': 'application/json'},
|
|
227
|
+
body: JSON.stringify(
|
|
228
|
+
{
|
|
229
|
+
model_id: modelId,
|
|
230
|
+
input: {
|
|
231
|
+
text: payload.input,
|
|
232
|
+
voice: payload.voice ?? defaultVoice,
|
|
233
|
+
speed: payload.speed ?? 1
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
),
|
|
237
|
+
signal
|
|
238
|
+
}
|
|
239
|
+
);
|
|
240
|
+
await requireSuccessfulResponse(response, signal, 'submission');
|
|
241
|
+
let result = await response.json();
|
|
242
|
+
assertNotAborted(signal);
|
|
243
|
+
const requestId = result?.request_id;
|
|
244
|
+
while (result?.status === 'QUEUED' || result?.status === 'IN_PROGRESS') {
|
|
245
|
+
if (!is.string(requestId) || !requestId) {
|
|
246
|
+
throw speechError(
|
|
247
|
+
'DigitalOcean speech returned a pending job without a request_id.',
|
|
248
|
+
'ARCANE_AI_INVALID_PROVIDER_RESULT'
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
await waitForStatus(POLL_INTERVAL_MS, signal);
|
|
252
|
+
let statusResponse;
|
|
253
|
+
do {
|
|
254
|
+
statusResponse = await fetchImpl(
|
|
255
|
+
`${INVOKE_URL}/${encodeURIComponent(requestId)}`,
|
|
256
|
+
{method: 'GET', headers, signal}
|
|
257
|
+
);
|
|
258
|
+
assertNotAborted(signal);
|
|
259
|
+
const retryDelay = statusResponse.status === 429
|
|
260
|
+
? retryAfterDelay(statusResponse)
|
|
261
|
+
: null;
|
|
262
|
+
if (retryDelay === null) break;
|
|
263
|
+
// Only status reads may be retried; submitting twice can create two paid jobs.
|
|
264
|
+
await statusResponse.text();
|
|
265
|
+
await waitForStatus(retryDelay, signal);
|
|
266
|
+
} while (true);
|
|
267
|
+
await requireSuccessfulResponse(statusResponse, signal, 'status read');
|
|
268
|
+
result = await statusResponse.json();
|
|
269
|
+
assertNotAborted(signal);
|
|
270
|
+
}
|
|
271
|
+
if (result?.status === 'FAILED') {
|
|
272
|
+
throw speechError(
|
|
273
|
+
`DigitalOcean speech generation failed: ${JSON.stringify(result)}`,
|
|
274
|
+
'ARCANE_AI_CLOUD_SPEECH_GENERATION_FAILED'
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
if (result?.status !== 'COMPLETED' || !is.string(result.output?.audio?.url)
|
|
278
|
+
|| !result.output.audio.url) {
|
|
279
|
+
throw speechError(
|
|
280
|
+
`DigitalOcean speech returned an unsupported result: ${JSON.stringify(result)}`,
|
|
281
|
+
'ARCANE_AI_INVALID_PROVIDER_RESULT'
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
assertNotAborted(signal);
|
|
285
|
+
// The returned media URL has its own delivery authority; never forward the inference key.
|
|
286
|
+
const audioResponse = await fetchImpl(
|
|
287
|
+
result.output.audio.url,
|
|
288
|
+
{signal}
|
|
289
|
+
);
|
|
290
|
+
await requireSuccessfulResponse(audioResponse, signal, 'audio download');
|
|
291
|
+
const audio = await audioResponse.blob();
|
|
292
|
+
assertNotAborted(signal);
|
|
293
|
+
if (generation !== requestGeneration || state !== 'ready') {
|
|
294
|
+
throw speechError(
|
|
295
|
+
'The cloud speech result was superseded by a provider transition.',
|
|
296
|
+
'ARCANE_AI_OPERATION_SUPERSEDED'
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
return audio;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const provider = {
|
|
303
|
+
protocol: 'arcane-ai-provider/2',
|
|
304
|
+
role: 'tts',
|
|
305
|
+
id,
|
|
306
|
+
localOnly: false,
|
|
307
|
+
maxConcurrentRequests,
|
|
308
|
+
|
|
309
|
+
catalog() {
|
|
310
|
+
return [
|
|
311
|
+
{
|
|
312
|
+
id: modelId,
|
|
313
|
+
providerId: id,
|
|
314
|
+
role: 'tts',
|
|
315
|
+
localOnly: false,
|
|
316
|
+
defaultVoice,
|
|
317
|
+
speech: {responseFormats: ['provider-native'], defaultResponseFormat: 'provider-native'}
|
|
318
|
+
}
|
|
319
|
+
];
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
async inspect(selection, context = {}) {
|
|
323
|
+
assertContext(context);
|
|
324
|
+
if (state === 'disposed' || disposeOperation) {
|
|
325
|
+
return {
|
|
326
|
+
available: false,
|
|
327
|
+
code: 'ARCANE_AI_PROVIDER_DISPOSED',
|
|
328
|
+
message: 'The cloud speech provider is disposed.'
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
assertSelection(selection);
|
|
332
|
+
const inspectionGeneration = generation;
|
|
333
|
+
await readApiKey(context.signal);
|
|
334
|
+
assertNotAborted(context.signal);
|
|
335
|
+
if (generation !== inspectionGeneration) {
|
|
336
|
+
throw speechError('Cloud speech inspection was superseded.', 'ARCANE_AI_OPERATION_SUPERSEDED');
|
|
337
|
+
}
|
|
338
|
+
return {available: true, authority: authority()};
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
status,
|
|
342
|
+
|
|
343
|
+
async load(context = {}) {
|
|
344
|
+
assertContext(context);
|
|
345
|
+
if (state === 'disposed' || disposeOperation) {
|
|
346
|
+
throw speechError('The cloud speech provider is disposed.', 'ARCANE_AI_PROVIDER_DISPOSED');
|
|
347
|
+
}
|
|
348
|
+
if (unloadOperation) {
|
|
349
|
+
throw speechError('The cloud speech provider is unloading.', 'ARCANE_AI_OPERATION_SUPERSEDED');
|
|
350
|
+
}
|
|
351
|
+
const loadGeneration = generation;
|
|
352
|
+
if (state !== 'ready') state = 'loading';
|
|
353
|
+
try {
|
|
354
|
+
await readApiKey(context.signal);
|
|
355
|
+
assertNotAborted(context.signal);
|
|
356
|
+
if (generation !== loadGeneration) {
|
|
357
|
+
throw speechError('Cloud speech activation was superseded.', 'ARCANE_AI_OPERATION_SUPERSEDED');
|
|
358
|
+
}
|
|
359
|
+
} catch (error) {
|
|
360
|
+
if (generation === loadGeneration && state === 'loading') state = 'unloaded';
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
363
|
+
state = 'ready';
|
|
364
|
+
return status();
|
|
365
|
+
},
|
|
366
|
+
|
|
367
|
+
async request(context = {}) {
|
|
368
|
+
assertContext(context);
|
|
369
|
+
if (context.role !== 'tts' || context.operation !== 'synthesize') {
|
|
370
|
+
throw speechError('Cloud speech supports only TTS synthesis.', 'ARCANE_AI_INVALID_REQUEST');
|
|
371
|
+
}
|
|
372
|
+
if (state !== 'ready' || unloadOperation || disposeOperation) {
|
|
373
|
+
throw speechError('The cloud speech provider is not ready.', 'ARCANE_AI_NOT_READY');
|
|
374
|
+
}
|
|
375
|
+
if (activeRequests.size >= maxConcurrentRequests) {
|
|
376
|
+
throw speechError('The cloud speech provider is at capacity.', 'ARCANE_AI_PROVIDER_BUSY');
|
|
377
|
+
}
|
|
378
|
+
const payload = context.payload;
|
|
379
|
+
if (!is.string(payload?.input) || !payload.input.trim()) {
|
|
380
|
+
throw new TypeError('Cloud speech input must be a nonempty string.');
|
|
381
|
+
}
|
|
382
|
+
if (payload.model !== undefined && payload.model !== modelId) {
|
|
383
|
+
throw speechError('The requested cloud speech model differs from the selection.', 'ARCANE_AI_MODEL_AUTHORITY_REQUIRED');
|
|
384
|
+
}
|
|
385
|
+
if (payload.voice !== undefined) assertIdentifier(payload.voice, 'Cloud speech voice');
|
|
386
|
+
if (payload.responseFormat !== undefined && payload.responseFormat !== 'provider-native') {
|
|
387
|
+
throw speechError('Cloud speech returns the provider-native audio format.', 'ARCANE_AI_UNSUPPORTED_RESPONSE_FORMAT');
|
|
388
|
+
}
|
|
389
|
+
const speed = payload.speed ?? 1;
|
|
390
|
+
if (!is.finite(speed) || speed < 0.7 || speed > 1.2) {
|
|
391
|
+
const error = new RangeError('Cloud speech speed must be from 0.7 through 1.2.');
|
|
392
|
+
error.code = 'ARCANE_AI_TTS_SPEED_INVALID';
|
|
393
|
+
throw error;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const controller = new AbortController();
|
|
397
|
+
function cancelCloudSpeechRequest() {
|
|
398
|
+
controller.abort(context.signal.reason);
|
|
399
|
+
}
|
|
400
|
+
context.signal?.addEventListener(
|
|
401
|
+
'abort',
|
|
402
|
+
cancelCloudSpeechRequest,
|
|
403
|
+
{once: true}
|
|
404
|
+
);
|
|
405
|
+
if (context.signal?.aborted) cancelCloudSpeechRequest();
|
|
406
|
+
const request = {controller, promise: null};
|
|
407
|
+
activeRequests.add(request);
|
|
408
|
+
request.promise = synthesize(payload, controller, generation);
|
|
409
|
+
try {
|
|
410
|
+
return await request.promise;
|
|
411
|
+
} catch (error) {
|
|
412
|
+
if (controller.signal.aborted) throw abortedSpeech(controller.signal);
|
|
413
|
+
throw error;
|
|
414
|
+
} finally {
|
|
415
|
+
context.signal?.removeEventListener('abort', cancelCloudSpeechRequest);
|
|
416
|
+
activeRequests.delete(request);
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
unload(context = {}) {
|
|
421
|
+
assertContext(context);
|
|
422
|
+
if (state === 'disposed') {
|
|
423
|
+
return Promise.resolve(
|
|
424
|
+
status()
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
if (unloadOperation) return unloadOperation;
|
|
428
|
+
state = 'unloading';
|
|
429
|
+
generation += 1;
|
|
430
|
+
for (const cancelCredentialWait of [...credentialWaits]) cancelCredentialWait();
|
|
431
|
+
const requests = [...activeRequests];
|
|
432
|
+
for (const request of requests) request.controller.abort('Cloud speech provider unloaded.');
|
|
433
|
+
unloadOperation = Promise.allSettled(
|
|
434
|
+
requests.map(
|
|
435
|
+
function pendingCloudSpeechRequest(request) {
|
|
436
|
+
return request.promise;
|
|
437
|
+
}
|
|
438
|
+
)
|
|
439
|
+
).then(
|
|
440
|
+
function finishCloudSpeechUnload() {
|
|
441
|
+
state = 'unloaded';
|
|
442
|
+
unloadOperation = null;
|
|
443
|
+
return status();
|
|
444
|
+
}
|
|
445
|
+
);
|
|
446
|
+
return unloadOperation;
|
|
447
|
+
},
|
|
448
|
+
|
|
449
|
+
dispose(context = {}) {
|
|
450
|
+
assertContext(context);
|
|
451
|
+
if (state === 'disposed') {
|
|
452
|
+
return Promise.resolve(
|
|
453
|
+
status()
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
if (disposeOperation) return disposeOperation;
|
|
457
|
+
disposeOperation = provider.unload(context).then(
|
|
458
|
+
function finishCloudSpeechDispose() {
|
|
459
|
+
state = 'disposed';
|
|
460
|
+
disposeOperation = null;
|
|
461
|
+
return status();
|
|
462
|
+
}
|
|
463
|
+
);
|
|
464
|
+
return disposeOperation;
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
return provider;
|
|
468
|
+
}
|
|
@@ -1,11 +1,91 @@
|
|
|
1
1
|
# Browser speech providers
|
|
2
2
|
|
|
3
|
-
`arcane-os/ai/browser-speech`
|
|
4
|
-
|
|
3
|
+
`arcane-os/ai/browser-speech` supplies caller-selected Whisper speech-to-text,
|
|
4
|
+
Kokoro text-to-speech, and DigitalOcean FAL text-to-speech providers. Its local path
|
|
5
5
|
provides artifact storage, live module routing, role Workers, provider/2
|
|
6
6
|
adapters, bounded parallel TTS synthesis, audio normalization, cancellation,
|
|
7
7
|
and cleanup.
|
|
8
8
|
|
|
9
|
+
## DigitalOcean FAL text-to-speech
|
|
10
|
+
|
|
11
|
+
Use the same AI speech queue with an explicitly selected remote provider.
|
|
12
|
+
The application supplies its current DigitalOcean inference key through a
|
|
13
|
+
function returning a string or promise; never place that key in source, logs, or model records.
|
|
14
|
+
This example receives an existing `ai` and application-owned key reader:
|
|
15
|
+
|
|
16
|
+
```javascript
|
|
17
|
+
import {createDigitalOceanFalTTSProvider} from 'arcane-os/ai/browser-speech';
|
|
18
|
+
|
|
19
|
+
async function selectCloudVoice(ai, getApiKey) {
|
|
20
|
+
const provider = createDigitalOceanFalTTSProvider({
|
|
21
|
+
id: 'my-digitalocean-voice',
|
|
22
|
+
model: {
|
|
23
|
+
id: 'fal-ai/elevenlabs/tts/multilingual-v2',
|
|
24
|
+
defaultVoice: 'Rachel'
|
|
25
|
+
},
|
|
26
|
+
getApiKey,
|
|
27
|
+
maxConcurrentRequests: 4
|
|
28
|
+
});
|
|
29
|
+
await ai.configureSpeechProvider('tts', provider, {
|
|
30
|
+
modelId: provider.catalog()[0].id
|
|
31
|
+
});
|
|
32
|
+
return provider;
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Construction and configuration make no network request. Explicit
|
|
37
|
+
`ai.setSpeechMuted(false)` awaits the key reader, checks credential presence, and activates
|
|
38
|
+
the adapter; its ready state does not prove remote credential validity.
|
|
39
|
+
Only synthesis submits text. Keep local STT configured independently, before
|
|
40
|
+
or after this TTS selection. Existing shared Chat/Speech controls own user
|
|
41
|
+
mute/unmute intent; do not reapply a saved unmute over a later manual mute.
|
|
42
|
+
An asynchronous key reader may await the application's already-running key
|
|
43
|
+
refresh at this activation boundary; it does not delay provider configuration
|
|
44
|
+
or page rendering. Cancellation releases this request's wait without cancelling
|
|
45
|
+
the application's shared refresh, and no late key result starts synthesis.
|
|
46
|
+
|
|
47
|
+
Once explicit activation is requested, feed each arriving text chunk immediately through
|
|
48
|
+
`ai.streamTTS(chunk)` and observe each promise without awaiting it ahead of
|
|
49
|
+
the next chunk. Pending activation retains these segments in the existing queue
|
|
50
|
+
until the selected provider is ready; text does not initiate activation itself.
|
|
51
|
+
`ai.finishTTS()` submits the final remainder. The existing
|
|
52
|
+
punctuation segmentation, concurrent provider queue, and original-order
|
|
53
|
+
playback remain the only owners. Use
|
|
54
|
+
`ai.configureTTSSegmentation({punctuation:'any',wordCadence:null})` when each
|
|
55
|
+
punctuation boundary should submit promptly. This is incremental playback of
|
|
56
|
+
completed synthesis jobs, not a live audio stream from DigitalOcean.
|
|
57
|
+
|
|
58
|
+
The transport submits `POST https://inference.do-ai.run/v1/async-invoke`, then
|
|
59
|
+
reads that job until its completed `output.audio.url` is available. Each
|
|
60
|
+
segment is submitted once; only status reads retry a `429` with `Retry-After`.
|
|
61
|
+
The audio download never receives the inference Authorization header.
|
|
62
|
+
The selected multilingual-v2 service supports speed `0.7` through `1.2`;
|
|
63
|
+
unsupported speeds reject rather than being silently clamped. Complete
|
|
64
|
+
input remains intact except for the existing explicitly owned speech-only
|
|
65
|
+
formatting cleanup described below. A cloud failure never selects local TTS.
|
|
66
|
+
|
|
67
|
+
`ai.stopAudio()` cancels the current local request/poll/download/playback
|
|
68
|
+
lifetime. Mute additionally unloads TTS. DigitalOcean exposes no documented
|
|
69
|
+
job cancellation operation through this contract: a submitted remote job may
|
|
70
|
+
continue after local cancellation. Browser use also depends on the inference
|
|
71
|
+
and returned media endpoints allowing the requesting origin.
|
|
72
|
+
|
|
73
|
+
Release this exact configured provider with:
|
|
74
|
+
|
|
75
|
+
```javascript
|
|
76
|
+
// Keep the provider returned by selectCloudVoice(ai, getApiKey).
|
|
77
|
+
await ai.configureSpeechProvider('tts', null, {expectedProvider: provider});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The identity is rechecked inside the SDK's serialized configuration operation,
|
|
81
|
+
so a later selected provider remains untouched. Returning to local TTS uses
|
|
82
|
+
the normal TTS-only `configureBrowserSpeech()` configuration. Neither action
|
|
83
|
+
unloads STT or changes its stored artifacts.
|
|
84
|
+
|
|
85
|
+
Transport references: [DigitalOcean asynchronous inference](https://docs.digitalocean.com/reference/api/reference/serverless-inference/#inference_create_async_invoke),
|
|
86
|
+
[DigitalOcean FAL guide](https://docs.digitalocean.com/products/inference/how-to/use-fal-models/),
|
|
87
|
+
and [multilingual-v2 model schema](https://fal.ai/models/fal-ai/elevenlabs/tts/multilingual-v2/api).
|
|
88
|
+
|
|
9
89
|
## Quick start: say one sentence
|
|
10
90
|
|
|
11
91
|
Use this in a browser application served by `arcane dev`, where the generated
|
|
@@ -844,8 +924,8 @@ records; this contract does not freeze them or shorten their content.
|
|
|
844
924
|
| --- | --- | --- |
|
|
845
925
|
| Browser | Shipped | Requires Workers, Fetch, Blob/File, object URLs, DBOPFS/OPFS, and Web Locks. Blob/File STT requests also require the browser audio decoder. |
|
|
846
926
|
| Native WebView | Conditional | Available when the WebView exposes the browser APIs above. It does not invoke Core speech. |
|
|
847
|
-
| Node |
|
|
848
|
-
|
|
|
927
|
+
| Node | Remote adapter available; local execution unavailable | DigitalOcean FAL uses Fetch and Blob. The local providers have no SDK Node speech-storage, Worker, or audio-decoder host. |
|
|
928
|
+
| Remote TTS | Explicitly selected | DigitalOcean FAL uses standard Fetch and Blob with an application-owned key; no Worker, OPFS, or local model is needed for this adapter. |
|
|
849
929
|
|
|
850
930
|
STT and TTS own independent provider lifecycles. A failure or cancellation in
|
|
851
931
|
one role does not disable the other role or authorize a fallback provider.
|
|
@@ -860,7 +940,8 @@ import {
|
|
|
860
940
|
createBrowserSpeechArtifactGraph,
|
|
861
941
|
createBrowserSpeechAuthority,
|
|
862
942
|
createBrowserWhisperProvider,
|
|
863
|
-
createDbopfsSpeechArtifactStore
|
|
943
|
+
createDbopfsSpeechArtifactStore,
|
|
944
|
+
createDigitalOceanFalTTSProvider
|
|
864
945
|
} from 'arcane-os/ai/browser-speech';
|
|
865
946
|
```
|
|
866
947
|
|
|
@@ -211,6 +211,38 @@ controller's `abort()` when the operation is cancelled or its page detaches.
|
|
|
211
211
|
`streamRequest()` is the corresponding object-form streaming API. See the
|
|
212
212
|
[AI module reference](../runtime-modules.md#aijs) for its complete options.
|
|
213
213
|
|
|
214
|
+
## Use a model for one browser request
|
|
215
|
+
|
|
216
|
+
Both object-form methods accept `model` for that request. For example, an
|
|
217
|
+
application can use 20B for document search while retaining its configured
|
|
218
|
+
120B conversation model:
|
|
219
|
+
|
|
220
|
+
```javascript
|
|
221
|
+
const result = await ai.fetchRequest({
|
|
222
|
+
model: 'openai-gpt-oss-20b',
|
|
223
|
+
messages: searchMessages
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
await ai.streamRequest({
|
|
227
|
+
model: 'openai-gpt-oss-20b',
|
|
228
|
+
messages: summaryMessages,
|
|
229
|
+
onChunk: displaySummaryChunk
|
|
230
|
+
});
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`searchMessages`, `summaryMessages`, and `displaySummaryChunk` are supplied by
|
|
234
|
+
the application. Their content and callbacks keep their ordinary contracts.
|
|
235
|
+
The exact model goes into each TWiN request payload without changing `ai.model`
|
|
236
|
+
or selecting another provider. Concurrent calls can choose different models;
|
|
237
|
+
later calls that omit `model` (or pass `undefined`) use the configured model.
|
|
238
|
+
Retries keep the same request model. Provider-returned model metadata remains
|
|
239
|
+
authoritative; an assembled stream completion uses the request model only when
|
|
240
|
+
the response omits it.
|
|
241
|
+
|
|
242
|
+
The browser API forwards an explicitly supplied model to registered providers,
|
|
243
|
+
whose own request contract determines its meaning. This does not switch or
|
|
244
|
+
activate the loaded model of a browser-WASM provider.
|
|
245
|
+
|
|
214
246
|
## Migrate saved preference tuples before using them
|
|
215
247
|
|
|
216
248
|
The six tuple slots consumed by `ai.setAI(...tuple)` are:
|