react-native-tts-kit 0.1.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 (64) hide show
  1. package/ATTRIBUTIONS.md +87 -0
  2. package/LICENSE +21 -0
  3. package/README.md +231 -0
  4. package/android/build.gradle +50 -0
  5. package/android/src/main/AndroidManifest.xml +3 -0
  6. package/android/src/main/java/expo/modules/ttskit/RNTTSKitModule.kt +158 -0
  7. package/android/src/main/java/expo/modules/ttskit/supertonic/AudioEngine.kt +158 -0
  8. package/android/src/main/java/expo/modules/ttskit/supertonic/ModelLocator.kt +372 -0
  9. package/android/src/main/java/expo/modules/ttskit/supertonic/SupertonicSession.kt +373 -0
  10. package/android/src/main/java/expo/modules/ttskit/supertonic/TextFrontend.kt +154 -0
  11. package/android/src/main/java/expo/modules/ttskit/supertonic/VoicePack.kt +47 -0
  12. package/build/engines/BufferedStreamEmitter.d.ts +26 -0
  13. package/build/engines/BufferedStreamEmitter.d.ts.map +1 -0
  14. package/build/engines/BufferedStreamEmitter.js +68 -0
  15. package/build/engines/BufferedStreamEmitter.js.map +1 -0
  16. package/build/engines/Engine.d.ts +15 -0
  17. package/build/engines/Engine.d.ts.map +1 -0
  18. package/build/engines/Engine.js +2 -0
  19. package/build/engines/Engine.js.map +1 -0
  20. package/build/engines/SupertonicEngine.d.ts +14 -0
  21. package/build/engines/SupertonicEngine.d.ts.map +1 -0
  22. package/build/engines/SupertonicEngine.js +183 -0
  23. package/build/engines/SupertonicEngine.js.map +1 -0
  24. package/build/engines/SystemEngine.d.ts +13 -0
  25. package/build/engines/SystemEngine.d.ts.map +1 -0
  26. package/build/engines/SystemEngine.js +78 -0
  27. package/build/engines/SystemEngine.js.map +1 -0
  28. package/build/index.d.ts +46 -0
  29. package/build/index.d.ts.map +1 -0
  30. package/build/index.js +118 -0
  31. package/build/index.js.map +1 -0
  32. package/build/types.d.ts +77 -0
  33. package/build/types.d.ts.map +1 -0
  34. package/build/types.js +2 -0
  35. package/build/types.js.map +1 -0
  36. package/build/voices/catalog.d.ts +12 -0
  37. package/build/voices/catalog.d.ts.map +1 -0
  38. package/build/voices/catalog.js +28 -0
  39. package/build/voices/catalog.js.map +1 -0
  40. package/build/voices/prosody.d.ts +8 -0
  41. package/build/voices/prosody.d.ts.map +1 -0
  42. package/build/voices/prosody.js +28 -0
  43. package/build/voices/prosody.js.map +1 -0
  44. package/expo-module.config.json +9 -0
  45. package/ios/RNTTSKit.podspec +28 -0
  46. package/ios/RNTTSKitModule.swift +133 -0
  47. package/ios/Supertonic/AudioEngine.swift +110 -0
  48. package/ios/Supertonic/ModelLocator.swift +416 -0
  49. package/ios/Supertonic/SupertonicSession.swift +405 -0
  50. package/ios/Supertonic/TextFrontend.swift +216 -0
  51. package/ios/Supertonic/VoicePack.swift +51 -0
  52. package/licenses/OpenRAIL-M.txt +209 -0
  53. package/package.json +77 -0
  54. package/src/engines/BufferedStreamEmitter.ts +50 -0
  55. package/src/engines/Engine.ts +28 -0
  56. package/src/engines/SupertonicEngine.ts +250 -0
  57. package/src/engines/SystemEngine.ts +96 -0
  58. package/src/engines/__tests__/BufferedStreamEmitter.test.ts +65 -0
  59. package/src/index.ts +156 -0
  60. package/src/types.ts +95 -0
  61. package/src/voices/__tests__/catalog.test.ts +46 -0
  62. package/src/voices/__tests__/prosody.test.ts +63 -0
  63. package/src/voices/catalog.ts +32 -0
  64. package/src/voices/prosody.ts +39 -0
@@ -0,0 +1,416 @@
1
+ import CryptoKit
2
+ import Foundation
3
+
4
+ struct PrefetchProgressInfo {
5
+ let bytesDownloaded: Int64
6
+ let totalBytes: Int64
7
+ var percent: Double {
8
+ guard totalBytes > 0 else { return 0 }
9
+ return Double(bytesDownloaded) / Double(totalBytes) * 100.0
10
+ }
11
+ }
12
+
13
+ /// Resolves and downloads the Supertonic asset bundle.
14
+ ///
15
+ /// Upstream layout (https://huggingface.co/Supertone/supertonic-3,
16
+ /// `opensource-multilingual` split — 31 languages):
17
+ /// onnx/
18
+ /// duration_predictor.onnx (3.7 MB)
19
+ /// text_encoder.onnx (36 MB)
20
+ /// vector_estimator.onnx (257 MB — cross-lingual weights, the bulk)
21
+ /// vocoder.onnx (101 MB)
22
+ /// tts.json (8 KB config)
23
+ /// unicode_indexer.json (~280 KB codepoint -> token id map)
24
+ /// voice_styles/
25
+ /// M1.json M2.json … F5.json (~290 KB each, 10 voices)
26
+ /// Grand total: ~401 MB on the wire (~382 MiB).
27
+ ///
28
+ /// We mirror that layout under Application Support/RNTTSKit/Supertonic/.
29
+ /// Pinning to a specific commit SHA so model updates can't silently break us.
30
+ enum ModelLocator {
31
+ /// Weight precision tier. fp16 is a smaller download but only lives on
32
+ /// the ahk-d mirror — the upstream Supertone repo ships fp32 only. See
33
+ /// `tools/quantize.md` for how the fp16 files are produced and validated.
34
+ /// ONNX graph I/O is float32 for both tiers (fp16 uses keep_io_types),
35
+ /// so SupertonicSession.swift does not need to change between them.
36
+ ///
37
+ /// Int8 was evaluated and dropped: MatMul-only int8 (required to avoid
38
+ /// ConvInteger ops the iOS CPU EP refuses) produced ~94%-of-fp32 sizes
39
+ /// AND −1 dB SNR vs fp32 — unusable. Not worth a separate tier.
40
+ enum Precision: String {
41
+ case fp32, fp16
42
+ /// Relative path under the mirror for this tier's ONNX files.
43
+ var onnxSubdir: String {
44
+ switch self {
45
+ case .fp32: return "onnx"
46
+ case .fp16: return "onnx-fp16"
47
+ }
48
+ }
49
+ /// Whether the upstream `Supertone/supertonic-3` repo also serves
50
+ /// this tier. Only fp32 is upstream; fp16 is mirror-only.
51
+ var hasUpstreamFallback: Bool { self == .fp32 }
52
+ }
53
+
54
+ /// Default tier shipped to users. Flip to `.fp16` after on-device
55
+ /// audio regression passes (benchmarks/golden.json).
56
+ static let precision: Precision = .fp16
57
+
58
+ /// Mirror sources, tried in order. We host a pinned mirror of the
59
+ /// Supertonic-3 multilingual weights (`opensource-multilingual` split, 31
60
+ /// languages) so that:
61
+ /// - Upstream availability changes (deletes, renames, paywall) don't
62
+ /// break installed copies of this package.
63
+ /// - We control when consumers see new model versions; an unpinned
64
+ /// `main` would let surprise upstream pushes change behavior.
65
+ ///
66
+ /// Both entries are pinned to commit SHAs. The fallback is the official
67
+ /// Supertone repo at the *same* logical version — if the mirror is down
68
+ /// we still want to serve v3, never v2 or v1 (English-only).
69
+ ///
70
+ /// `MIRROR_REVISION` and `UPSTREAM_REVISION` happen to be different
71
+ /// commits because each repo has its own commit history, but the file
72
+ /// contents at these revisions are byte-identical at the fp32 tier.
73
+ static let mirrorRevision = "4cb89eb91e92e9a92b60cac890b464f55a5d0064"
74
+ static let upstreamRevision = "724fb5abbf5502583fb520898d45929e62f02c0b"
75
+
76
+ /// Per-tier URL list. fp32 falls back to upstream; quantized tiers are
77
+ /// mirror-only because upstream does not host them.
78
+ static var baseURLs: [String] {
79
+ var urls = ["https://huggingface.co/ahk-d/supertonic-3/resolve/\(mirrorRevision)"]
80
+ if precision.hasUpstreamFallback {
81
+ urls.append("https://huggingface.co/Supertone/supertonic-3/resolve/\(upstreamRevision)")
82
+ }
83
+ return urls
84
+ }
85
+
86
+ static let onnxFiles = [
87
+ "duration_predictor.onnx",
88
+ "text_encoder.onnx",
89
+ "vector_estimator.onnx",
90
+ "vocoder.onnx",
91
+ "tts.json",
92
+ "unicode_indexer.json"
93
+ ]
94
+
95
+ static let voiceIds = ["M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5"]
96
+
97
+ /// SHA-256 fingerprints of every shipped file at the pinned mirror commit.
98
+ ///
99
+ /// `download()` verifies each file post-download and rejects the
100
+ /// mirror+fallback pair if both serve corrupted or substituted bytes.
101
+ /// To regenerate when bumping mirrorRevision/upstreamRevision: run
102
+ /// `tools/fingerprint.sh` and paste output here. Cross-checked against
103
+ /// upstream — values are byte-identical between the two repos.
104
+ static let expectedHashes: [String: String] = [
105
+ "onnx/duration_predictor.onnx": "c3eb91414d5ff8a7a239b7fe9e34e7e2bf8a8140d8375ffb14718b1c639325db",
106
+ "onnx/text_encoder.onnx": "c7befd5ea8c3119769e8a6c1486c4edc6a3bc8365c67621c881bbb774b9902ff",
107
+ "onnx/vector_estimator.onnx": "883ac868ea0275ef0e991524dc64f16b3c0376efd7c320af6b53f5b780d7c61c",
108
+ "onnx/vocoder.onnx": "085de76dd8e8d5836d6ca66826601f615939218f90e519f70ee8a36ed2a4c4ba",
109
+ "onnx/tts.json": "42078d3aef1cd43ab43021f3c54f47d2d75ceb4e75f627f118890128b06a0d09",
110
+ "onnx/unicode_indexer.json": "9bf7346e43883a81f8645c81224f786d43c5b57f3641f6e7671a7d6c493cb24f",
111
+ "voice_styles/F1.json": "bbdec6ee00231c2c742ad05483df5334cab3b52fda3ba38e6a07059c4563dbc2",
112
+ "voice_styles/F2.json": "7c722c6a72707b1a77f035d67f0d1351ba187738e06f7683e8c72b1df3477fc6",
113
+ "voice_styles/F3.json": "12f6ef2573baa2defa1128069cb59f203e3ab67c92af77b42df8a0e3a2f7c6ab",
114
+ "voice_styles/F4.json": "c2fa764c1225a76dfc3e2c73e8aa4f70d9ee48793860eb34c295fff01c2e032b",
115
+ "voice_styles/F5.json": "45966e73316415626cf41a7d1c6f3b4c70dbc1ba2bee5c1978ef0ce33244fc8d",
116
+ "voice_styles/M1.json": "e35604687f5d23694b8e91593a93eec0e4eca6c0b02bb8ed69139ab2ea6b0a5b",
117
+ "voice_styles/M2.json": "b76cbf62bac707c710cf0ae5aba5e31eea1a6339a9734bfae33ab98499534a50",
118
+ "voice_styles/M3.json": "ea1ac35ccb91b0d7ecad533a2fbd0eec10c91513d8951e3b25fbba99954e159b",
119
+ "voice_styles/M4.json": "ca8eefad4fcd989c9379032ff3e50738adc547eeb5e221b82593a6d7b3bac303",
120
+ "voice_styles/M5.json": "dd22b92740314321f8ae11c5e87f8dd60d060f15dd3a632b5adf77f471f77af2",
121
+
122
+ // fp16 weights — produced by tools/quantize_colab.ipynb.
123
+ // Attention sub-graphs kept in fp32 to work around an onnxconverter_common
124
+ // bug; vector_estimator therefore ends up at ~54% of fp32 instead of 50%.
125
+ // Paste new hashes here when re-quantizing; placeholder values must be
126
+ // updated together with the mirrorRevision SHA above.
127
+ "onnx-fp16/duration_predictor.onnx": "95bf8c2dd3affd6e40bb57ad1c76018e47abc7b56a7978fe211ebe1359e478f1",
128
+ "onnx-fp16/text_encoder.onnx": "fdfb21cb1596a6ac84699a6a0e236add97f95bfb492264209807777dd6c2e046",
129
+ "onnx-fp16/vector_estimator.onnx": "7df9169002c8b8af4990bb1370cbb1c6600bcffef9749d9a83200e1b30a7a8b8",
130
+ "onnx-fp16/vocoder.onnx": "f409960b6e74ef6e51c32b2cc77047ffbd426179f341214f42efb2a61aa91e57",
131
+ ]
132
+
133
+ /// Lookup for a relative path. Returns nil if no fingerprint is registered
134
+ /// (in which case verification is skipped for that file).
135
+ static func expectedHash(forRelativePath path: String) -> String? {
136
+ return expectedHashes[path]
137
+ }
138
+
139
+ /// True if `url` is missing or its bytes don't match `expectedHash(forRelativePath:)`.
140
+ /// On hash mismatch, deletes the file so the caller re-downloads it. This
141
+ /// covers two real-world cases:
142
+ /// 1. The mirror revision was bumped to a new model build (e.g. an fp16
143
+ /// bugfix). Old cached file's SHA no longer matches.
144
+ /// 2. The file was partially written / corrupted by an interrupted download.
145
+ /// Files without a registered hash (configs not in `expectedHashes`) are
146
+ /// trusted on cache hit; only missing/corrupt is detected.
147
+ static func needsDownload(at url: URL, relativePath: String) -> Bool {
148
+ guard FileManager.default.fileExists(atPath: url.path) else { return true }
149
+ guard let expected = expectedHash(forRelativePath: relativePath) else { return false }
150
+ let actual = sha256(of: url)?.lowercased() ?? ""
151
+ if actual == expected.lowercased() { return false }
152
+ NSLog("[ST.locator] cached %@ hash mismatch (have %@, want %@) — re-downloading",
153
+ relativePath, actual.prefix(12) as CVarArg, expected.prefix(12) as CVarArg)
154
+ try? FileManager.default.removeItem(at: url)
155
+ return true
156
+ }
157
+
158
+ static var supportDirectory: URL {
159
+ let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
160
+ let dir = base.appendingPathComponent("RNTTSKit/Supertonic", isDirectory: true)
161
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
162
+ try? FileManager.default.createDirectory(at: dir.appendingPathComponent(precision.onnxSubdir), withIntermediateDirectories: true)
163
+ try? FileManager.default.createDirectory(at: dir.appendingPathComponent("voice_styles"), withIntermediateDirectories: true)
164
+ return dir
165
+ }
166
+
167
+ static var onnxDirectory: URL { supportDirectory.appendingPathComponent(precision.onnxSubdir) }
168
+ static var voicesDirectory: URL { supportDirectory.appendingPathComponent("voice_styles") }
169
+
170
+ /// Search every loaded bundle (main app, Pod resource bundles, frameworks) for a
171
+ /// model file the host has pre-shipped alongside the package.
172
+ static func bundledFile(named name: String, ext: String) -> URL? {
173
+ let baseName = (name as NSString).deletingPathExtension
174
+ for bundle in Bundle.allBundles + Bundle.allFrameworks {
175
+ if let url = bundle.url(forResource: baseName, withExtension: ext),
176
+ FileManager.default.fileExists(atPath: url.path) {
177
+ return url
178
+ }
179
+ }
180
+ return nil
181
+ }
182
+
183
+ static func resolvedOnnxURL(for filename: String) -> URL {
184
+ let ext = (filename as NSString).pathExtension
185
+ let base = (filename as NSString).deletingPathExtension
186
+ if let bundled = bundledFile(named: base, ext: ext) { return bundled }
187
+ return onnxDirectory.appendingPathComponent(filename)
188
+ }
189
+
190
+ static func resolvedVoiceURL(for voiceId: String) -> URL {
191
+ if let bundled = bundledFile(named: voiceId, ext: "json") { return bundled }
192
+ return voicesDirectory.appendingPathComponent("\(voiceId).json")
193
+ }
194
+
195
+ /// Wipe every downloaded file under Application Support/RNTTSKit/Supertonic
196
+ /// (all precision subdirs + voice_styles). Pre-bundled files in the app
197
+ /// resource bundle are NOT touched — they're read-only and don't live here.
198
+ /// Next call to `ensureModel()` will re-download from the mirror.
199
+ static func clearCache() {
200
+ let dir = supportDirectory
201
+ do {
202
+ try FileManager.default.removeItem(at: dir)
203
+ NSLog("[ST.locator] cleared cache at %@", dir.path)
204
+ } catch {
205
+ NSLog("[ST.locator] clearCache failed at %@: %@", dir.path, String(describing: error))
206
+ }
207
+ }
208
+
209
+ /// True iff every ONNX/config file is on disk (bundled or downloaded).
210
+ static func modelExists() -> Bool {
211
+ for f in onnxFiles {
212
+ if !FileManager.default.fileExists(atPath: resolvedOnnxURL(for: f).path) { return false }
213
+ }
214
+ // At least one voice must exist.
215
+ return voiceIds.contains { FileManager.default.fileExists(atPath: resolvedVoiceURL(for: $0).path) }
216
+ }
217
+
218
+ /// Build the per-mirror URL list for a given relative path (e.g. "onnx/tts.json").
219
+ private static func candidateURLs(for relativePath: String) -> [URL] {
220
+ baseURLs.compactMap { URL(string: "\($0)/\(relativePath)") }
221
+ }
222
+
223
+ static func ensureModel(progress: @escaping (PrefetchProgressInfo) -> Void) async throws {
224
+ // Skip already-present files (bundled or previously downloaded).
225
+ // Each entry: (relative path, candidate URL list, destination on disk).
226
+ var pending: [(String, [URL], URL)] = []
227
+ for f in onnxFiles {
228
+ let dst = resolvedOnnxURL(for: f)
229
+ // Config files (tts.json, unicode_indexer.json) only live under
230
+ // upstream's onnx/ — quantization doesn't touch them. Pull from
231
+ // the fp32 path regardless of the active precision tier.
232
+ let isConfig = f.hasSuffix(".json")
233
+ let rel = "\(isConfig ? "onnx" : precision.onnxSubdir)/\(f)"
234
+ if needsDownload(at: dst, relativePath: rel) {
235
+ pending.append((rel, candidateURLs(for: rel), dst))
236
+ }
237
+ }
238
+ for v in voiceIds {
239
+ let dst = resolvedVoiceURL(for: v)
240
+ let rel = "voice_styles/\(v).json"
241
+ if needsDownload(at: dst, relativePath: rel) {
242
+ pending.append((rel, candidateURLs(for: rel), dst))
243
+ }
244
+ }
245
+ if pending.isEmpty {
246
+ logCachedSize(prefix: "cache hit")
247
+ progress(PrefetchProgressInfo(bytesDownloaded: 1, totalBytes: 1))
248
+ return
249
+ }
250
+ NSLog("[ST.locator] downloading %d file(s) (precision=%@)",
251
+ pending.count, precision.rawValue)
252
+ // Discover sizes from whichever mirror responds first. Used only for
253
+ // progress accounting; if no mirror responds to HEAD the download
254
+ // itself will surface the failure.
255
+ var fileTotals: [Int64] = []
256
+ for (_, urls, _) in pending {
257
+ fileTotals.append(await firstSuccessfulSize(urls: urls))
258
+ }
259
+ let grandTotal = fileTotals.reduce(0, +)
260
+ var alreadyDownloaded: Int64 = 0
261
+ for (i, (rel, urls, dst)) in pending.enumerated() {
262
+ try await downloadWithFallback(candidates: urls, to: dst, relativePath: rel) { fileBytes in
263
+ progress(PrefetchProgressInfo(
264
+ bytesDownloaded: alreadyDownloaded + fileBytes,
265
+ totalBytes: grandTotal
266
+ ))
267
+ }
268
+ // Log each file's on-disk size after it lands so a download summary
269
+ // shows up incrementally, not only after the slow vector_estimator.
270
+ let sz = (try? FileManager.default.attributesOfItem(atPath: dst.path)[.size] as? Int64) ?? -1
271
+ NSLog("[ST.locator] downloaded %@ (%@)", rel, formatBytes(sz))
272
+ alreadyDownloaded += fileTotals[i]
273
+ }
274
+ progress(PrefetchProgressInfo(bytesDownloaded: grandTotal, totalBytes: grandTotal))
275
+ logCachedSize(prefix: "downloaded")
276
+ }
277
+
278
+ /// Sums every file under the current precision's onnx dir + voice_styles
279
+ /// and emits a one-line log. Called from `ensureModel()` after a successful
280
+ /// pass (whether bytes were pulled or files were already on disk).
281
+ private static func logCachedSize(prefix: String) {
282
+ let dirs = [onnxDirectory, voicesDirectory]
283
+ var total: Int64 = 0
284
+ var fileCount = 0
285
+ for dir in dirs {
286
+ guard let it = FileManager.default.enumerator(at: dir, includingPropertiesForKeys: [.fileSizeKey]) else { continue }
287
+ for case let url as URL in it {
288
+ if let sz = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) {
289
+ total += Int64(sz)
290
+ fileCount += 1
291
+ }
292
+ }
293
+ }
294
+ NSLog("[ST.locator] %@: %@ across %d file(s) under %@",
295
+ prefix, formatBytes(total), fileCount, supportDirectory.path)
296
+ }
297
+
298
+ /// "138.1 MB" / "1.9 MB" / "8.3 KB" / "—" — small helper so the log lines
299
+ /// don't dump raw byte counts that nobody reads.
300
+ private static func formatBytes(_ bytes: Int64) -> String {
301
+ if bytes < 0 { return "—" }
302
+ let f = ByteCountFormatter()
303
+ f.allowedUnits = [.useKB, .useMB, .useGB]
304
+ f.countStyle = .file
305
+ return f.string(fromByteCount: bytes)
306
+ }
307
+
308
+ private static func firstSuccessfulSize(urls: [URL]) async -> Int64 {
309
+ for url in urls {
310
+ var req = URLRequest(url: url); req.httpMethod = "HEAD"
311
+ do {
312
+ let (_, resp) = try await URLSession.shared.data(for: req)
313
+ if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) { continue }
314
+ if resp.expectedContentLength > 0 { return resp.expectedContentLength }
315
+ } catch {
316
+ continue
317
+ }
318
+ }
319
+ return 0
320
+ }
321
+
322
+ private static func downloadWithFallback(
323
+ candidates: [URL],
324
+ to destination: URL,
325
+ relativePath: String,
326
+ progress: @escaping (Int64) -> Void
327
+ ) async throws {
328
+ var lastError: Error? = nil
329
+ for url in candidates {
330
+ do {
331
+ try await download(from: url, to: destination, progress: progress)
332
+ // Verify file integrity if we have an expected hash.
333
+ if let expected = expectedHash(forRelativePath: relativePath) {
334
+ if let actual = sha256(of: destination), actual.lowercased() == expected.lowercased() {
335
+ return
336
+ }
337
+ // Hash mismatch — delete and try next mirror.
338
+ try? FileManager.default.removeItem(at: destination)
339
+ lastError = NSError(
340
+ domain: "ttskit.modellocator", code: -2,
341
+ userInfo: [NSLocalizedDescriptionKey:
342
+ "Downloaded \(relativePath) failed SHA-256 check (mirror may be compromised or stale)."]
343
+ )
344
+ continue
345
+ }
346
+ return
347
+ } catch {
348
+ lastError = error
349
+ // Try the next mirror.
350
+ }
351
+ }
352
+ throw lastError ?? URLError(.cannotConnectToHost)
353
+ }
354
+
355
+ /// Stream-hashes the file at `url` without holding it in memory.
356
+ private static func sha256(of url: URL) -> String? {
357
+ guard let stream = InputStream(url: url) else { return nil }
358
+ stream.open()
359
+ defer { stream.close() }
360
+ var hasher = SHA256()
361
+ let bufSize = 1 << 16 // 64 KiB
362
+ let buf = UnsafeMutablePointer<UInt8>.allocate(capacity: bufSize)
363
+ defer { buf.deallocate() }
364
+ while stream.hasBytesAvailable {
365
+ let n = stream.read(buf, maxLength: bufSize)
366
+ if n <= 0 { break }
367
+ hasher.update(bufferPointer: UnsafeRawBufferPointer(start: buf, count: n))
368
+ }
369
+ return hasher.finalize().map { String(format: "%02x", $0) }.joined()
370
+ }
371
+
372
+ /// Streamed download with periodic progress callbacks. Drains in 1 MiB chunks.
373
+ private static func download(
374
+ from url: URL,
375
+ to destination: URL,
376
+ progress: @escaping (Int64) -> Void
377
+ ) async throws {
378
+ try? FileManager.default.createDirectory(
379
+ at: destination.deletingLastPathComponent(),
380
+ withIntermediateDirectories: true
381
+ )
382
+ let (bytes, _) = try await URLSession.shared.bytes(from: url)
383
+ let tmp = destination.appendingPathExtension("part")
384
+ try? FileManager.default.removeItem(at: tmp)
385
+ FileManager.default.createFile(atPath: tmp.path, contents: nil)
386
+ let handle = try FileHandle(forWritingTo: tmp)
387
+ defer { try? handle.close() }
388
+
389
+ let drainEvery = 1 << 20 // 1 MiB
390
+ var pending = Data(); pending.reserveCapacity(drainEvery)
391
+ var written: Int64 = 0
392
+ var lastReport: Int64 = 0
393
+ let reportEvery: Int64 = 256 * 1024
394
+
395
+ for try await byte in bytes {
396
+ pending.append(byte)
397
+ if pending.count >= drainEvery {
398
+ try handle.write(contentsOf: pending)
399
+ written += Int64(pending.count)
400
+ pending.removeAll(keepingCapacity: true)
401
+ if written - lastReport >= reportEvery {
402
+ progress(written)
403
+ lastReport = written
404
+ }
405
+ }
406
+ }
407
+ if !pending.isEmpty {
408
+ try handle.write(contentsOf: pending)
409
+ written += Int64(pending.count)
410
+ }
411
+ try handle.close()
412
+ try? FileManager.default.removeItem(at: destination)
413
+ try FileManager.default.moveItem(at: tmp, to: destination)
414
+ progress(written)
415
+ }
416
+ }