arcane-os 0.94.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 +18 -0
- package/README.md +1 -1
- package/browser-runtime/core/client.mjs +195 -0
- package/docs/reference/inventory/package-api.json +1 -1
- package/docs/reference/native-magpie.md +98 -0
- package/docs/reference/native-nemotron.md +204 -0
- package/docs/reference/native-speech.md +145 -1
- package/package.json +5 -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/src/core/services/speech.mjs +235 -2
- 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
|
@@ -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
|
+
}
|