arcane-os 0.63.0 → 0.64.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 +22 -0
- package/README.md +1 -1
- package/browser-runtime/core/client.mjs +7 -2
- package/docs/reference/README.md +2 -0
- package/docs/reference/core-client.md +11 -2
- package/docs/reference/core-native-packaging.md +35 -6
- package/docs/reference/core-repositories.md +149 -0
- package/docs/reference/inventory/package-api.json +59 -3
- package/docs/reference/local-ai.md +5 -2
- package/docs/reference/local-image-flux.md +1 -1
- package/docs/reference/local-image-generation.md +6 -0
- package/docs/reference/local-image-sdxl.md +139 -0
- package/docs/reference/local-whisper.md +219 -0
- package/docs/reference/native-speech.md +51 -2
- package/docs/reference/runtime-modules.md +16 -2
- package/docs/reference/sdk-api.md +162 -2
- package/package.json +4 -1
- package/runtime/arcane/components/speech.html +15 -3
- package/runtime/arcane/components/voice-transcription.html +19 -3
- package/runtime/arcane/modules/AI.js +250 -27
- package/runtime/arcane/modules/AIProviderRuntime.js +20 -3
- package/runtime/arcane/modules/AIRuntimeState.js +15 -2
- package/runtime/arcane/modules/ComponentContracts.js +3 -0
- package/schemas/arcane-app.schema.json +53 -2
- package/schemas/arcane-package.schema.json +53 -2
- package/src/core/repositories.mjs +210 -0
- package/src/core/services/speech.mjs +1 -0
- package/src/local-ai/config.mjs +7 -3
- package/src/local-ai/dev.mjs +32 -2
- package/src/local-ai/image-library.mjs +9 -0
- package/src/local-ai/image-models/sdxl.mjs +18 -0
- package/src/local-ai/install.mjs +26 -7
- package/src/local-ai/whisper/build.mjs +48 -0
- package/src/local-ai/whisper/config.mjs +61 -0
- package/src/local-ai/whisper/index.mjs +515 -0
- package/src/local-ai/whisper/install.mjs +274 -0
- package/src/local-ai/whisper/native/CMakeLists.txt +98 -0
- package/src/local-ai/whisper/native/NOTICE +18 -0
- package/src/local-ai/whisper/native/README.md +104 -0
- package/src/local-ai/whisper/native/main.cpp +489 -0
- package/src/native/portable-layout.mjs +37 -7
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import {mkdir, readdir} from 'node:fs/promises';
|
|
2
|
+
import {homedir} from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import Is from 'strong-type';
|
|
5
|
+
import {ArcaneError, throwIfAborted} from '../errors.mjs';
|
|
6
|
+
import {runProcess} from '../process.mjs';
|
|
7
|
+
import {repositoryPull, repositoryPush, repositoryStatus} from '../repository.mjs';
|
|
8
|
+
|
|
9
|
+
const is = new Is(false);
|
|
10
|
+
const directoryOperations = new Map();
|
|
11
|
+
|
|
12
|
+
/** Resolve persistent native data without creating or migrating anything. */
|
|
13
|
+
export function resolveArcaneDataPaths({dataRoot} = {}) {
|
|
14
|
+
let root = dataRoot;
|
|
15
|
+
if (root === undefined) {
|
|
16
|
+
const home = homedir();
|
|
17
|
+
switch (process.platform) {
|
|
18
|
+
case 'win32':
|
|
19
|
+
root = path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'ArcaneData');
|
|
20
|
+
break;
|
|
21
|
+
case 'darwin':
|
|
22
|
+
root = path.join(home, 'Library', 'Application Support', 'ArcaneData');
|
|
23
|
+
break;
|
|
24
|
+
case 'linux': {
|
|
25
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
26
|
+
const base = xdg && path.isAbsolute(xdg) ? xdg : path.join(home, '.local', 'share');
|
|
27
|
+
root = path.join(base, 'ArcaneData');
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
default:
|
|
31
|
+
throw new ArcaneError(
|
|
32
|
+
'ARCANE_DATA_ROOT_REQUIRED',
|
|
33
|
+
'This native host must supply its persistent ArcaneData directory through dataRoot.'
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
requireString(root, 'dataRoot');
|
|
38
|
+
const resolved = path.resolve(root);
|
|
39
|
+
return {dataRoot: resolved, repositoriesRoot: path.join(resolved, 'Repos')};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One connected checkout, composed into an application's own Core service. */
|
|
43
|
+
export function createRepositoryWorkspace(
|
|
44
|
+
{name, directory, dataRoot, remote, branch, onEvent, run = runProcess} = {}
|
|
45
|
+
) {
|
|
46
|
+
let selected = directory;
|
|
47
|
+
if (selected === undefined) {
|
|
48
|
+
requireString(name, 'name');
|
|
49
|
+
if (name === '.' || name === '..' || /[/\\]/u.test(name)) {
|
|
50
|
+
throw new TypeError('name must be one directory name; use directory for an explicit repository path.');
|
|
51
|
+
}
|
|
52
|
+
selected = path.join(resolveArcaneDataPaths({dataRoot}).repositoriesRoot, name);
|
|
53
|
+
}
|
|
54
|
+
requireString(selected, 'directory');
|
|
55
|
+
if (remote !== undefined) requireString(remote, 'remote');
|
|
56
|
+
if (branch !== undefined) requireString(branch, 'branch');
|
|
57
|
+
if (!is.function(run)) throw new TypeError('run must implement the SDK process adapter.');
|
|
58
|
+
const repositoryDirectory = path.resolve(selected);
|
|
59
|
+
const pending = new Set();
|
|
60
|
+
let opened = false;
|
|
61
|
+
let closing = null;
|
|
62
|
+
|
|
63
|
+
async function prepare(signal) {
|
|
64
|
+
throwIfAborted(signal);
|
|
65
|
+
if (opened) return {directory: repositoryDirectory, cloned: false};
|
|
66
|
+
let entries;
|
|
67
|
+
try {
|
|
68
|
+
entries = await readdir(repositoryDirectory);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error.code !== 'ENOENT') throw error;
|
|
71
|
+
entries = [];
|
|
72
|
+
}
|
|
73
|
+
throwIfAborted(signal);
|
|
74
|
+
if (entries.length) {
|
|
75
|
+
// A parent checkout or a bare object cache is not this connection's
|
|
76
|
+
// working root. Inspection never changes an existing destination.
|
|
77
|
+
const result = await run(
|
|
78
|
+
'git',
|
|
79
|
+
['rev-parse', '--is-inside-work-tree', '--show-prefix'],
|
|
80
|
+
{cwd: repositoryDirectory, signal, onEvent}
|
|
81
|
+
);
|
|
82
|
+
throwIfAborted(signal);
|
|
83
|
+
const [inside, prefix] = result.stdout.split(/\r?\n/u);
|
|
84
|
+
if (inside !== 'true' || prefix !== '') {
|
|
85
|
+
throw new ArcaneError(
|
|
86
|
+
'ARCANE_REPOSITORY_DIRECTORY_INVALID',
|
|
87
|
+
'The selected nonempty directory is not a Git working repository root.',
|
|
88
|
+
{details: result}
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
opened = true;
|
|
92
|
+
return {directory: repositoryDirectory, cloned: false};
|
|
93
|
+
}
|
|
94
|
+
requireString(remote, 'remote');
|
|
95
|
+
await mkdir(path.dirname(repositoryDirectory), {recursive: true});
|
|
96
|
+
throwIfAborted(signal);
|
|
97
|
+
const arguments_ = ['clone', '--progress'];
|
|
98
|
+
if (branch !== undefined) arguments_.push('--branch', branch);
|
|
99
|
+
arguments_.push('--', remote, repositoryDirectory);
|
|
100
|
+
const result = await run(
|
|
101
|
+
'git',
|
|
102
|
+
arguments_,
|
|
103
|
+
{cwd: path.dirname(repositoryDirectory), signal, onEvent}
|
|
104
|
+
);
|
|
105
|
+
throwIfAborted(signal);
|
|
106
|
+
opened = true;
|
|
107
|
+
return {directory: repositoryDirectory, cloned: true, stdout: result.stdout, stderr: result.stderr};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function accept(operation, signal) {
|
|
111
|
+
try {
|
|
112
|
+
throwIfAborted(signal);
|
|
113
|
+
if (closing) throw new ArcaneError('CORE_CLOSING', 'The repository workspace is closing.');
|
|
114
|
+
} catch (error) {
|
|
115
|
+
return Promise.reject(error);
|
|
116
|
+
}
|
|
117
|
+
const result = orderDirectoryOperation(
|
|
118
|
+
repositoryDirectory,
|
|
119
|
+
async function runRepositoryOperation() {
|
|
120
|
+
throwIfAborted(signal);
|
|
121
|
+
return operation();
|
|
122
|
+
}
|
|
123
|
+
);
|
|
124
|
+
pending.add(result);
|
|
125
|
+
function settled() {
|
|
126
|
+
pending.delete(result);
|
|
127
|
+
}
|
|
128
|
+
result.then(settled, settled);
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function open({signal} = {}) {
|
|
133
|
+
return accept(
|
|
134
|
+
function openRepository() {
|
|
135
|
+
return prepare(signal);
|
|
136
|
+
},
|
|
137
|
+
signal
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function status({signal} = {}) {
|
|
142
|
+
return accept(
|
|
143
|
+
async function readRepositoryStatus() {
|
|
144
|
+
await prepare(signal);
|
|
145
|
+
return repositoryStatus({workspaceRoot: repositoryDirectory, signal, onEvent, run});
|
|
146
|
+
},
|
|
147
|
+
signal
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function pull({signal} = {}) {
|
|
152
|
+
return accept(
|
|
153
|
+
async function pullRepository() {
|
|
154
|
+
await prepare(signal);
|
|
155
|
+
return repositoryPull({workspaceRoot: repositoryDirectory, signal, onEvent, run});
|
|
156
|
+
},
|
|
157
|
+
signal
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function push({signal} = {}) {
|
|
162
|
+
return accept(
|
|
163
|
+
async function pushRepository() {
|
|
164
|
+
await prepare(signal);
|
|
165
|
+
return repositoryPush({workspaceRoot: repositoryDirectory, signal, onEvent, run});
|
|
166
|
+
},
|
|
167
|
+
signal
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function close() {
|
|
172
|
+
if (!closing) {
|
|
173
|
+
closing = Promise.allSettled([...pending]).then(
|
|
174
|
+
function operationsDrained(results) {
|
|
175
|
+
const failures = results.filter(
|
|
176
|
+
function failed(result) {
|
|
177
|
+
return result.status === 'rejected';
|
|
178
|
+
}
|
|
179
|
+
).map(
|
|
180
|
+
function reason(result) {
|
|
181
|
+
return result.reason;
|
|
182
|
+
}
|
|
183
|
+
);
|
|
184
|
+
if (failures.length) throw new AggregateError(failures, 'Repository operations failed while draining.');
|
|
185
|
+
}
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return closing;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {directory: repositoryDirectory, open, status, pull, push, close, drain: close, dispose: close};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function requireString(value, name) {
|
|
195
|
+
if (!is.string(value) || value === '') throw new TypeError(`${name} must be a nonempty string.`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function orderDirectoryOperation(directory, operation) {
|
|
199
|
+
const key = process.platform === 'win32' ? directory.toLowerCase() : directory;
|
|
200
|
+
const previous = directoryOperations.get(key) ?? Promise.resolve();
|
|
201
|
+
const result = previous.then(operation, operation);
|
|
202
|
+
function releaseQueue() {
|
|
203
|
+
if (directoryOperations.get(key) === tail) directoryOperations.delete(key);
|
|
204
|
+
}
|
|
205
|
+
// Only the scheduling tail settles successfully after an error. Each caller
|
|
206
|
+
// retains its complete failure; a later operation can retry the same path.
|
|
207
|
+
const tail = result.then(releaseQueue, releaseQueue);
|
|
208
|
+
directoryOperations.set(key, tail);
|
|
209
|
+
return result;
|
|
210
|
+
}
|
package/src/local-ai/config.mjs
CHANGED
|
@@ -4,9 +4,11 @@ import Is from 'strong-type';
|
|
|
4
4
|
import {ArcaneError, ERROR_CODES, throwIfAborted} from '../errors.mjs';
|
|
5
5
|
import {SD14_MODEL} from './image-models/sd14.mjs';
|
|
6
6
|
import {FLUX2_KLEIN_4B_MODEL} from './image-models/flux.mjs';
|
|
7
|
+
import {SDXL_BASE_1_0_MODEL} from './image-models/sdxl.mjs';
|
|
8
|
+
import {normalizeWhisperRuntimeRequirement} from './whisper/config.mjs';
|
|
7
9
|
|
|
8
10
|
const is = new Is(false);
|
|
9
|
-
export const LOCAL_AI_RUNTIME_IDS = ['llama.cpp', 'ollama', 'onnx', 'stable-diffusion.cpp'];
|
|
11
|
+
export const LOCAL_AI_RUNTIME_IDS = ['llama.cpp', 'ollama', 'onnx', 'stable-diffusion.cpp', 'whisper.cpp'];
|
|
10
12
|
|
|
11
13
|
/** Image requirements describe assets without acquiring or loading a model. */
|
|
12
14
|
export function normalizeImageRuntimeRequirement(record) {
|
|
@@ -20,9 +22,10 @@ export function normalizeImageRuntimeRequirement(record) {
|
|
|
20
22
|
const models = (record.models ?? []).map(
|
|
21
23
|
function selectedImageModel(value) {
|
|
22
24
|
const model = value === 'sd14' ? SD14_MODEL
|
|
23
|
-
: value === 'flux2-klein-4b' ? FLUX2_KLEIN_4B_MODEL
|
|
25
|
+
: value === 'flux2-klein-4b' ? FLUX2_KLEIN_4B_MODEL
|
|
26
|
+
: value === 'sdxl-base-1.0' ? SDXL_BASE_1_0_MODEL : value;
|
|
24
27
|
if (!model || !is.object(model) || is.array(model) || !is.string(model.id) || !model.id) {
|
|
25
|
-
throw new ArcaneError(ERROR_CODES.usage, 'An image model needs a descriptor with an id, or a built-in model ID (sd14
|
|
28
|
+
throw new ArcaneError(ERROR_CODES.usage, 'An image model needs a descriptor with an id, or a built-in model ID (sd14, flux2-klein-4b, or sdxl-base-1.0).');
|
|
26
29
|
}
|
|
27
30
|
if (!model.resources || !is.object(model.resources) || is.array(model.resources)) {
|
|
28
31
|
throw new ArcaneError(ERROR_CODES.usage, `Image model ${model.id} needs resources keyed by model role.`);
|
|
@@ -59,6 +62,7 @@ export function normalizeLocalAIConfig(value) {
|
|
|
59
62
|
throw new ArcaneError(ERROR_CODES.usage, `Unknown local AI runtime: ${String(record?.id ?? item)}.`);
|
|
60
63
|
}
|
|
61
64
|
if (record.id === 'stable-diffusion.cpp') return normalizeImageRuntimeRequirement(record);
|
|
65
|
+
if (record.id === 'whisper.cpp') return normalizeWhisperRuntimeRequirement(record);
|
|
62
66
|
return {...record};
|
|
63
67
|
});
|
|
64
68
|
return {...value, runtimes};
|
package/src/local-ai/dev.mjs
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
1
2
|
import Is from 'strong-type';
|
|
2
3
|
import {createCoreRuntime} from '../core/runtime.mjs';
|
|
3
4
|
import {createLocalAIService} from '../core/services/local-ai.mjs';
|
|
4
5
|
import {createLocalImageService} from '../core/services/image.mjs';
|
|
5
6
|
import {createModelAssetService} from '../core/services/model-assets.mjs';
|
|
7
|
+
import {createSpeechService} from '../core/services/speech.mjs';
|
|
6
8
|
import {CORE_PROTOCOL, CoreError, serializeCoreError} from '../../browser-runtime/core/contracts.mjs';
|
|
7
9
|
import {normalizeLocalAIConfig} from './config.mjs';
|
|
8
10
|
import {discoverLocalAIRuntimes} from './discover.mjs';
|
|
9
11
|
import {ensureLocalAIRuntimes} from './install.mjs';
|
|
12
|
+
import {createWhisperRuntime} from './whisper/index.mjs';
|
|
10
13
|
|
|
11
14
|
const is = new Is(false);
|
|
12
15
|
|
|
@@ -84,6 +87,7 @@ export function createDevelopmentLocalAI({config, appRoot, directory, applicatio
|
|
|
84
87
|
let runtime;
|
|
85
88
|
let service;
|
|
86
89
|
let imageService;
|
|
90
|
+
let speechService;
|
|
87
91
|
let modelAssets;
|
|
88
92
|
let stopFrames;
|
|
89
93
|
let preparationError = null;
|
|
@@ -162,6 +166,7 @@ export function createDevelopmentLocalAI({config, appRoot, directory, applicatio
|
|
|
162
166
|
for (const record of current.services) writeFrame(connection, event('core.service.state', record));
|
|
163
167
|
writeFrame(connection, event('localai.state', service.current()));
|
|
164
168
|
if (imageService) writeFrame(connection, event('image.state', imageService.current()));
|
|
169
|
+
if (speechService) writeFrame(connection, event('speech.state', speechService.current()));
|
|
165
170
|
if (modelAssets) writeFrame(connection, event('modelAssets.state', modelAssets.current()));
|
|
166
171
|
}
|
|
167
172
|
|
|
@@ -170,7 +175,7 @@ export function createDevelopmentLocalAI({config, appRoot, directory, applicatio
|
|
|
170
175
|
const localConfig = {
|
|
171
176
|
...selected,
|
|
172
177
|
runtimes: selected.runtimes.filter(function selectedChatRuntime(requirement) {
|
|
173
|
-
return requirement.id !== 'stable-diffusion.cpp';
|
|
178
|
+
return requirement.id !== 'stable-diffusion.cpp' && requirement.id !== 'whisper.cpp';
|
|
174
179
|
})
|
|
175
180
|
};
|
|
176
181
|
service = createLocalAIService(
|
|
@@ -203,6 +208,30 @@ export function createDevelopmentLocalAI({config, appRoot, directory, applicatio
|
|
|
203
208
|
);
|
|
204
209
|
services.push(modelAssets, imageService);
|
|
205
210
|
}
|
|
211
|
+
const whisperRequirement = selected.runtimes.find(
|
|
212
|
+
function selectedWhisperRuntime(requirement) {
|
|
213
|
+
return requirement.id === 'whisper.cpp';
|
|
214
|
+
}
|
|
215
|
+
);
|
|
216
|
+
if (whisperRequirement) {
|
|
217
|
+
const stt = createWhisperRuntime(
|
|
218
|
+
{
|
|
219
|
+
modelId: whisperRequirement.modelId,
|
|
220
|
+
temporaryDirectory: path.join(appRoot, '.arcane', 'speech', 'whisper'),
|
|
221
|
+
onEvent,
|
|
222
|
+
async prepare({signal, onEvent: report}) {
|
|
223
|
+
const installed = await ensureLocalAIRuntimes(
|
|
224
|
+
{runtimes: [whisperRequirement], directory, signal, onEvent: report}
|
|
225
|
+
);
|
|
226
|
+
return installed[0];
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
);
|
|
230
|
+
speechService = createSpeechService(
|
|
231
|
+
{stt, signal: operationSignal}
|
|
232
|
+
);
|
|
233
|
+
services.push(speechService);
|
|
234
|
+
}
|
|
206
235
|
runtime = createCoreRuntime({application, version, services});
|
|
207
236
|
stopFrames = runtime.onFrame(routeFrame);
|
|
208
237
|
runtime.start();
|
|
@@ -387,7 +416,8 @@ export function createDevelopmentLocalAI({config, appRoot, directory, applicatio
|
|
|
387
416
|
return {state: closed ? 'closed' : preparationError ? 'error' : runtime ? 'ready' : 'preparing',
|
|
388
417
|
error: preparationError ? serializeCoreError(preparationError) : null,
|
|
389
418
|
core: runtime?.current() ?? null, localAI: service?.current() ?? null,
|
|
390
|
-
image: imageService?.current() ?? null
|
|
419
|
+
image: imageService?.current() ?? null,
|
|
420
|
+
speech: speechService?.current() ?? null};
|
|
391
421
|
}
|
|
392
422
|
|
|
393
423
|
function close() {
|
|
@@ -18,6 +18,15 @@ export function createImageLibrary(
|
|
|
18
18
|
) {
|
|
19
19
|
const requireBinding = createRequire(bindingModulePath);
|
|
20
20
|
const koffi = requireBinding(bindingModulePath);
|
|
21
|
+
const configuration = koffi.config();
|
|
22
|
+
// Async inference needs the normal native stack before Koffi creates its pools.
|
|
23
|
+
if (configuration.async_stack_size < configuration.sync_stack_size) {
|
|
24
|
+
koffi.config(
|
|
25
|
+
{
|
|
26
|
+
async_stack_size: configuration.sync_stack_size
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
}
|
|
21
30
|
const library = loadLibrary(libraryPath);
|
|
22
31
|
const backendDirectory = dirname(libraryPath);
|
|
23
32
|
let backendLibrary;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Official complete SDXL checkpoint; acquisition belongs to the model asset owner. */
|
|
2
|
+
export const SDXL_BASE_1_0_MODEL = {
|
|
3
|
+
id: 'sdxl-base-1.0',
|
|
4
|
+
name: 'SDXL Base 1.0',
|
|
5
|
+
family: 'sdxl',
|
|
6
|
+
resources: {
|
|
7
|
+
model: {
|
|
8
|
+
url: 'https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/462165984030d82259a11f4367a4eed129e94a7b/sd_xl_base_1.0.safetensors',
|
|
9
|
+
filename: 'sd_xl_base_1.0.safetensors'
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
context: {},
|
|
13
|
+
defaults: {
|
|
14
|
+
width: 1024,
|
|
15
|
+
height: 1024
|
|
16
|
+
},
|
|
17
|
+
operations: ['txt2img']
|
|
18
|
+
};
|
package/src/local-ai/install.mjs
CHANGED
|
@@ -10,6 +10,8 @@ import {createEventQueue} from '../event-queue.mjs';
|
|
|
10
10
|
import {runProcess} from '../process.mjs';
|
|
11
11
|
import {extractLocalAIArchive} from './archive.mjs';
|
|
12
12
|
import {normalizeImageRuntimeRequirement} from './config.mjs';
|
|
13
|
+
import {normalizeWhisperRuntimeRequirement} from './whisper/config.mjs';
|
|
14
|
+
import {bundledWhisperRuntime, installedWhisperRuntime, installWhisperDistribution, whisperRuntimeSelection} from './whisper/install.mjs';
|
|
13
15
|
|
|
14
16
|
const is=new Is(false);
|
|
15
17
|
const installations=new Map();
|
|
@@ -21,11 +23,15 @@ function selectRuntimes(runtimes){
|
|
|
21
23
|
const selected=new Map();
|
|
22
24
|
for(const item of runtimes){
|
|
23
25
|
const requirement=is.string(item)?{id:item}:item;
|
|
24
|
-
const runtime=requirement?.id==='stable-diffusion.cpp'?normalizeImageRuntimeRequirement(requirement)
|
|
25
|
-
|
|
26
|
+
const runtime=requirement?.id==='stable-diffusion.cpp'?normalizeImageRuntimeRequirement(requirement)
|
|
27
|
+
:requirement?.id==='whisper.cpp'?normalizeWhisperRuntimeRequirement(requirement):requirement;
|
|
28
|
+
if(!runtime||!['llama.cpp','ollama','nemo-speech','onnx','stable-diffusion.cpp','whisper.cpp'].includes(runtime.id)){
|
|
26
29
|
throw new ArcaneError(ERROR_CODES.targetUnavailable,`Local AI runtime installation is unavailable for ${String(runtime?.id)}.`);
|
|
27
30
|
}
|
|
28
31
|
const prior=selected.get(runtime.id);
|
|
32
|
+
if(prior&&runtime.id==='whisper.cpp'&&whisperRuntimeSelection(prior)!==whisperRuntimeSelection(runtime)){
|
|
33
|
+
throw new ArcaneError(ERROR_CODES.usage,'Select one complete whisper.cpp runtime and model requirement for this operation.');
|
|
34
|
+
}
|
|
29
35
|
if(prior&&(prior.version!==runtime.version||prior.url!==runtime.url||(runtime.id==='stable-diffusion.cpp'&&prior.backend!==runtime.backend))){
|
|
30
36
|
throw new ArcaneError(ERROR_CODES.usage,`Select one ${runtime.id} runtime version and URL for this operation.`);
|
|
31
37
|
}
|
|
@@ -351,7 +357,7 @@ function bundledImageModelResource(resource, descriptor, model, role, runtimeRoo
|
|
|
351
357
|
return bundled;
|
|
352
358
|
}
|
|
353
359
|
|
|
354
|
-
async function installedRuntime(base,runtime,platform,architecture,signal){
|
|
360
|
+
async function installedRuntime(base,runtime,platform,architecture,signal,reuseWhisper=false){
|
|
355
361
|
let entries;
|
|
356
362
|
try{
|
|
357
363
|
entries=await readdir(base,{withFileTypes:true});
|
|
@@ -374,9 +380,19 @@ async function installedRuntime(base,runtime,platform,architecture,signal){
|
|
|
374
380
|
}
|
|
375
381
|
if(installation.requestVersion!==(runtime.version??null)||installation.requestUrl!==(runtime.url??null))continue;
|
|
376
382
|
if (runtime.id === 'stable-diffusion.cpp' && installation.requestBackend !== runtime.backend) continue;
|
|
383
|
+
let whisperRequirement;
|
|
384
|
+
if (runtime.id === 'whisper.cpp') {
|
|
385
|
+
if (!installation.requestWhisper) continue;
|
|
386
|
+
whisperRequirement = JSON.parse(installation.requestWhisper);
|
|
387
|
+
if (whisperRuntimeSelection(whisperRequirement, !reuseWhisper) !== whisperRuntimeSelection(runtime, !reuseWhisper)) continue;
|
|
388
|
+
}
|
|
377
389
|
const record=installation.runtime;
|
|
378
390
|
if(record?.id!==runtime.id||record.platform!==platform||record.architecture!==architecture)continue;
|
|
379
391
|
try{
|
|
392
|
+
if(runtime.id==='whisper.cpp'){
|
|
393
|
+
if(await installedWhisperRuntime(record,signal,!reuseWhisper))return reuseWhisper?{runtime:record,requirement:whisperRequirement}:record;
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
380
396
|
if (runtime.id === 'stable-diffusion.cpp') {
|
|
381
397
|
const locations = [record.libraryPath, record.bindingModulePath];
|
|
382
398
|
if (!Array.isArray(record.variants) || record.variants.length === 0) continue;
|
|
@@ -524,10 +540,11 @@ async function installRuntime(runtime,{directory,platform,architecture,signal,on
|
|
|
524
540
|
await onEvent({type:'local-ai.install.available',message:`${runtime.id} ${installed.version} is already installed.`,data:installed});
|
|
525
541
|
return installed;
|
|
526
542
|
}
|
|
543
|
+
const reusableWhisper=runtime.id==='whisper.cpp'?await installedRuntime(base,runtime,platform,architecture,signal,true):null;
|
|
527
544
|
if(runtime.id==='onnx'&&(!['win32','linux','darwin'].includes(platform)||!['x64','arm64'].includes(architecture))){
|
|
528
545
|
throw new ArcaneError(ERROR_CODES.targetUnavailable,`onnxruntime-node has no supported ${platform}/${architecture} CPU runtime.`);
|
|
529
546
|
}
|
|
530
|
-
const release=runtime.id
|
|
547
|
+
const release=['onnx','whisper.cpp'].includes(runtime.id)?null:await selectRelease(runtime,platform,architecture,signal);
|
|
531
548
|
throwIfAborted(signal);
|
|
532
549
|
await mkdir(base,{recursive:true});
|
|
533
550
|
const attempt=await mkdtemp(path.join(base,'install-'));
|
|
@@ -538,6 +555,8 @@ async function installRuntime(runtime,{directory,platform,architecture,signal,on
|
|
|
538
555
|
let locations;
|
|
539
556
|
if(runtime.id==='onnx'){
|
|
540
557
|
locations=await installONNXPackage(runtime,{root,directory,platform,architecture,signal,onEvent});
|
|
558
|
+
} else if (runtime.id === 'whisper.cpp') {
|
|
559
|
+
locations = await installWhisperDistribution(runtime, {root, directory, platform, architecture, signal, onEvent, upstreamResponse, reusable: reusableWhisper});
|
|
541
560
|
} else if (runtime.id === 'stable-diffusion.cpp') {
|
|
542
561
|
locations = await installImageDistribution(
|
|
543
562
|
release,
|
|
@@ -556,7 +575,7 @@ async function installRuntime(runtime,{directory,platform,architecture,signal,on
|
|
|
556
575
|
const record={id:runtime.id,platform,architecture,root,...locations,...(runtime.version?{requestedVersion:runtime.version}:{})};
|
|
557
576
|
throwIfAborted(signal);
|
|
558
577
|
if(archive)await unlink(archive);
|
|
559
|
-
await writeFile(path.join(attempt,'installation.json'),`${JSON.stringify({requestVersion:runtime.version??null,requestUrl:runtime.url??null,...(runtime.id==='stable-diffusion.cpp'?{requestBackend:runtime.backend}:{}),runtime:record},null,2)}\n`,{flag:'wx',signal});
|
|
578
|
+
await writeFile(path.join(attempt,'installation.json'),`${JSON.stringify({requestVersion:runtime.version??null,requestUrl:runtime.url??null,...(runtime.id==='stable-diffusion.cpp'?{requestBackend:runtime.backend}:{}),...(runtime.id==='whisper.cpp'?{requestWhisper:whisperRuntimeSelection(runtime)}:{}),runtime:record},null,2)}\n`,{flag:'wx',signal});
|
|
560
579
|
completed=true;
|
|
561
580
|
await onEvent({type:'local-ai.install.completed',message:`${runtime.id} ${record.version} is available.`,data:record});
|
|
562
581
|
return record;
|
|
@@ -576,7 +595,7 @@ async function installRuntime(runtime,{directory,platform,architecture,signal,on
|
|
|
576
595
|
|
|
577
596
|
function shareInstallation(runtime,options){
|
|
578
597
|
throwIfAborted(options.signal);
|
|
579
|
-
const key=JSON.stringify([options.directory,options.platform,options.architecture,runtime.id,runtime.version??null,runtime.url??null,runtime.id==='stable-diffusion.cpp'?runtime.backend:null]);
|
|
598
|
+
const key=JSON.stringify([options.directory,options.platform,options.architecture,runtime.id,runtime.version??null,runtime.url??null,runtime.id==='stable-diffusion.cpp'?runtime.backend:null,runtime.id==='whisper.cpp'?whisperRuntimeSelection(runtime):null]);
|
|
580
599
|
let entry=installations.get(key);
|
|
581
600
|
if(entry?.controller.signal.aborted){
|
|
582
601
|
// The prior cancelled attempt owns its cleanup. A later request waits
|
|
@@ -725,7 +744,7 @@ export async function bundleLocalAIRuntimes({runtimes=[],directory,outputRoot,pl
|
|
|
725
744
|
const files=[];
|
|
726
745
|
const relative=`runtime/local-ai/${runtime.id}`;
|
|
727
746
|
const destination=path.join(root,relative);
|
|
728
|
-
const bundled={...runtime,root:relative};
|
|
747
|
+
const bundled=runtime.id==='whisper.cpp'?bundledWhisperRuntime(runtime,relative):{...runtime,root:relative};
|
|
729
748
|
for(const field of ['executable','modulePath','includeDirectory','libraryDirectory','binaryDirectory','cmakeDirectory','libraryPath','bindingModulePath']){
|
|
730
749
|
if(runtime[field]===undefined)continue;
|
|
731
750
|
bundled[field]=path.posix.join(relative,path.relative(runtime.root,runtime[field]).split(path.sep).join('/'));
|
|
@@ -0,0 +1,48 @@
|
|
|
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 only the persistent first-party helper against an installed runtime. */
|
|
10
|
+
export async function buildWhisperHelper({
|
|
11
|
+
runtime,
|
|
12
|
+
outputRoot,
|
|
13
|
+
cmake = 'cmake',
|
|
14
|
+
generator,
|
|
15
|
+
cmakeArgs = [],
|
|
16
|
+
env,
|
|
17
|
+
signal,
|
|
18
|
+
onEvent
|
|
19
|
+
} = {}) {
|
|
20
|
+
if (!is.string(runtime?.directory) || !is.string(runtime?.sourceDirectory) || !is.string(outputRoot)) {
|
|
21
|
+
throw new TypeError('The selected Whisper runtime directory, matching sourceDirectory, and helper outputRoot are required.');
|
|
22
|
+
}
|
|
23
|
+
const root = path.resolve(outputRoot);
|
|
24
|
+
const build = path.join(root, 'build');
|
|
25
|
+
const source = fileURLToPath(new URL('./native/', import.meta.url));
|
|
26
|
+
await mkdir(root, {recursive: true});
|
|
27
|
+
const options = {env, signal, onEvent};
|
|
28
|
+
const configure = await runProcess(cmake, [
|
|
29
|
+
'-S', source,
|
|
30
|
+
'-B', build,
|
|
31
|
+
...(generator ? ['-G', generator] : []),
|
|
32
|
+
`-DWHISPER_SOURCE_DIRECTORY=${path.resolve(runtime.sourceDirectory)}`,
|
|
33
|
+
`-DWHISPER_RUNTIME_DIRECTORY=${path.resolve(runtime.directory)}`,
|
|
34
|
+
`-DCMAKE_INSTALL_PREFIX=${root}`,
|
|
35
|
+
'-DCMAKE_BUILD_TYPE=Release',
|
|
36
|
+
...cmakeArgs
|
|
37
|
+
], options);
|
|
38
|
+
const compile = await runProcess(cmake, ['--build', build, '--config', 'Release'], options);
|
|
39
|
+
const install = await runProcess(cmake, ['--install', build, '--config', 'Release'], options);
|
|
40
|
+
return {
|
|
41
|
+
platform: process.platform,
|
|
42
|
+
architecture: process.arch,
|
|
43
|
+
root,
|
|
44
|
+
executable: path.join(root, 'bin', process.platform === 'win32' ? 'arcane-whisper.exe' : 'arcane-whisper'),
|
|
45
|
+
runtimeDirectory: path.resolve(runtime.directory),
|
|
46
|
+
diagnostics: {configure, compile, install}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import Is from 'strong-type';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {ArcaneError, ERROR_CODES} from '../../errors.mjs';
|
|
4
|
+
|
|
5
|
+
const is = new Is(false);
|
|
6
|
+
|
|
7
|
+
export const WHISPER_SMALL_MODEL = {
|
|
8
|
+
id: 'whisper-small',
|
|
9
|
+
url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin',
|
|
10
|
+
filename: 'ggml-small.bin'
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Describe a selected native runtime and models without acquiring them. */
|
|
14
|
+
export function normalizeWhisperRuntimeRequirement(record) {
|
|
15
|
+
if (record.helperRoot !== undefined && (!is.string(record.helperRoot) || !record.helperRoot)) {
|
|
16
|
+
throw new ArcaneError(ERROR_CODES.usage, 'Whisper helperRoot must name the complete selected helper directory.');
|
|
17
|
+
}
|
|
18
|
+
const helperRoot = record.helperRoot ?? (is.string(record.helperExecutable) ? path.dirname(record.helperExecutable) : undefined);
|
|
19
|
+
const backend = record.backend ?? 'auto';
|
|
20
|
+
if (!['auto', 'cuda', 'metal', 'cpu'].includes(backend)) {
|
|
21
|
+
throw new ArcaneError(ERROR_CODES.usage, `Unknown whisper.cpp backend: ${String(backend)}.`);
|
|
22
|
+
}
|
|
23
|
+
if (record.models !== undefined && !is.array(record.models)) {
|
|
24
|
+
throw new ArcaneError(ERROR_CODES.usage, 'whisper.cpp models must be an array of selected model IDs or descriptors.');
|
|
25
|
+
}
|
|
26
|
+
const models = (record.models ?? []).map(function selectedWhisperModel(value) {
|
|
27
|
+
const model = value === 'whisper-small' ? WHISPER_SMALL_MODEL : value;
|
|
28
|
+
if (!model || !is.object(model) || is.array(model) || !is.string(model.id) || !model.id) {
|
|
29
|
+
throw new ArcaneError(ERROR_CODES.usage, 'A Whisper model needs a descriptor with an id, or the built-in whisper-small model ID.');
|
|
30
|
+
}
|
|
31
|
+
if ((!is.string(model.path) || !model.path) && (!is.string(model.url) || !model.url)) {
|
|
32
|
+
throw new ArcaneError(ERROR_CODES.usage, `Whisper model ${model.id} needs its selected path or URL.`);
|
|
33
|
+
}
|
|
34
|
+
return {...model};
|
|
35
|
+
});
|
|
36
|
+
if (record.variants !== undefined && (!is.array(record.variants) || record.variants.length === 0)) {
|
|
37
|
+
throw new ArcaneError(ERROR_CODES.usage, 'whisper.cpp variants must be a nonempty array when supplied.');
|
|
38
|
+
}
|
|
39
|
+
const variants = record.variants?.map(function selectedWhisperVariant(variant) {
|
|
40
|
+
if (!variant || !is.object(variant) || is.array(variant) || !['cuda', 'metal', 'cpu'].includes(variant.backend)) {
|
|
41
|
+
throw new ArcaneError(ERROR_CODES.usage, 'Each Whisper runtime variant needs its cuda, metal, or cpu backend.');
|
|
42
|
+
}
|
|
43
|
+
if ((!is.string(variant.root) || !variant.root) && (!is.string(variant.url) || !variant.url)) {
|
|
44
|
+
throw new ArcaneError(ERROR_CODES.usage, `Whisper ${variant.backend} needs its complete runtime root or archive URL.`);
|
|
45
|
+
}
|
|
46
|
+
return {...variant};
|
|
47
|
+
});
|
|
48
|
+
if (record.modelId !== undefined && (!is.string(record.modelId) || !models.some(function matchingModel(model) {
|
|
49
|
+
return model.id === record.modelId;
|
|
50
|
+
}))) {
|
|
51
|
+
throw new ArcaneError(ERROR_CODES.usage, 'Whisper modelId must name one of the explicitly selected models.');
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
...record,
|
|
55
|
+
version: record.version ?? '1.9.4',
|
|
56
|
+
backend,
|
|
57
|
+
models,
|
|
58
|
+
...(helperRoot === undefined ? {} : {helperRoot}),
|
|
59
|
+
...(variants ? {variants} : {})
|
|
60
|
+
};
|
|
61
|
+
}
|