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
@@ -1615,17 +1615,205 @@ class AI {
1615
1615
  const expectedOperation=role==='stt'?'transcribe':'synthesize';
1616
1616
  let state='unloaded';
1617
1617
  let busy=false;
1618
+ let nativeWatch=null;
1619
+ let nativeRequest=null;
1620
+
1621
+ function continuingNativeSpeechRequest(){
1622
+ const selected=nativeWatch?.snapshot?.roles?.[role];
1623
+ return role==='stt'&&busy&&nativeRequest?.requestId
1624
+ &&nativeRequest.watch===nativeWatch&&!nativeRequest.signal?.aborted
1625
+ &&nativeWatch.speech===runtime.#nativeSpeech(providerId,role)
1626
+ &&nativeWatch.modelId===runtime.#builtInSpeechModel(role)
1627
+ &&nativeWatch.snapshot.status==='ok'&&!nativeWatch.snapshot.closed
1628
+ &&selected?.modelId===nativeWatch.modelId
1629
+ &&selected.providerId===nativeWatch.engineId
1630
+ &&selected.requestId===nativeRequest.requestId
1631
+ &&selected.loaded===false
1632
+ &&((selected.state==='recovering'&&selected.busy===true)
1633
+ ||selected.state==='error');
1634
+ }
1635
+
1636
+ function nativeSpeechReady(){
1637
+ if(!nativeWatch
1638
+ ||nativeWatch.speech!==runtime.#nativeSpeech(providerId,role)
1639
+ ||nativeWatch.modelId!==runtime.#builtInSpeechModel(role)){
1640
+ return false;
1641
+ }
1642
+ const snapshot=nativeWatch.snapshot;
1643
+ const selected=snapshot?.roles?.[role];
1644
+ if(selected){
1645
+ return selected.modelId===nativeWatch.modelId
1646
+ &&selected.providerId===nativeWatch.engineId
1647
+ &&selected.loaded===true
1648
+ &&selected.available===true
1649
+ &&['ready','running'].includes(selected.state);
1650
+ }
1651
+ // Older fixed-model hosts expose independent health, not engine lifecycle.
1652
+ return snapshot?.status==='ok'
1653
+ &&nativeWatch.modelId===(role==='stt'?'whisper-small':'kokoro')
1654
+ &&snapshot[role==='stt'?'sttEngine':'ttsEngine']
1655
+ ===(role==='stt'?'whisper.cpp':'kokoro-onnx')
1656
+ &&snapshot[role==='stt'?'transcriptionAvailable':'synthesisAvailable']===true;
1657
+ }
1658
+
1659
+ function nativeSpeechReadinessError(){
1660
+ const snapshot=nativeWatch?.snapshot;
1661
+ if(nativeWatch?.error){
1662
+ return nativeWatch.error;
1663
+ }
1664
+ if(!snapshot){
1665
+ return aiProviderError(
1666
+ 'The native speech API returned no readiness status.',
1667
+ 'AI_NATIVE_LOCAL_REQUIRED'
1668
+ );
1669
+ }
1670
+ const selected=snapshot.roles?.[role];
1671
+ if(selected?.modelId&&selected.modelId!==nativeWatch.modelId){
1672
+ return aiProviderError(
1673
+ `The native ${role.toUpperCase()} model differs from the selected AI model.`,
1674
+ 'ARCANE_AI_MODEL_AUTHORITY_REQUIRED'
1675
+ );
1676
+ }
1677
+ if(selected?.providerId&&selected.providerId!==nativeWatch.engineId){
1678
+ return aiProviderError(
1679
+ `The native ${role.toUpperCase()} engine was replaced.`,
1680
+ 'ARCANE_AI_MODEL_AUTHORITY_REQUIRED'
1681
+ );
1682
+ }
1683
+ if(snapshot.closed===true
1684
+ ||['closing','closed'].includes(snapshot.status)
1685
+ ||['error','unavailable','closed','disposed'].includes(selected?.state)
1686
+ ||(!selected&&!nativeSpeechReady())){
1687
+ return aiProviderError(
1688
+ `The native ${role.toUpperCase()} model is not ready.`,
1689
+ 'ARCANE_AI_ROLE_NOT_READY',
1690
+ selected?.error
1691
+ );
1692
+ }
1693
+ return null;
1694
+ }
1695
+
1696
+ function stopNativeSpeechWatch(){
1697
+ const previous=nativeWatch;
1698
+ nativeWatch=null;
1699
+ previous?.unsubscribe?.();
1700
+ }
1701
+
1702
+ function waitForNativeSpeech(context){
1703
+ stopNativeSpeechWatch();
1704
+ const watch={
1705
+ speech:runtime.#nativeSpeech(providerId,role),
1706
+ modelId:context.selection.modelId,
1707
+ engineId:null,
1708
+ snapshot:null,
1709
+ error:null,
1710
+ revision:0,
1711
+ unsubscribe:null
1712
+ };
1713
+ nativeWatch=watch;
1714
+ return new Promise(function waitForNativeSpeechState(resolve,reject){
1715
+ let settled=false;
1716
+ function finish(error){
1717
+ if(settled){
1718
+ return;
1719
+ }
1720
+ settled=true;
1721
+ context.signal?.removeEventListener('abort',cancel);
1722
+ if(error){
1723
+ reject(error);
1724
+ }else{
1725
+ resolve();
1726
+ }
1727
+ }
1728
+ function cancel(){
1729
+ finish(normalizeAIRequestAbort(context.signal.reason));
1730
+ }
1731
+ function reportObservationFailure(error){
1732
+ finish(error);
1733
+ arcaneLogging.error('Native speech readiness observation failed.',error);
1734
+ }
1735
+ function observe(snapshot,error=null){
1736
+ if(nativeWatch!==watch){
1737
+ return;
1738
+ }
1739
+ watch.snapshot=snapshot;
1740
+ watch.error=error;
1741
+ watch.engineId??=snapshot?.roles?.[role]?.providerId??null;
1742
+ if(context.signal?.aborted&&!settled){
1743
+ cancel();
1744
+ }else if(nativeSpeechReady()){
1745
+ finish();
1746
+ nativeRequest?.refreshState?.();
1747
+ }else if(continuingNativeSpeechRequest()){
1748
+ nativeRequest.refreshState?.();
1749
+ }else{
1750
+ const failure=nativeSpeechReadinessError();
1751
+ if(failure){
1752
+ finish(failure);
1753
+ }
1754
+ if(state==='ready'){
1755
+ state='unloaded';
1756
+ runtime.#retainBuiltInSpeechReadiness(
1757
+ runtime.#reconcileBuiltInSpeechReadiness()
1758
+ );
1759
+ }
1760
+ }
1761
+ }
1762
+ context.signal?.addEventListener('abort',cancel,{once:true});
1763
+ try{
1764
+ if(context.signal?.aborted){
1765
+ cancel();
1766
+ return;
1767
+ }
1768
+ if(!is.function(watch.speech?.status)){
1769
+ throw aiProviderError(
1770
+ 'The native speech API does not expose readiness status.',
1771
+ 'AI_NATIVE_LOCAL_REQUIRED'
1772
+ );
1773
+ }
1774
+ if(is.function(globalThis.Arcane?.events?.on)){
1775
+ watch.unsubscribe=globalThis.Arcane.events.on(
1776
+ 'speech.state',
1777
+ function observeNativeSpeechState(snapshot){
1778
+ watch.revision+=1;
1779
+ try{
1780
+ observe(snapshot);
1781
+ }catch(error){
1782
+ reportObservationFailure(error);
1783
+ }
1784
+ }
1785
+ );
1786
+ }
1787
+ const revision=watch.revision;
1788
+ Promise.resolve(watch.speech.status()).then(
1789
+ function receiveNativeSpeechStatus(snapshot){
1790
+ if(watch.revision===revision){
1791
+ observe(snapshot);
1792
+ }
1793
+ },
1794
+ function rejectNativeSpeechStatus(error){
1795
+ if(watch.revision===revision){
1796
+ observe(null,error);
1797
+ }
1798
+ }
1799
+ ).catch(reportObservationFailure);
1800
+ }catch(error){
1801
+ finish(error);
1802
+ }
1803
+ });
1804
+ }
1618
1805
 
1619
1806
  function statusBuiltInSpeechProvider(){
1620
- if(state==='ready'
1621
- &&!busy
1622
- &&!runtime.#builtInSpeechCapability(role,providerId)){
1623
- state='unloaded';
1624
- }
1807
+ const requestPending=Boolean(continuingNativeSpeechRequest());
1808
+ const selected=nativeWatch?.snapshot?.roles?.[role];
1809
+ const current=state==='ready'&&!nativeSpeechReady()
1810
+ ?requestPending?selected.state:'unloaded':state;
1625
1811
  return completeValue({
1626
- state,
1627
- loaded:state==='ready',
1628
- busy
1812
+ state:current,
1813
+ loaded:current==='ready',
1814
+ busy,
1815
+ requestPending,
1816
+ ...(current==='error'?{error:selected.error}:{})
1629
1817
  });
1630
1818
  }
1631
1819
 
@@ -1646,6 +1834,11 @@ class AI {
1646
1834
 
1647
1835
  function releaseBuiltInSpeechRequest(){
1648
1836
  busy=false;
1837
+ nativeRequest=null;
1838
+ if(state==='ready'&&!nativeSpeechReady()){
1839
+ state='unloaded';
1840
+ stopNativeSpeechWatch();
1841
+ }
1649
1842
  }
1650
1843
 
1651
1844
  return completeValue({
@@ -1676,7 +1869,7 @@ class AI {
1676
1869
  return runtime.#builtInSpeechInspection(role,providerId,selection);
1677
1870
  },
1678
1871
  status:statusBuiltInSpeechProvider,
1679
- load:function loadBuiltInSpeechProvider(context={}){
1872
+ load:async function loadBuiltInSpeechProvider(context={}){
1680
1873
  if(context.signal?.aborted){
1681
1874
  throw normalizeAIRequestAbort(context.signal.reason);
1682
1875
  }
@@ -1706,22 +1899,35 @@ class AI {
1706
1899
  unit:'items',
1707
1900
  heartbeat:false
1708
1901
  });
1709
- if(context.signal?.aborted){
1902
+ try{
1903
+ await waitForNativeSpeech(context);
1904
+ if(context.signal?.aborted){
1905
+ throw normalizeAIRequestAbort(context.signal.reason);
1906
+ }
1907
+ assertBuiltInSpeechSelection(context.selection);
1908
+ if(!nativeSpeechReady()){
1909
+ throw nativeSpeechReadinessError()??aiProviderError(
1910
+ `The native ${role.toUpperCase()} model lost readiness.`,
1911
+ 'ARCANE_AI_ROLE_NOT_READY'
1912
+ );
1913
+ }
1914
+ state='ready';
1915
+ context.progress({
1916
+ phase:'capability',
1917
+ completed:1,
1918
+ total:1,
1919
+ unit:'items',
1920
+ heartbeat:false
1921
+ });
1922
+ return completeValue({
1923
+ authority:inspection.authority,
1924
+ status:statusBuiltInSpeechProvider()
1925
+ });
1926
+ }catch(error){
1710
1927
  state='unloaded';
1711
- throw normalizeAIRequestAbort(context.signal.reason);
1928
+ stopNativeSpeechWatch();
1929
+ throw error;
1712
1930
  }
1713
- state='ready';
1714
- context.progress({
1715
- phase:'capability',
1716
- completed:1,
1717
- total:1,
1718
- unit:'items',
1719
- heartbeat:false
1720
- });
1721
- return completeValue({
1722
- authority:inspection.authority,
1723
- status:statusBuiltInSpeechProvider()
1724
- });
1725
1931
  },
1726
1932
  request:function requestBuiltInSpeechProvider(context={}){
1727
1933
  if(context.signal?.aborted){
@@ -1748,10 +1954,18 @@ class AI {
1748
1954
  );
1749
1955
  }
1750
1956
  busy=true;
1957
+ const operation=role==='stt'?{
1958
+ requestId:null,watch:nativeWatch,signal:context.signal,
1959
+ refreshState:context.refreshState
1960
+ }:null;
1961
+ nativeRequest=operation;
1751
1962
  const request=role==='stt'
1752
1963
  ?runtime.#requestBuiltInSpeechTranscription(
1753
1964
  context.payload,
1754
- context.signal
1965
+ context.signal,
1966
+ function ownNativeSpeechRequest({requestId}){
1967
+ if(nativeRequest===operation)operation.requestId=requestId;
1968
+ }
1755
1969
  )
1756
1970
  :runtime.#requestBuiltInSpeechSynthesis(
1757
1971
  context.payload,
@@ -1770,6 +1984,7 @@ class AI {
1770
1984
  );
1771
1985
  }
1772
1986
  state='unloaded';
1987
+ stopNativeSpeechWatch();
1773
1988
  return statusBuiltInSpeechProvider();
1774
1989
  },
1775
1990
  dispose:function disposeBuiltInSpeechProvider(context={}){
@@ -1783,6 +1998,7 @@ class AI {
1783
1998
  );
1784
1999
  }
1785
2000
  state='disposed';
2001
+ stopNativeSpeechWatch();
1786
2002
  return statusBuiltInSpeechProvider();
1787
2003
  }
1788
2004
  });
@@ -1837,6 +2053,7 @@ class AI {
1837
2053
  continue;
1838
2054
  }
1839
2055
  if(record.unregister()){
2056
+ record.provider.dispose();
1840
2057
  this.#builtInSpeechProviders.delete(key);
1841
2058
  }
1842
2059
  }
@@ -1916,7 +2133,13 @@ class AI {
1916
2133
  return runtime.#providerRuntime.status(role);
1917
2134
  }
1918
2135
  const status=runtime.#providerRuntime.status(role);
1919
- if(runtime.#builtInSpeechCapability(role,selection.providerId)){
2136
+ const record=runtime.#builtInSpeechProviders.get(
2137
+ runtime.#builtInSpeechProviderKey(role,selection.providerId)
2138
+ );
2139
+ const providerStatus=record?.provider.status();
2140
+ if(runtime.#builtInSpeechCapability(role,selection.providerId)
2141
+ &&(!(status.loaded===true||status.busy===true)
2142
+ ||providerStatus?.loaded===true||providerStatus?.requestPending===true)){
1920
2143
  return status;
1921
2144
  }
1922
2145
  if(status.loaded===true
@@ -4068,7 +4291,7 @@ class AI {
4068
4291
  return null;
4069
4292
  }
4070
4293
 
4071
- async #requestBuiltInSpeechTranscription(payload={},signal=null){
4294
+ async #requestBuiltInSpeechTranscription(payload={},signal=null,onRequest=undefined){
4072
4295
  const audio=payload?.audio;
4073
4296
  if(!audio||!is.function(audio.arrayBuffer)){
4074
4297
  throw new TypeError('Speech transcription requires an audio Blob or File.');
@@ -4090,7 +4313,7 @@ class AI {
4090
4313
  audioBase64:this.#arrayBufferToBase64(audioBytes),
4091
4314
  mimeType,
4092
4315
  model
4093
- },{signal});
4316
+ },{signal,onRequest});
4094
4317
  }catch(error){
4095
4318
  if(isAIRequestAbort(error,signal)){
4096
4319
  throw normalizeAIRequestAbort(error);
@@ -4037,6 +4037,15 @@ export class AIProviderRuntime {
4037
4037
  payload: options.payload,
4038
4038
  signal: controller.signal
4039
4039
  };
4040
+ if (role === 'stt') {
4041
+ providerRequest.refreshState = function refreshActiveProviderState() {
4042
+ if (slot.generation === generation && !slot.unloadPromise
4043
+ && !slot.disposePromise && !controller.signal.aborted
4044
+ && slot.activeRequests.get(requestSequence) === requestRecord) {
4045
+ runtime.#publishRoleRequestState(slot);
4046
+ }
4047
+ };
4048
+ }
4040
4049
  if (SPEECH_ROLES.includes(role)) {
4041
4050
  arcaneLogging.debug('[Arcane speech runtime] provider.request', {generation, requestSequence, operationId}, providerRequest);
4042
4051
  }
@@ -4579,16 +4588,24 @@ export class AIProviderRuntime {
4579
4588
  if (!requestRecord && slot.requestQueue.length === 0) {
4580
4589
  return false;
4581
4590
  }
4591
+ const provider = this.#providerFor(slot);
4592
+ const providerState = slot.role === 'stt' && provider
4593
+ ? validateProviderStatus(provider.status()) : null;
4594
+ const pendingRecovery = requestRecord && providerState?.loaded === false
4595
+ && ['recovering', 'error'].includes(providerState.state);
4582
4596
  publishAIRuntimeRoleState(
4583
4597
  slot.role,
4584
4598
  roleRecord(
4585
4599
  slot.role,
4586
4600
  slot.selection,
4587
4601
  {
4588
- state: 'ready',
4589
- loaded: true,
4602
+ state: pendingRecovery ? providerState.state : 'ready',
4603
+ loaded: !pendingRecovery,
4590
4604
  busy: true,
4591
- operationId: requestRecord?.operationId ?? null
4605
+ operationId: requestRecord?.operationId ?? null,
4606
+ ...(pendingRecovery && providerState.state === 'error'
4607
+ ? {error: stateError(providerState.error, 'ARCANE_AI_PROVIDER_REQUEST_FAILED')}
4608
+ : {})
4592
4609
  }
4593
4610
  )
4594
4611
  );
@@ -21,12 +21,22 @@ export const AI_RUNTIME_STATES = completeValue([
21
21
  'unavailable',
22
22
  'unloaded',
23
23
  'loading',
24
+ 'recovering',
24
25
  'ready',
25
26
  'unloading',
26
27
  'error',
27
28
  'disposed'
28
29
  ]);
29
30
 
31
+ /** Keep only an already-owned operation through its truthful recovery/error state. */
32
+ export function continuesAIRuntimeOperation(previous, current, operationId) {
33
+ return is.string(operationId) && operationId.length > 0
34
+ && previous?.operationId === operationId && current?.operationId === operationId
35
+ && previous.providerId === current.providerId && previous.modelId === current.modelId
36
+ && current.loaded === false && current.busy === true
37
+ && ['recovering', 'error'].includes(current.state);
38
+ }
39
+
30
40
  const ROLE_KEYS = completeValue([
31
41
  'role',
32
42
  'state',
@@ -77,6 +87,7 @@ const MUST_BE_UNLOADED = new Set([
77
87
  'unavailable',
78
88
  'unloaded',
79
89
  'loading',
90
+ 'recovering',
80
91
  'disposed'
81
92
  ]);
82
93
  const STARTUP_TERMINAL_STATES = new Set([
@@ -291,8 +302,10 @@ function copyRoleRecord(role, record) {
291
302
  && (record.providerId === null || record.modelId === null)) {
292
303
  fail('a loaded role must identify its provider and model.');
293
304
  }
294
- if (record.busy && (record.state !== 'ready' || !record.loaded)) {
295
- fail('a busy role must be ready and loaded.');
305
+ const pendingRecovery = ['recovering', 'error'].includes(record.state)
306
+ && record.operationId !== null;
307
+ if (record.busy && !pendingRecovery && (record.state !== 'ready' || !record.loaded)) {
308
+ fail('a busy role must be ready and loaded or retain its recovering/error operation.');
296
309
  }
297
310
  if (record.state === 'error' && error === null) {
298
311
  fail('an error role state must include error details.');
@@ -607,6 +607,9 @@ function createSTTActivationController({
607
607
  if(role?.state==='loading'){
608
608
  return `Transcription ${formatAIRuntimeProgress(role.progress,'loading')}; Cancel is available.`;
609
609
  }
610
+ if(role?.state==='recovering'){
611
+ return 'Transcription is recovering the current request.';
612
+ }
610
613
  if(role?.state==='unloading'){
611
614
  return `Transcription ${formatAIRuntimeProgress(role.progress,'releasing')}.`;
612
615
  }
@@ -262,7 +262,7 @@
262
262
  "type": "array",
263
263
  "items": {
264
264
  "oneOf": [
265
- { "enum": ["llama.cpp", "ollama", "onnx", "stable-diffusion.cpp"] },
265
+ { "enum": ["llama.cpp", "ollama", "onnx", "stable-diffusion.cpp", "whisper.cpp"] },
266
266
  {
267
267
  "type": "object",
268
268
  "required": ["id"],
@@ -271,7 +271,8 @@
271
271
  "version": { "type": "string" },
272
272
  "url": { "type": "string" }
273
273
  }
274
- }
274
+ },
275
+ { "$ref": "#/$defs/whisperRuntime" }
275
276
  ]
276
277
  }
277
278
  },
@@ -294,6 +295,56 @@
294
295
  }
295
296
  }
296
297
  },
298
+ "whisperRuntime": {
299
+ "type": "object",
300
+ "required": ["id"],
301
+ "description": "Native Whisper transcription. Models are explicitly selected; describing a runtime acquires nothing. Automatic Windows x64 preparation selects intact CUDA and CPU distributions, an SDK helper, and FFmpeg. Other hosts supply their selected complete runtime and decoder trees.",
302
+ "properties": {
303
+ "id": { "const": "whisper.cpp" },
304
+ "version": { "type": "string", "default": "1.9.4" },
305
+ "backend": { "enum": ["auto", "cuda", "metal", "cpu"], "default": "auto" },
306
+ "helperRoot": { "type": "string", "description": "Complete helper directory copied into the native runtime, including its app-local libraries. Defaults to the directory containing helperExecutable." },
307
+ "helperExecutable": { "type": "string" },
308
+ "helperUrl": { "type": "string" },
309
+ "helperVersion": { "type": "string" },
310
+ "decoderRoot": { "type": "string" },
311
+ "decoderExecutable": { "type": "string" },
312
+ "decoderUrl": { "type": "string" },
313
+ "modelId": { "type": "string" },
314
+ "variants": {
315
+ "type": "array",
316
+ "items": {
317
+ "type": "object",
318
+ "required": ["backend"],
319
+ "properties": {
320
+ "backend": { "enum": ["cuda", "metal", "cpu"] },
321
+ "root": { "type": "string" },
322
+ "url": { "type": "string" },
323
+ "libraryDirectory": { "type": "string" }
324
+ }
325
+ }
326
+ },
327
+ "models": {
328
+ "type": "array",
329
+ "default": [],
330
+ "items": {
331
+ "oneOf": [
332
+ { "const": "whisper-small" },
333
+ {
334
+ "type": "object",
335
+ "required": ["id"],
336
+ "properties": {
337
+ "id": { "type": "string" },
338
+ "path": { "type": "string" },
339
+ "url": { "type": "string" },
340
+ "filename": { "type": "string" }
341
+ }
342
+ }
343
+ ]
344
+ }
345
+ }
346
+ }
347
+ },
297
348
  "pwa": {
298
349
  "type": "object",
299
350
  "additionalProperties": false,
@@ -156,7 +156,7 @@
156
156
  "type": "array",
157
157
  "items": {
158
158
  "oneOf": [
159
- { "enum": ["llama.cpp", "ollama", "onnx", "stable-diffusion.cpp"] },
159
+ { "enum": ["llama.cpp", "ollama", "onnx", "stable-diffusion.cpp", "whisper.cpp"] },
160
160
  {
161
161
  "type": "object",
162
162
  "required": ["id"],
@@ -165,7 +165,8 @@
165
165
  "version": { "type": "string" },
166
166
  "url": { "type": "string" }
167
167
  }
168
- }
168
+ },
169
+ { "$ref": "#/$defs/whisperRuntime" }
169
170
  ]
170
171
  }
171
172
  },
@@ -188,6 +189,56 @@
188
189
  }
189
190
  }
190
191
  },
192
+ "whisperRuntime": {
193
+ "type": "object",
194
+ "required": ["id"],
195
+ "description": "Native Whisper transcription. Models are explicitly selected; describing a runtime acquires nothing. Automatic Windows x64 preparation selects intact CUDA and CPU distributions, an SDK helper, and FFmpeg. Other hosts supply their selected complete runtime and decoder trees.",
196
+ "properties": {
197
+ "id": { "const": "whisper.cpp" },
198
+ "version": { "type": "string", "default": "1.9.4" },
199
+ "backend": { "enum": ["auto", "cuda", "metal", "cpu"], "default": "auto" },
200
+ "helperRoot": { "type": "string", "description": "Complete helper directory copied into the native runtime, including its app-local libraries. Defaults to the directory containing helperExecutable." },
201
+ "helperExecutable": { "type": "string" },
202
+ "helperUrl": { "type": "string" },
203
+ "helperVersion": { "type": "string" },
204
+ "decoderRoot": { "type": "string" },
205
+ "decoderExecutable": { "type": "string" },
206
+ "decoderUrl": { "type": "string" },
207
+ "modelId": { "type": "string" },
208
+ "variants": {
209
+ "type": "array",
210
+ "items": {
211
+ "type": "object",
212
+ "required": ["backend"],
213
+ "properties": {
214
+ "backend": { "enum": ["cuda", "metal", "cpu"] },
215
+ "root": { "type": "string" },
216
+ "url": { "type": "string" },
217
+ "libraryDirectory": { "type": "string" }
218
+ }
219
+ }
220
+ },
221
+ "models": {
222
+ "type": "array",
223
+ "default": [],
224
+ "items": {
225
+ "oneOf": [
226
+ { "const": "whisper-small" },
227
+ {
228
+ "type": "object",
229
+ "required": ["id"],
230
+ "properties": {
231
+ "id": { "type": "string" },
232
+ "path": { "type": "string" },
233
+ "url": { "type": "string" },
234
+ "filename": { "type": "string" }
235
+ }
236
+ }
237
+ ]
238
+ }
239
+ }
240
+ }
241
+ },
191
242
  "pwa": {
192
243
  "type": "object",
193
244
  "additionalProperties": false,