arcane-os 0.57.0 → 0.59.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 (60) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +6 -2
  3. package/browser-runtime/ai/core-onnx.mjs +96 -0
  4. package/browser-runtime/ai/onnx-tensors.mjs +85 -0
  5. package/browser-runtime/core/client.mjs +152 -17
  6. package/docs/architecture.md +5 -2
  7. package/docs/platform-targets.md +30 -13
  8. package/docs/reference/README.md +22 -3
  9. package/docs/reference/availability-and-normalization.md +14 -7
  10. package/docs/reference/cli.md +22 -10
  11. package/docs/reference/core-client.md +87 -0
  12. package/docs/reference/core-native-packaging.md +129 -9
  13. package/docs/reference/core-preferences.md +49 -0
  14. package/docs/reference/diarization.md +206 -0
  15. package/docs/reference/git-text-snapshot.md +104 -0
  16. package/docs/reference/inventory/package-api.json +205 -23
  17. package/docs/reference/local-ai.md +145 -11
  18. package/docs/reference/protocols.md +28 -19
  19. package/docs/reference/pwa.md +74 -4
  20. package/docs/reference/sdk-api.md +589 -39
  21. package/package.json +11 -3
  22. package/schemas/arcane-app.schema.json +3 -3
  23. package/schemas/arcane-package.schema.json +2 -2
  24. package/schemas/native-build-plan.schema.json +27 -1
  25. package/schemas/target-adapter.schema.json +2 -0
  26. package/src/app-descriptor.mjs +1 -1
  27. package/src/cli/main.mjs +14 -4
  28. package/src/constants.mjs +2 -0
  29. package/src/core/host.mjs +14 -5
  30. package/src/core/hosts/macos/ArcaneHost.h +86 -0
  31. package/src/core/hosts/macos/ArcaneHost.m +455 -0
  32. package/src/core/hosts/macos/ArcaneLauncher.m +432 -0
  33. package/src/core/hosts/windows/ArcaneLauncher.cs +4 -1
  34. package/src/core/services/local-ai.mjs +82 -4
  35. package/src/core/services/packaged-web.mjs +89 -0
  36. package/src/core/services/preferences.mjs +201 -0
  37. package/src/core-classic-source.mjs +2 -2
  38. package/src/dev-server.mjs +2 -5
  39. package/src/diarization/build.mjs +35 -0
  40. package/src/diarization/index.mjs +432 -0
  41. package/src/diarization/native/CMakeLists.txt +9 -0
  42. package/src/diarization/native/main.cpp +269 -0
  43. package/src/diarization/service.mjs +129 -0
  44. package/src/git-text-snapshot.mjs +265 -0
  45. package/src/import-map.mjs +9 -4
  46. package/src/index.mjs +1 -0
  47. package/src/local-ai/config.mjs +1 -1
  48. package/src/local-ai/install.mjs +134 -19
  49. package/src/local-ai/onnx-worker.mjs +85 -0
  50. package/src/local-ai/onnx.mjs +368 -0
  51. package/src/native/macos-provider.mjs +291 -0
  52. package/src/native/portable-layout.mjs +10 -2
  53. package/src/native/portable-provider.mjs +10 -5
  54. package/src/native-plan.mjs +9 -1
  55. package/src/native-provider-loader.mjs +3 -1
  56. package/src/process.mjs +123 -17
  57. package/src/scaffold.mjs +1 -1
  58. package/src/targets/index.mjs +11 -1
  59. package/src/templates/workspace-template.mjs +6 -5
  60. package/src/toolchain.mjs +42 -13
@@ -0,0 +1,432 @@
1
+ import {PassThrough} from 'node:stream';
2
+ import {AsyncLocalStorage} from 'node:async_hooks';
3
+ import path from 'node:path';
4
+ import Is from 'strong-type';
5
+ import {runProcess} from '../process.mjs';
6
+ import {createArcaneEventSource} from '../event-manager.mjs';
7
+
8
+ const is = new Is(false);
9
+
10
+ /** One retained CPU model, with independently owned caller-fed audio streams. */
11
+ export function createDiarization({executable, modelPath, runtime, onEvent} = {}) {
12
+ if (!is.string(executable) || !is.string(modelPath)) {
13
+ throw new TypeError('Diarization requires its helper executable and selected GGUF model path.');
14
+ }
15
+ const input = new PassThrough();
16
+ const streams = new Map();
17
+ const opening = new Set();
18
+ const callbackContext = new AsyncLocalStorage();
19
+ const pending = new Map();
20
+ const events = createArcaneEventSource(
21
+ {},
22
+ {source: 'diarization', eventTypes: ['diarization.state']}
23
+ );
24
+ let state = 'loading';
25
+ let error = null;
26
+ let metadata = null;
27
+ let fragment = '';
28
+ let requestId = 0;
29
+ let streamId = 0;
30
+ let writeTail = Promise.resolve();
31
+ let closing = null;
32
+ let closingRequested = false;
33
+ let eventsDisposed = false;
34
+ let resolveReady;
35
+ let rejectReady;
36
+ const ready = new Promise(function modelReadiness(resolve, reject) {
37
+ resolveReady = resolve;
38
+ rejectReady = reject;
39
+ });
40
+ // Startup starts independently. Failure stays observable through ready and
41
+ // current state even before the first caller requests a stream.
42
+ ready.catch(function observeReadinessFailure() {});
43
+
44
+ function current() {
45
+ return {state, model: 'nvidia/Nemotron-3-Diarization', modelPath, ...metadata, error};
46
+ }
47
+
48
+ function publish() {
49
+ if (!eventsDisposed) events.dispatch('diarization.state', current());
50
+ }
51
+
52
+ function subscribe(listener, {emitCurrent = true, signal} = {}) {
53
+ if (eventsDisposed) {
54
+ if (emitCurrent && !signal?.aborted) listener(current());
55
+ return function alreadyClosed() {};
56
+ }
57
+ function deliver(occurrence) { listener(occurrence.detail); }
58
+ const unsubscribe = events.on('diarization.state', deliver, {signal});
59
+ try {
60
+ if (emitCurrent && !signal?.aborted) listener(current());
61
+ } catch (failure) {
62
+ unsubscribe();
63
+ throw failure;
64
+ }
65
+ return unsubscribe;
66
+ }
67
+
68
+ function fail(failure) {
69
+ error = error && error !== failure
70
+ ? new AggregateError([error, failure], 'The diarization helper reported multiple failures.')
71
+ : failure;
72
+ state = 'error';
73
+ rejectReady(error);
74
+ for (const rejectStream of streams.values()) rejectStream(error);
75
+ for (const operation of pending.values()) operation.reject(error);
76
+ pending.clear();
77
+ input.end();
78
+ publish();
79
+ return error;
80
+ }
81
+
82
+ async function receive({stream, chunk}) {
83
+ if (stream !== 'stdout') return;
84
+ fragment += chunk;
85
+ let boundary = fragment.indexOf('\n');
86
+ while (boundary !== -1) {
87
+ const line = fragment.substring(0, boundary);
88
+ fragment = fragment.substring(boundary + 1);
89
+ if (line !== '') {
90
+ let message;
91
+ try {
92
+ message = JSON.parse(line);
93
+ } catch (failure) {
94
+ const unreadable = diarizationError('DIARIZATION_PROTOCOL_UNREADABLE', 'The helper returned an unreadable response.', {line});
95
+ unreadable.cause = failure;
96
+ throw unreadable;
97
+ }
98
+ if (message.ready) {
99
+ metadata = {speakers: message.speakers, secondsPerFrame: message.secondsPerFrame};
100
+ if (!closingRequested) state = 'ready';
101
+ resolveReady(current());
102
+ publish();
103
+ } else if (message.request === 0 && message.error) {
104
+ fail(diarizationError('DIARIZATION_MODEL_FAILED', message.error));
105
+ } else {
106
+ const operation = pending.get(message.request);
107
+ if (!operation) throw diarizationError('DIARIZATION_PROTOCOL_UNKNOWN_REQUEST', `Unknown diarization response ${message.request}.`, message);
108
+ pending.delete(message.request);
109
+ if (message.error) {
110
+ operation.reject(diarizationError('DIARIZATION_NATIVE_FAILED', message.error));
111
+ } else {
112
+ operation.resolve(message.result ?? message);
113
+ }
114
+ }
115
+ }
116
+ boundary = fragment.indexOf('\n');
117
+ }
118
+ }
119
+
120
+ const processTask = runProcess(executable, [modelPath], {
121
+ env: runtimeEnvironment(runtime),
122
+ input: {
123
+ [Symbol.asyncIterator]: function helperInput() {
124
+ const iterator = input[Symbol.asyncIterator]();
125
+ return {
126
+ next: function nextInput() { return iterator.next(); },
127
+ return: function closeInput() {
128
+ // Cooperate with a pending next() when the process exits
129
+ // unexpectedly; this producer belongs to this model.
130
+ input.end();
131
+ return iterator.return();
132
+ }
133
+ };
134
+ }
135
+ },
136
+ cancellationMode: 'close-input',
137
+ onOutput: receive,
138
+ captureOutput: {stdout: false, stderr: true},
139
+ emitOutputEvents: {stdout: false, stderr: true},
140
+ onEvent
141
+ });
142
+ const completion = processTask.then(
143
+ function helperExited(result) {
144
+ if (fragment !== '') {
145
+ throw diarizationError('DIARIZATION_PROTOCOL_INCOMPLETE', 'The helper exited during a response.', fragment);
146
+ }
147
+ if (!closingRequested || pending.size) {
148
+ throw diarizationError('DIARIZATION_HELPER_EXITED', 'The diarization helper exited before its work completed.', result);
149
+ }
150
+ state = 'closed';
151
+ publish();
152
+ return result;
153
+ }
154
+ ).catch(function helperFailed(failure) {
155
+ throw fail(failure);
156
+ }).finally(function disposeModelEvents() {
157
+ eventsDisposed = true;
158
+ events.dispose();
159
+ });
160
+ completion.catch(function observeHelperFailure() {});
161
+
162
+ function request(operation, id, parameters = '', audio) {
163
+ if (error) return Promise.reject(error);
164
+ const idOfRequest = ++requestId;
165
+ let resolveResponse;
166
+ let rejectResponse;
167
+ const response = new Promise(function nativeResponse(resolve, reject) {
168
+ resolveResponse = resolve;
169
+ rejectResponse = reject;
170
+ });
171
+ pending.set(idOfRequest, {resolve: resolveResponse, reject: rejectResponse});
172
+ const writing = writeTail.then(async function writeCommand() {
173
+ if (error) throw error;
174
+ await writeInput(input, `${operation} ${idOfRequest} ${id}${parameters ? ` ${parameters}` : ''}\n`);
175
+ if (audio) await writeInput(input, audio);
176
+ });
177
+ writeTail = writing.catch(function commandWriteFailed(failure) {
178
+ pending.delete(idOfRequest);
179
+ rejectResponse(failure);
180
+ fail(failure);
181
+ });
182
+ return response;
183
+ }
184
+
185
+ function openStream(options) {
186
+ const task = openOwnedStream(options);
187
+ opening.add(task);
188
+ function releaseOpening() { opening.delete(task); }
189
+ task.then(releaseOpening, releaseOpening);
190
+ return task;
191
+ }
192
+
193
+ async function openOwnedStream({sampleRate = 16000, onUpdate, onProbabilities, signal} = {}) {
194
+ if (closingRequested) throw diarizationError('DIARIZATION_CLOSING', 'Diarization is closing.');
195
+ signal?.throwIfAborted();
196
+ if (!is.integer(sampleRate) || (sampleRate !== 0 && (sampleRate < 8000 || sampleRate > 96000))) {
197
+ throw new TypeError('sampleRate must be 0 (model rate) or an integer from 8000 through 96000.');
198
+ }
199
+ if (onUpdate !== undefined && !is.function(onUpdate)) throw new TypeError('onUpdate must be a function.');
200
+ if (onProbabilities !== undefined && !is.function(onProbabilities)) throw new TypeError('onProbabilities must be a function.');
201
+ await ready;
202
+ signal?.throwIfAborted();
203
+ if (closingRequested) throw diarizationError('DIARIZATION_CLOSING', 'Diarization is closing.');
204
+ const id = ++streamId;
205
+ await request('open', id, `${sampleRate} ${onProbabilities ? 1 : 0}`);
206
+ if (closingRequested || signal?.aborted) {
207
+ await request('close', id);
208
+ signal?.throwIfAborted();
209
+ throw diarizationError('DIARIZATION_CLOSING', 'Diarization is closing.');
210
+ }
211
+ let tail = Promise.resolve();
212
+ let stopping = null;
213
+ let releasing = null;
214
+ let ended = false;
215
+ let cancelled = null;
216
+ let latest = null;
217
+ let completeStream;
218
+ let failStream;
219
+ const streamCompletion = new Promise(function streamLifetime(resolve, reject) {
220
+ completeStream = resolve;
221
+ failStream = reject;
222
+ });
223
+ streamCompletion.catch(function observeStreamFailure() {});
224
+
225
+ function ownStop(task) {
226
+ stopping = task;
227
+ task.then(completeStream, failStream);
228
+ return task;
229
+ }
230
+
231
+ function requireActive() {
232
+ if (cancelled) throw cancelled;
233
+ if (error) throw error;
234
+ signal?.throwIfAborted();
235
+ }
236
+
237
+ function enqueue(operation) {
238
+ const result = tail.then(operation);
239
+ tail = result.then(function fulfilled() {}, function rejected() {});
240
+ return result;
241
+ }
242
+
243
+ async function deliver(callback, value) {
244
+ const delivery = {stream, active: true};
245
+ try {
246
+ return await callbackContext.run(delivery, callback, value);
247
+ } finally {
248
+ delivery.active = false;
249
+ }
250
+ }
251
+
252
+ function insideCallback() {
253
+ const delivery = callbackContext.getStore();
254
+ return delivery?.active && delivery.stream === stream;
255
+ }
256
+
257
+ async function accept(result) {
258
+ requireActive();
259
+ if (result.ok) return latest;
260
+ const {probabilities, ...snapshot} = result;
261
+ if (probabilities) {
262
+ await deliver(onProbabilities, {
263
+ ...probabilities, speakers: result.speakers, secondsPerFrame: result.secondsPerFrame
264
+ });
265
+ requireActive();
266
+ }
267
+ if (onUpdate) {
268
+ await deliver(onUpdate, snapshot);
269
+ requireActive();
270
+ }
271
+ latest = snapshot;
272
+ return snapshot;
273
+ }
274
+
275
+ function release() {
276
+ if (releasing) return releasing;
277
+ releasing = (async function releaseStream() {
278
+ signal?.removeEventListener('abort', abort);
279
+ try {
280
+ await request('close', id);
281
+ } finally {
282
+ streams.delete(stream);
283
+ ended = true;
284
+ }
285
+ })();
286
+ return releasing;
287
+ }
288
+
289
+ function push(audio) {
290
+ if (insideCallback()) {
291
+ return Promise.reject(diarizationError('DIARIZATION_CALLBACK_WAIT', 'Push more audio after the output callback returns; cancel() is available inside callbacks.'));
292
+ }
293
+ if (!is.float32Array(audio)) return Promise.reject(new TypeError('Audio must be a mono Float32Array.'));
294
+ if (stopping || ended) return Promise.reject(diarizationError('DIARIZATION_STREAM_CLOSED', 'The audio stream has ended.'));
295
+ return enqueue(async function pushAudio() {
296
+ requireActive();
297
+ // Upstream advances on model chunks. Read results between 160 ms
298
+ // pushes so probability history is delivered before compaction.
299
+ const samplesPerPush = Math.round((sampleRate || 16000) * 0.16);
300
+ for (let offset = 0; offset < audio.length; offset += samplesPerPush) {
301
+ requireActive();
302
+ const samples = audio.subarray(offset, offset + samplesPerPush);
303
+ const result = await request('push', id, String(samples.length), encodeAudio(samples));
304
+ await accept(result);
305
+ }
306
+ return latest;
307
+ });
308
+ }
309
+
310
+ function finish() {
311
+ if (insideCallback()) {
312
+ return Promise.reject(diarizationError('DIARIZATION_CALLBACK_WAIT', 'Finish the stream after its output callback returns; cancel() is available inside callbacks.'));
313
+ }
314
+ if (stopping) return stopping;
315
+ return ownStop(enqueue(async function finishAudio() {
316
+ let failure;
317
+ let result;
318
+ try {
319
+ requireActive();
320
+ result = await accept(await request('finish', id));
321
+ } catch (operationFailure) {
322
+ failure = operationFailure;
323
+ }
324
+ try { await release(); } catch (cleanupFailure) {
325
+ if (failure && failure !== cleanupFailure) throw new AggregateError([failure, cleanupFailure], 'Finishing diarization and closing its stream failed.');
326
+ throw cleanupFailure;
327
+ }
328
+ if (failure) throw failure;
329
+ return result;
330
+ }));
331
+ }
332
+
333
+ function cancel(reason) {
334
+ if (ended) return Promise.resolve();
335
+ cancelled ??= diarizationError('DIARIZATION_CANCELLED', 'Diarization was cancelled.', reason);
336
+ // Close follows the already-written native call, not the caller's
337
+ // callback/JS queue. A callback can therefore await cancel safely.
338
+ const drained = release();
339
+ if (!stopping) ownStop(drained);
340
+ return drained;
341
+ }
342
+
343
+ function abort() {
344
+ // The stream's completion retains a cleanup failure. Cancellation
345
+ // of this caller never changes another stream or the shared model.
346
+ cancel(signal.reason).catch(function observeCancellationFailure() {});
347
+ }
348
+
349
+ const stream = {push, finish, cancel, completion: streamCompletion, current: function streamResult() { return latest; }};
350
+ streams.set(stream, failStream);
351
+ signal?.addEventListener('abort', abort, {once: true});
352
+ if (signal?.aborted) {
353
+ await cancel(signal.reason);
354
+ signal.throwIfAborted();
355
+ }
356
+ return stream;
357
+ }
358
+
359
+ async function diarize({audio, ...options} = {}) {
360
+ const stream = await openStream(options);
361
+ try {
362
+ await stream.push(audio);
363
+ return await stream.finish();
364
+ } catch (failure) {
365
+ try { await stream.cancel(failure); } catch (cleanupFailure) {
366
+ if (cleanupFailure !== failure) throw new AggregateError([failure, cleanupFailure], 'Diarization and stream cleanup failed.');
367
+ }
368
+ throw failure;
369
+ }
370
+ }
371
+
372
+ function close() {
373
+ if (callbackContext.getStore()?.active) {
374
+ return Promise.reject(diarizationError('DIARIZATION_CALLBACK_WAIT', 'Close diarization after its output callback returns; stream.cancel() is available inside callbacks.'));
375
+ }
376
+ if (closing) return closing;
377
+ closingRequested = true;
378
+ state = 'closing';
379
+ closing = (async function drainModel() {
380
+ await Promise.allSettled([...opening]);
381
+ const results = await Promise.allSettled([...streams.keys()].map(function finishStream(stream) {
382
+ return stream.finish();
383
+ }));
384
+ await writeTail;
385
+ input.end();
386
+ try {
387
+ await completion;
388
+ } catch (failure) {
389
+ results.push({status: 'rejected', reason: failure});
390
+ }
391
+ const failures = results.filter(function rejected(result) {
392
+ return result.status === 'rejected';
393
+ }).map(function reason(result) { return result.reason; });
394
+ if (failures.length) throw new AggregateError(failures, 'Diarization failed while draining.');
395
+ })();
396
+ publish();
397
+ return closing;
398
+ }
399
+
400
+ return {ready, completion, current, subscribe, openStream, diarize, close};
401
+ }
402
+
403
+ function writeInput(input, value) {
404
+ return new Promise(function writeFrame(resolve, reject) {
405
+ input.write(value, function inputWritten(error) {
406
+ if (error) reject(error);
407
+ else resolve();
408
+ });
409
+ });
410
+ }
411
+
412
+ function encodeAudio(samples) {
413
+ const encoded = Buffer.allocUnsafe(samples.length * Float32Array.BYTES_PER_ELEMENT);
414
+ const bits = new Uint32Array(samples.buffer, samples.byteOffset, samples.length);
415
+ for (let index = 0; index < samples.length; index++) encoded.writeUInt32LE(bits[index], index * 4);
416
+ return encoded;
417
+ }
418
+
419
+ function runtimeEnvironment(runtime) {
420
+ if (!runtime) return undefined;
421
+ const directories = [runtime.binaryDirectory, runtime.libraryDirectory].filter(Boolean);
422
+ if (!directories.length) return undefined;
423
+ const variable = process.platform === 'win32' ? 'PATH' : process.platform === 'darwin' ? 'DYLD_LIBRARY_PATH' : 'LD_LIBRARY_PATH';
424
+ return {[variable]: [...directories, process.env[variable]].filter(Boolean).join(path.delimiter)};
425
+ }
426
+
427
+ function diarizationError(code, message, details) {
428
+ const error = new Error(message);
429
+ error.code = code;
430
+ error.details = details;
431
+ return error;
432
+ }
@@ -0,0 +1,9 @@
1
+ cmake_minimum_required(VERSION 3.26)
2
+ project(ArcaneDiarization LANGUAGES CXX)
3
+
4
+ find_package(NeMoSpeech REQUIRED COMPONENTS Diarization)
5
+
6
+ add_executable(arcane-diarization main.cpp)
7
+ target_compile_features(arcane-diarization PRIVATE cxx_std_17)
8
+ target_link_libraries(arcane-diarization PRIVATE NeMoSpeech::Diarization)
9
+ install(TARGETS arcane-diarization RUNTIME DESTINATION bin)