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.
Files changed (44) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +1 -1
  3. package/browser-runtime/core/client.mjs +7 -2
  4. package/docs/reference/README.md +7 -0
  5. package/docs/reference/cli.md +5 -1
  6. package/docs/reference/core-client.md +11 -2
  7. package/docs/reference/core-native-packaging.md +57 -8
  8. package/docs/reference/core-repositories.md +149 -0
  9. package/docs/reference/inventory/package-api.json +163 -9
  10. package/docs/reference/local-ai.md +51 -7
  11. package/docs/reference/local-image-flux.md +154 -0
  12. package/docs/reference/local-image-generation.md +12 -0
  13. package/docs/reference/local-image-sdxl.md +139 -0
  14. package/docs/reference/local-whisper.md +219 -0
  15. package/docs/reference/native-speech.md +163 -0
  16. package/docs/reference/runtime-modules.md +16 -2
  17. package/docs/reference/sdk-api.md +335 -6
  18. package/package.json +5 -1
  19. package/runtime/arcane/components/speech.html +15 -3
  20. package/runtime/arcane/components/voice-transcription.html +19 -3
  21. package/runtime/arcane/modules/AI.js +250 -27
  22. package/runtime/arcane/modules/AIProviderRuntime.js +20 -3
  23. package/runtime/arcane/modules/AIRuntimeState.js +15 -2
  24. package/runtime/arcane/modules/ComponentContracts.js +3 -0
  25. package/schemas/arcane-app.schema.json +53 -2
  26. package/schemas/arcane-package.schema.json +53 -2
  27. package/src/core/repositories.mjs +210 -0
  28. package/src/core/services/speech.mjs +361 -0
  29. package/src/local-ai/config.mjs +9 -3
  30. package/src/local-ai/dev.mjs +32 -2
  31. package/src/local-ai/image-library.mjs +9 -0
  32. package/src/local-ai/image-models/flux.mjs +31 -0
  33. package/src/local-ai/image-models/sdxl.mjs +18 -0
  34. package/src/local-ai/install.mjs +26 -7
  35. package/src/local-ai/whisper/build.mjs +48 -0
  36. package/src/local-ai/whisper/config.mjs +61 -0
  37. package/src/local-ai/whisper/index.mjs +515 -0
  38. package/src/local-ai/whisper/install.mjs +274 -0
  39. package/src/local-ai/whisper/native/CMakeLists.txt +98 -0
  40. package/src/local-ai/whisper/native/NOTICE +18 -0
  41. package/src/local-ai/whisper/native/README.md +104 -0
  42. package/src/local-ai/whisper/native/main.cpp +489 -0
  43. package/src/native/portable-layout.mjs +37 -7
  44. 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;
@@ -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 : value;
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 the built-in sd14 ID.');
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};
@@ -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() {