@@ -4,21 +4,36 @@ import Foundation
44public enum TtsResourceDownloader {
55
66 private static let logger = AppLogger ( category: " TtsResourceDownloader " )
7+ private static let zhRepoRemotePath = " alexwengg/tts-zh "
78
89 /// Download a voice embedding JSON file from HuggingFace
910 public static func downloadVoiceEmbedding( voice: String ) async throws -> Data {
10- let url = try ModelRegistry . resolveModel ( Repo . kokoro. remotePath, " voices/ \( voice) .json " )
11+ // Primary repo (FluidInference/kokoro-82m-coreml)
12+ let primary = try ModelRegistry . resolveModel ( Repo . kokoro. remotePath, " voices/ \( voice) .json " )
13+ // Fallback repo for zh voices
14+ let fallback = try ModelRegistry . resolveModel ( zhRepoRemotePath, " voices/ \( voice) .json " )
1115
12- do {
13- let data = try await AssetDownloader . fetchData (
16+ func attempt ( _ url : URL ) async throws -> Data {
17+ try await AssetDownloader . fetchData (
1418 from: url,
1519 description: " \( voice) voice embedding JSON " ,
1620 logger: logger
1721 )
18- logger. info ( " Downloaded voice embedding JSON for \( voice) " )
22+ }
23+
24+ do {
25+ let data = try await attempt ( primary)
26+ logger. info ( " Downloaded voice embedding JSON for \( voice) from primary repo " )
1927 return data
2028 } catch {
21- throw TTSError . modelNotFound ( " Voice embedding JSON unavailable for \( voice) : \( error. localizedDescription) " )
29+ logger. warning ( " Primary voice embedding missing for \( voice) ; trying zh repo " )
30+ do {
31+ let data = try await attempt ( fallback)
32+ logger. info ( " Downloaded voice embedding JSON for \( voice) from zh repo " )
33+ return data
34+ } catch {
35+ throw TTSError . modelNotFound ( " Voice embedding JSON unavailable for \( voice) " )
36+ }
2237 }
2338 }
2439
@@ -74,3 +89,71 @@ public enum TtsResourceDownloader {
7489 }
7590
7691}
92+
93+ // MARK: - zh assets (vocab + char lexicon)
94+
95+ extension TtsResourceDownloader {
96+ /// Ensure `zh_vocab_index.json` exists in Kokoro cache. Falls back to downloading `zh.json` and writing it
97+ /// as `zh_vocab_index.json` if needed.
98+ @discardableResult
99+ public static func ensureZhVocabularyInCache( ) async throws -> URL {
100+ let cacheDir = try TtsModels . cacheDirectoryURL ( )
101+ let kokoroDir = cacheDir. appendingPathComponent ( " Models/kokoro " )
102+ try FileManager . default. createDirectory ( at: kokoroDir, withIntermediateDirectories: true )
103+
104+ let zhVocabURL = kokoroDir. appendingPathComponent ( " zh_vocab_index.json " )
105+ if FileManager . default. fileExists ( atPath: zhVocabURL. path) {
106+ return zhVocabURL
107+ }
108+
109+ let primary = try ModelRegistry . resolveModel ( zhRepoRemotePath, " zh_vocab_index.json " )
110+ let alt = try ModelRegistry . resolveModel ( zhRepoRemotePath, " zh.json " )
111+
112+ do {
113+ let descriptor = AssetDownloader . Descriptor (
114+ description: " zh_vocab_index.json " ,
115+ remoteURL: primary,
116+ destinationURL: zhVocabURL
117+ )
118+ return try await AssetDownloader . ensure ( descriptor, logger: logger)
119+ } catch {
120+ logger. warning ( " zh_vocab_index.json not found in zh repo; trying zh.json " )
121+ let data = try await AssetDownloader . fetchData (
122+ from: alt,
123+ description: " zh.json " ,
124+ logger: logger
125+ )
126+ try data. write ( to: zhVocabURL, options: [ . atomic] )
127+ logger. info ( " Cached zh vocabulary as zh_vocab_index.json " )
128+ return zhVocabURL
129+ }
130+ }
131+
132+ /// Ensure `zh_char_phonemes.json` exists in Kokoro cache, downloading from zh repo if missing.
133+ @discardableResult
134+ public static func ensureZhCharPhonemesInCache( ) async throws -> URL {
135+ let cacheDir = try TtsModels . cacheDirectoryURL ( )
136+ let kokoroDir = cacheDir. appendingPathComponent ( " Models/kokoro " )
137+ try FileManager . default. createDirectory ( at: kokoroDir, withIntermediateDirectories: true )
138+
139+ let localURL = kokoroDir. appendingPathComponent ( " zh_char_phonemes.json " )
140+ if FileManager . default. fileExists ( atPath: localURL. path) {
141+ return localURL
142+ }
143+
144+ let remoteURL = try ModelRegistry . resolveModel ( zhRepoRemotePath, " zh_char_phonemes.json " )
145+ let descriptor = AssetDownloader . Descriptor (
146+ description: " zh_char_phonemes.json " ,
147+ remoteURL: remoteURL,
148+ destinationURL: localURL
149+ )
150+ return try await AssetDownloader . ensure ( descriptor, logger: logger)
151+ }
152+
153+ /// Convenience: ensure both zh vocab and char phoneme lexicon exist in cache.
154+ public static func ensureZhAssetsInCache( ) async throws -> ( vocabURL: URL , lexURL: URL ) {
155+ async let v = ensureZhVocabularyInCache ( )
156+ async let l = ensureZhCharPhonemesInCache ( )
157+ return try await ( vocabURL: v, lexURL: l)
158+ }
159+ }
0 commit comments