arcane-os 0.92.0 → 0.94.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 +32 -0
- package/README.md +1 -1
- package/browser-runtime/ai/twin-cloud.mjs +109 -46
- package/browser-runtime/core/client.mjs +20 -0
- package/browser-runtime/core/contracts.mjs +20 -0
- package/browser-runtime/core/native-diagnostics.mjs +217 -0
- package/docs/reference/ai/twin-cloud.md +35 -14
- package/docs/reference/availability-and-normalization.md +1 -1
- package/docs/reference/core/arcane-api.md +32 -0
- package/docs/reference/core/arcane-events.md +25 -0
- package/docs/reference/core-client.md +99 -0
- package/docs/reference/inventory/package-api.json +97 -13
- package/docs/reference/local-ai.md +81 -5
- package/docs/reference/native-diagnostics.md +167 -0
- package/docs/reference/native-speech.md +220 -11
- package/docs/reference/runtime-modules.md +21 -18
- package/docs/reference/sdk-api.md +152 -11
- package/package.json +4 -1
- package/schemas/arcane-app.schema.json +2 -2
- package/schemas/arcane-package.schema.json +2 -2
- package/src/core/app-control.mjs +1 -0
- package/src/core/hosts/windows/ArcaneAppControl.cs +1 -1
- package/src/core/hosts/windows/ArcaneHost.cs +167 -0
- package/src/core/hosts/windows/ArcaneLauncher.cs +1 -0
- package/src/core/hosts/windows/ArcaneNativeDiagnostics.cs +137 -0
- package/src/core/hosts/windows/ArcaneNotifications.cs +822 -0
- package/src/core/hosts/windows/ArcaneWindowControl.cs +3 -2
- package/src/core/services/speech.mjs +85 -23
- package/src/core-native-diagnostics-source.mjs +13 -0
- package/src/import-map.mjs +1 -0
- 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/native/portable-layout.mjs +67 -9
|
@@ -38,7 +38,7 @@ namespace Arcane.Core.Hosts.Windows
|
|
|
38
38
|
{
|
|
39
39
|
if (appControl == null) return;
|
|
40
40
|
appControlScript = await browser.AddScriptToExecuteOnDocumentCreatedAsync(
|
|
41
|
-
"globalThis[" + AppControlDocumentKey + "]
|
|
41
|
+
"globalThis[" + AppControlDocumentKey + "] ??= globalThis.crypto.randomUUID();");
|
|
42
42
|
if (closing) return;
|
|
43
43
|
browser.NavigationStarting += AppControlNavigationStarting;
|
|
44
44
|
browser.NavigationCompleted += AppControlNavigationCompleted;
|
|
@@ -140,7 +140,7 @@ namespace Arcane.Core.Hosts.Windows
|
|
|
140
140
|
{ "url", webView.Source == null ? null : webView.Source.AbsoluteUri },
|
|
141
141
|
{ "documentGeneration", appControlGeneration }, { "ready", AppControlDocumentReady() },
|
|
142
142
|
{ "window", AppControlWindow() },
|
|
143
|
-
{ "operations", new string[] { "status", "inspect", "capture", "act", "key", "resize" } }
|
|
143
|
+
{ "operations", new string[] { "status", "diagnostics", "inspect", "capture", "act", "key", "resize" } }
|
|
144
144
|
};
|
|
145
145
|
}
|
|
146
146
|
|
|
@@ -158,6 +158,7 @@ namespace Arcane.Core.Hosts.Windows
|
|
|
158
158
|
{
|
|
159
159
|
cancellation.ThrowIfCancellationRequested();
|
|
160
160
|
if (method == "app.control.status") return AppControlStatus();
|
|
161
|
+
if (method == "app.control.diagnostics") return nativeDiagnostics.Snapshot(parameters);
|
|
161
162
|
if (method != "app.control.inspect" && method != "app.control.capture" && method != "app.control.act"
|
|
162
163
|
&& method != "app.control.key" && method != "app.control.resize")
|
|
163
164
|
throw new ArgumentException("Unknown app-control method: " + method);
|
|
@@ -44,8 +44,8 @@ function waitForLoad(task, signal) {
|
|
|
44
44
|
/** Hosts supply selected engines; this service owns their independent lifetimes. */
|
|
45
45
|
export function createSpeechService({stt, tts, signal} = {}) {
|
|
46
46
|
const roles = {
|
|
47
|
-
stt: {engine: stt, operation: 'transcribe', state: null, loading: null, unsubscribe: null},
|
|
48
|
-
tts: {engine: tts, operation: 'synthesize', state: null, loading: null, unsubscribe: null}
|
|
47
|
+
stt: {engine: stt, operation: 'transcribe', state: null, loading: null, changing: null, unsubscribe: null},
|
|
48
|
+
tts: {engine: tts, operation: 'synthesize', state: null, loading: null, changing: null, unsubscribe: null}
|
|
49
49
|
};
|
|
50
50
|
for (const [name, role] of Object.entries(roles)) {
|
|
51
51
|
if (role.engine == null) {
|
|
@@ -124,6 +124,36 @@ export function createSpeechService({stt, tts, signal} = {}) {
|
|
|
124
124
|
publish();
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
function observeRoleTask(name, task, startup = false) {
|
|
128
|
+
const role = roles[name];
|
|
129
|
+
role.changing = task;
|
|
130
|
+
// Explicit load cancellation belongs to that caller's wait, not another
|
|
131
|
+
// inference request. Only the service-owned startup is a shared wait.
|
|
132
|
+
role.loading = startup ? task : null;
|
|
133
|
+
jobs.add(task);
|
|
134
|
+
function settled(error, failed) {
|
|
135
|
+
jobs.delete(task);
|
|
136
|
+
// An older startup or activation must not replace the selected state.
|
|
137
|
+
if (role.loading === task) role.loading = null;
|
|
138
|
+
if (role.changing !== task) return;
|
|
139
|
+
role.changing = null;
|
|
140
|
+
if (closed) return;
|
|
141
|
+
if (failed && startup && !closing) {
|
|
142
|
+
roleFailed(name, error);
|
|
143
|
+
} else {
|
|
144
|
+
role.state = role.engine.current();
|
|
145
|
+
publish();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
task.then(
|
|
149
|
+
function speechRoleSettled() { settled(undefined, false); },
|
|
150
|
+
function speechRoleFailed(error) { settled(error, true); }
|
|
151
|
+
).catch(function speechRoleObserverFailed(error) {
|
|
152
|
+
console.error('Speech role state observer failed.', error);
|
|
153
|
+
});
|
|
154
|
+
return task;
|
|
155
|
+
}
|
|
156
|
+
|
|
127
157
|
function startRole(name) {
|
|
128
158
|
const role = roles[name];
|
|
129
159
|
if (!role.engine || closing) {
|
|
@@ -142,34 +172,60 @@ export function createSpeechService({stt, tts, signal} = {}) {
|
|
|
142
172
|
roleFailed(name, error);
|
|
143
173
|
return;
|
|
144
174
|
}
|
|
175
|
+
// Replay can synchronously select an activation or close the service.
|
|
176
|
+
if (role.changing || closing || closed) return;
|
|
145
177
|
const task = Promise.resolve().then(
|
|
146
178
|
async function loadSpeechEngine() {
|
|
147
179
|
lifetimeSignal.throwIfAborted();
|
|
148
|
-
|
|
180
|
+
if (role.changing !== task) return;
|
|
181
|
+
return role.engine.load(
|
|
149
182
|
{signal: lifetimeSignal}
|
|
150
183
|
);
|
|
151
|
-
if (!closed) {
|
|
152
|
-
role.state = role.engine.current();
|
|
153
|
-
publish();
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
);
|
|
157
|
-
role.loading = task;
|
|
158
|
-
task.then(
|
|
159
|
-
function speechEngineLoaded() {
|
|
160
|
-
role.loading = null;
|
|
161
|
-
},
|
|
162
|
-
function speechEngineLoadFailed(error) {
|
|
163
|
-
role.loading = null;
|
|
164
|
-
if (!closing) {
|
|
165
|
-
roleFailed(name, error);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
).catch(
|
|
169
|
-
function speechLoadObserverFailed(error) {
|
|
170
|
-
console.error('Speech load state observer failed.', error);
|
|
171
184
|
}
|
|
172
185
|
);
|
|
186
|
+
observeRoleTask(name, task, true);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function changeRole(operation, parameters, request) {
|
|
190
|
+
if (closing || closed) {
|
|
191
|
+
throw failure('CORE_CLOSING', 'The speech service is closing.');
|
|
192
|
+
}
|
|
193
|
+
if (!parameters || !is.object(parameters) || is.array(parameters)
|
|
194
|
+
|| !['stt', 'tts'].includes(parameters.role)) {
|
|
195
|
+
throw failure('SPEECH_ROLE_INVALID', 'Select the stt or tts speech role.');
|
|
196
|
+
}
|
|
197
|
+
const {role: name, ...selection} = parameters;
|
|
198
|
+
const role = roles[name];
|
|
199
|
+
const operationSignal = request.signal
|
|
200
|
+
? AbortSignal.any([lifetimeSignal, request.signal]) : lifetimeSignal;
|
|
201
|
+
operationSignal.throwIfAborted();
|
|
202
|
+
if (!role.engine) {
|
|
203
|
+
throw failure('SPEECH_ENGINE_UNAVAILABLE', `No ${name} speech engine is configured.`);
|
|
204
|
+
}
|
|
205
|
+
if (!is.function(role.engine[operation])) {
|
|
206
|
+
throw failure('SPEECH_OPERATION_UNAVAILABLE', `The ${name} speech engine does not support ${operation}().`);
|
|
207
|
+
}
|
|
208
|
+
let acknowledgementFailed = false;
|
|
209
|
+
let acknowledgementError;
|
|
210
|
+
const task = Promise.resolve().then(async function changeSpeechRole() {
|
|
211
|
+
if (acknowledgementFailed) throw acknowledgementError;
|
|
212
|
+
operationSignal.throwIfAborted();
|
|
213
|
+
const result = await role.engine[operation]({...selection, signal: operationSignal});
|
|
214
|
+
operationSignal.throwIfAborted();
|
|
215
|
+
return result;
|
|
216
|
+
});
|
|
217
|
+
// Retain ownership before an acknowledgement observer can close or reenter.
|
|
218
|
+
observeRoleTask(name, task);
|
|
219
|
+
try {
|
|
220
|
+
request.emit?.('speech.progress', {
|
|
221
|
+
requestId: request.requestId, role: name, operation,
|
|
222
|
+
status: 'Thinking', progress: {phase: 'accepted'}
|
|
223
|
+
});
|
|
224
|
+
} catch (error) {
|
|
225
|
+
acknowledgementFailed = true;
|
|
226
|
+
acknowledgementError = error;
|
|
227
|
+
}
|
|
228
|
+
return task;
|
|
173
229
|
}
|
|
174
230
|
|
|
175
231
|
function invoke(name, parameters, request) {
|
|
@@ -340,6 +396,12 @@ export function createSpeechService({stt, tts, signal} = {}) {
|
|
|
340
396
|
},
|
|
341
397
|
methods: {
|
|
342
398
|
'speech.status': current,
|
|
399
|
+
'speech.load': function load(parameters, request = {}) {
|
|
400
|
+
return changeRole('load', parameters, request);
|
|
401
|
+
},
|
|
402
|
+
'speech.unload': function unload(parameters, request = {}) {
|
|
403
|
+
return changeRole('unload', parameters, request);
|
|
404
|
+
},
|
|
343
405
|
'speech.transcribe': function transcribe(parameters, request = {}) {
|
|
344
406
|
return invoke('stt', parameters, request);
|
|
345
407
|
},
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {readFile} from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
/** Project the one canonical diagnostic observer into a document-start script. */
|
|
4
|
+
export async function createNativeDiagnosticsSource() {
|
|
5
|
+
const source = await readFile(new URL('../browser-runtime/core/native-diagnostics.mjs', import.meta.url), 'utf8');
|
|
6
|
+
return `(function installArcaneNativeDiagnostics(global){
|
|
7
|
+
'use strict';
|
|
8
|
+
if(global.top!==undefined&&global.top!==global)return;
|
|
9
|
+
${source.replace(/^export (?=(?:async )?function )/gmu, '')}
|
|
10
|
+
installNativeDiagnostics(global);
|
|
11
|
+
})(globalThis);
|
|
12
|
+
`;
|
|
13
|
+
}
|
package/src/import-map.mjs
CHANGED
|
@@ -44,6 +44,7 @@ const SDK_BROWSER_SELF_IMPORTS=new Map([
|
|
|
44
44
|
['arcane-os/websocket-client','sdk/websocket-client.mjs'],
|
|
45
45
|
['arcane-os/core/client','sdk/core/client.mjs'],
|
|
46
46
|
['arcane-os/core/contracts','sdk/core/contracts.mjs'],
|
|
47
|
+
['arcane-os/core/native-diagnostics','sdk/core/native-diagnostics.mjs'],
|
|
47
48
|
['arcane-os/document-acquisition','sdk/document-acquisition.mjs'],
|
|
48
49
|
['arcane-os/logging','sdk/logging.mjs'],
|
|
49
50
|
['arcane-os/browser-device','sdk/browser-device.mjs'],
|
package/src/local-ai/config.mjs
CHANGED
|
@@ -8,7 +8,17 @@ import {SDXL_BASE_1_0_MODEL} from './image-models/sdxl.mjs';
|
|
|
8
8
|
import {normalizeWhisperRuntimeRequirement} from './whisper/config.mjs';
|
|
9
9
|
|
|
10
10
|
const is = new Is(false);
|
|
11
|
-
export const LOCAL_AI_RUNTIME_IDS = ['llama.cpp', 'ollama', 'onnx', 'stable-diffusion.cpp', 'whisper.cpp'];
|
|
11
|
+
export const LOCAL_AI_RUNTIME_IDS = ['llama.cpp', 'ollama', 'onnx', 'stable-diffusion.cpp', 'whisper.cpp', 'kokoro-native'];
|
|
12
|
+
|
|
13
|
+
/** Describe the selected helper without acquiring it or selecting model assets. */
|
|
14
|
+
export function normalizeKokoroRuntimeRequirement(record) {
|
|
15
|
+
for (const field of ['helperRoot', 'helperExecutable', 'espeakDataDirectory', 'libraryDirectory']) {
|
|
16
|
+
if (record[field] !== undefined && (!is.string(record[field]) || record[field] === '')) {
|
|
17
|
+
throw new ArcaneError(ERROR_CODES.usage, `kokoro-native ${field} must be a nonempty path.`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return {...record};
|
|
21
|
+
}
|
|
12
22
|
|
|
13
23
|
/** Select a llama distribution without discovering hardware or acquiring it. */
|
|
14
24
|
export function normalizeLlamaRuntimeRequirement(record) {
|
|
@@ -79,6 +89,7 @@ export function normalizeLocalAIConfig(value) {
|
|
|
79
89
|
if (record.id === 'llama.cpp') return normalizeLlamaRuntimeRequirement(record);
|
|
80
90
|
if (record.id === 'stable-diffusion.cpp') return normalizeImageRuntimeRequirement(record);
|
|
81
91
|
if (record.id === 'whisper.cpp') return normalizeWhisperRuntimeRequirement(record);
|
|
92
|
+
if (record.id === 'kokoro-native') return normalizeKokoroRuntimeRequirement(record);
|
|
82
93
|
return {...record};
|
|
83
94
|
});
|
|
84
95
|
return {...value, runtimes};
|
package/src/local-ai/dev.mjs
CHANGED
|
@@ -9,7 +9,9 @@ import {normalizeLocalAIConfig} from './config.mjs';
|
|
|
9
9
|
import {discoverLocalAIRuntimes} from './discover.mjs';
|
|
10
10
|
import {ensureLocalAIRuntimes} from './install.mjs';
|
|
11
11
|
import {createWhisperRuntime} from './whisper/index.mjs';
|
|
12
|
+
import {createNativeKokoroRuntime} from './kokoro/index.mjs';
|
|
12
13
|
import {createExecutionDeviceCatalog} from './execution-devices.mjs';
|
|
14
|
+
import {ArcaneError, ERROR_CODES} from '../errors.mjs';
|
|
13
15
|
|
|
14
16
|
/** Local engines share the ordinary app-service development transport. */
|
|
15
17
|
export function createDevelopmentLocalAI({
|
|
@@ -24,7 +26,7 @@ export function createDevelopmentLocalAI({
|
|
|
24
26
|
const localConfig = {
|
|
25
27
|
...selected,
|
|
26
28
|
runtimes: selected.runtimes.filter(function selectedChatRuntime(requirement) {
|
|
27
|
-
return
|
|
29
|
+
return !['stable-diffusion.cpp', 'whisper.cpp', 'kokoro-native'].includes(requirement.id);
|
|
28
30
|
})
|
|
29
31
|
};
|
|
30
32
|
const service = createLocalAIService(
|
|
@@ -48,7 +50,7 @@ export function createDevelopmentLocalAI({
|
|
|
48
50
|
return requirement.id === 'stable-diffusion.cpp';
|
|
49
51
|
});
|
|
50
52
|
if (imageSelected || selected.runtimes.some(function selectedProjectedRuntime(requirement) {
|
|
51
|
-
return ['onnx', 'llama.cpp'].includes(requirement.id);
|
|
53
|
+
return ['onnx', 'llama.cpp', 'kokoro-native'].includes(requirement.id);
|
|
52
54
|
})) {
|
|
53
55
|
modelAssets = createModelAssetService({appRoot});
|
|
54
56
|
definitions.push(modelAssets);
|
|
@@ -71,8 +73,13 @@ export function createDevelopmentLocalAI({
|
|
|
71
73
|
const whisperRequirement = selected.runtimes.find(function selectedWhisperRuntime(requirement) {
|
|
72
74
|
return requirement.id === 'whisper.cpp';
|
|
73
75
|
});
|
|
76
|
+
const kokoroRequirement = selected.runtimes.find(function selectedKokoroRuntime(requirement) {
|
|
77
|
+
return requirement.id === 'kokoro-native';
|
|
78
|
+
});
|
|
79
|
+
let stt;
|
|
80
|
+
let tts;
|
|
74
81
|
if (whisperRequirement) {
|
|
75
|
-
|
|
82
|
+
stt = createWhisperRuntime(
|
|
76
83
|
{
|
|
77
84
|
modelId: whisperRequirement.modelId,
|
|
78
85
|
temporaryDirectory: path.join(appRoot, '.arcane', 'speech', 'whisper'),
|
|
@@ -85,7 +92,49 @@ export function createDevelopmentLocalAI({
|
|
|
85
92
|
}
|
|
86
93
|
}
|
|
87
94
|
);
|
|
88
|
-
|
|
95
|
+
}
|
|
96
|
+
if (kokoroRequirement) {
|
|
97
|
+
tts = createNativeKokoroRuntime({
|
|
98
|
+
modelId: kokoroRequirement.modelId,
|
|
99
|
+
model: kokoroRequirement.model,
|
|
100
|
+
revision: kokoroRequirement.revision,
|
|
101
|
+
dtype: kokoroRequirement.dtype,
|
|
102
|
+
paths: kokoroRequirement.paths,
|
|
103
|
+
assetProjectionId: kokoroRequirement.assetProjectionId,
|
|
104
|
+
resourcePaths: kokoroRequirement.resourcePaths,
|
|
105
|
+
sessionOptions: kokoroRequirement.sessionOptions,
|
|
106
|
+
executionPreference: kokoroRequirement.executionPreference,
|
|
107
|
+
executionTarget: kokoroRequirement.executionTarget,
|
|
108
|
+
modelAssets, signal, onEvent,
|
|
109
|
+
async prepare({signal: activationSignal}) {
|
|
110
|
+
if (!localConfig.runtimes.some(function selectedONNX(requirement) { return requirement.id === 'onnx'; })) {
|
|
111
|
+
throw new ArcaneError(ERROR_CODES.targetUnavailable, 'Native Kokoro requires an explicitly selected onnx runtime.');
|
|
112
|
+
}
|
|
113
|
+
const preparation = new AbortController();
|
|
114
|
+
const preparationSignal = AbortSignal.any([activationSignal, preparation.signal]);
|
|
115
|
+
function preparationFailed(error) {
|
|
116
|
+
preparation.abort(error);
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
// Helper acquisition and the existing ONNX owner's startup are
|
|
120
|
+
// independent. Join both before native activation can continue.
|
|
121
|
+
const outcomes = await Promise.allSettled([
|
|
122
|
+
waitForKokoroONNX(preparationSignal).catch(preparationFailed),
|
|
123
|
+
ensureLocalAIRuntimes({
|
|
124
|
+
runtimes: [kokoroRequirement], directory, signal: preparationSignal, onEvent
|
|
125
|
+
}).catch(preparationFailed)
|
|
126
|
+
]);
|
|
127
|
+
const failures = outcomes.filter(function failed(result) { return result.status === 'rejected'; })
|
|
128
|
+
.map(function cause(result) { return result.reason; });
|
|
129
|
+
if (failures.length === 1) throw failures[0];
|
|
130
|
+
if (failures.length) throw new AggregateError(failures, 'Native Kokoro preparation failed.');
|
|
131
|
+
preparationSignal.throwIfAborted();
|
|
132
|
+
return {onnx: outcomes[0].value, runtime: outcomes[1].value[0]};
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (stt || tts) {
|
|
137
|
+
speechService = createSpeechService({stt, tts, signal});
|
|
89
138
|
definitions.push(speechService);
|
|
90
139
|
}
|
|
91
140
|
const core = createDevelopmentCore(
|
|
@@ -101,6 +150,24 @@ export function createDevelopmentLocalAI({
|
|
|
101
150
|
}
|
|
102
151
|
}
|
|
103
152
|
);
|
|
153
|
+
function waitForKokoroONNX(activationSignal) {
|
|
154
|
+
activationSignal.throwIfAborted();
|
|
155
|
+
let cancel;
|
|
156
|
+
const cancelled = new Promise(function observePreparationCancellation(_resolve, reject) {
|
|
157
|
+
cancel = function stopWaitingForONNX() { reject(activationSignal.reason); };
|
|
158
|
+
activationSignal.addEventListener('abort', cancel, {once: true});
|
|
159
|
+
});
|
|
160
|
+
const ready = core.ready.then(async function existingONNXOwner(runtime) {
|
|
161
|
+
activationSignal.throwIfAborted();
|
|
162
|
+
const owner = await runtime.getService('local-ai');
|
|
163
|
+
activationSignal.throwIfAborted();
|
|
164
|
+
return owner.getONNXRuntime();
|
|
165
|
+
});
|
|
166
|
+
// Cancel this selected activation's wait without stopping the shared owner.
|
|
167
|
+
return Promise.race([ready, cancelled]).finally(function releaseONNXWait() {
|
|
168
|
+
activationSignal.removeEventListener('abort', cancel);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
104
171
|
function current() {
|
|
105
172
|
return {
|
|
106
173
|
...core.current(),
|
package/src/local-ai/install.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import {ArcaneError,ERROR_CODES,normalizeError,throwIfAborted} from '../errors.m
|
|
|
10
10
|
import {createEventQueue} from '../event-queue.mjs';
|
|
11
11
|
import {runProcess} from '../process.mjs';
|
|
12
12
|
import {extractLocalAIArchive} from './archive.mjs';
|
|
13
|
-
import {normalizeImageRuntimeRequirement,normalizeLlamaRuntimeRequirement} from './config.mjs';
|
|
13
|
+
import {normalizeImageRuntimeRequirement,normalizeKokoroRuntimeRequirement,normalizeLlamaRuntimeRequirement} from './config.mjs';
|
|
14
14
|
import {normalizeWhisperRuntimeRequirement} from './whisper/config.mjs';
|
|
15
15
|
import {bundledWhisperRuntime, installedWhisperRuntime, installWhisperDistribution, whisperRuntimeSelection} from './whisper/install.mjs';
|
|
16
16
|
|
|
@@ -28,6 +28,21 @@ function selectedONNXNativeDistribution(runtime,platform,architecture){
|
|
|
28
28
|
?onnxWindowsDistribution:null;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
function kokoroRuntimeSelection(runtime) {
|
|
32
|
+
return JSON.stringify(
|
|
33
|
+
{
|
|
34
|
+
source: runtime.helperRoot ? 'directory' : runtime.url ? 'archive' : 'sdk-release',
|
|
35
|
+
version: runtime.version ?? null,
|
|
36
|
+
url: runtime.url ?? null,
|
|
37
|
+
helperRoot: runtime.helperRoot ? path.resolve(runtime.helperRoot) : null,
|
|
38
|
+
helperExecutable: runtime.helperExecutable ?? null,
|
|
39
|
+
espeakDataDirectory: runtime.espeakDataDirectory ?? null,
|
|
40
|
+
libraryDirectory: runtime.libraryDirectory ?? null,
|
|
41
|
+
sdkVersion: !runtime.helperRoot && !runtime.url ? packageMetadata.version : null
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
31
46
|
function selectRuntimes(runtimes){
|
|
32
47
|
if(!is.array(runtimes)){
|
|
33
48
|
throw new ArcaneError(ERROR_CODES.usage,'Local AI runtimes must be an array.');
|
|
@@ -37,14 +52,23 @@ function selectRuntimes(runtimes){
|
|
|
37
52
|
const requirement=is.string(item)?{id:item}:item;
|
|
38
53
|
const runtime=requirement?.id==='stable-diffusion.cpp'?normalizeImageRuntimeRequirement(requirement)
|
|
39
54
|
:requirement?.id==='whisper.cpp'?normalizeWhisperRuntimeRequirement(requirement)
|
|
55
|
+
:requirement?.id==='kokoro-native'?normalizeKokoroRuntimeRequirement(requirement)
|
|
40
56
|
:requirement?.id==='llama.cpp'?normalizeLlamaRuntimeRequirement(requirement):requirement;
|
|
41
|
-
if(!runtime||!['llama.cpp','ollama','nemo-speech','onnx','stable-diffusion.cpp','whisper.cpp'].includes(runtime.id)){
|
|
57
|
+
if(!runtime||!['llama.cpp','ollama','nemo-speech','onnx','stable-diffusion.cpp','whisper.cpp','kokoro-native'].includes(runtime.id)){
|
|
42
58
|
throw new ArcaneError(ERROR_CODES.targetUnavailable,`Local AI runtime installation is unavailable for ${String(runtime?.id)}.`);
|
|
43
59
|
}
|
|
44
60
|
const prior=selected.get(runtime.id);
|
|
45
61
|
if(prior&&runtime.id==='whisper.cpp'&&whisperRuntimeSelection(prior)!==whisperRuntimeSelection(runtime)){
|
|
46
62
|
throw new ArcaneError(ERROR_CODES.usage,'Select one complete whisper.cpp runtime and model requirement for this operation.');
|
|
47
63
|
}
|
|
64
|
+
if (runtime.id === 'kokoro-native') {
|
|
65
|
+
if (runtime.helperRoot && runtime.url) {
|
|
66
|
+
throw new ArcaneError(ERROR_CODES.usage, 'Select either a kokoro-native helperRoot or an archive URL for this operation.');
|
|
67
|
+
}
|
|
68
|
+
if (prior && kokoroRuntimeSelection(prior) !== kokoroRuntimeSelection(runtime)) {
|
|
69
|
+
throw new ArcaneError(ERROR_CODES.usage, 'Select one complete kokoro-native helper requirement for this operation.');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
48
72
|
if(prior&&(prior.version!==runtime.version||prior.url!==runtime.url
|
|
49
73
|
||(['stable-diffusion.cpp','llama.cpp'].includes(runtime.id)&&prior.backend!==runtime.backend)
|
|
50
74
|
||(runtime.id==='llama.cpp'&&prior.companionUrl!==runtime.companionUrl))){
|
|
@@ -111,6 +135,20 @@ async function selectRelease(runtime,platform,architecture,signal){
|
|
|
111
135
|
if (runtime.id === 'stable-diffusion.cpp') {
|
|
112
136
|
return selectImageRelease(runtime, platform, architecture, signal);
|
|
113
137
|
}
|
|
138
|
+
if (runtime.id === 'kokoro-native') {
|
|
139
|
+
if (runtime.helperRoot) return null;
|
|
140
|
+
if (!runtime.url) {
|
|
141
|
+
if (platform !== 'win32' || architecture !== 'x64') {
|
|
142
|
+
throw new ArcaneError(ERROR_CODES.targetUnavailable, `Supply a complete kokoro-native archive URL or helperRoot for ${platform}/${architecture}.`);
|
|
143
|
+
}
|
|
144
|
+
const name = 'arcane-kokoro-windows-x64.tar.gz';
|
|
145
|
+
return {
|
|
146
|
+
version: packageMetadata.version,
|
|
147
|
+
url: `https://github.com/TheWizardNexus/arcane-os-sdk/releases/download/${packageMetadata.version}/${name}`,
|
|
148
|
+
name
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
114
152
|
const backend=runtime.id==='llama.cpp'?llamaBackendCandidate(runtime,platform,architecture):undefined;
|
|
115
153
|
if(runtime.url){
|
|
116
154
|
const name=path.posix.basename(new URL(runtime.url).pathname);
|
|
@@ -432,6 +470,7 @@ async function installedRuntime(base,runtime,platform,architecture,signal,reuseW
|
|
|
432
470
|
throw error;
|
|
433
471
|
}
|
|
434
472
|
if(installation.requestVersion!==(runtime.version??null)||installation.requestUrl!==(runtime.url??null))continue;
|
|
473
|
+
if (runtime.id === 'kokoro-native' && installation.requestKokoro !== kokoroRuntimeSelection(runtime)) continue;
|
|
435
474
|
if (runtime.id === 'stable-diffusion.cpp' && installation.requestBackend !== runtime.backend) continue;
|
|
436
475
|
if(runtime.id==='llama.cpp'&&((installation.requestBackend??'auto')!==runtime.backend
|
|
437
476
|
||(installation.requestCompanionUrl??null)!==(runtime.companionUrl??null)))continue;
|
|
@@ -445,6 +484,10 @@ async function installedRuntime(base,runtime,platform,architecture,signal,reuseW
|
|
|
445
484
|
if(record?.id!==runtime.id||record.platform!==platform||record.architecture!==architecture)continue;
|
|
446
485
|
if(runtime.id==='llama.cpp'&&record.backend!==undefined&&record.backend!==llamaBackend)continue;
|
|
447
486
|
try{
|
|
487
|
+
if (runtime.id === 'kokoro-native') {
|
|
488
|
+
if (await installedKokoroRuntime(record, signal)) return record;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
448
491
|
if(runtime.id==='whisper.cpp'){
|
|
449
492
|
if(await installedWhisperRuntime(record,signal,!reuseWhisper))return reuseWhisper?{runtime:record,requirement:whisperRequirement}:record;
|
|
450
493
|
continue;
|
|
@@ -507,6 +550,51 @@ async function installedRuntime(base,runtime,platform,architecture,signal,reuseW
|
|
|
507
550
|
return null;
|
|
508
551
|
}
|
|
509
552
|
|
|
553
|
+
function kokoroRuntimeLocations(runtime, root, platform) {
|
|
554
|
+
const sourceRoot = runtime.helperRoot ? path.resolve(runtime.helperRoot) : root;
|
|
555
|
+
const defaults = {
|
|
556
|
+
helperExecutable: path.join('bin', `arcane-kokoro${platform === 'win32' ? '.exe' : ''}`),
|
|
557
|
+
espeakDataDirectory: 'share',
|
|
558
|
+
libraryDirectory: 'bin'
|
|
559
|
+
};
|
|
560
|
+
const locations = {};
|
|
561
|
+
for (const [field, fallback] of Object.entries(defaults)) {
|
|
562
|
+
const selected = path.resolve(sourceRoot, runtime[field] ?? fallback);
|
|
563
|
+
const relative = path.relative(sourceRoot, selected);
|
|
564
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
565
|
+
throw new ArcaneError(ERROR_CODES.usage, `kokoro-native ${field} must belong to the selected complete helper tree so it can be relocated.`);
|
|
566
|
+
}
|
|
567
|
+
locations[field] = path.join(root, relative);
|
|
568
|
+
}
|
|
569
|
+
return locations;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async function installedKokoroRuntime(record, signal) {
|
|
573
|
+
throwIfAborted(signal);
|
|
574
|
+
const locations = [record.helperExecutable, record.espeakDataDirectory, record.libraryDirectory];
|
|
575
|
+
const missingLocation = locations.some(
|
|
576
|
+
function missingKokoroLocation(location) {
|
|
577
|
+
return !is.string(location);
|
|
578
|
+
}
|
|
579
|
+
);
|
|
580
|
+
if (missingLocation) return false;
|
|
581
|
+
// eSpeak's data argument is the parent of its espeak-ng-data directory.
|
|
582
|
+
locations.push(path.join(record.espeakDataDirectory, 'espeak-ng-data'));
|
|
583
|
+
const entries = await Promise.all(
|
|
584
|
+
locations.map(
|
|
585
|
+
function inspectKokoroLocation(location) {
|
|
586
|
+
return stat(location);
|
|
587
|
+
}
|
|
588
|
+
)
|
|
589
|
+
);
|
|
590
|
+
throwIfAborted(signal);
|
|
591
|
+
return entries.every(
|
|
592
|
+
function kokoroLocationPresent(info, index) {
|
|
593
|
+
return index === 0 ? info.isFile() : info.isDirectory();
|
|
594
|
+
}
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
510
598
|
async function runtimeExecutable(root,id,platform,signal){
|
|
511
599
|
const filename=`${id==='llama.cpp'?'llama-server':'ollama'}${platform==='win32'?'.exe':''}`;
|
|
512
600
|
const directories=[root];
|
|
@@ -645,6 +733,22 @@ async function installRuntime(runtime,{directory,platform,architecture,signal,on
|
|
|
645
733
|
release,
|
|
646
734
|
{root, directory, platform, architecture, signal, onEvent}
|
|
647
735
|
);
|
|
736
|
+
} else if (runtime.id === 'kokoro-native' && runtime.helperRoot) {
|
|
737
|
+
const sourceRoot = path.resolve(runtime.helperRoot);
|
|
738
|
+
const destination = path.relative(sourceRoot, root);
|
|
739
|
+
if (destination !== '..' && !destination.startsWith(`..${path.sep}`) && !path.isAbsolute(destination)) {
|
|
740
|
+
throw new ArcaneError(ERROR_CODES.usage, 'The kokoro-native installation directory must be outside helperRoot so copying does not include its own output.');
|
|
741
|
+
}
|
|
742
|
+
locations = {version: runtime.version ?? 'custom', ...kokoroRuntimeLocations(runtime, root, platform)};
|
|
743
|
+
await onEvent(
|
|
744
|
+
{
|
|
745
|
+
type: 'local-ai.install.copying',
|
|
746
|
+
message: 'Copying the selected complete Kokoro helper tree.',
|
|
747
|
+
data: {id: runtime.id, platform, architecture}
|
|
748
|
+
}
|
|
749
|
+
);
|
|
750
|
+
const files = [];
|
|
751
|
+
await copyRuntimeTree(sourceRoot, root, root, files, signal);
|
|
648
752
|
}else{
|
|
649
753
|
const assets=[{url:release.url,name:release.name,archive}];
|
|
650
754
|
if(companionArchive){
|
|
@@ -679,13 +783,17 @@ async function installRuntime(runtime,{directory,platform,architecture,signal,on
|
|
|
679
783
|
...(runtime.id==='llama.cpp'?{backend:release.backend,requestedBackend:release.requestedBackend}:{}),
|
|
680
784
|
...(runtime.id==='nemo-speech'
|
|
681
785
|
?await nemoRuntimeDirectories(root,signal)
|
|
786
|
+
:runtime.id==='kokoro-native'?kokoroRuntimeLocations(runtime,root,platform)
|
|
682
787
|
:{executable:await runtimeExecutable(root,runtime.id,platform,signal)})};
|
|
683
788
|
}
|
|
684
789
|
const record={id:runtime.id,platform,architecture,root,...locations,...(runtime.version?{requestedVersion:runtime.version}:{})};
|
|
790
|
+
if (runtime.id === 'kokoro-native' && !await installedKokoroRuntime(record, signal)) {
|
|
791
|
+
throw new ArcaneError(ERROR_CODES.operationFailed, 'Kokoro preparation did not produce the selected helper executable, eSpeak data, and library directory.');
|
|
792
|
+
}
|
|
685
793
|
throwIfAborted(signal);
|
|
686
794
|
if(archive)await unlink(archive);
|
|
687
795
|
if(companionArchive)await unlink(companionArchive);
|
|
688
|
-
await writeFile(path.join(attempt,'installation.json'),`${JSON.stringify({requestVersion:runtime.version??null,requestUrl:runtime.url??null,...(['stable-diffusion.cpp','llama.cpp'].includes(runtime.id)?{requestBackend:runtime.backend}:{}),...(runtime.id==='llama.cpp'?{requestCompanionUrl:runtime.companionUrl??null}:{}),...(runtime.id==='whisper.cpp'?{requestWhisper:whisperRuntimeSelection(runtime)}:{}),runtime:record},null,2)}\n`,{flag:'wx',signal});
|
|
796
|
+
await writeFile(path.join(attempt,'installation.json'),`${JSON.stringify({requestVersion:runtime.version??null,requestUrl:runtime.url??null,...(['stable-diffusion.cpp','llama.cpp'].includes(runtime.id)?{requestBackend:runtime.backend}:{}),...(runtime.id==='llama.cpp'?{requestCompanionUrl:runtime.companionUrl??null}:{}),...(runtime.id==='whisper.cpp'?{requestWhisper:whisperRuntimeSelection(runtime)}:{}),...(runtime.id==='kokoro-native'?{requestKokoro:kokoroRuntimeSelection(runtime)}:{}),runtime:record},null,2)}\n`,{flag:'wx',signal});
|
|
689
797
|
completed=true;
|
|
690
798
|
await onEvent({type:'local-ai.install.completed',message:`${runtime.id} ${record.version} is available.`,data:record});
|
|
691
799
|
return record;
|
|
@@ -705,7 +813,7 @@ async function installRuntime(runtime,{directory,platform,architecture,signal,on
|
|
|
705
813
|
|
|
706
814
|
function shareInstallation(runtime,options){
|
|
707
815
|
throwIfAborted(options.signal);
|
|
708
|
-
const key=JSON.stringify([options.directory,options.platform,options.architecture,runtime.id,runtime.version??null,runtime.url??null,['stable-diffusion.cpp','llama.cpp'].includes(runtime.id)?runtime.backend:null,runtime.id==='whisper.cpp'?whisperRuntimeSelection(runtime):null,runtime.id==='llama.cpp'?runtime.companionUrl??null:null]);
|
|
816
|
+
const key=JSON.stringify([options.directory,options.platform,options.architecture,runtime.id,runtime.version??null,runtime.url??null,['stable-diffusion.cpp','llama.cpp'].includes(runtime.id)?runtime.backend:null,runtime.id==='whisper.cpp'?whisperRuntimeSelection(runtime):null,runtime.id==='llama.cpp'?runtime.companionUrl??null:null,runtime.id==='kokoro-native'?kokoroRuntimeSelection(runtime):null]);
|
|
709
817
|
let entry=installations.get(key);
|
|
710
818
|
if(entry?.controller.signal.aborted){
|
|
711
819
|
// The prior cancelled attempt owns its cleanup. A later request waits
|
|
@@ -855,7 +963,7 @@ export async function bundleLocalAIRuntimes({runtimes=[],directory,outputRoot,pl
|
|
|
855
963
|
const relative=`runtime/local-ai/${runtime.id}`;
|
|
856
964
|
const destination=path.join(root,relative);
|
|
857
965
|
const bundled=runtime.id==='whisper.cpp'?bundledWhisperRuntime(runtime,relative):{...runtime,root:relative};
|
|
858
|
-
for(const field of ['executable','modulePath','includeDirectory','libraryDirectory','binaryDirectory','cmakeDirectory','libraryPath','bindingModulePath']){
|
|
966
|
+
for(const field of ['executable','modulePath','includeDirectory','libraryDirectory','binaryDirectory','cmakeDirectory','libraryPath','bindingModulePath','helperExecutable','espeakDataDirectory']){
|
|
859
967
|
if(runtime[field]===undefined)continue;
|
|
860
968
|
bundled[field]=path.posix.join(relative,path.relative(runtime.root,runtime[field]).split(path.sep).join('/'));
|
|
861
969
|
}
|