arcane-os 0.64.0 → 0.65.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 (41) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/browser-runtime/core/client.mjs +62 -5
  4. package/docs/guides/native-whisper-openvino.md +334 -0
  5. package/docs/reference/README.md +4 -0
  6. package/docs/reference/arcane-ollama.md +1 -1
  7. package/docs/reference/codex-app-server.md +269 -0
  8. package/docs/reference/core-client.md +60 -2
  9. package/docs/reference/core-development.md +157 -0
  10. package/docs/reference/core-native-packaging.md +13 -0
  11. package/docs/reference/core-repositories.md +112 -3
  12. package/docs/reference/inventory/package-api.json +143 -3
  13. package/docs/reference/inventory/runtime-modules.json +3 -2
  14. package/docs/reference/local-ai.md +73 -3
  15. package/docs/reference/local-image-generation.md +5 -0
  16. package/docs/reference/local-image-sdxl.md +1 -1
  17. package/docs/reference/mcp-stdio.md +263 -0
  18. package/docs/reference/runtime-components.md +18 -1
  19. package/docs/reference/runtime-modules.md +10 -2
  20. package/docs/reference/sdk-api.md +251 -1
  21. package/package.json +6 -2
  22. package/runtime/arcane/modules/AI.js +156 -13
  23. package/runtime/arcane/modules/Ollama.js +1 -1
  24. package/runtime/arcane/modules/OllamaModelIdentifier.js +13 -0
  25. package/schemas/arcane-app.schema.json +8 -1
  26. package/schemas/arcane-package.schema.json +8 -1
  27. package/src/codex/app-server.mjs +845 -0
  28. package/src/core/development.mjs +457 -0
  29. package/src/core/repositories.mjs +170 -5
  30. package/src/core/services/local-ai.mjs +80 -10
  31. package/src/dev-server.mjs +3 -2
  32. package/src/index.mjs +1 -0
  33. package/src/local-ai/dev.mjs +80 -426
  34. package/src/local-ai/whisper/config.mjs +31 -3
  35. package/src/local-ai/whisper/index.mjs +62 -5
  36. package/src/local-ai/whisper/install.mjs +72 -14
  37. package/src/local-ai/whisper/native/main.cpp +33 -2
  38. package/src/local-ai/whisper/openvino-build.mjs +138 -0
  39. package/src/mcp/stdio-server.mjs +579 -0
  40. package/src/native/portable-layout.mjs +3 -1
  41. package/src/toolchain.mjs +19 -8
@@ -37,6 +37,9 @@ export function createWhisperRuntime({runtime, prepare, modelId, temporaryDirect
37
37
  requestedBackend: helper?.variant.backend ?? runtime?.backend ?? 'auto',
38
38
  observedBackend: helper?.metadata?.observedBackend ?? null,
39
39
  backendEvidence: helper?.metadata?.backendEvidence ?? null,
40
+ requestedEncoder: runtime?.encoder === 'openvino-npu' ? 'NPU' : null,
41
+ observedEncoder: helper?.metadata?.observedEncoder ?? null,
42
+ encoderEvidence: helper?.metadata?.encoderEvidence ?? null,
40
43
  error: error ? {code: error.code, message: error.message} : null
41
44
  };
42
45
  }
@@ -80,6 +83,11 @@ export function createWhisperRuntime({runtime, prepare, modelId, temporaryDirect
80
83
  const library = variant.libraryDirectory ?? variant.root;
81
84
  const args = ['--model-base64', encode(model.path), '--runtime-base64', encode(library),
82
85
  '--backend', variant.backend === 'cpu' ? 'cpu' : 'gpu'];
86
+ if (runtime.encoder === 'openvino-npu') {
87
+ args.push('--encoder-model-base64', encode(model.encoderPath),
88
+ '--encoder-cache-base64', encode(path.join(temporaryDirectory, 'encoder-cache')),
89
+ '--encoder-device', 'NPU');
90
+ }
83
91
  if (runtime.threads !== undefined) args.push('--threads', String(runtime.threads));
84
92
 
85
93
  async function receive({stream, chunk}) {
@@ -96,7 +104,7 @@ export function createWhisperRuntime({runtime, prepare, modelId, temporaryDirect
96
104
  session.metadata = record;
97
105
  readiness.resolve(record);
98
106
  } else if (record.type === 'error' && !record.requestId) {
99
- readiness.reject(whisperError('WHISPER_MODEL_FAILED', record.message));
107
+ readiness.reject(whisperError(record.code ?? 'WHISPER_MODEL_FAILED', record.message));
100
108
  } else if (pending && record.requestId === pending.id) {
101
109
  if (record.type === 'complete') {
102
110
  pending.response.resolve({text: record.text, language: record.language,
@@ -179,7 +187,7 @@ export function createWhisperRuntime({runtime, prepare, modelId, temporaryDirect
179
187
  session.input.end();
180
188
  }
181
189
  }
182
- try { await session.completion; }
190
+ try { return await session.completion; }
183
191
  catch (failure) {
184
192
  if (!session.controller.signal.aborted) throw failure;
185
193
  }
@@ -217,6 +225,13 @@ export function createWhisperRuntime({runtime, prepare, modelId, temporaryDirect
217
225
  }
218
226
  const model = runtime.models?.find(function selectedModel(record) { return record.id === selected; });
219
227
  if (!model) throw whisperError('WHISPER_MODEL_UNAVAILABLE', `The selected model ${selected} is not prepared.`);
228
+ if (runtime.encoder === 'openvino-npu') {
229
+ if (!is.string(model.encoderPath) || !is.string(model.encoderDataPath)) {
230
+ throw whisperError('WHISPER_ENCODER_UNAVAILABLE', 'The selected NPU model requires its matching OpenVINO XML and BIN files.');
231
+ }
232
+ await mkdir(path.join(temporaryDirectory, 'encoder-cache'), {recursive: true});
233
+ loadSignal.throwIfAborted();
234
+ }
220
235
  const variants = selectedVariants(runtime);
221
236
  const failures = [];
222
237
  for (const variant of variants) {
@@ -236,8 +251,13 @@ export function createWhisperRuntime({runtime, prepare, modelId, temporaryDirect
236
251
  failures.push(failure);
237
252
  try { await stopHelper(session, {terminate: true}); }
238
253
  catch (exitFailure) { if (exitFailure !== failure) failures.push(exitFailure); }
254
+ if (runtime.encoder === 'openvino-npu') {
255
+ try { await session.completion; }
256
+ catch (exitFailure) { if (exitFailure !== failure) failure.cause = exitFailure; }
257
+ }
239
258
  helper = null;
240
259
  loadSignal.throwIfAborted();
260
+ if (failure.code === 'WHISPER_ENCODER_UNAVAILABLE') throw failure;
241
261
  if (session.observerFailure || failure.code === 'WHISPER_PROTOCOL_INCOMPLETE') throw failure;
242
262
  if (variant !== variants[variants.length - 1] && onEvent) {
243
263
  await onEvent({type: 'whisper.backend.failed', message: failure.message,
@@ -247,7 +267,10 @@ export function createWhisperRuntime({runtime, prepare, modelId, temporaryDirect
247
267
  loadSignal.removeEventListener('abort', cancelLoading);
248
268
  }
249
269
  }
250
- throw new AggregateError(failures, 'The selected Whisper model could not load in the prepared runtimes.');
270
+ if (runtime.encoder === 'openvino-npu' && failures.length === 1) throw failures[0];
271
+ const failure = new AggregateError(failures, 'The selected Whisper model could not load in the prepared runtimes.');
272
+ if (runtime.encoder === 'openvino-npu') failure.code = failures[0]?.code ?? 'WHISPER_MODEL_FAILED';
273
+ throw failure;
251
274
  }).catch(function modelLoadFailed(failure) {
252
275
  error = loadSignal.aborted ? null : failure;
253
276
  state = unloading || closing ? 'unloading' : loadSignal.aborted ? 'unloaded' : 'error';
@@ -265,7 +288,7 @@ export function createWhisperRuntime({runtime, prepare, modelId, temporaryDirect
265
288
  || session.observerFailure || failure.code === 'WHISPER_PROTOCOL_INCOMPLETE'
266
289
  || (runtime.backend && runtime.backend !== 'auto')) return null;
267
290
  if (failure.code !== 'WHISPER_INFERENCE_FAILED' && !session.failed) return null;
268
- return runtime.variants.find(function preparedCPU(variant) { return variant.backend === 'cpu'; }) ?? null;
291
+ return selectedVariants(runtime).find(function preparedCPU(variant) { return variant.backend === 'cpu'; }) ?? null;
269
292
  }
270
293
 
271
294
  function transcribe(request, {signal, onProgress, requestId: suppliedRequestId} = {}) {
@@ -384,6 +407,13 @@ export function createWhisperRuntime({runtime, prepare, modelId, temporaryDirect
384
407
  const response = deferred();
385
408
  session.pending = {id, response, signal, onProgress, attempt};
386
409
  function cancelInference() {
410
+ if (runtime.encoder === 'openvino-npu') {
411
+ // OpenVINO's synchronous encoder call has no cancellation seam
412
+ // in whisper.cpp. Retire the owned process and join its exit.
413
+ session.stopping = true;
414
+ session.controller.abort(signal.reason);
415
+ return;
416
+ }
387
417
  writeCommand(session.input, `cancel\t${id}\n`).catch(function cancellationWriteFailed(failure) {
388
418
  session.controller.abort(failure);
389
419
  });
@@ -398,6 +428,29 @@ export function createWhisperRuntime({runtime, prepare, modelId, temporaryDirect
398
428
  signal.throwIfAborted();
399
429
  return result;
400
430
  } catch (failure) {
431
+ if (runtime.encoder === 'openvino-npu' && failure.code === 'WHISPER_INFERENCE_FAILED') {
432
+ const recovering = cpuRecoveryVariant(session, failure);
433
+ try { failure.data = await stopHelper(session); }
434
+ catch (exitFailure) { if (exitFailure !== failure) failure.cause = exitFailure; }
435
+ if (helper === session) {
436
+ helper = null;
437
+ state = unloading || closing ? 'unloading' : signal.aborted ? 'unloaded' : recovering ? 'recovering' : 'error';
438
+ error = signal.aborted || recovering ? null : failure;
439
+ publish();
440
+ }
441
+ signal.throwIfAborted();
442
+ throw failure;
443
+ }
444
+ if (signal.aborted && runtime.encoder === 'openvino-npu') {
445
+ await stopHelper(session, {terminate: true});
446
+ if (helper === session) {
447
+ helper = null;
448
+ state = unloading || closing ? 'unloading' : 'unloaded';
449
+ error = null;
450
+ publish();
451
+ }
452
+ signal.throwIfAborted();
453
+ }
401
454
  if (!response.settled) {
402
455
  session.controller.abort(failure);
403
456
  await Promise.allSettled([session.completion]);
@@ -453,7 +506,11 @@ export function createWhisperRuntime({runtime, prepare, modelId, temporaryDirect
453
506
  }
454
507
 
455
508
  function selectedVariants(runtime) {
456
- const variants = runtime.variants ?? [];
509
+ const variants = (runtime.variants ?? []).filter(function matchingEncoder(variant) {
510
+ return runtime.encoder === 'openvino-npu'
511
+ ? variant.encoder === 'openvino-npu'
512
+ : variant.encoder === undefined;
513
+ });
457
514
  if (runtime.backend && runtime.backend !== 'auto') {
458
515
  const selected = variants.filter(function selectedBackend(variant) { return variant.backend === runtime.backend; });
459
516
  if (selected.length) return selected;
@@ -29,7 +29,10 @@ export async function installedWhisperRuntime(record, signal, includeModels = tr
29
29
  files.push(variant.executable);
30
30
  directories.push(variant.root, variant.libraryDirectory);
31
31
  }
32
- if (includeModels) for (const model of record.models) files.push(model.path);
32
+ if (includeModels) for (const model of record.models) {
33
+ files.push(model.path);
34
+ if (record.encoder === 'openvino-npu') files.push(model.encoderPath, model.encoderDataPath);
35
+ }
33
36
  for (const location of directories) {
34
37
  throwIfAborted(signal);
35
38
  if (!is.string(location) || !(await stat(location)).isDirectory()) return false;
@@ -104,6 +107,17 @@ function defaultWhisperVariants(requirement, platform, architecture) {
104
107
  if (platform !== 'win32' || architecture !== 'x64' || requirement.version !== '1.9.4') {
105
108
  throw new ArcaneError(ERROR_CODES.targetUnavailable, `Automatic Whisper distributions are available for 1.9.4 on win32/x64. Supply complete runtime variants for ${requirement.version} on ${platform}/${architecture}.`);
106
109
  }
110
+ if (requirement.encoder === 'openvino-npu') {
111
+ if (requirement.backend === 'metal') {
112
+ throw new ArcaneError(ERROR_CODES.targetUnavailable, 'The selected Windows OpenVINO Whisper distribution has no Metal decoder backend.');
113
+ }
114
+ const version = requirement.helperVersion ?? packageMetadata.version;
115
+ return [{
116
+ backend: requirement.backend === 'cpu' ? 'cpu' : 'cuda',
117
+ encoder: 'openvino-npu',
118
+ url: `https://github.com/TheWizardNexus/arcane-os-sdk/releases/download/${version}/arcane-whisper-openvino-windows-x64.tar.gz`
119
+ }];
120
+ }
107
121
  if (requirement.backend === 'metal') {
108
122
  throw new ArcaneError(ERROR_CODES.targetUnavailable, 'The selected Windows Whisper distribution has no Metal backend.');
109
123
  }
@@ -125,7 +139,10 @@ export async function installWhisperDistribution(requirement, options) {
125
139
  const controller = new AbortController();
126
140
  const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
127
141
  const preparation = {...options, signal};
128
- const variants = reusable?.runtime.variants ?? defaultWhisperVariants(requirement, platform, architecture);
142
+ const integratedEncoder = requirement.encoder === 'openvino-npu' && !requirement.variants;
143
+ const variants = reusable
144
+ ? integratedEncoder ? [reusable.runtime.variants[0]] : reusable.runtime.variants
145
+ : defaultWhisperVariants(requirement, platform, architecture);
129
146
  if (!requirement.helperExecutable && !requirement.helperUrl && (platform !== 'win32' || architecture !== 'x64')) {
130
147
  throw new ArcaneError(ERROR_CODES.targetUnavailable, `Supply the Arcane Whisper helperExecutable or helperUrl for ${platform}/${architecture}.`);
131
148
  }
@@ -133,7 +150,7 @@ export async function installWhisperDistribution(requirement, options) {
133
150
  throw new ArcaneError(ERROR_CODES.targetUnavailable, `Supply a complete decoder tree through decoderExecutable and decoderRoot, or decoderUrl, for ${platform}/${architecture}.`);
134
151
  }
135
152
  const helperName = `arcane-whisper${platform === 'win32' ? '.exe' : ''}`;
136
- const helperRoot = path.join(root, 'helper');
153
+ let helperRoot = path.join(root, 'helper');
137
154
  let helperExecutable = path.join(helperRoot, helperName);
138
155
  const decoderRoot = path.join(root, 'decoder');
139
156
  const models = [];
@@ -151,10 +168,18 @@ export async function installWhisperDistribution(requirement, options) {
151
168
  else await prepareWhisperArchive(variant.url, variantRoot, preparation);
152
169
  const libraryName = platform === 'win32' ? 'whisper.dll' : platform === 'darwin' ? 'libwhisper.dylib' : 'libwhisper.so';
153
170
  const libraryDirectory = selectedLibraryDirectory ?? path.dirname(await findWhisperFile(variantRoot, libraryName, signal));
154
- preparedVariants[index] = {backend: variant.backend, root: variantRoot, libraryDirectory};
171
+ preparedVariants[index] = {backend: variant.backend, root: variantRoot, libraryDirectory,
172
+ ...(variant.encoder ? {encoder: variant.encoder} : {})};
173
+ if (requirement.encoder === 'openvino-npu' && !requirement.helperExecutable && !requirement.helperUrl) {
174
+ preparedVariants[index].executable = await findWhisperFile(variantRoot, helperName, signal);
175
+ }
155
176
  };
156
177
  });
157
178
  jobs.push(async function prepareHelper() {
179
+ // The optional native asset owns its matching helper and runtime as
180
+ // one complete tree. Reuse that tree once and resolve its helper after
181
+ // variant preparation joins, including when models change later.
182
+ if (requirement.encoder === 'openvino-npu' && !requirement.helperExecutable && !requirement.helperUrl) return;
158
183
  const existingHelper = reusable?.runtime.helperExecutable ?? requirement.helperExecutable;
159
184
  if (existingHelper) {
160
185
  const source = path.resolve(existingHelper);
@@ -190,25 +215,46 @@ export async function installWhisperDistribution(requirement, options) {
190
215
  jobs.push(async function prepareSelectedModel() {
191
216
  const modelRoot = path.join(root, 'models', String(index));
192
217
  await mkdir(modelRoot, {recursive: true});
193
- const filename = model.path ? path.basename(model.path) : model.filename ?? path.posix.basename(new URL(model.url).pathname);
194
- const destination = path.join(modelRoot, filename);
195
218
  const priorIndex = reusable?.requirement.models.findIndex(function sameSelectedModel(candidate) {
196
219
  return JSON.stringify(candidate) === JSON.stringify(model);
197
220
  }) ?? -1;
198
- let source = model.path;
221
+ let sourceModel = model;
199
222
  if (priorIndex >= 0) {
200
- const priorPath = reusable.runtime.models[priorIndex]?.path;
201
- if (priorPath) {
223
+ const prior = reusable.runtime.models[priorIndex];
224
+ if (prior?.path) {
202
225
  try {
203
- if ((await stat(priorPath)).isFile()) source = priorPath;
226
+ const paths = [prior.path, ...(requirement.encoder === 'openvino-npu' ? [prior.encoderPath, prior.encoderDataPath] : [])];
227
+ let available = true;
228
+ for (const location of paths) if (!location || !(await stat(location)).isFile()) available = false;
229
+ if (available) sourceModel = prior;
204
230
  } catch (error) {
205
231
  if (error.code !== 'ENOENT') throw error;
206
232
  }
207
233
  }
208
234
  }
209
- if (source) await copyFile(path.resolve(source), destination);
235
+ if (!sourceModel.path && model.archiveUrl) {
236
+ await prepareWhisperArchive(model.archiveUrl, modelRoot, preparation);
237
+ models[index] = {...model,
238
+ path: await findWhisperFile(modelRoot, model.filename, signal),
239
+ ...(requirement.encoder === 'openvino-npu' ? {
240
+ encoderPath: await findWhisperFile(modelRoot, model.encoderFilename, signal),
241
+ encoderDataPath: await findWhisperFile(modelRoot, model.encoderDataFilename, signal)
242
+ } : {})};
243
+ return;
244
+ }
245
+ const filename = sourceModel.path ? path.basename(sourceModel.path) : model.filename ?? path.posix.basename(new URL(model.url).pathname);
246
+ const destination = path.join(modelRoot, filename);
247
+ if (sourceModel.path) await copyFile(path.resolve(sourceModel.path), destination);
210
248
  else await downloadWhisperResource(model.url, destination, preparation);
211
- models[index] = {...model, path: destination};
249
+ const preparedModel = {...model, path: destination};
250
+ if (requirement.encoder === 'openvino-npu') {
251
+ for (const field of ['encoderPath', 'encoderDataPath']) {
252
+ throwIfAborted(signal);
253
+ preparedModel[field] = path.join(modelRoot, path.basename(sourceModel[field]));
254
+ await copyFile(path.resolve(sourceModel[field]), preparedModel[field]);
255
+ }
256
+ }
257
+ models[index] = preparedModel;
212
258
  });
213
259
  }
214
260
  let nextJob = 0;
@@ -232,16 +278,26 @@ export async function installWhisperDistribution(requirement, options) {
232
278
  throwIfAborted(options.signal);
233
279
  if (failures.length === 1) throw failures[0];
234
280
  if (failures.length > 1) throw new AggregateError(failures, 'Whisper dependency preparation failed.');
281
+ if (requirement.encoder === 'openvino-npu' && !requirement.helperExecutable && !requirement.helperUrl) {
282
+ helperRoot = preparedVariants[0].root;
283
+ helperExecutable = preparedVariants[0].executable;
284
+ }
285
+ // The precompiled optional tree owns both decoder backends. Share its
286
+ // helper and native files while keeping the normal GPU-first CPU recovery.
287
+ if (integratedEncoder && requirement.backend === 'auto') {
288
+ preparedVariants.push({...preparedVariants[0], backend: 'cpu'});
289
+ }
235
290
  const locations = {
236
291
  version: requirement.version,
237
292
  backend: requirement.backend,
293
+ ...(requirement.encoder ? {encoder: requirement.encoder} : {}),
238
294
  helperRoot,
239
295
  helperExecutable,
240
296
  helperVersion: requirement.helperVersion ?? packageMetadata.version,
241
297
  decoderRoot,
242
298
  decoderExecutable,
243
299
  variants: preparedVariants.map(function completedWhisperVariant(variant) {
244
- return {...variant, executable: helperExecutable};
300
+ return {...variant, executable: variant.executable ?? helperExecutable};
245
301
  }),
246
302
  models,
247
303
  ...(requirement.modelId ? {modelId: requirement.modelId} : models.length === 1 ? {modelId: models[0].id} : {})
@@ -268,7 +324,9 @@ export function bundledWhisperRuntime(runtime, relativeRoot) {
268
324
  return {...variant, root: bundledWhisperPath(variant.root), libraryDirectory: bundledWhisperPath(variant.libraryDirectory), executable: bundledWhisperPath(variant.executable)};
269
325
  }),
270
326
  models: runtime.models.map(function bundledWhisperModel(model) {
271
- return {...model, path: bundledWhisperPath(model.path)};
327
+ return {...model, path: bundledWhisperPath(model.path),
328
+ ...(model.encoderPath ? {encoderPath: bundledWhisperPath(model.encoderPath)} : {}),
329
+ ...(model.encoderDataPath ? {encoderDataPath: bundledWhisperPath(model.encoderDataPath)} : {})};
272
330
  })
273
331
  };
274
332
  }
@@ -30,6 +30,9 @@ struct Configuration {
30
30
  std::string model;
31
31
  std::string runtime;
32
32
  std::string backend = "gpu";
33
+ std::string encoder_model;
34
+ std::string encoder_cache;
35
+ std::string encoder_device;
33
36
  int threads = 1;
34
37
  };
35
38
 
@@ -393,6 +396,12 @@ Configuration read_configuration(int argc, char** argv) {
393
396
  configuration.runtime = decode_base64(value);
394
397
  } else if (option == "--backend") {
395
398
  configuration.backend = value;
399
+ } else if (option == "--encoder-model-base64") {
400
+ configuration.encoder_model = decode_base64(value);
401
+ } else if (option == "--encoder-cache-base64") {
402
+ configuration.encoder_cache = decode_base64(value);
403
+ } else if (option == "--encoder-device") {
404
+ configuration.encoder_device = value;
396
405
  } else if (option == "--threads") {
397
406
  configuration.threads = std::stoi(value);
398
407
  } else {
@@ -405,6 +414,12 @@ Configuration read_configuration(int argc, char** argv) {
405
414
  if (configuration.backend != "gpu" && configuration.backend != "cpu") {
406
415
  throw std::runtime_error("The requested backend must be gpu or cpu.");
407
416
  }
417
+ if (!configuration.encoder_model.empty() || !configuration.encoder_cache.empty()
418
+ || !configuration.encoder_device.empty()) {
419
+ if (configuration.encoder_device != "NPU" || configuration.encoder_model.empty()) {
420
+ throw std::runtime_error("The OpenVINO encoder requires its selected model path and NPU device.");
421
+ }
422
+ }
408
423
  if (configuration.threads < 1) {
409
424
  throw std::runtime_error("Native inference requires a positive thread count.");
410
425
  }
@@ -416,7 +431,9 @@ int serve(const Configuration& configuration) {
416
431
  whisper_log_set(native_log, &log);
417
432
  emit("{\"type\":\"loading\",\"model\":" + json_string(configuration.model)
418
433
  + ",\"requestedBackend\":" + json_string(configuration.backend)
419
- + ",\"observedBackend\":null,\"backendEvidence\":null}");
434
+ + ",\"observedBackend\":null,\"backendEvidence\":null"
435
+ + ",\"requestedEncoder\":" + (configuration.encoder_device.empty() ? "null" : json_string(configuration.encoder_device))
436
+ + ",\"observedEncoder\":null,\"encoderEvidence\":null}");
420
437
  ggml_backend_load_all_from_path(configuration.runtime.c_str());
421
438
  whisper_context_params parameters = whisper_context_default_params();
422
439
  parameters.use_gpu = configuration.backend == "gpu";
@@ -425,15 +442,29 @@ int serve(const Configuration& configuration) {
425
442
  if (!context) {
426
443
  throw std::runtime_error("Whisper could not load the selected model.");
427
444
  }
445
+ if (!configuration.encoder_device.empty()) {
446
+ const int result = whisper_ctx_init_openvino_encoder(context.get(), configuration.encoder_model.c_str(),
447
+ "NPU", configuration.encoder_cache.empty() ? nullptr : configuration.encoder_cache.c_str());
448
+ if (result != 0) {
449
+ emit_error("", "whisper_ctx_init_openvino_encoder for NPU returned " + std::to_string(result) + ".",
450
+ "WHISPER_ENCODER_UNAVAILABLE");
451
+ return 1;
452
+ }
453
+ }
428
454
  {
429
455
  const std::lock_guard<std::mutex> lock(log.mutex);
430
456
  // The public C API exposes available devices, not a context backend
431
457
  // getter. This observation is explicitly attributed to completed native
432
458
  // initialization logs; it does not claim every operation uses a GPU.
459
+ // A successful OpenVINO call establishes encoder initialization on the
460
+ // explicit NPU device, independently of decoder/backend observation.
433
461
  emit("{\"type\":\"ready\",\"model\":" + json_string(configuration.model)
434
462
  + ",\"requestedBackend\":" + json_string(configuration.backend)
435
463
  + ",\"observedBackend\":" + (log.backend.empty() ? "null" : json_string(log.backend))
436
- + ",\"backendEvidence\":" + (log.backend.empty() ? "null" : "\"runtime-log\"") + "}");
464
+ + ",\"backendEvidence\":" + (log.backend.empty() ? "null" : "\"runtime-log\"")
465
+ + ",\"requestedEncoder\":" + (configuration.encoder_device.empty() ? "null" : json_string(configuration.encoder_device))
466
+ + ",\"observedEncoder\":" + (configuration.encoder_device.empty() ? "null" : json_string(configuration.encoder_device))
467
+ + ",\"encoderEvidence\":" + (configuration.encoder_device.empty() ? "null" : "\"openvino-initialization\"") + "}");
437
468
  }
438
469
  Commands commands;
439
470
  std::thread reader(read_commands, std::ref(commands));
@@ -0,0 +1,138 @@
1
+ import path from 'node:path';
2
+ import {cp, mkdir, readdir, readFile, writeFile} from 'node:fs/promises';
3
+ import Is from 'strong-type';
4
+ import {runProcess} from '../../process.mjs';
5
+
6
+ const is = new Is(false);
7
+
8
+ /** Produce the optional Windows Intel NPU encoder runtime from installed upstream sources. */
9
+ export async function buildWhisperOpenVinoRuntime({
10
+ sourceDirectory,
11
+ openvinoDirectory,
12
+ cudaDirectory,
13
+ outputRoot,
14
+ cmake = 'cmake',
15
+ generator,
16
+ cmakeArgs = [],
17
+ env,
18
+ signal,
19
+ onEvent
20
+ } = {}) {
21
+ if (!is.string(sourceDirectory) || !is.string(openvinoDirectory) || !is.string(outputRoot)) {
22
+ throw new TypeError('Whisper sourceDirectory, OpenVINO openvinoDirectory, and outputRoot are required.');
23
+ }
24
+ if (process.platform !== 'win32' || process.arch !== 'x64') {
25
+ throw new Error('This optional OpenVINO producer currently targets Windows x64.');
26
+ }
27
+ const output = path.resolve(outputRoot);
28
+ const source = path.join(output, 'source');
29
+ const build = path.join(output, 'build');
30
+ const root = path.join(output, 'runtime');
31
+ const libraryDirectory = path.join(root, 'bin');
32
+ const toolkit = path.resolve(openvinoDirectory);
33
+ const options = {env, signal, onEvent};
34
+ signal?.throwIfAborted();
35
+ await cp(path.resolve(sourceDirectory), source, {recursive: true});
36
+ signal?.throwIfAborted();
37
+ await prepareOpenVinoSource(source);
38
+ const configure = await runProcess(cmake, [
39
+ '-S', source,
40
+ '-B', build,
41
+ ...(generator ? ['-G', generator] : []),
42
+ '-DCMAKE_BUILD_TYPE=Release',
43
+ `-DCMAKE_RUNTIME_OUTPUT_DIRECTORY=${path.join(build, 'bin')}`,
44
+ `-DCMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE=${path.join(build, 'bin')}`,
45
+ '-DBUILD_SHARED_LIBS=ON',
46
+ '-DWHISPER_OPENVINO=ON',
47
+ '-DWHISPER_BUILD_IS_DEV=OFF',
48
+ '-DWHISPER_BUILD_TESTS=OFF',
49
+ '-DWHISPER_BUILD_EXAMPLES=OFF',
50
+ '-DWHISPER_BUILD_SERVER=OFF',
51
+ '-DGGML_BACKEND_DL=ON',
52
+ '-DGGML_CPU=ON',
53
+ '-DGGML_NATIVE=OFF',
54
+ '-DGGML_CUDA=OFF',
55
+ '-DGGML_OPENVINO=OFF',
56
+ '-DGGML_OPENMP_FETCH=OFF',
57
+ '-DGGML_CCACHE=OFF',
58
+ `-DOpenVINO_DIR=${path.join(toolkit, 'runtime', 'cmake')}`,
59
+ ...cmakeArgs
60
+ ], options);
61
+ const compile = await runProcess(cmake, [
62
+ '--build', build, '--config', 'Release', '--target', 'whisper', 'ggml-cpu'
63
+ ], options);
64
+ signal?.throwIfAborted();
65
+ await mkdir(libraryDirectory, {recursive: true});
66
+ await mkdir(path.join(root, 'lib'), {recursive: true});
67
+ await mkdir(path.join(root, 'notices', 'whisper.cpp'), {recursive: true});
68
+ // Keep all selected upstream runtime DLLs together for Windows and plugin lookup.
69
+ await cp(path.join(build, 'bin'), libraryDirectory, {recursive: true});
70
+ await copyImportLibraries(build, path.join(root, 'lib'));
71
+ await cp(path.join(toolkit, 'runtime', 'bin', 'intel64', 'Release'), libraryDirectory, {recursive: true});
72
+ const tbbDirectory = path.join(toolkit, 'runtime', '3rdparty', 'tbb', 'bin');
73
+ for (const member of await readdir(tbbDirectory, {withFileTypes: true})) {
74
+ if (member.isFile() && member.name.endsWith('.dll') && !member.name.endsWith('_debug.dll')) {
75
+ await cp(path.join(tbbDirectory, member.name), path.join(libraryDirectory, member.name));
76
+ }
77
+ }
78
+ if (cudaDirectory) {
79
+ // The matching upstream plugin supplies CUDA without compiling a new backend.
80
+ for (const name of [
81
+ 'ggml-cuda.dll', 'cublas64_12.dll', 'cublasLt64_12.dll', 'cudart64_12.dll',
82
+ 'nvblas64_12.dll', 'nvrtc-builtins64_124.dll', 'nvrtc64_120_0.dll'
83
+ ]) {
84
+ await cp(path.join(cudaDirectory, name), path.join(libraryDirectory, name));
85
+ }
86
+ }
87
+ await cp(path.join(toolkit, 'docs', 'licensing'), path.join(root, 'notices', 'openvino'), {recursive: true});
88
+ await cp(path.join(source, 'LICENSE'), path.join(root, 'notices', 'whisper.cpp', 'LICENSE'));
89
+ await writeFile(path.join(root, 'notices', 'whisper.cpp', 'CHANGES.txt'),
90
+ 'whisper.cpp 1.9.4: propagate OpenVINO encoder inference failure and send both OpenVINO exception diagnostics to stderr.\n');
91
+ signal?.throwIfAborted();
92
+ return {
93
+ version: '1.9.4',
94
+ openvinoVersion: '2026.3.1',
95
+ platform: process.platform,
96
+ architecture: process.arch,
97
+ encoder: 'openvino-npu',
98
+ backend: cudaDirectory ? 'cuda' : 'cpu',
99
+ backends: cudaDirectory ? ['cuda', 'cpu'] : ['cpu'],
100
+ root,
101
+ directory: root,
102
+ libraryDirectory,
103
+ sourceDirectory: source,
104
+ diagnostics: {configure, compile}
105
+ };
106
+ }
107
+
108
+ async function prepareOpenVinoSource(source) {
109
+ const whisperPath = path.join(source, 'src', 'whisper.cpp');
110
+ const encoderPath = path.join(source, 'src', 'openvino', 'whisper-openvino-encoder.cpp');
111
+ const whisper = await readFile(whisperPath, 'utf8');
112
+ const call = ' whisper_openvino_encode(wstate.ctx_openvino, mel, wstate.embd_enc);';
113
+ if (!whisper.includes(call)) {
114
+ throw new Error('The selected Whisper source does not contain the expected OpenVINO encoder call.');
115
+ }
116
+ await writeFile(whisperPath, whisper.replace(call,
117
+ ' if (!whisper_openvino_encode(wstate.ctx_openvino, mel, wstate.embd_enc)) {\n return false;\n }'));
118
+ const encoder = await readFile(encoderPath, 'utf8');
119
+ const compileDiagnostic = 'std::cout << "in openvino encoder compile routine: exception: "';
120
+ const inferenceDiagnostic = 'std::cout << "in openvino encode inference execution routine: exception: "';
121
+ if (!encoder.includes(compileDiagnostic) || !encoder.includes(inferenceDiagnostic)) {
122
+ throw new Error('The selected Whisper source does not contain both expected OpenVINO diagnostics.');
123
+ }
124
+ await writeFile(encoderPath, encoder
125
+ .replace(compileDiagnostic, compileDiagnostic.replace('std::cout', 'std::cerr'))
126
+ .replace(inferenceDiagnostic, inferenceDiagnostic.replace('std::cout', 'std::cerr')));
127
+ }
128
+
129
+ async function copyImportLibraries(directory, destination) {
130
+ for (const member of await readdir(directory, {withFileTypes: true})) {
131
+ const source = path.join(directory, member.name);
132
+ if (member.isDirectory()) {
133
+ await copyImportLibraries(source, destination);
134
+ } else if (member.name === 'whisper.lib' || /^ggml(?:-.*)?\.lib$/.test(member.name)) {
135
+ await cp(source, path.join(destination, member.name));
136
+ }
137
+ }
138
+ }