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 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.33.6` SDK contract. Applications pin one exact npm
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.33.6 new hello-speech --path ./hello-speech --target browser
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
  ```
@@ -10,3 +10,4 @@ export {
10
10
  createBrowserKokoroProvider,
11
11
  createBrowserWhisperProvider,
12
12
  } from "./browser-speech-providers.mjs";
13
+ export { createDigitalOceanFalTTSProvider } from "./digitalocean-speech.mjs";
@@ -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` is the browser-only SDK boundary for
4
- caller-selected Whisper speech-to-text and Kokoro text-to-speech runtimes. It
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 | Importable, execution unavailable | The ESM subpath imports, but the SDK supplies no Node speech storage, Worker, or audio-decoder host. |
848
- | Cloud | Not offered | The SDK's built-in speech profile is device-only: Whisper owns STT and Kokoro owns TTS. |
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: