arcane-os 0.33.5 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.34.0
4
+
5
+ - Add `createDigitalOceanFalTTSProvider` through `arcane-os/ai/browser-speech`
6
+ for DigitalOcean's asynchronous FAL speech service. Applications select the
7
+ model and voice and supply a current credential getter, which may return a
8
+ promise. Configuration and activation make no provider request; synthesis
9
+ preserves complete input and fetches returned audio without forwarding the
10
+ inference credential. Status polling never retries a paid submission.
11
+ - Add `AI.configureSpeechProvider(role, provider, options)` for independent
12
+ STT or TTS replacement and removal, preserving the other selected role.
13
+ Credential waits, synthesis, and local polling follow cancellation and
14
+ provider teardown. Cancelling local work does not establish cancellation of
15
+ an already-submitted remote job.
16
+ - Preserve explicit unmute intent while the canonical AI owner, selected
17
+ provider registration, or speech configuration is still becoming ready.
18
+ Muting and teardown cancel that pending activation.
19
+ - Allow `user.updateExplicit(current => partialOrNull)` to make a conditional
20
+ profile update against fresh durable fields inside the existing serialized
21
+ write owner. Async callbacks are supported; `null` returns the fresh profile
22
+ without saving. Existing object and JSON updates remain supported. Timestamp
23
+ setters save only their changed fields so they preserve newer profile edits.
24
+
25
+ ## 0.33.6
26
+
27
+ - Resume transcript bottom-follow when an accepted non-synthetic user message
28
+ is sent, including when the reader had scrolled upward. Preserve reading
29
+ position for incoming content, synthetic requests, and canceled submissions.
30
+ - Keep HTTP 529 on its existing bounded retry path when reading its diagnostic
31
+ body fails. Preserve the actual parser or body-read error in retry observations
32
+ and final failure, with cancellation and other HTTP behavior unchanged.
33
+
3
34
  ## 0.33.5
4
35
 
5
36
  - Deliver the first visible streamed chat chunk to speech exactly once, without
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.5` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.34.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
 
@@ -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.5 new hello-speech --path ./hello-speech --target browser
88
+ npx arcane-os@0.34.0 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
+ }
@@ -170,16 +170,23 @@ export async function fetchHTTPResponse(url, options, {onRetry = null} = {}) {
170
170
  if (response?.ok) {
171
171
  return response;
172
172
  }
173
+ const status = response?.status ?? null;
173
174
  if (response) {
174
175
  const contentType = response.headers.get('content-type') || '';
175
- error = contentType.includes('application/json')
176
- ? await response.json()
177
- : await response.text();
176
+ try {
177
+ error = contentType.includes('application/json')
178
+ ? await response.json()
179
+ : await response.text();
180
+ } catch (bodyError) {
181
+ if (status !== 529 || isAIRequestAbort(bodyError, signal)) {
182
+ throw bodyError;
183
+ }
184
+ error = bodyError;
185
+ }
178
186
  if (signal?.aborted) {
179
187
  throw normalizeAIRequestAbort(signal.reason);
180
188
  }
181
189
  }
182
- const status = response?.status ?? null;
183
190
  const message = is.string(error)
184
191
  ? error
185
192
  : error?.error?.message ?? error?.message;
@@ -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