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.
- package/CHANGELOG.md +33 -0
- package/README.md +1 -1
- package/browser-runtime/ai/twin-cloud.mjs +109 -46
- package/browser-runtime/core/client.mjs +197 -0
- package/docs/reference/ai/twin-cloud.md +35 -14
- package/docs/reference/availability-and-normalization.md +1 -1
- package/docs/reference/inventory/package-api.json +96 -12
- package/docs/reference/local-ai.md +81 -5
- package/docs/reference/native-magpie.md +98 -0
- package/docs/reference/native-nemotron.md +204 -0
- package/docs/reference/native-speech.md +364 -11
- package/docs/reference/runtime-modules.md +21 -18
- package/docs/reference/sdk-api.md +152 -11
- package/package.json +6 -1
- package/runtime/arcane/modules/AI.js +114 -14
- package/runtime/arcane/modules/AIProviderRuntime.js +32 -15
- package/runtime/arcane/modules/ContinuousVoiceCapture.js +118 -21
- package/runtime/arcane/modules/NativeSpeechCapture.js +198 -0
- package/runtime/arcane/modules/NativeSpeechPlayback.js +304 -0
- package/runtime/arcane/modules/VoiceCaptureWorklet.js +62 -2
- package/schemas/arcane-app.schema.json +2 -2
- package/schemas/arcane-package.schema.json +2 -2
- package/src/core/services/speech.mjs +320 -25
- package/src/local-ai/config.mjs +12 -1
- package/src/local-ai/dev.mjs +71 -4
- package/src/local-ai/install.mjs +113 -5
- package/src/local-ai/kokoro/frontend.mjs +219 -0
- package/src/local-ai/kokoro/helper.mjs +168 -0
- package/src/local-ai/kokoro/index.mjs +645 -0
- package/src/local-ai/kokoro/native/CMakeLists.txt +45 -0
- package/src/local-ai/kokoro/native/main.cpp +249 -0
- package/src/local-ai/magpie/build.mjs +36 -0
- package/src/local-ai/magpie/helper.mjs +254 -0
- package/src/local-ai/magpie/index.mjs +488 -0
- package/src/local-ai/magpie/native/CMakeLists.txt +10 -0
- package/src/local-ai/magpie/native/main.cpp +270 -0
- package/src/local-ai/nemotron/build.mjs +53 -0
- package/src/local-ai/nemotron/helper.mjs +308 -0
- package/src/local-ai/nemotron/index.mjs +637 -0
- package/src/local-ai/nemotron/native/CMakeLists.txt +17 -0
- package/src/local-ai/nemotron/native/main.cpp +282 -0
- package/src/native/portable-layout.mjs +67 -9
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {readFile} from 'node:fs/promises';
|
|
3
|
+
import {randomUUID} from 'node:crypto';
|
|
4
|
+
import {AsyncLocalStorage} from 'node:async_hooks';
|
|
5
|
+
import Is from 'strong-type';
|
|
6
|
+
import {createArcaneEventSource} from '../../event-manager.mjs';
|
|
7
|
+
import {serializeCoreError} from '../../../browser-runtime/core/contracts.mjs';
|
|
8
|
+
import {createMagpieHelper} from './helper.mjs';
|
|
9
|
+
|
|
10
|
+
const is = new Is(false);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Retained Magpie/NanoCodec synthesis through the selected native C ABI.
|
|
14
|
+
* Ordered onAudio/onProgress callbacks may abort their caller-owned signal.
|
|
15
|
+
* Join stop or engine lifecycle completion outside those callbacks.
|
|
16
|
+
*/
|
|
17
|
+
export function createMagpieRuntime({
|
|
18
|
+
runtime, prepare, paths, modelAssets, assetProjectionId, resourcePaths, modelId = 'magpie',
|
|
19
|
+
voice = 'Aria', languageCode = 'en-US', executionTarget, signal, onEvent
|
|
20
|
+
} = {}) {
|
|
21
|
+
const events = createArcaneEventSource(
|
|
22
|
+
{}, {source: 'magpie', eventTypes: ['magpie.state']}
|
|
23
|
+
);
|
|
24
|
+
const requests = new Map();
|
|
25
|
+
const retirements = new Set();
|
|
26
|
+
const callbacks = new AsyncLocalStorage();
|
|
27
|
+
let selection = {paths, assetProjectionId, resourcePaths, executionTarget};
|
|
28
|
+
let activation = null;
|
|
29
|
+
let state = 'unloaded';
|
|
30
|
+
let error = null;
|
|
31
|
+
let progress = null;
|
|
32
|
+
let closing = null;
|
|
33
|
+
let disposed = false;
|
|
34
|
+
|
|
35
|
+
function current() {
|
|
36
|
+
return {
|
|
37
|
+
providerId: 'magpie-native', modelId, state,
|
|
38
|
+
loaded: Boolean(activation?.ready && !activation.controller.signal.aborted),
|
|
39
|
+
busy: Boolean(activation && (!activation.ready || activation.active)),
|
|
40
|
+
requestId: activation?.active?.requestId ?? null,
|
|
41
|
+
queuedRequests: activation?.queue.length ?? 0,
|
|
42
|
+
defaultVoice: voice, languageCode,
|
|
43
|
+
voices: activation?.voices ? Object.keys(activation.voices) : [],
|
|
44
|
+
speech: {streaming: true, responseFormats: ['wav', 'pcm'], defaultResponseFormat: 'wav'},
|
|
45
|
+
execution: activation ? {requested: {...activation.selection.executionTarget}, configuredBackend: activation.selection.executionTarget?.backend} : null,
|
|
46
|
+
sampleRate: activation?.metadata?.sampleRate ?? null,
|
|
47
|
+
progress, error: error ? serializeCoreError(error) : null
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function publish() {
|
|
52
|
+
if (!disposed) events.dispatch('magpie.state', current());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function subscribe(listener, {replay = true, signal: subscriptionSignal} = {}) {
|
|
56
|
+
if (!is.function(listener)) throw new TypeError('A Magpie state listener must be a function.');
|
|
57
|
+
if (disposed) {
|
|
58
|
+
if (replay && !subscriptionSignal?.aborted) notify(listener, current());
|
|
59
|
+
return function disposedSubscription() {};
|
|
60
|
+
}
|
|
61
|
+
const unsubscribe = events.on(
|
|
62
|
+
'magpie.state', function stateChanged(event) { notify(listener, event.detail); },
|
|
63
|
+
{signal: subscriptionSignal}
|
|
64
|
+
);
|
|
65
|
+
try { if (replay && !subscriptionSignal?.aborted) notify(listener, current()); }
|
|
66
|
+
catch (failure) { unsubscribe(); throw failure; }
|
|
67
|
+
return unsubscribe;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function assertOpen(operationSignal) {
|
|
71
|
+
signal?.throwIfAborted();
|
|
72
|
+
operationSignal?.throwIfAborted();
|
|
73
|
+
if (closing || disposed) throw failure('MAGPIE_CLOSED', 'The Magpie engine is closing.');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function load(options = {}) {
|
|
77
|
+
assertOpen(options.signal);
|
|
78
|
+
if (options.modelId !== undefined && options.modelId !== modelId) {
|
|
79
|
+
throw failure('MAGPIE_MODEL_MISMATCH', 'This engine owns a different selected model.');
|
|
80
|
+
}
|
|
81
|
+
const next = {...selection};
|
|
82
|
+
for (const field of ['paths', 'assetProjectionId', 'resourcePaths', 'executionTarget']) {
|
|
83
|
+
if (Object.hasOwn(options, field)) next[field] = options[field];
|
|
84
|
+
}
|
|
85
|
+
if (activation && sameSelection(activation.selection, next) && !activation.retirement) {
|
|
86
|
+
if (activation.ready) return Promise.resolve(current());
|
|
87
|
+
return waitFor(activation.loaded, options.signal);
|
|
88
|
+
}
|
|
89
|
+
requireOutsideCallback('Replace the Magpie selection');
|
|
90
|
+
if (!next.paths && !next.assetProjectionId) {
|
|
91
|
+
selection = next;
|
|
92
|
+
return unload();
|
|
93
|
+
}
|
|
94
|
+
if (!is.string(runtime?.helperExecutable) && !is.function(prepare)) {
|
|
95
|
+
throw failure('MAGPIE_RUNTIME_UNAVAILABLE', 'Supply the prepared platform-matching Magpie helper executable.');
|
|
96
|
+
}
|
|
97
|
+
if (!['cpu', 'cuda'].includes(next.executionTarget?.backend)) {
|
|
98
|
+
throw failure('MAGPIE_DEVICE_UNAVAILABLE', 'The native device owner must explicitly select the CPU or CUDA backend.');
|
|
99
|
+
}
|
|
100
|
+
if (next.assetProjectionId && !is.function(modelAssets?.retain)) {
|
|
101
|
+
throw failure('MAGPIE_RESOURCES_UNAVAILABLE', 'A model projection requires the existing model-assets owner.');
|
|
102
|
+
}
|
|
103
|
+
const retained = next.assetProjectionId ? modelAssets.retain(next.assetProjectionId) : null;
|
|
104
|
+
const predecessor = activation;
|
|
105
|
+
const entry = {
|
|
106
|
+
selection: next, controller: new AbortController(), retained, helper: null,
|
|
107
|
+
ready: false, metadata: null, voices: null, queue: [], active: null,
|
|
108
|
+
preparation: null, runtimePreparation: null, loaded: null, retirement: null
|
|
109
|
+
};
|
|
110
|
+
selection = next;
|
|
111
|
+
activation = entry;
|
|
112
|
+
state = 'loading';
|
|
113
|
+
error = null;
|
|
114
|
+
progress = {phase: 'loading-model'};
|
|
115
|
+
const previous = predecessor ? retire(predecessor, cancellation('The Magpie selection changed.')) : Promise.resolve();
|
|
116
|
+
entry.preparation = Promise.resolve().then(async function prepareActivation() {
|
|
117
|
+
await previous;
|
|
118
|
+
entry.controller.signal.throwIfAborted();
|
|
119
|
+
entry.runtimePreparation = Promise.resolve().then(async function prepareNativeRuntime() {
|
|
120
|
+
if (!is.string(runtime?.helperExecutable)) {
|
|
121
|
+
const preparedRuntime = await prepare({signal: entry.controller.signal});
|
|
122
|
+
entry.controller.signal.throwIfAborted();
|
|
123
|
+
runtime = preparedRuntime;
|
|
124
|
+
}
|
|
125
|
+
if (!is.string(runtime?.helperExecutable)) {
|
|
126
|
+
throw failure('MAGPIE_RUNTIME_UNAVAILABLE', 'Preparation did not supply a platform-matching Magpie helper executable.');
|
|
127
|
+
}
|
|
128
|
+
return runtime;
|
|
129
|
+
});
|
|
130
|
+
entry.runtimePreparation.catch(function observePreparationFailure() {});
|
|
131
|
+
const files = selectedPaths(entry);
|
|
132
|
+
const configuration = await readFile(
|
|
133
|
+
path.join(files.tokenizerDirectory, 'model_config.yaml'), {encoding: 'utf8', signal: entry.controller.signal}
|
|
134
|
+
);
|
|
135
|
+
entry.controller.signal.throwIfAborted();
|
|
136
|
+
// Read the selected model's own scalar reference, not GGUF labels or a copied voice table.
|
|
137
|
+
const reference = /^speaker_map:\s*(?:"nemo:([^"\r\n]+)"|'nemo:([^'\r\n]+)'|nemo:([^\r\n]+))\s*$/mu.exec(configuration);
|
|
138
|
+
if (!reference) throw failure('MAGPIE_VOICES_UNAVAILABLE', 'The selected tokenizer configuration has no supported speaker_map reference.');
|
|
139
|
+
const mapPath = path.join(files.tokenizerDirectory, (reference[1] ?? reference[2] ?? reference[3]).trim());
|
|
140
|
+
entry.voices = JSON.parse(await readFile(mapPath, {encoding: 'utf8', signal: entry.controller.signal}));
|
|
141
|
+
const speaker = selectedVoice(entry.voices, voice);
|
|
142
|
+
const preparedRuntime = await entry.runtimePreparation;
|
|
143
|
+
entry.controller.signal.throwIfAborted();
|
|
144
|
+
entry.helper = createMagpieHelper({runtime: preparedRuntime, paths: files, speaker, executionTarget: next.executionTarget, onEvent});
|
|
145
|
+
entry.helper.completion.catch(function helperFailed(cause) {
|
|
146
|
+
if (!entry.retirement) retire(entry, cause).catch(reportFailure);
|
|
147
|
+
});
|
|
148
|
+
entry.metadata = await entry.helper.ready;
|
|
149
|
+
entry.controller.signal.throwIfAborted();
|
|
150
|
+
entry.ready = true;
|
|
151
|
+
if (activation === entry) {
|
|
152
|
+
state = 'ready';
|
|
153
|
+
progress = null;
|
|
154
|
+
publish();
|
|
155
|
+
}
|
|
156
|
+
entry.controller.signal.throwIfAborted();
|
|
157
|
+
return current();
|
|
158
|
+
});
|
|
159
|
+
entry.loaded = entry.preparation.catch(async function activationFailed(cause) {
|
|
160
|
+
try { await retire(entry, cause); }
|
|
161
|
+
catch (cleanupError) { throw combine(cause, cleanupError, 'Loading Magpie and releasing its resources failed.'); }
|
|
162
|
+
throw cause;
|
|
163
|
+
});
|
|
164
|
+
entry.loaded.catch(reportLoadFailure);
|
|
165
|
+
publish();
|
|
166
|
+
return waitFor(entry.loaded, options.signal);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function selectedPaths(entry) {
|
|
170
|
+
if (!entry.retained) return entry.selection.paths;
|
|
171
|
+
const mapping = entry.selection.resourcePaths;
|
|
172
|
+
function member(name) {
|
|
173
|
+
const selected = entry.retained.members.find(function matches(record) { return record.path === name; });
|
|
174
|
+
if (!selected) throw failure('MAGPIE_RESOURCES_UNAVAILABLE', `The selected projection has no member ${String(name)}.`);
|
|
175
|
+
return selected.nativePath;
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
model: member(mapping?.model), codec: member(mapping?.codec),
|
|
179
|
+
tokenizerDirectory: path.dirname(member(mapping?.tokenizerConfig))
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function retire(entry, reason) {
|
|
184
|
+
if (entry.retirement) return entry.retirement;
|
|
185
|
+
entry.ready = false;
|
|
186
|
+
entry.retirement = Promise.resolve().then(async function retireActivation() {
|
|
187
|
+
const failures = [];
|
|
188
|
+
// Start close before joining preparation: model creation has no C-ABI abort.
|
|
189
|
+
let helperClose = entry.helper?.close();
|
|
190
|
+
helperClose?.catch(function observeCloseFailure() {});
|
|
191
|
+
await Promise.allSettled([entry.preparation, entry.runtimePreparation]);
|
|
192
|
+
helperClose ??= entry.helper?.close();
|
|
193
|
+
if (helperClose) {
|
|
194
|
+
try { await helperClose; }
|
|
195
|
+
catch (failure) { failures.push(failure); }
|
|
196
|
+
}
|
|
197
|
+
if (entry.active) await entry.active.settled.promise;
|
|
198
|
+
try { await entry.retained?.release(); }
|
|
199
|
+
catch (failure) { failures.push(failure); }
|
|
200
|
+
if (activation === entry) {
|
|
201
|
+
activation = null;
|
|
202
|
+
state = reason?.name === 'AbortError' ? 'unloaded' : 'error';
|
|
203
|
+
error = reason?.name === 'AbortError' ? null : reason;
|
|
204
|
+
if (failures.length) { state = 'error'; error = new AggregateError(failures, 'Magpie cleanup failed.'); }
|
|
205
|
+
progress = null;
|
|
206
|
+
publish();
|
|
207
|
+
}
|
|
208
|
+
if (failures.length) throw new AggregateError(failures, 'Magpie cleanup failed.');
|
|
209
|
+
});
|
|
210
|
+
retirements.add(entry.retirement);
|
|
211
|
+
function retired() { retirements.delete(entry.retirement); }
|
|
212
|
+
entry.retirement.then(retired, retired);
|
|
213
|
+
entry.controller.abort(reason);
|
|
214
|
+
if (activation === entry) { state = 'unloading'; progress = null; publish(); }
|
|
215
|
+
return entry.retirement;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function synthesize(request, options = {}) {
|
|
219
|
+
return enqueue(request, options, false);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function synthesizeStream(request, options = {}) {
|
|
223
|
+
if (!is.function(options.onAudio)) throw new TypeError('Streaming Magpie synthesis requires an ordered onAudio consumer.');
|
|
224
|
+
return enqueue(request, options, true);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function enqueue(request, options, streaming) {
|
|
228
|
+
assertOpen(options.signal);
|
|
229
|
+
if (!is.string(request?.input)) throw new TypeError('Magpie synthesis requires the complete input string.');
|
|
230
|
+
representable(request.input, 'input');
|
|
231
|
+
if (request.model !== undefined && request.model !== modelId) {
|
|
232
|
+
throw failure('MAGPIE_MODEL_MISMATCH', 'The request selected another speech model.');
|
|
233
|
+
}
|
|
234
|
+
if (request.speed !== undefined && request.speed !== 1) {
|
|
235
|
+
throw failure('MAGPIE_SPEED_UNAVAILABLE', 'The native Magpie API supplies its natural speaking rate only.');
|
|
236
|
+
}
|
|
237
|
+
const format = request.responseFormat ?? 'wav';
|
|
238
|
+
if (!streaming && !['wav', 'pcm'].includes(format)) {
|
|
239
|
+
throw failure('MAGPIE_FORMAT_UNAVAILABLE', `Magpie supports complete WAV or PCM output; ${String(format)} is unavailable.`);
|
|
240
|
+
}
|
|
241
|
+
const selectedLanguage = request.languageCode ?? request.language_code ?? languageCode;
|
|
242
|
+
if (!is.string(selectedLanguage)) throw new TypeError('The selected language code must be a string.');
|
|
243
|
+
representable(selectedLanguage, 'language code');
|
|
244
|
+
const entry = activation;
|
|
245
|
+
if (!entry?.ready || entry.controller.signal.aborted) {
|
|
246
|
+
throw failure('MAGPIE_MODEL_NOT_READY', 'Load the selected Magpie model before synthesis.');
|
|
247
|
+
}
|
|
248
|
+
const selected = request.voice ?? voice;
|
|
249
|
+
const speaker = selectedVoice(entry.voices, selected);
|
|
250
|
+
const requestId = options.requestId ?? randomUUID();
|
|
251
|
+
if (requests.has(requestId)) throw failure('MAGPIE_REQUEST_ACTIVE', 'That utterance request is already active or queued.');
|
|
252
|
+
const controller = new AbortController();
|
|
253
|
+
const operationSignal = AbortSignal.any([entry.controller.signal, controller.signal, ...(options.signal ? [options.signal] : [])]);
|
|
254
|
+
const job = {
|
|
255
|
+
request, requestId, voice: selected, speaker, languageCode: selectedLanguage, format, streaming,
|
|
256
|
+
options, controller, signal: operationSignal, response: deferred(), settled: deferred(),
|
|
257
|
+
cancel: null, failure: null
|
|
258
|
+
};
|
|
259
|
+
job.cancel = function cancelRequest() {
|
|
260
|
+
if (entry.active !== job) {
|
|
261
|
+
const position = entry.queue.indexOf(job);
|
|
262
|
+
if (position >= 0) entry.queue.splice(position, 1);
|
|
263
|
+
finish(entry, job, operationSignal.reason);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
requests.set(requestId, job);
|
|
267
|
+
entry.queue.push(job);
|
|
268
|
+
operationSignal.addEventListener('abort', job.cancel, {once: true});
|
|
269
|
+
if (operationSignal.aborted) job.cancel();
|
|
270
|
+
advance(entry);
|
|
271
|
+
publish();
|
|
272
|
+
return job.response.promise;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function advance(entry) {
|
|
276
|
+
if (entry.active || !entry.ready || entry.controller.signal.aborted) return;
|
|
277
|
+
const job = entry.queue.shift();
|
|
278
|
+
if (!job) return;
|
|
279
|
+
entry.active = job;
|
|
280
|
+
state = 'running';
|
|
281
|
+
error = null;
|
|
282
|
+
progress = {phase: 'accepted'};
|
|
283
|
+
job.task = Promise.resolve().then(async function generateSpeech() {
|
|
284
|
+
job.signal.throwIfAborted();
|
|
285
|
+
const chunks = [];
|
|
286
|
+
const metadata = await entry.helper.synthesize(
|
|
287
|
+
{text: job.request.input, languageCode: job.languageCode, speaker: job.speaker},
|
|
288
|
+
{
|
|
289
|
+
signal: job.signal,
|
|
290
|
+
async onProgress(value) {
|
|
291
|
+
progress = value;
|
|
292
|
+
publish();
|
|
293
|
+
await deliver(job, job.options.onProgress, value);
|
|
294
|
+
},
|
|
295
|
+
async onAudio(value) {
|
|
296
|
+
job.signal.throwIfAborted();
|
|
297
|
+
if (job.streaming) await deliver(job, job.options.onAudio, {...value, requestId: job.requestId});
|
|
298
|
+
else chunks.push(value.pcm);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
);
|
|
302
|
+
job.signal.throwIfAborted();
|
|
303
|
+
const result = {
|
|
304
|
+
requestId: job.requestId, sampleRate: metadata.sampleRate, channels: 1,
|
|
305
|
+
model: modelId, voice: job.voice, languageCode: job.languageCode, speed: 1
|
|
306
|
+
};
|
|
307
|
+
if (job.streaming) return {...result, format: 's16le', complete: true};
|
|
308
|
+
const audio = job.format === 'wav' ? encodeWav(chunks, metadata.sampleRate) : Buffer.concat(chunks);
|
|
309
|
+
return {...result, audioBase64: audio.toString('base64'),
|
|
310
|
+
contentType: job.format === 'wav' ? 'audio/wav' : 'audio/pcm', format: job.format, sampleFormat: 's16le'};
|
|
311
|
+
});
|
|
312
|
+
job.task.then(
|
|
313
|
+
function generated(result) { finish(entry, job, null, result); },
|
|
314
|
+
function generationFailed(cause) { finish(entry, job, cause); }
|
|
315
|
+
).catch(reportFailure);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function finish(entry, job, cause, result) {
|
|
319
|
+
if (!requests.has(job.requestId)) return;
|
|
320
|
+
job.signal.removeEventListener('abort', job.cancel);
|
|
321
|
+
requests.delete(job.requestId);
|
|
322
|
+
job.failure = cause;
|
|
323
|
+
if (entry.active === job) entry.active = null;
|
|
324
|
+
if (cause) job.response.reject(cause);
|
|
325
|
+
else job.response.resolve(result);
|
|
326
|
+
job.settled.resolve();
|
|
327
|
+
if (activation === entry && !entry.retirement) {
|
|
328
|
+
state = entry.ready ? 'ready' : 'unloaded';
|
|
329
|
+
if (cause && !job.signal.aborted) error = cause;
|
|
330
|
+
progress = null;
|
|
331
|
+
publish();
|
|
332
|
+
}
|
|
333
|
+
advance(entry);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function stop({requestId} = {}) {
|
|
337
|
+
const job = requests.get(requestId);
|
|
338
|
+
if (!job) return {requestId, stopped: false};
|
|
339
|
+
requireOutsideCallback('Stop this Magpie utterance', job);
|
|
340
|
+
job.controller.abort(cancellation('The owning caller stopped this utterance.'));
|
|
341
|
+
await job.settled.promise;
|
|
342
|
+
if (job.failure && job.failure !== job.signal.reason) throw job.failure;
|
|
343
|
+
return {requestId, stopped: true};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function unload() {
|
|
347
|
+
requireOutsideCallback('Unload Magpie');
|
|
348
|
+
if (activation) retire(activation, cancellation('The Magpie engine is unloading.')).catch(reportFailure);
|
|
349
|
+
const results = await Promise.allSettled([...retirements]);
|
|
350
|
+
const failures = results.filter(function rejected(result) { return result.status === 'rejected'; })
|
|
351
|
+
.map(function reason(result) { return result.reason; });
|
|
352
|
+
if (failures.length) throw new AggregateError(failures, 'Unloading Magpie failed.');
|
|
353
|
+
return current();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function close() {
|
|
357
|
+
try { requireOutsideCallback('Close Magpie'); }
|
|
358
|
+
catch (error) { return Promise.reject(error); }
|
|
359
|
+
if (closing) return closing;
|
|
360
|
+
closing = Promise.resolve().then(async function closeEngine() {
|
|
361
|
+
try { await unload(); }
|
|
362
|
+
finally {
|
|
363
|
+
signal?.removeEventListener('abort', lifetimeEnded);
|
|
364
|
+
state = 'disposed';
|
|
365
|
+
publish();
|
|
366
|
+
disposed = true;
|
|
367
|
+
events.dispose();
|
|
368
|
+
}
|
|
369
|
+
return current();
|
|
370
|
+
});
|
|
371
|
+
return closing;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function deliver(job, callback, value) {
|
|
375
|
+
if (!callback) return;
|
|
376
|
+
const context = {job, active: true};
|
|
377
|
+
try { await callbacks.run(context, callback, value); }
|
|
378
|
+
finally { context.active = false; }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function requireOutsideCallback(operation, job) {
|
|
382
|
+
const context = callbacks.getStore();
|
|
383
|
+
if (context?.active && (!job || context.job === job)) {
|
|
384
|
+
throw failure('MAGPIE_CALLBACK_WAIT', `${operation} after the output callback returns; abort the caller-owned signal synchronously and join outside the callback.`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function lifetimeEnded() {
|
|
389
|
+
// The lifetime listener owns this background join; the callback only aborts.
|
|
390
|
+
callbacks.exit(function closeAfterLifetime() { close().catch(reportFailure); });
|
|
391
|
+
}
|
|
392
|
+
signal?.addEventListener('abort', lifetimeEnded, {once: true});
|
|
393
|
+
if (signal?.aborted) lifetimeEnded();
|
|
394
|
+
return {current, subscribe, load, unload, synthesize, synthesizeStream, stop, close};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function selectedVoice(voices, voice) {
|
|
398
|
+
if (!is.string(voice)) throw new TypeError('A Magpie voice must name a voice in the selected model speaker map.');
|
|
399
|
+
const name = Object.keys(voices).find(function sameVoice(candidate) { return candidate.toLowerCase() === voice.toLowerCase(); });
|
|
400
|
+
const speaker = name === undefined ? undefined : voices[name];
|
|
401
|
+
if (!is.integer(speaker) || speaker < 0 || speaker > 0x7fffffff) {
|
|
402
|
+
throw failure('MAGPIE_VOICE_UNAVAILABLE', `The selected model has no voice ${voice}.`);
|
|
403
|
+
}
|
|
404
|
+
return speaker;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function representable(value, name) {
|
|
408
|
+
if (value.includes('\0') || !value.isWellFormed()) {
|
|
409
|
+
throw failure('MAGPIE_TEXT_UNREPRESENTABLE', `The public UTF-8 C string interface cannot consume this complete ${name}.`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function sameSelection(left, right) {
|
|
414
|
+
return left.paths === right.paths && left.assetProjectionId === right.assetProjectionId
|
|
415
|
+
&& left.resourcePaths === right.resourcePaths && left.executionTarget === right.executionTarget;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function encodeWav(chunks, sampleRate) {
|
|
419
|
+
const audio = Buffer.concat(chunks);
|
|
420
|
+
// RIFF fields are external format framing, never synthesis admission or truncation.
|
|
421
|
+
if (audio.length + 36 > 0xffffffff) {
|
|
422
|
+
throw failure('MAGPIE_WAV_FORMAT_UNAVAILABLE', 'This complete audio exceeds RIFF WAV framing; select PCM or streaming output.');
|
|
423
|
+
}
|
|
424
|
+
const header = Buffer.alloc(44);
|
|
425
|
+
header.write('RIFF', 0);
|
|
426
|
+
header.writeUInt32LE(audio.length + 36, 4);
|
|
427
|
+
header.write('WAVEfmt ', 8);
|
|
428
|
+
header.writeUInt32LE(16, 16);
|
|
429
|
+
header.writeUInt16LE(1, 20);
|
|
430
|
+
header.writeUInt16LE(1, 22);
|
|
431
|
+
header.writeUInt32LE(sampleRate, 24);
|
|
432
|
+
header.writeUInt32LE(sampleRate * 2, 28);
|
|
433
|
+
header.writeUInt16LE(2, 32);
|
|
434
|
+
header.writeUInt16LE(16, 34);
|
|
435
|
+
header.write('data', 36);
|
|
436
|
+
header.writeUInt32LE(audio.length, 40);
|
|
437
|
+
return Buffer.concat([header, audio]);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function waitFor(task, signal) {
|
|
441
|
+
if (!signal) return task;
|
|
442
|
+
signal.throwIfAborted();
|
|
443
|
+
return new Promise(function waitForLoad(resolve, reject) {
|
|
444
|
+
function cancel() { signal.removeEventListener('abort', cancel); reject(signal.reason); }
|
|
445
|
+
signal.addEventListener('abort', cancel, {once: true});
|
|
446
|
+
task.then(function loaded(result) {
|
|
447
|
+
signal.removeEventListener('abort', cancel);
|
|
448
|
+
if (signal.aborted) reject(signal.reason);
|
|
449
|
+
else resolve(result);
|
|
450
|
+
}, function failed(error) {
|
|
451
|
+
signal.removeEventListener('abort', cancel);
|
|
452
|
+
reject(error);
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function deferred() {
|
|
458
|
+
const result = {};
|
|
459
|
+
result.promise = new Promise(function awaitResult(resolve, reject) { result.resolve = resolve; result.reject = reject; });
|
|
460
|
+
result.promise.catch(function observeResultFailure() {});
|
|
461
|
+
return result;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function failure(code, message) {
|
|
465
|
+
const error = new Error(message);
|
|
466
|
+
error.code = code;
|
|
467
|
+
return error;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function cancellation(message) {
|
|
471
|
+
const error = failure('ARCANE_AI_REQUEST_ABORTED', message);
|
|
472
|
+
error.name = 'AbortError';
|
|
473
|
+
return error;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function combine(left, right, message) {
|
|
477
|
+
return left === right ? left : new AggregateError([left, right], message, {cause: left});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function notify(listener, snapshot) {
|
|
481
|
+
const result = listener(snapshot);
|
|
482
|
+
if (result && is.function(result.then)) Promise.resolve(result).catch(reportFailure);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function reportFailure(error) { console.error('Magpie operation or cleanup failed.', error); }
|
|
486
|
+
function reportLoadFailure(error) { if (error?.name !== 'AbortError') reportFailure(error); }
|
|
487
|
+
|
|
488
|
+
export default createMagpieRuntime;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
cmake_minimum_required(VERSION 3.26)
|
|
2
|
+
project(ArcaneMagpie LANGUAGES CXX)
|
|
3
|
+
|
|
4
|
+
find_package(NeMoSpeech REQUIRED COMPONENTS TTS)
|
|
5
|
+
find_package(Threads REQUIRED)
|
|
6
|
+
|
|
7
|
+
add_executable(arcane-magpie main.cpp)
|
|
8
|
+
target_compile_features(arcane-magpie PRIVATE cxx_std_17)
|
|
9
|
+
target_link_libraries(arcane-magpie PRIVATE NeMoSpeech::TTS Threads::Threads)
|
|
10
|
+
install(TARGETS arcane-magpie RUNTIME DESTINATION bin)
|