arcane-os 0.94.0 → 0.96.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 (43) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +1 -1
  3. package/browser-runtime/ai/core-model-assets.mjs +74 -0
  4. package/browser-runtime/core/client.mjs +195 -0
  5. package/docs/reference/README.md +9 -0
  6. package/docs/reference/ai/browser-speech.md +61 -10
  7. package/docs/reference/core-client.md +35 -0
  8. package/docs/reference/core-native-packaging.md +38 -6
  9. package/docs/reference/inventory/package-api.json +133 -5
  10. package/docs/reference/inventory/runtime-modules.json +39 -13
  11. package/docs/reference/model-assets.md +164 -3
  12. package/docs/reference/native-magpie.md +98 -0
  13. package/docs/reference/native-nemotron.md +204 -0
  14. package/docs/reference/native-speech.md +250 -5
  15. package/docs/reference/runtime-modules.md +157 -11
  16. package/docs/reference/sdk-api.md +247 -4
  17. package/package.json +7 -3
  18. package/runtime/arcane/modules/AI.js +114 -14
  19. package/runtime/arcane/modules/AIProviderRuntime.js +32 -15
  20. package/runtime/arcane/modules/ContinuousVoiceCapture.js +118 -21
  21. package/runtime/arcane/modules/NativeSpeechCapture.js +198 -0
  22. package/runtime/arcane/modules/NativeSpeechPlayback.js +304 -0
  23. package/runtime/arcane/modules/VoiceCaptureWorklet.js +62 -2
  24. package/src/core/host.mjs +1 -0
  25. package/src/core/services/local-ai.mjs +46 -12
  26. package/src/core/services/model-assets.mjs +491 -59
  27. package/src/core/services/speech.mjs +237 -4
  28. package/src/local-ai/magpie/build.mjs +36 -0
  29. package/src/local-ai/magpie/helper.mjs +254 -0
  30. package/src/local-ai/magpie/index.mjs +488 -0
  31. package/src/local-ai/magpie/native/CMakeLists.txt +10 -0
  32. package/src/local-ai/magpie/native/main.cpp +270 -0
  33. package/src/local-ai/nemo-models.mjs +160 -0
  34. package/src/local-ai/nemotron/build.mjs +53 -0
  35. package/src/local-ai/nemotron/helper.mjs +308 -0
  36. package/src/local-ai/nemotron/index.mjs +693 -0
  37. package/src/local-ai/nemotron/native/CMakeLists.txt +17 -0
  38. package/src/local-ai/nemotron/native/main.cpp +282 -0
  39. package/src/native/macos-provider.mjs +14 -7
  40. package/src/native/portable-assembly.mjs +180 -0
  41. package/src/native/portable-provider.mjs +17 -161
  42. package/src/native/publish-output.mjs +101 -0
  43. package/src/native/windows-provider.mjs +12 -6
@@ -42,7 +42,7 @@ function waitForLoad(task, signal) {
42
42
  }
43
43
 
44
44
  /** Hosts supply selected engines; this service owns their independent lifetimes. */
45
- export function createSpeechService({stt, tts, signal} = {}) {
45
+ export function createSpeechService({stt, tts, signal, autoLoad = true} = {}) {
46
46
  const roles = {
47
47
  stt: {engine: stt, operation: 'transcribe', state: null, loading: null, changing: null, unsubscribe: null},
48
48
  tts: {engine: tts, operation: 'synthesize', state: null, loading: null, changing: null, unsubscribe: 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
  };
@@ -173,7 +177,7 @@ export function createSpeechService({stt, tts, signal} = {}) {
173
177
  return;
174
178
  }
175
179
  // Replay can synchronously select an activation or close the service.
176
- if (role.changing || closing || closed) return;
180
+ if (role.changing || closing || closed || autoLoad === false) return;
177
181
  const task = Promise.resolve().then(
178
182
  async function loadSpeechEngine() {
179
183
  lifetimeSignal.throwIfAborted();
@@ -285,6 +289,201 @@ export function createSpeechService({stt, tts, signal} = {}) {
285
289
  }
286
290
  }
287
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
+
288
487
  function subscribe(listener, {replay = true, signal: subscriptionSignal} = {}) {
289
488
  if (!is.function(listener)) {
290
489
  throw new TypeError('A speech state listener must be a function.');
@@ -407,6 +606,28 @@ export function createSpeechService({stt, tts, signal} = {}) {
407
606
  },
408
607
  'speech.synthesize': function synthesize(parameters, request = {}) {
409
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);
410
631
  }
411
632
  },
412
633
  drain: close,
@@ -420,4 +641,16 @@ export function createSpeechService({stt, tts, signal} = {}) {
420
641
  };
421
642
  }
422
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
+
423
656
  export default createSpeechService;
@@ -0,0 +1,36 @@
1
+ import path from 'node:path';
2
+ import {fileURLToPath} from 'node:url';
3
+ import {mkdir} from 'node:fs/promises';
4
+ import Is from 'strong-type';
5
+ import {runProcess} from '../../process.mjs';
6
+
7
+ const is = new Is(false);
8
+
9
+ /** Build the first-party adapter against an explicitly prepared NeMo TTS runtime. */
10
+ export async function buildMagpieHelper({runtime, outputRoot, signal, onEvent} = {}) {
11
+ if (!is.string(runtime?.cmakeDirectory) || !is.string(outputRoot)) {
12
+ throw new TypeError('Magpie requires the selected NeMo runtime cmakeDirectory and helper outputRoot.');
13
+ }
14
+ const root = path.resolve(outputRoot);
15
+ const build = path.join(root, 'build');
16
+ const source = fileURLToPath(new URL('./native/', import.meta.url));
17
+ await mkdir(root, {recursive: true});
18
+ const options = {signal, onEvent};
19
+ const configure = await runProcess(
20
+ 'cmake',
21
+ ['-S', source, '-B', build, `-DNeMoSpeech_DIR=${path.resolve(runtime.cmakeDirectory)}`,
22
+ `-DCMAKE_INSTALL_PREFIX=${root}`, '-DCMAKE_BUILD_TYPE=Release'],
23
+ options
24
+ );
25
+ const compile = await runProcess('cmake', ['--build', build, '--config', 'Release'], options);
26
+ const install = await runProcess('cmake', ['--install', build, '--config', 'Release'], options);
27
+ return {
28
+ platform: process.platform,
29
+ architecture: process.arch,
30
+ root,
31
+ helperExecutable: path.join(root, 'bin', process.platform === 'win32' ? 'arcane-magpie.exe' : 'arcane-magpie'),
32
+ diagnostics: {configure, compile, install}
33
+ };
34
+ }
35
+
36
+ export default buildMagpieHelper;
@@ -0,0 +1,254 @@
1
+ import path from 'node:path';
2
+ import {PassThrough} from 'node:stream';
3
+ import Is from 'strong-type';
4
+ import {runProcess} from '../../process.mjs';
5
+
6
+ const is = new Is(false);
7
+
8
+ /** One retained native synthesizer; the host owns playback and audio devices. */
9
+ export function createMagpieHelper({runtime, paths, speaker, executionTarget, onEvent}) {
10
+ const input = new PassThrough();
11
+ const lifetime = new AbortController();
12
+ const readiness = deferred();
13
+ let sequence = 0;
14
+ let pending = null;
15
+ let metadata = null;
16
+ let fragment = Buffer.alloc(0);
17
+ let frame = null;
18
+ let writes = Promise.resolve();
19
+ let stopping = false;
20
+ let exited = false;
21
+ let terminalError = null;
22
+ let closing = null;
23
+ const env = {...process.env};
24
+ const directory = runtime.libraryDirectory ?? path.dirname(runtime.helperExecutable);
25
+ const variable = process.platform === 'win32'
26
+ ? Object.keys(env).find(function pathVariable(name) { return name.toLowerCase() === 'path'; }) ?? 'PATH'
27
+ : process.platform === 'darwin' ? 'DYLD_LIBRARY_PATH' : 'LD_LIBRARY_PATH';
28
+ env[variable] = [directory, env[variable]].filter(Boolean).join(path.delimiter);
29
+ // The native device owner supplies the selected CUDA UUID/visible device.
30
+ // This adapter neither invents a device ordinal nor retries on another device.
31
+ if (executionTarget.cudaVisibleDevices !== undefined) {
32
+ env.CUDA_VISIBLE_DEVICES = executionTarget.cudaVisibleDevices;
33
+ }
34
+ const iterator = input[Symbol.asyncIterator]();
35
+ const incoming = {
36
+ [Symbol.asyncIterator]() { return this; },
37
+ next() { return iterator.next(); },
38
+ return() { input.end(); return iterator.return(); }
39
+ };
40
+ const completion = runProcess(
41
+ runtime.helperExecutable,
42
+ [paths.model, paths.codec, paths.tokenizerDirectory, String(speaker), executionTarget.backend],
43
+ {
44
+ env, signal: lifetime.signal, input: incoming, onEvent,
45
+ outputEncoding: {stdout: null, stderr: 'utf8'},
46
+ captureOutput: {stdout: false, stderr: true},
47
+ emitOutputEvents: {stdout: false, stderr: true},
48
+ onOutput: receive
49
+ }
50
+ ).then(function processExited(result) {
51
+ if (fragment.length || frame) throw failure('MAGPIE_PROTOCOL_INCOMPLETE', 'The Magpie helper exited during an output frame.');
52
+ if (!stopping || (pending && !pending.terminal)) throw failure('MAGPIE_HELPER_EXITED', 'The Magpie helper exited before its work completed.', result);
53
+ return result;
54
+ }).catch(function processFailed(error) {
55
+ terminalError = error;
56
+ throw error;
57
+ }).finally(function processSettled() {
58
+ exited = true;
59
+ input.end();
60
+ const error = terminalError ?? failure('MAGPIE_HELPER_CLOSED', 'The Magpie helper has closed.');
61
+ readiness.reject(error);
62
+ pending?.response.reject(error);
63
+ });
64
+ completion.catch(function observeProcessFailure() {});
65
+
66
+ async function receive({stream, chunk}) {
67
+ if (stream !== 'stdout') return;
68
+ fragment = fragment.length ? Buffer.concat([fragment, chunk]) : chunk;
69
+ for (;;) {
70
+ if (!frame) {
71
+ const boundary = fragment.indexOf(10);
72
+ if (boundary < 0) return;
73
+ const [kind, id, encodedLength] = fragment.subarray(0, boundary).toString('utf8').split('\t');
74
+ const length = Number(encodedLength);
75
+ if (!is.safeInteger(length) || length < 0) {
76
+ throw failure('MAGPIE_PROTOCOL_UNREADABLE', 'The Magpie helper returned unreadable transport framing.');
77
+ }
78
+ frame = {kind, id, length};
79
+ fragment = fragment.subarray(boundary + 1);
80
+ }
81
+ if (fragment.length < frame.length) return;
82
+ const payload = fragment.subarray(0, frame.length);
83
+ fragment = fragment.subarray(frame.length);
84
+ const record = frame;
85
+ frame = null;
86
+ if (record.kind === 'fatal') {
87
+ throw failure('MAGPIE_NATIVE_FAILED', payload.toString('utf8'));
88
+ }
89
+ if (record.kind === 'ready') {
90
+ const sampleRate = Number(payload.toString('utf8'));
91
+ if (!is.integer(sampleRate) || sampleRate <= 0) {
92
+ throw failure('MAGPIE_PROTOCOL_UNREADABLE', 'The native synthesizer returned an unreadable sample rate.');
93
+ }
94
+ metadata = {sampleRate, channels: 1, format: 's16le'};
95
+ readiness.resolve(metadata);
96
+ continue;
97
+ }
98
+ const operation = pending;
99
+ if (!operation || record.id !== operation.id) {
100
+ throw failure('MAGPIE_PROTOCOL_UNKNOWN_REQUEST', `The Magpie helper returned ${record.kind} without its active request.`);
101
+ }
102
+ if (record.kind === 'audio' || record.kind === 'started') {
103
+ if (operation.signal?.aborted || operation.callbackError) continue;
104
+ try {
105
+ if (record.kind === 'audio') {
106
+ // Awaited delivery lets runProcess pause stdout and native PCM production.
107
+ await operation.onAudio({pcm: Buffer.from(payload), ...metadata});
108
+ } else {
109
+ await operation.onProgress?.({phase: 'synthesizing'});
110
+ }
111
+ } catch (error) {
112
+ operation.callbackError = error;
113
+ try { await writeCommand([Buffer.from(`stop\t${operation.id}\n`)]); }
114
+ catch (stopError) {
115
+ throw new AggregateError([error, stopError], 'The Magpie consumer and native cancellation failed.');
116
+ }
117
+ }
118
+ } else if (record.kind === 'error') {
119
+ operation.terminal = true;
120
+ const nativeError = failure('MAGPIE_NATIVE_FAILED', payload.toString('utf8'));
121
+ const previous = operation.callbackError ?? operation.signal?.reason;
122
+ operation.response.reject(previous
123
+ ? new AggregateError([previous, nativeError], 'Magpie synthesis and its consumer failed.') : nativeError);
124
+ } else if (record.kind === 'cancelled') {
125
+ operation.terminal = true;
126
+ operation.response.reject(operation.callbackError ?? operation.signal?.reason
127
+ ?? failure('MAGPIE_SYNTHESIS_CANCELLED', 'The native synthesizer cancelled this utterance.'));
128
+ } else if (record.kind === 'done') {
129
+ operation.terminal = true;
130
+ if (operation.callbackError) operation.response.reject(operation.callbackError);
131
+ else if (operation.signal?.aborted) operation.response.reject(operation.signal.reason);
132
+ else operation.response.resolve(metadata);
133
+ } else {
134
+ throw failure('MAGPIE_PROTOCOL_UNREADABLE', `The Magpie helper returned an unknown record ${record.kind}.`);
135
+ }
136
+ }
137
+ }
138
+
139
+ function writeCommand(parts) {
140
+ const writing = writes.then(async function writeFramedCommand() {
141
+ if (exited || lifetime.signal.aborted) throw terminalError ?? lifetime.signal.reason
142
+ ?? failure('MAGPIE_HELPER_CLOSED', 'The Magpie helper has closed.');
143
+ for (const part of parts) {
144
+ await new Promise(function writeInput(resolve, reject) {
145
+ input.write(part, function inputWritten(error) {
146
+ if (error) reject(error);
147
+ else resolve();
148
+ });
149
+ });
150
+ }
151
+ });
152
+ writes = writing.catch(function commandFailed(error) {
153
+ // Incomplete framing cannot be reused. Retire only this owned process.
154
+ terminalError = error;
155
+ stopping = true;
156
+ lifetime.abort(error);
157
+ });
158
+ return writing;
159
+ }
160
+
161
+ async function synthesize({text, languageCode, speaker: selectedSpeaker}, {signal, onAudio, onProgress} = {}) {
162
+ signal?.throwIfAborted();
163
+ await readiness.promise;
164
+ signal?.throwIfAborted();
165
+ if (stopping || exited || pending) {
166
+ throw terminalError ?? failure('MAGPIE_HELPER_UNAVAILABLE', 'The Magpie helper is unavailable for another utterance.');
167
+ }
168
+ const operation = {
169
+ id: String(++sequence), response: deferred(), signal, onAudio, onProgress, callbackError: null, terminal: false
170
+ };
171
+ pending = operation;
172
+ function stopUtterance() {
173
+ writeCommand([Buffer.from(`stop\t${operation.id}\n`)]).catch(function stopFailed(error) {
174
+ terminalError = error;
175
+ });
176
+ }
177
+ const language = Buffer.from(languageCode, 'utf8');
178
+ const content = Buffer.from(text, 'utf8');
179
+ try {
180
+ // Install cancellation after enqueueing the complete text frame, so a
181
+ // concurrent stop cannot precede its own native synthesis command.
182
+ const writing = writeCommand([
183
+ Buffer.from(`synthesize\t${operation.id}\t${selectedSpeaker}\t${language.length}\t${content.length}\n`),
184
+ language, content
185
+ ]);
186
+ signal?.addEventListener('abort', stopUtterance, {once: true});
187
+ if (signal?.aborted) stopUtterance();
188
+ await writing;
189
+ return await operation.response.promise;
190
+ } catch (error) {
191
+ if (lifetime.signal.aborted || exited) {
192
+ try { await completion; }
193
+ catch (processError) {
194
+ if (processError !== error) throw new AggregateError([error, processError], 'Magpie synthesis and process cleanup failed.');
195
+ }
196
+ }
197
+ throw error;
198
+ } finally {
199
+ signal?.removeEventListener('abort', stopUtterance);
200
+ if (pending === operation) pending = null;
201
+ }
202
+ }
203
+
204
+ function close() {
205
+ if (closing) return closing;
206
+ stopping = true;
207
+ closing = Promise.resolve().then(async function closeHelper() {
208
+ if (!exited) {
209
+ if (!metadata) {
210
+ // No C-ABI cancellation exists during model creation.
211
+ lifetime.abort(failure('MAGPIE_LOADING_CANCELLED', 'The Magpie model load was cancelled.'));
212
+ } else {
213
+ await writeCommand([Buffer.from('close\t0\n')]);
214
+ input.end();
215
+ }
216
+ }
217
+ try { await completion; }
218
+ catch (error) {
219
+ if (!expectedCancellation(error, lifetime.signal)) throw error;
220
+ }
221
+ });
222
+ return closing;
223
+ }
224
+
225
+ return {
226
+ ready: readiness.promise, completion, synthesize, close,
227
+ get exited() { return exited; }
228
+ };
229
+ }
230
+
231
+ function deferred() {
232
+ const value = {};
233
+ value.promise = new Promise(function awaitResponse(resolve, reject) {
234
+ value.resolve = resolve;
235
+ value.reject = reject;
236
+ });
237
+ value.promise.catch(function observePendingFailure() {});
238
+ return value;
239
+ }
240
+
241
+ function failure(code, message, data) {
242
+ const error = new Error(message);
243
+ error.code = code;
244
+ if (data !== undefined) error.data = data;
245
+ return error;
246
+ }
247
+
248
+ function expectedCancellation(error, signal) {
249
+ if (!signal.aborted) return false;
250
+ if (error === signal.reason) return true;
251
+ if (!['ARCANE_CANCELLED', 'ABORT_ERR'].includes(error?.code) && error?.name !== 'AbortError') return false;
252
+ if (is.array(error.errors) && !error.errors.every(function cancelled(cause) { return expectedCancellation(cause, signal); })) return false;
253
+ return error.cause !== undefined && expectedCancellation(error.cause, signal);
254
+ }