react-native-sherpa-onnx 0.3.6 → 0.3.8

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