@runanywhere/web 0.1.0-beta.1

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 (205) hide show
  1. package/README.md +795 -0
  2. package/dist/Foundation/ErrorTypes.d.ts +61 -0
  3. package/dist/Foundation/ErrorTypes.d.ts.map +1 -0
  4. package/dist/Foundation/ErrorTypes.js +90 -0
  5. package/dist/Foundation/ErrorTypes.js.map +1 -0
  6. package/dist/Foundation/EventBus.d.ts +171 -0
  7. package/dist/Foundation/EventBus.d.ts.map +1 -0
  8. package/dist/Foundation/EventBus.js +113 -0
  9. package/dist/Foundation/EventBus.js.map +1 -0
  10. package/dist/Foundation/PlatformAdapter.d.ts +101 -0
  11. package/dist/Foundation/PlatformAdapter.d.ts.map +1 -0
  12. package/dist/Foundation/PlatformAdapter.js +417 -0
  13. package/dist/Foundation/PlatformAdapter.js.map +1 -0
  14. package/dist/Foundation/SDKLogger.d.ts +33 -0
  15. package/dist/Foundation/SDKLogger.d.ts.map +1 -0
  16. package/dist/Foundation/SDKLogger.js +82 -0
  17. package/dist/Foundation/SDKLogger.js.map +1 -0
  18. package/dist/Foundation/SherpaONNXBridge.d.ts +134 -0
  19. package/dist/Foundation/SherpaONNXBridge.d.ts.map +1 -0
  20. package/dist/Foundation/SherpaONNXBridge.js +332 -0
  21. package/dist/Foundation/SherpaONNXBridge.js.map +1 -0
  22. package/dist/Foundation/StructOffsets.d.ts +147 -0
  23. package/dist/Foundation/StructOffsets.d.ts.map +1 -0
  24. package/dist/Foundation/StructOffsets.js +161 -0
  25. package/dist/Foundation/StructOffsets.js.map +1 -0
  26. package/dist/Foundation/WASMBridge.d.ts +241 -0
  27. package/dist/Foundation/WASMBridge.d.ts.map +1 -0
  28. package/dist/Foundation/WASMBridge.js +393 -0
  29. package/dist/Foundation/WASMBridge.js.map +1 -0
  30. package/dist/Infrastructure/ArchiveUtility.d.ts +25 -0
  31. package/dist/Infrastructure/ArchiveUtility.d.ts.map +1 -0
  32. package/dist/Infrastructure/ArchiveUtility.js +139 -0
  33. package/dist/Infrastructure/ArchiveUtility.js.map +1 -0
  34. package/dist/Infrastructure/AudioCapture.d.ts +87 -0
  35. package/dist/Infrastructure/AudioCapture.d.ts.map +1 -0
  36. package/dist/Infrastructure/AudioCapture.js +231 -0
  37. package/dist/Infrastructure/AudioCapture.js.map +1 -0
  38. package/dist/Infrastructure/AudioPlayback.d.ts +53 -0
  39. package/dist/Infrastructure/AudioPlayback.d.ts.map +1 -0
  40. package/dist/Infrastructure/AudioPlayback.js +117 -0
  41. package/dist/Infrastructure/AudioPlayback.js.map +1 -0
  42. package/dist/Infrastructure/DeviceCapabilities.d.ts +39 -0
  43. package/dist/Infrastructure/DeviceCapabilities.d.ts.map +1 -0
  44. package/dist/Infrastructure/DeviceCapabilities.js +111 -0
  45. package/dist/Infrastructure/DeviceCapabilities.js.map +1 -0
  46. package/dist/Infrastructure/ExtensionRegistry.d.ts +30 -0
  47. package/dist/Infrastructure/ExtensionRegistry.d.ts.map +1 -0
  48. package/dist/Infrastructure/ExtensionRegistry.js +41 -0
  49. package/dist/Infrastructure/ExtensionRegistry.js.map +1 -0
  50. package/dist/Infrastructure/ModelDownloader.d.ts +98 -0
  51. package/dist/Infrastructure/ModelDownloader.d.ts.map +1 -0
  52. package/dist/Infrastructure/ModelDownloader.js +431 -0
  53. package/dist/Infrastructure/ModelDownloader.js.map +1 -0
  54. package/dist/Infrastructure/ModelLoaderTypes.d.ts +34 -0
  55. package/dist/Infrastructure/ModelLoaderTypes.d.ts.map +1 -0
  56. package/dist/Infrastructure/ModelLoaderTypes.js +12 -0
  57. package/dist/Infrastructure/ModelLoaderTypes.js.map +1 -0
  58. package/dist/Infrastructure/ModelManager.d.ts +219 -0
  59. package/dist/Infrastructure/ModelManager.d.ts.map +1 -0
  60. package/dist/Infrastructure/ModelManager.js +885 -0
  61. package/dist/Infrastructure/ModelManager.js.map +1 -0
  62. package/dist/Infrastructure/ModelRegistry.d.ts +131 -0
  63. package/dist/Infrastructure/ModelRegistry.d.ts.map +1 -0
  64. package/dist/Infrastructure/ModelRegistry.js +122 -0
  65. package/dist/Infrastructure/ModelRegistry.js.map +1 -0
  66. package/dist/Infrastructure/OPFSStorage.d.ts +143 -0
  67. package/dist/Infrastructure/OPFSStorage.d.ts.map +1 -0
  68. package/dist/Infrastructure/OPFSStorage.js +330 -0
  69. package/dist/Infrastructure/OPFSStorage.js.map +1 -0
  70. package/dist/Infrastructure/VLMWorkerBridge.d.ts +211 -0
  71. package/dist/Infrastructure/VLMWorkerBridge.d.ts.map +1 -0
  72. package/dist/Infrastructure/VLMWorkerBridge.js +264 -0
  73. package/dist/Infrastructure/VLMWorkerBridge.js.map +1 -0
  74. package/dist/Infrastructure/VLMWorkerRuntime.d.ts +38 -0
  75. package/dist/Infrastructure/VLMWorkerRuntime.d.ts.map +1 -0
  76. package/dist/Infrastructure/VLMWorkerRuntime.js +503 -0
  77. package/dist/Infrastructure/VLMWorkerRuntime.js.map +1 -0
  78. package/dist/Infrastructure/VideoCapture.d.ts +118 -0
  79. package/dist/Infrastructure/VideoCapture.d.ts.map +1 -0
  80. package/dist/Infrastructure/VideoCapture.js +207 -0
  81. package/dist/Infrastructure/VideoCapture.js.map +1 -0
  82. package/dist/Public/Extensions/DiffusionTypes.d.ts +64 -0
  83. package/dist/Public/Extensions/DiffusionTypes.d.ts.map +1 -0
  84. package/dist/Public/Extensions/DiffusionTypes.js +28 -0
  85. package/dist/Public/Extensions/DiffusionTypes.js.map +1 -0
  86. package/dist/Public/Extensions/EmbeddingsTypes.d.ts +33 -0
  87. package/dist/Public/Extensions/EmbeddingsTypes.d.ts.map +1 -0
  88. package/dist/Public/Extensions/EmbeddingsTypes.js +13 -0
  89. package/dist/Public/Extensions/EmbeddingsTypes.js.map +1 -0
  90. package/dist/Public/Extensions/RunAnywhere+Diffusion.d.ts +44 -0
  91. package/dist/Public/Extensions/RunAnywhere+Diffusion.d.ts.map +1 -0
  92. package/dist/Public/Extensions/RunAnywhere+Diffusion.js +189 -0
  93. package/dist/Public/Extensions/RunAnywhere+Diffusion.js.map +1 -0
  94. package/dist/Public/Extensions/RunAnywhere+Embeddings.d.ts +56 -0
  95. package/dist/Public/Extensions/RunAnywhere+Embeddings.d.ts.map +1 -0
  96. package/dist/Public/Extensions/RunAnywhere+Embeddings.js +240 -0
  97. package/dist/Public/Extensions/RunAnywhere+Embeddings.js.map +1 -0
  98. package/dist/Public/Extensions/RunAnywhere+ModelManagement.d.ts +53 -0
  99. package/dist/Public/Extensions/RunAnywhere+ModelManagement.d.ts.map +1 -0
  100. package/dist/Public/Extensions/RunAnywhere+ModelManagement.js +153 -0
  101. package/dist/Public/Extensions/RunAnywhere+ModelManagement.js.map +1 -0
  102. package/dist/Public/Extensions/RunAnywhere+STT.d.ts +95 -0
  103. package/dist/Public/Extensions/RunAnywhere+STT.d.ts.map +1 -0
  104. package/dist/Public/Extensions/RunAnywhere+STT.js +417 -0
  105. package/dist/Public/Extensions/RunAnywhere+STT.js.map +1 -0
  106. package/dist/Public/Extensions/RunAnywhere+StructuredOutput.d.ts +69 -0
  107. package/dist/Public/Extensions/RunAnywhere+StructuredOutput.d.ts.map +1 -0
  108. package/dist/Public/Extensions/RunAnywhere+StructuredOutput.js +196 -0
  109. package/dist/Public/Extensions/RunAnywhere+StructuredOutput.js.map +1 -0
  110. package/dist/Public/Extensions/RunAnywhere+TTS.d.ts +55 -0
  111. package/dist/Public/Extensions/RunAnywhere+TTS.d.ts.map +1 -0
  112. package/dist/Public/Extensions/RunAnywhere+TTS.js +253 -0
  113. package/dist/Public/Extensions/RunAnywhere+TTS.js.map +1 -0
  114. package/dist/Public/Extensions/RunAnywhere+TextGeneration.d.ts +80 -0
  115. package/dist/Public/Extensions/RunAnywhere+TextGeneration.d.ts.map +1 -0
  116. package/dist/Public/Extensions/RunAnywhere+TextGeneration.js +436 -0
  117. package/dist/Public/Extensions/RunAnywhere+TextGeneration.js.map +1 -0
  118. package/dist/Public/Extensions/RunAnywhere+ToolCalling.d.ts +82 -0
  119. package/dist/Public/Extensions/RunAnywhere+ToolCalling.d.ts.map +1 -0
  120. package/dist/Public/Extensions/RunAnywhere+ToolCalling.js +576 -0
  121. package/dist/Public/Extensions/RunAnywhere+ToolCalling.js.map +1 -0
  122. package/dist/Public/Extensions/RunAnywhere+VAD.d.ts +70 -0
  123. package/dist/Public/Extensions/RunAnywhere+VAD.d.ts.map +1 -0
  124. package/dist/Public/Extensions/RunAnywhere+VAD.js +231 -0
  125. package/dist/Public/Extensions/RunAnywhere+VAD.js.map +1 -0
  126. package/dist/Public/Extensions/RunAnywhere+VLM.d.ts +58 -0
  127. package/dist/Public/Extensions/RunAnywhere+VLM.d.ts.map +1 -0
  128. package/dist/Public/Extensions/RunAnywhere+VLM.js +262 -0
  129. package/dist/Public/Extensions/RunAnywhere+VLM.js.map +1 -0
  130. package/dist/Public/Extensions/RunAnywhere+VoiceAgent.d.ts +49 -0
  131. package/dist/Public/Extensions/RunAnywhere+VoiceAgent.d.ts.map +1 -0
  132. package/dist/Public/Extensions/RunAnywhere+VoiceAgent.js +222 -0
  133. package/dist/Public/Extensions/RunAnywhere+VoiceAgent.js.map +1 -0
  134. package/dist/Public/Extensions/RunAnywhere+VoicePipeline.d.ts +63 -0
  135. package/dist/Public/Extensions/RunAnywhere+VoicePipeline.d.ts.map +1 -0
  136. package/dist/Public/Extensions/RunAnywhere+VoicePipeline.js +168 -0
  137. package/dist/Public/Extensions/RunAnywhere+VoicePipeline.js.map +1 -0
  138. package/dist/Public/Extensions/STTTypes.d.ts +53 -0
  139. package/dist/Public/Extensions/STTTypes.d.ts.map +1 -0
  140. package/dist/Public/Extensions/STTTypes.js +16 -0
  141. package/dist/Public/Extensions/STTTypes.js.map +1 -0
  142. package/dist/Public/Extensions/TTSTypes.d.ts +31 -0
  143. package/dist/Public/Extensions/TTSTypes.d.ts.map +1 -0
  144. package/dist/Public/Extensions/TTSTypes.js +3 -0
  145. package/dist/Public/Extensions/TTSTypes.js.map +1 -0
  146. package/dist/Public/Extensions/ToolCallingTypes.d.ts +78 -0
  147. package/dist/Public/Extensions/ToolCallingTypes.d.ts.map +1 -0
  148. package/dist/Public/Extensions/ToolCallingTypes.js +8 -0
  149. package/dist/Public/Extensions/ToolCallingTypes.js.map +1 -0
  150. package/dist/Public/Extensions/VADTypes.d.ts +30 -0
  151. package/dist/Public/Extensions/VADTypes.d.ts.map +1 -0
  152. package/dist/Public/Extensions/VADTypes.js +8 -0
  153. package/dist/Public/Extensions/VADTypes.js.map +1 -0
  154. package/dist/Public/Extensions/VLMTypes.d.ts +56 -0
  155. package/dist/Public/Extensions/VLMTypes.d.ts.map +1 -0
  156. package/dist/Public/Extensions/VLMTypes.js +24 -0
  157. package/dist/Public/Extensions/VLMTypes.js.map +1 -0
  158. package/dist/Public/Extensions/VoiceAgentTypes.d.ts +42 -0
  159. package/dist/Public/Extensions/VoiceAgentTypes.d.ts.map +1 -0
  160. package/dist/Public/Extensions/VoiceAgentTypes.js +12 -0
  161. package/dist/Public/Extensions/VoiceAgentTypes.js.map +1 -0
  162. package/dist/Public/Extensions/VoicePipelineTypes.d.ts +69 -0
  163. package/dist/Public/Extensions/VoicePipelineTypes.d.ts.map +1 -0
  164. package/dist/Public/Extensions/VoicePipelineTypes.js +9 -0
  165. package/dist/Public/Extensions/VoicePipelineTypes.js.map +1 -0
  166. package/dist/Public/RunAnywhere.d.ts +121 -0
  167. package/dist/Public/RunAnywhere.d.ts.map +1 -0
  168. package/dist/Public/RunAnywhere.js +332 -0
  169. package/dist/Public/RunAnywhere.js.map +1 -0
  170. package/dist/index.d.ts +67 -0
  171. package/dist/index.d.ts.map +1 -0
  172. package/dist/index.js +53 -0
  173. package/dist/index.js.map +1 -0
  174. package/dist/types/LLMTypes.d.ts +48 -0
  175. package/dist/types/LLMTypes.d.ts.map +1 -0
  176. package/dist/types/LLMTypes.js +8 -0
  177. package/dist/types/LLMTypes.js.map +1 -0
  178. package/dist/types/enums.d.ts +144 -0
  179. package/dist/types/enums.d.ts.map +1 -0
  180. package/dist/types/enums.js +159 -0
  181. package/dist/types/enums.js.map +1 -0
  182. package/dist/types/index.d.ts +9 -0
  183. package/dist/types/index.d.ts.map +1 -0
  184. package/dist/types/index.js +8 -0
  185. package/dist/types/index.js.map +1 -0
  186. package/dist/types/models.d.ts +154 -0
  187. package/dist/types/models.d.ts.map +1 -0
  188. package/dist/types/models.js +8 -0
  189. package/dist/types/models.js.map +1 -0
  190. package/dist/workers/vlm-worker.d.ts +9 -0
  191. package/dist/workers/vlm-worker.d.ts.map +1 -0
  192. package/dist/workers/vlm-worker.js +10 -0
  193. package/dist/workers/vlm-worker.js.map +1 -0
  194. package/package.json +62 -0
  195. package/wasm/racommons-webgpu.js +156 -0
  196. package/wasm/racommons-webgpu.wasm +0 -0
  197. package/wasm/racommons.js +126 -0
  198. package/wasm/racommons.wasm +0 -0
  199. package/wasm/sherpa/sherpa-onnx-asr.js +1538 -0
  200. package/wasm/sherpa/sherpa-onnx-glue-original.js +19 -0
  201. package/wasm/sherpa/sherpa-onnx-glue.js +17 -0
  202. package/wasm/sherpa/sherpa-onnx-tts.js +657 -0
  203. package/wasm/sherpa/sherpa-onnx-vad.js +337 -0
  204. package/wasm/sherpa/sherpa-onnx-wave.js +88 -0
  205. package/wasm/sherpa/sherpa-onnx.wasm +0 -0
@@ -0,0 +1,885 @@
1
+ /**
2
+ * Model Manager - Thin orchestrator for model lifecycle
3
+ *
4
+ * Composes ModelRegistry (catalog) + ModelDownloader (downloads) and adds
5
+ * model-loading orchestration (STT / TTS / LLM / VLM routing).
6
+ *
7
+ * The public API is unchanged — `ModelManager` is still a singleton that
8
+ * exposes `registerModels()`, `downloadModel()`, `loadModel()`, `onChange()`, etc.
9
+ * Internally it delegates catalog operations to the Registry and download
10
+ * operations to the Downloader.
11
+ */
12
+ import { WASMBridge } from '../Foundation/WASMBridge';
13
+ import { SherpaONNXBridge } from '../Foundation/SherpaONNXBridge';
14
+ import { EventBus } from '../Foundation/EventBus';
15
+ import { SDKLogger } from '../Foundation/SDKLogger';
16
+ import { STTModelType } from '../Public/Extensions/STTTypes';
17
+ import { ModelCategory, LLMFramework, ModelStatus, DownloadStage, SDKEventType } from '../types/enums';
18
+ import { OPFSStorage } from './OPFSStorage';
19
+ import { ModelRegistry } from './ModelRegistry';
20
+ import { ModelDownloader } from './ModelDownloader';
21
+ import { extractTarGz } from './ArchiveUtility';
22
+ // Re-export types so existing imports from './Infrastructure/ModelManager' still work
23
+ export { ModelCategory, LLMFramework, ModelStatus, DownloadStage };
24
+ // ---------------------------------------------------------------------------
25
+ // Model Manager Singleton
26
+ // ---------------------------------------------------------------------------
27
+ const logger = new SDKLogger('ModelManager');
28
+ class ModelManagerImpl {
29
+ registry = new ModelRegistry();
30
+ storage = new OPFSStorage();
31
+ downloader;
32
+ /**
33
+ * Tracks loaded models per category — allows STT + LLM + TTS simultaneously
34
+ * for the voice pipeline. Key = ModelCategory, Value = model id.
35
+ */
36
+ loadedByCategory = new Map();
37
+ /** LRU metadata: lastUsedAt timestamps persisted in OPFS */
38
+ metadata = {};
39
+ /** Pluggable VLM loader (set by the app via setVLMLoader) */
40
+ vlmLoader = null;
41
+ /** Pluggable model loaders — registered by the Public layer during init */
42
+ llmLoader = null;
43
+ sttLoader = null;
44
+ ttsLoader = null;
45
+ vadLoader = null;
46
+ constructor() {
47
+ this.downloader = new ModelDownloader(this.registry, this.storage);
48
+ // Initialize OPFS storage (non-blocking)
49
+ this.initStorage();
50
+ // Request persistent storage so browser won't evict our cached models
51
+ this.requestPersistentStorage();
52
+ }
53
+ async initStorage() {
54
+ await this.storage.initialize();
55
+ }
56
+ // --- Registration API (called by the app) ---
57
+ /**
58
+ * Register a catalog of models. Resolves compact definitions into full
59
+ * ManagedModel entries and checks OPFS for previously downloaded files.
60
+ */
61
+ registerModels(models) {
62
+ this.registry.registerModels(models);
63
+ // Check OPFS for previously downloaded models (async, updates status when done)
64
+ this.refreshDownloadStatus();
65
+ }
66
+ /**
67
+ * Set the VLM loader implementation. Called by the app to plug in
68
+ * worker-based VLM loading (the SDK doesn't create Web Workers directly).
69
+ */
70
+ setVLMLoader(loader) {
71
+ this.vlmLoader = loader;
72
+ }
73
+ /** Register the LLM model loader (text generation extension). */
74
+ setLLMLoader(loader) { this.llmLoader = loader; }
75
+ /** Register the STT model loader (speech-to-text extension). */
76
+ setSTTLoader(loader) { this.sttLoader = loader; }
77
+ /** Register the TTS model loader (text-to-speech extension). */
78
+ setTTSLoader(loader) { this.ttsLoader = loader; }
79
+ /** Register the VAD model loader (voice activity detection extension). */
80
+ setVADLoader(loader) { this.vadLoader = loader; }
81
+ // --- Internal init ---
82
+ /** Request persistent storage to prevent browser from evicting cached models */
83
+ async requestPersistentStorage() {
84
+ try {
85
+ if (navigator.storage?.persist) {
86
+ const persisted = await navigator.storage.persist();
87
+ logger.info(`Persistent storage: ${persisted ? 'granted' : 'denied'}`);
88
+ }
89
+ }
90
+ catch {
91
+ // Not supported or denied — non-critical
92
+ }
93
+ }
94
+ /**
95
+ * Check OPFS for models that were downloaded in a previous session.
96
+ * Updates their status from 'registered' to 'downloaded'.
97
+ * Also loads persisted LRU metadata for each model.
98
+ * Only checks file existence + size — does NOT read file contents into memory.
99
+ */
100
+ async refreshDownloadStatus() {
101
+ // Load persisted metadata (lastUsedAt timestamps)
102
+ this.metadata = await this.storage.loadMetadata();
103
+ for (const model of this.registry.getModels()) {
104
+ if (model.status !== ModelStatus.Registered)
105
+ continue;
106
+ try {
107
+ const size = await this.downloader.getOPFSFileSize(model.id);
108
+ if (size !== null && size > 0) {
109
+ this.registry.updateModel(model.id, { status: ModelStatus.Downloaded, sizeBytes: size });
110
+ // Ensure metadata entry exists — use persisted value or fall back to OPFS lastModified
111
+ if (!this.metadata[model.id]) {
112
+ const stored = await this.storage.listModels();
113
+ const entry = stored.find((s) => s.id === model.id);
114
+ this.metadata[model.id] = {
115
+ lastUsedAt: entry?.lastModified ?? Date.now(),
116
+ sizeBytes: size,
117
+ };
118
+ }
119
+ }
120
+ }
121
+ catch {
122
+ // Not in OPFS, keep as registered
123
+ }
124
+ }
125
+ // Persist any newly created metadata entries
126
+ await this.storage.saveMetadata(this.metadata);
127
+ }
128
+ // --- Queries (delegated to registry) ---
129
+ getModels() {
130
+ return this.registry.getModels();
131
+ }
132
+ getModelsByCategory(category) {
133
+ return this.registry.getModelsByCategory(category);
134
+ }
135
+ getModelsByFramework(framework) {
136
+ return this.registry.getModelsByFramework(framework);
137
+ }
138
+ getLLMModels() {
139
+ return this.registry.getLLMModels();
140
+ }
141
+ getVLMModels() {
142
+ return this.registry.getVLMModels();
143
+ }
144
+ getSTTModels() {
145
+ return this.registry.getSTTModels();
146
+ }
147
+ getTTSModels() {
148
+ return this.registry.getTTSModels();
149
+ }
150
+ getVADModels() {
151
+ return this.registry.getVADModels();
152
+ }
153
+ getLoadedModel(category) {
154
+ if (category) {
155
+ const id = this.loadedByCategory.get(category);
156
+ return id ? this.registry.getModel(id) ?? null : null;
157
+ }
158
+ return this.registry.getModels().find((m) => m.status === ModelStatus.Loaded) ?? null;
159
+ }
160
+ getLoadedModelId(category) {
161
+ if (category) {
162
+ return this.loadedByCategory.get(category) ?? null;
163
+ }
164
+ // Legacy: return first loaded model id
165
+ return this.registry.getModels().find((m) => m.status === ModelStatus.Loaded)?.id ?? null;
166
+ }
167
+ /** Check if models for all given categories are loaded */
168
+ areAllLoaded(categories) {
169
+ return categories.every((c) => this.loadedByCategory.has(c));
170
+ }
171
+ /**
172
+ * Ensure a model is loaded for the given category.
173
+ * If already loaded, returns the loaded model. If a downloaded model exists,
174
+ * loads it automatically. Returns null if no suitable model is available.
175
+ *
176
+ * @param options.coexist Forwarded to `loadModel()`. When true, only swaps
177
+ * models of the same category instead of unloading everything.
178
+ */
179
+ async ensureLoaded(category, options) {
180
+ // Check if already loaded
181
+ const loaded = this.getLoadedModel(category);
182
+ if (loaded)
183
+ return loaded;
184
+ // Find a downloaded model for this category
185
+ const models = this.getModels();
186
+ const downloaded = models.find(m => m.modality === category && m.status === ModelStatus.Downloaded);
187
+ if (!downloaded)
188
+ return null;
189
+ // Load it
190
+ await this.loadModel(downloaded.id, options);
191
+ return this.getLoadedModel(category);
192
+ }
193
+ // --- Download (delegated to downloader) ---
194
+ /**
195
+ * Check whether downloading a model will fit in OPFS without eviction.
196
+ * Returns a result indicating whether it fits and which models could be
197
+ * evicted if not. Does NOT perform any mutations.
198
+ */
199
+ async checkDownloadFit(modelId) {
200
+ const model = this.registry.getModel(modelId);
201
+ if (!model)
202
+ return { fits: true, availableBytes: 0, neededBytes: 0, evictionCandidates: [] };
203
+ // Find the currently loaded model for the same category (excluded from eviction)
204
+ const loadedId = this.loadedByCategory.get(model.modality ?? ModelCategory.Language);
205
+ return this.downloader.checkStorageQuota(model, this.metadata, loadedId ?? undefined);
206
+ }
207
+ async downloadModel(modelId) {
208
+ return this.downloader.downloadModel(modelId);
209
+ }
210
+ // --- Model loading orchestration ---
211
+ /**
212
+ * Load a model by ID.
213
+ *
214
+ * @param options.coexist When `true`, only unload the model of the **same
215
+ * category** (swap) rather than unloading ALL loaded models. Use this for
216
+ * multi-model pipelines like Voice (STT + LLM + TTS).
217
+ * Default is `false` — unloads everything to reclaim memory.
218
+ */
219
+ async loadModel(modelId, options) {
220
+ const model = this.registry.getModel(modelId);
221
+ if (!model || (model.status !== ModelStatus.Downloaded && model.status !== ModelStatus.Registered))
222
+ return false;
223
+ const category = model.modality ?? ModelCategory.Language;
224
+ if (options?.coexist) {
225
+ // Pipeline mode: only unload models of the SAME category (swap).
226
+ // Other categories remain loaded for multi-model workflows.
227
+ const currentId = this.loadedByCategory.get(category);
228
+ if (currentId && currentId !== modelId) {
229
+ logger.info(`Swapping ${category} model: ${currentId} → ${modelId}`);
230
+ await this.unloadModelByCategory(category);
231
+ }
232
+ }
233
+ else {
234
+ // Default: Unload ALL currently loaded models before loading the new one.
235
+ //
236
+ // In a browser environment, memory is limited (WASM linear memory +
237
+ // WebGPU buffers). The user interacts with one feature at a time
238
+ // (chat, vision, transcribe, etc.), so there's no need to keep models
239
+ // from other categories resident.
240
+ await this.unloadAll(modelId);
241
+ }
242
+ this.registry.updateModel(modelId, { status: ModelStatus.Loading });
243
+ EventBus.shared.emit('model.loadStarted', SDKEventType.Model, { modelId, category });
244
+ try {
245
+ if (model.modality === ModelCategory.Multimodal) {
246
+ // VLM: Worker reads from OPFS directly when possible.
247
+ // When OPFS quota is exceeded, models live only in the main-thread
248
+ // memory cache — we must read and transfer them to the Worker.
249
+ const exists = await this.downloader.existsInOPFS(modelId);
250
+ if (!exists) {
251
+ throw new Error('Model not downloaded — please download the model first.');
252
+ }
253
+ const inActualOPFS = await this.downloader.existsInActualOPFS(modelId);
254
+ if (inActualOPFS) {
255
+ // Worker can read from OPFS directly (optimal: avoids main-thread copy)
256
+ await this.loadLLMModel(model, modelId, new Uint8Array(0));
257
+ }
258
+ else {
259
+ // Model is in memory cache only (OPFS quota exceeded) — read and transfer to Worker
260
+ logger.debug(`VLM model ${modelId} not in OPFS, reading from memory cache to transfer to Worker`);
261
+ const data = await this.downloader.loadFromOPFS(modelId);
262
+ if (!data)
263
+ throw new Error('Model not downloaded — please download the model first.');
264
+ await this.loadLLMModel(model, modelId, data);
265
+ }
266
+ }
267
+ else {
268
+ const data = await this.downloader.loadFromOPFS(modelId);
269
+ if (!data) {
270
+ throw new Error('Model not downloaded — please download the model first.');
271
+ }
272
+ if (model.modality === ModelCategory.SpeechRecognition) {
273
+ await this.loadSTTModel(model, data);
274
+ }
275
+ else if (model.modality === ModelCategory.SpeechSynthesis) {
276
+ await this.loadTTSModel(model, data);
277
+ }
278
+ else if (model.modality === ModelCategory.Audio) {
279
+ await this.loadVADModel(model, data);
280
+ }
281
+ else {
282
+ await this.loadLLMModel(model, modelId, data);
283
+ }
284
+ }
285
+ this.loadedByCategory.set(category, modelId);
286
+ this.registry.updateModel(modelId, { status: ModelStatus.Loaded });
287
+ EventBus.shared.emit('model.loadCompleted', SDKEventType.Model, { modelId, category });
288
+ // Update LRU metadata
289
+ this.touchLastUsed(modelId, model.sizeBytes ?? 0);
290
+ return true;
291
+ }
292
+ catch (err) {
293
+ const message = err instanceof Error
294
+ ? err.message
295
+ : (typeof err === 'object' ? JSON.stringify(err) : String(err));
296
+ logger.error(`Failed to load model ${modelId}: ${message}`);
297
+ this.registry.updateModel(modelId, { status: ModelStatus.Error, error: message });
298
+ EventBus.shared.emit('model.loadFailed', SDKEventType.Model, { modelId, error: message });
299
+ return false;
300
+ }
301
+ }
302
+ async unloadModel(modelId) {
303
+ const model = this.registry.getModel(modelId);
304
+ if (!model)
305
+ return;
306
+ const category = model.modality ?? ModelCategory.Language;
307
+ await this.unloadModelByCategory(category);
308
+ }
309
+ /**
310
+ * Unload ALL currently loaded models.
311
+ *
312
+ * Called automatically before loading a new model, and can also be called
313
+ * explicitly by app code (e.g. on tab switch) to release all resources.
314
+ *
315
+ * @param exceptModelId - Optional model ID to skip (the model about to be loaded).
316
+ * Avoids redundant unload+reload of the same model.
317
+ */
318
+ async unloadAll(exceptModelId) {
319
+ // Snapshot categories to avoid mutation during iteration
320
+ const loaded = [...this.loadedByCategory.entries()];
321
+ if (loaded.length === 0)
322
+ return;
323
+ for (const [category, loadedId] of loaded) {
324
+ if (exceptModelId && loadedId === exceptModelId)
325
+ continue;
326
+ logger.info(`Unloading ${category} model (${loadedId}) — freeing resources`);
327
+ await this.unloadModelByCategory(category);
328
+ }
329
+ }
330
+ async deleteModel(modelId) {
331
+ // Remove from loaded tracking if this model is loaded
332
+ for (const [category, id] of this.loadedByCategory) {
333
+ if (id === modelId) {
334
+ this.loadedByCategory.delete(category);
335
+ break;
336
+ }
337
+ }
338
+ // Delete primary file
339
+ await this.downloader.deleteFromOPFS(modelId);
340
+ // Delete additional files
341
+ const model = this.registry.getModel(modelId);
342
+ if (model?.additionalFiles) {
343
+ for (const file of model.additionalFiles) {
344
+ await this.downloader.deleteFromOPFS(this.downloader.additionalFileKey(modelId, file.filename));
345
+ }
346
+ }
347
+ this.registry.updateModel(modelId, { status: ModelStatus.Registered, downloadProgress: undefined, sizeBytes: undefined });
348
+ this.removeMetadata(modelId);
349
+ }
350
+ /** Clear all models from OPFS and reset registry statuses. */
351
+ async clearAll() {
352
+ await this.storage.clearAll();
353
+ this.metadata = {};
354
+ this.loadedByCategory.clear();
355
+ for (const model of this.registry.getModels()) {
356
+ if (model.status !== ModelStatus.Registered) {
357
+ this.registry.updateModel(model.id, {
358
+ status: ModelStatus.Registered,
359
+ downloadProgress: undefined,
360
+ sizeBytes: undefined,
361
+ });
362
+ }
363
+ }
364
+ }
365
+ async getStorageInfo() {
366
+ let modelCount = 0;
367
+ let totalSize = 0;
368
+ try {
369
+ const root = await navigator.storage.getDirectory();
370
+ const modelsDir = await root.getDirectoryHandle('models');
371
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
372
+ for await (const entry of modelsDir.values()) {
373
+ if (entry.kind === 'file') {
374
+ modelCount++;
375
+ const file = await entry.getFile();
376
+ totalSize += file.size;
377
+ }
378
+ }
379
+ }
380
+ catch {
381
+ // OPFS may not exist yet
382
+ }
383
+ let available = 0;
384
+ try {
385
+ const estimate = await navigator.storage.estimate();
386
+ available = (estimate.quota ?? 0) - (estimate.usage ?? 0);
387
+ }
388
+ catch {
389
+ // storage API may not be available
390
+ }
391
+ return { modelCount, totalSize, available };
392
+ }
393
+ // --- LRU Metadata ---
394
+ /** Get the last-used timestamp for a model (0 if never recorded). */
395
+ getModelLastUsedAt(modelId) {
396
+ return this.metadata[modelId]?.lastUsedAt ?? 0;
397
+ }
398
+ /** Update lastUsedAt for a model and persist to OPFS (fire-and-forget). */
399
+ touchLastUsed(modelId, sizeBytes) {
400
+ this.metadata[modelId] = { lastUsedAt: Date.now(), sizeBytes };
401
+ // Persist asynchronously — don't block the caller
402
+ this.storage.saveMetadata(this.metadata).catch(() => { });
403
+ }
404
+ /** Remove metadata entry when a model is deleted. */
405
+ removeMetadata(modelId) {
406
+ delete this.metadata[modelId];
407
+ this.storage.saveMetadata(this.metadata).catch(() => { });
408
+ }
409
+ // --- Subscriptions (delegated to registry) ---
410
+ onChange(callback) {
411
+ return this.registry.onChange(callback);
412
+ }
413
+ // ---------------------------------------------------------------------------
414
+ // Private — model loading by modality
415
+ // ---------------------------------------------------------------------------
416
+ /**
417
+ * Load an LLM/VLM model into the RACommons Emscripten FS.
418
+ */
419
+ async loadLLMModel(model, modelId, data) {
420
+ const fsDir = `/models`;
421
+ const fsPath = `${fsDir}/${modelId}.gguf`;
422
+ if (model.modality === ModelCategory.Multimodal) {
423
+ // VLM models are loaded in a dedicated Web Worker that reads from OPFS.
424
+ }
425
+ else {
426
+ // Text-only LLM: write to main-thread Emscripten FS as before
427
+ const bridge = WASMBridge.shared;
428
+ if (!bridge.isLoaded) {
429
+ throw new Error('WASM module not loaded — SDK not initialized.');
430
+ }
431
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
432
+ const m = bridge.module;
433
+ if (typeof m.FS_createPath !== 'function' || typeof m.FS_createDataFile !== 'function') {
434
+ throw new Error('Emscripten FS helper functions not available on WASM module.');
435
+ }
436
+ m.FS_createPath('/', 'models', true, true);
437
+ try {
438
+ m.FS_unlink(fsPath);
439
+ }
440
+ catch { /* File doesn't exist yet */ }
441
+ logger.debug(`Writing ${data.length} bytes to ${fsPath}`);
442
+ m.FS_createDataFile('/models', `${modelId}.gguf`, data, true, true, true);
443
+ logger.debug(`Model file written to ${fsPath}`);
444
+ }
445
+ if (model.modality === ModelCategory.Multimodal) {
446
+ const mmprojFile = model.additionalFiles?.find((f) => f.filename.includes('mmproj'));
447
+ if (!mmprojFile) {
448
+ logger.warning(`No mmproj found, loading as text-only LLM: ${modelId}`);
449
+ if (!this.llmLoader)
450
+ throw new Error('No LLM loader registered. Call ModelManager.setLLMLoader() first.');
451
+ await this.llmLoader.loadModel(fsPath, modelId, model.name);
452
+ }
453
+ else {
454
+ // Ensure mmproj is in OPFS or memory cache (fallback download if missing)
455
+ const mmprojKey = this.downloader.additionalFileKey(modelId, mmprojFile.filename);
456
+ const mmprojExists = await this.downloader.existsInOPFS(mmprojKey);
457
+ if (!mmprojExists && mmprojFile.url) {
458
+ logger.debug(`mmproj not in OPFS, downloading on-demand: ${mmprojFile.filename}`);
459
+ const mmprojDownload = await this.downloader.downloadFile(mmprojFile.url);
460
+ await this.downloader.storeInOPFS(mmprojKey, mmprojDownload);
461
+ }
462
+ if (!this.vlmLoader) {
463
+ throw new Error('No VLM loader registered. Call ModelManager.setVLMLoader() first.');
464
+ }
465
+ // Initialize the Worker (loads its own WASM instance)
466
+ if (!this.vlmLoader.isInitialized) {
467
+ logger.info('Initializing VLM loader...');
468
+ await this.vlmLoader.init();
469
+ }
470
+ // When model/mmproj are only in memory cache (OPFS quota exceeded),
471
+ // we need to read and transfer the data to the Worker.
472
+ let modelDataBuf;
473
+ let mmprojDataBuf;
474
+ const modelInOPFS = await this.downloader.existsInActualOPFS(modelId);
475
+ if (!modelInOPFS && data.length > 0) {
476
+ // data was already read from memory cache in the caller
477
+ modelDataBuf = new ArrayBuffer(data.byteLength);
478
+ new Uint8Array(modelDataBuf).set(data);
479
+ logger.debug(`Transferring model data to VLM Worker (${(data.length / 1024 / 1024).toFixed(1)} MB)`);
480
+ }
481
+ const mmprojInOPFS = await this.downloader.existsInActualOPFS(mmprojKey);
482
+ if (!mmprojInOPFS) {
483
+ const mmprojBytes = await this.downloader.loadFromOPFS(mmprojKey);
484
+ if (mmprojBytes) {
485
+ mmprojDataBuf = new ArrayBuffer(mmprojBytes.byteLength);
486
+ new Uint8Array(mmprojDataBuf).set(mmprojBytes);
487
+ logger.debug(`Transferring mmproj data to VLM Worker (${(mmprojBytes.length / 1024 / 1024).toFixed(1)} MB)`);
488
+ }
489
+ }
490
+ // Load model via the pluggable VLM loader
491
+ logger.info(`Loading VLM model: ${modelId}`);
492
+ await this.vlmLoader.loadModel({
493
+ modelOpfsKey: modelId,
494
+ modelFilename: `${modelId}.gguf`,
495
+ mmprojOpfsKey: mmprojKey,
496
+ mmprojFilename: mmprojFile.filename,
497
+ modelId,
498
+ modelName: model.name,
499
+ modelData: modelDataBuf,
500
+ mmprojData: mmprojDataBuf,
501
+ });
502
+ logger.info(`VLM model loaded: ${modelId}`);
503
+ }
504
+ }
505
+ else if (model.modality === ModelCategory.Language) {
506
+ if (!this.llmLoader)
507
+ throw new Error('No LLM loader registered. Call ModelManager.setLLMLoader() first.');
508
+ await this.llmLoader.loadModel(fsPath, modelId, model.name);
509
+ logger.info(`LLM model loaded via TextGeneration: ${modelId}`);
510
+ }
511
+ }
512
+ /**
513
+ * Load an STT model into sherpa-onnx.
514
+ *
515
+ * Supports two modes:
516
+ * 1. **Archive** (isArchive=true): Download is a .tar.gz that bundles encoder,
517
+ * decoder, tokens, etc. Matches the Swift SDK approach.
518
+ * 2. **Individual files**: Separate encoder/decoder/tokens downloads.
519
+ */
520
+ async loadSTTModel(model, primaryData) {
521
+ if (!this.sttLoader)
522
+ throw new Error('No STT loader registered. Call ModelManager.setSTTLoader() first.');
523
+ const sherpa = SherpaONNXBridge.shared;
524
+ await sherpa.ensureLoaded();
525
+ const modelDir = `/models/${model.id}`;
526
+ if (model.isArchive) {
527
+ await this.loadSTTFromArchive(model, primaryData, sherpa, modelDir);
528
+ }
529
+ else {
530
+ await this.loadSTTFromIndividualFiles(model, primaryData, sherpa, modelDir);
531
+ }
532
+ logger.info(`STT model loaded via sherpa-onnx: ${model.id}`);
533
+ }
534
+ /**
535
+ * Load an STT model from a .tar.gz archive (matching Swift SDK approach).
536
+ * Extracts encoder, decoder, and tokens from the archive automatically.
537
+ */
538
+ async loadSTTFromArchive(model, archiveData, sherpa, modelDir) {
539
+ logger.debug(`Extracting STT archive for ${model.id} (${archiveData.length} bytes)...`);
540
+ const entries = await extractTarGz(archiveData);
541
+ logger.debug(`Extracted ${entries.length} files from STT archive`);
542
+ const prefix = this.findArchivePrefix(entries.map(e => e.path));
543
+ // Write all files and auto-discover key paths
544
+ let encoderPath = null;
545
+ let decoderPath = null;
546
+ let tokensPath = null;
547
+ let joinerPath = null;
548
+ let modelPath = null;
549
+ for (const entry of entries) {
550
+ const relativePath = prefix ? entry.path.slice(prefix.length) : entry.path;
551
+ const fsPath = `${modelDir}/${relativePath}`;
552
+ sherpa.writeFile(fsPath, entry.data);
553
+ // Auto-discover by filename pattern
554
+ if (relativePath.includes('encoder') && relativePath.endsWith('.onnx')) {
555
+ encoderPath = fsPath;
556
+ }
557
+ else if (relativePath.includes('decoder') && relativePath.endsWith('.onnx')) {
558
+ decoderPath = fsPath;
559
+ }
560
+ else if (relativePath.includes('joiner') && relativePath.endsWith('.onnx')) {
561
+ joinerPath = fsPath;
562
+ }
563
+ else if (relativePath.includes('tokens') && relativePath.endsWith('.txt')) {
564
+ tokensPath = fsPath;
565
+ }
566
+ else if (relativePath.endsWith('.onnx') && !relativePath.includes('encoder') && !relativePath.includes('decoder') && !relativePath.includes('joiner')) {
567
+ modelPath = fsPath;
568
+ }
569
+ }
570
+ // Route to the appropriate STT model type
571
+ if (model.id.includes('whisper')) {
572
+ if (!encoderPath || !decoderPath || !tokensPath) {
573
+ throw new Error(`Whisper archive for '${model.id}' missing encoder/decoder/tokens`);
574
+ }
575
+ await this.sttLoader.loadModel({
576
+ modelId: model.id,
577
+ type: STTModelType.Whisper,
578
+ modelFiles: { encoder: encoderPath, decoder: decoderPath, tokens: tokensPath },
579
+ sampleRate: 16000,
580
+ language: 'en',
581
+ });
582
+ }
583
+ else if (model.id.includes('paraformer')) {
584
+ if (!modelPath || !tokensPath) {
585
+ throw new Error(`Paraformer archive for '${model.id}' missing model/tokens`);
586
+ }
587
+ await this.sttLoader.loadModel({
588
+ modelId: model.id,
589
+ type: STTModelType.Paraformer,
590
+ modelFiles: { model: modelPath, tokens: tokensPath },
591
+ sampleRate: 16000,
592
+ });
593
+ }
594
+ else if (model.id.includes('zipformer')) {
595
+ if (!encoderPath || !decoderPath || !joinerPath || !tokensPath) {
596
+ throw new Error(`Zipformer archive for '${model.id}' missing encoder/decoder/joiner/tokens`);
597
+ }
598
+ await this.sttLoader.loadModel({
599
+ modelId: model.id,
600
+ type: STTModelType.Zipformer,
601
+ modelFiles: { encoder: encoderPath, decoder: decoderPath, joiner: joinerPath, tokens: tokensPath },
602
+ sampleRate: 16000,
603
+ });
604
+ }
605
+ else {
606
+ throw new Error(`Unknown STT model type for model: ${model.id}`);
607
+ }
608
+ }
609
+ /**
610
+ * Load an STT model from individual downloaded files (legacy path).
611
+ */
612
+ async loadSTTFromIndividualFiles(model, primaryData, sherpa, modelDir) {
613
+ const primaryFilename = model.url.split('/').pop();
614
+ const primaryPath = `${modelDir}/${primaryFilename}`;
615
+ logger.debug(`Writing STT primary file to ${primaryPath} (${primaryData.length} bytes)`);
616
+ sherpa.writeFile(primaryPath, primaryData);
617
+ // Write additional files to sherpa FS (download on-demand if missing from OPFS)
618
+ const additionalPaths = {};
619
+ if (model.additionalFiles) {
620
+ for (const file of model.additionalFiles) {
621
+ const fileKey = this.downloader.additionalFileKey(model.id, file.filename);
622
+ let fileData = await this.downloader.loadFromOPFS(fileKey);
623
+ if (!fileData) {
624
+ logger.debug(`Additional file ${file.filename} not in OPFS, downloading...`);
625
+ fileData = await this.downloader.downloadFile(file.url);
626
+ await this.downloader.storeInOPFS(fileKey, fileData);
627
+ }
628
+ const filePath = `${modelDir}/${file.filename}`;
629
+ logger.debug(`Writing STT file to ${filePath} (${fileData.length} bytes)`);
630
+ sherpa.writeFile(filePath, fileData);
631
+ additionalPaths[file.filename] = filePath;
632
+ }
633
+ }
634
+ // Determine model type and build config based on the model ID
635
+ if (model.id.includes('whisper')) {
636
+ const encoderPath = primaryPath;
637
+ const decoderFilename = model.additionalFiles?.find(f => f.filename.includes('decoder'))?.filename;
638
+ const tokensFilename = model.additionalFiles?.find(f => f.filename.includes('tokens'))?.filename;
639
+ if (!decoderFilename || !tokensFilename) {
640
+ throw new Error('Whisper model requires encoder, decoder, and tokens files');
641
+ }
642
+ await this.sttLoader.loadModel({
643
+ modelId: model.id,
644
+ type: STTModelType.Whisper,
645
+ modelFiles: {
646
+ encoder: encoderPath,
647
+ decoder: `${modelDir}/${decoderFilename}`,
648
+ tokens: `${modelDir}/${tokensFilename}`,
649
+ },
650
+ sampleRate: 16000,
651
+ language: 'en',
652
+ });
653
+ }
654
+ else if (model.id.includes('paraformer')) {
655
+ const tokensFilename = model.additionalFiles?.find(f => f.filename.includes('tokens'))?.filename;
656
+ if (!tokensFilename) {
657
+ throw new Error('Paraformer model requires model and tokens files');
658
+ }
659
+ await this.sttLoader.loadModel({
660
+ modelId: model.id,
661
+ type: STTModelType.Paraformer,
662
+ modelFiles: { model: primaryPath, tokens: `${modelDir}/${tokensFilename}` },
663
+ sampleRate: 16000,
664
+ });
665
+ }
666
+ else if (model.id.includes('zipformer')) {
667
+ const decoderFilename = model.additionalFiles?.find(f => f.filename.includes('decoder'))?.filename;
668
+ const joinerFilename = model.additionalFiles?.find(f => f.filename.includes('joiner'))?.filename;
669
+ const tokensFilename = model.additionalFiles?.find(f => f.filename.includes('tokens'))?.filename;
670
+ if (!decoderFilename || !joinerFilename || !tokensFilename) {
671
+ throw new Error('Zipformer model requires encoder, decoder, joiner, and tokens files');
672
+ }
673
+ await this.sttLoader.loadModel({
674
+ modelId: model.id,
675
+ type: STTModelType.Zipformer,
676
+ modelFiles: {
677
+ encoder: primaryPath,
678
+ decoder: `${modelDir}/${decoderFilename}`,
679
+ joiner: `${modelDir}/${joinerFilename}`,
680
+ tokens: `${modelDir}/${tokensFilename}`,
681
+ },
682
+ sampleRate: 16000,
683
+ });
684
+ }
685
+ else {
686
+ throw new Error(`Unknown STT model type for model: ${model.id}`);
687
+ }
688
+ }
689
+ /**
690
+ * Load a TTS model into the sherpa-onnx Emscripten FS and initialise the TTS engine.
691
+ *
692
+ * Supports two modes:
693
+ * 1. **Archive** (isArchive=true): Download is a .tar.gz that bundles model files +
694
+ * espeak-ng-data. Matches the Swift SDK approach — extract and write all files.
695
+ * 2. **Individual files** (legacy): Separate model + companion file downloads.
696
+ */
697
+ async loadTTSModel(model, primaryData) {
698
+ if (!this.ttsLoader)
699
+ throw new Error('No TTS loader registered. Call ModelManager.setTTSLoader() first.');
700
+ const sherpa = SherpaONNXBridge.shared;
701
+ await sherpa.ensureLoaded();
702
+ const modelDir = `/models/${model.id}`;
703
+ if (model.isArchive) {
704
+ await this.loadTTSFromArchive(model, primaryData, sherpa, modelDir);
705
+ }
706
+ else {
707
+ await this.loadTTSFromIndividualFiles(model, primaryData, sherpa, modelDir);
708
+ }
709
+ logger.info(`TTS model loaded via sherpa-onnx: ${model.id}`);
710
+ }
711
+ /**
712
+ * Load a TTS model from a .tar.gz archive (matching Swift SDK approach).
713
+ *
714
+ * The archive contains all necessary files in a nested directory:
715
+ * model.onnx, tokens.txt, espeak-ng-data/, etc.
716
+ * We extract everything and write it to the sherpa virtual FS.
717
+ */
718
+ async loadTTSFromArchive(model, archiveData, sherpa, modelDir) {
719
+ logger.debug(`Extracting TTS archive for ${model.id} (${archiveData.length} bytes)...`);
720
+ const entries = await extractTarGz(archiveData);
721
+ logger.debug(`Extracted ${entries.length} files from archive`);
722
+ // Find the common prefix (nested directory) — archives typically contain
723
+ // one top-level directory with all files inside it.
724
+ const prefix = this.findArchivePrefix(entries.map(e => e.path));
725
+ // Write all extracted files to the sherpa virtual FS
726
+ let modelPath = null;
727
+ let tokensPath = null;
728
+ let dataDirPath = null;
729
+ for (const entry of entries) {
730
+ // Strip the nested directory prefix to get relative path
731
+ const relativePath = prefix ? entry.path.slice(prefix.length) : entry.path;
732
+ const fsPath = `${modelDir}/${relativePath}`;
733
+ sherpa.writeFile(fsPath, entry.data);
734
+ // Auto-discover key files
735
+ if (relativePath.endsWith('.onnx') && !relativePath.includes('/')) {
736
+ modelPath = fsPath;
737
+ }
738
+ if (relativePath === 'tokens.txt') {
739
+ tokensPath = fsPath;
740
+ }
741
+ if (relativePath.startsWith('espeak-ng-data/') && !dataDirPath) {
742
+ dataDirPath = `${modelDir}/espeak-ng-data`;
743
+ }
744
+ }
745
+ if (!modelPath) {
746
+ throw new Error(`TTS archive for '${model.id}' does not contain an .onnx model file`);
747
+ }
748
+ if (!tokensPath) {
749
+ throw new Error(`TTS archive for '${model.id}' does not contain tokens.txt`);
750
+ }
751
+ logger.debug(`TTS archive extracted — model: ${modelPath}, tokens: ${tokensPath}, dataDir: ${dataDirPath ?? 'none'}`);
752
+ await this.ttsLoader.loadVoice({
753
+ voiceId: model.id,
754
+ modelPath,
755
+ tokensPath,
756
+ dataDir: dataDirPath ?? '',
757
+ numThreads: 1,
758
+ });
759
+ }
760
+ /**
761
+ * Load a TTS model from individual downloaded files.
762
+ * Used when models are registered with individual file URLs (e.g. HuggingFace)
763
+ * rather than tar.gz archives. Downloads espeak-ng-data on-demand for Piper models.
764
+ */
765
+ async loadTTSFromIndividualFiles(model, primaryData, sherpa, modelDir) {
766
+ const primaryFilename = model.url.split('/').pop();
767
+ const primaryPath = `${modelDir}/${primaryFilename}`;
768
+ logger.debug(`Writing TTS primary file to ${primaryPath} (${primaryData.length} bytes)`);
769
+ sherpa.writeFile(primaryPath, primaryData);
770
+ // Write additional files (tokens.txt, *.json, etc.)
771
+ const additionalPaths = {};
772
+ if (model.additionalFiles) {
773
+ for (const file of model.additionalFiles) {
774
+ const fileKey = this.downloader.additionalFileKey(model.id, file.filename);
775
+ let fileData = await this.downloader.loadFromOPFS(fileKey);
776
+ if (!fileData) {
777
+ logger.debug(`Additional file ${file.filename} not in OPFS, downloading...`);
778
+ fileData = await this.downloader.downloadFile(file.url);
779
+ await this.downloader.storeInOPFS(fileKey, fileData);
780
+ }
781
+ const filePath = `${modelDir}/${file.filename}`;
782
+ logger.debug(`Writing TTS file to ${filePath} (${fileData.length} bytes)`);
783
+ sherpa.writeFile(filePath, fileData);
784
+ additionalPaths[file.filename] = filePath;
785
+ }
786
+ }
787
+ const tokensPath = additionalPaths['tokens.txt'];
788
+ if (!tokensPath) {
789
+ throw new Error('TTS model requires tokens.txt file');
790
+ }
791
+ await this.ttsLoader.loadVoice({
792
+ voiceId: model.id,
793
+ modelPath: primaryPath,
794
+ tokensPath,
795
+ dataDir: '', // espeak-ng-data is bundled in archives; individual-file path doesn't include it
796
+ numThreads: 1,
797
+ });
798
+ }
799
+ /**
800
+ * Load a VAD model (Silero) into the sherpa-onnx Emscripten FS.
801
+ * The Silero VAD is a single ONNX file — write it to FS and initialise.
802
+ */
803
+ async loadVADModel(model, data) {
804
+ const sherpa = SherpaONNXBridge.shared;
805
+ await sherpa.ensureLoaded();
806
+ const modelDir = `/models/${model.id}`;
807
+ const filename = model.url?.split('/').pop() ?? 'silero_vad.onnx';
808
+ const fsPath = `${modelDir}/${filename}`;
809
+ logger.debug(`Writing VAD model to ${fsPath} (${data.length} bytes)`);
810
+ sherpa.writeFile(fsPath, data);
811
+ if (!this.vadLoader)
812
+ throw new Error('No VAD loader registered. Call ModelManager.setVADLoader() first.');
813
+ await this.vadLoader.loadModel({ modelPath: fsPath });
814
+ logger.info(`VAD model loaded: ${model.id}`);
815
+ }
816
+ /**
817
+ * Find the common directory prefix in archive entry paths.
818
+ * Archives typically contain a single top-level directory (nested structure).
819
+ * Returns the prefix including trailing '/', or empty string if no common prefix.
820
+ */
821
+ findArchivePrefix(paths) {
822
+ if (paths.length === 0)
823
+ return '';
824
+ // Check if all paths share a common first directory component
825
+ const firstSlash = paths[0].indexOf('/');
826
+ if (firstSlash === -1)
827
+ return '';
828
+ const candidate = paths[0].slice(0, firstSlash + 1);
829
+ const allMatch = paths.every(p => p.startsWith(candidate));
830
+ return allMatch ? candidate : '';
831
+ }
832
+ /** Unload the currently loaded model for a specific category */
833
+ async unloadModelByCategory(category) {
834
+ const modelId = this.loadedByCategory.get(category);
835
+ if (!modelId)
836
+ return;
837
+ logger.info(`Unloading ${category} model: ${modelId}`);
838
+ try {
839
+ if (category === ModelCategory.SpeechRecognition) {
840
+ await this.sttLoader?.unloadModel();
841
+ }
842
+ else if (category === ModelCategory.SpeechSynthesis) {
843
+ await this.ttsLoader?.unloadVoice();
844
+ }
845
+ else if (category === ModelCategory.Audio) {
846
+ this.vadLoader?.cleanup();
847
+ }
848
+ else if (category === ModelCategory.Multimodal) {
849
+ await this.vlmLoader?.unloadModel();
850
+ }
851
+ else {
852
+ await this.llmLoader?.unloadModel();
853
+ }
854
+ // Clean up Emscripten FS model files to release WASM linear memory.
855
+ // LLM models (Language) write .gguf files into the main-thread
856
+ // Emscripten FS. VLM (Multimodal) models are handled by the Worker's
857
+ // own WASM FS and don't need cleanup here.
858
+ if (category === ModelCategory.Language) {
859
+ try {
860
+ const bridge = WASMBridge.shared;
861
+ if (bridge.isLoaded) {
862
+ const m = bridge.module;
863
+ const fsPath = `/models/${modelId}.gguf`;
864
+ try {
865
+ m.FS_unlink(fsPath);
866
+ }
867
+ catch { /* file may not exist */ }
868
+ logger.debug(`Cleaned up Emscripten FS: ${fsPath}`);
869
+ }
870
+ }
871
+ catch {
872
+ // Non-critical — FS cleanup is best-effort
873
+ }
874
+ }
875
+ }
876
+ catch (err) {
877
+ logger.warning(`Error during unload of ${modelId}: ${err instanceof Error ? err.message : String(err)}`);
878
+ }
879
+ this.registry.updateModel(modelId, { status: ModelStatus.Downloaded });
880
+ this.loadedByCategory.delete(category);
881
+ EventBus.shared.emit('model.unloaded', SDKEventType.Model, { modelId, category });
882
+ }
883
+ }
884
+ export const ModelManager = new ModelManagerImpl();
885
+ //# sourceMappingURL=ModelManager.js.map