arcane-os 0.62.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 +40 -0
- package/README.md +1 -1
- package/browser-runtime/core/client.mjs +7 -2
- package/docs/reference/README.md +7 -0
- package/docs/reference/cli.md +5 -1
- package/docs/reference/core-client.md +11 -2
- package/docs/reference/core-native-packaging.md +57 -8
- package/docs/reference/core-repositories.md +149 -0
- package/docs/reference/inventory/package-api.json +163 -9
- package/docs/reference/local-ai.md +51 -7
- package/docs/reference/local-image-flux.md +154 -0
- package/docs/reference/local-image-generation.md +12 -0
- package/docs/reference/local-image-sdxl.md +139 -0
- package/docs/reference/local-whisper.md +219 -0
- package/docs/reference/native-speech.md +163 -0
- package/docs/reference/runtime-modules.md +16 -2
- package/docs/reference/sdk-api.md +335 -6
- package/package.json +5 -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 +361 -0
- package/src/local-ai/config.mjs +9 -3
- package/src/local-ai/dev.mjs +32 -2
- package/src/local-ai/image-library.mjs +9 -0
- package/src/local-ai/image-models/flux.mjs +31 -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
- package/src/native/windows-provider.mjs +39 -15
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import Is from 'strong-type';
|
|
2
|
+
import {createArcaneEventSource} from '../../event-manager.mjs';
|
|
3
|
+
import {CoreError, serializeCoreError} from '../../../browser-runtime/core/contracts.mjs';
|
|
4
|
+
|
|
5
|
+
const is = new Is(false);
|
|
6
|
+
|
|
7
|
+
function failure(code, message) {
|
|
8
|
+
return new CoreError(
|
|
9
|
+
{code, message}
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function waitForLoad(task, signal) {
|
|
14
|
+
signal.throwIfAborted();
|
|
15
|
+
return new Promise(
|
|
16
|
+
function awaitSpeechLoad(resolve, reject) {
|
|
17
|
+
function cancelled() {
|
|
18
|
+
signal.removeEventListener('abort', cancelled);
|
|
19
|
+
reject(signal.reason);
|
|
20
|
+
}
|
|
21
|
+
signal.addEventListener(
|
|
22
|
+
'abort',
|
|
23
|
+
cancelled,
|
|
24
|
+
{once: true}
|
|
25
|
+
);
|
|
26
|
+
task.then(
|
|
27
|
+
function loaded(value) {
|
|
28
|
+
signal.removeEventListener('abort', cancelled);
|
|
29
|
+
if (signal.aborted) {
|
|
30
|
+
reject(signal.reason);
|
|
31
|
+
} else {
|
|
32
|
+
resolve(value);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
function loadFailed(error) {
|
|
36
|
+
signal.removeEventListener('abort', cancelled);
|
|
37
|
+
reject(error);
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Hosts supply selected engines; this service owns their independent lifetimes. */
|
|
45
|
+
export function createSpeechService({stt, tts, signal} = {}) {
|
|
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}
|
|
49
|
+
};
|
|
50
|
+
for (const [name, role] of Object.entries(roles)) {
|
|
51
|
+
if (role.engine == null) {
|
|
52
|
+
role.state = {providerId: '', modelId: null, state: 'unavailable', loaded: false, busy: false};
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
for (const method of ['current', 'subscribe', 'load', 'close', role.operation]) {
|
|
56
|
+
if (!is.function(role.engine[method])) {
|
|
57
|
+
throw new TypeError(`The ${name} speech engine requires ${method}().`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
role.state = role.engine.current();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const lifetime = new AbortController();
|
|
64
|
+
const lifetimeSignal = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal;
|
|
65
|
+
const owner = {};
|
|
66
|
+
const events = createArcaneEventSource(
|
|
67
|
+
owner,
|
|
68
|
+
{source: 'core-speech', eventTypes: ['speech.state']}
|
|
69
|
+
);
|
|
70
|
+
const jobs = new Set();
|
|
71
|
+
const publications = [];
|
|
72
|
+
let context;
|
|
73
|
+
let started = false;
|
|
74
|
+
let publishing = false;
|
|
75
|
+
let closing = null;
|
|
76
|
+
let closed = false;
|
|
77
|
+
|
|
78
|
+
function available(role) {
|
|
79
|
+
return started && !closing && !closed && role.state.loaded === true
|
|
80
|
+
&& (role.state.state === 'ready' || role.state.state === 'running');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function current() {
|
|
84
|
+
const transcriptionAvailable = available(roles.stt);
|
|
85
|
+
const synthesisAvailable = available(roles.tts);
|
|
86
|
+
return {
|
|
87
|
+
ready: transcriptionAvailable && synthesisAvailable,
|
|
88
|
+
synthesisAvailable,
|
|
89
|
+
transcriptionAvailable,
|
|
90
|
+
status: closed ? 'closed' : closing ? 'closing' : started ? 'ok' : 'created',
|
|
91
|
+
ttsEngine: roles.tts.state.providerId ?? '',
|
|
92
|
+
sttEngine: roles.stt.state.providerId ?? '',
|
|
93
|
+
roles: {
|
|
94
|
+
stt: {...roles.stt.state, available: transcriptionAvailable},
|
|
95
|
+
tts: {...roles.tts.state, available: synthesisAvailable}
|
|
96
|
+
},
|
|
97
|
+
closed
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function publish() {
|
|
102
|
+
publications.push(current());
|
|
103
|
+
if (publishing) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
publishing = true;
|
|
107
|
+
try {
|
|
108
|
+
// A listener may close the service. Finish this snapshot before
|
|
109
|
+
// publishing the lifecycle change that listener caused.
|
|
110
|
+
while (publications.length) {
|
|
111
|
+
const snapshot = publications.shift();
|
|
112
|
+
events.dispatch('speech.state', snapshot);
|
|
113
|
+
context?.emit('speech.state', snapshot);
|
|
114
|
+
}
|
|
115
|
+
} finally {
|
|
116
|
+
publishing = false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function roleFailed(name, error) {
|
|
121
|
+
const role = roles[name];
|
|
122
|
+
role.state = {...role.state, state: 'error', error: serializeCoreError(error)};
|
|
123
|
+
console.error(`The ${name} speech engine failed.`, error);
|
|
124
|
+
publish();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function startRole(name) {
|
|
128
|
+
const role = roles[name];
|
|
129
|
+
if (!role.engine || closing) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
role.unsubscribe = role.engine.subscribe(
|
|
134
|
+
function speechEngineChanged(state) {
|
|
135
|
+
if (!closed) {
|
|
136
|
+
role.state = state;
|
|
137
|
+
publish();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
roleFailed(name, error);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const task = Promise.resolve().then(
|
|
146
|
+
async function loadSpeechEngine() {
|
|
147
|
+
lifetimeSignal.throwIfAborted();
|
|
148
|
+
await role.engine.load(
|
|
149
|
+
{signal: lifetimeSignal}
|
|
150
|
+
);
|
|
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
|
+
}
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function invoke(name, parameters, request) {
|
|
176
|
+
if (closing || closed) {
|
|
177
|
+
throw failure('CORE_CLOSING', 'The speech service is closing.');
|
|
178
|
+
}
|
|
179
|
+
const role = roles[name];
|
|
180
|
+
const operationSignal = request.signal
|
|
181
|
+
? AbortSignal.any([lifetimeSignal, request.signal]) : lifetimeSignal;
|
|
182
|
+
operationSignal.throwIfAborted();
|
|
183
|
+
if (!role.engine) {
|
|
184
|
+
throw failure('SPEECH_ENGINE_UNAVAILABLE', `No ${name} speech engine is configured.`);
|
|
185
|
+
}
|
|
186
|
+
request.emit?.(
|
|
187
|
+
'speech.progress',
|
|
188
|
+
{requestId: request.requestId, role: name, status: 'Thinking', progress: {phase: 'accepted'}}
|
|
189
|
+
);
|
|
190
|
+
const task = (async function invokeSpeechEngine() {
|
|
191
|
+
if (role.loading) {
|
|
192
|
+
await waitForLoad(role.loading, operationSignal);
|
|
193
|
+
}
|
|
194
|
+
operationSignal.throwIfAborted();
|
|
195
|
+
const result = await role.engine[role.operation](
|
|
196
|
+
parameters,
|
|
197
|
+
{
|
|
198
|
+
signal: operationSignal,
|
|
199
|
+
requestId: request.requestId,
|
|
200
|
+
onProgress(progress) {
|
|
201
|
+
if (!operationSignal.aborted) {
|
|
202
|
+
request.emit?.(
|
|
203
|
+
'speech.progress',
|
|
204
|
+
{requestId: request.requestId, role: name, status: 'Thinking', progress}
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
);
|
|
210
|
+
operationSignal.throwIfAborted();
|
|
211
|
+
return result;
|
|
212
|
+
})();
|
|
213
|
+
jobs.add(task);
|
|
214
|
+
function speechRequestSettled() {
|
|
215
|
+
jobs.delete(task);
|
|
216
|
+
}
|
|
217
|
+
task.then(speechRequestSettled, speechRequestSettled);
|
|
218
|
+
return task;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function notifyListener(listener, state) {
|
|
222
|
+
const result = listener(state);
|
|
223
|
+
if (result && is.function(result.then)) {
|
|
224
|
+
Promise.resolve(result).catch(
|
|
225
|
+
function speechListenerFailed(error) {
|
|
226
|
+
console.error('Speech state listener failed.', error);
|
|
227
|
+
}
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function subscribe(listener, {replay = true, signal: subscriptionSignal} = {}) {
|
|
233
|
+
if (!is.function(listener)) {
|
|
234
|
+
throw new TypeError('A speech state listener must be a function.');
|
|
235
|
+
}
|
|
236
|
+
if (closed) {
|
|
237
|
+
if (replay && !subscriptionSignal?.aborted) {
|
|
238
|
+
notifyListener(listener, current());
|
|
239
|
+
}
|
|
240
|
+
return function closedSubscription() {};
|
|
241
|
+
}
|
|
242
|
+
const unsubscribe = events.on(
|
|
243
|
+
'speech.state',
|
|
244
|
+
function speechStateChanged(event) {
|
|
245
|
+
notifyListener(listener, event.detail);
|
|
246
|
+
},
|
|
247
|
+
{signal: subscriptionSignal}
|
|
248
|
+
);
|
|
249
|
+
try {
|
|
250
|
+
if (replay && !subscriptionSignal?.aborted) {
|
|
251
|
+
notifyListener(listener, current());
|
|
252
|
+
}
|
|
253
|
+
} catch (error) {
|
|
254
|
+
unsubscribe();
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
return unsubscribe;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function close() {
|
|
261
|
+
if (closing) {
|
|
262
|
+
return closing;
|
|
263
|
+
}
|
|
264
|
+
closing = Promise.resolve().then(
|
|
265
|
+
async function closeSpeechService() {
|
|
266
|
+
const shutdowns = Object.entries(roles).map(
|
|
267
|
+
async function closeSpeechEngine([name, role]) {
|
|
268
|
+
if (!role.engine) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
await role.engine.close();
|
|
273
|
+
role.state = role.engine.current();
|
|
274
|
+
} catch (error) {
|
|
275
|
+
roleFailed(name, error);
|
|
276
|
+
throw error;
|
|
277
|
+
} finally {
|
|
278
|
+
role.unsubscribe?.();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
);
|
|
282
|
+
const loading = Object.values(roles).map(
|
|
283
|
+
function speechLoadTask(role) {
|
|
284
|
+
return role.loading;
|
|
285
|
+
}
|
|
286
|
+
).filter(Boolean);
|
|
287
|
+
const results = await Promise.allSettled([...shutdowns, ...loading, ...jobs]);
|
|
288
|
+
lifetimeSignal.removeEventListener('abort', abortService);
|
|
289
|
+
closed = true;
|
|
290
|
+
publish();
|
|
291
|
+
const failures = [];
|
|
292
|
+
for (let index = 0; index < shutdowns.length; index += 1) {
|
|
293
|
+
if (results[index].status === 'rejected') {
|
|
294
|
+
failures.push(results[index].reason);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (failures.length) {
|
|
298
|
+
throw new AggregateError(failures, 'Speech service shutdown failed.');
|
|
299
|
+
}
|
|
300
|
+
return current();
|
|
301
|
+
}
|
|
302
|
+
);
|
|
303
|
+
lifetime.abort();
|
|
304
|
+
publish();
|
|
305
|
+
return closing;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function abortService() {
|
|
309
|
+
close().catch(
|
|
310
|
+
function speechShutdownFailed(error) {
|
|
311
|
+
console.error('Speech service shutdown failed.', error);
|
|
312
|
+
}
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
lifetimeSignal.addEventListener(
|
|
317
|
+
'abort',
|
|
318
|
+
abortService,
|
|
319
|
+
{once: true}
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
name: 'speech',
|
|
324
|
+
current,
|
|
325
|
+
subscribe,
|
|
326
|
+
close,
|
|
327
|
+
start(currentContext) {
|
|
328
|
+
if (started || closing) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
context = currentContext;
|
|
332
|
+
if (lifetimeSignal.aborted) {
|
|
333
|
+
abortService();
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
started = true;
|
|
337
|
+
startRole('stt');
|
|
338
|
+
startRole('tts');
|
|
339
|
+
publish();
|
|
340
|
+
},
|
|
341
|
+
methods: {
|
|
342
|
+
'speech.status': current,
|
|
343
|
+
'speech.transcribe': function transcribe(parameters, request = {}) {
|
|
344
|
+
return invoke('stt', parameters, request);
|
|
345
|
+
},
|
|
346
|
+
'speech.synthesize': function synthesize(parameters, request = {}) {
|
|
347
|
+
return invoke('tts', parameters, request);
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
drain: close,
|
|
351
|
+
async dispose() {
|
|
352
|
+
try {
|
|
353
|
+
await close();
|
|
354
|
+
} finally {
|
|
355
|
+
events.dispose();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export default createSpeechService;
|
package/src/local-ai/config.mjs
CHANGED
|
@@ -3,9 +3,12 @@ import path from 'node:path';
|
|
|
3
3
|
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
|
+
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';
|
|
6
9
|
|
|
7
10
|
const is = new Is(false);
|
|
8
|
-
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'];
|
|
9
12
|
|
|
10
13
|
/** Image requirements describe assets without acquiring or loading a model. */
|
|
11
14
|
export function normalizeImageRuntimeRequirement(record) {
|
|
@@ -18,9 +21,11 @@ export function normalizeImageRuntimeRequirement(record) {
|
|
|
18
21
|
}
|
|
19
22
|
const models = (record.models ?? []).map(
|
|
20
23
|
function selectedImageModel(value) {
|
|
21
|
-
const model = value === 'sd14' ? SD14_MODEL
|
|
24
|
+
const model = value === 'sd14' ? SD14_MODEL
|
|
25
|
+
: value === 'flux2-klein-4b' ? FLUX2_KLEIN_4B_MODEL
|
|
26
|
+
: value === 'sdxl-base-1.0' ? SDXL_BASE_1_0_MODEL : value;
|
|
22
27
|
if (!model || !is.object(model) || is.array(model) || !is.string(model.id) || !model.id) {
|
|
23
|
-
throw new ArcaneError(ERROR_CODES.usage, 'An image model needs a descriptor with an id, or
|
|
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).');
|
|
24
29
|
}
|
|
25
30
|
if (!model.resources || !is.object(model.resources) || is.array(model.resources)) {
|
|
26
31
|
throw new ArcaneError(ERROR_CODES.usage, `Image model ${model.id} needs resources keyed by model role.`);
|
|
@@ -57,6 +62,7 @@ export function normalizeLocalAIConfig(value) {
|
|
|
57
62
|
throw new ArcaneError(ERROR_CODES.usage, `Unknown local AI runtime: ${String(record?.id ?? item)}.`);
|
|
58
63
|
}
|
|
59
64
|
if (record.id === 'stable-diffusion.cpp') return normalizeImageRuntimeRequirement(record);
|
|
65
|
+
if (record.id === 'whisper.cpp') return normalizeWhisperRuntimeRequirement(record);
|
|
60
66
|
return {...record};
|
|
61
67
|
});
|
|
62
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() {
|