snapshot current state before gitea sync
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# macOS
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
# Python cache and virtual environments
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
**/.venv/
|
||||
|
||||
# AI tool caches / generated artifacts
|
||||
tools/kokoro_coreml/.venv/
|
||||
tools/kokoro_coreml/.venv310/
|
||||
tools/kokoro_coreml/kokoro-coreml/checkpoints/
|
||||
tools/kokoro_coreml/kokoro-coreml/coreml/
|
||||
autoaudiobook/.venv/
|
||||
autoaudiobook/samples/
|
||||
|
||||
# Build outputs
|
||||
build/
|
||||
DerivedData/
|
||||
*.dSYM/
|
||||
*.log
|
||||
tmp/
|
||||
|
||||
# Xcode user-specific files
|
||||
*.xcuserstate
|
||||
*.xcworkspace/xcuserdata/
|
||||
*.xcodeproj/xcuserdata/
|
||||
3
Config/Signing.xcconfig
Normal file
3
Config/Signing.xcconfig
Normal file
@@ -0,0 +1,3 @@
|
||||
CODE_SIGN_STYLE = Automatic
|
||||
DEVELOPMENT_TEAM = NG5W75WE8U
|
||||
CODE_SIGN_IDENTITY = Apple Development
|
||||
1
Vendor/SwiftSoup
vendored
Submodule
1
Vendor/SwiftSoup
vendored
Submodule
Submodule Vendor/SwiftSoup added at 855ac2e627
1
Vendor/ZipFoundation
vendored
Submodule
1
Vendor/ZipFoundation
vendored
Submodule
Submodule Vendor/ZipFoundation added at d6e0da4509
703
Vorleser.xcodeproj/project.pbxproj
Normal file
703
Vorleser.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,703 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1173DA36CC81A687FCD9ADEF /* AudioPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607049D947C8E99A2EFEAE2B /* AudioPlaybackService.swift */; };
|
||||
16A7B21A1735D83F72ABE479 /* TextChunker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85269B5CD0BEC628EE701F6D /* TextChunker.swift */; };
|
||||
2873719E4068119DC4AF8E6E /* MacContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC91648998FFBEA606BB391 /* MacContentView.swift */; };
|
||||
34166D992482C7B7426B5522 /* VorleserApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5CA6531783A31383ABBC5D2 /* VorleserApp.swift */; };
|
||||
4CE6864A9ED8CC539C8B7CE5 /* TextChunker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CD128D7B6A97B4FCE4029C /* TextChunker.swift */; };
|
||||
509AA480B488B31919F29088 /* SynthesisWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3FFA134DDAED316C5CFB082 /* SynthesisWorker.swift */; };
|
||||
548CB9DAF59F86B24974F8E4 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = B0D36FF6F6C2B26774D08DC7 /* SwiftSoup */; };
|
||||
5922CC46A3F17E81D35176AA /* am_michael.f32 in Resources */ = {isa = PBXBuildFile; fileRef = E81F0CBA9F875D46BC1526E3 /* am_michael.f32 */; };
|
||||
5D711DB48ABE34775D5FDB98 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = C1752C03CDF5CAD6901643D1 /* ZIPFoundation */; };
|
||||
610C2F9387026F62F61C8CF2 /* DocumentPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DA26953AA44A2E7FD5A02BE /* DocumentPicker.swift */; };
|
||||
6215D254F0FFF5DA2B20AECA /* EPUBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01E3A445F73A6332FD740DC /* EPUBService.swift */; };
|
||||
6553CBE1C2A85C0E3B5AAB32 /* ReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0658A4F43BB4E96F5F4FFEE /* ReaderView.swift */; };
|
||||
6665F2B72ACD3728EB59EAF0 /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A25386256250D132B07A40A8 /* LibraryViewModel.swift */; };
|
||||
68907CD0425F824EDEAA474B /* ModelDownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 095B8213401737EBE5BD051E /* ModelDownloadManager.swift */; };
|
||||
68B454E96B643A49C8575B1F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF069F9DF9CB378E19213C1A /* ContentView.swift */; };
|
||||
774433CFD75BDD0BD3FD7CE3 /* KokoroPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81B1729BB2EEFB2F938A4ED1 /* KokoroPipeline.swift */; };
|
||||
7F7A8848F3F6A6305B69F207 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = EFE297A06C9DB8F3A03CC722 /* ZIPFoundation */; };
|
||||
857BA97045E2D5DC1BE459CA /* am_michael_256.f32 in Resources */ = {isa = PBXBuildFile; fileRef = 2261729AEC15D2904FC451D7 /* am_michael_256.f32 */; };
|
||||
8B60D8666A4A93F9E89E3D9A /* BookItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B83FC4EDCDD4B8E955E50F5 /* BookItem.swift */; };
|
||||
8F70FF75CF4252218AB7D9B9 /* config.json in Resources */ = {isa = PBXBuildFile; fileRef = B96222D416F28D8ADABB8690 /* config.json */; };
|
||||
91D5040FD66AC881D66737AC /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 40DA6F30AF230EF31BE90E5D /* SwiftSoup */; };
|
||||
9C0EE7F98FDCBE710E0FABF2 /* EPUBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1736FACE2FDE0410DA52FDCF /* EPUBService.swift */; };
|
||||
9C182CD3A6163FB87780D9B6 /* AssetVerifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4BBAF6BE51E77F99FA99D6A /* AssetVerifier.swift */; };
|
||||
9CAD6CF7B191D03B2DF31D1C /* kokoro_decoder_only_3s.mlpackage in Sources */ = {isa = PBXBuildFile; fileRef = 1305807ABD715C606FEA6DFA /* kokoro_decoder_only_3s.mlpackage */; };
|
||||
A2FEFDB8B74FBB03FFAC8E98 /* kokoro_f0n_10s.mlpackage in Sources */ = {isa = PBXBuildFile; fileRef = A337EE4E3A4036F440E4FD9C /* kokoro_f0n_10s.mlpackage */; };
|
||||
A70470255CE4892795B128A2 /* KokoroTokenizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9565A2BC053068E43C4023E8 /* KokoroTokenizer.swift */; };
|
||||
A7BFB668CD8C8D9BF53970A8 /* am_michael.pt in Resources */ = {isa = PBXBuildFile; fileRef = E3DB5C54ADDC336BDEE52370 /* am_michael.pt */; };
|
||||
ADD9720B3F47ADA5662A1252 /* ModelManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CAEC669F2D50C02A202551 /* ModelManifest.swift */; };
|
||||
AFB35F64AFB50B3ED9569E5A /* kokoro_duration.mlpackage in Sources */ = {isa = PBXBuildFile; fileRef = 69C5DD780EBC730C24A7EAD2 /* kokoro_duration.mlpackage */; };
|
||||
BF4C90C35F076CFB1374688D /* AudioSynthesisService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 996137D48F183F1E4C91800B /* AudioSynthesisService.swift */; };
|
||||
C37859F5789D350B87B6992F /* kokoro_decoder_only_10s.mlpackage in Sources */ = {isa = PBXBuildFile; fileRef = B1895F72980568E5F7EFA85E /* kokoro_decoder_only_10s.mlpackage */; };
|
||||
C6AFD11DD6725CA9422C419E /* MacLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8865DCEC0F532253D25B3D97 /* MacLibraryViewModel.swift */; };
|
||||
D1CE101DCF0DE54F6F301C95 /* EspeakPhonemizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63F44ED11BB4C142F40FB9C7 /* EspeakPhonemizer.swift */; };
|
||||
D577A9313BC7656040C53FD9 /* VorleserMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CFB7D317E65F1324C34D3F0 /* VorleserMacApp.swift */; };
|
||||
E8BDFA760B9CFE0B8FAFF8B8 /* ReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEBD2C99C256768BB156EFCC /* ReaderViewModel.swift */; };
|
||||
EC040FC1433D4B3628D69765 /* kokoro_f0n_3s.mlpackage in Sources */ = {isa = PBXBuildFile; fileRef = 7B8464C0EC10657806B6E626 /* kokoro_f0n_3s.mlpackage */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
0655C39862135E3CC301233A /* SwiftSoup */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SwiftSoup; path = Vendor/SwiftSoup; sourceTree = SOURCE_ROOT; };
|
||||
06CD128D7B6A97B4FCE4029C /* TextChunker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextChunker.swift; sourceTree = "<group>"; };
|
||||
095B8213401737EBE5BD051E /* ModelDownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelDownloadManager.swift; sourceTree = "<group>"; };
|
||||
0DA26953AA44A2E7FD5A02BE /* DocumentPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentPicker.swift; sourceTree = "<group>"; };
|
||||
1305807ABD715C606FEA6DFA /* kokoro_decoder_only_3s.mlpackage */ = {isa = PBXFileReference; path = kokoro_decoder_only_3s.mlpackage; sourceTree = "<group>"; };
|
||||
1736FACE2FDE0410DA52FDCF /* EPUBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBService.swift; sourceTree = "<group>"; };
|
||||
2261729AEC15D2904FC451D7 /* am_michael_256.f32 */ = {isa = PBXFileReference; path = am_michael_256.f32; sourceTree = "<group>"; };
|
||||
3857F14DFCD4924A4340F42B /* Signing.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Signing.xcconfig; sourceTree = "<group>"; };
|
||||
3CFB7D317E65F1324C34D3F0 /* VorleserMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorleserMacApp.swift; sourceTree = "<group>"; };
|
||||
607049D947C8E99A2EFEAE2B /* AudioPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackService.swift; sourceTree = "<group>"; };
|
||||
63F44ED11BB4C142F40FB9C7 /* EspeakPhonemizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EspeakPhonemizer.swift; sourceTree = "<group>"; };
|
||||
69C5DD780EBC730C24A7EAD2 /* kokoro_duration.mlpackage */ = {isa = PBXFileReference; path = kokoro_duration.mlpackage; sourceTree = "<group>"; };
|
||||
7241D29C019B5B111851F09E /* VorleserMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VorleserMac.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
78B456910B098FF29B2C3DCC /* ZipFoundation */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ZipFoundation; path = Vendor/ZipFoundation; sourceTree = SOURCE_ROOT; };
|
||||
7B83FC4EDCDD4B8E955E50F5 /* BookItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookItem.swift; sourceTree = "<group>"; };
|
||||
7B8464C0EC10657806B6E626 /* kokoro_f0n_3s.mlpackage */ = {isa = PBXFileReference; path = kokoro_f0n_3s.mlpackage; sourceTree = "<group>"; };
|
||||
81B1729BB2EEFB2F938A4ED1 /* KokoroPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KokoroPipeline.swift; sourceTree = "<group>"; };
|
||||
84CAEC669F2D50C02A202551 /* ModelManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelManifest.swift; sourceTree = "<group>"; };
|
||||
85269B5CD0BEC628EE701F6D /* TextChunker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextChunker.swift; sourceTree = "<group>"; };
|
||||
8865DCEC0F532253D25B3D97 /* MacLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacLibraryViewModel.swift; sourceTree = "<group>"; };
|
||||
9565A2BC053068E43C4023E8 /* KokoroTokenizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KokoroTokenizer.swift; sourceTree = "<group>"; };
|
||||
996137D48F183F1E4C91800B /* AudioSynthesisService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSynthesisService.swift; sourceTree = "<group>"; };
|
||||
A0658A4F43BB4E96F5F4FFEE /* ReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderView.swift; sourceTree = "<group>"; };
|
||||
A25386256250D132B07A40A8 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = "<group>"; };
|
||||
A337EE4E3A4036F440E4FD9C /* kokoro_f0n_10s.mlpackage */ = {isa = PBXFileReference; path = kokoro_f0n_10s.mlpackage; sourceTree = "<group>"; };
|
||||
B1895F72980568E5F7EFA85E /* kokoro_decoder_only_10s.mlpackage */ = {isa = PBXFileReference; path = kokoro_decoder_only_10s.mlpackage; sourceTree = "<group>"; };
|
||||
B5CA6531783A31383ABBC5D2 /* VorleserApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorleserApp.swift; sourceTree = "<group>"; };
|
||||
B96222D416F28D8ADABB8690 /* config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = config.json; sourceTree = "<group>"; };
|
||||
BF069F9DF9CB378E19213C1A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
C3FFA134DDAED316C5CFB082 /* SynthesisWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynthesisWorker.swift; sourceTree = "<group>"; };
|
||||
D01E3A445F73A6332FD740DC /* EPUBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBService.swift; sourceTree = "<group>"; };
|
||||
E3DB5C54ADDC336BDEE52370 /* am_michael.pt */ = {isa = PBXFileReference; path = am_michael.pt; sourceTree = "<group>"; };
|
||||
E4BBAF6BE51E77F99FA99D6A /* AssetVerifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetVerifier.swift; sourceTree = "<group>"; };
|
||||
E81F0CBA9F875D46BC1526E3 /* am_michael.f32 */ = {isa = PBXFileReference; path = am_michael.f32; sourceTree = "<group>"; };
|
||||
EEBD2C99C256768BB156EFCC /* ReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderViewModel.swift; sourceTree = "<group>"; };
|
||||
F602A780D06CF08113B0B040 /* Vorleser.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Vorleser.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
FEC91648998FFBEA606BB391 /* MacContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacContentView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
1252E70F7090988D4BD7BFEE /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5D711DB48ABE34775D5FDB98 /* ZIPFoundation in Frameworks */,
|
||||
548CB9DAF59F86B24974F8E4 /* SwiftSoup in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
59B4BBC05DBAE06B85C55206 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
7F7A8848F3F6A6305B69F207 /* ZIPFoundation in Frameworks */,
|
||||
91D5040FD66AC881D66737AC /* SwiftSoup in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
00DDDCA9D08C8871DFE5E2C6 /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E4BBAF6BE51E77F99FA99D6A /* AssetVerifier.swift */,
|
||||
607049D947C8E99A2EFEAE2B /* AudioPlaybackService.swift */,
|
||||
1736FACE2FDE0410DA52FDCF /* EPUBService.swift */,
|
||||
63F44ED11BB4C142F40FB9C7 /* EspeakPhonemizer.swift */,
|
||||
81B1729BB2EEFB2F938A4ED1 /* KokoroPipeline.swift */,
|
||||
9565A2BC053068E43C4023E8 /* KokoroTokenizer.swift */,
|
||||
8865DCEC0F532253D25B3D97 /* MacLibraryViewModel.swift */,
|
||||
C3FFA134DDAED316C5CFB082 /* SynthesisWorker.swift */,
|
||||
06CD128D7B6A97B4FCE4029C /* TextChunker.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0EB0E6D095C1B61F22CFCA3A /* Config */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B96222D416F28D8ADABB8690 /* config.json */,
|
||||
);
|
||||
path = Config;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
453A1AEE6DFF6B0971E3C127 /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0EB0E6D095C1B61F22CFCA3A /* Config */,
|
||||
967C242FA01032FE1F4FD74A /* Models */,
|
||||
CB478F6D37690D8B63AC03E6 /* Voices */,
|
||||
);
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5C5F841A908AC94F515A14DF /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
996137D48F183F1E4C91800B /* AudioSynthesisService.swift */,
|
||||
D01E3A445F73A6332FD740DC /* EPUBService.swift */,
|
||||
A25386256250D132B07A40A8 /* LibraryViewModel.swift */,
|
||||
095B8213401737EBE5BD051E /* ModelDownloadManager.swift */,
|
||||
84CAEC669F2D50C02A202551 /* ModelManifest.swift */,
|
||||
EEBD2C99C256768BB156EFCC /* ReaderViewModel.swift */,
|
||||
85269B5CD0BEC628EE701F6D /* TextChunker.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
967C242FA01032FE1F4FD74A /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1305807ABD715C606FEA6DFA /* kokoro_decoder_only_3s.mlpackage */,
|
||||
B1895F72980568E5F7EFA85E /* kokoro_decoder_only_10s.mlpackage */,
|
||||
69C5DD780EBC730C24A7EAD2 /* kokoro_duration.mlpackage */,
|
||||
7B8464C0EC10657806B6E626 /* kokoro_f0n_3s.mlpackage */,
|
||||
A337EE4E3A4036F440E4FD9C /* kokoro_f0n_10s.mlpackage */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA2551B713A3837EBFFE36ED = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CA0FE0B06C7F73AE98CB9C0E /* Config */,
|
||||
B6DABD163E3B1A42BA34A10D /* Packages */,
|
||||
C91060BFBE7C457FBA96A1D3 /* Vorleser */,
|
||||
B53E7A13A3D61433250080D0 /* VorleserMac */,
|
||||
FEFF49B59E5B20C71387C257 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B53E7A13A3D61433250080D0 /* VorleserMac */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FEC91648998FFBEA606BB391 /* MacContentView.swift */,
|
||||
3CFB7D317E65F1324C34D3F0 /* VorleserMacApp.swift */,
|
||||
453A1AEE6DFF6B0971E3C127 /* Resources */,
|
||||
00DDDCA9D08C8871DFE5E2C6 /* Services */,
|
||||
);
|
||||
path = VorleserMac;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B6DABD163E3B1A42BA34A10D /* Packages */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0655C39862135E3CC301233A /* SwiftSoup */,
|
||||
78B456910B098FF29B2C3DCC /* ZipFoundation */,
|
||||
);
|
||||
name = Packages;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B7584ED8034338CD7C617867 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0DA26953AA44A2E7FD5A02BE /* DocumentPicker.swift */,
|
||||
A0658A4F43BB4E96F5F4FFEE /* ReaderView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BF849F2658E55D7F49854887 /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7B83FC4EDCDD4B8E955E50F5 /* BookItem.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C91060BFBE7C457FBA96A1D3 /* Vorleser */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BF069F9DF9CB378E19213C1A /* ContentView.swift */,
|
||||
B5CA6531783A31383ABBC5D2 /* VorleserApp.swift */,
|
||||
BF849F2658E55D7F49854887 /* Models */,
|
||||
5C5F841A908AC94F515A14DF /* Services */,
|
||||
B7584ED8034338CD7C617867 /* Views */,
|
||||
);
|
||||
path = Vorleser;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CA0FE0B06C7F73AE98CB9C0E /* Config */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3857F14DFCD4924A4340F42B /* Signing.xcconfig */,
|
||||
);
|
||||
path = Config;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CB478F6D37690D8B63AC03E6 /* Voices */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2261729AEC15D2904FC451D7 /* am_michael_256.f32 */,
|
||||
E81F0CBA9F875D46BC1526E3 /* am_michael.f32 */,
|
||||
E3DB5C54ADDC336BDEE52370 /* am_michael.pt */,
|
||||
);
|
||||
path = Voices;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FEFF49B59E5B20C71387C257 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F602A780D06CF08113B0B040 /* Vorleser.app */,
|
||||
7241D29C019B5B111851F09E /* VorleserMac.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
15368B3CC5F10505A5542431 /* VorleserMac */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 910E34933F556E9FD04522FB /* Build configuration list for PBXNativeTarget "VorleserMac" */;
|
||||
buildPhases = (
|
||||
421B395AF45EAB2389E36EDA /* Sources */,
|
||||
1FAD9B3D3D372A7193D268DA /* Resources */,
|
||||
59B4BBC05DBAE06B85C55206 /* Frameworks */,
|
||||
19C2D6A4838A08DD26BA48F9 /* Bundle espeak-ng */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = VorleserMac;
|
||||
packageProductDependencies = (
|
||||
EFE297A06C9DB8F3A03CC722 /* ZIPFoundation */,
|
||||
40DA6F30AF230EF31BE90E5D /* SwiftSoup */,
|
||||
);
|
||||
productName = VorleserMac;
|
||||
productReference = 7241D29C019B5B111851F09E /* VorleserMac.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
F8874C75585C039A65C14A8F /* Vorleser */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 08279EC5D81ED3A0A3B8E7C8 /* Build configuration list for PBXNativeTarget "Vorleser" */;
|
||||
buildPhases = (
|
||||
6B2D8F9CBB5D255FABC2DF25 /* Sources */,
|
||||
1252E70F7090988D4BD7BFEE /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Vorleser;
|
||||
packageProductDependencies = (
|
||||
C1752C03CDF5CAD6901643D1 /* ZIPFoundation */,
|
||||
B0D36FF6F6C2B26774D08DC7 /* SwiftSoup */,
|
||||
);
|
||||
productName = Vorleser;
|
||||
productReference = F602A780D06CF08113B0B040 /* Vorleser.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
883E55BF581DFC7ED329F0DA /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1430;
|
||||
TargetAttributes = {
|
||||
15368B3CC5F10505A5542431 = {
|
||||
DevelopmentTeam = NG5W75WE8U;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
F8874C75585C039A65C14A8F = {
|
||||
DevelopmentTeam = NG5W75WE8U;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 5C0E46E98AB9768E0EDC7F1B /* Build configuration list for PBXProject "Vorleser" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
Base,
|
||||
en,
|
||||
);
|
||||
mainGroup = AA2551B713A3837EBFFE36ED;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
7F54DACBE04DB2E26884B508 /* XCLocalSwiftPackageReference "Vendor/SwiftSoup" */,
|
||||
675AD79D8C32F0F3F21CB7B5 /* XCLocalSwiftPackageReference "Vendor/ZipFoundation" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
F8874C75585C039A65C14A8F /* Vorleser */,
|
||||
15368B3CC5F10505A5542431 /* VorleserMac */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
1FAD9B3D3D372A7193D268DA /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5922CC46A3F17E81D35176AA /* am_michael.f32 in Resources */,
|
||||
A7BFB668CD8C8D9BF53970A8 /* am_michael.pt in Resources */,
|
||||
857BA97045E2D5DC1BE459CA /* am_michael_256.f32 in Resources */,
|
||||
8F70FF75CF4252218AB7D9B9 /* config.json in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
19C2D6A4838A08DD26BA48F9 /* Bundle espeak-ng */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Bundle espeak-ng";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Resources/Tools/espeak-ng",
|
||||
"${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Resources/Tools/espeak-ng-data/phondata",
|
||||
"${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Frameworks/libespeak-ng.dylib",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "set -euo pipefail\nBIN_SRC=\"/opt/homebrew/bin/espeak-ng\"\nDATA_SRC=\"/opt/homebrew/share/espeak-ng-data\"\nDYLIB_SRC=\"/opt/homebrew/lib/libespeak-ng.dylib\"\n\nif [ ! -x \"$BIN_SRC\" ]; then\n echo \"espeak-ng not found at $BIN_SRC. Install via: brew install espeak-ng\"\n exit 1\nfi\nif [ ! -d \"$DATA_SRC\" ]; then\n echo \"espeak-ng data not found at $DATA_SRC\"\n exit 1\nfi\nif [ ! -f \"$DYLIB_SRC\" ]; then\n echo \"libespeak-ng.dylib not found at $DYLIB_SRC\"\n exit 1\nfi\n\nRES_DIR=\"${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Resources\"\nTOOL_DIR=\"${RES_DIR}/Tools\"\nFRAMEWORK_DIR=\"${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Frameworks\"\nmkdir -p \"$TOOL_DIR\" \"$FRAMEWORK_DIR\"\n\ncp -f \"$BIN_SRC\" \"$TOOL_DIR/espeak-ng\"\nchmod +x \"$TOOL_DIR/espeak-ng\"\nrsync -a \"$DATA_SRC/\" \"$TOOL_DIR/espeak-ng-data/\"\ncp -f \"$DYLIB_SRC\" \"$FRAMEWORK_DIR/libespeak-ng.dylib\"\n\ninstall_name_tool -id \"@rpath/libespeak-ng.dylib\" \"$FRAMEWORK_DIR/libespeak-ng.dylib\"\ninstall_name_tool -change \"$DYLIB_SRC\" \"@rpath/libespeak-ng.dylib\" \"$TOOL_DIR/espeak-ng\"\ninstall_name_tool -add_rpath \"@executable_path/../Frameworks\" \"$TOOL_DIR/espeak-ng\" || true\n\ncodesign --force --sign - --timestamp=none \"$FRAMEWORK_DIR/libespeak-ng.dylib\"\ncodesign --force --sign - --timestamp=none \"$TOOL_DIR/espeak-ng\"\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
421B395AF45EAB2389E36EDA /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
9C182CD3A6163FB87780D9B6 /* AssetVerifier.swift in Sources */,
|
||||
1173DA36CC81A687FCD9ADEF /* AudioPlaybackService.swift in Sources */,
|
||||
9C0EE7F98FDCBE710E0FABF2 /* EPUBService.swift in Sources */,
|
||||
D1CE101DCF0DE54F6F301C95 /* EspeakPhonemizer.swift in Sources */,
|
||||
774433CFD75BDD0BD3FD7CE3 /* KokoroPipeline.swift in Sources */,
|
||||
A70470255CE4892795B128A2 /* KokoroTokenizer.swift in Sources */,
|
||||
2873719E4068119DC4AF8E6E /* MacContentView.swift in Sources */,
|
||||
C6AFD11DD6725CA9422C419E /* MacLibraryViewModel.swift in Sources */,
|
||||
509AA480B488B31919F29088 /* SynthesisWorker.swift in Sources */,
|
||||
4CE6864A9ED8CC539C8B7CE5 /* TextChunker.swift in Sources */,
|
||||
D577A9313BC7656040C53FD9 /* VorleserMacApp.swift in Sources */,
|
||||
C37859F5789D350B87B6992F /* kokoro_decoder_only_10s.mlpackage in Sources */,
|
||||
9CAD6CF7B191D03B2DF31D1C /* kokoro_decoder_only_3s.mlpackage in Sources */,
|
||||
AFB35F64AFB50B3ED9569E5A /* kokoro_duration.mlpackage in Sources */,
|
||||
A2FEFDB8B74FBB03FFAC8E98 /* kokoro_f0n_10s.mlpackage in Sources */,
|
||||
EC040FC1433D4B3628D69765 /* kokoro_f0n_3s.mlpackage in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
6B2D8F9CBB5D255FABC2DF25 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
BF4C90C35F076CFB1374688D /* AudioSynthesisService.swift in Sources */,
|
||||
8B60D8666A4A93F9E89E3D9A /* BookItem.swift in Sources */,
|
||||
68B454E96B643A49C8575B1F /* ContentView.swift in Sources */,
|
||||
610C2F9387026F62F61C8CF2 /* DocumentPicker.swift in Sources */,
|
||||
6215D254F0FFF5DA2B20AECA /* EPUBService.swift in Sources */,
|
||||
6665F2B72ACD3728EB59EAF0 /* LibraryViewModel.swift in Sources */,
|
||||
68907CD0425F824EDEAA474B /* ModelDownloadManager.swift in Sources */,
|
||||
ADD9720B3F47ADA5662A1252 /* ModelManifest.swift in Sources */,
|
||||
6553CBE1C2A85C0E3B5AAB32 /* ReaderView.swift in Sources */,
|
||||
E8BDFA760B9CFE0B8FAFF8B8 /* ReaderViewModel.swift in Sources */,
|
||||
16A7B21A1735D83F72ABE479 /* TextChunker.swift in Sources */,
|
||||
34166D992482C7B7426B5522 /* VorleserApp.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
0A73B635C02BCC20E4C867D1 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 3857F14DFCD4924A4340F42B /* Signing.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleShortVersionString = 0.1.0;
|
||||
INFOPLIST_KEY_CFBundleVersion = 1;
|
||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
|
||||
INFOPLIST_KEY_UIBackgroundModes = (
|
||||
audio,
|
||||
processing,
|
||||
);
|
||||
INFOPLIST_KEY_UIFileSharingEnabled = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.felixfoertsch.vorleser;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
127706133EE774A829656100 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 3857F14DFCD4924A4340F42B /* Signing.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleShortVersionString = 0.1.0;
|
||||
INFOPLIST_KEY_CFBundleVersion = 1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.felixfoertsch.vorleser.mac;
|
||||
SDKROOT = macosx;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
6511FD010BF67ACEA7CA9239 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 3857F14DFCD4924A4340F42B /* Signing.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleShortVersionString = 0.1.0;
|
||||
INFOPLIST_KEY_CFBundleVersion = 1;
|
||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
|
||||
INFOPLIST_KEY_UIBackgroundModes = (
|
||||
audio,
|
||||
processing,
|
||||
);
|
||||
INFOPLIST_KEY_UIFileSharingEnabled = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.felixfoertsch.vorleser;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
8C1B3BB72A7DF57561B03D25 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 3857F14DFCD4924A4340F42B /* Signing.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleShortVersionString = 0.1.0;
|
||||
INFOPLIST_KEY_CFBundleVersion = 1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.felixfoertsch.vorleser.mac;
|
||||
SDKROOT = macosx;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
B99A49578EB57D09FC154DE2 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
"DEBUG=1",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.9;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
CC4A9125F315FD4E9A1D3873 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.9;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
08279EC5D81ED3A0A3B8E7C8 /* Build configuration list for PBXNativeTarget "Vorleser" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
6511FD010BF67ACEA7CA9239 /* Debug */,
|
||||
0A73B635C02BCC20E4C867D1 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
5C0E46E98AB9768E0EDC7F1B /* Build configuration list for PBXProject "Vorleser" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
B99A49578EB57D09FC154DE2 /* Debug */,
|
||||
CC4A9125F315FD4E9A1D3873 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
910E34933F556E9FD04522FB /* Build configuration list for PBXNativeTarget "VorleserMac" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
8C1B3BB72A7DF57561B03D25 /* Debug */,
|
||||
127706133EE774A829656100 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
675AD79D8C32F0F3F21CB7B5 /* XCLocalSwiftPackageReference "Vendor/ZipFoundation" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Vendor/ZipFoundation;
|
||||
};
|
||||
7F54DACBE04DB2E26884B508 /* XCLocalSwiftPackageReference "Vendor/SwiftSoup" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Vendor/SwiftSoup;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
40DA6F30AF230EF31BE90E5D /* SwiftSoup */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = SwiftSoup;
|
||||
};
|
||||
B0D36FF6F6C2B26774D08DC7 /* SwiftSoup */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = SwiftSoup;
|
||||
};
|
||||
C1752C03CDF5CAD6901643D1 /* ZIPFoundation */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = ZIPFoundation;
|
||||
};
|
||||
EFE297A06C9DB8F3A03CC722 /* ZIPFoundation */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = ZIPFoundation;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 883E55BF581DFC7ED329F0DA /* Project object */;
|
||||
}
|
||||
7
Vorleser.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
Vorleser.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "lrucache",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/nicklockwood/LRUCache.git",
|
||||
"state" : {
|
||||
"revision" : "cb5b2bd0da83ad29c0bec762d39f41c8ad0eaf3e",
|
||||
"version" : "1.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-atomics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-atomics.git",
|
||||
"state" : {
|
||||
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
|
||||
"version" : "1.3.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
94
Vorleser/ContentView.swift
Normal file
94
Vorleser/ContentView.swift
Normal file
@@ -0,0 +1,94 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var viewModel = LibraryViewModel()
|
||||
@State private var selectedItem: BookItem?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
header
|
||||
|
||||
if viewModel.items.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
bookList
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(20)
|
||||
.navigationTitle("Vorleser")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Import EPUB") {
|
||||
viewModel.isImporting = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $viewModel.isImporting) {
|
||||
DocumentPicker(onPick: { url in
|
||||
viewModel.isImporting = false
|
||||
viewModel.importPicked(url: url)
|
||||
}, onCancel: {
|
||||
viewModel.isImporting = false
|
||||
})
|
||||
}
|
||||
.alert("Import failed", isPresented: .constant(viewModel.lastErrorMessage != nil), actions: {
|
||||
Button("OK", role: .cancel) {
|
||||
viewModel.lastErrorMessage = nil
|
||||
}
|
||||
}, message: {
|
||||
Text(viewModel.lastErrorMessage ?? "")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Local EPUB reader")
|
||||
.font(.title2.weight(.semibold))
|
||||
Text("On-device, high-quality narration powered by CoreML.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("No books yet")
|
||||
.font(.headline)
|
||||
Text("Import an EPUB from Files or iCloud to start preparing narration.")
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Import EPUB") {
|
||||
viewModel.isImporting = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(.thinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
|
||||
private var bookList: some View {
|
||||
List(viewModel.items) { item in
|
||||
NavigationLink(value: item) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(item.title)
|
||||
.font(.headline)
|
||||
Text(item.sourceURL.lastPathComponent)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.navigationDestination(for: BookItem.self) { item in
|
||||
ReaderView(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
}
|
||||
15
Vorleser/Models/BookItem.swift
Normal file
15
Vorleser/Models/BookItem.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
struct BookItem: Identifiable, Hashable {
|
||||
let id: UUID
|
||||
let title: String
|
||||
let sourceURL: URL
|
||||
let addedAt: Date
|
||||
|
||||
init(title: String, sourceURL: URL, addedAt: Date = Date()) {
|
||||
self.id = UUID()
|
||||
self.title = title
|
||||
self.sourceURL = sourceURL
|
||||
self.addedAt = addedAt
|
||||
}
|
||||
}
|
||||
42
Vorleser/Services/AudioSynthesisService.swift
Normal file
42
Vorleser/Services/AudioSynthesisService.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import CoreML
|
||||
|
||||
final class AudioSynthesisService {
|
||||
struct SynthesisChunk {
|
||||
let pcmData: Data
|
||||
let sampleRate: Double
|
||||
let channels: AVAudioChannelCount
|
||||
}
|
||||
|
||||
struct KokoroModelPaths {
|
||||
let durationModel: URL
|
||||
let decoder3s: URL
|
||||
let decoder10s: URL
|
||||
let voicesDirectory: URL
|
||||
let tokenizerConfig: URL
|
||||
}
|
||||
|
||||
private(set) var isPrepared = false
|
||||
|
||||
func prepareIfNeeded(paths: KokoroModelPaths) async throws {
|
||||
guard !isPrepared else { return }
|
||||
_ = try await compileIfNeeded(at: paths.durationModel)
|
||||
_ = try await compileIfNeeded(at: paths.decoder3s)
|
||||
_ = try await compileIfNeeded(at: paths.decoder10s)
|
||||
isPrepared = true
|
||||
}
|
||||
|
||||
func synthesize(text: String) async throws -> SynthesisChunk {
|
||||
// TODO: Kokoro CoreML inference goes here.
|
||||
throw NSError(domain: "AudioSynthesisService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Kokoro pipeline not implemented yet."])
|
||||
}
|
||||
|
||||
private func compileIfNeeded(at modelURL: URL) async throws -> URL {
|
||||
if modelURL.pathExtension == "mlmodelc" {
|
||||
return modelURL
|
||||
}
|
||||
let compiledURL = try await MLModel.compileModel(at: modelURL)
|
||||
return compiledURL
|
||||
}
|
||||
}
|
||||
190
Vorleser/Services/EPUBService.swift
Normal file
190
Vorleser/Services/EPUBService.swift
Normal file
@@ -0,0 +1,190 @@
|
||||
import Foundation
|
||||
import ZIPFoundation
|
||||
import SwiftSoup
|
||||
|
||||
struct EPUBChapter: Identifiable, Hashable {
|
||||
let id: UUID
|
||||
let title: String
|
||||
let rawText: String
|
||||
|
||||
init(title: String, rawText: String) {
|
||||
self.id = UUID()
|
||||
self.title = title
|
||||
self.rawText = rawText
|
||||
}
|
||||
}
|
||||
|
||||
final class EPUBService {
|
||||
enum EPUBError: Error, LocalizedError {
|
||||
case missingContainer
|
||||
case missingRootfile
|
||||
case missingOPF
|
||||
case invalidOPF
|
||||
case missingSpine
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingContainer:
|
||||
return "Missing META-INF/container.xml"
|
||||
case .missingRootfile:
|
||||
return "container.xml did not include a rootfile"
|
||||
case .missingOPF:
|
||||
return "OPF file missing"
|
||||
case .invalidOPF:
|
||||
return "OPF parsing failed"
|
||||
case .missingSpine:
|
||||
return "OPF spine is empty"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractChapters(from epubURL: URL) throws -> [EPUBChapter] {
|
||||
let tmpDir = try createTempDirectory()
|
||||
try unzip(epubURL, to: tmpDir)
|
||||
|
||||
let containerURL = tmpDir.appendingPathComponent("META-INF/container.xml")
|
||||
guard FileManager.default.fileExists(atPath: containerURL.path) else {
|
||||
throw EPUBError.missingContainer
|
||||
}
|
||||
|
||||
let containerData = try Data(contentsOf: containerURL)
|
||||
let rootfilePath = try parseContainer(data: containerData)
|
||||
guard let rootfilePath else {
|
||||
throw EPUBError.missingRootfile
|
||||
}
|
||||
|
||||
let opfURL = tmpDir.appendingPathComponent(rootfilePath)
|
||||
guard FileManager.default.fileExists(atPath: opfURL.path) else {
|
||||
throw EPUBError.missingOPF
|
||||
}
|
||||
|
||||
let opfData = try Data(contentsOf: opfURL)
|
||||
let opfResult = try parseOPF(data: opfData)
|
||||
guard !opfResult.spine.isEmpty else {
|
||||
throw EPUBError.missingSpine
|
||||
}
|
||||
|
||||
let baseURL = opfURL.deletingLastPathComponent()
|
||||
var chapters: [EPUBChapter] = []
|
||||
|
||||
for idref in opfResult.spine {
|
||||
guard let href = opfResult.manifest[idref] else { continue }
|
||||
let contentURL = baseURL.appendingPathComponent(href)
|
||||
guard FileManager.default.fileExists(atPath: contentURL.path) else { continue }
|
||||
|
||||
let html = try String(contentsOf: contentURL, encoding: .utf8)
|
||||
let text = try SwiftSoup.parse(html).text()
|
||||
let title = try extractTitle(from: html) ?? contentURL.deletingPathExtension().lastPathComponent
|
||||
let cleaned = cleanText(text)
|
||||
if cleaned.isEmpty { continue }
|
||||
chapters.append(EPUBChapter(title: title, rawText: cleaned))
|
||||
}
|
||||
|
||||
return chapters
|
||||
}
|
||||
|
||||
private func unzip(_ url: URL, to destination: URL) throws {
|
||||
let archive = try Archive(url: url, accessMode: .read)
|
||||
for entry in archive {
|
||||
let entryURL = destination.appendingPathComponent(entry.path)
|
||||
let parent = entryURL.deletingLastPathComponent()
|
||||
if !FileManager.default.fileExists(atPath: parent.path) {
|
||||
try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true)
|
||||
}
|
||||
_ = try archive.extract(entry, to: entryURL)
|
||||
}
|
||||
}
|
||||
|
||||
private func createTempDirectory() throws -> URL {
|
||||
let base = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
let dir = base.appendingPathComponent("epub_\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir
|
||||
}
|
||||
|
||||
private func parseContainer(data: Data) throws -> String? {
|
||||
let parser = XMLParser(data: data)
|
||||
let delegate = ContainerParser()
|
||||
parser.delegate = delegate
|
||||
parser.parse()
|
||||
return delegate.rootfilePath
|
||||
}
|
||||
|
||||
private func parseOPF(data: Data) throws -> OPFParseResult {
|
||||
let parser = XMLParser(data: data)
|
||||
let delegate = OPFParser()
|
||||
parser.delegate = delegate
|
||||
parser.parse()
|
||||
return OPFParseResult(title: delegate.title?.trimmingCharacters(in: .whitespacesAndNewlines), manifest: delegate.manifest, spine: delegate.spine)
|
||||
}
|
||||
|
||||
private func extractTitle(from html: String) throws -> String? {
|
||||
let doc = try SwiftSoup.parse(html)
|
||||
if let h1 = try doc.select("h1").first() {
|
||||
return try h1.text()
|
||||
}
|
||||
if let title = try doc.select("title").first() {
|
||||
return try title.text()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func cleanText(_ text: String) -> String {
|
||||
let collapsed = text.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
return collapsed.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
private struct OPFParseResult {
|
||||
let title: String?
|
||||
let manifest: [String: String]
|
||||
let spine: [String]
|
||||
}
|
||||
|
||||
private final class ContainerParser: NSObject, XMLParserDelegate {
|
||||
var rootfilePath: String?
|
||||
|
||||
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {
|
||||
guard elementName.lowercased().contains("rootfile") else { return }
|
||||
if let path = attributeDict["full-path"] {
|
||||
rootfilePath = path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class OPFParser: NSObject, XMLParserDelegate {
|
||||
var manifest: [String: String] = [:]
|
||||
var spine: [String] = []
|
||||
var title: String?
|
||||
|
||||
private var isCollectingTitle = false
|
||||
private var titleBuffer = ""
|
||||
|
||||
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {
|
||||
let name = elementName.lowercased()
|
||||
if name == "item", let id = attributeDict["id"], let href = attributeDict["href"] {
|
||||
manifest[id] = href
|
||||
} else if name == "itemref", let idref = attributeDict["idref"] {
|
||||
spine.append(idref)
|
||||
} else if name.hasSuffix("title"), title == nil {
|
||||
isCollectingTitle = true
|
||||
titleBuffer = ""
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
if isCollectingTitle {
|
||||
titleBuffer.append(string)
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
|
||||
if isCollectingTitle, elementName.lowercased().hasSuffix("title") {
|
||||
let trimmed = titleBuffer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
title = trimmed
|
||||
}
|
||||
isCollectingTitle = false
|
||||
}
|
||||
}
|
||||
}
|
||||
16
Vorleser/Services/LibraryViewModel.swift
Normal file
16
Vorleser/Services/LibraryViewModel.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class LibraryViewModel: ObservableObject {
|
||||
@Published private(set) var items: [BookItem] = []
|
||||
@Published var isImporting: Bool = false
|
||||
@Published var lastErrorMessage: String?
|
||||
|
||||
func importPicked(url: URL) {
|
||||
let title = url.deletingPathExtension().lastPathComponent
|
||||
let item = BookItem(title: title, sourceURL: url)
|
||||
items.insert(item, at: 0)
|
||||
}
|
||||
}
|
||||
66
Vorleser/Services/ModelDownloadManager.swift
Normal file
66
Vorleser/Services/ModelDownloadManager.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class ModelDownloadManager: ObservableObject {
|
||||
enum DownloadState: Equatable {
|
||||
case idle
|
||||
case downloading(String)
|
||||
case completed
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
@Published private(set) var state: DownloadState = .idle
|
||||
@Published private(set) var progress: Double = 0
|
||||
|
||||
func downloadAll(manifest: ModelManifest) async {
|
||||
state = .downloading("Preparing download")
|
||||
progress = 0
|
||||
|
||||
let allAssets = manifest.assets + manifest.voices + [manifest.tokenizer]
|
||||
let total = Double(allAssets.count)
|
||||
var completed = 0.0
|
||||
|
||||
do {
|
||||
for asset in allAssets {
|
||||
state = .downloading("Downloading \(asset.name)")
|
||||
_ = try await download(asset: asset)
|
||||
completed += 1
|
||||
progress = completed / total
|
||||
}
|
||||
state = .completed
|
||||
} catch {
|
||||
state = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func localURL(for asset: ModelManifest.Asset) throws -> URL {
|
||||
let destination = try Self.modelsDirectory().appendingPathComponent(asset.localFolderName, isDirectory: true)
|
||||
return destination.appendingPathComponent(asset.remoteURL.lastPathComponent)
|
||||
}
|
||||
|
||||
func download(asset: ModelManifest.Asset) async throws -> URL {
|
||||
let destination = try Self.modelsDirectory().appendingPathComponent(asset.localFolderName, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
|
||||
|
||||
let (tempURL, response) = try await URLSession.shared.download(from: asset.remoteURL)
|
||||
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
||||
let finalURL = destination.appendingPathComponent(asset.remoteURL.lastPathComponent)
|
||||
if FileManager.default.fileExists(atPath: finalURL.path) {
|
||||
try FileManager.default.removeItem(at: finalURL)
|
||||
}
|
||||
try FileManager.default.moveItem(at: tempURL, to: finalURL)
|
||||
return finalURL
|
||||
}
|
||||
|
||||
static func modelsDirectory() throws -> URL {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let folder = base.appendingPathComponent("Models", isDirectory: true)
|
||||
if !FileManager.default.fileExists(atPath: folder.path) {
|
||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
}
|
||||
return folder
|
||||
}
|
||||
}
|
||||
33
Vorleser/Services/ModelManifest.swift
Normal file
33
Vorleser/Services/ModelManifest.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
struct ModelManifest {
|
||||
struct Asset: Hashable {
|
||||
let name: String
|
||||
let remoteURL: URL
|
||||
let localFolderName: String
|
||||
}
|
||||
|
||||
let modelName: String
|
||||
let assets: [Asset]
|
||||
let voices: [Asset]
|
||||
let tokenizer: Asset
|
||||
|
||||
static func kokoro() -> ModelManifest {
|
||||
// TODO: Replace URLs with your hosted files.
|
||||
let base = URL(string: "https://example.com/kokoro/")!
|
||||
|
||||
let assets = [
|
||||
Asset(name: "duration", remoteURL: base.appendingPathComponent("kokoro_duration.mlpackage"), localFolderName: "kokoro"),
|
||||
Asset(name: "decoder_3s", remoteURL: base.appendingPathComponent("KokoroDecoder_HAR_3s.mlpackage"), localFolderName: "kokoro"),
|
||||
Asset(name: "decoder_10s", remoteURL: base.appendingPathComponent("KokoroDecoder_HAR_10s.mlpackage"), localFolderName: "kokoro")
|
||||
]
|
||||
|
||||
let voices = [
|
||||
Asset(name: "am_michael", remoteURL: base.appendingPathComponent("voices/am_michael.pt"), localFolderName: "kokoro/voices")
|
||||
]
|
||||
|
||||
let tokenizer = Asset(name: "tokenizer", remoteURL: base.appendingPathComponent("checkpoints/config.json"), localFolderName: "kokoro")
|
||||
|
||||
return ModelManifest(modelName: "Kokoro", assets: assets, voices: voices, tokenizer: tokenizer)
|
||||
}
|
||||
}
|
||||
61
Vorleser/Services/ReaderViewModel.swift
Normal file
61
Vorleser/Services/ReaderViewModel.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
final class ReaderViewModel: ObservableObject {
|
||||
enum PreparationState: Equatable {
|
||||
case idle
|
||||
case parsing
|
||||
case ready(Int)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
@Published private(set) var preparationState: PreparationState = .idle
|
||||
@Published private(set) var chapters: [EPUBChapter] = []
|
||||
@Published var isPreparingModel: Bool = false
|
||||
|
||||
let book: BookItem
|
||||
let downloadManager = ModelDownloadManager()
|
||||
|
||||
private let epubService = EPUBService()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
var modelDownloadState: ModelDownloadManager.DownloadState {
|
||||
downloadManager.state
|
||||
}
|
||||
|
||||
var modelDownloadProgress: Double {
|
||||
downloadManager.progress
|
||||
}
|
||||
|
||||
init(book: BookItem) {
|
||||
self.book = book
|
||||
downloadManager.objectWillChange
|
||||
.sink { [weak self] _ in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func parseBook() {
|
||||
preparationState = .parsing
|
||||
Task {
|
||||
do {
|
||||
let chapters = try epubService.extractChapters(from: book.sourceURL)
|
||||
self.chapters = chapters
|
||||
preparationState = .ready(chapters.count)
|
||||
} catch {
|
||||
preparationState = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func downloadModel() {
|
||||
Task {
|
||||
isPreparingModel = true
|
||||
await downloadManager.downloadAll(manifest: .kokoro())
|
||||
isPreparingModel = false
|
||||
}
|
||||
}
|
||||
}
|
||||
40
Vorleser/Services/TextChunker.swift
Normal file
40
Vorleser/Services/TextChunker.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
import NaturalLanguage
|
||||
|
||||
struct TextChunker {
|
||||
let maxCharacters: Int
|
||||
|
||||
init(maxCharacters: Int = 900) {
|
||||
self.maxCharacters = maxCharacters
|
||||
}
|
||||
|
||||
func chunk(_ text: String) -> [String] {
|
||||
let tokenizer = NLTokenizer(unit: .sentence)
|
||||
tokenizer.string = text
|
||||
|
||||
var chunks: [String] = []
|
||||
var current = ""
|
||||
|
||||
tokenizer.enumerateTokens(in: text.startIndex..<text.endIndex) { range, _ in
|
||||
let sentence = String(text[range]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !sentence.isEmpty else { return true }
|
||||
|
||||
if current.count + sentence.count + 1 > maxCharacters {
|
||||
if !current.isEmpty {
|
||||
chunks.append(current.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
current = ""
|
||||
}
|
||||
}
|
||||
|
||||
current.append(sentence)
|
||||
current.append(" ")
|
||||
return true
|
||||
}
|
||||
|
||||
if !current.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
chunks.append(current.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
}
|
||||
41
Vorleser/Views/DocumentPicker.swift
Normal file
41
Vorleser/Views/DocumentPicker.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct DocumentPicker: UIViewControllerRepresentable {
|
||||
let onPick: (URL) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
||||
let types: [UTType] = [UTType(filenameExtension: "epub")].compactMap { $0 }
|
||||
let controller = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
|
||||
controller.allowsMultipleSelection = false
|
||||
controller.delegate = context.coordinator
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(onPick: onPick, onCancel: onCancel)
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, UIDocumentPickerDelegate {
|
||||
private let onPick: (URL) -> Void
|
||||
private let onCancel: () -> Void
|
||||
|
||||
init(onPick: @escaping (URL) -> Void, onCancel: @escaping () -> Void) {
|
||||
self.onPick = onPick
|
||||
self.onCancel = onCancel
|
||||
}
|
||||
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
guard let url = urls.first else { return }
|
||||
onPick(url)
|
||||
}
|
||||
|
||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
96
Vorleser/Views/ReaderView.swift
Normal file
96
Vorleser/Views/ReaderView.swift
Normal file
@@ -0,0 +1,96 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ReaderView: View {
|
||||
@StateObject private var viewModel: ReaderViewModel
|
||||
|
||||
init(item: BookItem) {
|
||||
_viewModel = StateObject(wrappedValue: ReaderViewModel(book: item))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(viewModel.book.title)
|
||||
.font(.title2.weight(.semibold))
|
||||
|
||||
modelSection
|
||||
|
||||
Divider()
|
||||
|
||||
parsingSection
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(20)
|
||||
.navigationTitle("Reader")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var modelSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Kokoro Model")
|
||||
.font(.headline)
|
||||
Text(modelStatusText)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
switch viewModel.modelDownloadState {
|
||||
case .idle, .failed:
|
||||
Button("Download Model") {
|
||||
viewModel.downloadModel()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
case .downloading:
|
||||
ProgressView(value: viewModel.modelDownloadProgress)
|
||||
case .completed:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(.thinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
|
||||
private var parsingSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("EPUB Parsing")
|
||||
.font(.headline)
|
||||
Text(parsingStatusText)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button("Parse EPUB") {
|
||||
viewModel.parseBook()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(.thinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
|
||||
private var modelStatusText: String {
|
||||
switch viewModel.modelDownloadState {
|
||||
case .idle:
|
||||
return "Model not downloaded"
|
||||
case .downloading(let message):
|
||||
return message
|
||||
case .completed:
|
||||
return "Model ready"
|
||||
case .failed(let message):
|
||||
return "Download failed: \(message)"
|
||||
}
|
||||
}
|
||||
|
||||
private var parsingStatusText: String {
|
||||
switch viewModel.preparationState {
|
||||
case .idle:
|
||||
return "Ready to parse"
|
||||
case .parsing:
|
||||
return "Parsing EPUB"
|
||||
case .ready(let count):
|
||||
return "Parsed \(count) chapters"
|
||||
case .failed(let message):
|
||||
return "Parsing failed: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
10
Vorleser/VorleserApp.swift
Normal file
10
Vorleser/VorleserApp.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct VorleserApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
100
VorleserMac/MacContentView.swift
Normal file
100
VorleserMac/MacContentView.swift
Normal file
@@ -0,0 +1,100 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MacContentView: View {
|
||||
@StateObject private var viewModel = MacLibraryViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Vorleser (macOS)")
|
||||
.font(.title.bold())
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button("Import EPUB") {
|
||||
viewModel.pickEPUB()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button("Load Test Text") {
|
||||
viewModel.loadTestText()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Load Debug Phonemes") {
|
||||
viewModel.loadDebugPhonemes()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Verify Bundled Assets") {
|
||||
viewModel.updateStatus(AssetVerifier.verify().message)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Export Last Audio") {
|
||||
viewModel.exportLastAudio()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Play") {
|
||||
viewModel.playFirstChunk()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
if let url = viewModel.selectedURL {
|
||||
Text("Selected: \(url.lastPathComponent)")
|
||||
.font(.callout)
|
||||
}
|
||||
|
||||
if !viewModel.statusMessage.isEmpty {
|
||||
Text(viewModel.statusMessage)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if viewModel.chapters.isEmpty {
|
||||
Text("No chapters loaded yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Picker("Chapter", selection: $viewModel.selectedChapterIndex) {
|
||||
ForEach(Array(viewModel.chapters.enumerated()), id: \.offset) { index, chapter in
|
||||
Text(chapter.title).tag(index)
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.selectedChapterIndex) {
|
||||
viewModel.selectChapter(index: viewModel.selectedChapterIndex)
|
||||
}
|
||||
|
||||
List(selection: $viewModel.selectedChunkIndex) {
|
||||
ForEach(Array(viewModel.chunks.enumerated()), id: \.offset) { index, chunk in
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Chunk \(index + 1)")
|
||||
.font(.headline)
|
||||
Text(chunk.prefix(240))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Play") {
|
||||
viewModel.playChunk(at: index)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.tag(index)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(20)
|
||||
.frame(minWidth: 720, minHeight: 520)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MacContentView()
|
||||
}
|
||||
150
VorleserMac/Resources/Config/config.json
Normal file
150
VorleserMac/Resources/Config/config.json
Normal file
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"istftnet": {
|
||||
"upsample_kernel_sizes": [20, 12],
|
||||
"upsample_rates": [10, 6],
|
||||
"gen_istft_hop_size": 5,
|
||||
"gen_istft_n_fft": 20,
|
||||
"resblock_dilation_sizes": [
|
||||
[1, 3, 5],
|
||||
[1, 3, 5],
|
||||
[1, 3, 5]
|
||||
],
|
||||
"resblock_kernel_sizes": [3, 7, 11],
|
||||
"upsample_initial_channel": 512
|
||||
},
|
||||
"dim_in": 64,
|
||||
"dropout": 0.2,
|
||||
"hidden_dim": 512,
|
||||
"max_conv_dim": 512,
|
||||
"max_dur": 50,
|
||||
"multispeaker": true,
|
||||
"n_layer": 3,
|
||||
"n_mels": 80,
|
||||
"n_token": 178,
|
||||
"style_dim": 128,
|
||||
"text_encoder_kernel_size": 5,
|
||||
"plbert": {
|
||||
"hidden_size": 768,
|
||||
"num_attention_heads": 12,
|
||||
"intermediate_size": 2048,
|
||||
"max_position_embeddings": 512,
|
||||
"num_hidden_layers": 12,
|
||||
"dropout": 0.1
|
||||
},
|
||||
"vocab": {
|
||||
";": 1,
|
||||
":": 2,
|
||||
",": 3,
|
||||
".": 4,
|
||||
"!": 5,
|
||||
"?": 6,
|
||||
"—": 9,
|
||||
"…": 10,
|
||||
"\"": 11,
|
||||
"(": 12,
|
||||
")": 13,
|
||||
"“": 14,
|
||||
"”": 15,
|
||||
" ": 16,
|
||||
"\u0303": 17,
|
||||
"ʣ": 18,
|
||||
"ʥ": 19,
|
||||
"ʦ": 20,
|
||||
"ʨ": 21,
|
||||
"ᵝ": 22,
|
||||
"\uAB67": 23,
|
||||
"A": 24,
|
||||
"I": 25,
|
||||
"O": 31,
|
||||
"Q": 33,
|
||||
"S": 35,
|
||||
"T": 36,
|
||||
"W": 39,
|
||||
"Y": 41,
|
||||
"ᵊ": 42,
|
||||
"a": 43,
|
||||
"b": 44,
|
||||
"c": 45,
|
||||
"d": 46,
|
||||
"e": 47,
|
||||
"f": 48,
|
||||
"h": 50,
|
||||
"i": 51,
|
||||
"j": 52,
|
||||
"k": 53,
|
||||
"l": 54,
|
||||
"m": 55,
|
||||
"n": 56,
|
||||
"o": 57,
|
||||
"p": 58,
|
||||
"q": 59,
|
||||
"r": 60,
|
||||
"s": 61,
|
||||
"t": 62,
|
||||
"u": 63,
|
||||
"v": 64,
|
||||
"w": 65,
|
||||
"x": 66,
|
||||
"y": 67,
|
||||
"z": 68,
|
||||
"ɑ": 69,
|
||||
"ɐ": 70,
|
||||
"ɒ": 71,
|
||||
"æ": 72,
|
||||
"β": 75,
|
||||
"ɔ": 76,
|
||||
"ɕ": 77,
|
||||
"ç": 78,
|
||||
"ɖ": 80,
|
||||
"ð": 81,
|
||||
"ʤ": 82,
|
||||
"ə": 83,
|
||||
"ɚ": 85,
|
||||
"ɛ": 86,
|
||||
"ɜ": 87,
|
||||
"ɟ": 90,
|
||||
"ɡ": 92,
|
||||
"ɥ": 99,
|
||||
"ɨ": 101,
|
||||
"ɪ": 102,
|
||||
"ʝ": 103,
|
||||
"ɯ": 110,
|
||||
"ɰ": 111,
|
||||
"ŋ": 112,
|
||||
"ɳ": 113,
|
||||
"ɲ": 114,
|
||||
"ɴ": 115,
|
||||
"ø": 116,
|
||||
"ɸ": 118,
|
||||
"θ": 119,
|
||||
"œ": 120,
|
||||
"ɹ": 123,
|
||||
"ɾ": 125,
|
||||
"ɻ": 126,
|
||||
"ʁ": 128,
|
||||
"ɽ": 129,
|
||||
"ʂ": 130,
|
||||
"ʃ": 131,
|
||||
"ʈ": 132,
|
||||
"ʧ": 133,
|
||||
"ʊ": 135,
|
||||
"ʋ": 136,
|
||||
"ʌ": 138,
|
||||
"ɣ": 139,
|
||||
"ɤ": 140,
|
||||
"χ": 142,
|
||||
"ʎ": 143,
|
||||
"ʒ": 147,
|
||||
"ʔ": 148,
|
||||
"ˈ": 156,
|
||||
"ˌ": 157,
|
||||
"ː": 158,
|
||||
"ʰ": 162,
|
||||
"ʲ": 164,
|
||||
"↓": 169,
|
||||
"→": 171,
|
||||
"↗": 172,
|
||||
"↘": 173,
|
||||
"ᵻ": 177
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"fileFormatVersion": "1.0.0",
|
||||
"itemInfoEntries": {
|
||||
"10A41D50-9A6A-46B4-9EE3-17F40605768E": {
|
||||
"author": "com.apple.CoreML",
|
||||
"description": "CoreML Model Specification",
|
||||
"name": "model.mlmodel",
|
||||
"path": "com.apple.CoreML/model.mlmodel"
|
||||
},
|
||||
"7DC183FF-5C6F-454C-BA3E-8D5898495BEF": {
|
||||
"author": "com.apple.CoreML",
|
||||
"description": "CoreML Model Weights",
|
||||
"name": "weights",
|
||||
"path": "com.apple.CoreML/weights"
|
||||
}
|
||||
},
|
||||
"rootModelIdentifier": "10A41D50-9A6A-46B4-9EE3-17F40605768E"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"fileFormatVersion": "1.0.0",
|
||||
"itemInfoEntries": {
|
||||
"BB7DA7FA-4D92-4B0F-BC42-EA198FC64A57": {
|
||||
"author": "com.apple.CoreML",
|
||||
"description": "CoreML Model Specification",
|
||||
"name": "model.mlmodel",
|
||||
"path": "com.apple.CoreML/model.mlmodel"
|
||||
},
|
||||
"DC159FC3-A48E-4DB6-9790-A78DE3F5BBD8": {
|
||||
"author": "com.apple.CoreML",
|
||||
"description": "CoreML Model Weights",
|
||||
"name": "weights",
|
||||
"path": "com.apple.CoreML/weights"
|
||||
}
|
||||
},
|
||||
"rootModelIdentifier": "BB7DA7FA-4D92-4B0F-BC42-EA198FC64A57"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"fileFormatVersion": "1.0.0",
|
||||
"itemInfoEntries": {
|
||||
"CF41DE8E-0A19-4F3F-8885-F07154AF4A4B": {
|
||||
"author": "com.apple.CoreML",
|
||||
"description": "CoreML Model Specification",
|
||||
"name": "model.mlmodel",
|
||||
"path": "com.apple.CoreML/model.mlmodel"
|
||||
},
|
||||
"FAF77E4F-EDB7-4B8C-B0ED-E7F8A42CFE0A": {
|
||||
"author": "com.apple.CoreML",
|
||||
"description": "CoreML Model Weights",
|
||||
"name": "weights",
|
||||
"path": "com.apple.CoreML/weights"
|
||||
}
|
||||
},
|
||||
"rootModelIdentifier": "CF41DE8E-0A19-4F3F-8885-F07154AF4A4B"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"fileFormatVersion": "1.0.0",
|
||||
"itemInfoEntries": {
|
||||
"5BBD3EBA-A41D-40C8-8D40-76912154E555": {
|
||||
"author": "com.apple.CoreML",
|
||||
"description": "CoreML Model Weights",
|
||||
"name": "weights",
|
||||
"path": "com.apple.CoreML/weights"
|
||||
},
|
||||
"B66B79D4-8D3F-4589-A5D1-459812919B86": {
|
||||
"author": "com.apple.CoreML",
|
||||
"description": "CoreML Model Specification",
|
||||
"name": "model.mlmodel",
|
||||
"path": "com.apple.CoreML/model.mlmodel"
|
||||
}
|
||||
},
|
||||
"rootModelIdentifier": "B66B79D4-8D3F-4589-A5D1-459812919B86"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"fileFormatVersion": "1.0.0",
|
||||
"itemInfoEntries": {
|
||||
"041E5B67-A9C9-4116-85BC-5726D2583C21": {
|
||||
"author": "com.apple.CoreML",
|
||||
"description": "CoreML Model Weights",
|
||||
"name": "weights",
|
||||
"path": "com.apple.CoreML/weights"
|
||||
},
|
||||
"2FFC5529-2C09-4D1D-AD15-888AD1E09277": {
|
||||
"author": "com.apple.CoreML",
|
||||
"description": "CoreML Model Specification",
|
||||
"name": "model.mlmodel",
|
||||
"path": "com.apple.CoreML/model.mlmodel"
|
||||
}
|
||||
},
|
||||
"rootModelIdentifier": "2FFC5529-2C09-4D1D-AD15-888AD1E09277"
|
||||
}
|
||||
BIN
VorleserMac/Resources/Voices/am_michael.f32
Normal file
BIN
VorleserMac/Resources/Voices/am_michael.f32
Normal file
Binary file not shown.
BIN
VorleserMac/Resources/Voices/am_michael.pt
Normal file
BIN
VorleserMac/Resources/Voices/am_michael.pt
Normal file
Binary file not shown.
BIN
VorleserMac/Resources/Voices/am_michael_256.f32
Normal file
BIN
VorleserMac/Resources/Voices/am_michael_256.f32
Normal file
Binary file not shown.
57
VorleserMac/Services/AssetVerifier.swift
Normal file
57
VorleserMac/Services/AssetVerifier.swift
Normal file
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
|
||||
struct AssetVerifier {
|
||||
struct Result {
|
||||
let ok: Bool
|
||||
let message: String
|
||||
}
|
||||
|
||||
static func verify() -> Result {
|
||||
let bundleRoot = Bundle.main.resourceURL?.path ?? "(unknown)"
|
||||
let modelCandidates = [
|
||||
"kokoro_duration.mlmodelc",
|
||||
"kokoro_decoder_only_3s.mlmodelc",
|
||||
"kokoro_duration.mlpackage",
|
||||
"kokoro_decoder_only_3s.mlpackage"
|
||||
]
|
||||
|
||||
let voiceName = "am_michael.pt"
|
||||
let configName = "config.json"
|
||||
|
||||
var missing: [String] = []
|
||||
|
||||
let modelFound = modelCandidates.filter { name in
|
||||
Bundle.main.url(forResource: name, withExtension: nil, subdirectory: "Models") != nil ||
|
||||
Bundle.main.url(forResource: name, withExtension: nil, subdirectory: nil) != nil
|
||||
}
|
||||
|
||||
if modelFound.isEmpty {
|
||||
missing.append("Models or root: kokoro_duration + kokoro_decoder_only_3s")
|
||||
}
|
||||
|
||||
if Bundle.main.url(forResource: voiceName, withExtension: nil, subdirectory: "Voices") == nil,
|
||||
Bundle.main.url(forResource: voiceName, withExtension: nil, subdirectory: nil) == nil {
|
||||
missing.append("Voices or root: \(voiceName)")
|
||||
}
|
||||
|
||||
if Bundle.main.url(forResource: configName, withExtension: nil, subdirectory: "Config") == nil,
|
||||
Bundle.main.url(forResource: configName, withExtension: nil, subdirectory: nil) == nil {
|
||||
missing.append("Config or root: \(configName)")
|
||||
}
|
||||
|
||||
if missing.isEmpty {
|
||||
return Result(ok: true, message: "All bundled assets found at:\n\(bundleRoot)")
|
||||
}
|
||||
|
||||
return Result(
|
||||
ok: false,
|
||||
message: """
|
||||
Missing assets:
|
||||
- \(missing.joined(separator: "\n- "))
|
||||
|
||||
Bundle resources path:
|
||||
\(bundleRoot)
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
98
VorleserMac/Services/AudioPlaybackService.swift
Normal file
98
VorleserMac/Services/AudioPlaybackService.swift
Normal file
@@ -0,0 +1,98 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
final class AudioPlaybackService {
|
||||
private let engine = AVAudioEngine()
|
||||
private let player = AVAudioPlayerNode()
|
||||
private var isConfigured = false
|
||||
private var fallbackPlayer: AVAudioPlayer?
|
||||
private var lastSamples: [Float] = []
|
||||
|
||||
init() {
|
||||
configureEngine()
|
||||
}
|
||||
|
||||
func play(samples: [Float]) {
|
||||
lastSamples = samples
|
||||
if !isConfigured {
|
||||
configureEngine()
|
||||
}
|
||||
let frameCount = AVAudioFrameCount(samples.count)
|
||||
guard let format = AVAudioFormat(standardFormatWithSampleRate: 24000, channels: 1),
|
||||
let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else {
|
||||
return
|
||||
}
|
||||
|
||||
buffer.frameLength = frameCount
|
||||
let channel = buffer.floatChannelData![0]
|
||||
for i in 0..<samples.count {
|
||||
channel[i] = samples[i]
|
||||
}
|
||||
|
||||
if isConfigured {
|
||||
player.stop()
|
||||
player.scheduleBuffer(buffer, at: nil, options: .interrupts)
|
||||
if !player.isPlaying {
|
||||
player.play()
|
||||
}
|
||||
} else {
|
||||
playViaFallback(buffer: buffer, format: format)
|
||||
}
|
||||
}
|
||||
|
||||
func exportLastWav() -> URL? {
|
||||
guard !lastSamples.isEmpty else { return nil }
|
||||
let frameCount = AVAudioFrameCount(lastSamples.count)
|
||||
guard let format = AVAudioFormat(standardFormatWithSampleRate: 24000, channels: 1),
|
||||
let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else {
|
||||
return nil
|
||||
}
|
||||
buffer.frameLength = frameCount
|
||||
let channel = buffer.floatChannelData![0]
|
||||
for i in 0..<lastSamples.count {
|
||||
channel[i] = lastSamples[i]
|
||||
}
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("vorleser_last.wav")
|
||||
do {
|
||||
let file = try AVAudioFile(forWriting: url, settings: format.settings)
|
||||
try file.write(from: buffer)
|
||||
return url
|
||||
} catch {
|
||||
print("Failed to export WAV: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func configureEngine() {
|
||||
engine.stop()
|
||||
engine.reset()
|
||||
if !engine.attachedNodes.contains(player) {
|
||||
engine.attach(player)
|
||||
}
|
||||
let format = AVAudioFormat(standardFormatWithSampleRate: 24000, channels: 1)
|
||||
engine.connect(player, to: engine.mainMixerNode, format: format)
|
||||
do {
|
||||
try engine.start()
|
||||
isConfigured = true
|
||||
} catch {
|
||||
isConfigured = false
|
||||
print("Audio engine failed to start: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func playViaFallback(buffer: AVAudioPCMBuffer, format: AVAudioFormat) {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("vorleser_fallback.wav")
|
||||
do {
|
||||
let file = try AVAudioFile(forWriting: url, settings: format.settings)
|
||||
try file.write(from: buffer)
|
||||
let player = try AVAudioPlayer(contentsOf: url)
|
||||
fallbackPlayer = player
|
||||
player.prepareToPlay()
|
||||
player.play()
|
||||
} catch {
|
||||
print("Fallback playback failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
190
VorleserMac/Services/EPUBService.swift
Normal file
190
VorleserMac/Services/EPUBService.swift
Normal file
@@ -0,0 +1,190 @@
|
||||
import Foundation
|
||||
import ZIPFoundation
|
||||
import SwiftSoup
|
||||
|
||||
struct EPUBChapter: Identifiable, Hashable {
|
||||
let id: UUID
|
||||
let title: String
|
||||
let rawText: String
|
||||
|
||||
init(title: String, rawText: String) {
|
||||
self.id = UUID()
|
||||
self.title = title
|
||||
self.rawText = rawText
|
||||
}
|
||||
}
|
||||
|
||||
final class EPUBService {
|
||||
enum EPUBError: Error, LocalizedError {
|
||||
case missingContainer
|
||||
case missingRootfile
|
||||
case missingOPF
|
||||
case invalidOPF
|
||||
case missingSpine
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingContainer:
|
||||
return "Missing META-INF/container.xml"
|
||||
case .missingRootfile:
|
||||
return "container.xml did not include a rootfile"
|
||||
case .missingOPF:
|
||||
return "OPF file missing"
|
||||
case .invalidOPF:
|
||||
return "OPF parsing failed"
|
||||
case .missingSpine:
|
||||
return "OPF spine is empty"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractChapters(from epubURL: URL) throws -> [EPUBChapter] {
|
||||
let tmpDir = try createTempDirectory()
|
||||
try unzip(epubURL, to: tmpDir)
|
||||
|
||||
let containerURL = tmpDir.appendingPathComponent("META-INF/container.xml")
|
||||
guard FileManager.default.fileExists(atPath: containerURL.path) else {
|
||||
throw EPUBError.missingContainer
|
||||
}
|
||||
|
||||
let containerData = try Data(contentsOf: containerURL)
|
||||
let rootfilePath = try parseContainer(data: containerData)
|
||||
guard let rootfilePath else {
|
||||
throw EPUBError.missingRootfile
|
||||
}
|
||||
|
||||
let opfURL = tmpDir.appendingPathComponent(rootfilePath)
|
||||
guard FileManager.default.fileExists(atPath: opfURL.path) else {
|
||||
throw EPUBError.missingOPF
|
||||
}
|
||||
|
||||
let opfData = try Data(contentsOf: opfURL)
|
||||
let opfResult = try parseOPF(data: opfData)
|
||||
guard !opfResult.spine.isEmpty else {
|
||||
throw EPUBError.missingSpine
|
||||
}
|
||||
|
||||
let baseURL = opfURL.deletingLastPathComponent()
|
||||
var chapters: [EPUBChapter] = []
|
||||
|
||||
for idref in opfResult.spine {
|
||||
guard let href = opfResult.manifest[idref] else { continue }
|
||||
let contentURL = baseURL.appendingPathComponent(href)
|
||||
guard FileManager.default.fileExists(atPath: contentURL.path) else { continue }
|
||||
|
||||
let html = try String(contentsOf: contentURL, encoding: .utf8)
|
||||
let text = try SwiftSoup.parse(html).text()
|
||||
let title = try extractTitle(from: html) ?? contentURL.deletingPathExtension().lastPathComponent
|
||||
let cleaned = cleanText(text)
|
||||
if cleaned.isEmpty { continue }
|
||||
chapters.append(EPUBChapter(title: title, rawText: cleaned))
|
||||
}
|
||||
|
||||
return chapters
|
||||
}
|
||||
|
||||
private func unzip(_ url: URL, to destination: URL) throws {
|
||||
let archive = try Archive(url: url, accessMode: .read)
|
||||
for entry in archive {
|
||||
let entryURL = destination.appendingPathComponent(entry.path)
|
||||
let parent = entryURL.deletingLastPathComponent()
|
||||
if !FileManager.default.fileExists(atPath: parent.path) {
|
||||
try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true)
|
||||
}
|
||||
_ = try archive.extract(entry, to: entryURL)
|
||||
}
|
||||
}
|
||||
|
||||
private func createTempDirectory() throws -> URL {
|
||||
let base = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
let dir = base.appendingPathComponent("epub_\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir
|
||||
}
|
||||
|
||||
private func parseContainer(data: Data) throws -> String? {
|
||||
let parser = XMLParser(data: data)
|
||||
let delegate = ContainerParser()
|
||||
parser.delegate = delegate
|
||||
parser.parse()
|
||||
return delegate.rootfilePath
|
||||
}
|
||||
|
||||
private func parseOPF(data: Data) throws -> OPFParseResult {
|
||||
let parser = XMLParser(data: data)
|
||||
let delegate = OPFParser()
|
||||
parser.delegate = delegate
|
||||
parser.parse()
|
||||
return OPFParseResult(title: delegate.title?.trimmingCharacters(in: .whitespacesAndNewlines), manifest: delegate.manifest, spine: delegate.spine)
|
||||
}
|
||||
|
||||
private func extractTitle(from html: String) throws -> String? {
|
||||
let doc = try SwiftSoup.parse(html)
|
||||
if let h1 = try doc.select("h1").first() {
|
||||
return try h1.text()
|
||||
}
|
||||
if let title = try doc.select("title").first() {
|
||||
return try title.text()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func cleanText(_ text: String) -> String {
|
||||
let collapsed = text.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
return collapsed.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
private struct OPFParseResult {
|
||||
let title: String?
|
||||
let manifest: [String: String]
|
||||
let spine: [String]
|
||||
}
|
||||
|
||||
private final class ContainerParser: NSObject, XMLParserDelegate {
|
||||
var rootfilePath: String?
|
||||
|
||||
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {
|
||||
guard elementName.lowercased().contains("rootfile") else { return }
|
||||
if let path = attributeDict["full-path"] {
|
||||
rootfilePath = path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class OPFParser: NSObject, XMLParserDelegate {
|
||||
var manifest: [String: String] = [:]
|
||||
var spine: [String] = []
|
||||
var title: String?
|
||||
|
||||
private var isCollectingTitle = false
|
||||
private var titleBuffer = ""
|
||||
|
||||
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {
|
||||
let name = elementName.lowercased()
|
||||
if name == "item", let id = attributeDict["id"], let href = attributeDict["href"] {
|
||||
manifest[id] = href
|
||||
} else if name == "itemref", let idref = attributeDict["idref"] {
|
||||
spine.append(idref)
|
||||
} else if name.hasSuffix("title"), title == nil {
|
||||
isCollectingTitle = true
|
||||
titleBuffer = ""
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
if isCollectingTitle {
|
||||
titleBuffer.append(string)
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
|
||||
if isCollectingTitle, elementName.lowercased().hasSuffix("title") {
|
||||
let trimmed = titleBuffer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
title = trimmed
|
||||
}
|
||||
isCollectingTitle = false
|
||||
}
|
||||
}
|
||||
}
|
||||
136
VorleserMac/Services/EspeakPhonemizer.swift
Normal file
136
VorleserMac/Services/EspeakPhonemizer.swift
Normal file
@@ -0,0 +1,136 @@
|
||||
import Foundation
|
||||
|
||||
final class EspeakPhonemizer {
|
||||
enum PhonemizerError: Error, LocalizedError {
|
||||
case binaryNotFound
|
||||
case failed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .binaryNotFound:
|
||||
return "espeak-ng binary not found."
|
||||
case .failed(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func phonemize(_ text: String) throws -> String {
|
||||
let binary = try resolveBinary()
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = binary
|
||||
process.arguments = ["-q", "--ipa=3", "-v", "en-us", "--stdin"]
|
||||
let dataPath = resolveDataPath()
|
||||
if let dataPath {
|
||||
let testFile = dataPath.appendingPathComponent("phontab")
|
||||
if !FileManager.default.fileExists(atPath: testFile.path) {
|
||||
throw PhonemizerError.failed("espeak-ng data missing phontab at \(testFile.path)")
|
||||
}
|
||||
} else {
|
||||
throw PhonemizerError.failed("espeak-ng data path not found")
|
||||
}
|
||||
let frameworksPath = resolveFrameworksPath()
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["ESPEAK_DATA_PATH"] = dataPath!.path
|
||||
if let frameworksPath {
|
||||
let existing = env["DYLD_FALLBACK_LIBRARY_PATH"] ?? ""
|
||||
let merged = existing.isEmpty ? frameworksPath.path : "\(frameworksPath.path):\(existing)"
|
||||
env["DYLD_FALLBACK_LIBRARY_PATH"] = merged
|
||||
}
|
||||
env["LC_ALL"] = "en_US.UTF-8"
|
||||
env["LANG"] = "en_US.UTF-8"
|
||||
process.environment = env
|
||||
|
||||
let inputPipe = Pipe()
|
||||
let outputPipe = Pipe()
|
||||
let errorPipe = Pipe()
|
||||
process.standardInput = inputPipe
|
||||
process.standardOutput = outputPipe
|
||||
process.standardError = errorPipe
|
||||
|
||||
try process.run()
|
||||
|
||||
if let data = text.data(using: .utf8) {
|
||||
inputPipe.fileHandleForWriting.write(data)
|
||||
}
|
||||
inputPipe.fileHandleForWriting.closeFile()
|
||||
|
||||
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
|
||||
process.waitUntilExit()
|
||||
|
||||
if process.terminationStatus != 0 {
|
||||
let errorMessage = String(data: errorData, encoding: .utf8) ?? ""
|
||||
let outputMessage = String(data: outputData, encoding: .utf8) ?? ""
|
||||
let combined = [errorMessage, outputMessage].filter { !$0.isEmpty }.joined(separator: "\n")
|
||||
let pathInfo = "binary=\(binary.path), data=\(dataPath?.path ?? "nil"), frameworks=\(frameworksPath?.path ?? "nil")"
|
||||
let fallback = combined.isEmpty ? "espeak-ng failed with exit code \(process.terminationStatus) (\(pathInfo))" : "\(combined)\n(\(pathInfo))"
|
||||
throw PhonemizerError.failed(fallback)
|
||||
}
|
||||
|
||||
let phonemes = String(data: outputData, encoding: .utf8) ?? ""
|
||||
let cleaned = phonemes
|
||||
.replacingOccurrences(of: "\\r", with: "")
|
||||
.replacingOccurrences(of: "\\n", with: " ")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
let filtered = cleaned.unicodeScalars.filter { $0.properties.generalCategory != .format }
|
||||
let final = String(String.UnicodeScalarView(filtered))
|
||||
if final.isEmpty {
|
||||
throw PhonemizerError.failed("espeak-ng returned empty output")
|
||||
}
|
||||
return final
|
||||
}
|
||||
|
||||
private func resolveBinary() throws -> URL {
|
||||
if let bundled = Bundle.main.url(forResource: "espeak-ng", withExtension: nil, subdirectory: "Tools") {
|
||||
return bundled
|
||||
}
|
||||
|
||||
let candidates = [
|
||||
"/opt/homebrew/bin/espeak-ng",
|
||||
"/usr/local/bin/espeak-ng",
|
||||
"/usr/bin/espeak-ng"
|
||||
]
|
||||
|
||||
for path in candidates {
|
||||
if FileManager.default.isExecutableFile(atPath: path) {
|
||||
return URL(fileURLWithPath: path)
|
||||
}
|
||||
}
|
||||
|
||||
throw PhonemizerError.binaryNotFound
|
||||
}
|
||||
|
||||
private func resolveDataPath() -> URL? {
|
||||
if let bundled = Bundle.main.resourceURL?.appendingPathComponent("Tools/espeak-ng-data"),
|
||||
FileManager.default.fileExists(atPath: bundled.path) {
|
||||
return bundled
|
||||
}
|
||||
let candidates = [
|
||||
"/opt/homebrew/share/espeak-ng-data",
|
||||
"/usr/local/share/espeak-ng-data",
|
||||
"/usr/share/espeak-ng-data"
|
||||
]
|
||||
for path in candidates {
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
return URL(fileURLWithPath: path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveFrameworksPath() -> URL? {
|
||||
if let frameworks = Bundle.main.privateFrameworksURL,
|
||||
FileManager.default.fileExists(atPath: frameworks.path) {
|
||||
return frameworks
|
||||
}
|
||||
let fallback = Bundle.main.bundleURL.appendingPathComponent("Contents/Frameworks")
|
||||
if FileManager.default.fileExists(atPath: fallback.path) {
|
||||
return fallback
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
385
VorleserMac/Services/KokoroPipeline.swift
Normal file
385
VorleserMac/Services/KokoroPipeline.swift
Normal file
@@ -0,0 +1,385 @@
|
||||
import Foundation
|
||||
import CoreML
|
||||
|
||||
final class KokoroPipeline {
|
||||
enum PipelineError: Error, LocalizedError {
|
||||
case modelNotFound(String)
|
||||
case outputMissing(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .modelNotFound(let name):
|
||||
return "Missing model: \(name)"
|
||||
case .outputMissing(let name):
|
||||
return "Missing output: \(name)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let phonemizer = EspeakPhonemizer()
|
||||
private let tokenizer: KokoroTokenizer
|
||||
private let durationModel: MLModel
|
||||
private let decoderModel: MLModel
|
||||
private let f0nModel: MLModel?
|
||||
private let bucketFrames: Int
|
||||
private let bucketF0Frames: Int
|
||||
private let playbackSpeed: Float = 0.7
|
||||
private let f0Scale: Float = 1.25
|
||||
private let outputGain: Float = 2.0
|
||||
private let voicePack: [Float]
|
||||
private let voicePackCount: Int
|
||||
|
||||
init() throws {
|
||||
tokenizer = try KokoroTokenizer.loadFromBundle()
|
||||
durationModel = try KokoroPipeline.loadModel(named: "kokoro_duration")
|
||||
// Use 3s bucket for debugging signal quality.
|
||||
decoderModel = try KokoroPipeline.loadModel(named: "kokoro_decoder_only_3s")
|
||||
f0nModel = try? KokoroPipeline.loadModel(named: "kokoro_f0n_3s")
|
||||
bucketFrames = 120
|
||||
bucketF0Frames = 240
|
||||
voicePack = try KokoroPipeline.loadVoicePack()
|
||||
voicePackCount = max(1, voicePack.count / 256)
|
||||
}
|
||||
|
||||
func synthesize(text: String) throws -> [Float] {
|
||||
let (phonemes, tokenIdsAll, unknown, phonemeScalarCount) = try prepareTokens(from: text, maxTokens: 96)
|
||||
if !unknown.isEmpty {
|
||||
print("KokoroTokenizer unknown tokens: \(unknown.prefix(20))")
|
||||
}
|
||||
print("Phonemes (preview): \(phonemes.prefix(200))")
|
||||
return try synthesizeTokenIds(tokenIdsAll, phonemeScalarCount: phonemeScalarCount)
|
||||
}
|
||||
|
||||
func synthesizeLong(text: String) throws -> [Float] {
|
||||
let (phonemes, tokenIdsAll, unknown, _) = try prepareTokens(from: text, maxTokens: 4096)
|
||||
if !unknown.isEmpty {
|
||||
print("KokoroTokenizer unknown tokens: \(unknown.prefix(20))")
|
||||
}
|
||||
print("Phonemes (preview): \(phonemes.prefix(200))")
|
||||
|
||||
let chunkSize = 70
|
||||
var chunks: [[Int]] = []
|
||||
var idx = 0
|
||||
while idx < tokenIdsAll.count {
|
||||
let end = min(tokenIdsAll.count, idx + chunkSize)
|
||||
chunks.append(Array(tokenIdsAll[idx..<end]))
|
||||
idx = end
|
||||
}
|
||||
|
||||
var audioChunks: [[Float]] = []
|
||||
for chunk in chunks {
|
||||
let samples = try synthesizeTokenIds(chunk, phonemeScalarCount: chunk.count)
|
||||
audioChunks.append(samples)
|
||||
}
|
||||
|
||||
return concatenateWithCrossfade(chunks: audioChunks, crossfade: 400)
|
||||
}
|
||||
|
||||
private func prepareTokens(from text: String, maxTokens: Int) throws -> (String, [Int], [String], Int) {
|
||||
let phonemes: String
|
||||
if text.hasPrefix("[PHONEMES]") {
|
||||
phonemes = text.replacingOccurrences(of: "[PHONEMES]", with: "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
} else {
|
||||
phonemes = try phonemizer.phonemize(text)
|
||||
}
|
||||
let (tokenIdsAll, unknown, phonemeScalarCount) = tokenizer.tokenize(phonemes, maxTokens: maxTokens)
|
||||
if tokenIdsAll.count > maxTokens {
|
||||
print("Token count truncated from \(tokenIdsAll.count) to \(maxTokens) for bucket")
|
||||
}
|
||||
return (phonemes, tokenIdsAll, unknown, phonemeScalarCount)
|
||||
}
|
||||
|
||||
private func synthesizeTokenIds(_ tokenIdsAll: [Int], phonemeScalarCount: Int) throws -> [Float] {
|
||||
let tokenIds = [0] + tokenIdsAll + [0]
|
||||
print("Token count: \(tokenIds.count)")
|
||||
|
||||
let inputIds = try MLMultiArray(shape: [1, 128], dataType: .int32)
|
||||
let attention = try MLMultiArray(shape: [1, 128], dataType: .int32)
|
||||
for i in 0..<128 {
|
||||
inputIds[i] = 0
|
||||
attention[i] = 0
|
||||
}
|
||||
for (idx, id) in tokenIds.prefix(128).enumerated() {
|
||||
inputIds[idx] = NSNumber(value: id)
|
||||
attention[idx] = 1
|
||||
}
|
||||
|
||||
let refS = try MLMultiArray(shape: [1, 256], dataType: .float32)
|
||||
let voiceIndex = max(0, min(voicePackCount - 1, max(1, phonemeScalarCount) - 1))
|
||||
let voiceOffset = voiceIndex * 256
|
||||
for i in 0..<256 {
|
||||
refS[i] = NSNumber(value: voicePack[voiceOffset + i])
|
||||
}
|
||||
|
||||
let speed = try MLMultiArray(shape: [1], dataType: .float32)
|
||||
speed[0] = NSNumber(value: playbackSpeed)
|
||||
|
||||
let durationProvider = try MLDictionaryFeatureProvider(dictionary: [
|
||||
"input_ids": MLFeatureValue(multiArray: inputIds),
|
||||
"attention_mask": MLFeatureValue(multiArray: attention),
|
||||
"ref_s": MLFeatureValue(multiArray: refS),
|
||||
"speed": MLFeatureValue(multiArray: speed)
|
||||
])
|
||||
let durationOutput = try durationModel.prediction(from: durationProvider)
|
||||
|
||||
guard let d = durationOutput.featureValue(for: "d")?.multiArrayValue else {
|
||||
throw PipelineError.outputMissing("d")
|
||||
}
|
||||
guard let tEn = durationOutput.featureValue(for: "t_en")?.multiArrayValue else {
|
||||
throw PipelineError.outputMissing("t_en")
|
||||
}
|
||||
guard let s = durationOutput.featureValue(for: "s")?.multiArrayValue else {
|
||||
throw PipelineError.outputMissing("s")
|
||||
}
|
||||
guard let predDur = durationOutput.featureValue(for: "pred_dur")?.multiArrayValue else {
|
||||
throw PipelineError.outputMissing("pred_dur")
|
||||
}
|
||||
let refSOut = durationOutput.featureValue(for: "ref_s_out")?.multiArrayValue ?? refS
|
||||
|
||||
let tokenCount = min(tokenIds.count, 128)
|
||||
let durations = makeDurations(predDur: predDur, tokenCount: tokenCount, targetFrames: bucketFrames)
|
||||
print("Durations sum: \(durations.reduce(0, +)) for tokenCount \(tokenCount)")
|
||||
let predAln = buildAlignment(tokenCount: tokenCount, targetFrames: bucketFrames, durations: durations)
|
||||
let asr = buildAligned(tensor: tEn, channels: 512, tokenCount: tokenCount, alignment: predAln, tokenAxis: 2, channelAxis: 1, frameCount: bucketFrames)
|
||||
let en = buildAligned(tensor: d, channels: 640, tokenCount: tokenCount, alignment: predAln, tokenAxis: 1, channelAxis: 2, frameCount: bucketFrames)
|
||||
|
||||
let (f0, n) = try predictF0N(en: en, s: s)
|
||||
|
||||
print("d min/max: \(minMax(d))")
|
||||
print("d shape/strides: \(d.shape) / \(d.strides)")
|
||||
print("t_en min/max: \(minMax(tEn))")
|
||||
print("t_en shape/strides: \(tEn.shape) / \(tEn.strides)")
|
||||
print("s min/max: \(minMax(s))")
|
||||
print("s shape/strides: \(s.shape) / \(s.strides)")
|
||||
print("asr min/max: \(minMax(asr))")
|
||||
print("asr shape/strides: \(asr.shape) / \(asr.strides)")
|
||||
print("en min/max: \(minMax(en))")
|
||||
print("en shape/strides: \(en.shape) / \(en.strides)")
|
||||
print("F0 min/max: \(minMax(f0))")
|
||||
print("F0 shape/strides: \(f0.shape) / \(f0.strides)")
|
||||
print("N min/max: \(minMax(n))")
|
||||
print("N shape/strides: \(n.shape) / \(n.strides)")
|
||||
|
||||
let decoderProvider = try MLDictionaryFeatureProvider(dictionary: [
|
||||
"asr": MLFeatureValue(multiArray: asr),
|
||||
"F0_pred": MLFeatureValue(multiArray: f0),
|
||||
"N_pred": MLFeatureValue(multiArray: n),
|
||||
"ref_s": MLFeatureValue(multiArray: refSOut)
|
||||
])
|
||||
let decoderOutput = try decoderModel.prediction(from: decoderProvider)
|
||||
|
||||
guard let waveform = decoderOutput.featureValue(for: "waveform")?.multiArrayValue else {
|
||||
throw PipelineError.outputMissing("waveform")
|
||||
}
|
||||
print("waveform shape/strides: \(waveform.shape) / \(waveform.strides) count=\(waveform.count)")
|
||||
|
||||
var samples = waveformToArray(waveform)
|
||||
applyGain(&samples, gain: outputGain)
|
||||
if let min = samples.min(), let max = samples.max() {
|
||||
print("Waveform min/max: \(min) / \(max)")
|
||||
}
|
||||
return samples
|
||||
}
|
||||
|
||||
private func concatenateWithCrossfade(chunks: [[Float]], crossfade: Int) -> [Float] {
|
||||
guard var output = chunks.first else { return [] }
|
||||
for chunk in chunks.dropFirst() {
|
||||
let fadeCount = min(crossfade, min(output.count, chunk.count))
|
||||
let start = output.count - fadeCount
|
||||
for i in 0..<fadeCount {
|
||||
let t = Float(i) / Float(max(1, fadeCount - 1))
|
||||
let a = 1 - t
|
||||
let b = t
|
||||
output[start + i] = output[start + i] * a + chunk[i] * b
|
||||
}
|
||||
if chunk.count > fadeCount {
|
||||
output.append(contentsOf: chunk[fadeCount...])
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private static func loadModel(named name: String) throws -> MLModel {
|
||||
if let url = Bundle.main.url(forResource: name, withExtension: "mlmodelc", subdirectory: "Models") ??
|
||||
Bundle.main.url(forResource: name, withExtension: "mlmodelc", subdirectory: nil) {
|
||||
return try MLModel(contentsOf: url)
|
||||
}
|
||||
throw PipelineError.modelNotFound(name)
|
||||
}
|
||||
|
||||
private func makeDurations(predDur: MLMultiArray, tokenCount: Int, targetFrames: Int) -> [Int] {
|
||||
var raw: [Int] = []
|
||||
raw.reserveCapacity(tokenCount)
|
||||
for i in 0..<tokenCount {
|
||||
let value = predDur[i].doubleValue
|
||||
let safe = value.isFinite ? value : 1.0
|
||||
let rounded = max(1, Int(safe.rounded()))
|
||||
raw.append(rounded)
|
||||
}
|
||||
let sum = max(1, raw.reduce(0, +))
|
||||
let scale = Double(targetFrames) / Double(sum)
|
||||
var scaled = raw.map { max(1, Int((Double($0) * scale).rounded())) }
|
||||
var current = scaled.reduce(0, +)
|
||||
if current > targetFrames {
|
||||
var i = scaled.count - 1
|
||||
while current > targetFrames && i >= 0 {
|
||||
if scaled[i] > 1 {
|
||||
scaled[i] -= 1
|
||||
current -= 1
|
||||
} else {
|
||||
i -= 1
|
||||
}
|
||||
}
|
||||
} else if current < targetFrames, let last = scaled.indices.last {
|
||||
scaled[last] += (targetFrames - current)
|
||||
}
|
||||
return scaled
|
||||
}
|
||||
|
||||
private func buildAlignment(tokenCount: Int, targetFrames: Int, durations: [Int]) -> [[Float]] {
|
||||
var alignment = Array(repeating: Array(repeating: Float(0), count: targetFrames), count: tokenCount)
|
||||
var cursor = 0
|
||||
for i in 0..<tokenCount {
|
||||
let dur = durations[i]
|
||||
if dur <= 0 { continue }
|
||||
let end = min(targetFrames, cursor + dur)
|
||||
if cursor >= targetFrames { break }
|
||||
for f in cursor..<end {
|
||||
alignment[i][f] = 1.0
|
||||
}
|
||||
cursor = end
|
||||
}
|
||||
return alignment
|
||||
}
|
||||
|
||||
private func buildAligned(tensor: MLMultiArray, channels: Int, tokenCount: Int, alignment: [[Float]], tokenAxis: Int, channelAxis: Int, frameCount: Int) -> MLMultiArray {
|
||||
let output = try! MLMultiArray(shape: [1, NSNumber(value: channels), NSNumber(value: frameCount)], dataType: .float32)
|
||||
let shape = tensor.shape.map { $0.intValue }
|
||||
let strides = tensor.strides.map { $0.intValue }
|
||||
let availableTokens = min(tokenCount, shape[tokenAxis])
|
||||
let availableChannels = min(channels, shape[channelAxis])
|
||||
|
||||
func offset(channel: Int, token: Int) -> Int {
|
||||
// assumes batch dimension = 1 at axis 0
|
||||
if strides.count >= 3 {
|
||||
var off = 0
|
||||
if channelAxis == 1 && tokenAxis == 2 {
|
||||
off = channel * strides[1] + token * strides[2]
|
||||
} else if tokenAxis == 1 && channelAxis == 2 {
|
||||
off = token * strides[1] + channel * strides[2]
|
||||
} else {
|
||||
// fallback for unexpected layouts
|
||||
off = channel * strides[max(channelAxis, 1)] + token * strides[max(tokenAxis, 1)]
|
||||
}
|
||||
return off
|
||||
}
|
||||
if strides.count == 2 {
|
||||
return channel * strides[0] + token * strides[1]
|
||||
}
|
||||
return channel * (shape.last ?? 0) + token
|
||||
}
|
||||
|
||||
for h in 0..<availableChannels {
|
||||
for f in 0..<frameCount {
|
||||
var sum: Float = 0
|
||||
for t in 0..<availableTokens {
|
||||
let weight = alignment[t][f]
|
||||
if weight == 0 { continue }
|
||||
let idx = offset(channel: h, token: t)
|
||||
if idx < 0 || idx >= tensor.count { continue }
|
||||
let value = tensor[idx].floatValue
|
||||
if !value.isFinite { continue }
|
||||
sum += value * weight
|
||||
}
|
||||
let outIndex = h * frameCount + f
|
||||
output[outIndex] = NSNumber(value: sum)
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private func predictF0N(en: MLMultiArray, s: MLMultiArray) throws -> (MLMultiArray, MLMultiArray) {
|
||||
guard let f0nModel else {
|
||||
let f0 = try MLMultiArray(shape: [1, NSNumber(value: bucketF0Frames)], dataType: .float32)
|
||||
let n = try MLMultiArray(shape: [1, NSNumber(value: bucketF0Frames)], dataType: .float32)
|
||||
for i in 0..<bucketF0Frames { f0[i] = 0; n[i] = 0 }
|
||||
return (f0, n)
|
||||
}
|
||||
|
||||
let provider = try MLDictionaryFeatureProvider(dictionary: [
|
||||
"en": MLFeatureValue(multiArray: en),
|
||||
"s": MLFeatureValue(multiArray: s)
|
||||
])
|
||||
let output = try f0nModel.prediction(from: provider)
|
||||
guard let f0 = output.featureValue(for: "F0_pred")?.multiArrayValue else {
|
||||
throw PipelineError.outputMissing("F0_pred")
|
||||
}
|
||||
guard let n = output.featureValue(for: "N_pred")?.multiArrayValue else {
|
||||
throw PipelineError.outputMissing("N_pred")
|
||||
}
|
||||
sanitize(array: f0)
|
||||
sanitize(array: n)
|
||||
if f0Scale != 1.0 {
|
||||
for i in 0..<f0.count {
|
||||
let v = f0[i].floatValue * f0Scale
|
||||
f0[i] = NSNumber(value: v)
|
||||
}
|
||||
}
|
||||
return (f0, n)
|
||||
}
|
||||
|
||||
private func sanitize(array: MLMultiArray) {
|
||||
for i in 0..<array.count {
|
||||
let value = array[i].floatValue
|
||||
if !value.isFinite {
|
||||
array[i] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func minMax(_ array: MLMultiArray) -> String {
|
||||
var minVal = Float.greatestFiniteMagnitude
|
||||
var maxVal = -Float.greatestFiniteMagnitude
|
||||
for i in 0..<array.count {
|
||||
let v = array[i].floatValue
|
||||
if !v.isFinite { continue }
|
||||
if v < minVal { minVal = v }
|
||||
if v > maxVal { maxVal = v }
|
||||
}
|
||||
if minVal == Float.greatestFiniteMagnitude { return "n/a" }
|
||||
return "\(minVal) / \(maxVal)"
|
||||
}
|
||||
|
||||
private static func loadVoicePack() throws -> [Float] {
|
||||
guard let url = Bundle.main.url(forResource: "am_michael", withExtension: "f32", subdirectory: "Voices") ??
|
||||
Bundle.main.url(forResource: "am_michael", withExtension: "f32", subdirectory: nil) else {
|
||||
throw CocoaError(.fileNoSuchFile)
|
||||
}
|
||||
let data = try Data(contentsOf: url)
|
||||
let count = data.count / MemoryLayout<Float>.size
|
||||
return data.withUnsafeBytes { rawPtr in
|
||||
let buffer = rawPtr.bindMemory(to: Float.self)
|
||||
return Array(buffer.prefix(count))
|
||||
}
|
||||
}
|
||||
|
||||
private func waveformToArray(_ array: MLMultiArray) -> [Float] {
|
||||
let count = array.count
|
||||
var samples = [Float](repeating: 0, count: count)
|
||||
for i in 0..<count {
|
||||
let value = array[i].floatValue
|
||||
samples[i] = value.isFinite ? value : 0
|
||||
}
|
||||
return samples
|
||||
}
|
||||
|
||||
private func applyGain(_ samples: inout [Float], gain: Float) {
|
||||
guard gain != 1 else { return }
|
||||
for i in 0..<samples.count {
|
||||
var v = samples[i] * gain
|
||||
if v > 1 { v = 1 }
|
||||
if v < -1 { v = -1 }
|
||||
samples[i] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
48
VorleserMac/Services/KokoroTokenizer.swift
Normal file
48
VorleserMac/Services/KokoroTokenizer.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
|
||||
struct KokoroTokenizer {
|
||||
let vocab: [String: Int]
|
||||
|
||||
init(vocab: [String: Int]) {
|
||||
self.vocab = vocab
|
||||
}
|
||||
|
||||
static func loadFromBundle() throws -> KokoroTokenizer {
|
||||
guard let url = Bundle.main.url(forResource: "config", withExtension: "json", subdirectory: "Config") ??
|
||||
Bundle.main.url(forResource: "config", withExtension: "json", subdirectory: nil) else {
|
||||
throw CocoaError(.fileNoSuchFile)
|
||||
}
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
let object = try JSONSerialization.jsonObject(with: data)
|
||||
guard
|
||||
let dict = object as? [String: Any],
|
||||
let vocab = dict["vocab"] as? [String: Int]
|
||||
else {
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
|
||||
return KokoroTokenizer(vocab: vocab)
|
||||
}
|
||||
|
||||
func tokenize(_ phonemes: String, maxTokens: Int = 128) -> (ids: [Int], unknown: [String], scalarCount: Int) {
|
||||
let cleanedScalars = phonemes.unicodeScalars.filter { $0.properties.generalCategory != .format }
|
||||
let cleanedView = String.UnicodeScalarView(cleanedScalars)
|
||||
var ids: [Int] = []
|
||||
var unknown: [String] = []
|
||||
|
||||
for scalar in cleanedView {
|
||||
let token = String(scalar)
|
||||
if let id = vocab[token] {
|
||||
ids.append(id)
|
||||
} else {
|
||||
unknown.append(token)
|
||||
}
|
||||
if ids.count >= maxTokens {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (ids, unknown, cleanedView.count)
|
||||
}
|
||||
}
|
||||
139
VorleserMac/Services/MacLibraryViewModel.swift
Normal file
139
VorleserMac/Services/MacLibraryViewModel.swift
Normal file
@@ -0,0 +1,139 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
final class MacLibraryViewModel: ObservableObject {
|
||||
@Published private(set) var selectedURL: URL?
|
||||
@Published private(set) var chapters: [EPUBChapter] = []
|
||||
@Published private(set) var chunks: [String] = []
|
||||
@Published var selectedChapterIndex: Int = 0
|
||||
@Published var selectedChunkIndex: Int?
|
||||
@Published private(set) var chunkedCount: Int = 0
|
||||
@Published private(set) var statusMessage: String = ""
|
||||
|
||||
private let epubService = EPUBService()
|
||||
private let chunker = TextChunker(maxCharacters: 220)
|
||||
private let audioPlayer = AudioPlaybackService()
|
||||
private let synthesisWorker = SynthesisWorker()
|
||||
|
||||
func pickEPUB() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = [.init(filenameExtension: "epub")].compactMap { $0 }
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canChooseDirectories = false
|
||||
panel.canChooseFiles = true
|
||||
|
||||
panel.begin { [weak self] result in
|
||||
guard result == .OK, let url = panel.urls.first else { return }
|
||||
self?.loadEPUB(url: url)
|
||||
}
|
||||
}
|
||||
|
||||
func loadTestText() {
|
||||
selectedURL = nil
|
||||
let chapter = EPUBChapter(
|
||||
title: "Test Chapter",
|
||||
rawText: """
|
||||
When magic returns to the Earth, its power calls Sam Verner. As Sam searches for his sister through the slick and scary streets of 2050, his quest leads him across the ocean to England, where druids guard secrets older than cities.
|
||||
|
||||
He follows rumors into crowded markets and dark alleyways, bargaining with strangers and tracing clues in a language he barely understands. Every answer creates another question, and every friend might also be a rival. In a world where technology and spellcraft collide, survival requires equal parts courage and caution.
|
||||
"""
|
||||
)
|
||||
chapters = [chapter]
|
||||
selectedChapterIndex = 0
|
||||
chunks = chunker.chunk(chapter.rawText)
|
||||
selectedChunkIndex = chunks.isEmpty ? nil : 0
|
||||
chunkedCount = chunks.count
|
||||
statusMessage = "Loaded test text (1 chapter, \(chunkedCount) chunks)"
|
||||
}
|
||||
|
||||
func loadDebugPhonemes() {
|
||||
selectedURL = nil
|
||||
let phonemes = "[PHONEMES]hˌW ɑɹ ju tədˈA? ˌI ɐm dˈuɪŋ ɹˈizənəbli wˈɛl, θˈæŋk ju fɔɹ ˈæskɪŋ"
|
||||
let chapter = EPUBChapter(
|
||||
title: "Debug Phonemes",
|
||||
rawText: phonemes
|
||||
)
|
||||
chapters = [chapter]
|
||||
selectedChapterIndex = 0
|
||||
chunks = chunker.chunk(chapter.rawText)
|
||||
selectedChunkIndex = chunks.isEmpty ? nil : 0
|
||||
chunkedCount = chunks.count
|
||||
statusMessage = "Loaded debug phonemes (1 chapter, \(chunkedCount) chunks)"
|
||||
}
|
||||
|
||||
func updateStatus(_ message: String) {
|
||||
statusMessage = message
|
||||
}
|
||||
|
||||
private func loadEPUB(url: URL) {
|
||||
selectedURL = url
|
||||
statusMessage = "Parsing EPUB…"
|
||||
|
||||
Task { [weak self] in
|
||||
do {
|
||||
guard let self else { return }
|
||||
let chapters = try self.epubService.extractChapters(from: url)
|
||||
let chunkCount = chapters.reduce(0) { total, chapter in
|
||||
total + self.chunker.chunk(chapter.rawText).count
|
||||
}
|
||||
|
||||
self.chapters = chapters
|
||||
self.selectedChapterIndex = 0
|
||||
self.chunks = chapters.first.map { self.chunker.chunk($0.rawText) } ?? []
|
||||
self.selectedChunkIndex = self.chunks.isEmpty ? nil : 0
|
||||
self.chunkedCount = chunkCount
|
||||
self.statusMessage = "Parsed \(chapters.count) chapters, \(chunkCount) chunks"
|
||||
} catch {
|
||||
self?.statusMessage = "Failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func selectChapter(index: Int) {
|
||||
guard index >= 0 && index < chapters.count else { return }
|
||||
selectedChapterIndex = index
|
||||
chunks = chunker.chunk(chapters[index].rawText)
|
||||
selectedChunkIndex = chunks.isEmpty ? nil : 0
|
||||
}
|
||||
|
||||
func synthesizeSelectedChunk() {
|
||||
guard !chunks.isEmpty else {
|
||||
statusMessage = "No chunks to synthesize."
|
||||
return
|
||||
}
|
||||
|
||||
let index = min(selectedChunkIndex ?? 0, chunks.count - 1)
|
||||
let text = chunks[index]
|
||||
statusMessage = "Phonemizing and synthesizing…"
|
||||
|
||||
Task {
|
||||
do {
|
||||
let samples = try await synthesisWorker.synthesize(text: text)
|
||||
audioPlayer.play(samples: samples)
|
||||
statusMessage = "Playing synthesized audio."
|
||||
} catch {
|
||||
statusMessage = "Synthesis failed: \(error.localizedDescription) (\(String(describing: error)))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func playFirstChunk() {
|
||||
selectedChunkIndex = 0
|
||||
synthesizeSelectedChunk()
|
||||
}
|
||||
|
||||
func playChunk(at index: Int) {
|
||||
guard index >= 0 && index < chunks.count else { return }
|
||||
selectedChunkIndex = index
|
||||
synthesizeSelectedChunk()
|
||||
}
|
||||
|
||||
func exportLastAudio() {
|
||||
if let url = audioPlayer.exportLastWav() {
|
||||
statusMessage = "Exported last audio to \(url.path)"
|
||||
} else {
|
||||
statusMessage = "No audio to export yet."
|
||||
}
|
||||
}
|
||||
}
|
||||
34
VorleserMac/Services/SynthesisWorker.swift
Normal file
34
VorleserMac/Services/SynthesisWorker.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
|
||||
actor SynthesisWorker {
|
||||
enum WorkerError: Error, LocalizedError {
|
||||
case pipelineUnavailable
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .pipelineUnavailable:
|
||||
return "Kokoro pipeline failed to initialize."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let pipeline: KokoroPipeline?
|
||||
|
||||
init() {
|
||||
pipeline = try? KokoroPipeline()
|
||||
}
|
||||
|
||||
func synthesize(text: String) throws -> [Float] {
|
||||
guard let pipeline else {
|
||||
throw WorkerError.pipelineUnavailable
|
||||
}
|
||||
return try pipeline.synthesize(text: text)
|
||||
}
|
||||
|
||||
func synthesizeLong(text: String) throws -> [Float] {
|
||||
guard let pipeline else {
|
||||
throw WorkerError.pipelineUnavailable
|
||||
}
|
||||
return try pipeline.synthesizeLong(text: text)
|
||||
}
|
||||
}
|
||||
85
VorleserMac/Services/TextChunker.swift
Normal file
85
VorleserMac/Services/TextChunker.swift
Normal file
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
import NaturalLanguage
|
||||
|
||||
struct TextChunker {
|
||||
let maxCharacters: Int
|
||||
|
||||
init(maxCharacters: Int = 900) {
|
||||
self.maxCharacters = maxCharacters
|
||||
}
|
||||
|
||||
func chunk(_ text: String) -> [String] {
|
||||
let tokenizer = NLTokenizer(unit: .sentence)
|
||||
tokenizer.string = text
|
||||
|
||||
var chunks: [String] = []
|
||||
var current = ""
|
||||
|
||||
tokenizer.enumerateTokens(in: text.startIndex..<text.endIndex) { range, _ in
|
||||
let sentence = String(text[range]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !sentence.isEmpty else { return true }
|
||||
|
||||
let parts = splitIfNeeded(sentence)
|
||||
for part in parts {
|
||||
if current.count + part.count + 1 > maxCharacters {
|
||||
if !current.isEmpty {
|
||||
chunks.append(current.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
current = ""
|
||||
}
|
||||
}
|
||||
current.append(part)
|
||||
current.append(" ")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if !current.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
chunks.append(current.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
private func splitIfNeeded(_ sentence: String) -> [String] {
|
||||
if sentence.count <= maxCharacters {
|
||||
return [sentence]
|
||||
}
|
||||
let clauseSeparators = CharacterSet(charactersIn: ",;:")
|
||||
var clauses: [String] = []
|
||||
var current = ""
|
||||
for scalar in sentence.unicodeScalars {
|
||||
current.unicodeScalars.append(scalar)
|
||||
if clauseSeparators.contains(scalar) {
|
||||
clauses.append(current.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
current = ""
|
||||
}
|
||||
}
|
||||
if !current.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
clauses.append(current.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
if clauses.allSatisfy({ $0.count <= maxCharacters }) {
|
||||
return clauses
|
||||
}
|
||||
return splitByWords(sentence)
|
||||
}
|
||||
|
||||
private func splitByWords(_ sentence: String) -> [String] {
|
||||
let words = sentence.split(whereSeparator: { $0.isWhitespace })
|
||||
var parts: [String] = []
|
||||
var current = ""
|
||||
for word in words {
|
||||
if current.count + word.count + 1 > maxCharacters {
|
||||
if !current.isEmpty {
|
||||
parts.append(current.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
current = ""
|
||||
}
|
||||
}
|
||||
current.append(contentsOf: word)
|
||||
current.append(" ")
|
||||
}
|
||||
if !current.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
parts.append(current.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
return parts
|
||||
}
|
||||
}
|
||||
11
VorleserMac/VorleserMacApp.swift
Normal file
11
VorleserMac/VorleserMacApp.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct VorleserMacApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
MacContentView()
|
||||
}
|
||||
.windowStyle(.titleBar)
|
||||
}
|
||||
}
|
||||
1
autoaudiobook
Submodule
1
autoaudiobook
Submodule
Submodule autoaudiobook added at b5ce4e701a
110
project.yml
Normal file
110
project.yml
Normal file
@@ -0,0 +1,110 @@
|
||||
name: Vorleser
|
||||
options:
|
||||
bundleIdPrefix: de.felixfoertsch
|
||||
developmentLanguage: en
|
||||
deploymentTarget:
|
||||
iOS: 26.0
|
||||
macOS: 15.0
|
||||
createIntermediateGroups: true
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: 5.9
|
||||
CURRENT_PROJECT_VERSION: 1
|
||||
MARKETING_VERSION: 0.1.0
|
||||
packages:
|
||||
ZipFoundation:
|
||||
path: Vendor/ZipFoundation
|
||||
SwiftSoup:
|
||||
path: Vendor/SwiftSoup
|
||||
|
||||
targets:
|
||||
Vorleser:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: Vorleser
|
||||
configFiles:
|
||||
Debug: Config/Signing.xcconfig
|
||||
Release: Config/Signing.xcconfig
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.felixfoertsch.vorleser
|
||||
IPHONEOS_DEPLOYMENT_TARGET: 26.0
|
||||
GENERATE_INFOPLIST_FILE: true
|
||||
INFOPLIST_KEY_CFBundleVersion: "1"
|
||||
INFOPLIST_KEY_CFBundleShortVersionString: "0.1.0"
|
||||
INFOPLIST_KEY_UIBackgroundModes: [audio, processing]
|
||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace: true
|
||||
INFOPLIST_KEY_UIFileSharingEnabled: true
|
||||
dependencies:
|
||||
- package: ZipFoundation
|
||||
product: ZIPFoundation
|
||||
- package: SwiftSoup
|
||||
product: SwiftSoup
|
||||
|
||||
VorleserMac:
|
||||
type: application
|
||||
platform: macOS
|
||||
sources:
|
||||
- path: VorleserMac
|
||||
resources:
|
||||
- path: VorleserMac/Resources/Models
|
||||
- path: VorleserMac/Resources/Voices
|
||||
- path: VorleserMac/Resources/Config
|
||||
- path: VorleserMac/Resources/Tools
|
||||
dependencies:
|
||||
- package: ZipFoundation
|
||||
product: ZIPFoundation
|
||||
- package: SwiftSoup
|
||||
product: SwiftSoup
|
||||
configFiles:
|
||||
Debug: Config/Signing.xcconfig
|
||||
Release: Config/Signing.xcconfig
|
||||
postBuildScripts:
|
||||
- name: Bundle espeak-ng
|
||||
script: |
|
||||
set -euo pipefail
|
||||
BIN_SRC="/opt/homebrew/bin/espeak-ng"
|
||||
DATA_SRC="/opt/homebrew/share/espeak-ng-data"
|
||||
DYLIB_SRC="/opt/homebrew/lib/libespeak-ng.dylib"
|
||||
|
||||
if [ ! -x "$BIN_SRC" ]; then
|
||||
echo "espeak-ng not found at $BIN_SRC. Install via: brew install espeak-ng"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "$DATA_SRC" ]; then
|
||||
echo "espeak-ng data not found at $DATA_SRC"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$DYLIB_SRC" ]; then
|
||||
echo "libespeak-ng.dylib not found at $DYLIB_SRC"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RES_DIR="${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Resources"
|
||||
TOOL_DIR="${RES_DIR}/Tools"
|
||||
FRAMEWORK_DIR="${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Frameworks"
|
||||
mkdir -p "$TOOL_DIR" "$FRAMEWORK_DIR"
|
||||
|
||||
cp -f "$BIN_SRC" "$TOOL_DIR/espeak-ng"
|
||||
chmod +x "$TOOL_DIR/espeak-ng"
|
||||
rsync -a "$DATA_SRC/" "$TOOL_DIR/espeak-ng-data/"
|
||||
cp -f "$DYLIB_SRC" "$FRAMEWORK_DIR/libespeak-ng.dylib"
|
||||
|
||||
install_name_tool -id "@rpath/libespeak-ng.dylib" "$FRAMEWORK_DIR/libespeak-ng.dylib"
|
||||
install_name_tool -change "$DYLIB_SRC" "@rpath/libespeak-ng.dylib" "$TOOL_DIR/espeak-ng"
|
||||
install_name_tool -add_rpath "@executable_path/../Frameworks" "$TOOL_DIR/espeak-ng" || true
|
||||
|
||||
codesign --force --sign - --timestamp=none "$FRAMEWORK_DIR/libespeak-ng.dylib"
|
||||
codesign --force --sign - --timestamp=none "$TOOL_DIR/espeak-ng"
|
||||
outputFiles:
|
||||
- ${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Resources/Tools/espeak-ng
|
||||
- ${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Resources/Tools/espeak-ng-data/phondata
|
||||
- ${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Frameworks/libespeak-ng.dylib
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.felixfoertsch.vorleser.mac
|
||||
MACOSX_DEPLOYMENT_TARGET: 15.0
|
||||
GENERATE_INFOPLIST_FILE: true
|
||||
INFOPLIST_KEY_CFBundleVersion: "1"
|
||||
INFOPLIST_KEY_CFBundleShortVersionString: "0.1.0"
|
||||
1
tools/kokoro_coreml/kokoro-coreml
Submodule
1
tools/kokoro_coreml/kokoro-coreml
Submodule
Submodule tools/kokoro_coreml/kokoro-coreml added at 6a5235fce6
2
tools/kokoro_coreml/mise.toml
Normal file
2
tools/kokoro_coreml/mise.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[tools]
|
||||
python = "3.10"
|
||||
Reference in New Issue
Block a user