arcane-os 0.93.0 → 0.95.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +1 -1
  3. package/browser-runtime/ai/twin-cloud.mjs +109 -46
  4. package/browser-runtime/core/client.mjs +197 -0
  5. package/docs/reference/ai/twin-cloud.md +35 -14
  6. package/docs/reference/availability-and-normalization.md +1 -1
  7. package/docs/reference/inventory/package-api.json +96 -12
  8. package/docs/reference/local-ai.md +81 -5
  9. package/docs/reference/native-magpie.md +98 -0
  10. package/docs/reference/native-nemotron.md +204 -0
  11. package/docs/reference/native-speech.md +364 -11
  12. package/docs/reference/runtime-modules.md +21 -18
  13. package/docs/reference/sdk-api.md +152 -11
  14. package/package.json +6 -1
  15. package/runtime/arcane/modules/AI.js +114 -14
  16. package/runtime/arcane/modules/AIProviderRuntime.js +32 -15
  17. package/runtime/arcane/modules/ContinuousVoiceCapture.js +118 -21
  18. package/runtime/arcane/modules/NativeSpeechCapture.js +198 -0
  19. package/runtime/arcane/modules/NativeSpeechPlayback.js +304 -0
  20. package/runtime/arcane/modules/VoiceCaptureWorklet.js +62 -2
  21. package/schemas/arcane-app.schema.json +2 -2
  22. package/schemas/arcane-package.schema.json +2 -2
  23. package/src/core/services/speech.mjs +320 -25
  24. package/src/local-ai/config.mjs +12 -1
  25. package/src/local-ai/dev.mjs +71 -4
  26. package/src/local-ai/install.mjs +113 -5
  27. package/src/local-ai/kokoro/frontend.mjs +219 -0
  28. package/src/local-ai/kokoro/helper.mjs +168 -0
  29. package/src/local-ai/kokoro/index.mjs +645 -0
  30. package/src/local-ai/kokoro/native/CMakeLists.txt +45 -0
  31. package/src/local-ai/kokoro/native/main.cpp +249 -0
  32. package/src/local-ai/magpie/build.mjs +36 -0
  33. package/src/local-ai/magpie/helper.mjs +254 -0
  34. package/src/local-ai/magpie/index.mjs +488 -0
  35. package/src/local-ai/magpie/native/CMakeLists.txt +10 -0
  36. package/src/local-ai/magpie/native/main.cpp +270 -0
  37. package/src/local-ai/nemotron/build.mjs +53 -0
  38. package/src/local-ai/nemotron/helper.mjs +308 -0
  39. package/src/local-ai/nemotron/index.mjs +637 -0
  40. package/src/local-ai/nemotron/native/CMakeLists.txt +17 -0
  41. package/src/local-ai/nemotron/native/main.cpp +282 -0
  42. package/src/native/portable-layout.mjs +67 -9
@@ -44,8 +44,8 @@ function waitForLoad(task, signal) {
44
44
  /** Hosts supply selected engines; this service owns their independent lifetimes. */
45
45
  export function createSpeechService({stt, tts, signal} = {}) {
46
46
  const roles = {
47
- stt: {engine: stt, operation: 'transcribe', state: null, loading: null, unsubscribe: null},
48
- tts: {engine: tts, operation: 'synthesize', state: null, loading: null, unsubscribe: null}
47
+ stt: {engine: stt, operation: 'transcribe', state: null, loading: null, changing: null, unsubscribe: null},
48
+ tts: {engine: tts, operation: 'synthesize', state: null, loading: null, changing: null, unsubscribe: null}
49
49
  };
50
50
  for (const [name, role] of Object.entries(roles)) {
51
51
  if (role.engine == null) {
@@ -68,6 +68,8 @@ export function createSpeechService({stt, tts, signal} = {}) {
68
68
  {source: 'core-speech', eventTypes: ['speech.state']}
69
69
  );
70
70
  const jobs = new Set();
71
+ const streams = new Map();
72
+ const syntheses = new Map();
71
73
  const publications = [];
72
74
  let context;
73
75
  let started = false;
@@ -91,8 +93,10 @@ export function createSpeechService({stt, tts, signal} = {}) {
91
93
  ttsEngine: roles.tts.state.providerId ?? '',
92
94
  sttEngine: roles.stt.state.providerId ?? '',
93
95
  roles: {
94
- stt: {...roles.stt.state, available: transcriptionAvailable},
95
- tts: {...roles.tts.state, available: synthesisAvailable}
96
+ stt: {...roles.stt.state, available: transcriptionAvailable,
97
+ supportsStreaming: is.function(roles.stt.engine?.openStream)},
98
+ tts: {...roles.tts.state, available: synthesisAvailable,
99
+ supportsStreaming: is.function(roles.tts.engine?.synthesizeStream)}
96
100
  },
97
101
  closed
98
102
  };
@@ -124,6 +128,36 @@ export function createSpeechService({stt, tts, signal} = {}) {
124
128
  publish();
125
129
  }
126
130
 
131
+ function observeRoleTask(name, task, startup = false) {
132
+ const role = roles[name];
133
+ role.changing = task;
134
+ // Explicit load cancellation belongs to that caller's wait, not another
135
+ // inference request. Only the service-owned startup is a shared wait.
136
+ role.loading = startup ? task : null;
137
+ jobs.add(task);
138
+ function settled(error, failed) {
139
+ jobs.delete(task);
140
+ // An older startup or activation must not replace the selected state.
141
+ if (role.loading === task) role.loading = null;
142
+ if (role.changing !== task) return;
143
+ role.changing = null;
144
+ if (closed) return;
145
+ if (failed && startup && !closing) {
146
+ roleFailed(name, error);
147
+ } else {
148
+ role.state = role.engine.current();
149
+ publish();
150
+ }
151
+ }
152
+ task.then(
153
+ function speechRoleSettled() { settled(undefined, false); },
154
+ function speechRoleFailed(error) { settled(error, true); }
155
+ ).catch(function speechRoleObserverFailed(error) {
156
+ console.error('Speech role state observer failed.', error);
157
+ });
158
+ return task;
159
+ }
160
+
127
161
  function startRole(name) {
128
162
  const role = roles[name];
129
163
  if (!role.engine || closing) {
@@ -142,34 +176,60 @@ export function createSpeechService({stt, tts, signal} = {}) {
142
176
  roleFailed(name, error);
143
177
  return;
144
178
  }
179
+ // Replay can synchronously select an activation or close the service.
180
+ if (role.changing || closing || closed) return;
145
181
  const task = Promise.resolve().then(
146
182
  async function loadSpeechEngine() {
147
183
  lifetimeSignal.throwIfAborted();
148
- await role.engine.load(
184
+ if (role.changing !== task) return;
185
+ return role.engine.load(
149
186
  {signal: lifetimeSignal}
150
187
  );
151
- if (!closed) {
152
- role.state = role.engine.current();
153
- publish();
154
- }
155
- }
156
- );
157
- role.loading = task;
158
- task.then(
159
- function speechEngineLoaded() {
160
- role.loading = null;
161
- },
162
- function speechEngineLoadFailed(error) {
163
- role.loading = null;
164
- if (!closing) {
165
- roleFailed(name, error);
166
- }
167
- }
168
- ).catch(
169
- function speechLoadObserverFailed(error) {
170
- console.error('Speech load state observer failed.', error);
171
188
  }
172
189
  );
190
+ observeRoleTask(name, task, true);
191
+ }
192
+
193
+ function changeRole(operation, parameters, request) {
194
+ if (closing || closed) {
195
+ throw failure('CORE_CLOSING', 'The speech service is closing.');
196
+ }
197
+ if (!parameters || !is.object(parameters) || is.array(parameters)
198
+ || !['stt', 'tts'].includes(parameters.role)) {
199
+ throw failure('SPEECH_ROLE_INVALID', 'Select the stt or tts speech role.');
200
+ }
201
+ const {role: name, ...selection} = parameters;
202
+ const role = roles[name];
203
+ const operationSignal = request.signal
204
+ ? AbortSignal.any([lifetimeSignal, request.signal]) : lifetimeSignal;
205
+ operationSignal.throwIfAborted();
206
+ if (!role.engine) {
207
+ throw failure('SPEECH_ENGINE_UNAVAILABLE', `No ${name} speech engine is configured.`);
208
+ }
209
+ if (!is.function(role.engine[operation])) {
210
+ throw failure('SPEECH_OPERATION_UNAVAILABLE', `The ${name} speech engine does not support ${operation}().`);
211
+ }
212
+ let acknowledgementFailed = false;
213
+ let acknowledgementError;
214
+ const task = Promise.resolve().then(async function changeSpeechRole() {
215
+ if (acknowledgementFailed) throw acknowledgementError;
216
+ operationSignal.throwIfAborted();
217
+ const result = await role.engine[operation]({...selection, signal: operationSignal});
218
+ operationSignal.throwIfAborted();
219
+ return result;
220
+ });
221
+ // Retain ownership before an acknowledgement observer can close or reenter.
222
+ observeRoleTask(name, task);
223
+ try {
224
+ request.emit?.('speech.progress', {
225
+ requestId: request.requestId, role: name, operation,
226
+ status: 'Thinking', progress: {phase: 'accepted'}
227
+ });
228
+ } catch (error) {
229
+ acknowledgementFailed = true;
230
+ acknowledgementError = error;
231
+ }
232
+ return task;
173
233
  }
174
234
 
175
235
  function invoke(name, parameters, request) {
@@ -229,6 +289,201 @@ export function createSpeechService({stt, tts, signal} = {}) {
229
289
  }
230
290
  }
231
291
 
292
+ function streamRole(name, operation, request, streamId) {
293
+ if (closing || closed) throw failure('CORE_CLOSING', 'The speech service is closing.');
294
+ const role = roles[name];
295
+ if (!is.function(role.engine?.[operation])) {
296
+ throw failure('SPEECH_OPERATION_UNAVAILABLE', `The ${name} speech engine does not support ${operation}().`);
297
+ }
298
+ const operationSignal = request.signal
299
+ ? AbortSignal.any([lifetimeSignal, request.signal]) : lifetimeSignal;
300
+ operationSignal.throwIfAborted();
301
+ request.emit?.('speech.progress', {
302
+ streamId, requestId: request.requestId, role: name, status: 'Thinking', progress: {phase: 'accepted'}
303
+ });
304
+ return {role, operationSignal};
305
+ }
306
+
307
+ function retainStreamTask(task) {
308
+ jobs.add(task);
309
+ function releaseSpeechStreamTask() { jobs.delete(task); }
310
+ task.then(releaseSpeechStreamTask, releaseSpeechStreamTask);
311
+ return task;
312
+ }
313
+
314
+ function openStream({streamId, options = {}} = {}, request) {
315
+ const activation = streamRole('stt', 'openStream', request, streamId);
316
+ const role = activation.role;
317
+ const controller = new AbortController();
318
+ const operationSignal = AbortSignal.any([activation.operationSignal, controller.signal]);
319
+ if (!is.string(streamId) || !streamId) throw new TypeError('A speech stream requires a streamId.');
320
+ if (streams.has(streamId)) throw failure('SPEECH_STREAM_ACTIVE', 'The speech stream is already open.');
321
+ const record = {stream: null, controller, queue: Promise.resolve(), ending: false, cancellation: null, task: null};
322
+ streams.set(streamId, record);
323
+
324
+ function cancelNativeStream() {
325
+ record.ending = true;
326
+ if (record.stream && !record.cancellation) {
327
+ record.cancellation = Promise.resolve().then(function cancelOwnedNativeStream() {
328
+ return record.stream.cancel();
329
+ });
330
+ }
331
+ return record.cancellation;
332
+ }
333
+ function abortedStream() {
334
+ cancelNativeStream()?.catch(function reportNativeStreamCancellation(error) {
335
+ console.error('Native speech stream cancellation failed.', error);
336
+ });
337
+ }
338
+ record.cancel = cancelNativeStream;
339
+ operationSignal.addEventListener('abort', abortedStream, {once: true});
340
+ const task = (async function ownTranscriptionStream() {
341
+ try {
342
+ if (role.loading) await waitForLoad(role.loading, operationSignal);
343
+ operationSignal.throwIfAborted();
344
+ record.stream = await role.engine.openStream({
345
+ ...options,
346
+ signal: operationSignal,
347
+ onUpdate: function reportTranscriptionUpdate(result) {
348
+ if (!operationSignal.aborted) request.emit?.('speech.transcription', {streamId, result});
349
+ }
350
+ });
351
+ if (operationSignal.aborted) {
352
+ await cancelNativeStream();
353
+ operationSignal.throwIfAborted();
354
+ }
355
+ request.emit?.('speech.opened', {
356
+ streamId,
357
+ ...(options.sampleRate === undefined ? {} : {sampleRate: options.sampleRate})
358
+ });
359
+ const result = await record.stream.completion;
360
+ operationSignal.throwIfAborted();
361
+ return result;
362
+ } catch (error) {
363
+ try { await cancelNativeStream(); } catch (cleanupError) {
364
+ throw new AggregateError([error, cleanupError], 'Speech stream and cleanup failed.');
365
+ }
366
+ throw error;
367
+ } finally {
368
+ operationSignal.removeEventListener('abort', abortedStream);
369
+ if (streams.get(streamId) === record) streams.delete(streamId);
370
+ }
371
+ })();
372
+ record.task = task;
373
+ return retainStreamTask(task);
374
+ }
375
+
376
+ function streamOperation(operation, {streamId, audio} = {}, request) {
377
+ request.signal?.throwIfAborted();
378
+ const record = streams.get(streamId);
379
+ if (operation === 'cancel') {
380
+ if (!record) return {streamId, cancelled: true};
381
+ record.controller.abort();
382
+ return record.task.then(
383
+ function streamFinishedBeforeCancellation() { return {streamId, cancelled: true}; },
384
+ function streamCancellationSettled(error) {
385
+ if (error?.name !== 'AbortError') throw error;
386
+ return {streamId, cancelled: true};
387
+ }
388
+ );
389
+ }
390
+ if (!record?.stream) throw failure('SPEECH_STREAM_UNAVAILABLE', 'The speech stream is not open.');
391
+ if (record.ending) throw failure('SPEECH_STREAM_ENDING', 'The speech stream is finishing.');
392
+ if (operation === 'finish') record.ending = true;
393
+ const task = record.queue.then(function performSpeechStreamOperation() {
394
+ request.signal?.throwIfAborted();
395
+ if (operation === 'push') return record.stream.push(decodeFloatAudio(audio));
396
+ return record.stream[operation]();
397
+ });
398
+ record.queue = task;
399
+ task.catch(function cancelFailedStreamOperation() {
400
+ record.cancel()?.catch(function reportStreamOperationCleanup(error) {
401
+ console.error('Failed speech stream cleanup failed.', error);
402
+ });
403
+ });
404
+ return retainStreamTask(task);
405
+ }
406
+
407
+ function synthesizeStream({streamId, request: payload} = {}, request) {
408
+ const activation = streamRole('tts', 'synthesizeStream', request, streamId);
409
+ const role = activation.role;
410
+ const controller = new AbortController();
411
+ const operationSignal = AbortSignal.any([activation.operationSignal, controller.signal]);
412
+ if (!is.string(streamId) || !streamId) throw new TypeError('Speech synthesis requires a streamId.');
413
+ if (syntheses.has(streamId)) throw failure('SPEECH_STREAM_ACTIVE', 'The speech synthesis is already active.');
414
+ const record = {controller, task: null, sequence: 0, acknowledgements: new Map()};
415
+ syntheses.set(streamId, record);
416
+ function cancelAudioDelivery() {
417
+ for (const acknowledgement of record.acknowledgements.values()) acknowledgement.reject(operationSignal.reason);
418
+ record.acknowledgements.clear();
419
+ }
420
+ operationSignal.addEventListener('abort', cancelAudioDelivery, {once: true});
421
+ const task = (async function ownSynthesisStream() {
422
+ try {
423
+ if (role.loading) await waitForLoad(role.loading, operationSignal);
424
+ operationSignal.throwIfAborted();
425
+ const result = await role.engine.synthesizeStream(payload, {
426
+ signal: operationSignal,
427
+ requestId: request.requestId,
428
+ onProgress: function reportSynthesisProgress(progress) {
429
+ if (!operationSignal.aborted) request.emit?.('speech.progress', {
430
+ streamId, requestId: request.requestId, role: 'tts', status: 'Thinking', progress
431
+ });
432
+ },
433
+ onAudio: async function reportSynthesisAudio(audio) {
434
+ operationSignal.throwIfAborted();
435
+ const sequence = ++record.sequence;
436
+ const consumed = new Promise(function waitForAudioConsumer(resolve, reject) {
437
+ record.acknowledgements.set(sequence, {resolve, reject});
438
+ });
439
+ // The native producer waits here, never the event parser.
440
+ // Cancellation releases this wait before joining native work.
441
+ try {
442
+ const pcm = audio.pcm;
443
+ const encoded = Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength);
444
+ request.emit?.('speech.audio', {
445
+ streamId, sequence,
446
+ audio: {...audio, pcm: {encoding: 'base64', data: encoded.toString('base64')}}
447
+ });
448
+ await consumed;
449
+ operationSignal.throwIfAborted();
450
+ } finally {
451
+ record.acknowledgements.delete(sequence);
452
+ }
453
+ }
454
+ });
455
+ operationSignal.throwIfAborted();
456
+ return result;
457
+ } finally {
458
+ operationSignal.removeEventListener('abort', cancelAudioDelivery);
459
+ if (syntheses.get(streamId) === record) syntheses.delete(streamId);
460
+ }
461
+ })();
462
+ record.task = task;
463
+ return retainStreamTask(task);
464
+ }
465
+
466
+ function synthesisControl(operation, {streamId, sequence} = {}, request) {
467
+ request.signal?.throwIfAborted();
468
+ const record = syntheses.get(streamId);
469
+ if (operation === 'cancel') {
470
+ if (!record) return {streamId, cancelled: true};
471
+ record.controller.abort();
472
+ return record.task.then(
473
+ function synthesisFinishedBeforeCancellation() { return {streamId, cancelled: true}; },
474
+ function synthesisCancellationSettled(error) {
475
+ if (error !== record.controller.signal.reason) throw error;
476
+ return {streamId, cancelled: true};
477
+ }
478
+ );
479
+ }
480
+ const acknowledgement = record?.acknowledgements.get(sequence);
481
+ if (!acknowledgement) throw failure('SPEECH_AUDIO_UNAVAILABLE', 'That speech audio contribution is not awaiting consumption.');
482
+ record.acknowledgements.delete(sequence);
483
+ acknowledgement.resolve();
484
+ return {streamId, sequence};
485
+ }
486
+
232
487
  function subscribe(listener, {replay = true, signal: subscriptionSignal} = {}) {
233
488
  if (!is.function(listener)) {
234
489
  throw new TypeError('A speech state listener must be a function.');
@@ -340,11 +595,39 @@ export function createSpeechService({stt, tts, signal} = {}) {
340
595
  },
341
596
  methods: {
342
597
  'speech.status': current,
598
+ 'speech.load': function load(parameters, request = {}) {
599
+ return changeRole('load', parameters, request);
600
+ },
601
+ 'speech.unload': function unload(parameters, request = {}) {
602
+ return changeRole('unload', parameters, request);
603
+ },
343
604
  'speech.transcribe': function transcribe(parameters, request = {}) {
344
605
  return invoke('stt', parameters, request);
345
606
  },
346
607
  'speech.synthesize': function synthesize(parameters, request = {}) {
347
608
  return invoke('tts', parameters, request);
609
+ },
610
+ // The open call stays pending so normal connection cancellation owns
611
+ // the native stream; controls never extend it to service lifetime.
612
+ 'speech.open': openStream,
613
+ 'speech.push': function push(parameters, request) {
614
+ return streamOperation('push', parameters, request);
615
+ },
616
+ 'speech.endpoint': function endpoint(parameters, request) {
617
+ return streamOperation('forceEndpoint', parameters, request);
618
+ },
619
+ 'speech.finish': function finish(parameters, request) {
620
+ return streamOperation('finish', parameters, request);
621
+ },
622
+ 'speech.cancel': function cancel(parameters, request) {
623
+ return streamOperation('cancel', parameters, request);
624
+ },
625
+ 'speech.synthesizeStream': synthesizeStream,
626
+ 'speech.audioConsumed': function audioConsumed(parameters, request) {
627
+ return synthesisControl('consumed', parameters, request);
628
+ },
629
+ 'speech.cancelSynthesis': function cancelSynthesis(parameters, request) {
630
+ return synthesisControl('cancel', parameters, request);
348
631
  }
349
632
  },
350
633
  drain: close,
@@ -358,4 +641,16 @@ export function createSpeechService({stt, tts, signal} = {}) {
358
641
  };
359
642
  }
360
643
 
644
+ function decodeFloatAudio(audio) {
645
+ if (audio?.encoding !== 'f32le' || !is.string(audio.data)) {
646
+ throw new TypeError('Core streaming audio requires {encoding: "f32le", data: BASE64}.');
647
+ }
648
+ const encoded = Buffer.from(audio.data, 'base64');
649
+ if (encoded.length % 4 !== 0) throw new TypeError('The f32le transport ends during a sample.');
650
+ const samples = new Float32Array(encoded.length / 4);
651
+ const bits = new Uint32Array(samples.buffer);
652
+ for (let index = 0; index < samples.length; index += 1) bits[index] = encoded.readUInt32LE(index * 4);
653
+ return samples;
654
+ }
655
+
361
656
  export default createSpeechService;
@@ -8,7 +8,17 @@ import {SDXL_BASE_1_0_MODEL} from './image-models/sdxl.mjs';
8
8
  import {normalizeWhisperRuntimeRequirement} from './whisper/config.mjs';
9
9
 
10
10
  const is = new Is(false);
11
- export const LOCAL_AI_RUNTIME_IDS = ['llama.cpp', 'ollama', 'onnx', 'stable-diffusion.cpp', 'whisper.cpp'];
11
+ export const LOCAL_AI_RUNTIME_IDS = ['llama.cpp', 'ollama', 'onnx', 'stable-diffusion.cpp', 'whisper.cpp', 'kokoro-native'];
12
+
13
+ /** Describe the selected helper without acquiring it or selecting model assets. */
14
+ export function normalizeKokoroRuntimeRequirement(record) {
15
+ for (const field of ['helperRoot', 'helperExecutable', 'espeakDataDirectory', 'libraryDirectory']) {
16
+ if (record[field] !== undefined && (!is.string(record[field]) || record[field] === '')) {
17
+ throw new ArcaneError(ERROR_CODES.usage, `kokoro-native ${field} must be a nonempty path.`);
18
+ }
19
+ }
20
+ return {...record};
21
+ }
12
22
 
13
23
  /** Select a llama distribution without discovering hardware or acquiring it. */
14
24
  export function normalizeLlamaRuntimeRequirement(record) {
@@ -79,6 +89,7 @@ export function normalizeLocalAIConfig(value) {
79
89
  if (record.id === 'llama.cpp') return normalizeLlamaRuntimeRequirement(record);
80
90
  if (record.id === 'stable-diffusion.cpp') return normalizeImageRuntimeRequirement(record);
81
91
  if (record.id === 'whisper.cpp') return normalizeWhisperRuntimeRequirement(record);
92
+ if (record.id === 'kokoro-native') return normalizeKokoroRuntimeRequirement(record);
82
93
  return {...record};
83
94
  });
84
95
  return {...value, runtimes};
@@ -9,7 +9,9 @@ import {normalizeLocalAIConfig} from './config.mjs';
9
9
  import {discoverLocalAIRuntimes} from './discover.mjs';
10
10
  import {ensureLocalAIRuntimes} from './install.mjs';
11
11
  import {createWhisperRuntime} from './whisper/index.mjs';
12
+ import {createNativeKokoroRuntime} from './kokoro/index.mjs';
12
13
  import {createExecutionDeviceCatalog} from './execution-devices.mjs';
14
+ import {ArcaneError, ERROR_CODES} from '../errors.mjs';
13
15
 
14
16
  /** Local engines share the ordinary app-service development transport. */
15
17
  export function createDevelopmentLocalAI({
@@ -24,7 +26,7 @@ export function createDevelopmentLocalAI({
24
26
  const localConfig = {
25
27
  ...selected,
26
28
  runtimes: selected.runtimes.filter(function selectedChatRuntime(requirement) {
27
- return requirement.id !== 'stable-diffusion.cpp' && requirement.id !== 'whisper.cpp';
29
+ return !['stable-diffusion.cpp', 'whisper.cpp', 'kokoro-native'].includes(requirement.id);
28
30
  })
29
31
  };
30
32
  const service = createLocalAIService(
@@ -48,7 +50,7 @@ export function createDevelopmentLocalAI({
48
50
  return requirement.id === 'stable-diffusion.cpp';
49
51
  });
50
52
  if (imageSelected || selected.runtimes.some(function selectedProjectedRuntime(requirement) {
51
- return ['onnx', 'llama.cpp'].includes(requirement.id);
53
+ return ['onnx', 'llama.cpp', 'kokoro-native'].includes(requirement.id);
52
54
  })) {
53
55
  modelAssets = createModelAssetService({appRoot});
54
56
  definitions.push(modelAssets);
@@ -71,8 +73,13 @@ export function createDevelopmentLocalAI({
71
73
  const whisperRequirement = selected.runtimes.find(function selectedWhisperRuntime(requirement) {
72
74
  return requirement.id === 'whisper.cpp';
73
75
  });
76
+ const kokoroRequirement = selected.runtimes.find(function selectedKokoroRuntime(requirement) {
77
+ return requirement.id === 'kokoro-native';
78
+ });
79
+ let stt;
80
+ let tts;
74
81
  if (whisperRequirement) {
75
- const stt = createWhisperRuntime(
82
+ stt = createWhisperRuntime(
76
83
  {
77
84
  modelId: whisperRequirement.modelId,
78
85
  temporaryDirectory: path.join(appRoot, '.arcane', 'speech', 'whisper'),
@@ -85,7 +92,49 @@ export function createDevelopmentLocalAI({
85
92
  }
86
93
  }
87
94
  );
88
- speechService = createSpeechService({stt, signal});
95
+ }
96
+ if (kokoroRequirement) {
97
+ tts = createNativeKokoroRuntime({
98
+ modelId: kokoroRequirement.modelId,
99
+ model: kokoroRequirement.model,
100
+ revision: kokoroRequirement.revision,
101
+ dtype: kokoroRequirement.dtype,
102
+ paths: kokoroRequirement.paths,
103
+ assetProjectionId: kokoroRequirement.assetProjectionId,
104
+ resourcePaths: kokoroRequirement.resourcePaths,
105
+ sessionOptions: kokoroRequirement.sessionOptions,
106
+ executionPreference: kokoroRequirement.executionPreference,
107
+ executionTarget: kokoroRequirement.executionTarget,
108
+ modelAssets, signal, onEvent,
109
+ async prepare({signal: activationSignal}) {
110
+ if (!localConfig.runtimes.some(function selectedONNX(requirement) { return requirement.id === 'onnx'; })) {
111
+ throw new ArcaneError(ERROR_CODES.targetUnavailable, 'Native Kokoro requires an explicitly selected onnx runtime.');
112
+ }
113
+ const preparation = new AbortController();
114
+ const preparationSignal = AbortSignal.any([activationSignal, preparation.signal]);
115
+ function preparationFailed(error) {
116
+ preparation.abort(error);
117
+ throw error;
118
+ }
119
+ // Helper acquisition and the existing ONNX owner's startup are
120
+ // independent. Join both before native activation can continue.
121
+ const outcomes = await Promise.allSettled([
122
+ waitForKokoroONNX(preparationSignal).catch(preparationFailed),
123
+ ensureLocalAIRuntimes({
124
+ runtimes: [kokoroRequirement], directory, signal: preparationSignal, onEvent
125
+ }).catch(preparationFailed)
126
+ ]);
127
+ const failures = outcomes.filter(function failed(result) { return result.status === 'rejected'; })
128
+ .map(function cause(result) { return result.reason; });
129
+ if (failures.length === 1) throw failures[0];
130
+ if (failures.length) throw new AggregateError(failures, 'Native Kokoro preparation failed.');
131
+ preparationSignal.throwIfAborted();
132
+ return {onnx: outcomes[0].value, runtime: outcomes[1].value[0]};
133
+ }
134
+ });
135
+ }
136
+ if (stt || tts) {
137
+ speechService = createSpeechService({stt, tts, signal});
89
138
  definitions.push(speechService);
90
139
  }
91
140
  const core = createDevelopmentCore(
@@ -101,6 +150,24 @@ export function createDevelopmentLocalAI({
101
150
  }
102
151
  }
103
152
  );
153
+ function waitForKokoroONNX(activationSignal) {
154
+ activationSignal.throwIfAborted();
155
+ let cancel;
156
+ const cancelled = new Promise(function observePreparationCancellation(_resolve, reject) {
157
+ cancel = function stopWaitingForONNX() { reject(activationSignal.reason); };
158
+ activationSignal.addEventListener('abort', cancel, {once: true});
159
+ });
160
+ const ready = core.ready.then(async function existingONNXOwner(runtime) {
161
+ activationSignal.throwIfAborted();
162
+ const owner = await runtime.getService('local-ai');
163
+ activationSignal.throwIfAborted();
164
+ return owner.getONNXRuntime();
165
+ });
166
+ // Cancel this selected activation's wait without stopping the shared owner.
167
+ return Promise.race([ready, cancelled]).finally(function releaseONNXWait() {
168
+ activationSignal.removeEventListener('abort', cancel);
169
+ });
170
+ }
104
171
  function current() {
105
172
  return {
106
173
  ...core.current(),