arcane-os 0.48.0 → 0.49.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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.49.0
4
+
5
+ - Queue complete TWiN Cloud/FAL synthesis jobs through credential readiness,
6
+ submission, remote completion and the complete audio response. Excess jobs
7
+ wait in order; cancellation removes waiting work before submission. Preserve
8
+ independent follow-up HTTP concurrency and dispatch cadence, full input,
9
+ caller cancellation and original-order playback.
10
+ - Queue initial POST attempts and eligible retries with a shared HTTP 429
11
+ cooldown. Preserve the bounded readable-rejection retry and never replay an
12
+ ambiguous POST network, JSON or error-body failure. Existing provider factory,
13
+ compatibility alias and configuration remain supported.
14
+ - Expose optional ordinary Kokoro `model.voices` display records through the
15
+ synchronous provider catalog without loading or fetching a model. Applications
16
+ own their selected inventory and previews; metadata does not limit synthesis
17
+ voices or claim playback availability. Existing artifact-graph voice paths
18
+ retain their meaning. No new dependency, saved-data migration or default
19
+ provider change; native browser TTS is a separate subsequent increment.
20
+
3
21
  ## 0.48.0
4
22
 
5
23
  - Add opt-in Chat `transcriptionMode='draft'` and public `appendDraft(text)`.
package/README.md CHANGED
@@ -19,7 +19,7 @@ version-locked SDK runtime, while an integrated Arcane checkout uses its live
19
19
  `arcane/` runtime. Both profiles share the theme, packaging, event, cancellation,
20
20
  and browser run contracts while retaining their selected application layout.
21
21
 
22
- This checkout defines the `0.48.0` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.49.0` SDK contract. Applications pin one exact npm
23
23
  version and lockfile; registry state is deliberately not baked into application
24
24
  artifacts.
25
25
 
@@ -899,6 +899,9 @@ export function createBrowserSpeechAuthority({
899
899
  const dtype = model.dtype === undefined
900
900
  ? null
901
901
  : identifier(model.dtype, "Browser speech model dtype");
902
+ if (role === "tts" && model.voices !== undefined && !is.array(model.voices)) {
903
+ throw new TypeError("Browser Kokoro model voices must be an array.");
904
+ }
902
905
  const modelFiles = uniqueFiles(
903
906
  model.files ?? [],
904
907
  "Browser speech model",
@@ -967,6 +970,7 @@ export function createBrowserSpeechAuthority({
967
970
  revision: modelRevision,
968
971
  ...(dtype === null ? {} : { dtype }),
969
972
  defaultVoice: normalizedModel.defaultVoice,
973
+ ...(role === "tts" && model.voices !== undefined ? { voices: model.voices } : {}),
970
974
  runtime: completeValue({
971
975
  adapter: normalizedRuntime.adapter,
972
976
  version: normalizedRuntime.version,
@@ -1151,7 +1151,7 @@ function createBrowserSpeechProvider({
1151
1151
  ...(authority.dtype ? { dtype: authority.dtype } : {}),
1152
1152
  runtime: authority.runtime,
1153
1153
  files: authority.files,
1154
- ...(authority.graph ? {
1154
+ ...(authority.graph || authority.voices !== undefined ? {
1155
1155
  voices: authority.voices,
1156
1156
  } : {}),
1157
1157
  speech,
@@ -3,7 +3,7 @@ import Queue from '../dependencies/js-queue/queue.js';
3
3
 
4
4
  const is = new Is(false);
5
5
  const INVOKE_URL = 'https://inference.do-ai.run/v1/async-invoke';
6
- const SUBMISSION_RETRY_DELAY_MS = 1000;
6
+ const RATE_LIMIT_RETRY_DELAY_MS = 1000;
7
7
 
8
8
  function speechError(message, code, cause) {
9
9
  const error = cause === undefined
@@ -36,30 +36,6 @@ function assertIdentifier(value, label) {
36
36
  }
37
37
  }
38
38
 
39
- function waitForStatus(delay, signal) {
40
- assertNotAborted(signal);
41
- return new Promise(
42
- function waitForCloudSpeechStatus(resolve, reject) {
43
- const timer = setTimeout(finishStatusWait, delay);
44
- function finishStatusWait() {
45
- signal.removeEventListener('abort', cancelStatusWait);
46
- resolve();
47
- }
48
- function cancelStatusWait() {
49
- clearTimeout(timer);
50
- signal.removeEventListener('abort', cancelStatusWait);
51
- reject(abortedSpeech(signal));
52
- }
53
- signal.addEventListener(
54
- 'abort',
55
- cancelStatusWait,
56
- {once: true}
57
- );
58
- if (signal.aborted) cancelStatusWait();
59
- }
60
- );
61
- }
62
-
63
39
  function retryAfterDelay(response) {
64
40
  const value = response.headers?.get?.('Retry-After');
65
41
  if (!value?.trim()) return null;
@@ -69,11 +45,11 @@ function retryAfterDelay(response) {
69
45
  return is.finite(date) ? Math.max(0, date - Date.now()) : null;
70
46
  }
71
47
 
72
- async function requireSuccessfulResponse(response, signal, operation) {
73
- if (response.ok) return response;
48
+ async function readResponseError(response, signal, operation) {
49
+ if (response.ok) return null;
74
50
  const detail = await response.text();
75
51
  assertNotAborted(signal);
76
- throw speechError(
52
+ return speechError(
77
53
  `TWiN Cloud speech ${operation} failed with HTTP ${response.status}${detail ? `: ${detail}` : '.'}`,
78
54
  'ARCANE_AI_CLOUD_SPEECH_HTTP_ERROR'
79
55
  );
@@ -83,8 +59,8 @@ async function requireSuccessfulResponse(response, signal, operation) {
83
59
  * A remote provider/2 TTS adapter. The application selects the model and voice,
84
60
  * and getApiKey returns its current credential or the application's existing
85
61
  * refresh promise. Credentials never enter status or configuration. The runtime
86
- * owns whole-job admission and playback order. This provider owns immediate
87
- * submissions and one configurable queue for follow-up HTTP/body operations.
62
+ * owns playback order. This provider queues whole synthesis jobs, submission
63
+ * attempts, and follow-up HTTP/body operations under one shared 429 cooldown.
88
64
  */
89
65
  export function createTwinCloudTTSProvider({
90
66
  id,
@@ -115,18 +91,132 @@ export function createTwinCloudTTSProvider({
115
91
 
116
92
  const modelId = model.id;
117
93
  const defaultVoice = model.defaultVoice;
94
+ const requests = new Set();
118
95
  const activeRequests = new Set();
119
96
  const credentialWaits = new Set();
120
97
  let state = 'unloaded';
121
98
  let generation = 0;
122
99
  let unloadOperation = null;
123
100
  let disposeOperation = null;
101
+ const jobs = new Queue();
102
+ jobs.autoRun = false;
103
+ const submissions = new Queue();
104
+ submissions.autoRun = false;
124
105
  const followUps = new Queue();
125
106
  followUps.autoRun = false;
107
+ let cooldownUntil = 0;
108
+ let submissionTimer = null;
126
109
  let activeFollowUps = 0;
127
110
  let nextFollowUpStart = 0;
128
111
  let followUpTimer = null;
129
112
 
113
+ function dispatchJobs() {
114
+ while (state === 'ready' && activeRequests.size < maxConcurrentRequests && jobs.size) {
115
+ jobs.next();
116
+ }
117
+ }
118
+
119
+ function queueSynthesis(request, payload, requestGeneration) {
120
+ const signal = request.controller.signal;
121
+ assertNotAborted(signal);
122
+ return new Promise(
123
+ function enqueueCloudSpeechJob(resolve, reject) {
124
+ function cancelQueuedJob() {
125
+ jobs.contents = jobs.contents.filter(
126
+ function keepOtherJob(task) {return task !== startJob;}
127
+ );
128
+ signal.removeEventListener('abort', cancelQueuedJob);
129
+ reject(abortedSpeech(signal));
130
+ dispatchJobs();
131
+ }
132
+ function finishJob() {
133
+ activeRequests.delete(request);
134
+ dispatchJobs();
135
+ }
136
+ function startJob() {
137
+ signal.removeEventListener('abort', cancelQueuedJob);
138
+ activeRequests.add(request);
139
+ // A slot belongs to the entire synthesis, including its
140
+ // accepted remote job, every read, and the complete audio.
141
+ synthesize(payload, request.controller, requestGeneration).then(
142
+ function acceptCloudSpeechJob(audio) {
143
+ finishJob();
144
+ resolve(audio);
145
+ },
146
+ function rejectCloudSpeechJob(error) {
147
+ finishJob();
148
+ reject(error);
149
+ }
150
+ );
151
+ }
152
+ jobs.add(startJob);
153
+ signal.addEventListener(
154
+ 'abort',
155
+ cancelQueuedJob,
156
+ {once: true}
157
+ );
158
+ if (signal.aborted) cancelQueuedJob();
159
+ else dispatchJobs();
160
+ }
161
+ );
162
+ }
163
+
164
+ function dispatchSubmissions() {
165
+ clearTimeout(submissionTimer);
166
+ submissionTimer = null;
167
+ if (state !== 'ready' || !submissions.size) return;
168
+ const delay = cooldownUntil - Date.now();
169
+ if (delay > 0) {
170
+ submissionTimer = setTimeout(dispatchSubmissions, Math.min(2147483647, delay));
171
+ return;
172
+ }
173
+ // Each admitted job awaits its current attempt before requeueing, so
174
+ // whole-job capacity also bounds concurrent submission attempts.
175
+ while (state === 'ready' && submissions.size) submissions.next();
176
+ }
177
+
178
+ function queueSubmission(readResponse, signal) {
179
+ assertNotAborted(signal);
180
+ return new Promise(
181
+ function enqueueCloudSpeechSubmission(resolve, reject) {
182
+ function cancelQueuedSubmission() {
183
+ submissions.contents = submissions.contents.filter(
184
+ function keepOtherSubmission(task) {return task !== startSubmission;}
185
+ );
186
+ signal.removeEventListener('abort', cancelQueuedSubmission);
187
+ reject(abortedSpeech(signal));
188
+ dispatchSubmissions();
189
+ }
190
+ async function consumeSubmission() {
191
+ assertNotAborted(signal);
192
+ return await readResponse();
193
+ }
194
+ function startSubmission() {
195
+ signal.removeEventListener('abort', cancelQueuedSubmission);
196
+ consumeSubmission().then(resolve, reject);
197
+ }
198
+ submissions.add(startSubmission);
199
+ signal.addEventListener(
200
+ 'abort',
201
+ cancelQueuedSubmission,
202
+ {once: true}
203
+ );
204
+ if (signal.aborted) cancelQueuedSubmission();
205
+ else dispatchSubmissions();
206
+ }
207
+ );
208
+ }
209
+
210
+ function observeRateLimit(response) {
211
+ if (response.status !== 429) return;
212
+ cooldownUntil = Math.max(
213
+ cooldownUntil,
214
+ Date.now() + Math.max(RATE_LIMIT_RETRY_DELAY_MS, retryAfterDelay(response) ?? 0)
215
+ );
216
+ dispatchSubmissions();
217
+ dispatchFollowUps();
218
+ }
219
+
130
220
  function dispatchFollowUps() {
131
221
  clearTimeout(followUpTimer);
132
222
  followUpTimer = null;
@@ -136,18 +226,20 @@ export function createTwinCloudTTSProvider({
136
226
  const eligibleIndex = followUps.contents.findIndex(
137
227
  function eligibleFollowUp(task) {return task.eligibleAt <= now;}
138
228
  );
139
- if (now < nextFollowUpStart || eligibleIndex === -1) {
229
+ const nextStart = Math.max(nextFollowUpStart, cooldownUntil);
230
+ if (now < nextStart || eligibleIndex === -1) {
140
231
  const firstEligibility = followUps.contents.reduce(
141
232
  function earliestFollowUp(time, task) {return Math.min(time, task.eligibleAt);},
142
233
  Infinity
143
234
  );
144
235
  // Long Retry-After dates may exceed the platform timer's range.
145
236
  // Revisit at that boundary without shortening the job's eligibility.
146
- followUpTimer = setTimeout(dispatchFollowUps, Math.min(2147483647, Math.max(nextFollowUpStart, firstEligibility) - now));
237
+ followUpTimer = setTimeout(dispatchFollowUps, Math.min(2147483647, Math.max(nextStart, firstEligibility) - now));
147
238
  return;
148
239
  }
149
- // A job's Retry-After does not hold up eligible siblings. Queue remains
150
- // the sole pending-task owner, with FIFO order among eligible entries.
240
+ // Successful pending responses retain job-local Retry-After timing.
241
+ // HTTP 429 also applies the shared cooldown before any queued start.
242
+ // Queue owns pending tasks in FIFO order among eligible entries.
151
243
  if (eligibleIndex > 0) {
152
244
  const [eligibleTask] = followUps.contents.splice(eligibleIndex, 1);
153
245
  followUps.contents.unshift(eligibleTask);
@@ -287,7 +379,7 @@ export function createTwinCloudTTSProvider({
287
379
  localOnly: false,
288
380
  state,
289
381
  loaded,
290
- busy: activeRequests.size > 0,
382
+ busy: requests.size > 0,
291
383
  execution: {
292
384
  requestedDevice: 'remote',
293
385
  selectedDevice: loaded ? 'remote' : null,
@@ -310,11 +402,12 @@ export function createTwinCloudTTSProvider({
310
402
  // an identified job's status read can retry a network failure.
311
403
  return {error, retryable: options.method === 'GET' && error instanceof TypeError};
312
404
  }
405
+ assertNotAborted(signal);
406
+ observeRateLimit(response);
313
407
  const nextEligibility = Date.now() + (retryAfterDelay(response) ?? 0);
314
- try {
315
- await requireSuccessfulResponse(response, signal, operation);
316
- } catch (error) {
317
- assertNotAborted(signal);
408
+ const error = await readResponseError(response, signal, operation);
409
+ assertNotAborted(signal);
410
+ if (error) {
318
411
  return {error, retryable: response.status === 429, eligibleAt: nextEligibility};
319
412
  }
320
413
  // Parsing errors are not Fetch failures and must not replay a job.
@@ -325,7 +418,7 @@ export function createTwinCloudTTSProvider({
325
418
  while (true) {
326
419
  assertNotAborted(signal);
327
420
  const outcome = options.method === 'POST'
328
- ? await readInferenceResponse()
421
+ ? await queueSubmission(readInferenceResponse, signal)
329
422
  : await queueFollowUp(readInferenceResponse, signal, eligibleAt);
330
423
  assertNotAborted(signal);
331
424
  if (!outcome.error) return outcome;
@@ -333,11 +426,6 @@ export function createTwinCloudTTSProvider({
333
426
  retryError = outcome.error;
334
427
  console.warn(`Retrying the cloud speech ${operation} once.`, retryError);
335
428
  eligibleAt = outcome.eligibleAt ?? 0;
336
- if (options.method === 'POST') {
337
- // A readable submission rejection retains its own bounded retry
338
- // delay. It never delays another job or enters the GET queue.
339
- await waitForStatus(Math.max(SUBMISSION_RETRY_DELAY_MS, eligibleAt - Date.now()), signal);
340
- }
341
429
  }
342
430
  }
343
431
 
@@ -402,7 +490,11 @@ export function createTwinCloudTTSProvider({
402
490
  const audio = await queueFollowUp(
403
491
  async function downloadCloudSpeechAudio() {
404
492
  const audioResponse = await fetchImpl(result.output.audio.url, {signal});
405
- await requireSuccessfulResponse(audioResponse, signal, 'audio download');
493
+ assertNotAborted(signal);
494
+ observeRateLimit(audioResponse);
495
+ const error = await readResponseError(audioResponse, signal, 'audio download');
496
+ assertNotAborted(signal);
497
+ if (error) throw error;
406
498
  return await audioResponse.blob();
407
499
  },
408
500
  signal,
@@ -491,9 +583,6 @@ export function createTwinCloudTTSProvider({
491
583
  if (state !== 'ready' || unloadOperation || disposeOperation) {
492
584
  throw speechError('The cloud speech provider is not ready.', 'ARCANE_AI_NOT_READY');
493
585
  }
494
- if (activeRequests.size >= maxConcurrentRequests) {
495
- throw speechError('The cloud speech provider is at capacity.', 'ARCANE_AI_PROVIDER_BUSY');
496
- }
497
586
  const payload = context.payload;
498
587
  if (!is.string(payload?.input) || !payload.input.trim()) {
499
588
  throw new TypeError('Cloud speech input must be a nonempty string.');
@@ -523,16 +612,16 @@ export function createTwinCloudTTSProvider({
523
612
  );
524
613
  if (context.signal?.aborted) cancelCloudSpeechRequest();
525
614
  const request = {controller, promise: null};
526
- activeRequests.add(request);
527
- request.promise = synthesize(payload, controller, generation);
615
+ requests.add(request);
528
616
  try {
617
+ request.promise = queueSynthesis(request, payload, generation);
529
618
  return await request.promise;
530
619
  } catch (error) {
531
620
  if (controller.signal.aborted) throw abortedSpeech(controller.signal);
532
621
  throw error;
533
622
  } finally {
534
623
  context.signal?.removeEventListener('abort', cancelCloudSpeechRequest);
535
- activeRequests.delete(request);
624
+ requests.delete(request);
536
625
  }
537
626
  },
538
627
 
@@ -546,11 +635,15 @@ export function createTwinCloudTTSProvider({
546
635
  if (unloadOperation) return unloadOperation;
547
636
  state = 'unloading';
548
637
  generation += 1;
638
+ clearTimeout(submissionTimer);
639
+ submissionTimer = null;
640
+ clearTimeout(followUpTimer);
641
+ followUpTimer = null;
549
642
  for (const cancelCredentialWait of [...credentialWaits]) cancelCredentialWait();
550
- const requests = [...activeRequests];
551
- for (const request of requests) request.controller.abort('Cloud speech provider unloaded.');
643
+ const pendingRequests = [...requests];
644
+ for (const request of pendingRequests) request.controller.abort('Cloud speech provider unloaded.');
552
645
  unloadOperation = Promise.allSettled(
553
- requests.map(
646
+ pendingRequests.map(
554
647
  function pendingCloudSpeechRequest(request) {
555
648
  return request.promise;
556
649
  }
@@ -138,11 +138,13 @@ playback remain the only owners. Use
138
138
  punctuation boundary should submit promptly. This is incremental playback of
139
139
  completed synthesis jobs, not a live audio stream from TWiN Cloud.
140
140
 
141
- The SDK submits `POST https://inference.do-ai.run/v1/async-invoke` immediately
142
- after credential readiness, outside the follow-up queue. It then reads that job
143
- until its completed `output.audio.url` is available. The provider's
144
- `maxConcurrentRequests` retains its whole-synthesis-job meaning; it is separate
145
- from `followUpQueue.maxConcurrentRequests`.
141
+ The provider queues each complete synthesis job, including initial
142
+ `POST https://inference.do-ai.run/v1/async-invoke` and any eligible submission
143
+ retry. `maxConcurrentRequests` defaults to four whole jobs. Each slot remains
144
+ occupied through credential readiness, submission, queued/running status, and
145
+ the complete final audio response. Additional requests wait in order; cancelling
146
+ a waiting job removes it before credential reading or submission. This capacity
147
+ is separate from `followUpQueue.maxConcurrentRequests`.
146
148
 
147
149
  One provider-local `js-queue` owns only follow-up status/result GETs and the final
148
150
  audio GET. `followUpQueue` defaults to `{maxConcurrentRequests:4,intervalMs:250}`;
@@ -153,20 +155,31 @@ job awaits its current response and body before immediately enqueueing another
153
155
  request only when incomplete or eligible for a retry. There is no additional
154
156
  per-job polling delay.
155
157
 
156
- A readable `Retry-After` changes only the affected job's next eligibility, not
157
- sibling requests or initial submissions. A readable `429` permits one retry of
158
- that submission or status read; submission retries retain a one-second minimum, while
159
- follow-up retries use the queue cadence and any longer readable `Retry-After`.
158
+ A successful pending response's `Retry-After` changes that job's next eligibility.
159
+ A `429` applies a shared cooldown to all queued submission and follow-up starts,
160
+ using a one-second minimum and any longer readable `Retry-After`. A completely
161
+ readable `429` permits one retry of that submission or status read, within the
162
+ same whole-job slot. Already dispatched operations retain their response ownership.
160
163
  An accepted job's status Fetch network failure may retry once for the same job;
161
- JSON parsing failures are not network retries. Repeated failures surface their
164
+ Ambiguous POST network, JSON parsing, and error-body read failures never replay
165
+ the submission. Repeated failures surface their
162
166
  complete diagnostics without an endless retry loop; a failed job does not cancel
163
167
  siblings. Retry diagnostics remain in the developer console, outside conversation
164
168
  history.
165
169
 
166
- The queue is created when this cloud factory is called, not when the browser
170
+ The provider-local queues are created when this cloud factory is called, not when the browser
167
171
  speech entrypoint is imported. Local Kokoro and Whisper do not use this queue;
168
172
  their setup, Workers, synthesis/transcription, and lifecycle remain independent.
169
173
 
174
+ Ordinary Kokoro descriptors may supply `model.voices` as complete display records,
175
+ for example `{id:'af_heart',name:'Heart',lang:'en-US'}`. The synchronous
176
+ `provider.catalog()[0].voices` preserves that array and its metadata without
177
+ loading the model or fetching a catalog. The application supplies the inventory
178
+ for its selected runtime; these records neither create a synthesis allow-list
179
+ nor establish that a voice has loaded or played. Ordinary Worker configuration
180
+ does not receive this display inventory. Existing artifact-graph voice paths
181
+ retain their separate meaning.
182
+
170
183
  The SDK pins published `js-queue` through npm. Its browser Queue entry, original
171
184
  package metadata, and license are projected unchanged by
172
185
  `node tools/sync-js-queue.mjs` after an SDK dependency update. The existing runtime
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.48.0",
3
+ "version": "0.49.0",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",