|
1 | 1 | import Foundation |
2 | 2 |
|
3 | | -/// Kokoro TTS resource downloader (lexicons, voice embeddings, eSpeak data) |
| 3 | +/// Kokoro TTS resource downloader (lexicons, voice embeddings) |
4 | 4 | public enum TtsResourceDownloader { |
5 | 5 |
|
6 | 6 | private static let logger = AppLogger(category: "TtsResourceDownloader") |
7 | 7 | private static let kokoroBaseURL = "https://huggingface.co/\(Repo.kokoro.remotePath)/resolve/main" |
8 | 8 |
|
9 | | - private static func packagedEspeakBundleURL() -> URL? { |
10 | | - #if SWIFT_PACKAGE |
11 | | - return Bundle.module.url( |
12 | | - forResource: "espeak-ng-data", |
13 | | - withExtension: "bundle", |
14 | | - subdirectory: "espeak-ng" |
15 | | - ) |
16 | | - #else |
17 | | - return nil |
18 | | - #endif |
19 | | - } |
20 | | - |
21 | | - private static func stagePackagedEspeakBundle( |
22 | | - from bundleURL: URL, |
23 | | - into repoPath: URL, |
24 | | - voicesPath: URL |
25 | | - ) throws { |
26 | | - let targetBundle = repoPath.appendingPathComponent("Resources/espeak-ng/espeak-ng-data.bundle") |
27 | | - let parentDir = targetBundle.deletingLastPathComponent() |
28 | | - try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) |
29 | | - if FileManager.default.fileExists(atPath: targetBundle.path) { |
30 | | - try FileManager.default.removeItem(at: targetBundle) |
31 | | - } |
32 | | - do { |
33 | | - try FileManager.default.copyItem(at: bundleURL, to: targetBundle) |
34 | | - } catch { |
35 | | - throw TTSError.downloadFailed( |
36 | | - "Failed to stage packaged eSpeak NG bundle: \(error.localizedDescription)" |
37 | | - ) |
38 | | - } |
39 | | - |
40 | | - guard FileManager.default.fileExists(atPath: voicesPath.path) else { |
41 | | - throw TTSError.downloadFailed( |
42 | | - "Packaged eSpeak NG data bundle missing 'voices' directory after copy" |
43 | | - ) |
44 | | - } |
45 | | - } |
46 | | - |
47 | 9 | /// Download a voice embedding JSON file from HuggingFace |
48 | 10 | public static func downloadVoiceEmbedding(voice: String) async throws -> Data { |
49 | 11 | let jsonURL = "\(kokoroBaseURL)/voices/\(voice).json" |
@@ -118,68 +80,4 @@ public enum TtsResourceDownloader { |
118 | 80 | } |
119 | 81 | } |
120 | 82 |
|
121 | | - /// Ensure the eSpeak NG data bundle is available locally for Kokoro G2P |
122 | | - @available(macOS 13.0, iOS 16.0, *) |
123 | | - public static func ensureEspeakDataBundle(in modelsDirectory: URL) async throws -> URL { |
124 | | - let repo = Repo.kokoro |
125 | | - let repoPath = modelsDirectory.appendingPathComponent(repo.folderName) |
126 | | - let bundleRoot = repoPath.appendingPathComponent( |
127 | | - "Resources/espeak-ng/espeak-ng-data.bundle/espeak-ng-data" |
128 | | - ) |
129 | | - let voices = bundleRoot.appendingPathComponent("voices") |
130 | | - |
131 | | - if FileManager.default.fileExists(atPath: voices.path) { |
132 | | - return bundleRoot |
133 | | - } |
134 | | - |
135 | | - try FileManager.default.createDirectory(at: repoPath, withIntermediateDirectories: true) |
136 | | - |
137 | | - if let bundledBundle = packagedEspeakBundleURL() { |
138 | | - try stagePackagedEspeakBundle(from: bundledBundle, into: repoPath, voicesPath: voices) |
139 | | - logger.info("Using packaged espeak-ng-data bundle shipped with FluidAudio resources") |
140 | | - return bundleRoot |
141 | | - } |
142 | | - |
143 | | - #if os(macOS) |
144 | | - logger.info("Downloading eSpeak NG data bundle from HuggingFace…") |
145 | | - |
146 | | - let zipPath = repoPath.appendingPathComponent("espeak-ng.zip") |
147 | | - let zipURL = URL(string: "https://huggingface.co/\(repo.remotePath)/resolve/main/espeak-ng.zip")! |
148 | | - |
149 | | - if !FileManager.default.fileExists(atPath: zipPath.path) { |
150 | | - let descriptor = AssetDownloader.Descriptor( |
151 | | - description: "espeak-ng.zip", |
152 | | - remoteURL: zipURL, |
153 | | - destinationURL: zipPath, |
154 | | - transferMode: .file() |
155 | | - ) |
156 | | - _ = try await AssetDownloader.ensure(descriptor, logger: logger) |
157 | | - } |
158 | | - |
159 | | - let resourcesDir = repoPath.appendingPathComponent("Resources") |
160 | | - |
161 | | - let process = Process() |
162 | | - process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") |
163 | | - process.arguments = ["-o", zipPath.path, "-d", resourcesDir.path] |
164 | | - process.standardOutput = FileHandle.nullDevice |
165 | | - process.standardError = FileHandle.nullDevice |
166 | | - try process.run() |
167 | | - process.waitUntilExit() |
168 | | - |
169 | | - guard process.terminationStatus == 0 else { |
170 | | - throw TTSError.downloadFailed( |
171 | | - "Failed to extract eSpeak NG data bundle via unzip (status=\(process.terminationStatus))") |
172 | | - } |
173 | | - logger.info("Extracted espeak-ng-data successfully") |
174 | | - #else |
175 | | - throw TTSError.downloadFailed( |
176 | | - "Packaged eSpeak NG data bundle missing from FluidAudio resources" |
177 | | - ) |
178 | | - #endif |
179 | | - |
180 | | - guard FileManager.default.fileExists(atPath: voices.path) else { |
181 | | - throw TTSError.downloadFailed("eSpeak NG data bundle missing 'voices' after extraction") |
182 | | - } |
183 | | - return bundleRoot |
184 | | - } |
185 | 83 | } |
0 commit comments