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.
- package/ATTRIBUTIONS.md +87 -0
- package/LICENSE +21 -0
- package/README.md +231 -0
- package/android/build.gradle +50 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/expo/modules/ttskit/RNTTSKitModule.kt +158 -0
- package/android/src/main/java/expo/modules/ttskit/supertonic/AudioEngine.kt +158 -0
- package/android/src/main/java/expo/modules/ttskit/supertonic/ModelLocator.kt +372 -0
- package/android/src/main/java/expo/modules/ttskit/supertonic/SupertonicSession.kt +373 -0
- package/android/src/main/java/expo/modules/ttskit/supertonic/TextFrontend.kt +154 -0
- package/android/src/main/java/expo/modules/ttskit/supertonic/VoicePack.kt +47 -0
- package/build/engines/BufferedStreamEmitter.d.ts +26 -0
- package/build/engines/BufferedStreamEmitter.d.ts.map +1 -0
- package/build/engines/BufferedStreamEmitter.js +68 -0
- package/build/engines/BufferedStreamEmitter.js.map +1 -0
- package/build/engines/Engine.d.ts +15 -0
- package/build/engines/Engine.d.ts.map +1 -0
- package/build/engines/Engine.js +2 -0
- package/build/engines/Engine.js.map +1 -0
- package/build/engines/SupertonicEngine.d.ts +14 -0
- package/build/engines/SupertonicEngine.d.ts.map +1 -0
- package/build/engines/SupertonicEngine.js +183 -0
- package/build/engines/SupertonicEngine.js.map +1 -0
- package/build/engines/SystemEngine.d.ts +13 -0
- package/build/engines/SystemEngine.d.ts.map +1 -0
- package/build/engines/SystemEngine.js +78 -0
- package/build/engines/SystemEngine.js.map +1 -0
- package/build/index.d.ts +46 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +118 -0
- package/build/index.js.map +1 -0
- package/build/types.d.ts +77 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/voices/catalog.d.ts +12 -0
- package/build/voices/catalog.d.ts.map +1 -0
- package/build/voices/catalog.js +28 -0
- package/build/voices/catalog.js.map +1 -0
- package/build/voices/prosody.d.ts +8 -0
- package/build/voices/prosody.d.ts.map +1 -0
- package/build/voices/prosody.js +28 -0
- package/build/voices/prosody.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/RNTTSKit.podspec +28 -0
- package/ios/RNTTSKitModule.swift +133 -0
- package/ios/Supertonic/AudioEngine.swift +110 -0
- package/ios/Supertonic/ModelLocator.swift +416 -0
- package/ios/Supertonic/SupertonicSession.swift +405 -0
- package/ios/Supertonic/TextFrontend.swift +216 -0
- package/ios/Supertonic/VoicePack.swift +51 -0
- package/licenses/OpenRAIL-M.txt +209 -0
- package/package.json +77 -0
- package/src/engines/BufferedStreamEmitter.ts +50 -0
- package/src/engines/Engine.ts +28 -0
- package/src/engines/SupertonicEngine.ts +250 -0
- package/src/engines/SystemEngine.ts +96 -0
- package/src/engines/__tests__/BufferedStreamEmitter.test.ts +65 -0
- package/src/index.ts +156 -0
- package/src/types.ts +95 -0
- package/src/voices/__tests__/catalog.test.ts +46 -0
- package/src/voices/__tests__/prosody.test.ts +63 -0
- package/src/voices/catalog.ts +32 -0
- 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
|
+
}
|