react-native-sherpa-onnx 0.2.0 → 0.3.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 (175) hide show
  1. package/README.md +232 -236
  2. package/SherpaOnnx.podspec +68 -64
  3. package/android/build.gradle +182 -192
  4. package/android/codegen.gradle +57 -0
  5. package/android/prebuilt-download.gradle +428 -0
  6. package/android/prebuilt-versions.gradle +43 -0
  7. package/android/proguard-rules.pro +10 -0
  8. package/android/src/main/assets/testModels/add_mul_add.onnx +28 -0
  9. package/android/src/main/assets/testModels/nnapi_internal_uint8_support.onnx +0 -0
  10. package/android/src/main/assets/testModels/qnn_multi_ctx_embed.onnx +0 -0
  11. package/android/src/main/cpp/CMakeLists.txt +166 -129
  12. package/android/src/main/cpp/CMakePresets.json +54 -0
  13. package/android/src/main/cpp/crypto/sha256.cpp +174 -0
  14. package/android/src/main/cpp/crypto/sha256.h +16 -0
  15. package/android/src/main/cpp/jni/archive/sherpa-onnx-archive-helper.cpp +404 -0
  16. package/android/src/main/cpp/jni/archive/sherpa-onnx-archive-helper.h +56 -0
  17. package/android/src/main/cpp/jni/archive/sherpa-onnx-archive-jni.cpp +181 -0
  18. package/android/src/main/cpp/jni/audio/sherpa-onnx-audio-convert-jni.cpp +888 -0
  19. package/{ios → android/src/main/cpp/jni/model_detect}/sherpa-onnx-common.h +18 -18
  20. package/android/src/main/cpp/jni/model_detect/sherpa-onnx-detect-jni-common.cpp +86 -0
  21. package/android/src/main/cpp/jni/model_detect/sherpa-onnx-detect-jni-common.h +20 -0
  22. package/android/src/main/cpp/jni/model_detect/sherpa-onnx-model-detect-helper.cpp +423 -0
  23. package/android/src/main/cpp/jni/model_detect/sherpa-onnx-model-detect-helper.h +55 -0
  24. package/android/src/main/cpp/jni/model_detect/sherpa-onnx-model-detect-stt.cpp +399 -0
  25. package/android/src/main/cpp/jni/model_detect/sherpa-onnx-model-detect-tts.cpp +238 -0
  26. package/{ios → android/src/main/cpp/jni/model_detect}/sherpa-onnx-model-detect.h +122 -89
  27. package/android/src/main/cpp/jni/model_detect/sherpa-onnx-stt-wrapper.cpp +99 -0
  28. package/android/src/main/cpp/jni/model_detect/sherpa-onnx-stt-wrapper.h +16 -0
  29. package/android/src/main/cpp/jni/model_detect/sherpa-onnx-tts-wrapper.cpp +78 -0
  30. package/android/src/main/cpp/jni/model_detect/sherpa-onnx-tts-wrapper.h +16 -0
  31. package/android/src/main/cpp/jni/module/sherpa-onnx-module-jni.cpp +190 -0
  32. package/android/src/main/cpp/jni/tts/sherpa-onnx-tts-zipvoice-jni.cpp +301 -0
  33. package/android/src/main/java/com/sherpaonnx/SherpaOnnxArchiveHelper.kt +94 -0
  34. package/android/src/main/java/com/sherpaonnx/{SherpaOnnxCoreHelper.kt → SherpaOnnxAssetHelper.kt} +350 -236
  35. package/android/src/main/java/com/sherpaonnx/SherpaOnnxModule.kt +791 -483
  36. package/android/src/main/java/com/sherpaonnx/SherpaOnnxSttHelper.kt +699 -109
  37. package/android/src/main/java/com/sherpaonnx/SherpaOnnxTtsHelper.kt +1123 -668
  38. package/android/src/main/java/com/sherpaonnx/ZipvoiceTtsWrapper.kt +187 -0
  39. package/ios/SherpaOnnx+Assets.h +11 -0
  40. package/ios/SherpaOnnx+Assets.mm +325 -0
  41. package/ios/SherpaOnnx+STT.mm +455 -118
  42. package/ios/SherpaOnnx+TTS.mm +1101 -712
  43. package/ios/SherpaOnnx.h +17 -6
  44. package/ios/SherpaOnnx.mm +206 -311
  45. package/ios/SherpaOnnx.xcconfig +19 -19
  46. package/ios/SherpaOnnxCoreMLHelper.swift +24 -0
  47. package/ios/archive/sherpa-onnx-archive-helper.h +21 -0
  48. package/ios/archive/sherpa-onnx-archive-helper.mm +296 -0
  49. package/ios/libarchive_darwin_config.h +153 -0
  50. package/{android/src/main/cpp/jni → ios/model_detect}/sherpa-onnx-common.h +18 -18
  51. package/ios/model_detect/sherpa-onnx-model-detect-helper.h +49 -0
  52. package/ios/model_detect/sherpa-onnx-model-detect-helper.mm +210 -0
  53. package/ios/model_detect/sherpa-onnx-model-detect-stt.mm +344 -0
  54. package/ios/model_detect/sherpa-onnx-model-detect-tts.mm +201 -0
  55. package/{android/src/main/cpp/jni → ios/model_detect}/sherpa-onnx-model-detect.h +117 -89
  56. package/ios/scripts/patch-libarchive-includes.sh +61 -0
  57. package/ios/scripts/setup-ios-libarchive.sh +98 -0
  58. package/ios/stt/sherpa-onnx-stt-wrapper.h +129 -0
  59. package/ios/stt/sherpa-onnx-stt-wrapper.mm +523 -0
  60. package/ios/{sherpa-onnx-tts-wrapper.h → tts/sherpa-onnx-tts-wrapper.h} +90 -85
  61. package/ios/{sherpa-onnx-tts-wrapper.mm → tts/sherpa-onnx-tts-wrapper.mm} +376 -345
  62. package/lib/module/NativeSherpaOnnx.js +3 -0
  63. package/lib/module/NativeSherpaOnnx.js.map +1 -1
  64. package/lib/module/audio/index.js +22 -0
  65. package/lib/module/audio/index.js.map +1 -0
  66. package/lib/module/diarization/index.js +1 -1
  67. package/lib/module/diarization/index.js.map +1 -1
  68. package/lib/module/download/ModelDownloadManager.js +918 -0
  69. package/lib/module/download/ModelDownloadManager.js.map +1 -0
  70. package/lib/module/download/extractTarBz2.js +53 -0
  71. package/lib/module/download/extractTarBz2.js.map +1 -0
  72. package/lib/module/download/index.js +6 -0
  73. package/lib/module/download/index.js.map +1 -0
  74. package/lib/module/download/validation.js +178 -0
  75. package/lib/module/download/validation.js.map +1 -0
  76. package/lib/module/enhancement/index.js +1 -1
  77. package/lib/module/enhancement/index.js.map +1 -1
  78. package/lib/module/index.js +41 -3
  79. package/lib/module/index.js.map +1 -1
  80. package/lib/module/separation/index.js +1 -1
  81. package/lib/module/separation/index.js.map +1 -1
  82. package/lib/module/stt/index.js +127 -60
  83. package/lib/module/stt/index.js.map +1 -1
  84. package/lib/module/stt/sttModelLanguages.js +512 -0
  85. package/lib/module/stt/sttModelLanguages.js.map +1 -0
  86. package/lib/module/stt/types.js +53 -1
  87. package/lib/module/stt/types.js.map +1 -1
  88. package/lib/module/tts/index.js +216 -289
  89. package/lib/module/tts/index.js.map +1 -1
  90. package/lib/module/tts/types.js +86 -1
  91. package/lib/module/tts/types.js.map +1 -1
  92. package/lib/module/types.js.map +1 -1
  93. package/lib/module/utils.js +86 -73
  94. package/lib/module/utils.js.map +1 -1
  95. package/lib/module/vad/index.js +1 -1
  96. package/lib/module/vad/index.js.map +1 -1
  97. package/lib/typescript/src/NativeSherpaOnnx.d.ts +192 -38
  98. package/lib/typescript/src/NativeSherpaOnnx.d.ts.map +1 -1
  99. package/lib/typescript/src/audio/index.d.ts +13 -0
  100. package/lib/typescript/src/audio/index.d.ts.map +1 -0
  101. package/lib/typescript/src/diarization/index.d.ts +3 -2
  102. package/lib/typescript/src/diarization/index.d.ts.map +1 -1
  103. package/lib/typescript/src/download/ModelDownloadManager.d.ts +108 -0
  104. package/lib/typescript/src/download/ModelDownloadManager.d.ts.map +1 -0
  105. package/lib/typescript/src/download/extractTarBz2.d.ts +14 -0
  106. package/lib/typescript/src/download/extractTarBz2.d.ts.map +1 -0
  107. package/lib/typescript/src/download/index.d.ts +7 -0
  108. package/lib/typescript/src/download/index.d.ts.map +1 -0
  109. package/lib/typescript/src/download/validation.d.ts +57 -0
  110. package/lib/typescript/src/download/validation.d.ts.map +1 -0
  111. package/lib/typescript/src/enhancement/index.d.ts +3 -2
  112. package/lib/typescript/src/enhancement/index.d.ts.map +1 -1
  113. package/lib/typescript/src/index.d.ts +26 -2
  114. package/lib/typescript/src/index.d.ts.map +1 -1
  115. package/lib/typescript/src/separation/index.d.ts +3 -2
  116. package/lib/typescript/src/separation/index.d.ts.map +1 -1
  117. package/lib/typescript/src/stt/index.d.ts +31 -43
  118. package/lib/typescript/src/stt/index.d.ts.map +1 -1
  119. package/lib/typescript/src/stt/sttModelLanguages.d.ts +52 -0
  120. package/lib/typescript/src/stt/sttModelLanguages.d.ts.map +1 -0
  121. package/lib/typescript/src/stt/types.d.ts +196 -9
  122. package/lib/typescript/src/stt/types.d.ts.map +1 -1
  123. package/lib/typescript/src/tts/index.d.ts +25 -211
  124. package/lib/typescript/src/tts/index.d.ts.map +1 -1
  125. package/lib/typescript/src/tts/types.d.ts +148 -25
  126. package/lib/typescript/src/tts/types.d.ts.map +1 -1
  127. package/lib/typescript/src/types.d.ts +0 -32
  128. package/lib/typescript/src/types.d.ts.map +1 -1
  129. package/lib/typescript/src/utils.d.ts +28 -13
  130. package/lib/typescript/src/utils.d.ts.map +1 -1
  131. package/lib/typescript/src/vad/index.d.ts +3 -2
  132. package/lib/typescript/src/vad/index.d.ts.map +1 -1
  133. package/package.json +250 -222
  134. package/scripts/check-qnn-support.sh +78 -0
  135. package/scripts/setup-ios-framework.sh +379 -282
  136. package/src/NativeSherpaOnnx.ts +474 -251
  137. package/src/audio/index.ts +32 -0
  138. package/src/diarization/index.ts +4 -2
  139. package/src/download/ModelDownloadManager.ts +1325 -0
  140. package/src/download/extractTarBz2.ts +78 -0
  141. package/src/download/index.ts +43 -0
  142. package/src/download/validation.ts +279 -0
  143. package/src/enhancement/index.ts +4 -2
  144. package/src/index.tsx +78 -27
  145. package/src/separation/index.ts +4 -2
  146. package/src/stt/index.ts +249 -89
  147. package/src/stt/sttModelLanguages.ts +237 -0
  148. package/src/stt/types.ts +263 -9
  149. package/src/tts/index.ts +470 -458
  150. package/src/tts/types.ts +373 -218
  151. package/src/types.ts +0 -44
  152. package/src/utils.ts +145 -131
  153. package/src/vad/index.ts +4 -2
  154. package/third_party/ffmpeg_prebuilt/ANDROID_RELEASE_TAG +1 -0
  155. package/third_party/libarchive_prebuilt/ANDROID_RELEASE_TAG +1 -0
  156. package/third_party/libarchive_prebuilt/IOS_RELEASE_TAG +1 -0
  157. package/third_party/sherpa-onnx-prebuilt/ANDROID_RELEASE_TAG +1 -0
  158. package/third_party/sherpa-onnx-prebuilt/IOS_RELEASE_TAG +1 -0
  159. package/android/src/main/cpp/include/sherpa-onnx/c-api/c-api.h +0 -1918
  160. package/android/src/main/cpp/include/sherpa-onnx/c-api/cxx-api.h +0 -841
  161. package/android/src/main/cpp/jni/sherpa-onnx-model-detect.cpp +0 -541
  162. package/android/src/main/cpp/jni/sherpa-onnx-stt-jni.cpp +0 -336
  163. package/android/src/main/cpp/jni/sherpa-onnx-stt-wrapper.cpp +0 -222
  164. package/android/src/main/cpp/jni/sherpa-onnx-stt-wrapper.h +0 -68
  165. package/android/src/main/cpp/jni/sherpa-onnx-tts-jni.cpp +0 -823
  166. package/android/src/main/cpp/jni/sherpa-onnx-tts-wrapper.cpp +0 -387
  167. package/android/src/main/cpp/jni/sherpa-onnx-tts-wrapper.h +0 -147
  168. package/ios/Frameworks/sherpa_onnx.xcframework.zip +0 -0
  169. package/ios/include/sherpa-onnx/c-api/c-api.h +0 -1918
  170. package/ios/include/sherpa-onnx/c-api/cxx-api.h +0 -841
  171. package/ios/sherpa-onnx-model-detect.mm +0 -441
  172. package/ios/sherpa-onnx-stt-wrapper.h +0 -48
  173. package/ios/sherpa-onnx-stt-wrapper.mm +0 -201
  174. package/scripts/copy-headers.js +0 -184
  175. package/scripts/setup-assets.js +0 -323
@@ -0,0 +1,1325 @@
1
+ import { Alert, Platform } from 'react-native';
2
+ import {
3
+ DocumentDirectoryPath,
4
+ exists,
5
+ readFile,
6
+ mkdir,
7
+ writeFile,
8
+ readDir,
9
+ stat,
10
+ unlink,
11
+ downloadFile,
12
+ stopDownload,
13
+ } from '@dr.pogodin/react-native-fs';
14
+ import type { TTSModelType } from '../tts/types';
15
+ import { extractTarBz2 } from './extractTarBz2';
16
+ import {
17
+ parseChecksumFile,
18
+ validateChecksum,
19
+ validateExtractedFiles,
20
+ checkDiskSpace,
21
+ } from './validation';
22
+
23
+ const RELEASE_API_BASE =
24
+ 'https://api.github.com/repos/k2-fsa/sherpa-onnx/releases/tags';
25
+ const CACHE_TTL_MINUTES = 24 * 60;
26
+ const MODEL_ARCHIVE_EXT = '.tar.bz2';
27
+ const MODEL_ONNX_EXT = '.onnx';
28
+
29
+ export enum ModelCategory {
30
+ Tts = 'tts',
31
+ Stt = 'stt',
32
+ Vad = 'vad',
33
+ Diarization = 'diarization',
34
+ Enhancement = 'enhancement',
35
+ Separation = 'separation',
36
+ }
37
+
38
+ /** TTS model type for meta; 'unknown' when id could not be classified. */
39
+ export type TtsModelType = TTSModelType | 'unknown';
40
+
41
+ export type Quantization = 'fp16' | 'int8' | 'int8-quantized' | 'unknown';
42
+
43
+ export type SizeTier = 'tiny' | 'small' | 'medium' | 'large' | 'unknown';
44
+
45
+ type ModelArchiveExt = 'tar.bz2' | 'onnx';
46
+
47
+ export type ModelMetaBase = {
48
+ id: string;
49
+ displayName: string;
50
+ downloadUrl: string;
51
+ archiveExt: ModelArchiveExt;
52
+ bytes: number;
53
+ sha256?: string;
54
+ category: ModelCategory;
55
+ };
56
+
57
+ export type TtsModelMeta = ModelMetaBase & {
58
+ type: TtsModelType;
59
+ languages: string[];
60
+ quantization: Quantization;
61
+ sizeTier: SizeTier;
62
+ category: ModelCategory.Tts;
63
+ };
64
+
65
+ export type DownloadProgress = {
66
+ bytesDownloaded: number;
67
+ totalBytes: number;
68
+ percent: number;
69
+ phase?: 'downloading' | 'extracting';
70
+ speed?: number; // bytes per second
71
+ eta?: number; // estimated seconds remaining
72
+ };
73
+
74
+ export type DownloadResult = {
75
+ modelId: string;
76
+ localPath: string;
77
+ };
78
+
79
+ export type DownloadProgressListener = (
80
+ category: ModelCategory,
81
+ modelId: string,
82
+ progress: DownloadProgress
83
+ ) => void;
84
+
85
+ export type ModelsListUpdatedListener = (
86
+ category: ModelCategory,
87
+ models: ModelMetaBase[]
88
+ ) => void;
89
+
90
+ type ModelManifest<T extends ModelMetaBase = ModelMetaBase> = {
91
+ downloadedAt: string;
92
+ lastUsed?: string;
93
+ model: T;
94
+ /** Total size in bytes of the extracted/model directory (set after download or computed once). */
95
+ sizeOnDisk?: number;
96
+ };
97
+
98
+ export type ModelWithMetadata<T extends ModelMetaBase = ModelMetaBase> = {
99
+ model: T;
100
+ downloadedAt: string;
101
+ lastUsed: string | null;
102
+ sizeOnDisk?: number;
103
+ };
104
+
105
+ type ChecksumIssue = {
106
+ category: ModelCategory;
107
+ id: string;
108
+ archivePath: string;
109
+ expected?: string;
110
+ message: string;
111
+ reason: 'CHECKSUM_FAILED' | 'CHECKSUM_MISMATCH';
112
+ };
113
+
114
+ const promptChecksumFallback = (issue: ChecksumIssue): Promise<boolean> =>
115
+ new Promise((resolve) => {
116
+ const reasonText =
117
+ issue.reason === 'CHECKSUM_FAILED'
118
+ ? 'Failed to compute checksum.'
119
+ : 'Computed checksum does not match the expected value.';
120
+ const body = `${reasonText}\n\n${issue.message}\n\nDo you want to keep the file and continue?`;
121
+
122
+ Alert.alert('Checksum Problem', body, [
123
+ {
124
+ text: 'Delete and cancel',
125
+ style: 'destructive',
126
+ onPress: () => resolve(false),
127
+ },
128
+ {
129
+ text: 'Keep file',
130
+ style: 'default',
131
+ onPress: () => resolve(true),
132
+ },
133
+ ]);
134
+ });
135
+
136
+ type CachePayload<T extends ModelMetaBase> = {
137
+ lastUpdated: string;
138
+ models: T[];
139
+ };
140
+
141
+ type CacheStatus = {
142
+ lastUpdated: string | null;
143
+ source: 'cache' | 'remote';
144
+ };
145
+
146
+ const memoryCacheByCategory: Partial<
147
+ Record<ModelCategory, CachePayload<ModelMetaBase>>
148
+ > = {};
149
+
150
+ const checksumCacheByCategory: Partial<
151
+ Record<ModelCategory, Map<string, string>>
152
+ > = {};
153
+
154
+ const downloadProgressListeners = new Set<DownloadProgressListener>();
155
+ const modelsListUpdatedListeners = new Set<ModelsListUpdatedListener>();
156
+
157
+ export const subscribeDownloadProgress = (
158
+ listener: DownloadProgressListener
159
+ ): (() => void) => {
160
+ downloadProgressListeners.add(listener);
161
+ return () => {
162
+ downloadProgressListeners.delete(listener);
163
+ };
164
+ };
165
+
166
+ const emitDownloadProgress = (
167
+ category: ModelCategory,
168
+ modelId: string,
169
+ progress: DownloadProgress
170
+ ) => {
171
+ for (const listener of downloadProgressListeners) {
172
+ try {
173
+ listener(category, modelId, progress);
174
+ } catch (error) {
175
+ console.warn('Download progress listener error:', error);
176
+ }
177
+ }
178
+ };
179
+
180
+ export const subscribeModelsListUpdated = (
181
+ listener: ModelsListUpdatedListener
182
+ ): (() => void) => {
183
+ modelsListUpdatedListeners.add(listener);
184
+ return () => {
185
+ modelsListUpdatedListeners.delete(listener);
186
+ };
187
+ };
188
+
189
+ const emitModelsListUpdated = (
190
+ category: ModelCategory,
191
+ models: ModelMetaBase[]
192
+ ) => {
193
+ for (const listener of modelsListUpdatedListeners) {
194
+ try {
195
+ listener(category, models);
196
+ } catch (error) {
197
+ console.warn('Models list listener error:', error);
198
+ }
199
+ }
200
+ };
201
+
202
+ const CATEGORY_CONFIG: Record<
203
+ ModelCategory,
204
+ { tag: string; cacheFile: string; baseDir: string }
205
+ > = {
206
+ [ModelCategory.Tts]: {
207
+ tag: 'tts-models',
208
+ cacheFile: 'tts-models.json',
209
+ baseDir: `${DocumentDirectoryPath}/sherpa-onnx/models/tts`,
210
+ },
211
+ [ModelCategory.Stt]: {
212
+ tag: 'asr-models',
213
+ cacheFile: 'asr-models.json',
214
+ baseDir: `${DocumentDirectoryPath}/sherpa-onnx/models/stt`,
215
+ },
216
+ [ModelCategory.Vad]: {
217
+ tag: 'asr-models',
218
+ cacheFile: 'vad-models.json',
219
+ baseDir: `${DocumentDirectoryPath}/sherpa-onnx/models/vad`,
220
+ },
221
+ [ModelCategory.Diarization]: {
222
+ tag: 'speaker-segmentation-models',
223
+ cacheFile: 'diarization-models.json',
224
+ baseDir: `${DocumentDirectoryPath}/sherpa-onnx/models/diarization`,
225
+ },
226
+ [ModelCategory.Enhancement]: {
227
+ tag: 'speech-enhancement-models',
228
+ cacheFile: 'enhancement-models.json',
229
+ baseDir: `${DocumentDirectoryPath}/sherpa-onnx/models/enhancement`,
230
+ },
231
+ [ModelCategory.Separation]: {
232
+ tag: 'source-separation-models',
233
+ cacheFile: 'separation-models.json',
234
+ baseDir: `${DocumentDirectoryPath}/sherpa-onnx/models/separation`,
235
+ },
236
+ };
237
+
238
+ function getCacheDir(): string {
239
+ return `${DocumentDirectoryPath}/sherpa-onnx/cache`;
240
+ }
241
+
242
+ function getCachePath(category: ModelCategory): string {
243
+ return `${getCacheDir()}/${CATEGORY_CONFIG[category].cacheFile}`;
244
+ }
245
+
246
+ function getModelsBaseDir(category: ModelCategory): string {
247
+ return CATEGORY_CONFIG[category].baseDir;
248
+ }
249
+
250
+ function getModelDir(category: ModelCategory, modelId: string): string {
251
+ return `${getModelsBaseDir(category)}/${modelId}`;
252
+ }
253
+
254
+ function getArchiveFilename(
255
+ modelId: string,
256
+ archiveExt: ModelArchiveExt
257
+ ): string {
258
+ return `${modelId}.${archiveExt}`;
259
+ }
260
+
261
+ function getArchivePath(
262
+ category: ModelCategory,
263
+ modelId: string,
264
+ archiveExt: ModelArchiveExt
265
+ ): string {
266
+ const filename = getArchiveFilename(modelId, archiveExt);
267
+ if (archiveExt === 'onnx') {
268
+ return `${getModelDir(category, modelId)}/${filename}`;
269
+ }
270
+ return `${getModelsBaseDir(category)}/${filename}`;
271
+ }
272
+
273
+ function getTarArchivePath(category: ModelCategory, modelId: string): string {
274
+ return getArchivePath(category, modelId, 'tar.bz2');
275
+ }
276
+
277
+ function getOnnxPath(category: ModelCategory, modelId: string): string {
278
+ return getArchivePath(category, modelId, 'onnx');
279
+ }
280
+
281
+ function getReadyMarkerPath(category: ModelCategory, modelId: string): string {
282
+ return `${getModelDir(category, modelId)}/.ready`;
283
+ }
284
+
285
+ function getManifestPath(category: ModelCategory, modelId: string): string {
286
+ return `${getModelDir(category, modelId)}/manifest.json`;
287
+ }
288
+
289
+ function getReleaseUrl(category: ModelCategory): string {
290
+ return `${RELEASE_API_BASE}/${CATEGORY_CONFIG[category].tag}`;
291
+ }
292
+
293
+ /**
294
+ * Retry helper with exponential backoff
295
+ * @param fn - The async function to retry
296
+ * @param options - Retry configuration
297
+ * @returns The result of the function
298
+ * @throws The last error if all retries fail or AbortError if aborted
299
+ */
300
+ async function retryWithBackoff<T>(
301
+ fn: () => Promise<T>,
302
+ options: {
303
+ maxRetries?: number;
304
+ initialDelayMs?: number;
305
+ maxDelayMs?: number;
306
+ backoffFactor?: number;
307
+ signal?: AbortSignal;
308
+ } = {}
309
+ ): Promise<T> {
310
+ const maxRetries = options.maxRetries ?? 3;
311
+ const initialDelayMs = options.initialDelayMs ?? 1000;
312
+ const maxDelayMs = options.maxDelayMs ?? 10000;
313
+ const backoffFactor = options.backoffFactor ?? 2;
314
+
315
+ let lastError: Error | undefined;
316
+
317
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
318
+ if (options.signal?.aborted) {
319
+ const abortError = new Error('Operation aborted');
320
+ abortError.name = 'AbortError';
321
+ throw abortError;
322
+ }
323
+
324
+ try {
325
+ return await fn();
326
+ } catch (error) {
327
+ lastError = error instanceof Error ? error : new Error(String(error));
328
+
329
+ // Don't retry on abort
330
+ if (lastError.name === 'AbortError' || options.signal?.aborted) {
331
+ throw lastError;
332
+ }
333
+
334
+ // If this was the last attempt, throw the error
335
+ if (attempt === maxRetries) {
336
+ throw lastError;
337
+ }
338
+
339
+ // Calculate delay with exponential backoff
340
+ const delayMs = Math.min(
341
+ initialDelayMs * Math.pow(backoffFactor, attempt),
342
+ maxDelayMs
343
+ );
344
+
345
+ console.warn(
346
+ `Retry attempt ${attempt + 1}/${maxRetries} after ${delayMs}ms due to:`,
347
+ lastError.message
348
+ );
349
+
350
+ // Wait before retrying
351
+ await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
352
+ }
353
+ }
354
+
355
+ throw lastError ?? new Error('Retry failed with no error');
356
+ }
357
+
358
+ async function fetchChecksumsFromRelease(
359
+ category: ModelCategory
360
+ ): Promise<Map<string, string>> {
361
+ // Return cached if available
362
+ if (checksumCacheByCategory[category]) {
363
+ return checksumCacheByCategory[category]!;
364
+ }
365
+
366
+ try {
367
+ const checksums = await retryWithBackoff(
368
+ async () => {
369
+ const response = await fetch(
370
+ `https://github.com/k2-fsa/sherpa-onnx/releases/download/${CATEGORY_CONFIG[category].tag}/checksum.txt`
371
+ );
372
+
373
+ if (!response.ok) {
374
+ throw new Error(
375
+ `Failed to fetch checksum.txt for ${category}: ${response.status}`
376
+ );
377
+ }
378
+
379
+ const content = await response.text();
380
+ return parseChecksumFile(content);
381
+ },
382
+ {
383
+ maxRetries: 3,
384
+ initialDelayMs: 1000,
385
+ }
386
+ );
387
+
388
+ checksumCacheByCategory[category] = checksums;
389
+ return checksums;
390
+ } catch (error) {
391
+ console.warn(
392
+ `SherpaOnnxChecksum: Error fetching checksums for ${category}:`,
393
+ error
394
+ );
395
+ return new Map();
396
+ }
397
+ }
398
+
399
+ function toTitleCase(value: string): string {
400
+ return value
401
+ .split(/[-_\s]+/g)
402
+ .filter(Boolean)
403
+ .map((token) => token[0]!.toUpperCase() + token.slice(1))
404
+ .join(' ');
405
+ }
406
+
407
+ function deriveDisplayName(id: string): string {
408
+ const cleaned = id.replace(/^sherpa-onnx-/, '');
409
+ return toTitleCase(cleaned);
410
+ }
411
+
412
+ function deriveType(id: string): TtsModelType {
413
+ const lower = id.toLowerCase();
414
+ if (lower.includes('vits')) return 'vits';
415
+ if (lower.includes('kokoro')) return 'kokoro';
416
+ if (lower.includes('matcha')) return 'matcha';
417
+ if (lower.includes('kitten')) return 'kitten';
418
+ if (lower.includes('pocket')) return 'pocket';
419
+ if (lower.includes('zipvoice')) return 'zipvoice';
420
+ return 'unknown';
421
+ }
422
+
423
+ function deriveQuantization(id: string): Quantization {
424
+ const lower = id.toLowerCase();
425
+ if (lower.includes('int8') && lower.includes('quant')) {
426
+ return 'int8-quantized';
427
+ }
428
+ if (lower.includes('int8')) return 'int8';
429
+ if (lower.includes('fp16')) return 'fp16';
430
+ return 'unknown';
431
+ }
432
+
433
+ function deriveSizeTier(id: string): SizeTier {
434
+ const lower = id.toLowerCase();
435
+ if (lower.includes('tiny')) return 'tiny';
436
+ if (lower.includes('small')) return 'small';
437
+ if (lower.includes('medium')) return 'medium';
438
+ if (lower.includes('large')) return 'large';
439
+ if (lower.includes('low')) return 'small';
440
+ return 'unknown';
441
+ }
442
+
443
+ function deriveLanguages(id: string): string[] {
444
+ const tokens = id.split(/[-_]+/g);
445
+ const languages = new Set<string>();
446
+ for (const token of tokens) {
447
+ if (/^[a-z]{2}$/.test(token)) {
448
+ languages.add(token);
449
+ continue;
450
+ }
451
+ if (/^[a-z]{2}[A-Z]{2}$/.test(token)) {
452
+ languages.add(token.slice(0, 2).toLowerCase());
453
+ continue;
454
+ }
455
+ if (/^[a-z]{2}-[A-Z]{2}$/.test(token)) {
456
+ languages.add(token.slice(0, 2).toLowerCase());
457
+ }
458
+ }
459
+ return Array.from(languages);
460
+ }
461
+
462
+ function getAssetExtension(name: string): ModelArchiveExt | null {
463
+ if (name.endsWith(MODEL_ARCHIVE_EXT)) return 'tar.bz2';
464
+ if (name.endsWith(MODEL_ONNX_EXT)) return 'onnx';
465
+ return null;
466
+ }
467
+
468
+ function stripAssetExtension(name: string, ext: ModelArchiveExt): string {
469
+ const suffix = `.${ext}`;
470
+ return name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
471
+ }
472
+
473
+ function isAssetSupportedForCategory(
474
+ category: ModelCategory,
475
+ name: string,
476
+ ext: ModelArchiveExt
477
+ ): boolean {
478
+ const lower = name.toLowerCase();
479
+
480
+ switch (category) {
481
+ case ModelCategory.Tts:
482
+ return ext === 'tar.bz2';
483
+ case ModelCategory.Stt:
484
+ return ext === 'tar.bz2' && !lower.includes('vad');
485
+ case ModelCategory.Vad:
486
+ return ext === 'onnx' && lower.includes('vad');
487
+ case ModelCategory.Diarization:
488
+ return ext === 'tar.bz2';
489
+ case ModelCategory.Enhancement:
490
+ return ext === 'onnx';
491
+ case ModelCategory.Separation:
492
+ return ext === 'tar.bz2' || ext === 'onnx';
493
+ default:
494
+ return false;
495
+ }
496
+ }
497
+
498
+ function parseDigestSha256(value?: string | null): string | undefined {
499
+ if (!value) return undefined;
500
+ const match = value.match(/^sha256:([a-f0-9]{64})$/i);
501
+ return match?.[1]?.toLowerCase();
502
+ }
503
+
504
+ function toTtsModelMeta(
505
+ asset: {
506
+ name: string;
507
+ size: number;
508
+ browser_download_url: string;
509
+ digest?: string | null;
510
+ },
511
+ archiveExt: ModelArchiveExt
512
+ ): TtsModelMeta | null {
513
+ if (archiveExt !== 'tar.bz2') {
514
+ return null;
515
+ }
516
+
517
+ const id = stripAssetExtension(asset.name, archiveExt);
518
+ const type = deriveType(id);
519
+ if (type === 'unknown') {
520
+ console.warn('SherpaOnnxModelList: Unsupported model', id);
521
+ }
522
+
523
+ return {
524
+ id,
525
+ displayName: deriveDisplayName(id),
526
+ type,
527
+ languages: deriveLanguages(id),
528
+ quantization: deriveQuantization(id),
529
+ sizeTier: deriveSizeTier(id),
530
+ downloadUrl: asset.browser_download_url,
531
+ archiveExt,
532
+ bytes: asset.size,
533
+ sha256: parseDigestSha256(asset.digest),
534
+ category: ModelCategory.Tts,
535
+ };
536
+ }
537
+
538
+ function toGenericModelMeta(
539
+ category: ModelCategory,
540
+ asset: {
541
+ name: string;
542
+ size: number;
543
+ browser_download_url: string;
544
+ digest?: string | null;
545
+ },
546
+ archiveExt: ModelArchiveExt
547
+ ): ModelMetaBase | null {
548
+ const id = stripAssetExtension(asset.name, archiveExt);
549
+ return {
550
+ id,
551
+ displayName: deriveDisplayName(id),
552
+ downloadUrl: asset.browser_download_url,
553
+ archiveExt,
554
+ bytes: asset.size,
555
+ sha256: parseDigestSha256(asset.digest),
556
+ category,
557
+ };
558
+ }
559
+
560
+ function toModelMeta(
561
+ category: ModelCategory,
562
+ asset: {
563
+ name: string;
564
+ size: number;
565
+ browser_download_url: string;
566
+ digest?: string | null;
567
+ }
568
+ ): ModelMetaBase | null {
569
+ const archiveExt = getAssetExtension(asset.name);
570
+ if (!archiveExt) return null;
571
+ if (!isAssetSupportedForCategory(category, asset.name, archiveExt)) {
572
+ return null;
573
+ }
574
+
575
+ if (category === ModelCategory.Tts) {
576
+ return toTtsModelMeta(asset, archiveExt);
577
+ }
578
+ return toGenericModelMeta(category, asset, archiveExt);
579
+ }
580
+
581
+ async function loadCacheFromDisk<T extends ModelMetaBase>(
582
+ category: ModelCategory
583
+ ): Promise<CachePayload<T> | null> {
584
+ const memoryCache = memoryCacheByCategory[category] as
585
+ | CachePayload<T>
586
+ | undefined;
587
+ if (memoryCache) return memoryCache;
588
+ const cachePath = getCachePath(category);
589
+ const existsResult = await exists(cachePath);
590
+ if (!existsResult) return null;
591
+
592
+ const raw = await readFile(cachePath, 'utf8');
593
+ const parsed = JSON.parse(raw) as CachePayload<T>;
594
+ memoryCacheByCategory[category] = parsed as CachePayload<ModelMetaBase>;
595
+ return parsed;
596
+ }
597
+
598
+ async function saveCache<T extends ModelMetaBase>(
599
+ category: ModelCategory,
600
+ payload: CachePayload<T>
601
+ ): Promise<void> {
602
+ const cacheDir = getCacheDir();
603
+ await mkdir(cacheDir);
604
+ await writeFile(getCachePath(category), JSON.stringify(payload), 'utf8');
605
+ memoryCacheByCategory[category] = payload as CachePayload<ModelMetaBase>;
606
+ }
607
+
608
+ function isCacheFresh<T extends ModelMetaBase>(
609
+ payload: CachePayload<T>,
610
+ ttlMinutes: number
611
+ ): boolean {
612
+ const updated = new Date(payload.lastUpdated).getTime();
613
+ if (!updated) return false;
614
+ const ageMs = Date.now() - updated;
615
+ return ageMs < ttlMinutes * 60 * 1000;
616
+ }
617
+
618
+ export async function listModelsByCategory<T extends ModelMetaBase>(
619
+ category: ModelCategory
620
+ ): Promise<T[]> {
621
+ const cache = await loadCacheFromDisk<T>(category);
622
+ return cache?.models ?? [];
623
+ }
624
+
625
+ export async function refreshModelsByCategory<T extends ModelMetaBase>(
626
+ category: ModelCategory,
627
+ options?: {
628
+ forceRefresh?: boolean;
629
+ cacheTtlMinutes?: number;
630
+ maxRetries?: number;
631
+ signal?: AbortSignal;
632
+ }
633
+ ): Promise<T[]> {
634
+ const ttl = options?.cacheTtlMinutes ?? CACHE_TTL_MINUTES;
635
+ const cached = await loadCacheFromDisk<T>(category);
636
+
637
+ if (!options?.forceRefresh && cached && isCacheFresh(cached, ttl)) {
638
+ return cached.models;
639
+ }
640
+
641
+ try {
642
+ const body = await retryWithBackoff(
643
+ async () => {
644
+ const response = await fetch(getReleaseUrl(category));
645
+ if (!response.ok) {
646
+ throw new Error(`Failed to fetch models: ${response.status}`);
647
+ }
648
+ return response.json();
649
+ },
650
+ {
651
+ maxRetries: options?.maxRetries ?? 3,
652
+ initialDelayMs: 1000,
653
+ signal: options?.signal,
654
+ }
655
+ );
656
+
657
+ const assets = Array.isArray(body?.assets) ? body.assets : [];
658
+ const models: T[] = assets
659
+ .map((asset: any) =>
660
+ toModelMeta(category, {
661
+ name: asset.name,
662
+ size: asset.size,
663
+ browser_download_url: asset.browser_download_url,
664
+ digest: asset.digest,
665
+ })
666
+ )
667
+ .filter((model: ModelMetaBase | null): model is T => Boolean(model));
668
+
669
+ // Load and attach SHA256 checksums from checksum.txt
670
+ const checksums = await fetchChecksumsFromRelease(category);
671
+ for (const model of models) {
672
+ const archiveFilename = getArchiveFilename(model.id, model.archiveExt);
673
+ const sha256 = checksums.get(archiveFilename);
674
+ if (sha256) {
675
+ model.sha256 = sha256;
676
+ } else if (model.sha256) {
677
+ model.sha256 = model.sha256.toLowerCase();
678
+ }
679
+ }
680
+
681
+ const payload: CachePayload<T> = {
682
+ lastUpdated: new Date().toISOString(),
683
+ models,
684
+ };
685
+ await saveCache(category, payload);
686
+ emitModelsListUpdated(category, models as ModelMetaBase[]);
687
+ return models;
688
+ } catch (error) {
689
+ // If retry failed and we have cached data, return it as fallback
690
+ if (cached) {
691
+ console.warn(
692
+ `Failed to refresh models for ${category}, using cached data:`,
693
+ error
694
+ );
695
+ return cached.models;
696
+ }
697
+ throw error;
698
+ }
699
+ }
700
+
701
+ export async function getModelsCacheStatusByCategory(
702
+ category: ModelCategory
703
+ ): Promise<CacheStatus> {
704
+ const cached = await loadCacheFromDisk(category);
705
+ if (!cached) {
706
+ return { lastUpdated: null, source: 'cache' };
707
+ }
708
+ return { lastUpdated: cached.lastUpdated, source: 'cache' };
709
+ }
710
+
711
+ export async function getModelByIdByCategory<T extends ModelMetaBase>(
712
+ category: ModelCategory,
713
+ id: string
714
+ ): Promise<T | null> {
715
+ const models = await listModelsByCategory<T>(category);
716
+ return models.find((model) => model.id === id) ?? null;
717
+ }
718
+
719
+ export async function listDownloadedModelsByCategory<T extends ModelMetaBase>(
720
+ category: ModelCategory
721
+ ): Promise<T[]> {
722
+ const baseDir = getModelsBaseDir(category);
723
+ const existsResult = await exists(baseDir);
724
+ if (!existsResult) return [];
725
+
726
+ const entries = await readDir(baseDir);
727
+ const models: T[] = [];
728
+
729
+ for (const entry of entries) {
730
+ if (!entry.isDirectory()) continue;
731
+ const manifestPath = getManifestPath(category, entry.name);
732
+ const manifestExists = await exists(manifestPath);
733
+ // Only list models that have a manifest (download + extraction fully complete).
734
+ // Directories without manifest are still being extracted and must not appear in the list.
735
+ if (!manifestExists) continue;
736
+ try {
737
+ const raw = await readFile(manifestPath, 'utf8');
738
+ const manifest = JSON.parse(raw) as ModelManifest<T>;
739
+ if (manifest.model) {
740
+ models.push(manifest.model);
741
+ }
742
+ } catch {
743
+ // ignore invalid manifest
744
+ }
745
+ }
746
+
747
+ return models;
748
+ }
749
+
750
+ export async function isModelDownloadedByCategory(
751
+ category: ModelCategory,
752
+ id: string
753
+ ): Promise<boolean> {
754
+ const readyPath = getReadyMarkerPath(category, id);
755
+ return exists(readyPath);
756
+ }
757
+
758
+ export async function getLocalModelPathByCategory(
759
+ category: ModelCategory,
760
+ id: string
761
+ ): Promise<string | null> {
762
+ const ready = await isModelDownloadedByCategory(category, id);
763
+ if (!ready) return null;
764
+
765
+ // Update lastUsed timestamp when model is accessed
766
+ await updateModelLastUsed(category, id);
767
+
768
+ return getModelDir(category, id);
769
+ }
770
+
771
+ export async function downloadModelByCategory<T extends ModelMetaBase>(
772
+ category: ModelCategory,
773
+ id: string,
774
+ opts?: {
775
+ onProgress?: (progress: DownloadProgress) => void;
776
+ overwrite?: boolean;
777
+ signal?: AbortSignal;
778
+ maxRetries?: number;
779
+ onChecksumIssue?: (issue: ChecksumIssue) => Promise<boolean>;
780
+ }
781
+ ): Promise<DownloadResult> {
782
+ const isAborted = () => Boolean(opts?.signal?.aborted);
783
+
784
+ if (opts?.signal?.aborted) {
785
+ const abortError = new Error('Download aborted');
786
+ abortError.name = 'AbortError';
787
+ throw abortError;
788
+ }
789
+
790
+ const model = await getModelByIdByCategory<T>(category, id);
791
+ if (!model) {
792
+ throw new Error(`Unknown model id: ${id}`);
793
+ }
794
+
795
+ const baseDir = getModelsBaseDir(category);
796
+ await mkdir(baseDir);
797
+
798
+ const downloadPath = getArchivePath(category, id, model.archiveExt);
799
+ const isArchive = model.archiveExt === 'tar.bz2';
800
+ const modelDir = getModelDir(category, id);
801
+
802
+ const sleep = (ms: number) =>
803
+ new Promise<void>((resolve) => {
804
+ setTimeout(resolve, ms);
805
+ });
806
+
807
+ const cleanupPartial = async () => {
808
+ if (!isArchive) {
809
+ return;
810
+ }
811
+
812
+ // Only clean up extracted model dir, preserve archive for download resume
813
+ if (await exists(modelDir)) {
814
+ await unlink(modelDir);
815
+ }
816
+ };
817
+
818
+ const cleanupPartialWithRetry = async () => {
819
+ for (let attempt = 0; attempt < 4; attempt += 1) {
820
+ await cleanupPartial();
821
+ if (!(await exists(modelDir))) {
822
+ return;
823
+ }
824
+ await sleep(400);
825
+ }
826
+
827
+ if (await exists(modelDir)) {
828
+ console.warn(
829
+ `Model cleanup after abort did not fully complete for ${category}:${id}`
830
+ );
831
+ }
832
+ };
833
+
834
+ // Step 1: Check available disk space
835
+ const diskSpaceCheck = await checkDiskSpace(model.bytes);
836
+ if (!diskSpaceCheck.success) {
837
+ throw new Error(`Insufficient disk space: ${diskSpaceCheck.message}`);
838
+ }
839
+
840
+ if (opts?.overwrite) {
841
+ if (await exists(modelDir)) {
842
+ await unlink(modelDir);
843
+ }
844
+ if (await exists(downloadPath)) {
845
+ await unlink(downloadPath);
846
+ }
847
+ } else {
848
+ // Clean up incomplete extractions but preserve partial downloads for resume
849
+ const readyMarkerExists = await exists(getReadyMarkerPath(category, id));
850
+ if (!readyMarkerExists) {
851
+ if (isArchive) {
852
+ // No ready marker found; only clean up extracted model dir
853
+ // Keep archive file to support download resume
854
+ if (await exists(modelDir)) {
855
+ // Removing partial model dir
856
+ await unlink(modelDir);
857
+ }
858
+ }
859
+ }
860
+ }
861
+
862
+ try {
863
+ // Step 2: Download archive or onnx file (with resume support)
864
+ if (!isArchive) {
865
+ await mkdir(modelDir);
866
+ }
867
+
868
+ const archiveExists = await exists(downloadPath);
869
+ let partialDownload = false;
870
+
871
+ if (archiveExists) {
872
+ // Check if this is a complete download or partial
873
+ const statResult = await stat(downloadPath);
874
+ const currentSize = statResult.size;
875
+ if (currentSize < model.bytes) {
876
+ partialDownload = true;
877
+ console.log(
878
+ `[Download] Resuming partial download for ${category}:${id} (${currentSize}/${model.bytes} bytes)`
879
+ );
880
+ }
881
+ }
882
+
883
+ if (!archiveExists || partialDownload) {
884
+ const maxRetries = opts?.maxRetries ?? 2;
885
+
886
+ await retryWithBackoff(
887
+ async () => {
888
+ const downloadStartTime = Date.now();
889
+
890
+ const download = downloadFile({
891
+ fromUrl: model.downloadUrl,
892
+ toFile: downloadPath,
893
+ progressDivider: 1,
894
+ resumable: () => {
895
+ // iOS only: Called when download is resumed
896
+ console.log(`[Download] Resuming download for ${category}:${id}`);
897
+ },
898
+ progress: (data) => {
899
+ if (isAborted()) {
900
+ return;
901
+ }
902
+ const total = data.contentLength || model.bytes || 0;
903
+ const percent = total > 0 ? (data.bytesWritten / total) * 100 : 0;
904
+
905
+ // Calculate speed and ETA
906
+ const now = Date.now();
907
+ const elapsedSeconds = (now - downloadStartTime) / 1000;
908
+
909
+ let speed: number | undefined;
910
+ let eta: number | undefined;
911
+
912
+ if (elapsedSeconds > 0.5) {
913
+ // Calculate overall speed (bytes per second)
914
+ speed = data.bytesWritten / elapsedSeconds;
915
+
916
+ // Calculate ETA based on current speed
917
+ const remainingBytes = total - data.bytesWritten;
918
+ if (speed > 0) {
919
+ eta = remainingBytes / speed;
920
+ }
921
+ }
922
+
923
+ const progress: DownloadProgress = {
924
+ bytesDownloaded: data.bytesWritten,
925
+ totalBytes: total,
926
+ percent,
927
+ phase: 'downloading',
928
+ speed,
929
+ eta,
930
+ };
931
+ opts?.onProgress?.(progress);
932
+ emitDownloadProgress(category, id, progress);
933
+ },
934
+ });
935
+
936
+ let downloadFinished = false;
937
+ let aborted = false;
938
+ const onAbort = () => {
939
+ aborted = true;
940
+ if (downloadFinished) return;
941
+ try {
942
+ stopDownload(download.jobId);
943
+ } catch {
944
+ // Swallow stop errors to avoid crashing the app on cancel.
945
+ }
946
+ };
947
+
948
+ if (opts?.signal) {
949
+ opts.signal.addEventListener('abort', onAbort);
950
+ }
951
+
952
+ let result: any;
953
+ try {
954
+ result = await download.promise;
955
+ } finally {
956
+ downloadFinished = true;
957
+ if (opts?.signal) {
958
+ opts.signal.removeEventListener('abort', onAbort);
959
+ }
960
+ }
961
+ if (aborted || opts?.signal?.aborted) {
962
+ const abortError = new Error('Download aborted');
963
+ abortError.name = 'AbortError';
964
+ throw abortError;
965
+ }
966
+ if (result.statusCode && result.statusCode >= 400) {
967
+ // For certain errors, delete partial download as resume won't help
968
+ const isNonResumableError =
969
+ result.statusCode === 404 || // Not found
970
+ result.statusCode === 410 || // Gone
971
+ result.statusCode === 451 || // Unavailable for legal reasons
972
+ result.statusCode === 416; // Range not satisfiable
973
+ if (isNonResumableError && (await exists(downloadPath))) {
974
+ console.warn(
975
+ `[Download] Non-resumable error ${result.statusCode}, removing partial download`
976
+ );
977
+ await unlink(downloadPath);
978
+ }
979
+ throw new Error(`Download failed: ${result.statusCode}`);
980
+ }
981
+ },
982
+ {
983
+ maxRetries,
984
+ initialDelayMs: 2000,
985
+ signal: opts?.signal,
986
+ }
987
+ );
988
+ }
989
+
990
+ if (opts?.signal?.aborted) {
991
+ const abortError = new Error('Download aborted');
992
+ abortError.name = 'AbortError';
993
+ throw abortError;
994
+ }
995
+
996
+ let extractResult: { sha256?: string } | null = null;
997
+ /** Total uncompressed bytes from libarchive progress; used for manifest.sizeOnDisk. */
998
+ let extractedTotalBytes = 0;
999
+
1000
+ if (isArchive) {
1001
+ await mkdir(modelDir);
1002
+ const extractStartTime = Date.now();
1003
+ extractResult = await extractTarBz2(
1004
+ downloadPath,
1005
+ modelDir,
1006
+ true,
1007
+ (evt) => {
1008
+ if (isAborted()) {
1009
+ return;
1010
+ }
1011
+ if (evt.totalBytes > 0) extractedTotalBytes = evt.totalBytes;
1012
+ if (model.bytes > 0) {
1013
+ // Calculate extraction speed and ETA
1014
+ const now = Date.now();
1015
+ const elapsedSeconds = (now - extractStartTime) / 1000;
1016
+
1017
+ let speed: number | undefined;
1018
+ let eta: number | undefined;
1019
+
1020
+ if (elapsedSeconds > 0.5) {
1021
+ speed = evt.bytes / elapsedSeconds;
1022
+ const remainingBytes = evt.totalBytes - evt.bytes;
1023
+ if (speed > 0) {
1024
+ eta = remainingBytes / speed;
1025
+ }
1026
+ }
1027
+
1028
+ const progress: DownloadProgress = {
1029
+ bytesDownloaded: evt.bytes,
1030
+ totalBytes: evt.totalBytes,
1031
+ percent: evt.percent,
1032
+ phase: 'extracting',
1033
+ speed,
1034
+ eta,
1035
+ };
1036
+ opts?.onProgress?.(progress);
1037
+ emitDownloadProgress(category, id, progress);
1038
+ }
1039
+ },
1040
+ opts?.signal
1041
+ );
1042
+ }
1043
+
1044
+ // Step 3: Validate checksum if available
1045
+ if (model.sha256) {
1046
+ const expectedSha = model.sha256.toLowerCase();
1047
+ let issue: ChecksumIssue | null = null;
1048
+
1049
+ if (isArchive) {
1050
+ const nativeSha = extractResult?.sha256?.toLowerCase();
1051
+ if (!nativeSha) {
1052
+ issue = {
1053
+ category,
1054
+ id,
1055
+ archivePath: downloadPath,
1056
+ expected: model.sha256,
1057
+ message: 'Native SHA-256 not available after extraction.',
1058
+ reason: 'CHECKSUM_FAILED',
1059
+ };
1060
+ } else if (nativeSha !== expectedSha) {
1061
+ issue = {
1062
+ category,
1063
+ id,
1064
+ archivePath: downloadPath,
1065
+ expected: model.sha256,
1066
+ message: `Checksum mismatch: expected ${model.sha256}, got ${extractResult?.sha256}`,
1067
+ reason: 'CHECKSUM_MISMATCH',
1068
+ };
1069
+ }
1070
+ } else {
1071
+ const checksumResult = await validateChecksum(
1072
+ downloadPath,
1073
+ expectedSha
1074
+ );
1075
+ if (!checksumResult.success) {
1076
+ issue = {
1077
+ category,
1078
+ id,
1079
+ archivePath: downloadPath,
1080
+ expected: model.sha256,
1081
+ message: checksumResult.message ?? 'Checksum validation failed.',
1082
+ reason:
1083
+ checksumResult.error === 'CHECKSUM_MISMATCH'
1084
+ ? 'CHECKSUM_MISMATCH'
1085
+ : 'CHECKSUM_FAILED',
1086
+ };
1087
+ }
1088
+ }
1089
+
1090
+ if (issue) {
1091
+ const keepFile = opts?.onChecksumIssue
1092
+ ? await opts.onChecksumIssue(issue)
1093
+ : await promptChecksumFallback(issue);
1094
+
1095
+ if (!keepFile) {
1096
+ if (await exists(modelDir)) {
1097
+ await unlink(modelDir);
1098
+ }
1099
+ if (await exists(downloadPath)) {
1100
+ await unlink(downloadPath);
1101
+ }
1102
+ throw new Error(`Checksum validation failed: ${issue.message}`);
1103
+ }
1104
+ }
1105
+ }
1106
+
1107
+ if (opts?.signal?.aborted) {
1108
+ const abortError = new Error('Download aborted');
1109
+ abortError.name = 'AbortError';
1110
+ throw abortError;
1111
+ }
1112
+
1113
+ // Step 4: Validate extracted files exist
1114
+ const filesValidation = await validateExtractedFiles(modelDir, category);
1115
+ if (!filesValidation.success) {
1116
+ // Clean up failed extraction
1117
+ await unlink(modelDir);
1118
+ throw new Error(
1119
+ `Extracted files validation failed: ${filesValidation.message}`
1120
+ );
1121
+ }
1122
+
1123
+ await writeFile(getReadyMarkerPath(category, id), 'ready', 'utf8');
1124
+ const now = new Date().toISOString();
1125
+ let sizeOnDisk: number | undefined;
1126
+ if (isArchive && extractedTotalBytes > 0) {
1127
+ sizeOnDisk = extractedTotalBytes;
1128
+ } else if (!isArchive) {
1129
+ try {
1130
+ const statResult = await stat(downloadPath);
1131
+ sizeOnDisk = statResult.size;
1132
+ } catch {
1133
+ // ignore
1134
+ }
1135
+ }
1136
+ await writeFile(
1137
+ getManifestPath(category, id),
1138
+ JSON.stringify({
1139
+ downloadedAt: now,
1140
+ lastUsed: now,
1141
+ model,
1142
+ sizeOnDisk,
1143
+ } as ModelManifest),
1144
+ 'utf8'
1145
+ );
1146
+
1147
+ // Notify subscribers (e.g. STT/TTS screens) so the model list updates without leaving the screen.
1148
+ const list = await listDownloadedModelsByCategory<ModelMetaBase>(category);
1149
+ emitModelsListUpdated(category, list);
1150
+
1151
+ return { modelId: id, localPath: modelDir };
1152
+ } catch (err) {
1153
+ if ((err instanceof Error && err.name === 'AbortError') || isAborted()) {
1154
+ await cleanupPartialWithRetry();
1155
+ }
1156
+ throw err;
1157
+ }
1158
+ }
1159
+
1160
+ /**
1161
+ * Update the lastUsed timestamp for a downloaded model
1162
+ */
1163
+ export async function updateModelLastUsed(
1164
+ category: ModelCategory,
1165
+ id: string
1166
+ ): Promise<void> {
1167
+ const manifestPath = getManifestPath(category, id);
1168
+ const existsResult = await exists(manifestPath);
1169
+ if (!existsResult) return;
1170
+
1171
+ try {
1172
+ const raw = await readFile(manifestPath, 'utf8');
1173
+ const manifest = JSON.parse(raw) as ModelManifest;
1174
+ manifest.lastUsed = new Date().toISOString();
1175
+ await writeFile(manifestPath, JSON.stringify(manifest), 'utf8');
1176
+ } catch (error) {
1177
+ console.warn(`Failed to update lastUsed for ${category}:${id}:`, error);
1178
+ }
1179
+ }
1180
+
1181
+ /**
1182
+ * Get all downloaded models with LRU metadata
1183
+ */
1184
+ export async function listDownloadedModelsWithMetadata<T extends ModelMetaBase>(
1185
+ category: ModelCategory
1186
+ ): Promise<ModelWithMetadata<T>[]> {
1187
+ const baseDir = getModelsBaseDir(category);
1188
+ const existsResult = await exists(baseDir);
1189
+ if (!existsResult) return [];
1190
+
1191
+ const entries = await readDir(baseDir);
1192
+ const modelsWithMetadata: ModelWithMetadata<T>[] = [];
1193
+
1194
+ for (const entry of entries) {
1195
+ if (!entry.isDirectory()) continue;
1196
+
1197
+ const manifestPath = getManifestPath(category, entry.name);
1198
+ const manifestExists = await exists(manifestPath);
1199
+
1200
+ if (manifestExists) {
1201
+ try {
1202
+ const raw = await readFile(manifestPath, 'utf8');
1203
+ const manifest = JSON.parse(raw) as ModelManifest<T>;
1204
+ if (manifest.model) {
1205
+ modelsWithMetadata.push({
1206
+ model: manifest.model,
1207
+ downloadedAt: manifest.downloadedAt,
1208
+ lastUsed: manifest.lastUsed ?? null,
1209
+ sizeOnDisk: manifest.sizeOnDisk ?? entry.size,
1210
+ });
1211
+ }
1212
+ } catch (error) {
1213
+ console.warn(
1214
+ `Failed to read manifest for ${category}:${entry.name}:`,
1215
+ error
1216
+ );
1217
+ }
1218
+ }
1219
+ }
1220
+
1221
+ return modelsWithMetadata;
1222
+ }
1223
+
1224
+ /**
1225
+ * Remove least recently used models to free up disk space
1226
+ * @param category - Model category
1227
+ * @param targetBytes - Target amount of bytes to free (optional)
1228
+ * @param maxModelsToDelete - Maximum number of models to delete (default: no limit)
1229
+ * @returns Array of deleted model IDs
1230
+ */
1231
+ export async function cleanupLeastRecentlyUsed(
1232
+ category: ModelCategory,
1233
+ options?: {
1234
+ targetBytes?: number;
1235
+ maxModelsToDelete?: number;
1236
+ keepCount?: number;
1237
+ }
1238
+ ): Promise<string[]> {
1239
+ const modelsWithMetadata = await listDownloadedModelsWithMetadata(category);
1240
+
1241
+ if (modelsWithMetadata.length === 0) {
1242
+ return [];
1243
+ }
1244
+
1245
+ // Keep at least this many models
1246
+ const keepCount = options?.keepCount ?? 1;
1247
+ if (modelsWithMetadata.length <= keepCount) {
1248
+ return [];
1249
+ }
1250
+
1251
+ // Sort by lastUsed (oldest first), then by downloadedAt if no lastUsed
1252
+ const sorted = modelsWithMetadata.sort((a, b) => {
1253
+ const aTime = a.lastUsed ?? a.downloadedAt;
1254
+ const bTime = b.lastUsed ?? b.downloadedAt;
1255
+ return new Date(aTime).getTime() - new Date(bTime).getTime();
1256
+ });
1257
+
1258
+ const deletedIds: string[] = [];
1259
+ let bytesFreed = 0;
1260
+ const targetBytes = options?.targetBytes ?? 0;
1261
+ const maxToDelete = options?.maxModelsToDelete ?? sorted.length - keepCount;
1262
+
1263
+ for (let i = 0; i < sorted.length - keepCount && i < maxToDelete; i++) {
1264
+ const item = sorted[i];
1265
+ if (!item) continue;
1266
+
1267
+ try {
1268
+ await deleteModelByCategory(category, item.model.id);
1269
+ deletedIds.push(item.model.id);
1270
+ bytesFreed += item.sizeOnDisk ?? 0;
1271
+
1272
+ console.log(
1273
+ `[LRU Cleanup] Deleted ${category}:${item.model.id} (freed ${
1274
+ (item.sizeOnDisk ?? 0) / 1024 / 1024
1275
+ } MB)`
1276
+ );
1277
+
1278
+ if (targetBytes > 0 && bytesFreed >= targetBytes) {
1279
+ break;
1280
+ }
1281
+ } catch (error) {
1282
+ console.warn(
1283
+ `[LRU Cleanup] Failed to delete ${category}:${item.model.id}:`,
1284
+ error
1285
+ );
1286
+ }
1287
+ }
1288
+
1289
+ return deletedIds;
1290
+ }
1291
+
1292
+ export async function deleteModelByCategory(
1293
+ category: ModelCategory,
1294
+ id: string
1295
+ ): Promise<void> {
1296
+ const modelDir = getModelDir(category, id);
1297
+ const tarPath = getTarArchivePath(category, id);
1298
+ const onnxPath = getOnnxPath(category, id);
1299
+ if (await exists(modelDir)) {
1300
+ await unlink(modelDir);
1301
+ }
1302
+ if (await exists(tarPath)) {
1303
+ await unlink(tarPath);
1304
+ }
1305
+ if (await exists(onnxPath)) {
1306
+ await unlink(onnxPath);
1307
+ }
1308
+ }
1309
+
1310
+ export async function clearModelCacheByCategory(
1311
+ category: ModelCategory
1312
+ ): Promise<void> {
1313
+ const cachePath = getCachePath(category);
1314
+ if (await exists(cachePath)) {
1315
+ await unlink(cachePath);
1316
+ }
1317
+ delete memoryCacheByCategory[category];
1318
+ }
1319
+
1320
+ export async function getDownloadStorageBase(): Promise<string> {
1321
+ if (Platform.OS === 'ios') {
1322
+ return DocumentDirectoryPath;
1323
+ }
1324
+ return DocumentDirectoryPath;
1325
+ }