arcane-os 0.40.1 → 0.41.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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.41.0
4
+
5
+ - Add `createTwinCloudTTSProvider` through `arcane-os/ai/browser-speech`,
6
+ retaining `createDigitalOceanFalTTSProvider` as the identical compatibility
7
+ function. Existing provider IDs, credentials, models, voices and endpoints
8
+ remain unchanged; no saved-data migration is performed.
9
+ - Submit cloud synthesis jobs immediately through the existing whole-job
10
+ capacity owner. Queue only follow-up status and audio GET operations through
11
+ one provider-local published `js-queue`, with independently configurable
12
+ `followUpQueue: {maxConcurrentRequests: 4, intervalMs: 250}` defaults.
13
+ Follow-up slots remain occupied through complete response-body consumption,
14
+ with spacing between actual dispatch starts and ordered same-job requeue.
15
+ - Preserve per-job Retry-After eligibility, bounded retries, cancellation and
16
+ unload without delaying eligible siblings or replaying ambiguous paid
17
+ submissions. Existing ordered playback, enabled speech intent and local
18
+ Whisper/Kokoro provider paths remain unchanged.
19
+
3
20
  ## 0.40.1
4
21
 
5
22
  - Include complete selected local resource URL variants in generated static
package/NOTICE CHANGED
@@ -8,6 +8,9 @@ informational notice and is not itself a grant of commercial rights.
8
8
  Third-party material retains its own terms:
9
9
 
10
10
  - event-pubsub 6.1.0: MIT License; see its installed `licence` file.
11
+ - js-queue 3.1.1: MIT License; see browser-runtime/dependencies/js-queue/licence.md.
12
+ - js-queue's easy-stack 2.1.0 dependency: MIT License; see its installed
13
+ `licence` file. The browser Queue entry does not import this stack module.
11
14
  - node-http-server 10.0.0: MIT License; see its installed `licence` file.
12
15
  - strong-type 2.0.1: MIT License; see its installed `licence` file.
13
16
  - vanilla-test 2.1.3: MIT License; see its installed `licence` file.
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.40.1` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.41.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
 
@@ -10,4 +10,7 @@ export {
10
10
  createBrowserKokoroProvider,
11
11
  createBrowserWhisperProvider,
12
12
  } from "./browser-speech-providers.mjs";
13
- export { createDigitalOceanFalTTSProvider } from "./digitalocean-speech.mjs";
13
+ export {
14
+ createTwinCloudTTSProvider,
15
+ createTwinCloudTTSProvider as createDigitalOceanFalTTSProvider,
16
+ } from "./twin-cloud-speech.mjs";
@@ -1,508 +1,2 @@
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
- let nextInferenceStart = 0;
117
-
118
- function authority() {
119
- return {
120
- protocol: 'arcane-ai-model-authority/1',
121
- providerId: id,
122
- modelId,
123
- role: 'tts',
124
- localOnly: false
125
- };
126
- }
127
-
128
- function readApiKey(signal) {
129
- assertNotAborted(signal);
130
- return new Promise(
131
- function waitForCloudSpeechCredential(resolve, reject) {
132
- let settled = false;
133
- function finishCredentialWait() {
134
- settled = true;
135
- credentialWaits.delete(cancelCredentialWait);
136
- signal?.removeEventListener('abort', cancelCredentialWait);
137
- }
138
- function rejectCredential(error) {
139
- if (settled) return;
140
- finishCredentialWait();
141
- reject(error);
142
- }
143
- function acceptCredential(apiKey) {
144
- if (settled) return;
145
- if (!is.string(apiKey) || !apiKey.trim()) {
146
- rejectCredential(
147
- speechError(
148
- 'A DigitalOcean inference access key is required for cloud speech.',
149
- 'ARCANE_AI_CLOUD_SPEECH_KEY_REQUIRED'
150
- )
151
- );
152
- return;
153
- }
154
- finishCredentialWait();
155
- resolve(apiKey);
156
- }
157
- function cancelCredentialWait() {
158
- rejectCredential(abortedSpeech(signal));
159
- }
160
- credentialWaits.add(cancelCredentialWait);
161
- signal?.addEventListener(
162
- 'abort',
163
- cancelCredentialWait,
164
- {once: true}
165
- );
166
- if (signal?.aborted) {
167
- cancelCredentialWait();
168
- return;
169
- }
170
- try {
171
- Promise.resolve(getApiKey()).then(acceptCredential, rejectCredential);
172
- } catch (error) {
173
- rejectCredential(error);
174
- }
175
- }
176
- );
177
- }
178
-
179
- function assertSelection(selection) {
180
- if (selection && (selection.providerId !== id
181
- || selection.modelId !== modelId
182
- || selection.localOnly === true
183
- || (selection.role !== undefined && selection.role !== 'tts'))) {
184
- throw speechError(
185
- 'The selected cloud speech model does not match this provider.',
186
- 'ARCANE_AI_MODEL_AUTHORITY_REQUIRED'
187
- );
188
- }
189
- }
190
-
191
- function assertContext(context) {
192
- if (context.role !== undefined && context.role !== 'tts') {
193
- throw speechError('This provider supports only TTS.', 'ARCANE_AI_INVALID_REQUEST');
194
- }
195
- assertSelection(context.selection);
196
- assertNotAborted(context.signal);
197
- }
198
-
199
- function status() {
200
- const loaded = state === 'ready';
201
- return {
202
- providerId: id,
203
- role: 'tts',
204
- modelId,
205
- localOnly: false,
206
- state,
207
- loaded,
208
- busy: activeRequests.size > 0,
209
- execution: {
210
- requestedDevice: 'remote',
211
- selectedDevice: loaded ? 'remote' : null,
212
- maxConcurrentRequests,
213
- activeRequestCount: activeRequests.size
214
- }
215
- };
216
- }
217
-
218
- async function fetchInference(url, options, operation) {
219
- const signal = options.signal;
220
- let retryError = null;
221
- while (true) {
222
- // Space request starts across jobs, not their completion. Remote jobs
223
- // still run concurrently and the runtime retains ordered playback.
224
- while (true) {
225
- assertNotAborted(signal);
226
- const delay = nextInferenceStart - Date.now();
227
- if (delay <= 0) break;
228
- await waitForStatus(delay, signal);
229
- }
230
- nextInferenceStart = Date.now() + POLL_INTERVAL_MS;
231
- let response;
232
- try {
233
- response = await fetchImpl(url, options);
234
- } catch (error) {
235
- assertNotAborted(signal);
236
- // A lost POST response may represent an accepted paid job. Only
237
- // an identified job's status read can retry a network failure.
238
- if (!retryError && options.method === 'GET' && error instanceof TypeError) {
239
- nextInferenceStart = Math.max(nextInferenceStart, Date.now() + POLL_INTERVAL_MS);
240
- retryError = error;
241
- console.warn('Retrying the cloud speech status read once.', error);
242
- continue;
243
- }
244
- throw error;
245
- }
246
- assertNotAborted(signal);
247
- if (response.status === 429) {
248
- // A readable rejection is safe to retry. Its cooldown also
249
- // applies to waiting siblings, including when this retry ends.
250
- const delay = Math.max(POLL_INTERVAL_MS, retryAfterDelay(response) ?? POLL_INTERVAL_MS);
251
- nextInferenceStart = Math.max(nextInferenceStart, Date.now() + delay);
252
- }
253
- try {
254
- await requireSuccessfulResponse(response, signal, operation);
255
- } catch (error) {
256
- assertNotAborted(signal);
257
- if (!retryError && response.status === 429) {
258
- retryError = error;
259
- console.warn(`Retrying the cloud speech ${operation} once.`, error);
260
- continue;
261
- }
262
- throw error;
263
- }
264
- return response;
265
- }
266
- }
267
-
268
- async function synthesize(payload, controller, requestGeneration) {
269
- const signal = controller.signal;
270
- const apiKey = await readApiKey(signal);
271
- assertNotAborted(signal);
272
- const headers = {Authorization: `Bearer ${apiKey}`};
273
- const response = await fetchInference(
274
- INVOKE_URL,
275
- {
276
- method: 'POST',
277
- headers: {...headers, 'Content-Type': 'application/json'},
278
- body: JSON.stringify(
279
- {
280
- model_id: modelId,
281
- input: {
282
- text: payload.input,
283
- voice: payload.voice ?? defaultVoice,
284
- speed: payload.speed ?? 1
285
- }
286
- }
287
- ),
288
- signal
289
- },
290
- 'submission'
291
- );
292
- let result = await response.json();
293
- assertNotAborted(signal);
294
- const requestId = result?.request_id;
295
- while (result?.status === 'QUEUED' || result?.status === 'IN_PROGRESS') {
296
- if (!is.string(requestId) || !requestId) {
297
- throw speechError(
298
- 'DigitalOcean speech returned a pending job without a request_id.',
299
- 'ARCANE_AI_INVALID_PROVIDER_RESULT'
300
- );
301
- }
302
- await waitForStatus(POLL_INTERVAL_MS, signal);
303
- const statusResponse = await fetchInference(
304
- `${INVOKE_URL}/${encodeURIComponent(requestId)}`,
305
- {method: 'GET', headers, signal},
306
- 'status read'
307
- );
308
- result = await statusResponse.json();
309
- assertNotAborted(signal);
310
- }
311
- if (result?.status === 'FAILED') {
312
- throw speechError(
313
- `DigitalOcean speech generation failed: ${JSON.stringify(result)}`,
314
- 'ARCANE_AI_CLOUD_SPEECH_GENERATION_FAILED'
315
- );
316
- }
317
- if (result?.status !== 'COMPLETED' || !is.string(result.output?.audio?.url)
318
- || !result.output.audio.url) {
319
- throw speechError(
320
- `DigitalOcean speech returned an unsupported result: ${JSON.stringify(result)}`,
321
- 'ARCANE_AI_INVALID_PROVIDER_RESULT'
322
- );
323
- }
324
- assertNotAborted(signal);
325
- // The returned media URL has its own delivery authority; never forward the inference key.
326
- const audioResponse = await fetchImpl(
327
- result.output.audio.url,
328
- {signal}
329
- );
330
- await requireSuccessfulResponse(audioResponse, signal, 'audio download');
331
- const audio = await audioResponse.blob();
332
- assertNotAborted(signal);
333
- if (generation !== requestGeneration || state !== 'ready') {
334
- throw speechError(
335
- 'The cloud speech result was superseded by a provider transition.',
336
- 'ARCANE_AI_OPERATION_SUPERSEDED'
337
- );
338
- }
339
- return audio;
340
- }
341
-
342
- const provider = {
343
- protocol: 'arcane-ai-provider/2',
344
- role: 'tts',
345
- id,
346
- localOnly: false,
347
- maxConcurrentRequests,
348
-
349
- catalog() {
350
- return [
351
- {
352
- id: modelId,
353
- providerId: id,
354
- role: 'tts',
355
- localOnly: false,
356
- defaultVoice,
357
- speech: {responseFormats: ['provider-native'], defaultResponseFormat: 'provider-native'}
358
- }
359
- ];
360
- },
361
-
362
- async inspect(selection, context = {}) {
363
- assertContext(context);
364
- if (state === 'disposed' || disposeOperation) {
365
- return {
366
- available: false,
367
- code: 'ARCANE_AI_PROVIDER_DISPOSED',
368
- message: 'The cloud speech provider is disposed.'
369
- };
370
- }
371
- assertSelection(selection);
372
- const inspectionGeneration = generation;
373
- await readApiKey(context.signal);
374
- assertNotAborted(context.signal);
375
- if (generation !== inspectionGeneration) {
376
- throw speechError('Cloud speech inspection was superseded.', 'ARCANE_AI_OPERATION_SUPERSEDED');
377
- }
378
- return {available: true, authority: authority()};
379
- },
380
-
381
- status,
382
-
383
- async load(context = {}) {
384
- assertContext(context);
385
- if (state === 'disposed' || disposeOperation) {
386
- throw speechError('The cloud speech provider is disposed.', 'ARCANE_AI_PROVIDER_DISPOSED');
387
- }
388
- if (unloadOperation) {
389
- throw speechError('The cloud speech provider is unloading.', 'ARCANE_AI_OPERATION_SUPERSEDED');
390
- }
391
- const loadGeneration = generation;
392
- if (state !== 'ready') state = 'loading';
393
- try {
394
- await readApiKey(context.signal);
395
- assertNotAborted(context.signal);
396
- if (generation !== loadGeneration) {
397
- throw speechError('Cloud speech activation was superseded.', 'ARCANE_AI_OPERATION_SUPERSEDED');
398
- }
399
- } catch (error) {
400
- if (generation === loadGeneration && state === 'loading') state = 'unloaded';
401
- throw error;
402
- }
403
- state = 'ready';
404
- return status();
405
- },
406
-
407
- async request(context = {}) {
408
- assertContext(context);
409
- if (context.role !== 'tts' || context.operation !== 'synthesize') {
410
- throw speechError('Cloud speech supports only TTS synthesis.', 'ARCANE_AI_INVALID_REQUEST');
411
- }
412
- if (state !== 'ready' || unloadOperation || disposeOperation) {
413
- throw speechError('The cloud speech provider is not ready.', 'ARCANE_AI_NOT_READY');
414
- }
415
- if (activeRequests.size >= maxConcurrentRequests) {
416
- throw speechError('The cloud speech provider is at capacity.', 'ARCANE_AI_PROVIDER_BUSY');
417
- }
418
- const payload = context.payload;
419
- if (!is.string(payload?.input) || !payload.input.trim()) {
420
- throw new TypeError('Cloud speech input must be a nonempty string.');
421
- }
422
- if (payload.model !== undefined && payload.model !== modelId) {
423
- throw speechError('The requested cloud speech model differs from the selection.', 'ARCANE_AI_MODEL_AUTHORITY_REQUIRED');
424
- }
425
- if (payload.voice !== undefined) assertIdentifier(payload.voice, 'Cloud speech voice');
426
- if (payload.responseFormat !== undefined && payload.responseFormat !== 'provider-native') {
427
- throw speechError('Cloud speech returns the provider-native audio format.', 'ARCANE_AI_UNSUPPORTED_RESPONSE_FORMAT');
428
- }
429
- const speed = payload.speed ?? 1;
430
- if (!is.finite(speed) || speed < 0.7 || speed > 1.2) {
431
- const error = new RangeError('Cloud speech speed must be from 0.7 through 1.2.');
432
- error.code = 'ARCANE_AI_TTS_SPEED_INVALID';
433
- throw error;
434
- }
435
-
436
- const controller = new AbortController();
437
- function cancelCloudSpeechRequest() {
438
- controller.abort(context.signal.reason);
439
- }
440
- context.signal?.addEventListener(
441
- 'abort',
442
- cancelCloudSpeechRequest,
443
- {once: true}
444
- );
445
- if (context.signal?.aborted) cancelCloudSpeechRequest();
446
- const request = {controller, promise: null};
447
- activeRequests.add(request);
448
- request.promise = synthesize(payload, controller, generation);
449
- try {
450
- return await request.promise;
451
- } catch (error) {
452
- if (controller.signal.aborted) throw abortedSpeech(controller.signal);
453
- throw error;
454
- } finally {
455
- context.signal?.removeEventListener('abort', cancelCloudSpeechRequest);
456
- activeRequests.delete(request);
457
- }
458
- },
459
-
460
- unload(context = {}) {
461
- assertContext(context);
462
- if (state === 'disposed') {
463
- return Promise.resolve(
464
- status()
465
- );
466
- }
467
- if (unloadOperation) return unloadOperation;
468
- state = 'unloading';
469
- generation += 1;
470
- for (const cancelCredentialWait of [...credentialWaits]) cancelCredentialWait();
471
- const requests = [...activeRequests];
472
- for (const request of requests) request.controller.abort('Cloud speech provider unloaded.');
473
- unloadOperation = Promise.allSettled(
474
- requests.map(
475
- function pendingCloudSpeechRequest(request) {
476
- return request.promise;
477
- }
478
- )
479
- ).then(
480
- function finishCloudSpeechUnload() {
481
- state = 'unloaded';
482
- unloadOperation = null;
483
- return status();
484
- }
485
- );
486
- return unloadOperation;
487
- },
488
-
489
- dispose(context = {}) {
490
- assertContext(context);
491
- if (state === 'disposed') {
492
- return Promise.resolve(
493
- status()
494
- );
495
- }
496
- if (disposeOperation) return disposeOperation;
497
- disposeOperation = provider.unload(context).then(
498
- function finishCloudSpeechDispose() {
499
- state = 'disposed';
500
- disposeOperation = null;
501
- return status();
502
- }
503
- );
504
- return disposeOperation;
505
- }
506
- };
507
- return provider;
508
- }
1
+ // Published compatibility name; the TWiN Cloud provider owns the implementation.
2
+ export {createTwinCloudTTSProvider as createDigitalOceanFalTTSProvider} from './twin-cloud-speech.mjs';