From d7626730dd8ab04e85f69d3094c7ed5c4f4855ff Mon Sep 17 00:00:00 2001 From: Masoud Date: Mon, 1 Apr 2024 08:49:58 +0330 Subject: [PATCH 1/2] Appending TTS Engine Next Generation by @jing332 --- .gitmodules | 3 +++ android/SherpaOnnxTtsEngine-NG | 1 + 2 files changed, 4 insertions(+) create mode 100644 .gitmodules create mode 160000 android/SherpaOnnxTtsEngine-NG diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..0d8cc5d95 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "android/SherpaOnnxTtsEngine-NG"] + path = android/SherpaOnnxTtsEngine-NG + url = https://github.com/mablue/SherpaOnnxTtsEngineAndroid.git diff --git a/android/SherpaOnnxTtsEngine-NG b/android/SherpaOnnxTtsEngine-NG new file mode 160000 index 000000000..cb6fd8df3 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG @@ -0,0 +1 @@ +Subproject commit cb6fd8df37e26d175a66c77945c11e9163d78a91 From 202c5fa5ab5626d054c49b9cb635bc3d61655e2b Mon Sep 17 00:00:00 2001 From: Masoud Date: Mon, 1 Apr 2024 12:20:37 +0330 Subject: [PATCH 2/2] appended as normal folder instade submodule #726 --- .gitmodules | 3 - android/SherpaOnnxTtsEngine-NG | 1 - .../.github/workflows/build.yml | 38 ++ .../.github/workflows/release.yml | 58 +++ android/SherpaOnnxTtsEngine-NG/.gitignore | 15 + android/SherpaOnnxTtsEngine-NG/CHANGELOG.md | 5 + android/SherpaOnnxTtsEngine-NG/README.md | 36 ++ android/SherpaOnnxTtsEngine-NG/app/.gitignore | 2 + .../app/build.gradle.kts | 155 +++++++ .../app/proguard-rules.pro | 105 +++++ .../tts/engine/ExampleInstrumentedTest.kt | 24 ++ .../app/src/debug/res/values/strings.xml | 4 + .../app/src/main/AndroidManifest.xml | 108 +++++ .../app/src/main/assets/.gitkeep | 0 .../main/java/com/k2fsa/sherpa/onnx/Tts.kt | 182 ++++++++ .../com/k2fsa/sherpa/onnx/tts/engine/App.kt | 21 + .../k2fsa/sherpa/onnx/tts/engine/AppConst.kt | 33 ++ .../k2fsa/sherpa/onnx/tts/engine/FileConst.kt | 13 + .../sherpa/onnx/tts/engine/GithubRelease.kt | 155 +++++++ .../onnx/tts/engine/NotificationConst.kt | 7 + .../sherpa/onnx/tts/engine/conf/AppConfig.kt | 11 + .../sherpa/onnx/tts/engine/conf/TtsConfig.kt | 43 ++ .../onnx/tts/engine/service/CheckVoiceData.kt | 26 ++ .../onnx/tts/engine/service/GetSampleText.kt | 60 +++ .../engine/service/IProgressTaskService.kt | 165 +++++++ .../tts/engine/service/InstallVoiceData.kt | 12 + .../tts/engine/service/ModelExportService.kt | 87 ++++ .../tts/engine/service/ModelManagerService.kt | 153 +++++++ .../onnx/tts/engine/service/TtsService.kt | 265 ++++++++++++ .../engine/synthesizer/ConfigModelManager.kt | 149 +++++++ .../engine/synthesizer/ConfigVoiceManager.kt | 110 +++++ .../engine/synthesizer/ModelPackageManager.kt | 199 +++++++++ .../engine/synthesizer/SampleTextManager.kt | 61 +++ .../engine/synthesizer/SynthesizerCache.kt | 120 ++++++ .../engine/synthesizer/SynthesizerManager.kt | 42 ++ .../tts/engine/synthesizer/config/Config.kt | 9 + .../synthesizer/config/ConfigManager.kt | 20 + .../synthesizer/config/ImplYamlConfig.kt | 68 +++ .../tts/engine/synthesizer/config/Model.kt | 29 ++ .../synthesizer/config/SampleTextConfig.kt | 36 ++ .../tts/engine/synthesizer/config/Voice.kt | 26 ++ .../onnx/tts/engine/ui/AuditionDialog.kt | 121 ++++++ .../onnx/tts/engine/ui/ConfirmDeleteDialog.kt | 67 +++ .../onnx/tts/engine/ui/ImplViewModel.kt | 31 ++ .../tts/engine/ui/LanguageSelectionDialog.kt | 41 ++ .../sherpa/onnx/tts/engine/ui/MainActivity.kt | 155 +++++++ .../sherpa/onnx/tts/engine/ui/NavRoutes.kt | 9 + .../ui/NotificationPermissionChecker.kt | 32 ++ .../tts/engine/ui/ShadowReorderableItem.kt | 44 ++ .../sherpa/onnx/tts/engine/ui/TtsViewModel.kt | 59 +++ .../onnx/tts/engine/ui/error/BigTextView.kt | 29 ++ .../engine/ui/error/ErrorDialogActivity.kt | 232 ++++++++++ .../engine/ui/error/ErrorDialogViewModel.kt | 7 + .../tts/engine/ui/models/AddModelsDialog.kt | 82 ++++ .../ui/models/ImportModelPackageDialog.kt | 84 ++++ .../tts/engine/ui/models/LanguageTextField.kt | 62 +++ .../tts/engine/ui/models/ModelDeleteDialog.kt | 62 +++ .../ui/models/ModelDownloadInstallDialog.kt | 142 ++++++ .../models/ModelDownloadInstallViewModel.kt | 41 ++ .../tts/engine/ui/models/ModelEditDialog.kt | 99 +++++ .../tts/engine/ui/models/ModelExportDialog.kt | 94 ++++ .../ui/models/ModelManagerMainToolBar.kt | 71 +++ .../engine/ui/models/ModelManagerScreen.kt | 372 ++++++++++++++++ .../engine/ui/models/ModelManagerViewModel.kt | 85 ++++ .../engine/ui/models/TaskAddedTipsDialog.kt | 21 + .../engine/ui/sampletext/SampleTextEdit.kt | 119 +++++ .../sampletext/SampleTextManagerActivity.kt | 19 + .../ui/sampletext/SampleTextManagerScreen.kt | 132 ++++++ .../sampletext/SampleTextMangerViewModel.kt | 42 ++ .../tts/engine/ui/settings/SettingsScreen.kt | 151 +++++++ .../tts/engine/ui/settings/SettingsWidgets.kt | 265 ++++++++++++ .../sherpa/onnx/tts/engine/ui/theme/Color.kt | 11 + .../sherpa/onnx/tts/engine/ui/theme/Theme.kt | 85 ++++ .../sherpa/onnx/tts/engine/ui/theme/Type.kt | 34 ++ .../tts/engine/ui/voices/AddVoiceDialog.kt | 179 ++++++++ .../onnx/tts/engine/ui/voices/SortDialog.kt | 25 ++ .../engine/ui/voices/VoiceEditViewModel.kt | 7 + .../engine/ui/voices/VoiceManagerScreen.kt | 380 ++++++++++++++++ .../engine/ui/voices/VoiceManagerViewModel.kt | 96 +++++ .../onnx/tts/engine/ui/widgets/AppDialog.kt | 229 ++++++++++ .../engine/ui/widgets/AppSelectionDialog.kt | 163 +++++++ .../engine/ui/widgets/AppSelectionToolBar.kt | 74 ++++ .../onnx/tts/engine/ui/widgets/AppSpinner.kt | 183 ++++++++ .../onnx/tts/engine/ui/widgets/AppTooltip.kt | 38 ++ .../engine/ui/widgets/DeleteForeverIcon.kt | 24 ++ .../tts/engine/ui/widgets/DeleteMenuItem.kt | 24 ++ .../tts/engine/ui/widgets/DenseTextField.kt | 243 +++++++++++ .../engine/ui/widgets/DropdownTextField.kt | 114 +++++ .../tts/engine/ui/widgets/EmptyTextToolbar.kt | 20 + .../onnx/tts/engine/ui/widgets/LabelSlider.kt | 222 ++++++++++ .../tts/engine/ui/widgets/LoadingContent.kt | 71 +++ .../engine/ui/widgets/LongClickIconButton.kt | 82 ++++ .../ui/widgets/SearchTextFieldInList.kt | 50 +++ .../tts/engine/ui/widgets/SelectableCard.kt | 70 +++ .../tts/engine/ui/widgets/TextCheckBox.kt | 44 ++ .../tts/engine/ui/widgets/TextFieldDialog.kt | 44 ++ .../onnx/tts/engine/ui/widgets/Widgets.kt | 84 ++++ .../onnx/tts/engine/utils/ClipBoardUtils.kt | 96 +++++ .../onnx/tts/engine/utils/CompressUtils.kt | 81 ++++ .../onnx/tts/engine/utils/Compressor.kt | 157 +++++++ .../onnx/tts/engine/utils/ExtensionUtils.kt | 406 ++++++++++++++++++ .../sherpa/onnx/tts/engine/utils/FileUtils.kt | 28 ++ .../onnx/tts/engine/utils/HandlerUtils.kt | 51 +++ .../onnx/tts/engine/utils/LocaleUtils.kt | 77 ++++ .../tts/engine/utils/NotificationUtils.kt | 80 ++++ .../onnx/tts/engine/utils/PcmAudioPlayer.kt | 110 +++++ .../onnx/tts/engine/utils/ThrottleUtil.kt | 20 + .../onnx/tts/engine/utils/ToastUtils.kt | 49 +++ .../app/src/main/jniLibs/arm64-v8a/.gitkeep | 0 .../app/src/main/jniLibs/armeabi-v7a/.gitkeep | 0 .../app/src/main/jniLibs/x86/.gitkeep | 0 .../app/src/main/jniLibs/x86_64/.gitkeep | 0 .../drawable-v24/ic_launcher_foreground.xml | 5 + .../app/src/main/res/layout/big_text_view.xml | 22 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 2252 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 4008 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 1518 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 2546 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 3070 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 5620 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 4580 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 8872 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 6254 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 12436 bytes .../app/src/main/res/values-fa/strings.xml | 87 ++++ .../app/src/main/res/values-zh/strings.xml | 86 ++++ .../app/src/main/res/values/colors.xml | 10 + .../res/values/ic_launcher_background.xml | 4 + .../app/src/main/res/values/strings.xml | 87 ++++ .../app/src/main/res/values/themes.xml | 5 + .../app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + .../app/src/main/res/xml/tts_engine.xml | 5 + .../sherpa/onnx/tts/engine/ExampleUnitTest.kt | 17 + .../SherpaOnnxTtsEngine-NG/build.gradle.kts | 8 + .../SherpaOnnxTtsEngine-NG/gradle.properties | 23 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + android/SherpaOnnxTtsEngine-NG/gradlew | 185 ++++++++ android/SherpaOnnxTtsEngine-NG/gradlew.bat | 89 ++++ android/SherpaOnnxTtsEngine-NG/images/1.jpg | Bin 0 -> 275622 bytes .../SherpaOnnxTtsEngine-NG/install-solib.sh | 9 + .../settings.gradle.kts | 18 + 145 files changed, 9883 insertions(+), 4 deletions(-) delete mode 160000 android/SherpaOnnxTtsEngine-NG create mode 100644 android/SherpaOnnxTtsEngine-NG/.github/workflows/build.yml create mode 100644 android/SherpaOnnxTtsEngine-NG/.github/workflows/release.yml create mode 100644 android/SherpaOnnxTtsEngine-NG/.gitignore create mode 100644 android/SherpaOnnxTtsEngine-NG/CHANGELOG.md create mode 100644 android/SherpaOnnxTtsEngine-NG/README.md create mode 100644 android/SherpaOnnxTtsEngine-NG/app/.gitignore create mode 100644 android/SherpaOnnxTtsEngine-NG/app/build.gradle.kts create mode 100644 android/SherpaOnnxTtsEngine-NG/app/proguard-rules.pro create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/androidTest/java/com/k2fsa/sherpa/onnx/tts/engine/ExampleInstrumentedTest.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/debug/res/values/strings.xml create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/AndroidManifest.xml create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/assets/.gitkeep create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/Tts.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/App.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/AppConst.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/FileConst.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/GithubRelease.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/NotificationConst.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/conf/AppConfig.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/conf/TtsConfig.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/CheckVoiceData.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/GetSampleText.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/IProgressTaskService.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/InstallVoiceData.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/ModelExportService.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/ModelManagerService.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/TtsService.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/ConfigModelManager.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/ConfigVoiceManager.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/ModelPackageManager.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/SampleTextManager.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/SynthesizerCache.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/SynthesizerManager.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/Config.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/ConfigManager.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/ImplYamlConfig.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/Model.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/SampleTextConfig.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/Voice.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/AuditionDialog.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/ConfirmDeleteDialog.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/ImplViewModel.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/LanguageSelectionDialog.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/MainActivity.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/NavRoutes.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/NotificationPermissionChecker.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/ShadowReorderableItem.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/TtsViewModel.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/error/BigTextView.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/error/ErrorDialogActivity.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/error/ErrorDialogViewModel.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/AddModelsDialog.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ImportModelPackageDialog.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/LanguageTextField.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelDeleteDialog.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelDownloadInstallDialog.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelDownloadInstallViewModel.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelEditDialog.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelExportDialog.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelManagerMainToolBar.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelManagerScreen.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelManagerViewModel.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/TaskAddedTipsDialog.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/sampletext/SampleTextEdit.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/sampletext/SampleTextManagerActivity.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/sampletext/SampleTextManagerScreen.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/sampletext/SampleTextMangerViewModel.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/settings/SettingsScreen.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/settings/SettingsWidgets.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/theme/Color.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/theme/Theme.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/theme/Type.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/voices/AddVoiceDialog.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/voices/SortDialog.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/voices/VoiceEditViewModel.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/voices/VoiceManagerScreen.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/voices/VoiceManagerViewModel.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/AppDialog.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/AppSelectionDialog.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/AppSelectionToolBar.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/AppSpinner.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/AppTooltip.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/DeleteForeverIcon.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/DeleteMenuItem.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/DenseTextField.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/DropdownTextField.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/EmptyTextToolbar.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/LabelSlider.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/LoadingContent.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/LongClickIconButton.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/SearchTextFieldInList.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/SelectableCard.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/TextCheckBox.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/TextFieldDialog.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/Widgets.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/ClipBoardUtils.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/CompressUtils.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/Compressor.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/ExtensionUtils.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/FileUtils.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/HandlerUtils.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/LocaleUtils.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/NotificationUtils.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/PcmAudioPlayer.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/ThrottleUtil.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/ToastUtils.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/jniLibs/arm64-v8a/.gitkeep create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/jniLibs/armeabi-v7a/.gitkeep create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/jniLibs/x86/.gitkeep create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/jniLibs/x86_64/.gitkeep create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/res/layout/big_text_view.xml create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/res/values-fa/strings.xml create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/res/values-zh/strings.xml create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/res/values/colors.xml create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/res/values/ic_launcher_background.xml create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/res/values/strings.xml create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/res/values/themes.xml create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/res/xml/backup_rules.xml create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/main/res/xml/tts_engine.xml create mode 100644 android/SherpaOnnxTtsEngine-NG/app/src/test/java/com/k2fsa/sherpa/onnx/tts/engine/ExampleUnitTest.kt create mode 100644 android/SherpaOnnxTtsEngine-NG/build.gradle.kts create mode 100644 android/SherpaOnnxTtsEngine-NG/gradle.properties create mode 100644 android/SherpaOnnxTtsEngine-NG/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/SherpaOnnxTtsEngine-NG/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/SherpaOnnxTtsEngine-NG/gradlew create mode 100644 android/SherpaOnnxTtsEngine-NG/gradlew.bat create mode 100644 android/SherpaOnnxTtsEngine-NG/images/1.jpg create mode 100644 android/SherpaOnnxTtsEngine-NG/install-solib.sh create mode 100644 android/SherpaOnnxTtsEngine-NG/settings.gradle.kts diff --git a/.gitmodules b/.gitmodules index 0d8cc5d95..e69de29bb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +0,0 @@ -[submodule "android/SherpaOnnxTtsEngine-NG"] - path = android/SherpaOnnxTtsEngine-NG - url = https://github.com/mablue/SherpaOnnxTtsEngineAndroid.git diff --git a/android/SherpaOnnxTtsEngine-NG b/android/SherpaOnnxTtsEngine-NG deleted file mode 160000 index cb6fd8df3..000000000 --- a/android/SherpaOnnxTtsEngine-NG +++ /dev/null @@ -1 +0,0 @@ -Subproject commit cb6fd8df37e26d175a66c77945c11e9163d78a91 diff --git a/android/SherpaOnnxTtsEngine-NG/.github/workflows/build.yml b/android/SherpaOnnxTtsEngine-NG/.github/workflows/build.yml new file mode 100644 index 000000000..011d862f9 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/.github/workflows/build.yml @@ -0,0 +1,38 @@ +name: Android CI (Test) + +on: + push: + branches: + - "master" + paths-ignore: + - "**.md" + - "images/**" + workflow_dispatch: + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Build + run: | + chmod +x *.sh + ./install-solib.sh + + chmod +x gradlew + ./gradlew assembleRelease --build-cache --parallel --daemon --warning-mode all + + - name: Upload apk + uses: actions/upload-artifact@v4 + with: + name: app-release + path: "${{ github.workspace }}/app/build/outputs/apk/release/*.apk" diff --git a/android/SherpaOnnxTtsEngine-NG/.github/workflows/release.yml b/android/SherpaOnnxTtsEngine-NG/.github/workflows/release.yml new file mode 100644 index 000000000..da56bae15 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/.github/workflows/release.yml @@ -0,0 +1,58 @@ +name: Build Release + +on: + push: + branches: + - "master" + paths: + - "CHANGELOG.md" + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + env: + outputs_dir: "${{ github.workspace }}/app/build/outputs" + ver_name: "" + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Setup Gradle + uses: gradle/gradle-build-action@v3 + + - name: Build + run: | + chmod +x *.sh + ./install-solib.sh + + chmod +x gradlew + ./gradlew assembleRelease --build-cache --parallel --daemon --warning-mode all + + - name: Init environment variable + run: | + echo "ver_name=$(grep -m 1 'versionName' ${{ env.outputs_dir }}/apk/release/output-metadata.json | cut -d\" -f4)" >> $GITHUB_ENV + + - name: Upload Mappings to Artifact + uses: actions/upload-artifact@v4 + with: + name: mappings_${{ env.ver_name }} + path: ${{ env.outputs_dir }}/mapping/*/*.txt + + - uses: softprops/action-gh-release@v2 + with: + name: ${{ env.ver_name }} + tag_name: ${{ env.ver_name }} + body_path: ${{ github.workspace }}/CHANGELOG.md + draft: false + prerelease: false + files: ${{env.outputs_dir}}/apk/release/*.apk + env: + GITHUB_TOKEN: ${{ secrets.TOKEN }} diff --git a/android/SherpaOnnxTtsEngine-NG/.gitignore b/android/SherpaOnnxTtsEngine-NG/.gitignore new file mode 100644 index 000000000..aa724b770 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/android/SherpaOnnxTtsEngine-NG/CHANGELOG.md b/android/SherpaOnnxTtsEngine-NG/CHANGELOG.md new file mode 100644 index 000000000..1cfb7f001 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/CHANGELOG.md @@ -0,0 +1,5 @@ +- Batch delete model +- Export model to zip +- Github proxy url +- Add tips +- refactor model and voice, and support speaker id. \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/README.md b/android/SherpaOnnxTtsEngine-NG/README.md new file mode 100644 index 000000000..0e071fc4d --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/README.md @@ -0,0 +1,36 @@ +[![Android CI (Test)](https://github.com/jing332/SherpaOnnxTtsEngineAndroid/actions/workflows/build.yml/badge.svg)](https://github.com/jing332/SherpaOnnxTtsEngineAndroid/actions/workflows/build.yml) +[![Build Release](https://github.com/jing332/SherpaOnnxTtsEngineAndroid/actions/workflows/release.yml/badge.svg)](https://github.com/jing332/SherpaOnnxTtsEngineAndroid/actions/workflows/release.yml) + +![GitHub release](https://img.shields.io/github/downloads/jing332/SherpaOnnxTtsEngineAndroid/total) +![GitHub release (latest by date)](https://img.shields.io/github/downloads/jing332/SherpaOnnxTtsEngineAndroid/latest/total) + +# SherpaOnnxTtsEngineAndroid + +> [!NOTE] +> Automatic downloading and installation of models is now supported! + +
+ Original steps + +Download Model: https://github.com/k2-fsa/sherpa-onnx/releases/tts-models + +then: + +1. Open Application -> Import model package -> choose *.tar.bz2 model file -> Wait for the import to + complete. + +> or manual import: + +1. Unzip to Android/data/com.k2fsa.sherpa.onnx.tts.engine/files/model +2. Open Application -> Add models + +> On Android 13+ devices, use [MT Manager](https://mt2.cn/download/) +> or [Material Files](https://github.com/zhanghai/MaterialFiles) +> or [MiXplorer](https://xdaforums.com/t/app-2-2-mixplorer-v6-x-released-fully-featured-file-manager.1523691/) +> to access the Android/data path. + +
+ +# Preview + +1 diff --git a/android/SherpaOnnxTtsEngine-NG/app/.gitignore b/android/SherpaOnnxTtsEngine-NG/app/.gitignore new file mode 100644 index 000000000..df52131bf --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/.gitignore @@ -0,0 +1,2 @@ +/build +*.so \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/build.gradle.kts b/android/SherpaOnnxTtsEngine-NG/app/build.gradle.kts new file mode 100644 index 000000000..7e1016aee --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/build.gradle.kts @@ -0,0 +1,155 @@ +import java.text.SimpleDateFormat +import java.util.Date +import java.util.TimeZone + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("kotlinx-serialization") + +} + +val buildTime: Long + get() { + val t = Date().time / 1000 + return t + } + +val releaseTime: String + get() { + val sdf = SimpleDateFormat("yy.MMddHH") + sdf.timeZone = TimeZone.getTimeZone("GMT+8") + return sdf.format(Date()) + } + +val version = "1.$releaseTime" + +val gitCommits: Int + get() { + val process = ProcessBuilder("git", "rev-list", "HEAD", "--count").start() + return process.inputStream.reader().use { it.readText() }.trim().toInt() + } +android { + namespace = "com.k2fsa.sherpa.onnx.tts.engine" + compileSdk = 34 + + defaultConfig { + applicationId = "com.k2fsa.sherpa.onnx.tts.engine" + minSdk = 21 + targetSdk = 34 + versionCode = gitCommits + versionName = version + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + signingConfigs { + create("release") { + storeFile = file("sherpa-onnx.jks") + storePassword = "sherpa-onnx" + keyAlias = "sherpa-onnx" + keyPassword = "sherpa-onnx" + } + } + + buildTypes { + release { + signingConfig = signingConfigs["release"] + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + + debug { + applicationIdSuffix = ".debug" + versionNameSuffix = "_debug" + } + } + compileOptions { + isCoreLibraryDesugaringEnabled = true + + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.8" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") + + implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0") + + implementation("com.squareup.okio:okio:3.3.0") + implementation("com.squareup.okhttp3:okhttp:4.11.0") + implementation("com.github.liangjingkanji:Net:3.6.4") + + implementation("androidx.navigation:navigation-compose:2.7.7") + implementation("androidx.documentfile:documentfile:1.0.1") + + implementation("org.apache.commons:commons-compress:1.26.0") + implementation("commons-codec:commons-codec:1.16.1") + + implementation("org.tukaani:xz:1.9") + + + implementation("com.geyifeng.immersionbar:immersionbar:3.2.2") + implementation("com.geyifeng.immersionbar:immersionbar-ktx:3.2.2") + + implementation("com.charleskorn.kaml:kaml:0.57.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2") + + implementation("com.github.FunnySaltyFish.ComposeDataSaver:data-saver:v1.1.5") + implementation("org.burnoutcrew.composereorderable:reorderable:0.9.6") + + val accompanistVersion = "0.34.0" + implementation("com.google.accompanist:accompanist-systemuicontroller:${accompanistVersion}") +// implementation("com.google.accompanist:accompanist-navigation-animation:${accompanistVersion}") + implementation("com.google.accompanist:accompanist-permissions:${accompanistVersion}") + + + implementation("androidx.constraintlayout:constraintlayout-compose:1.0.1") + implementation("androidx.navigation:navigation-compose:2.7.7") + + implementation("io.github.dokar3:sheets-m3:0.5.4") + + val composeBom = platform("androidx.compose:compose-bom:2024.02.01") + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("androidx.activity:activity-compose:1.8.2") + implementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.appcompat:appcompat:1.6.1") + +// implementation("androidx.compose.material3:material-icons-core") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") + + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation(composeBom) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/proguard-rules.pro b/android/SherpaOnnxTtsEngine-NG/app/proguard-rules.pro new file mode 100644 index 000000000..e5f0148e4 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/proguard-rules.pro @@ -0,0 +1,105 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +-keep class com.k2fsa.sherpa.onnx.* { *; } + + +#-------------- 去掉所有打印 ------------- + +-assumenosideeffects class android.util.Log { +public static *** d(...); + +# public static *** e(...); + +public static *** i(...); + +public static *** v(...); + +public static *** println(...); + +public static *** w(...); + +public static *** wtf(...); + +} + +-assumenosideeffects class android.util.Log { +public static *** d(...); + +public static *** v(...); + +} + +-assumenosideeffects class android.util.Log { +# public static *** e(...); + +public static *** v(...); + +} + +-assumenosideeffects class android.util.Log { +public static *** i(...); + +public static *** v(...); + +} + +-assumenosideeffects class android.util.Log { +public static *** w(...); + +public static *** v(...); + +} + +-assumenosideeffects class java.io.PrintStream { +public *** println(...); + +public *** print(...); + +} + +# Keep `Companion` object fields of serializable classes. +# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. +-if @kotlinx.serialization.Serializable class ** +-keepclassmembers class <1> { + static <1>$Companion Companion; +} + +# Keep `serializer()` on companion objects (both default and named) of serializable classes. +-if @kotlinx.serialization.Serializable class ** { + static **$* *; +} +-keepclassmembers class <2>$<3> { + kotlinx.serialization.KSerializer serializer(...); +} + +# Keep `INSTANCE.serializer()` of serializable objects. +-if @kotlinx.serialization.Serializable class ** { + public static ** INSTANCE; +} +-keepclassmembers class <1> { + public static <1> INSTANCE; + kotlinx.serialization.KSerializer serializer(...); +} + +# @Serializable and @Polymorphic are used at runtime for polymorphic serialization. +-keepattributes RuntimeVisibleAnnotations,AnnotationDefault \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/androidTest/java/com/k2fsa/sherpa/onnx/tts/engine/ExampleInstrumentedTest.kt b/android/SherpaOnnxTtsEngine-NG/app/src/androidTest/java/com/k2fsa/sherpa/onnx/tts/engine/ExampleInstrumentedTest.kt new file mode 100644 index 000000000..6713b5be7 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/androidTest/java/com/k2fsa/sherpa/onnx/tts/engine/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.k2fsa.sherpa.onnx.tts.engine + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.k2fsa.sherpa.onnx.tts.engine", appContext.packageName) + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/debug/res/values/strings.xml b/android/SherpaOnnxTtsEngine-NG/app/src/debug/res/values/strings.xml new file mode 100644 index 000000000..f61e0bf20 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/debug/res/values/strings.xml @@ -0,0 +1,4 @@ + + + DB·Next-gen Kaldi: TTS + \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/AndroidManifest.xml b/android/SherpaOnnxTtsEngine-NG/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..ccb10d872 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/AndroidManifest.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/assets/.gitkeep b/android/SherpaOnnxTtsEngine-NG/app/src/main/assets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/Tts.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/Tts.kt new file mode 100644 index 000000000..4c10bb1c2 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/Tts.kt @@ -0,0 +1,182 @@ +// Copyright (c) 2023 Xiaomi Corporation +package com.k2fsa.sherpa.onnx + +import android.content.res.AssetManager + +data class OfflineTtsVitsModelConfig( + var model: String, + var lexicon: String = "", + var tokens: String, + var dataDir: String = "", + var noiseScale: Float = 0.667f, + var noiseScaleW: Float = 0.8f, + var lengthScale: Float = 1.0f, +) + +data class OfflineTtsModelConfig( + var vits: OfflineTtsVitsModelConfig, + var numThreads: Int = 1, + var debug: Boolean = false, + var provider: String = "cpu", +) + +data class OfflineTtsConfig( + var model: OfflineTtsModelConfig, + var ruleFsts: String = "", + var maxNumSentences: Int = 1, +) + +class GeneratedAudio( + val samples: FloatArray, + val sampleRate: Int, +) { + fun save(filename: String) = + saveImpl(filename = filename, samples = samples, sampleRate = sampleRate) + + private external fun saveImpl( + filename: String, + samples: FloatArray, + sampleRate: Int + ): Boolean +} + +class OfflineTts( + assetManager: AssetManager? = null, + var config: OfflineTtsConfig, +) { + var isRunning = false + private var ptr: Long + + init { + if (assetManager != null) { + ptr = new(assetManager, config) + } else { + ptr = newFromFile(config) + } + } + + fun sampleRate() = getSampleRate(ptr) + + fun numSpeakers() = getNumSpeakers(ptr) + + @Synchronized + fun generate( + text: String, + sid: Int = 0, + speed: Float = 1.0f + ): GeneratedAudio { + isRunning = true + var objArray = generateImpl(ptr, text = text, sid = sid, speed = speed) + isRunning = false + return GeneratedAudio( + samples = objArray[0] as FloatArray, + sampleRate = objArray[1] as Int + ) + } + @Synchronized + fun generateWithCallback( + text: String, + sid: Int = 0, + speed: Float = 1.0f, + callback: (samples: FloatArray) -> Unit + ): GeneratedAudio { + isRunning = true + var objArray = generateWithCallbackImpl( + ptr, + text = text, + sid = sid, + speed = speed, + callback = callback + ) + isRunning = false + return GeneratedAudio( + samples = objArray[0] as FloatArray, + sampleRate = objArray[1] as Int + ) + } + + fun allocate(assetManager: AssetManager? = null) { + if (ptr == 0L) { + if (assetManager != null) { + ptr = new(assetManager, config) + } else { + ptr = newFromFile(config) + } + } + } + + fun free() { + if (ptr != 0L) { + delete(ptr) + ptr = 0 + } + } + + protected fun finalize() { + delete(ptr) + } + + private external fun new( + assetManager: AssetManager, + config: OfflineTtsConfig, + ): Long + + private external fun newFromFile( + config: OfflineTtsConfig, + ): Long + + private external fun delete(ptr: Long) + private external fun getSampleRate(ptr: Long): Int + private external fun getNumSpeakers(ptr: Long): Int + + // The returned array has two entries: + // - the first entry is an 1-D float array containing audio samples. + // Each sample is normalized to the range [-1, 1] + // - the second entry is the sample rate + external fun generateImpl( + ptr: Long, + text: String, + sid: Int = 0, + speed: Float = 1.0f + ): Array + + external fun generateWithCallbackImpl( + ptr: Long, + text: String, + sid: Int = 0, + speed: Float = 1.0f, + callback: (samples: FloatArray) -> Unit + ): Array + + companion object { + init { + System.loadLibrary("sherpa-onnx-jni") + } + } +} + +// please refer to +// https://k2-fsa.github.io/sherpa/onnx/tts/pretrained_models/index.html +// to download models +fun getOfflineTtsConfig( + modelDir: String, + modelName: String, + lexicon: String, + dataDir: String, + ruleFsts: String +): OfflineTtsConfig? { + return OfflineTtsConfig( + model = OfflineTtsModelConfig( + vits = OfflineTtsVitsModelConfig( + model = "$modelDir/$modelName", + lexicon = "$modelDir/$lexicon", + tokens = "$modelDir/tokens.txt", + dataDir = dataDir + ), + numThreads = 2, + debug = true, + provider = "cpu", + ), + ruleFsts = ruleFsts, + ) +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/App.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/App.kt new file mode 100644 index 000000000..d0b4dde93 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/App.kt @@ -0,0 +1,21 @@ +package com.k2fsa.sherpa.onnx.tts.engine + +import android.app.Application +import com.drake.net.utils.withIO +import com.k2fsa.sherpa.onnx.tts.engine.utils.CompressorFactory +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +val app by lazy { App.instance } + +class App : Application() { + companion object { + lateinit var instance: App + } + + override fun onCreate() { + super.onCreate() + instance = this + } + +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/AppConst.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/AppConst.kt new file mode 100644 index 000000000..fee0b2adf --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/AppConst.kt @@ -0,0 +1,33 @@ +package com.k2fsa.sherpa.onnx.tts.engine + +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.charleskorn.kaml.SingleLineStringStyle +import com.charleskorn.kaml.Yaml +import com.charleskorn.kaml.YamlConfiguration +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json + +object AppConst { + @Suppress("DEPRECATION") + val localBroadcast by lazy { LocalBroadcastManager.getInstance(app) } + + val yaml = Yaml( + configuration = YamlConfiguration( + encodeDefaults = false, + strictMode = false, + singleLineStringStyle = SingleLineStringStyle.PlainExceptAmbiguous + ) + ) + + @OptIn(ExperimentalSerializationApi::class) + val jsonBuilder by lazy { + Json { + allowStructuredMapKeys = true + ignoreUnknownKeys = true + prettyPrint = true + isLenient = true + explicitNulls = false //忽略为null的字段 + allowStructuredMapKeys = true + } + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/FileConst.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/FileConst.kt new file mode 100644 index 000000000..07bdf99ba --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/FileConst.kt @@ -0,0 +1,13 @@ +package com.k2fsa.sherpa.onnx.tts.engine + +object FileConst { + var modelDir: String = + app.getExternalFilesDir("model")!!.absolutePath + + var cacheModelDir = + app.externalCacheDir!!.resolve("model").absolutePath + val cacheDownloadDir = app.externalCacheDir!!.resolve("download").absolutePath + + var configPath: String = "$modelDir/config.yaml" + var sampleTextPath: String = "$modelDir/sampleText.yaml" +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/GithubRelease.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/GithubRelease.kt new file mode 100644 index 000000000..56b6809e3 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/GithubRelease.kt @@ -0,0 +1,155 @@ +package com.k2fsa.sherpa.onnx.tts.engine + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class GithubRelease( + @SerialName("assets") + val assets: List = listOf(), +// @SerialName("assets_url") +// val assetsUrl: String = "", // https://api.github.com/repos/jing332/frpandroid/releases/117392218/assets +// @SerialName("author") +// val author: Author = Author(), +// @SerialName("body") + val body: String = "", // > 未知CPU架构?请优先选择体积最大的APK### 更新内容:- 系统通知内容中支持显示局域网IP +// @SerialName("created_at") +// val createdAt: String = "", // 2023-08-16T03:02:15Z +// @SerialName("draft") +// val draft: Boolean = false, // false +// @SerialName("html_url") +// val htmlUrl: String = "", // https://github.com/jing332/frpandroid/releases/tag/1.23.081611 +// @SerialName("id") +// val id: Int = 0, // 117392218 +// @SerialName("name") +// val name: String = "", // 1.23.081611 +// @SerialName("node_id") +// val nodeId: String = "", // RE_kwDOKGSbbc4G_0Na +// @SerialName("prerelease") +// val prerelease: Boolean = false, // false +// @SerialName("published_at") +// val publishedAt: String = "", // 2023-08-16T03:29:36Z + @SerialName("tag_name") + val tagName: String = "", // 1.23.081611 +// @SerialName("tarball_url") +// val tarballUrl: String = "", // https://api.github.com/repos/jing332/frpandroid/tarball/1.23.081611 +// @SerialName("target_commitish") +// val targetCommitish: String = "", // master +// @SerialName("upload_url") +// val uploadUrl: String = "", // https://uploads.github.com/repos/jing332/frpandroid/releases/117392218/assets{?name,label} +// @SerialName("url") +// val url: String = "", // https://api.github.com/repos/jing332/frpandroid/releases/117392218 +// @SerialName("zipball_url") +// val zipballUrl: String = "" // https://api.github.com/repos/jing332/frpandroid/zipball/1.23.081611 +) { + @Serializable + data class Asset( + @SerialName("browser_download_url") + val browserDownloadUrl: String = "", // https://github.com/jing332/frpandroid/releases/download/1.23.081611/AList-v1.23.081611.apk + @SerialName("content_type") + val contentType: String = "", // application/vnd.android.package-archive +// @SerialName("created_at") +// val createdAt: String = "", // 2023-08-16T03:29:37Z +// @SerialName("download_count") +// val downloadCount: Int = 0, // 28 +// @SerialName("id") +// val id: Long = 0, // 121683040 +// @SerialName("label") +// val label: String = "", + @SerialName("name") + val name: String = "", // AList-v1.23.081611.apk +// @SerialName("node_id") +// val nodeId: String = "", // RA_kwDOKGSbbc4HQLxg + @SerialName("size") + val size: Long = 0, // 71948726 +// @SerialName("state") +// val state: String = "", // uploaded +// @SerialName("updated_at") +// val updatedAt: String = "", // 2023-08-16T03:29:39Z +// @SerialName("uploader") +// val uploader: Uploader = Uploader(), +// @SerialName("url") +// val url: String = "" // https://api.github.com/repos/jing332/frpandroid/releases/assets/121683040 + ) { + @Serializable + data class Uploader( + @SerialName("avatar_url") + val avatarUrl: String = "", // https://avatars.githubusercontent.com/in/15368?v=4 + @SerialName("events_url") + val eventsUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy} + @SerialName("followers_url") + val followersUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/followers + @SerialName("following_url") + val followingUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user} + @SerialName("gists_url") + val gistsUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id} + @SerialName("gravatar_id") + val gravatarId: String = "", + @SerialName("html_url") + val htmlUrl: String = "", // https://github.com/apps/github-actions + @SerialName("id") + val id: Int = 0, // 41898282 + @SerialName("login") + val login: String = "", // github-actions[bot] + @SerialName("node_id") + val nodeId: String = "", // MDM6Qm90NDE4OTgyODI= + @SerialName("organizations_url") + val organizationsUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/orgs + @SerialName("received_events_url") + val receivedEventsUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/received_events + @SerialName("repos_url") + val reposUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/repos + @SerialName("site_admin") + val siteAdmin: Boolean = false, // false + @SerialName("starred_url") + val starredUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo} + @SerialName("subscriptions_url") + val subscriptionsUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/subscriptions + @SerialName("type") + val type: String = "", // Bot + @SerialName("url") + val url: String = "" // https://api.github.com/users/github-actions%5Bbot%5D + ) + } + + @Serializable + data class Author( + @SerialName("avatar_url") + val avatarUrl: String = "", // https://avatars.githubusercontent.com/in/15368?v=4 + @SerialName("events_url") + val eventsUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy} + @SerialName("followers_url") + val followersUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/followers + @SerialName("following_url") + val followingUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user} + @SerialName("gists_url") + val gistsUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id} + @SerialName("gravatar_id") + val gravatarId: String = "", + @SerialName("html_url") + val htmlUrl: String = "", // https://github.com/apps/github-actions + @SerialName("id") + val id: Int = 0, // 41898282 + @SerialName("login") + val login: String = "", // github-actions[bot] + @SerialName("node_id") + val nodeId: String = "", // MDM6Qm90NDE4OTgyODI= + @SerialName("organizations_url") + val organizationsUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/orgs + @SerialName("received_events_url") + val receivedEventsUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/received_events + @SerialName("repos_url") + val reposUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/repos + @SerialName("site_admin") + val siteAdmin: Boolean = false, // false + @SerialName("starred_url") + val starredUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo} + @SerialName("subscriptions_url") + val subscriptionsUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/subscriptions + @SerialName("type") + val type: String = "", // Bot + @SerialName("url") + val url: String = "" // https://api.github.com/users/github-actions%5Bbot%5D + ) +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/NotificationConst.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/NotificationConst.kt new file mode 100644 index 000000000..6382c8ea1 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/NotificationConst.kt @@ -0,0 +1,7 @@ +package com.k2fsa.sherpa.onnx.tts.engine + +object NotificationConst { + const val MODEL_EXPORT_CHANNEL = "model_export" + const val MODEL_PACKAGE_INSTALLER_CHANNEL = "model_package_installer" + +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/conf/AppConfig.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/conf/AppConfig.kt new file mode 100644 index 000000000..164991fa1 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/conf/AppConfig.kt @@ -0,0 +1,11 @@ +package com.k2fsa.sherpa.onnx.tts.engine.conf + +import com.funny.data_saver.core.DataSaverPreferences +import com.funny.data_saver.core.mutableDataSaverStateOf +import com.k2fsa.sherpa.onnx.tts.engine.App + +object AppConfig { + private val dataSaverPref = DataSaverPreferences(App.instance.getSharedPreferences("app", 0)) + + val ghProxyUrl = mutableDataSaverStateOf(dataSaverPref, "ghProxyUrl", "") +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/conf/TtsConfig.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/conf/TtsConfig.kt new file mode 100644 index 000000000..ca2085346 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/conf/TtsConfig.kt @@ -0,0 +1,43 @@ +package com.k2fsa.sherpa.onnx.tts.engine.conf + +import com.funny.data_saver.core.DataSaverConverter +import com.funny.data_saver.core.DataSaverPreferences +import com.funny.data_saver.core.mutableDataSaverStateOf +import com.k2fsa.sherpa.onnx.tts.engine.App +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config.Voice + +object TtsConfig { + private val dataSaverPref = DataSaverPreferences(App.instance.getSharedPreferences("tts", 0)) + + init { + DataSaverConverter.registerTypeConverters( + save = { it.toString() }, + restore = { Voice.from(it) } + ) + } + + val voice = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "voice", + initialValue = Voice.EMPTY + ) + + val timeoutDestruction = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "timeoutDestruction", + initialValue = 3 + ) + + val cacheSize = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "cacheSize", + initialValue = 3 + ) + + val threadNum = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "threadNum", + initialValue = 2 + ) + +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/CheckVoiceData.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/CheckVoiceData.kt new file mode 100644 index 000000000..7d5da96bf --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/CheckVoiceData.kt @@ -0,0 +1,26 @@ +package com.k2fsa.sherpa.onnx.tts.engine.service + +import android.content.Intent +import android.os.Bundle +import android.speech.tts.TextToSpeech +import androidx.appcompat.app.AppCompatActivity +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.ConfigModelManager +import com.k2fsa.sherpa.onnx.tts.engine.utils.newLocaleFromCode +import com.k2fsa.sherpa.onnx.tts.engine.utils.toIso3Code + +class CheckVoiceData : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val languages = ConfigModelManager.languages().map { newLocaleFromCode(it) } + + val intent = Intent().apply { + putStringArrayListExtra( + TextToSpeech.Engine.EXTRA_AVAILABLE_VOICES, + arrayListOf(*languages.map { it.toIso3Code() }.distinct().toTypedArray()) + ) + putStringArrayListExtra(TextToSpeech.Engine.EXTRA_UNAVAILABLE_VOICES, arrayListOf()) + } + setResult(TextToSpeech.Engine.CHECK_VOICE_DATA_PASS, intent) + finish() + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/GetSampleText.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/GetSampleText.kt new file mode 100644 index 000000000..094953bb0 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/GetSampleText.kt @@ -0,0 +1,60 @@ +package com.k2fsa.sherpa.onnx.tts.engine.service + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.speech.tts.TextToSpeech +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.SampleTextManager +import com.k2fsa.sherpa.onnx.tts.engine.utils.toCode +import com.k2fsa.sherpa.onnx.tts.engine.utils.toLocaleFromIso3 + +class GetSampleText : Activity() { + private fun getCode(): String { + val language = intent.getStringExtra("language") ?: "" + val country = intent.getStringExtra("country") ?: "" + val variant = intent.getStringExtra("variant") ?: "" + + return when { + language.isNotEmpty() && country.isNotEmpty() && variant.isNotEmpty() -> { + "$language-$country-$variant" + } + + language.isNotEmpty() && country.isNotEmpty() -> { + "$language-$country" + } + + language.isNotEmpty() -> { + language + } + + else -> { + "" + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + var result = TextToSpeech.LANG_AVAILABLE + var text = "" + val code = getCode() + println("GetSampleText: code=$code") + + text = SampleTextManager.getSampleText(code).ifEmpty { + result = TextToSpeech.LANG_NOT_SUPPORTED + "" + } + + val intent = Intent().apply { + if (result == TextToSpeech.LANG_AVAILABLE) { + putExtra(TextToSpeech.Engine.EXTRA_SAMPLE_TEXT, text) + } else { + putExtra("sampleText", text) + } + } + + println("GetSampleText: result=${result}, text=$text") + setResult(result, intent) + finish() + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/IProgressTaskService.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/IProgressTaskService.kt new file mode 100644 index 000000000..6e3a445c0 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/IProgressTaskService.kt @@ -0,0 +1,165 @@ +package com.k2fsa.sherpa.onnx.tts.engine.service + +import android.app.Notification +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.os.IBinder +import android.os.SystemClock +import android.util.Log +import androidx.core.content.ContextCompat +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.ui.MainActivity +import com.k2fsa.sherpa.onnx.tts.engine.utils.NotificationUtils +import com.k2fsa.sherpa.onnx.tts.engine.utils.NotificationUtils.notificationBuilder +import com.k2fsa.sherpa.onnx.tts.engine.utils.ThrottleUtil +import com.k2fsa.sherpa.onnx.tts.engine.utils.pendingIntentFlags +import com.k2fsa.sherpa.onnx.tts.engine.utils.startForegroundCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel + +abstract class IProgressTaskService( + private val chanId: String, private val chanNameStrId: Int +) : + Service() { + companion object { + const val TAG = "ModelManagerService" + + const val ACTION_NOTIFICATION_CANCEL = + "com.k2fsa.sherpa.onnx.tts.engine.service.IProgressTaskService.ACTION_NOTIFICATION_CANCEL" + + const val EXTRA_NOTIFICATION_ID = "notification_id" + } + + override fun onBind(intent: Intent): IBinder? = null + + private var mNotificationId = NotificationUtils.nextNotificationId() + private val mNotificationReceiver by lazy { NotificationReceiver() } + + inner class NotificationReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == ACTION_NOTIFICATION_CANCEL) { + if (mNotificationId == intent.getIntExtra( + EXTRA_NOTIFICATION_ID, + NotificationUtils.UNSPECIFIED_ID + ) + ) { + stopSelf() + } + } + } + } + + override fun onCreate() { + super.onCreate() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationUtils.createChannel( + chanId, + getString(chanNameStrId), + ) + } + + ContextCompat.registerReceiver( + this, + mNotificationReceiver, + IntentFilter(ACTION_NOTIFICATION_CANCEL), + ContextCompat.RECEIVER_EXPORTED + ) + } + + @Suppress("DEPRECATION") + override fun onDestroy() { + mNotificationId = NotificationUtils.UNSPECIFIED_ID + stopForeground(true) + mScope.cancel() + unregisterReceiver(mNotificationReceiver) + + super.onDestroy() + } + + @Suppress("DEPRECATION") + private fun createNotification( + summary: String, + progress: Int, + title: String, + content: String + ): Notification { + Log.d(TAG, "createNotification: $progress, $title, $content") + return notificationBuilder(chanId).apply { + setContentTitle(title) + setContentText(content) + setSmallIcon(R.mipmap.ic_launcher) +// setVisibility(Notification.VISIBILITY_PUBLIC) + style = Notification.BigTextStyle().setSummaryText(summary).setBigContentTitle(title) + .bigText(content) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE) + } + + setProgress(100, progress, progress == -1) + + val cancelPending = PendingIntent.getBroadcast( + /* context = */ this@IProgressTaskService, + /* requestCode = */ 0, + /* intent = */ Intent(ACTION_NOTIFICATION_CANCEL).apply { + putExtra(EXTRA_NOTIFICATION_ID, mNotificationId) + }, + /* flags = */ pendingIntentFlags + ) + addAction( + Notification.Action.Builder( + 0, + getString(android.R.string.cancel), + cancelPending + ).build() + ) + setContentIntent( + PendingIntent.getActivity( + this@IProgressTaskService, + 0, + Intent(this@IProgressTaskService, MainActivity::class.java), + pendingIntentFlags + ) + ) + }.build() + } + + abstract fun onTimedOut() + + protected val mScope = CoroutineScope(Dispatchers.IO) + + private val timeoutThrottle = ThrottleUtil(mScope, time = 1000L * 15) //15s + private fun setTimeout() { + timeoutThrottle.runAction { + onTimedOut() + stopSelf() + } + } + + private var mLastUpdateNotification = 0L + protected fun updateNotification( + summary: String, + progress: Int, + title: String, + content: String + ) { + setTimeout() + if (mNotificationId != NotificationUtils.UNSPECIFIED_ID) { + if (SystemClock.elapsedRealtime() - mLastUpdateNotification < 500) return + + Log.d(TAG, "startForegroundCompat: $progress, $title, $content") + startForegroundCompat( + mNotificationId, + createNotification(summary, progress, title, content) + ) + mLastUpdateNotification = SystemClock.elapsedRealtime() + } + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/InstallVoiceData.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/InstallVoiceData.kt new file mode 100644 index 000000000..a32516227 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/InstallVoiceData.kt @@ -0,0 +1,12 @@ +package com.k2fsa.sherpa.onnx.tts.engine.service + +import android.app.Activity +import android.os.Bundle +import android.view.Window + +class InstallVoiceData : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + requestWindowFeature(Window.FEATURE_NO_TITLE) + super.onCreate(savedInstanceState) + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/ModelExportService.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/ModelExportService.kt new file mode 100644 index 000000000..09fb6c85e --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/ModelExportService.kt @@ -0,0 +1,87 @@ +package com.k2fsa.sherpa.onnx.tts.engine.service + +import android.content.Intent +import android.util.Log +import com.k2fsa.sherpa.onnx.tts.engine.NotificationConst +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.ModelPackageManager +import com.k2fsa.sherpa.onnx.tts.engine.utils.NotificationUtils.sendNotification +import com.k2fsa.sherpa.onnx.tts.engine.utils.grantReadWritePermission +import kotlinx.coroutines.launch +import java.io.OutputStream + +class ModelExportService : IProgressTaskService("model_export", R.string.export) { + companion object { + const val TAG = "ModelExportService" + + const val EXTRA_MODELS = "models" + const val EXTRA_TYPE = "type" + } + + override fun onTimedOut() { + sendNotification( + channelId = NotificationConst.MODEL_EXPORT_CHANNEL, + title = getString(R.string.export_failed), + content = getString(R.string.timed_out) + ) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + fun error(msg: String): Int { + Log.e(TAG, msg) + stopSelf() + return super.onStartCommand(intent, flags, startId) + } + + val uri = intent?.data ?: run { + return error("No uri to export") + } + + val models = intent.getStringArrayExtra(EXTRA_MODELS) ?: run { + return error("No models to export") + } + + val type = intent.getStringExtra(EXTRA_TYPE) ?: run { + return error("No type to export") + } + + runCatching { + uri.grantReadWritePermission(contentResolver) + }.onFailure { + return error("Failed to grant uri permission: $uri") + } + + updateNotification(getString(R.string.export), 0, "", models.joinToString()) + + val out = contentResolver.openOutputStream(uri) ?: run { + return error("Failed to open output stream") + } + mScope.launch { + out.use { + execute(type, it, models.toList()) + + sendNotification( + channelId = NotificationConst.MODEL_EXPORT_CHANNEL, + title = getString(R.string.export_finished), + content = models.joinToString() + ) + stopSelf() + } + } + + + return super.onStartCommand(intent, flags, startId) + } + + private suspend fun execute(type: String, ous: OutputStream, models: List) { + ModelPackageManager.exportModelsToZip( + models = models, + type = type, + ous = ous, + onZipProgress = { name, entrySize, bytes -> + val progress = (bytes * 100 / entrySize).toInt() + updateNotification(getString(R.string.zipping), progress, name, "") + } + ) + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/ModelManagerService.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/ModelManagerService.kt new file mode 100644 index 000000000..c45a38429 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/ModelManagerService.kt @@ -0,0 +1,153 @@ +package com.k2fsa.sherpa.onnx.tts.engine.service + +import android.content.Intent +import android.util.Log +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import com.drake.net.component.Progress +import com.k2fsa.sherpa.onnx.tts.engine.NotificationConst +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.conf.AppConfig +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.ModelPackageManager +import com.k2fsa.sherpa.onnx.tts.engine.utils.NotificationUtils.sendNotification +import com.k2fsa.sherpa.onnx.tts.engine.utils.fileName +import com.k2fsa.sherpa.onnx.tts.engine.utils.formatFileSize +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch + +class ModelManagerService : + IProgressTaskService( + NotificationConst.MODEL_PACKAGE_INSTALLER_CHANNEL, + R.string.model_package_installer + ) { + companion object { + const val TAG = "ModelManagerService" + + const val EXTRA_FILE_NAME = "file_name" + } + + // [1MB / 25MB] 100 KB/s + private fun Progress.toNotificationContent(): String = + "[${currentSize()} / ${totalSize()}] \t ${speedSize()}/s" + + override fun onTimedOut() { + sendNotification( + channelId = NotificationConst.MODEL_PACKAGE_INSTALLER_CHANNEL, + title = getString(R.string.model_install_failed), + content = getString(R.string.timed_out) + ) + } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + val mUri = intent.data?.toString() ?: run { + Log.e(TAG, "onStartCommand: uri is null") + stopSelf() + return super.onStartCommand(intent, flags, startId) + } + val mFileName = intent.getStringExtra(EXTRA_FILE_NAME) ?: kotlin.run { + Log.d(TAG, "onStartCommand: fileName is null, from local file install") + "" + } + Log.i(TAG, "onStartCommand: uri=$mUri, fileName=$mFileName") + + updateNotification( + "", + 0, + getString(if (mFileName.isEmpty()) R.string.model_package_installer else R.string.downloading), + mFileName + ) + + mScope.launch { + runCatching { + execute(mUri, mFileName) + }.onFailure { + if (it is CancellationException) return@onFailure + + Log.e(TAG, "onStartCommand: execute failed", it) + sendNotification( + channelId = NotificationConst.MODEL_PACKAGE_INSTALLER_CHANNEL, + title = getString(R.string.model_install_failed), + content = it.message ?: getString(R.string.error) + ) + } + stopSelf() + } + + return super.onStartCommand(intent, flags, startId) + } + + private suspend fun execute(uriString: String, fileName: String) { + Log.d(TAG, "execute: $uriString, $fileName") + + fun updateUnzipProgress(file: String, total: Long, current: Long) { + val name = file.substringAfterLast('/') + val str = + "${current.formatFileSize(this)} / ${total.formatFileSize(this)}" + updateNotification( + getString(R.string.unzipping), + progress = ((current / total.toDouble()) * 100).toInt(), + title = name, + content = str, + ) + } + + fun updateStartMoveFiles() { + updateNotification( + "", + progress = -1, + title = getString(R.string.moving_files), + content = "..." + ) + } + + val ok = if (fileName.isBlank()) { + val uri = uriString.toUri() + val file = DocumentFile.fromSingleUri(this, uri) + val name = file?.name ?: throw IllegalArgumentException("file is null: uri=${uri}") + val type = name.substringAfter(".") + + val ins = contentResolver.openInputStream(uri) + ?: throw IllegalArgumentException("openInputStream return null: uri=${uri}") + Log.d(TAG, "execute: type=$type") + ModelPackageManager.installPackage( + type, + ins, + onUnzipProgress = ::updateUnzipProgress, + onStartMoveFiles = ::updateStartMoveFiles + ) + } else { + val url = if (AppConfig.ghProxyUrl.value.isEmpty()) + uriString + else + "${AppConfig.ghProxyUrl.value.removeSuffix("/")}/$uriString" + ModelPackageManager.installPackageFromUrl( + url = url, + fileName = fileName, + onDownloadProgress = { + updateNotification( + summary = getString(R.string.downloading), + progress = it.progress(), + title = fileName, + content = it.toNotificationContent() + ) + }, + onUnzipProgress = ::updateUnzipProgress, + onStartMoveFiles = ::updateStartMoveFiles + ) + } + + sendNotification( + channelId = NotificationConst.MODEL_PACKAGE_INSTALLER_CHANNEL, + title = getString(if (ok) R.string.model_installed else R.string.model_install_failed), + content = fileName.ifBlank { + try { + uriString.toUri().fileName(this) + } catch (e: Exception) { + Log.e(TAG, "execute: unable to get file name from uri=$uriString", e) + "" + } + } + ) + } + +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/TtsService.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/TtsService.kt new file mode 100644 index 000000000..9488d4184 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/service/TtsService.kt @@ -0,0 +1,265 @@ +package com.k2fsa.sherpa.onnx.tts.engine.service + +import android.media.AudioFormat +import android.speech.tts.SynthesisCallback +import android.speech.tts.SynthesisRequest +import android.speech.tts.TextToSpeech +import android.speech.tts.TextToSpeechService +import android.speech.tts.Voice +import android.util.Log +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.conf.TtsConfig +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.ConfigModelManager +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.ConfigModelManager.toOfflineTtsConfig +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.ConfigVoiceManager +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.SynthesizerManager +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config.Model +import com.k2fsa.sherpa.onnx.tts.engine.ui.TAG +import com.k2fsa.sherpa.onnx.tts.engine.utils.longToast +import com.k2fsa.sherpa.onnx.tts.engine.utils.newLocaleFromCode +import com.k2fsa.sherpa.onnx.tts.engine.utils.toByteArray +import java.util.Locale +import kotlin.math.min + +/* +https://developer.android.com/reference/java/util/Locale#getISO3Language() +https://developer.android.com/reference/java/util/Locale#getISO3Country() + +eng, USA, +eng, USA, POSIX +eng, +eng, GBR +afr, +afr, NAM +afr, ZAF +agq +agq, CMR +aka, +aka, GHA +amh, +amh, ETH +ara, +ara, 001 +ara, ARE +ara, BHR, +deu +deu, AUT +deu, BEL +deu, CHE +deu, ITA +deu, ITA +deu, LIE +deu, LUX +spa, +spa, 419 +spa, ARG, +spa, BRA +fra, +fra, BEL, +fra, FRA, + +E Failed to check TTS data, no activity found for Intent +{ act=android.speech.tts.engine.CHECK_TTS_DATA pkg=com.k2fsa.sherpa.chapter5 }) + +E Failed to get default language from engine com.k2fsa.sherpa.chapter5 +Engine failed voice data integrity check (null return)com.k2fsa.sherpa.chapter5 +Failed to get default language from engine com.k2fsa.sherpa.chapter5 + +*/ + +class TtsService : TextToSpeechService() { + companion object { + const val NOT_SET_VOICE_NAME = "NOT_SET_VOICE" + } + + private var languages: List = emptyList() + override fun onCreate() { + Log.i(TAG, "onCreate tts service") + + ConfigModelManager.load() + languages = ConfigModelManager.languages().map { newLocaleFromCode(it) } + + super.onCreate() + } + + override fun onDestroy() { + Log.i(TAG, "onDestroy tts service") + super.onDestroy() + } + + // https://developer.android.com/reference/kotlin/android/speech/tts/TextToSpeechService#onislanguageavailable + override fun onIsLanguageAvailable(_lang: String?, _country: String?, _variant: String?): Int { + Log.d(TAG, "onIsLanguageAvailable: $_lang, $_country $_variant") + val lang = _lang ?: "" + val country = _country ?: "" + + languages.forEach { + val l = it.isO3Language + val c = it.isO3Country + + if (l == lang && c == country) { + return TextToSpeech.LANG_COUNTRY_AVAILABLE + } else if (l == lang) { + return TextToSpeech.LANG_AVAILABLE + } + } + + return TextToSpeech.LANG_NOT_SUPPORTED + } + + // https://developer.android.com/reference/kotlin/android/speech/tts/TextToSpeechService#onLoadLanguage(kotlin.String,%20kotlin.String,%20kotlin.String) + override fun onLoadLanguage(_lang: String?, _country: String?, _variant: String?): Int { + return onIsLanguageAvailable(_lang, _country, _variant) + } + + override fun onGetLanguage(): Array { + return arrayOf("", "", "") + } + + override fun onLoadVoice(voiceName: String?): Int { + Log.i(TAG, "onLoadVoice: $voiceName") + return onIsValidVoiceName(voiceName) + } + + override fun onGetVoices(): MutableList { + val list = mutableListOf() + list.add( + Voice( + NOT_SET_VOICE_NAME, Locale("zh", "CN"), Voice.QUALITY_NORMAL, + /* latency = */ Voice.LATENCY_NORMAL, + /* requiresNetworkConnection = */ false, + /* features = */ setOf() + ) + ) + list.add( + Voice( + NOT_SET_VOICE_NAME, Locale("en", "US"), Voice.QUALITY_NORMAL, + /* latency = */ Voice.LATENCY_NORMAL, + /* requiresNetworkConnection = */ false, + /* features = */ setOf() + ) + ) + ConfigVoiceManager.speakers().forEach { + val model = ConfigModelManager.models().find { m -> m.id == it.model } ?: return@forEach + list.add( + Voice( + /* name = */ model.id + "_" + it.id, + /* locale = */ newLocaleFromCode(model.lang), + /* quality = */ Voice.QUALITY_NORMAL, + /* latency = */ Voice.LATENCY_NORMAL, + /* requiresNetworkConnection = */ false, + /* features = */ setOf(it.name) + ) + ) + } + + Log.i(TAG, "onGetVoices: ${list.size}") + return list + } + + override fun onIsValidVoiceName(voiceName: String?): Int { + Log.i(TAG, "onIsValidVoiceName: $voiceName") + if (voiceName.isNullOrBlank()) + return TextToSpeech.ERROR + + return if (voiceName == NOT_SET_VOICE_NAME || + com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config.Voice.from(voiceName).run { + ConfigVoiceManager.speakers().any { it.id == id } && ConfigModelManager.models() + .any { it.id == model } + } + ) { + TextToSpeech.SUCCESS + } else { + TextToSpeech.ERROR + } + } + + override fun onGetDefaultVoiceNameFor( + lang: String?, + country: String?, + variant: String? + ): String { + Log.i(TAG, "onGetDefaultVoiceNameFor: $lang, $country, $variant") + return NOT_SET_VOICE_NAME + } + + override fun onStop() {} + + private fun getTtsModel(voiceName: String?): Model? { + Log.d(TAG, "getTtsModel: $voiceName") + return ConfigModelManager.models() + .run { if (voiceName == null || voiceName == NOT_SET_VOICE_NAME) null else this } + ?.find { it.id == voiceName } + ?: ConfigModelManager.models().find { it.id == TtsConfig.voice.value.model } + ?: ConfigModelManager.models().getOrNull(0) + } + + override fun onSynthesizeText(request: SynthesisRequest?, callback: SynthesisCallback?) { + if (request == null || callback == null) { + return + } + val language = request.language + val country = request.country + val variant = request.variant + val text = request.charSequenceText.toString() + val voiceName: String? = request.voiceName + + val ret = onIsLanguageAvailable(language, country, variant) + if (ret == TextToSpeech.LANG_NOT_SUPPORTED) { + callback.error() + return + } + Log.i(TAG, "text: $text") + + val voice = + if (voiceName.isNullOrBlank() || voiceName == NOT_SET_VOICE_NAME) TtsConfig.voice.value else + com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config.Voice.from(voiceName) + val ttsModel = ConfigModelManager.models().find { it.id == voice.model } + Log.i(TAG, "ttsConfig: $ttsModel") + if (ttsModel == null) { + Log.e(TAG, "tts not found") + longToast(R.string.tts_config_not_set) + callback.error() + return + } + + val tts = SynthesizerManager.getTTS(ttsModel.toOfflineTtsConfig()) + + // Note that AudioFormat.ENCODING_PCM_FLOAT requires API level >= 24 + // callback.start(tts.sampleRate(), AudioFormat.ENCODING_PCM_FLOAT, 1) + + callback.start(tts.sampleRate(), AudioFormat.ENCODING_PCM_16BIT, 1) + + if (text.isBlank() || text.isEmpty()) { + callback.done() + return + } + + val ttsCallback = { floatSamples: FloatArray -> + // convert FloatArray to ByteArray + val samples = floatSamples.toByteArray() + val maxBufferSize: Int = callback.maxBufferSize + var offset = 0 + while (offset < samples.size) { + val bytesToWrite = min(maxBufferSize, samples.size - offset) + callback.audioAvailable(samples, offset, bytesToWrite) + offset += bytesToWrite + } + + } + + val speed = request.speechRate / 100f + Log.i( + TAG, + "voice: ${voice}, text: $text, speechRate: ${request.speechRate} (speed: ${speed})" + ) + tts.generateWithCallback( + text = text, + sid = voice.id, + speed = speed, + callback = ttsCallback, + ) + + callback.done() + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/ConfigModelManager.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/ConfigModelManager.kt new file mode 100644 index 000000000..90f2fa1ae --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/ConfigModelManager.kt @@ -0,0 +1,149 @@ +package com.k2fsa.sherpa.onnx.tts.engine.synthesizer + +import android.content.Context +import android.util.Log +import com.k2fsa.sherpa.onnx.OfflineTtsConfig +import com.k2fsa.sherpa.onnx.OfflineTtsModelConfig +import com.k2fsa.sherpa.onnx.OfflineTtsVitsModelConfig +import com.k2fsa.sherpa.onnx.tts.engine.FileConst +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config.ConfigManager +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config.Model +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.File + +object ConfigModelManager { + const val TAG = "ModelManager" + + private val models = mutableListOf() + + fun models(): List = models + + private val _modelsFlow by lazy { MutableStateFlow>(emptyList()) } + val modelsFlow: StateFlow> + get() = _modelsFlow.asStateFlow() + + + private fun notifyModelsChange() { + Log.d(TAG, "notifyModelsChange: ${models.size}") + _modelsFlow.value = models.toList() + } + + @Synchronized + fun load() { + models.addAll(ConfigManager.config.models) + instinctModels() + notifyModelsChange() + } + + fun languages(): List { + return models.distinctBy { it.lang }.map { it.lang } + } + + fun defaultLanguage(): String { + return models.firstOrNull()?.lang ?: "en" + } + + // 去重models + @Synchronized + private fun instinctModels() { + val list = models.distinctBy { it.id } + Log.d(TAG, "deduplicateModels: ${list.size}") + models.clear() + models.addAll(list) + ConfigManager.updateConfig(ConfigManager.config.copy(models = models)) + } + + @Synchronized + fun removeModel(vararg model: Model) { + models.removeAll(model.toSet()) + ConfigManager.updateConfig(ConfigManager.config.copy(models = models)) + notifyModelsChange() + } + + @Synchronized + fun addModel(vararg model: Model) { + model.forEach { m -> + if (!models.any { it.id == m.id }) + models.add(m) + } + + ConfigManager.updateConfig(ConfigManager.config.copy(models = models)) + notifyModelsChange() + } + + fun updateModels(model: Model) { + models.indexOfFirst { it.id == model.id }.takeIf { it != -1 }?.let { + models[it] = model.copy() + ConfigManager.updateConfig(ConfigManager.config.copy(models = models)) + notifyModelsChange() + } + } + + @Synchronized + fun updateModels(model: List) { + models.clear() + models.addAll(model) + ConfigManager.updateConfig(ConfigManager.config.copy(models = models)) + notifyModelsChange() + } + + fun getNotAddedModels(context: Context): List { + val addedIds = models.map { it.id } + return analyzeToModels().filter { it.id !in addedIds } + } + + fun analyzeToModel(dir: File): Model? { + Log.d(TAG, "load model: ${dir.name}") + val onnx = dir.listFiles { _, name -> name.endsWith(".onnx") } + ?.run { if (isNotEmpty()) first() else null } + ?: return null + + val dataDir = dir.resolve("espeak-ng-data").takeIf { it.exists() } + + return Model( + id = dir.name, + onnx = dir.name + "/" + onnx.name, + lexicon = if (dataDir == null) "${dir.name}/lexicon.txt" else "", + ruleFsts = if (dataDir == null) "${dir.name}/rule.fst" else "", + tokens = "${dir.name}/tokens.txt", + dataDir = dataDir?.run { "${dir.name}/espeak-ng-data" } ?: "", + lang = "en-US", + ) + } + + // 根据文件目录结构获取模型列表 + fun analyzeToModels(): List { + Log.d(TAG, "modelPath: ${FileConst.modelDir}") + val list = mutableListOf() + File(FileConst.modelDir).listFiles()!!.forEach { dir -> + if (dir.isDirectory) + analyzeToModel(dir)?.let { + list.add(it) + } + } + + return list + } + + fun Model.toOfflineTtsConfig(root: String = FileConst.modelDir): OfflineTtsConfig { + fun format(str: String): String { + return if (str.isBlank()) "" else "$root/$str" + } + + return OfflineTtsConfig( + model = OfflineTtsModelConfig( + vits = OfflineTtsVitsModelConfig( + model = format(onnx), + lexicon = format(lexicon), + tokens = format(tokens), + dataDir = format(dataDir), + ), + debug = true, + provider = "cpu", + ), + ruleFsts = format(ruleFsts), + ) + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/ConfigVoiceManager.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/ConfigVoiceManager.kt new file mode 100644 index 000000000..c82926ef3 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/ConfigVoiceManager.kt @@ -0,0 +1,110 @@ +package com.k2fsa.sherpa.onnx.tts.engine.synthesizer + +import android.util.Log +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config.ConfigManager +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config.Voice +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.Collections + +object ConfigVoiceManager : MutableCollection { + private var speakers = mutableListOf() + fun speakers(): List = speakers + + private val _flow by lazy { MutableStateFlow>(emptyList()) } + val flow: StateFlow> + get() = _flow.asStateFlow() + + private fun notifySpeakersChange() { + Log.d(ConfigModelManager.TAG, "notifySpeakersChange: ${speakers.size}") + ConfigManager.updateConfig(config = ConfigManager.config.copy(voices = speakers)) + _flow.value = speakers.toList() + } + + @Synchronized + fun load() { + speakers = ConfigManager.config.voices.toMutableList() + distinct() + notifySpeakersChange() + } + + fun move(from: Int, to: Int) { + Collections.swap(speakers, from, to) + notifySpeakersChange() + } + + fun update(speaker: Voice) { + val index = speakers.indexOfFirst { it.id == speaker.id } + if (index != -1) { + speakers[index] = speaker + notifySpeakersChange() + } + } + + fun reset(list: List) { + speakers.clear() + speakers.addAll(list) + notifySpeakersChange() + } + + fun distinct() { + speakers = speakers.distinctBy { it.toString() }.toMutableList() + notifySpeakersChange() + } + + override fun add(element: Voice): Boolean { + speakers.add(element) + notifySpeakersChange() + return true + } + + override fun contains(element: Voice): Boolean { + return speakers.contains(element) + } + + override val size: Int + get() = speakers.size + + override fun clear() { + speakers.clear() + notifySpeakersChange() + } + + override fun addAll(elements: Collection): Boolean { + speakers.addAll(elements) + notifySpeakersChange() + return true + } + + override fun isEmpty(): Boolean { + return speakers.isEmpty() + } + + override fun iterator(): MutableIterator { + return speakers.iterator() + } + + override fun retainAll(elements: Collection): Boolean { + return speakers.retainAll(elements).apply { + notifySpeakersChange() + } + } + + override fun removeAll(elements: Collection): Boolean { + return speakers.removeAll(elements).apply { + notifySpeakersChange() + } + } + + override fun remove(element: Voice): Boolean { + return speakers.remove(element).apply { + notifySpeakersChange() + } + } + + override fun containsAll(elements: Collection): Boolean { + return speakers.containsAll(elements) + } + +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/ModelPackageManager.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/ModelPackageManager.kt new file mode 100644 index 000000000..e85b23830 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/ModelPackageManager.kt @@ -0,0 +1,199 @@ +package com.k2fsa.sherpa.onnx.tts.engine.synthesizer + +import com.drake.net.Get +import com.drake.net.component.Progress +import com.drake.net.exception.ResponseException +import com.drake.net.interfaces.ProgressListener +import com.k2fsa.sherpa.onnx.tts.engine.AppConst +import com.k2fsa.sherpa.onnx.tts.engine.FileConst +import com.k2fsa.sherpa.onnx.tts.engine.GithubRelease +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config.Model +import com.k2fsa.sherpa.onnx.tts.engine.utils.CompressUtils +import com.k2fsa.sherpa.onnx.tts.engine.utils.CompressorFactory +import kotlinx.coroutines.coroutineScope +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.decodeFromStream +import okhttp3.Response +import org.apache.commons.io.FileUtils +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.util.UUID + +object ModelPackageManager { + val supportedTypes = listOf( + "tar.bz2", + "tgz", + "tar.gz", + "txz", + "tar.xz", + "zip" + ) + + init { + runCatching { + clearCache() + } + } + + private fun clearCache() { + FileUtils.deleteDirectory(File(FileConst.cacheModelDir)) + FileUtils.deleteDirectory(File(FileConst.cacheDownloadDir)) + } + + @OptIn(ExperimentalSerializationApi::class) + suspend fun getTtsModels(): List = coroutineScope { + val resp = Get("").await() + + val body = resp.body + return@coroutineScope if (resp.isSuccessful && body != null) + AppConst.jsonBuilder.decodeFromStream(body.byteStream()) + else { + throw ResponseException(resp, "Failed to get tts models, ${resp.code} ${resp.message}") + } + } + + /** + * Delete directory: [FileConst.modelDir]/[modelId] + */ + fun deleteModel(modelId: String): Boolean { + val dir = File(FileConst.modelDir + File.separator + modelId) + try { + FileUtils.deleteDirectory(dir) + } catch (_: Exception) { + return false + } + + return true + } + + /** + * Unzip package to [FileConst.cacheModelDir]/[subDir]` + * + * [subDir] default to UUID + */ + suspend fun extractModelPackage( + type: String, + ins: InputStream, + progressListener: CompressUtils.ProgressListener, + subDir: String = UUID.randomUUID().toString(), + ): String { + val target = FileConst.cacheModelDir + File.separator + subDir + + val compressor = CompressorFactory.createCompressor(type) ?: throw IllegalArgumentException( + "Unsupported type: $type" + ) + compressor.uncompress(ins, target, progressListener) + + return target + } + + /** + * Install model package from local directory + * + * [source] example: [FileConst.cacheModelDir]/$uuid` + */ + fun installModelPackageFromDir(source: File): Boolean { + val dirs = source.listFiles { file, _ -> + file.isDirectory + } ?: return false + + val models = dirs.mapNotNull { + ConfigModelManager.analyzeToModel(it) + } + + val ok = models.isNotEmpty() + if (ok) { + FileUtils.copyDirectory(source, File(FileConst.modelDir)) + ConfigModelManager.addModel(*models.toTypedArray()) + } + + return ok + } + + /** + * Auto extract and install model package from input stream + * + * [ins] *.tar.bz2 input stream + */ + suspend fun installPackage( + type: String, + ins: InputStream, + onUnzipProgress: (file: String, total: Long, current: Long) -> Unit, + onStartMoveFiles: () -> Unit + ): Boolean { + val target = extractModelPackage(type, ins, progressListener = { name, entrySize, bytes -> + onUnzipProgress(name, entrySize, bytes) + }) + + onStartMoveFiles() + return installModelPackageFromDir(File(target)) + } + + /** + * Download model package from url and install + */ + suspend fun installPackageFromUrl( + url: String, + fileName: String, + onDownloadProgress: (Progress) -> Unit, + onUnzipProgress: (file: String, total: Long, current: Long) -> Unit, + onStartMoveFiles: () -> Unit + ): Boolean { + val file = downloadModelPackage(url, fileName) { + onDownloadProgress(it) + } + + val type = fileName.substringAfter(".") + + return file.inputStream().use { + installPackage(type, it, onUnzipProgress, onStartMoveFiles) + } + } + + private suspend fun downloadModelPackage( + url: String, + fileName: String, + onProgress: (Progress) -> Unit + ): File = coroutineScope { + val downloadDir = File(FileConst.cacheDownloadDir) + downloadDir.mkdirs() + val file = Get(url) { + if (fileName.isNotBlank()) + setDownloadFileName(fileName) + setDownloadDir(downloadDir) + addDownloadListener(object : ProgressListener() { + override fun onProgress(p: Progress) { + onProgress(p) + } + }) + }.await() + + return@coroutineScope file + } + + + suspend fun exportModelsToZip( + models: List, + type: String, + ous: OutputStream, + onZipProgress: CompressUtils.ProgressListener + ): File { + val cacheDir = File(FileConst.cacheModelDir + File.separator + UUID.randomUUID().toString()) + cacheDir.mkdirs() + + models.forEach { + val modelDir = File(FileConst.modelDir + File.separator + it) + val target = File(cacheDir, it) + FileUtils.copyDirectory(modelDir, target) + } + + val compressor = CompressorFactory.createCompressor(type) ?: throw IllegalArgumentException( + "Unsupported type: $type" + ) + + compressor.compress(cacheDir.absolutePath, ous, onZipProgress) + + return File("") + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/SampleTextManager.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/SampleTextManager.kt new file mode 100644 index 000000000..6d23632f9 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/SampleTextManager.kt @@ -0,0 +1,61 @@ +package com.k2fsa.sherpa.onnx.tts.engine.synthesizer + +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config.SampleTextConfig +import kotlin.random.Random + +object SampleTextManager { + val defaultTextMap by lazy { + hashMapOf( + "zh-CN" to "使用新一代卡尔迪的语音合成引擎", + "en-US" to "Use the new generation of Cardi's speech synthesis engine", + "ja-JP" to "新しい世代のカーディの音声合成エンジンを使用する", + "ko-KR" to "새로운 세대의 카디 음성 합성 엔진 사용", + "fr-FR" to "Utilisez le nouveau moteur de synthèse vocale de Cardi", + "de-DE" to "Verwenden Sie den neuen Cardi-Sprachsynthese-Engine", + "es-ES" to "Utilice el nuevo motor de síntesis de voz de Cardi", + "it-IT" to "Utilizza il nuovo motore di sintesi vocale di Cardi", + "pt-BR" to "Use o novo motor de síntese de voz da Cardi", + "ru-RU" to "Используйте новый двигатель синтеза речи Cardi", + "ar-SA" to "استخدم محرك تركيب الصوت الجديد من Cardi", + "tr-TR" to "Yeni nesil Cardi'nin konuşma sentezi motorunu kullanın", + "vi-VN" to "Sử dụng động cơ tổng hợp giọng nói thế hệ mới của Cardi", + "th-TH" to "ใช้เครื่องยนต์การสังเคราะห์เสียงรุ่นใหม่ของ Cardi", + "id-ID" to "Gunakan mesin sintesis suara generasi baru Cardi", + "ms-MY" to "Gunakan enjin sintesis suara generasi baru Cardi", + "hi-IN" to "नए पीढ़ी के कार्डी की ध्वनि संश्लेषण इंजन का उपयोग करें", + "bn-IN" to "নতুন প্রজন্মের কার্ডির বক্তব্য সিন্থেসিস ইঞ্জিন ব্যবহার করুন", + "pa-IN" to "ਨਵੇਂ ਪੀੜੀ ਦੇ ਕਾਰਡੀ ਦੀ ਬੋਲੀ ਸੰਘਣਨ ਇੰਜਨ ਦੀ ਵਰਤੋਂ ਕਰੋ", + "ta-IN" to "புதிய தலைப்பு கார்டியின் பேச்சு செயலி இயக்கத்தை பயன்படுத்தவும்", + "te-IN" to "కార్డి యొక్క కూడా పీఠిక వాక్య సంయోజన ఇంజన్ ను ఉపయోగించండి", + "ml-IN" to "കാർഡിയുടെ പുതിയ തലത്തിന്റെ സ്പീച് സിൻഥസിസ് ഇഞ്ചിൻ ഉപയോഗിക്കുക", + "kn-IN" to "ಕಾರ್ಡಿಯ ಹೊಸ ತಲೆಯ ಸ್ಪೀಚ್ ಸಿಂಥಿಸೈಸ್ ಇಂಜಿನ್ ಅನ್ನು ಬಳಸಿ", + "gu-IN" to "કાર્ડીની નવી પીઢીનો વાણી સંશ્લેષણ ઇંજન વાપરો", + "mr-IN" to "कार्डीच्या नव्या पीढीचा ध्वनी संश्लेषण इंजिन वापरा", + "or-IN" to "କାର୍ଡିର ନୂଆ ପୀଢ଼ୀର ବାକ୍ୟ ସଂଶ୍ଲେଷଣ ଇଞ୍ଜିନ ବ୍ୟବହାର କରନ୍ତୁ", + "as-IN" to "কাৰ্ডীৰ নতুন পুংজীয়াৰ বাক্য সংশ্লেষণ ইঞ্জিন ব্যৱহাৰ কৰক", + "ne-NP" to "कार्डीको नयाँ पीढीको वाणी संश्लेषण इन्जिन प्रयोग गर्नुहोस्", + "si-LK" to "කාඩියේ නව පූර්ව වක්‍ර සංදර්ශක එන්ජින් භාවිතා කරන්න", + "my-MM" to "ကာဒီ သည် အသစ်တစ်ခုအတွက် အသုံးပြုသည်", + "km-KH" to "ប្រើប្រាស់ម៉ូតូសមុខរបស់កាតីក្នុងការបង្កើតសមុខរបស់ខ្លួន", + "lo-LA" to "ໃຊ້ເຄື່ອງດຽວຂອງກາດອີໂອເຊຍໃໝ່", + ) + + } + val defaultText: String + get() = defaultTextMap["en-US"] ?: "" + + + @Suppress("IfThenToElvis") + fun getSampleText(code: String): String { + val lang = code.lowercase() + val texts = SampleTextConfig.config.toList() + val list = texts.find { it.first.lowercase() == lang }?.second + ?: texts.find { it.first.lowercase() == lang.substringBefore("-") }?.second + return if (list == null) { + defaultTextMap.toList().find { it.first.lowercase() == lang }?.second ?: defaultText + } else { + list.random(Random(System.currentTimeMillis())) + } + + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/SynthesizerCache.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/SynthesizerCache.kt new file mode 100644 index 000000000..8116761c7 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/SynthesizerCache.kt @@ -0,0 +1,120 @@ +package com.k2fsa.sherpa.onnx.tts.engine.synthesizer + +import android.util.Log +import com.k2fsa.sherpa.onnx.tts.engine.conf.TtsConfig +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.DelayQueue +import java.util.concurrent.Delayed +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.math.max +import kotlin.math.min + + +interface ImplCache { + fun destroy() + fun canDestroy(): Boolean +} + + +internal class SynthesizerCache { + companion object { + const val TAG = "SynthesizerCache" + + private val delayTime: Int + get() = 1000 * 60 * min(1, TtsConfig.timeoutDestruction.value) + + private val maxCacheSize: Int + get() = max(1, TtsConfig.cacheSize.value) + } + + private val delayQueue = DelayQueue() + private val queueMap = ConcurrentHashMap() + private val executor = Executors.newSingleThreadExecutor() + + private var isTaskRunning = false + private fun ensureTaskRunning() { + if (isTaskRunning) return + + synchronized(delayQueue) { + isTaskRunning = true + executor.execute { + while (true) { + if (delayQueue.isEmpty()) { + break + } else { + val task = delayQueue.take() + if (task.obj.canDestroy()) { + Log.d(TAG, "ensureTaskRunning: ${task.id} is destroyable.") + task.obj.destroy() + queueMap.remove(task.id) + } else { + Log.d(TAG, "ensureTaskRunning: ${task.id} is running, not destroyable.") + delayQueue.add(task.apply { reset() }) + } + } + } + isTaskRunning = false + } + } + } + + private fun limitSize() { + if (queueMap.size > maxCacheSize) { + val oldestEntry = + queueMap.entries.minByOrNull { it.value.getDelay(TimeUnit.MILLISECONDS) } + oldestEntry?.let { + if (it.value.obj.canDestroy()) { + it.value.obj.destroy() + queueMap.remove(it.key) + delayQueue.remove(it.value) + } + } + } + } + + @Synchronized + fun cache(id: String, obj: ImplCache) { + limitSize() + + val task = DelayedDestroyTask(delayTime = delayTime, id, obj) + delayQueue.add(task) + queueMap[id] = task + ensureTaskRunning() + } + + @Synchronized + fun getById(id: String): ImplCache? { + limitSize() + + queueMap[id]?.let { + if (it.getDelay(TimeUnit.MILLISECONDS) <= 1000 * 10) { // 小于10s便重置 + it.reset() + } + return it.obj + } + return null + } + + class DelayedDestroyTask(private val delayTime: Int, val id: String, val obj: ImplCache) : + Delayed { + private var expireTime: Long = 0L + + init { + reset() + } + + fun reset() { + expireTime = System.currentTimeMillis() + delayTime + } + + override fun compareTo(other: Delayed?): Int = + getDelay(TimeUnit.MILLISECONDS).compareTo( + (other as DelayedDestroyTask).getDelay(TimeUnit.MILLISECONDS) + ) + + + override fun getDelay(unit: TimeUnit?): Long = + unit?.convert(expireTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS) ?: 0 + } +} diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/SynthesizerManager.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/SynthesizerManager.kt new file mode 100644 index 000000000..1ae478931 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/SynthesizerManager.kt @@ -0,0 +1,42 @@ +package com.k2fsa.sherpa.onnx.tts.engine.synthesizer + +import android.util.Log +import com.k2fsa.sherpa.onnx.OfflineTts +import com.k2fsa.sherpa.onnx.OfflineTtsConfig +import com.k2fsa.sherpa.onnx.tts.engine.conf.TtsConfig +import kotlin.math.max + +object SynthesizerManager { + const val TAG = "SynthesizerManager" + + private val cacheManager = SynthesizerCache() + + fun getTTS(cfg: OfflineTtsConfig): OfflineTts { + val tts = (cacheManager.getById(cfg.model.vits.model) as Synthesizer?)?.tts + tts?.let { + Log.d(TAG, "getTTS (from cache): ${it.config}") + } + + val model = cfg.model.copy( + numThreads = max(1, TtsConfig.threadNum.value) + ) + + return tts ?: OfflineTts(config = cfg.copy(model = model)).run { + cacheTTS(cfg.model.vits.model, this) + this + } + } + + private fun cacheTTS(id: String, tts: OfflineTts) { + Log.d(TAG, "cacheTTS: ${tts.config}") + cacheManager.cache(id, Synthesizer(tts)) + } + + private class Synthesizer(val tts: OfflineTts) : ImplCache { + override fun destroy() { + tts.free() + } + + override fun canDestroy(): Boolean = !tts.isRunning + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/Config.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/Config.kt new file mode 100644 index 000000000..fe483efc4 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/Config.kt @@ -0,0 +1,9 @@ +package com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config + +import kotlinx.serialization.Serializable + +@Serializable +data class Config( + val models: List = emptyList(), + val voices: List = emptyList() +) \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/ConfigManager.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/ConfigManager.kt new file mode 100644 index 000000000..b90aaf1d8 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/ConfigManager.kt @@ -0,0 +1,20 @@ +package com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config + +import com.charleskorn.kaml.decodeFromStream +import com.k2fsa.sherpa.onnx.tts.engine.AppConst +import com.k2fsa.sherpa.onnx.tts.engine.FileConst.configPath +import kotlinx.serialization.encodeToString +import java.io.InputStream + +object ConfigManager : ImplYamlConfig(configPath, { Config() }) { + const val TAG = "ConfigManager" + + override fun encode(o: Config): String { + return AppConst.yaml.encodeToString(o) + } + + override fun decode(ins: InputStream): Config { + return AppConst.yaml.decodeFromStream(ins) + } + +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/ImplYamlConfig.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/ImplYamlConfig.kt new file mode 100644 index 000000000..0a6d3905e --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/ImplYamlConfig.kt @@ -0,0 +1,68 @@ +package com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config + +import android.util.Log +import com.k2fsa.sherpa.onnx.tts.engine.app +import com.k2fsa.sherpa.onnx.tts.engine.utils.longToast +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import java.io.File +import java.io.FileNotFoundException +import java.io.InputStream + +open class ImplYamlConfig(val filePath: String, val factory: () -> T) { + open fun decode(ins: InputStream): T { + throw NotImplementedError("") + } + + open fun encode(o: T): String { + throw NotImplementedError("") + } + + private var mConfig: T? = null + + val config + get() = mConfig ?: load().run { mConfig!! } + + fun updateConfig(config: T) { + mConfig = config + write(config = config) + + _configFlow.tryEmit(mConfig!!) + } + + fun load() { + mConfig = read() + _configFlow.tryEmit(mConfig!!) + } + + private val _configFlow by lazy { + MutableStateFlow(config) + } + + val configFlow: Flow + get() = _configFlow + + private fun read(path: String = filePath): T { + val file = File(path) + + try { + file.inputStream().use { + return decode(it) + } + + } catch (e: FileNotFoundException) { + Log.e(ConfigManager.TAG, "readConfig: ", e) + + val obj = factory() + write(config = obj) + + return obj + } + } + + private fun write(path: String = filePath, config: T) { + val file = File(path) + + file.writeText(encode(config)) + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/Model.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/Model.kt new file mode 100644 index 000000000..5cc4ae5b7 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/Model.kt @@ -0,0 +1,29 @@ +package com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config + +import kotlinx.serialization.Serializable + +@Serializable +data class Model( + val id: String, + val name: String = id, + val onnx: String, + val dataDir: String, + + val lexicon: String, + val ruleFsts: String, + val tokens: String, + + val lang: String, +) { + companion object { + val EMPTY = Model( + id = "", + onnx = "", + dataDir = "", + lexicon = "", + ruleFsts = "", + tokens = "", + lang = "" + ) + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/SampleTextConfig.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/SampleTextConfig.kt new file mode 100644 index 000000000..9a388a2dc --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/SampleTextConfig.kt @@ -0,0 +1,36 @@ +package com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config + +import com.charleskorn.kaml.decodeFromStream +import com.k2fsa.sherpa.onnx.tts.engine.AppConst +import com.k2fsa.sherpa.onnx.tts.engine.FileConst +import kotlinx.serialization.encodeToString +import java.io.InputStream + +typealias SampleTextMap = Map> + +object SampleTextConfig : + ImplYamlConfig(FileConst.sampleTextPath, { mapOf() }) { + override fun decode(ins: InputStream): SampleTextMap { + return AppConst.yaml.decodeFromStream(ins) + } + + override fun encode(o: SampleTextMap): String { + return AppConst.yaml.encodeToString(o) + } + + operator fun get(code: String): List? { + return config[code] + } + + operator fun set(code: String, list: List) { + updateConfig(config.toMutableMap().apply { + this[code] = list + }) + } + + fun remove(code: String) { + updateConfig(config.toMutableMap().apply { + this.remove(code) + }) + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/Voice.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/Voice.kt new file mode 100644 index 000000000..aed77eb05 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/synthesizer/config/Voice.kt @@ -0,0 +1,26 @@ +package com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config + +import kotlinx.serialization.Serializable + +@Serializable +data class Voice(val model: String = "", val id: Int = 0, val name: String = "") { + companion object { + val EMPTY = Voice() + + fun from(s: String): Voice { + val i = s.indexOfLast { it == '_' } + val id = if (i != -1) s.substring(i + 1) else "" + val model = if (i != -1) s.substring(0, i) else "" + return if (id.isBlank() || model.isBlank()) EMPTY + else try { + Voice(model = model, id = id.toInt()) + } catch (_: NumberFormatException) { + EMPTY + } + } + } + + override fun toString(): String = "${model}_${id}" + + fun contains(voice: Voice): Boolean = voice.toString() == this.toString() +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/AuditionDialog.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/AuditionDialog.kt new file mode 100644 index 000000000..1b824e4ee --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/AuditionDialog.kt @@ -0,0 +1,121 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui + +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioTrack +import android.util.Log +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.ConfigModelManager +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.ConfigModelManager.toOfflineTtsConfig +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.SampleTextManager +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.SynthesizerManager +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config.Model +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config.Voice +import com.k2fsa.sherpa.onnx.tts.engine.utils.PcmAudioPlayer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private fun getAudioTrack(sampleRate: Int): AudioTrack { + val bufLength = AudioTrack.getMinBufferSize( + sampleRate, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_FLOAT + ) + Log.i(TAG, "sampleRate: ${sampleRate}, buffLength: ${bufLength}") + + val attr = AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build() + + val format = AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_PCM_FLOAT) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .setSampleRate(sampleRate) + .build() + + return AudioTrack( + attr, format, bufLength, AudioTrack.MODE_STREAM, + AudioManager.AUDIO_SESSION_ID_GENERATE + ) +} + +@Composable +fun AuditionDialog( + onDismissRequest: () -> Unit, + voice: Voice, +) { + val model = remember { ConfigModelManager.models().find { it.id == voice.model } } ?: run { + onDismissRequest() + return + } + + val text = SampleTextManager.getSampleText(model.lang) + val config = remember { model.toOfflineTtsConfig() } + val scope = rememberCoroutineScope() + val context = LocalContext.current + val audioPlayer = remember { + PcmAudioPlayer() + } + + LaunchedEffect(key1 = Unit) { + val tts = SynthesizerManager.getTTS(config) + println("numSpeakers: " + tts.numSpeakers()) + + val track = getAudioTrack(tts.sampleRate()) + track.play() + scope.launch(Dispatchers.IO) { + tts.generateWithCallback(text = text, sid = voice.id) { + println("write to track: ${it.size}") + track.write(it, 0, it.size, AudioTrack.WRITE_BLOCKING) + } + withContext(Dispatchers.Main) { onDismissRequest() } + } + } + + DisposableEffect(key1 = Unit) { + onDispose { + audioPlayer.release() + } + } + + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton(onClick = { + onDismissRequest() + }) { + Text(stringResource(id = android.R.string.cancel)) + } + }, + title = { + Column { + Text( + text = stringResource(id = R.string.audition), + style = MaterialTheme.typography.titleMedium + ) + + Text( + text = config.model.vits.model.split("/").last(), + style = MaterialTheme.typography.bodyMedium + ) + } + }, + text = { + Text(text = text, textAlign = androidx.compose.ui.text.style.TextAlign.Center) + } + ) +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/ConfirmDeleteDialog.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/ConfirmDeleteDialog.kt new file mode 100644 index 000000000..cc50f7f28 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/ConfirmDeleteDialog.kt @@ -0,0 +1,67 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.DeleteForeverIcon + +@Composable +fun ConfirmDeleteDialog( + onDismissRequest: () -> Unit, + name: String, + desc: String = "", + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(stringResource(id = R.string.is_confirm_delete)) }, + text = { + Column { + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + text = name, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + text = desc, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + } + }, + confirmButton = { + TextButton(onClick = { + onConfirm() + }) { + Text( + stringResource(id = R.string.delete), + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold + ) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(id = android.R.string.cancel)) + } + }, + icon = { + DeleteForeverIcon(null) + } + ) +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/ImplViewModel.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/ImplViewModel.kt new file mode 100644 index 000000000..180e4b279 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/ImplViewModel.kt @@ -0,0 +1,31 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.ViewModel +import com.k2fsa.sherpa.onnx.tts.engine.ui.error.showErrorDialog +import com.k2fsa.sherpa.onnx.tts.engine.utils.runOnUI + +@Composable +fun ErrorHandler(vm: ImplViewModel, title: String? = null) { + val context = LocalContext.current + LaunchedEffect(key1 = vm.error) { + vm.error?.let { + context.showErrorDialog(it, title) + vm.error = null + } + + } +} + +open class ImplViewModel() : ViewModel() { + var error by mutableStateOf(null) + + fun postError(t: Throwable) { + runOnUI { error = t } + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/LanguageSelectionDialog.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/LanguageSelectionDialog.kt new file mode 100644 index 000000000..2157b99dd --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/LanguageSelectionDialog.kt @@ -0,0 +1,41 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.AppSelectionDialog +import com.k2fsa.sherpa.onnx.tts.engine.utils.newLocaleFromCode +import com.k2fsa.sherpa.onnx.tts.engine.utils.toCode +import java.util.Locale + +@Composable +fun LanguageSelectionDialog( + onDismissRequest: () -> Unit, + language: String, + filter: List = emptyList(), + onLanguageSelected: (String) -> Unit +) { + val isoLangCodes = + remember { + Locale.getAvailableLocales().toList().map { it.toCode() }.distinct().filterNot { + filter.contains(it) + } + } + + AppSelectionDialog( + onDismissRequest = onDismissRequest, + title = { Text(text = stringResource(id = R.string.language)) }, + value = language, + values = isoLangCodes, + entries = isoLangCodes.map { + val displayName = newLocaleFromCode(it).displayName + + displayName + "\n" + it + }, + onClick = { value, _ -> + onLanguageSelected((value as String)) + }, + ) +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/MainActivity.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/MainActivity.kt new file mode 100644 index 000000000..d0eef911a --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/MainActivity.kt @@ -0,0 +1,155 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.k2fsa.sherpa.onnx.tts.engine.ui + +import android.os.Bundle +import android.os.SystemClock +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FilePresent +import androidx.compose.material.icons.filled.RecordVoiceOver +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.ui.models.ModelManagerScreen +import com.k2fsa.sherpa.onnx.tts.engine.ui.settings.SettingsScreen +import com.k2fsa.sherpa.onnx.tts.engine.ui.theme.SherpaOnnxTtsEngineTheme +import com.k2fsa.sherpa.onnx.tts.engine.ui.voices.VoiceManagerScreen +import com.k2fsa.sherpa.onnx.tts.engine.utils.navigateSingleTop +import com.k2fsa.sherpa.onnx.tts.engine.utils.toast + +const val TAG = "sherpa-onnx-tts-engine" + +val LocalNavController = compositionLocalOf { + error("No NavController provided") +} + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + +// startService(Intent(this, DownloadModelService::class.java).apply { +// data = +// "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-nl_BE-nathalie-x_low.tar.bz2".toUri() +// putExtra( +// DownloadModelService.EXTRA_FILE_NAME, +// "test_25mb.tar.bz2" +// ) +// }) + + + setContent { + var lastBackDownTime by remember { mutableLongStateOf(0L) } + BackHandler() { + val duration = 2000 + SystemClock.elapsedRealtime().let { + if (it - lastBackDownTime <= duration) { + finish() + } else { + lastBackDownTime = it + toast(R.string.press_back_again_to_exit) + } + } + } + NotificationPermissionChecker() + SherpaOnnxTtsEngineTheme { + val navController = rememberNavController() + navController.enableOnBackPressed(false) + val entryState by navController.currentBackStackEntryAsState() + Scaffold(bottomBar = { + fun containsRoute(route: String): Boolean { + return entryState?.destination?.route?.contains(route) ?: false + } + + NavigationBar { + @Composable + fun Item(strId: Int, id: String, icon: ImageVector) { + NavigationBarItem( + alwaysShowLabel = true, + selected = containsRoute(id), + onClick = { + /*navController.navigate(id) { + navController.graph.startDestinationRoute?.let { route -> + popUpTo(route) { + saveState = true + } + } + launchSingleTop = true + restoreState = true + }*/ + navController.navigateSingleTop(id, popUpToMain = true) + }, + icon = { Icon(icon, contentDescription = null) }, + label = { + Text(stringResource(strId)) + } + ) + } + + Item( + R.string.model, + NavRoutes.ModelManager.id, + Icons.Default.FilePresent + ) + Item( + R.string.voice, + NavRoutes.SpeakerManager.id, + Icons.Default.RecordVoiceOver + ) + Item( + R.string.settings, + NavRoutes.Settings.id, + Icons.Default.Settings + ) + } + }) { + CompositionLocalProvider(LocalNavController provides navController) { + NavHost( + modifier = Modifier.padding(bottom = it.calculateBottomPadding()), + navController = navController, + startDestination = NavRoutes.SpeakerManager.id + ) { + + composable(NavRoutes.ModelManager.id) { + ModelManagerScreen() + } + + composable(NavRoutes.SpeakerManager.id) { + VoiceManagerScreen() + } + + composable(NavRoutes.Settings.id) { + SettingsScreen() + } + } + } + + + } + } + } + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/NavRoutes.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/NavRoutes.kt new file mode 100644 index 000000000..8e8fa2e2e --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/NavRoutes.kt @@ -0,0 +1,9 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui + +import com.k2fsa.sherpa.onnx.tts.engine.R + +sealed class NavRoutes(val id: String, val strId: Int) { + data object ModelManager : NavRoutes("model_manager", R.string.model_manager) + data object SpeakerManager : NavRoutes("speaker_manager", R.string.voice_manager) + data object Settings : NavRoutes("settings", R.string.settings) +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/NotificationPermissionChecker.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/NotificationPermissionChecker.kt new file mode 100644 index 000000000..6dce8864a --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/NotificationPermissionChecker.kt @@ -0,0 +1,32 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui + +import android.Manifest +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionStatus +import com.google.accompanist.permissions.rememberPermissionState + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun NotificationPermissionChecker() { + val context = LocalContext.current + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // A13 + val notificationPermission = + rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS) + + LaunchedEffect(key1 = notificationPermission) { + when (notificationPermission.status) { + is PermissionStatus.Denied -> { +// context.longToast(context.getString(R.string.notification_goto_settings_enable)) + notificationPermission.launchPermissionRequest() + } + + is PermissionStatus.Granted -> { + } + } + } + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/ShadowReorderableItem.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/ShadowReorderableItem.kt new file mode 100644 index 000000000..2920e8d29 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/ShadowReorderableItem.kt @@ -0,0 +1,44 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui + +import android.view.HapticFeedbackConstants +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.DefaultShadowColor +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp +import org.burnoutcrew.reorderable.ReorderableItem +import org.burnoutcrew.reorderable.ReorderableState + +@Composable +fun LazyItemScope.ShadowReorderableItem( + reorderableState: ReorderableState<*>, + key: Any, + content: @Composable LazyItemScope.(isDragging: Boolean) -> Unit +) { + val view = LocalView.current + ReorderableItem(reorderableState, key) { isDragging -> + if (isDragging) { + view.isHapticFeedbackEnabled = true + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + } + + val elevation = + animateDpAsState(if (isDragging) 24.dp else 0.dp, label = "") + Box( + modifier = Modifier + .shadow( + elevation.value, + ambientColor = MaterialTheme.colorScheme.onBackground, + spotColor = MaterialTheme.colorScheme.onBackground + ) + ) { + content(isDragging) + } + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/TtsViewModel.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/TtsViewModel.kt new file mode 100644 index 000000000..528ec76ae --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/TtsViewModel.kt @@ -0,0 +1,59 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui + +import android.speech.tts.TextToSpeech +import android.speech.tts.TextToSpeech.OnInitListener +import android.speech.tts.UtteranceProgressListener +import android.util.Log +import androidx.lifecycle.ViewModel +import com.k2fsa.sherpa.onnx.tts.engine.App +import java.util.Locale + +class TtsViewModel : ViewModel() { + + // https://developer.android.com/reference/kotlin/android/speech/tts/TextToSpeech.OnInitListener + private val onInitListener = OnInitListener { status -> + when (status) { + TextToSpeech.SUCCESS -> Log.i(TAG, "Init tts succeded") + TextToSpeech.ERROR -> Log.i(TAG, "Init tts failed") + else -> Log.i(TAG, "Unknown status $status") + } + } + + // https://developer.android.com/reference/kotlin/android/speech/tts/UtteranceProgressListener + private val utteranceProgressListener = object : UtteranceProgressListener() { + override fun onStart(utteranceId: String?) { + Log.i(TAG, "onStart: $utteranceId") + } + + override fun onStop(utteranceId: String?, interrupted: Boolean) { + Log.i(TAG, "onStop: $utteranceId, $interrupted") + super.onStop(utteranceId, interrupted) + } + + override fun onError(utteranceId: String?, errorCode: Int) { + Log.i(TAG, "onError: $utteranceId, $errorCode") + super.onError(utteranceId, errorCode) + } + + override fun onDone(utteranceId: String?) { + Log.i(TAG, "onDone: $utteranceId") + } + + @Deprecated("Deprecated in Java") + override fun onError(utteranceId: String?) { + Log.i(TAG, "onError: $utteranceId") + } + } + + val tts = TextToSpeech(App.instance, onInitListener, "com.k2fsa.sherpa.onnx.tts.engine") + + init { +// tts.setLanguage(Locale(TtsEngine.lang!!)) + tts.setOnUtteranceProgressListener(utteranceProgressListener) + } + + override fun onCleared() { + super.onCleared() + tts.shutdown() + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/error/BigTextView.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/error/BigTextView.kt new file mode 100644 index 000000000..b03fe2b3c --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/error/BigTextView.kt @@ -0,0 +1,29 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.error + +import android.content.Context +import android.util.AttributeSet +import android.widget.FrameLayout +import android.widget.TextView +import com.k2fsa.sherpa.onnx.tts.engine.R + +class BigTextView(context: Context, attrs: AttributeSet?, defaultStyle: Int) : + FrameLayout(context, attrs, defaultStyle) { + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + constructor(context: Context) : this(context, null, 0) + + init { + inflate(context, R.layout.big_text_view, this) + } + + private val mText by lazy { + findViewById(R.id.tv_log) + } + + fun setText(text: CharSequence) { + mText.text = text + } + + fun append(text: CharSequence) { + mText.append(text) + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/error/ErrorDialogActivity.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/error/ErrorDialogActivity.kt new file mode 100644 index 000000000..c76a3ef79 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/error/ErrorDialogActivity.kt @@ -0,0 +1,232 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.error + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.Typeface +import android.os.Bundle +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.style.StyleSpan +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.k2fsa.sherpa.onnx.tts.engine.AppConst +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.ui.theme.SherpaOnnxTtsEngineTheme +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.AppDialog +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.LoadingContent +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.OkButton +import com.k2fsa.sherpa.onnx.tts.engine.utils.ClipboardUtils +import com.k2fsa.sherpa.onnx.tts.engine.utils.runOnUI +import com.k2fsa.sherpa.onnx.tts.engine.utils.toast +import java.util.UUID + +fun Context.showErrorDialog(t: Throwable, title: String? = null) { + runOnUI { + ErrorDialogActivity.start(this, title ?: "Error", t) + } +} + +@Suppress("DEPRECATION") +class ErrorDialogActivity : ComponentActivity() { + companion object { + const val ACTION_FINISH = + "com.k2fsa.sherpa.onnx.tts.engine.ui.error.ErrorDialogActivity.ACTION_FINISH" + + const val KEY_T_DATA = "throwable" + const val KEY_TITLE = "title" + private const val KEY_ID = "id" + + val vm by lazy { ErrorDialogViewModel() } + + fun start(context: Context, title: String, t: Throwable) { + val id = UUID.randomUUID().toString() + vm.throwableList[id] = t + context.startActivity(Intent(context, ErrorDialogActivity::class.java).apply { + putExtra(KEY_TITLE, title) + putExtra(KEY_ID, id) + }) + } + } + + private val mReceiver by lazy { MyReceiver() } + + inner class MyReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == ACTION_FINISH) { + finish() + } + } + } + + override fun onDestroy() { + super.onDestroy() + val id = intent.getStringExtra(KEY_ID) ?: return + vm.throwableList.remove(id) + + AppConst.localBroadcast.unregisterReceiver(mReceiver) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val lp = window.attributes + lp.alpha = 0.0f + window.attributes = lp + + AppConst.localBroadcast.registerReceiver(mReceiver, IntentFilter(ACTION_FINISH)) + + val title = intent.getStringExtra(KEY_TITLE) ?: getString(R.string.error) + val t = vm.throwableList[intent.getStringExtra(KEY_ID) ?: ""] + ?: intent.getSerializableExtra(KEY_T_DATA) as? Throwable + + if (t == null) { + toast(R.string.error) + finish() + return + } + + val str = t.stackTraceToString() + setContent { + SherpaOnnxTtsEngineTheme { + var showDialog by remember { mutableStateOf(true) } + var isLoading by remember { mutableStateOf(false) } + AppDialog( + onDismissRequest = { + showDialog = false + finish() + }, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(title) + } + }, + content = { + LoadingContent(isLoading = isLoading) { + Column { + SelectionContainer { + Text( + text = t.localizedMessage ?: "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + } + ThrowableText(t = str) + } + } + }, + buttons = { + Row(Modifier.fillMaxWidth()) { + TextButton(modifier = Modifier.padding(end = 8.dp), onClick = { + ClipboardUtils.copyText(str) + toast(R.string.copied) + showDialog = false + finish() + }) { + Text(stringResource(id = android.R.string.copy)) + } + + Row(Modifier.weight(1f)) { + Spacer(modifier = Modifier.weight(1f)) + +// TextButton(enabled = !isLoading, +// onClick = { +// +// lifecycleScope.launch(Dispatchers.Main) { +// isLoading = true +// kotlin.runCatching { +// val url = withIO { Tts_server_lib.uploadLog(str) } +// ClipboardUtils.copyText(url) +// longToast(R.string.copied) +// }.onFailure { +// longToast( +// getString( +// R.string.upload_failed, +// it.message +// ) +// ) +// } +// isLoading = false +// } +// }) { +// Text(stringResource(id = R.string.upload_to_url)) +// } + + OkButton { + showDialog = false + finish() + } + } + } + } + ) + } + } + } + + @Composable + fun ThrowableText(modifier: Modifier = Modifier, t: String) { + AndroidView( + modifier = modifier, + factory = { ctx -> + val tv = BigTextView(ctx) + + t.lines().forEach { + val span = if (it.trimStart().startsWith("at")) { + SpannableStringBuilder(it).apply { + setSpan( + StyleSpan(Typeface.ITALIC), + 0, + it.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } else { + SpannableStringBuilder(it).apply { + setSpan( + StyleSpan(Typeface.BOLD), + 0, + it.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } + + tv.append(span) + tv.append("\n") + } + + tv + } + ) + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/error/ErrorDialogViewModel.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/error/ErrorDialogViewModel.kt new file mode 100644 index 000000000..681ebf4f6 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/error/ErrorDialogViewModel.kt @@ -0,0 +1,7 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.error + +import androidx.lifecycle.ViewModel + +class ErrorDialogViewModel : ViewModel() { + internal val throwableList = mutableMapOf() +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/AddModelsDialog.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/AddModelsDialog.kt new file mode 100644 index 000000000..03b77b376 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/AddModelsDialog.kt @@ -0,0 +1,82 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.models + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.k2fsa.sherpa.onnx.tts.engine.FileConst +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.ConfigModelManager +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config.Model + +@Composable +fun AddModelsDialog(onDismissRequest: () -> Unit) { + val context = androidx.compose.ui.platform.LocalContext.current + val models = remember { ConfigModelManager.getNotAddedModels(context) } + val checkedModels = remember { mutableStateListOf() } + AlertDialog(onDismissRequest = onDismissRequest, title = { + Column { + Text(text = stringResource(id = R.string.add_models)) + SelectionContainer { + Text(text = FileConst.modelDir, style = MaterialTheme.typography.bodySmall) + } + } + }, text = { + if (models.isEmpty()) + Text( + textAlign = TextAlign.Center, + text = stringResource(R.string.no_models_waiting_to_added), + color = MaterialTheme.colorScheme.error + ) + + LazyColumn { + items(models) { model -> + val checked = checkedModels.contains(model) + Row( + Modifier + .fillMaxWidth() + .clickable(role = Role.Checkbox) { + if (checked) checkedModels.remove(model) else + checkedModels.add(model) + } + .minimumInteractiveComponentSize() + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked = checked, onCheckedChange = null) + Text(text = model.name, modifier = Modifier.padding(start = 4.dp)) + } + } + } + }, confirmButton = { + TextButton(onClick = { + ConfigModelManager.addModel(*checkedModels.toTypedArray()) + onDismissRequest() + }) { + Text(text = stringResource(id = android.R.string.ok)) + } + }, dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(id = android.R.string.cancel)) + } + }) +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ImportModelPackageDialog.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ImportModelPackageDialog.kt new file mode 100644 index 000000000..fb41e27e0 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ImportModelPackageDialog.kt @@ -0,0 +1,84 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.models + +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.documentfile.provider.DocumentFile +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.service.ModelManagerService +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.ModelPackageManager +import com.k2fsa.sherpa.onnx.tts.engine.utils.grantReadPermission +import com.k2fsa.sherpa.onnx.tts.engine.utils.longToast + +@Composable +fun ImportModelPackageDialog(onDismissRequest: () -> Unit) { + var showTipsDialog by remember { mutableStateOf(false) } + + if (showTipsDialog) + AlertDialog(onDismissRequest = onDismissRequest, + title = { Text(stringResource(id = R.string.import_model_package)) }, + text = { Text(stringResource(R.string.task_added_tips)) }, + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(id = android.R.string.ok)) + } + } + ) + + val context = LocalContext.current + val filepicker = + rememberLauncherForActivityResult(contract = ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) { + onDismissRequest() + return@rememberLauncherForActivityResult + } + + try { + uri.grantReadPermission(context.contentResolver) + } catch (e: Exception) { + context.longToast(R.string.unable_grant_read_permission) + onDismissRequest() + return@rememberLauncherForActivityResult + } + + val file = DocumentFile.fromSingleUri(context, uri)?.name + if (file == null) { + context.longToast(R.string.unable_get_file_name) + onDismissRequest() + return@rememberLauncherForActivityResult + } + + if (ModelPackageManager.supportedTypes.none { file.endsWith(it) }) { + context.longToast( + R.string.only_ext_files_are_supported, + ModelPackageManager.supportedTypes.joinToString(" ") + ) + + onDismissRequest() + return@rememberLauncherForActivityResult + } + + showTipsDialog = true + + context.startService(Intent(context, ModelManagerService::class.java).apply { + data = uri + }) + } + + LaunchedEffect(key1 = Unit) { + filepicker.launch(arrayOf("*/*")) + } + + +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/LanguageTextField.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/LanguageTextField.kt new file mode 100644 index 000000000..71f47bc04 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/LanguageTextField.kt @@ -0,0 +1,62 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.models + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.ui.LanguageSelectionDialog +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.DenseOutlinedField +import com.k2fsa.sherpa.onnx.tts.engine.utils.toLocale + + +@Composable +fun LanguageTextField(modifier: Modifier, language: String, onLanguageChange: (String) -> Unit) { + var showLangSelectDialog by remember { mutableStateOf(false) } + if (showLangSelectDialog) + LanguageSelectionDialog( + onDismissRequest = { showLangSelectDialog = false }, + language = language + ) { + onLanguageChange(it) + showLangSelectDialog = false + } + + Column(modifier) { + DenseOutlinedField( + modifier = modifier, + value = language, + onValueChange = onLanguageChange, + label = { Text(text = stringResource(R.string.language)) }, + trailingIcon = { + IconButton(onClick = { showLangSelectDialog = true }) { + Icon( + Icons.Default.FilterList, + contentDescription = stringResource(id = R.string.language) + ) + } + } + ) + + val langName = remember(language) { language.toLocale().displayName } + Text( + modifier = Modifier.fillMaxWidth(), + text = langName.ifBlank { language }, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + } +} diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelDeleteDialog.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelDeleteDialog.kt new file mode 100644 index 000000000..f47a05514 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelDeleteDialog.kt @@ -0,0 +1,62 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.models + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.compose.widgets.TextCheckBox +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.AppDialog +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.CancelButton +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.OkButton + +@Composable +internal fun ModelDeleteDialog( + onDismissRequest: () -> Unit, + message: String, + onConfirm: (Boolean) -> Unit, +) { + var isDeleteFile by rememberSaveable { mutableStateOf(false) } + + AppDialog(onDismissRequest = onDismissRequest, + title = { + Text(text = stringResource(R.string.delete_model)) + }, content = { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(message, style = MaterialTheme.typography.bodyMedium) + val color = if (isDeleteFile) MaterialTheme.colorScheme.error else Color.Unspecified + TextCheckBox( + modifier = Modifier.padding(top = 24.dp), + colors = CheckboxDefaults.colors(checkedColor = color, uncheckedColor = color), + text = { Text(stringResource(R.string.delete_model_file), color = color) }, + checked = isDeleteFile, + onCheckedChange = { isDeleteFile = it }) + } + }, buttons = { + Row { + CancelButton { + onDismissRequest() + } + OkButton { + onConfirm(isDeleteFile) + } + } + }) +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelDownloadInstallDialog.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelDownloadInstallDialog.kt new file mode 100644 index 000000000..04440fa8b --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelDownloadInstallDialog.kt @@ -0,0 +1,142 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.models + +import android.content.Intent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.lifecycle.viewmodel.compose.viewModel +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.service.ModelManagerService +import com.k2fsa.sherpa.onnx.tts.engine.service.ModelManagerService.Companion.EXTRA_FILE_NAME +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.AppDialog +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.LoadingContent +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.SearchTextFieldInList +import com.k2fsa.sherpa.onnx.tts.engine.utils.clickableRipple +import com.k2fsa.sherpa.onnx.tts.engine.utils.formatFileSize + +@Preview +@Composable +private fun PreviewModelDownloadDialog() { + var show by remember { mutableStateOf(false) } + + ModelDownloadInstallDialog(onDismissRequest = { show = false }) +} + +@Composable +fun ModelDownloadInstallDialog( + onDismissRequest: () -> Unit, +) { + val vm: ModelDownloadInstallViewModel = viewModel() + val context = LocalContext.current + + LaunchedEffect(key1 = Unit) { + vm.load() + } + + var showTips by remember { mutableStateOf(false) } + if (showTips) + TaskAddedTipsDialog( + onDismissRequest = { + showTips = false + onDismissRequest() + }, + title = stringResource(id = R.string.download_model) + ) + + AppDialog(onDismissRequest = onDismissRequest, + title = { Text(stringResource(id = R.string.download_model)) }, content = { + if (vm.error.isNotEmpty()) + Text( + text = vm.error, + modifier = Modifier.align(Alignment.Center), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + + val isLoading = vm.modelList.isEmpty() && vm.error.isEmpty() + LoadingContent( + modifier = Modifier.fillMaxWidth(), + isLoading = isLoading + ) { + Column(Modifier.fillMaxWidth()) { + val state = rememberLazyListState() + var search by rememberSaveable { mutableStateOf("") } + if (!isLoading && vm.error.isEmpty()) + SearchTextFieldInList( + Modifier.align(Alignment.CenterHorizontally), + onSearch = { search = it } + ) + + LazyColumn(state = state) { + items(vm.modelList, key = { it.browserDownloadUrl }) { asset -> + if (!asset.name.contains(search, ignoreCase = true)) return@items + + Row( + Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .clickableRipple { + context.startService( + Intent( + context, + ModelManagerService::class.java + ).apply { + data = asset.browserDownloadUrl.toUri() + putExtra(EXTRA_FILE_NAME, asset.name) + } + ) + showTips = true + } + .minimumInteractiveComponentSize() + .padding(horizontal = 4.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text(asset.name, style = MaterialTheme.typography.titleSmall) + val size = + remember(asset.size) { asset.size.formatFileSize(context) } + Text(size, style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + } + }, buttons = { + Row { +// TextButton(onClick = { /*TODO*/ }) { +// Text(stringResource(id = android.R.string.ok)) +// } + + TextButton(onClick = onDismissRequest) { + Text(stringResource(id = android.R.string.cancel)) + } + } + }) +} + diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelDownloadInstallViewModel.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelDownloadInstallViewModel.kt new file mode 100644 index 000000000..49d91142f --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelDownloadInstallViewModel.kt @@ -0,0 +1,41 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.models + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.drake.net.Get +import com.k2fsa.sherpa.onnx.tts.engine.AppConst +import com.k2fsa.sherpa.onnx.tts.engine.GithubRelease +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.ConfigModelManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ModelDownloadInstallViewModel : ViewModel() { + var error by mutableStateOf("") + val modelList = mutableStateListOf() +// val checkedModels = mutableStateListOf() + + fun load() { + error = "" + modelList.clear() + viewModelScope.launch(Dispatchers.IO) { + runCatching { + val str = + Get("https://api.github.com/repos/k2-fsa/sherpa-onnx/releases/tags/tts-models").await() + val release: GithubRelease = AppConst.jsonBuilder.decodeFromString(str) + val addedModels = ConfigModelManager.models() + modelList.addAll(release.assets.filter { asset -> + // contains only tar.bz2 and not added + val ext = ".tar.bz2" + asset.name.endsWith(ext) && + addedModels.find { it.id == asset.name.removeSuffix(ext) } == null + }) + }.onFailure { + error = it.message ?: "Unknown error" + } + } + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelEditDialog.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelEditDialog.kt new file mode 100644 index 000000000..e5ca060e6 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelEditDialog.kt @@ -0,0 +1,99 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.models + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config.Model +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.AppDialog +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.CancelButton +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.DenseOutlinedField +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.OkButton + +@Composable +fun ModelEditDialog( + onDismissRequest: () -> Unit, + model: Model, + onSave: (Model) -> Unit +) { + var data by remember { mutableStateOf(model) } + AppDialog(onDismissRequest = onDismissRequest, title = { + Text(stringResource(R.string.edit)) + }, content = { + Content( + model = data, + onModelChange = { + data = it + } + ) + }, buttons = { + Row { + CancelButton(onClick = onDismissRequest) + OkButton(Modifier.padding(start = 4.dp), onClick = { + onSave(data.copy()) + onDismissRequest() + }) + } + }) +} + + +@Composable +private fun Content( + modifier: Modifier = Modifier, + model: Model, + onModelChange: (Model) -> Unit +) { + Column(modifier) { + DenseOutlinedField( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + value = model.id, + onValueChange = { + onModelChange(model.copy(id = it)) + }, + label = { Text(text = "ID") } + ) + + DenseOutlinedField( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + value = model.name, + onValueChange = { + onModelChange(model.copy(name = it)) + }, + label = { Text(text = stringResource(R.string.display_name)) } + ) + + DenseOutlinedField( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + value = model.onnx, + onValueChange = { + onModelChange(model.copy(onnx = it)) + }, + label = { Text(text = stringResource(R.string.onnx_model_file)) } + ) + + LanguageTextField( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + language = model.lang, + onLanguageChange = { onModelChange(model.copy(lang = it)) } + ) + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelExportDialog.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelExportDialog.kt new file mode 100644 index 000000000..482223aa6 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelExportDialog.kt @@ -0,0 +1,94 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.models + +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.service.ModelExportService +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config.Model +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.AppDialog +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.CancelButton +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.OkButton +import com.k2fsa.sherpa.onnx.tts.engine.utils.grantReadWritePermission + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ModelExportDialog(show: Boolean, onDismissRequest: () -> Unit, models: List) { + var showTips by remember { mutableStateOf(false) } + if (showTips) + TaskAddedTipsDialog( + onDismissRequest = { + showTips = false + }, + title = stringResource(id = R.string.export) + ) + + val context = LocalContext.current + var currentType by rememberSaveable { mutableStateOf("zip") } + val filepicker = + rememberLauncherForActivityResult(contract = ActivityResultContracts.CreateDocument("*/*")) { + it?.let { uri -> + uri.grantReadWritePermission(context.contentResolver) + + context.startService(Intent(context, ModelExportService::class.java).apply { + putExtra(ModelExportService.EXTRA_MODELS, models.map { it.id }.toTypedArray()) + putExtra(ModelExportService.EXTRA_TYPE, currentType) + data = uri + }) + + showTips = true + } + + onDismissRequest() + } + + + val typeList = remember { listOf("zip", "tar.gz", "tar.xz", "tar.bz2") } + if (show) + AppDialog(onDismissRequest = onDismissRequest, title = { + Text(text = stringResource(id = R.string.export)) + }, content = { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + ) { + typeList.forEach { + val selected = it == currentType + FilterChip( + selected, + modifier = Modifier.padding(horizontal = 4.dp), + onClick = { currentType = it }, + label = { + Text( + it, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal + ) + } + ) + } + } + }, buttons = { + CancelButton { onDismissRequest() } + OkButton { + filepicker.launch("onnx-models.${currentType}") + } + }) +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelManagerMainToolBar.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelManagerMainToolBar.kt new file mode 100644 index 000000000..a4887280f --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelManagerMainToolBar.kt @@ -0,0 +1,71 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.models + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AddToPhotos +import androidx.compose.material.icons.filled.Archive +import androidx.compose.material.icons.filled.Download +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.k2fsa.sherpa.onnx.tts.engine.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ModelManagerMainToolBar( + modifier: Modifier, + onAddModels: () -> Unit, + onImportModels: () -> Unit, + onDownloadModels: () -> Unit +) { + val context = LocalContext.current + TopAppBar( + modifier = modifier, + title = { Text(stringResource(id = R.string.app_name)) }, + actions = { + var showOptions by remember { mutableStateOf(false) } + IconButton(onClick = { showOptions = true }) { + Icon(Icons.Default.Add, stringResource(id = R.string.add)) + DropdownMenu(expanded = showOptions, onDismissRequest = { showOptions = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.add_models)) }, + onClick = onAddModels, + leadingIcon = { + Icon(Icons.Default.AddToPhotos, null) + } + ) + + DropdownMenuItem( + text = { Text(stringResource(R.string.import_model_package)) }, + onClick = onImportModels, + leadingIcon = { + Icon(Icons.Default.Archive, null) + } + ) + + DropdownMenuItem( + text = { Text(stringResource(R.string.download_model)) }, + onClick = onDownloadModels, + leadingIcon = { + Icon(Icons.Default.Download, null) + } + ) + } + } + + } + ) +} + diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelManagerScreen.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelManagerScreen.kt new file mode 100644 index 000000000..52ae05097 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelManagerScreen.kt @@ -0,0 +1,372 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.models + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Output +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.onLongClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.ConfigModelManager +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config.Model +import com.k2fsa.sherpa.onnx.tts.engine.ui.ErrorHandler +import com.k2fsa.sherpa.onnx.tts.engine.ui.ShadowReorderableItem +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.AppDialog +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.AppSelectionToolBar +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.DeleteMenuItem +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.SelectableCard +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.SelectionToolBarState +import com.k2fsa.sherpa.onnx.tts.engine.utils.performLongPress +import com.k2fsa.sherpa.onnx.tts.engine.utils.toLocale +import org.burnoutcrew.reorderable.detectReorder +import org.burnoutcrew.reorderable.rememberReorderableLazyListState +import org.burnoutcrew.reorderable.reorderable + + +@Composable +fun ModelManagerScreen() { + val vm: ModelManagerViewModel = viewModel() + val context = LocalContext.current + var showImportDialog by remember { mutableStateOf(false) } + if (showImportDialog) + AddModelsDialog { showImportDialog = false } + + var showImportPackageDialog by remember { mutableStateOf(false) } + if (showImportPackageDialog) + ImportModelPackageDialog { showImportPackageDialog = false } + + var showDlModelDialog by remember { mutableStateOf(false) } + if (showDlModelDialog) + ModelDownloadInstallDialog { showDlModelDialog = false } + + var showLanguageDialog by remember { mutableStateOf(false) } + if (showLanguageDialog) { + var text by remember { mutableStateOf("") } + AppDialog( + onDismissRequest = { showLanguageDialog = false }, + title = { Text(stringResource(id = R.string.language)) }, + content = { + LanguageTextField(modifier = Modifier.fillMaxWidth(), language = text) { + text = it + } + }, + buttons = { + Row { + TextButton(onClick = { showLanguageDialog = false }) { + Text(stringResource(id = android.R.string.cancel)) + } + TextButton( + enabled = text.isNotBlank(), + onClick = { + showLanguageDialog = false + vm.setLanguagesForSelectedModels(text) + }) { + Text(stringResource(id = android.R.string.ok)) + } + + } + } + ) + } + + var showDeleteDialog by remember { mutableStateOf?>(null) } + if (showDeleteDialog != null) { + val models = showDeleteDialog!! + ModelDeleteDialog( + onDismissRequest = { showDeleteDialog = null }, + message = remember(models) { models.joinToString { it.name } }, + onConfirm = { deleteFile -> + vm.deleteModels(models, deleteFile) + showDeleteDialog = null + } + ) + } + + var showExportDialog by remember { mutableStateOf?>(null) } + ModelExportDialog( + show = showExportDialog != null, + onDismissRequest = { showExportDialog = null }, + models = showExportDialog ?: emptyList(), + ) + + LaunchedEffect(key1 = vm) { + vm.load() + } + ErrorHandler(vm = vm) + + val toolBarState = remember { + SelectionToolBarState( + onSelectAll = vm::selectAll, + onSelectInvert = vm::selectInvert, + onSelectClear = vm::clearSelect + ) + } + Scaffold(topBar = { + AppSelectionToolBar(state = toolBarState, mainBar = { + ModelManagerMainToolBar( + modifier = Modifier, + onAddModels = { showImportDialog = true }, + onImportModels = { showImportPackageDialog = true }, + onDownloadModels = { showDlModelDialog = true } + ) + }) { + var showOptions by remember { mutableStateOf(false) } + IconButton(onClick = { showOptions = true }) { + Icon(Icons.Default.MoreVert, stringResource(id = R.string.more_options)) + DropdownMenu(expanded = showOptions, onDismissRequest = { showOptions = false }) { + DropdownMenuItem( + leadingIcon = { Icon(Icons.Default.Output, null) }, + text = { Text(stringResource(id = R.string.export)) }, + onClick = { + showExportDialog = vm.selectedModels + showOptions = false + } + ) + + DropdownMenuItem( + leadingIcon = { Icon(Icons.Default.Language, null) }, + text = { Text(stringResource(id = R.string.change_language)) }, + onClick = { + showLanguageDialog = true + showOptions = false + } + ) + + DeleteMenuItem { + showOptions = false + showDeleteDialog = vm.selectedModels.toList() + } + } + } + } + }) { + ModelManagerScreenContent( + Modifier + .padding(it) + .fillMaxSize(), + vm = vm, + toolBarState = toolBarState, + onDeleteModel = { showDeleteDialog = listOf(it) }, + onExportModel = { showExportDialog = listOf(it) } + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ModelManagerScreenContent( + modifier: Modifier = Modifier, + toolBarState: SelectionToolBarState, + onDeleteModel: (Model) -> Unit, + onExportModel: (Model) -> Unit, + + vm: ModelManagerViewModel = viewModel() +) { + val context = LocalContext.current + + var showModelEditDialog by remember { mutableStateOf(null) } + if (showModelEditDialog != null) { + ModelEditDialog( + onDismissRequest = { showModelEditDialog = null }, + model = showModelEditDialog!!, + onSave = { ConfigModelManager.updateModels(it) } + ) + } + + val selectMode = vm.selectedModels.isNotEmpty() + LaunchedEffect(key1 = vm.selectedModels.size) { + toolBarState.selectedCount.value = vm.selectedModels.size + } + + val view = LocalView.current + val reorderState = + rememberReorderableLazyListState(listState = vm.listState, onMove = { from, to -> + vm.moveModel(from.index, to.index) + view.announceForAccessibility( + context.getString( + R.string.list_moved_desc, + from.index.toString(), + to.index.toString() + ) + ) + }) + + if (vm.models.value.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = stringResource(id = R.string.no_models_tips), + style = MaterialTheme.typography.titleMedium + ) + } + } + + LazyColumn(modifier = modifier.reorderable(reorderState), state = reorderState.listState) { + items(vm.models.value, { it.id }) { model -> + val lang = remember(model.lang) { model.lang.toLocale().displayName } + val selected = vm.selectedModels.contains(model) + ShadowReorderableItem(reorderableState = reorderState, key = model.id) { + ModelItem( + modifier = Modifier + .animateItemPlacement() + .padding(4.dp), + reorderModifier = Modifier.detectReorder(reorderState), + name = model.name, + lang = lang, + selected = selected, + onEdit = { showModelEditDialog = model }, + onClick = { + if (selectMode) { + if (selected) + vm.selectedModels.remove(model) + else + vm.selectedModels.add(model) + } +// TtsConfig.modelId.value = model.id + }, + + onLongClick = { + if (!selectMode) + vm.selectedModels.add(model) + }, + onDelete = { onDeleteModel(model) }, + onExport = { onExportModel(model) } + ) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ModelItem( + modifier: Modifier, + reorderModifier: Modifier = Modifier, + name: String, + lang: String, + selected: Boolean, + onExport: () -> Unit, + onClick: () -> Unit, + onLongClick: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit +) { + val context = LocalContext.current + val view = LocalView.current + + SelectableCard( + name = name, + selected = selected, + modifier + .clip(CardDefaults.shape) + .combinedClickable( + onClick = onClick, + onLongClick = { + view.performLongPress() + onLongClick() + }, + ), + ) { + Box(modifier = Modifier.padding(4.dp)) { + Row { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + Modifier + .weight(1f) + .padding(start = 4.dp) + ) { + Text( + text = name, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + + ) + Row { + Text( + text = lang, + style = MaterialTheme.typography.bodyMedium + ) + } + } + Row { + IconButton(onClick = onEdit) { + Icon( + Icons.Default.Edit, + contentDescription = stringResource(id = R.string.edit), + ) + } + + var showOptions by remember { mutableStateOf(false) } + IconButton(modifier = reorderModifier.semantics { + onLongClick(context.getString(R.string.drag_sort_desc)) { true } + }, + onClick = { showOptions = true }) { + Icon( + Icons.Default.MoreVert, + contentDescription = stringResource(id = R.string.more_options), + ) + + DropdownMenu( + expanded = showOptions, + onDismissRequest = { showOptions = false }) { + + DropdownMenuItem( + text = { Text(stringResource(id = R.string.export)) }, + leadingIcon = { + Icon(Icons.Default.Output, null) + }, + onClick = { + showOptions = false + onExport() + } + ) + + DeleteMenuItem { + showOptions = false + onDelete() + } + + } + } + } + } + } + } + } +} diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelManagerViewModel.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelManagerViewModel.kt new file mode 100644 index 000000000..64d9ffb15 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/ModelManagerViewModel.kt @@ -0,0 +1,85 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.models + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.viewModelScope +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.ConfigModelManager +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.ModelPackageManager +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config.Model +import com.k2fsa.sherpa.onnx.tts.engine.ui.ImplViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.util.Collections + +class ModelManagerViewModel : ImplViewModel() { + internal val models = mutableStateOf>(emptyList()) + internal val selectedModels = mutableStateListOf() + internal val listState by lazy { LazyListState() } + + override fun onCleared() { + super.onCleared() + models.value = emptyList() + selectedModels.clear() + } + + fun load() { + viewModelScope.launch(Dispatchers.IO) { + runCatching { + ConfigModelManager.load() + }.onSuccess { + ConfigModelManager.modelsFlow.collectLatest { + selectedModels.clear() + models.value = it + } + }.onFailure { + postError(it) + } + } + } + + fun moveModel(from: Int, to: Int) { + val list = ConfigModelManager.models().toMutableList() + Collections.swap(list, from, to) + ConfigModelManager.updateModels(list) + } + + fun deleteModel(model: Model) { + ConfigModelManager.removeModel(model) + } + + fun setLanguagesForSelectedModels(lang: String) { + val list = ConfigModelManager.models().toMutableList() + list.forEachIndexed { index, model -> + if (selectedModels.find { it.id == model.id } != null) { + list[index] = model.copy(lang = lang) + } + } + ConfigModelManager.updateModels(list) + } + + fun selectAll() { + selectedModels.clear() + selectedModels.addAll(models.value) + } + + fun selectInvert() { + ConfigModelManager.models().filter { !selectedModels.contains(it) }.let { + selectedModels.clear() + selectedModels.addAll(it) + } + } + + fun clearSelect() { + selectedModels.clear() + } + + fun deleteModels(models: List, deleteFile: Boolean) { + if (deleteFile) + models.forEach { + ModelPackageManager.deleteModel(it.id) + } + ConfigModelManager.removeModel(*models.toTypedArray()) + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/TaskAddedTipsDialog.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/TaskAddedTipsDialog.kt new file mode 100644 index 000000000..22ef647bc --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/models/TaskAddedTipsDialog.kt @@ -0,0 +1,21 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.models + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.k2fsa.sherpa.onnx.tts.engine.R + +@Composable +fun TaskAddedTipsDialog(onDismissRequest: () -> Unit, title: String) { + AlertDialog(onDismissRequest = onDismissRequest, + title = { Text(title) }, + text = { Text(stringResource(R.string.task_added_tips)) }, + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(id = android.R.string.ok)) + } + } + ) +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/sampletext/SampleTextEdit.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/sampletext/SampleTextEdit.kt new file mode 100644 index 000000000..d1d68cd65 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/sampletext/SampleTextEdit.kt @@ -0,0 +1,119 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.sampletext + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.ui.theme.SherpaOnnxTtsEngineTheme +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.AppDialog +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.DeleteForeverIcon +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.DenseOutlinedField + +@Preview +@Composable +private fun PreviewSampleTextEdit() { + SherpaOnnxTtsEngineTheme { + var list by remember { + mutableStateOf(listOf("Text1", "Text2")) + } + SampleTextEdit( + modifier = Modifier, + list = list, + onListChange = { list = it } + ) + } +} + +@Composable +fun SampleTextEditDialog( + onDismissRequest: () -> Unit, code: String, + list: List, + onConfirm: (List) -> Unit +) { + var data by remember { mutableStateOf(list) } + AppDialog( + onDismissRequest = onDismissRequest, title = { Text(code) }, + content = { + SampleTextEdit(modifier = Modifier, list = data) { + data = it + } + }, + buttons = { + Row { + TextButton(onClick = onDismissRequest) { + Text(stringResource(android.R.string.cancel)) + } + TextButton(onClick = { + onConfirm(data) + onDismissRequest() + }) { + Text(stringResource(android.R.string.ok)) + } + } + } + ) +} + +@Composable +fun SampleTextEdit(modifier: Modifier, list: List, onListChange: (List) -> Unit) { + LazyColumn { + list.forEachIndexed { index, s -> + item { + DenseOutlinedField( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp, horizontal = 4.dp), + value = s, + onValueChange = { + val newList = list.toMutableList() + newList[index] = it + onListChange(newList) + }, + label = { Text((index + 1).toString()) }, + trailingIcon = { + IconButton(onClick = { + val newList = list.toMutableList() + newList.removeAt(index) + onListChange(newList) + }) { + DeleteForeverIcon() + } + } + ) + } + } + + item { + TextButton( + modifier = Modifier.fillMaxWidth(), + onClick = { + val newList = list.toMutableList() + newList.add("") + onListChange(newList) + }) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Add, contentDescription = null) + Text(stringResource(R.string.add)) + } + } + } + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/sampletext/SampleTextManagerActivity.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/sampletext/SampleTextManagerActivity.kt new file mode 100644 index 000000000..c04fa5cde --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/sampletext/SampleTextManagerActivity.kt @@ -0,0 +1,19 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.sampletext + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import com.k2fsa.sherpa.onnx.tts.engine.ui.theme.SherpaOnnxTtsEngineTheme + +class SampleTextManagerActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + SherpaOnnxTtsEngineTheme { + SampleTextManagerScreen() + } + } + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/sampletext/SampleTextManagerScreen.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/sampletext/SampleTextManagerScreen.kt new file mode 100644 index 000000000..cc6bf69d0 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/sampletext/SampleTextManagerScreen.kt @@ -0,0 +1,132 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.sampletext + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.ui.ConfirmDeleteDialog +import com.k2fsa.sherpa.onnx.tts.engine.ui.LanguageSelectionDialog +import com.k2fsa.sherpa.onnx.tts.engine.utils.newLocaleFromCode + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SampleTextManagerScreen() { + val vm: SampleTextMangerViewModel = viewModel() + + var showAddLanguage by remember { mutableStateOf(false) } + if (showAddLanguage) { + LanguageSelectionDialog( + onDismissRequest = { showAddLanguage = false }, + language = "", + filter = vm.languages + ) { + vm.addLanguage(it) + showAddLanguage = false + } + } + + Scaffold(topBar = { + TopAppBar(title = { Text(stringResource(id = R.string.sample_text)) }, actions = { + IconButton(onClick = { showAddLanguage = true }) { + Icon(Icons.Default.Add, stringResource(id = R.string.add)) + } + }) + }) { + SampleTextManagerContent(Modifier.padding(it), vm = vm) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SampleTextManagerContent(modifier: Modifier, vm: SampleTextMangerViewModel = viewModel()) { + + var showEditDialog by remember { mutableStateOf(null) } + if (showEditDialog != null) { + val code = showEditDialog!! + SampleTextEditDialog( + onDismissRequest = { showEditDialog = null }, + code = showEditDialog!!, + list = vm.getList(code) ?: emptyList(), + onConfirm = { + vm.updateList(code, it) + showEditDialog = null + } + ) + } + + var showDeleteDialog by remember { mutableStateOf(null) } + if (showDeleteDialog != null) { + val code = showDeleteDialog!! + ConfirmDeleteDialog( + onDismissRequest = { showDeleteDialog = null }, + name = code + ) { + vm.removeLanguage(code) + showDeleteDialog = null + } + } + + LazyColumn(modifier) { + items(vm.languages) { + val locale = remember(it) { newLocaleFromCode(it) } + val displayName = remember(locale) { locale.getDisplayName(locale) } + LanguageItem( + Modifier + .animateItemPlacement() + .fillMaxWidth() + .padding(4.dp), + name = displayName, + code = it, + onClick = { showEditDialog = it }, + onDelete = { showDeleteDialog = it } + ) + } + } +} + + +@Composable +fun LanguageItem( + modifier: Modifier, + name: String, + code: String, + onClick: () -> Unit, + onDelete: () -> Unit +) { + ElevatedCard(modifier = modifier, onClick = onClick) { + Row(Modifier.padding(4.dp)) { + Column(Modifier.weight(1f)) { + Text(text = name, style = MaterialTheme.typography.titleMedium) + Text(text = code, style = MaterialTheme.typography.bodyMedium) + } + + IconButton(onClick = onDelete) { + Icon(Icons.Default.DeleteForever, stringResource(id = R.string.delete)) + } + } + } +} diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/sampletext/SampleTextMangerViewModel.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/sampletext/SampleTextMangerViewModel.kt new file mode 100644 index 000000000..791ed4ab8 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/sampletext/SampleTextMangerViewModel.kt @@ -0,0 +1,42 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.sampletext + +import androidx.compose.runtime.mutableStateListOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config.SampleTextConfig +import kotlinx.coroutines.launch + +class SampleTextMangerViewModel : ViewModel() { + val languages = mutableStateListOf() + + init { + load() + } + + fun load() { + viewModelScope.launch { + SampleTextConfig.configFlow.collect { data -> + val l = data.toList().map { it.first } + languages.clear() + languages.addAll(l) + + } + } + } + + fun getList(code: String): List? { + return SampleTextConfig[code] + } + + fun updateList(code: String, list: List) { + SampleTextConfig[code] = list + } + + fun addLanguage(code: String) { + SampleTextConfig[code] = emptyList() + } + + fun removeLanguage(code: String) { + SampleTextConfig.remove(code) + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/settings/SettingsScreen.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/settings/SettingsScreen.kt new file mode 100644 index 000000000..0238969dc --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/settings/SettingsScreen.kt @@ -0,0 +1,151 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Height +import androidx.compose.material.icons.filled.Http +import androidx.compose.material.icons.filled.LinearScale +import androidx.compose.material.icons.filled.TextFields +import androidx.compose.material.icons.filled.Workspaces +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.conf.AppConfig +import com.k2fsa.sherpa.onnx.tts.engine.conf.TtsConfig +import com.k2fsa.sherpa.onnx.tts.engine.ui.sampletext.SampleTextManagerActivity +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.DenseOutlinedField +import com.k2fsa.sherpa.onnx.tts.engine.utils.clickableRipple +import com.k2fsa.sherpa.onnx.tts.engine.utils.startActivity + +@Composable +private fun GhProxyPreference() { + val list = remember { + mapOf( + "ghproxy.com" to "https://mirror.ghproxy.com", + "moeyy.cn" to "https://github.moeyy.xyz", + "ghproxy.org" to "https://ghproxy.org" + ).toList() + } + + var ghProxy by remember { AppConfig.ghProxyUrl } + PreferenceDialog( + title = { Text(stringResource(R.string.github_proxy)) }, + subTitle = { + val host = + remember(ghProxy) { + ghProxy.removePrefix("https://").removePrefix("http://") + } + if (host.isNotBlank()) + Text(host, style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.github_proxy_summary)) + }, + icon = { Icon(Icons.Default.Http, null) }, + dialogContent = { + LazyColumn( + Modifier + .align(Alignment.CenterHorizontally) + ) { + items(list.toList()) { + Text( + text = it.first, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.small) + .clickableRipple { + ghProxy = it.second + } + .minimumInteractiveComponentSize(), + style = MaterialTheme.typography.bodyLarge + ) + } + } + DenseOutlinedField(modifier = Modifier.fillMaxWidth(), + value = ghProxy, onValueChange = { ghProxy = it }, + trailingIcon = { + IconButton(onClick = { ghProxy = "" }) { + Icon(Icons.Default.Clear, null) + } + } + ) + + } + ) +} + +@Composable +fun SettingsScreen() { + val context = LocalContext.current + Scaffold { paddingValues -> + Column(Modifier.padding(paddingValues)) { + DividerPreference { Text(stringResource(id = R.string.app_name)) } + GhProxyPreference() + + DividerPreference { Text("TTS") } + BasePreferenceWidget( + onClick = { context.startActivity(SampleTextManagerActivity::class.java) }, + title = { Text(stringResource(R.string.sample_text)) }, + subTitle = { Text(stringResource(R.string.sample_text_summary)) }, + icon = { Icon(Icons.Default.TextFields, null) } + ) + + DividerPreference { + Text(stringResource(id = R.string.engine)) + } + + var timeout by remember { TtsConfig.timeoutDestruction } + val timeoutStr = stringResource(id = R.string.minute_format, timeout) + SliderPreference( + icon = { Icon(Icons.Default.LinearScale, null) }, + valueRange = 1f..60f, + title = { Text(stringResource(R.string.timeout_destruction)) }, + subTitle = { Text(stringResource(R.string.timeout_destruction_summary)) }, + value = timeout.toFloat(), + onValueChange = { timeout = it.toInt() }, + label = timeoutStr + ) + + var cacheSize by remember { TtsConfig.cacheSize } + val cacheSizeStr = cacheSize.toString() + SliderPreference( + icon = { Icon(Icons.Default.Height, null) }, + valueRange = 1f..10f, + title = { Text(stringResource(R.string.cache_size)) }, + subTitle = { Text(stringResource(R.string.cache_size_summary)) }, + value = cacheSize.toFloat(), + onValueChange = { cacheSize = it.toInt() }, + label = cacheSizeStr + ) + + var threadNum by remember { TtsConfig.threadNum } + val threadNumStr = threadNum.toString() + SliderPreference( + icon = { Icon(Icons.Default.Workspaces, null) }, + valueRange = 1f..8f, + title = { Text(stringResource(R.string.thread_num)) }, + subTitle = { Text(stringResource(R.string.thread_num_summary)) }, + value = threadNum.toFloat(), + onValueChange = { threadNum = it.toInt() }, + label = threadNumStr + ) + } + } +} diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/settings/SettingsWidgets.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/settings/SettingsWidgets.kt new file mode 100644 index 000000000..7e9a32cf1 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/settings/SettingsWidgets.kt @@ -0,0 +1,265 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.AppDialog +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.DenseOutlinedField +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.LabelSlider +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.OkButton + +@Composable +internal fun DropdownPreference( + modifier: Modifier = Modifier, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + icon: @Composable () -> Unit, + title: @Composable () -> Unit, + subTitle: @Composable () -> Unit, + actions: @Composable ColumnScope. () -> Unit = {} +) { + BasePreferenceWidget(modifier = modifier, icon = icon, onClick = { + onExpandedChange(true) + }, title = title, subTitle = subTitle) { + DropdownMenu( + modifier = Modifier.align(Alignment.Top), + expanded = expanded, + onDismissRequest = { onExpandedChange(false) }) { + actions() + } + } +} + +@Composable +fun TextFieldPreference( + modifier: Modifier = Modifier, + icon: @Composable () -> Unit = {}, + title: @Composable () -> Unit, + subTitle: @Composable () -> Unit, + + value: String, + onValueChange: (String) -> Unit, +) { + var showDialog by remember { mutableStateOf(false) } + if (showDialog) { + var text by remember(value) { mutableStateOf(value) } + AlertDialog( + title = title, + text = { + DenseOutlinedField( + value = text, + onValueChange = { text = it }, +// modifier = Modifier.padding(top = 8.dp) + ) + }, + onDismissRequest = { showDialog = false }, + confirmButton = { + OkButton { + onValueChange(text) + } + }, dismissButton = { + TextButton(onClick = { showDialog = false }) { + Text(stringResource(id = R.string.close)) + } + } + ) + } + + BasePreferenceWidget(icon = icon, title = title, subTitle = subTitle, onClick = { + + }, content = { + + }) +} + +@Composable +internal fun DividerPreference(title: @Composable () -> Unit) { + Column(Modifier.padding(top = 4.dp)) { + HorizontalDivider(thickness = 0.5.dp) + Row( + Modifier + .padding(vertical = 8.dp) + .align(Alignment.CenterHorizontally) + ) { + CompositionLocalProvider( + LocalTextStyle provides MaterialTheme.typography.titleMedium.copy( + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ), + ) { + title() + } + } + } + +} + +@Composable +internal fun SwitchPreference( + modifier: Modifier = Modifier, + title: @Composable () -> Unit, + subTitle: @Composable () -> Unit, + icon: @Composable () -> Unit = {}, + + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + BasePreferenceWidget( + modifier = modifier.semantics(mergeDescendants = true) { + role = Role.Switch + }, + onClick = { onCheckedChange(!checked) }, + title = title, + subTitle = subTitle, + icon = icon, + content = { + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = Modifier.align(Alignment.CenterVertically) + ) + } + ) +} + +@Composable +internal fun BasePreferenceWidget( + modifier: Modifier = Modifier, + onClick: () -> Unit, + title: @Composable () -> Unit, + subTitle: @Composable () -> Unit = {}, + icon: @Composable () -> Unit = {}, + content: @Composable RowScope.() -> Unit = {}, +) { + Row(modifier = modifier + .minimumInteractiveComponentSize() + .defaultMinSize(minHeight = 64.dp) + .clip(MaterialTheme.shapes.extraSmall) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple() + ) { + onClick() + } + .padding(8.dp) + ) { + Column( + Modifier.align(Alignment.CenterVertically) + ) { + icon() + } + + Column( + Modifier + .weight(1f) + .align(Alignment.CenterVertically) + .padding(horizontal = 8.dp) + ) { + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.titleMedium) { + title() + } + + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyMedium) { + subTitle() + } + } + + Row(Modifier.align(Alignment.CenterVertically)) { + content() + } + } +} + + +@Composable +internal fun SliderPreference( + title: @Composable () -> Unit, + subTitle: @Composable () -> Unit, + icon: @Composable () -> Unit = {}, + value: Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange = 0f..1f, + steps: Int = 0, + label: String, +) { + PreferenceDialog( + title = title, + subTitle = subTitle, + dialogContent = { + LabelSlider( + modifier = Modifier.padding(vertical = 16.dp), + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + steps = steps, + buttonSteps = 1f, + buttonLongSteps = 2f, + text = label + ) + }, + icon = icon, + endContent = { Text(label) } + ) +} + +@Composable +internal fun PreferenceDialog( + modifier: Modifier = Modifier, + title: @Composable () -> Unit, + subTitle: @Composable () -> Unit, + icon: @Composable () -> Unit, + + dialogContent: @Composable ColumnScope.() -> Unit, + endContent: @Composable RowScope.() -> Unit = {}, +) { + var showDialog by remember { mutableStateOf(false) } + if (showDialog) { + AppDialog(title = title, content = { + Column { + dialogContent() + } + }, buttons = { + TextButton(onClick = { showDialog = false }) { + Text(stringResource(id = R.string.close)) + } + }, onDismissRequest = { showDialog = false }) + } + BasePreferenceWidget(modifier, onClick = { + showDialog = true + }, title = title, icon = icon, subTitle = subTitle) { + endContent() + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/theme/Color.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/theme/Color.kt new file mode 100644 index 000000000..9af2bb262 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/theme/Theme.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/theme/Theme.kt new file mode 100644 index 000000000..49b93ec61 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/theme/Theme.kt @@ -0,0 +1,85 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.theme + +import android.app.Activity +import android.graphics.Color +import android.os.Build +import androidx.activity.ComponentActivity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat +import com.gyf.immersionbar.ImmersionBar +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.SetupSystemBars + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun SherpaOnnxTtsEngineTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = /*colorScheme.primary.toArgb()*/ Color.TRANSPARENT + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + } + } + + //沉浸式状态栏 + ImmersionBar.with(LocalView.current.context as ComponentActivity) +// .transparentStatusBar() +// .transparentNavigationBar() // BottomSheet 会有问题 多padding了一个输入法的高度 + .statusBarDarkFont(!darkTheme) + .navigationBarDarkIcon(!darkTheme) + .keyboardEnable(true) +// .keyboardMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + .init() + + SetupSystemBars() + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/theme/Type.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/theme/Type.kt new file mode 100644 index 000000000..e66166931 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/voices/AddVoiceDialog.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/voices/AddVoiceDialog.kt new file mode 100644 index 000000000..8d1951043 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/voices/AddVoiceDialog.kt @@ -0,0 +1,179 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.voices + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Headset +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.ConfigModelManager +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.ConfigModelManager.toOfflineTtsConfig +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.ConfigVoiceManager +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.SynthesizerManager +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config.Model +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config.Voice +import com.k2fsa.sherpa.onnx.tts.engine.ui.AuditionDialog +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.AppDialog +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.AppSpinner +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.CancelButton +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.DenseOutlinedField +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.IntSlider +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.LoadingContent +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.OkButton +import com.k2fsa.sherpa.onnx.tts.engine.utils.performLongPress +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.internal.notify +import kotlin.math.max + +@Composable +internal fun AddVoiceDialog( + onDismissRequest: () -> Unit, + initialVoice: Voice, + onConfirm: (Voice) -> Unit +) { + var voice by remember { mutableStateOf(initialVoice) } + val isCopy = initialVoice != Voice.EMPTY + + val isDuplicate = + remember(voice) { + ConfigVoiceManager/*.filterNot { it == initialVoice }*/ + .find { it.toString() == voice.toString() } != null + } + var tips by remember { mutableStateOf("") } + val context = LocalContext.current + val view = LocalView.current + LaunchedEffect(key1 = isDuplicate) { + tips = if (isDuplicate) context.getString(R.string.duplicate_voices) else "" + if (isDuplicate) + (0..2).forEach { _ -> + view.performLongPress() + } + } + + var showAudition by remember { mutableStateOf(false) } + if (showAudition) + AuditionDialog(onDismissRequest = { showAudition = false }, voice = voice) + + AppDialog(onDismissRequest = onDismissRequest, title = { + Text(text = stringResource(if (isCopy) R.string.copy_voice else R.string.add_voice)) + }, content = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + DenseOutlinedField( + modifier = Modifier.fillMaxWidth(), + value = voice.name, + onValueChange = { + voice = voice.copy(name = it) + }, + label = { + Text(text = stringResource(id = R.string.display_name)) + }, + isError = voice.name.isBlank() + ) + + val models = remember { + ConfigModelManager.models().toMutableList().apply { + add(0, Model.EMPTY.copy(name = context.getString(R.string.please_select))) + } + } + + var loading by remember { mutableStateOf(true) } + var speakerNum by remember { mutableIntStateOf(0) } + AppSpinner( + modifier = Modifier.padding(top = 4.dp), + label = { Text(stringResource(id = R.string.model)) }, + value = voice.model, + values = remember { models.map { it.id } }, + entries = remember { models.map { it.name } }, + onSelectedChange = { value, _ -> + val id = value as String + voice = voice.copy(model = id) + } + ) + + LaunchedEffect(key1 = voice.model) { + speakerNum = 0 + loading = true + speakerNum = if (voice.model.isEmpty()) 1 + else withContext(Dispatchers.IO) { + models.find { it.id == voice.model }?.toOfflineTtsConfig()?.let { + val tts = SynthesizerManager.getTTS(it) + tts.numSpeakers() + } ?: 1 + } + loading = false + } + + val onlyDefault = speakerNum == 1 + LaunchedEffect(key1 = onlyDefault) { + if (onlyDefault) + voice = voice.copy(id = 0) + } + + LoadingContent(isLoading = loading) { + val str = if (voice.id == 0) stringResource(id = R.string.default_speaker) + else stringResource(R.string.speaker_id_desc, "${voice.id}") + + if (onlyDefault) + Text( + modifier = Modifier + .align(Alignment.Center) + .padding(vertical = 4.dp), + textAlign = TextAlign.Center, + text = stringResource(R.string.not_support_more_speaker), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold + ) + else + IntSlider( + modifier = Modifier.padding(top = 4.dp), + label = str, value = voice.id.toFloat(), onValueChange = { + voice = voice.copy(id = it.toInt()) + }, valueRange = 0f..max(speakerNum.toFloat(), 0f) + ) + } + + if (tips.isNotBlank()) + Text( + modifier = Modifier.padding(top = 4.dp), + text = tips, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold, + ) + } + }, buttons = { + Row(verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.weight(1f)) { + IconButton(enabled = voice.model.isNotBlank(), onClick = { showAudition = true }) { + Icon(Icons.Default.Headset, stringResource(id = R.string.audition)) + } + } + CancelButton(onClick = onDismissRequest) + OkButton(enabled = voice.name.isNotBlank() && voice.model.isNotBlank() && !isDuplicate) { + onConfirm(voice) + onDismissRequest() + } + } + }) +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/voices/SortDialog.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/voices/SortDialog.kt new file mode 100644 index 000000000..b7b32b43c --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/voices/SortDialog.kt @@ -0,0 +1,25 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.voices + +import androidx.compose.runtime.Composable +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.AppDialog +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.CancelButton +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.OkButton + +@Composable +fun SortDialog(onDismissRequest: () -> Unit, onConfirm: () -> Unit) { + AppDialog(onDismissRequest = onDismissRequest, title = { + + + }, content = { + + }, buttons = { + CancelButton { + onDismissRequest() + } + + OkButton { + onConfirm() + } + } + ) +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/voices/VoiceEditViewModel.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/voices/VoiceEditViewModel.kt new file mode 100644 index 000000000..dde7d350e --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/voices/VoiceEditViewModel.kt @@ -0,0 +1,7 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.voices + +import androidx.lifecycle.ViewModel + +class VoiceEditViewModel : ViewModel() { + +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/voices/VoiceManagerScreen.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/voices/VoiceManagerScreen.kt new file mode 100644 index 000000000..10bcde3d4 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/voices/VoiceManagerScreen.kt @@ -0,0 +1,380 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.voices + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.EditNote +import androidx.compose.material.icons.filled.Headset +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.SortByAlpha +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.conf.TtsConfig +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config.Voice +import com.k2fsa.sherpa.onnx.tts.engine.ui.AuditionDialog +import com.k2fsa.sherpa.onnx.tts.engine.ui.ConfirmDeleteDialog +import com.k2fsa.sherpa.onnx.tts.engine.ui.ErrorHandler +import com.k2fsa.sherpa.onnx.tts.engine.ui.ShadowReorderableItem +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.AppSelectionToolBar +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.DeleteMenuItem +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.SelectableCard +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.SelectionToolBarState +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.TextFieldDialog +import com.k2fsa.sherpa.onnx.tts.engine.ui.widgets.VerticalBar +import com.k2fsa.sherpa.onnx.tts.engine.utils.performLongPress +import com.k2fsa.sherpa.onnx.tts.engine.utils.toast +import org.burnoutcrew.reorderable.detectReorder +import org.burnoutcrew.reorderable.rememberReorderableLazyListState +import org.burnoutcrew.reorderable.reorderable + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun VoiceManagerScreen() { + val vm: VoiceManagerViewModel = viewModel() + val context = LocalContext.current + + var showAddVoiceDialog by remember { mutableStateOf(null) } + if (showAddVoiceDialog != null) { + AddVoiceDialog( + onDismissRequest = { showAddVoiceDialog = null }, + initialVoice = showAddVoiceDialog!!, + onConfirm = { + showAddVoiceDialog = null + vm.addVoice(it) + } + ) + } + + var showEditNameDialog by remember { mutableStateOf(null) } + if (showEditNameDialog != null) { + val voice = showEditNameDialog!! + TextFieldDialog( + title = stringResource(id = R.string.display_name), + initialText = voice.name, + onDismissRequest = { showEditNameDialog = null }) { + showEditNameDialog = null + vm.updateVoice(voice.copy(name = it)) + } + } + + var showDeleteDialog by remember { mutableStateOf?>(null) } + if (showDeleteDialog != null) { + val voices = showDeleteDialog!! + ConfirmDeleteDialog( + onDismissRequest = { showDeleteDialog = null }, + name = voices.joinToString { it.name }, + ) { + vm.delete(voices) + showDeleteDialog = null + } + } + + + var showAudition by remember { mutableStateOf(null) } + if (showAudition != null) { + AuditionDialog(onDismissRequest = { showAudition = null }, voice = showAudition!!) + } + + val selectionState = remember { + SelectionToolBarState( + onSelectAll = vm::selectAll, + onSelectInvert = vm::selectInvert, + onSelectClear = vm::selectClear + ) + } + + Scaffold(topBar = { + AppSelectionToolBar(state = selectionState, mainBar = { + TopAppBar(title = { Text(stringResource(id = R.string.app_name)) }, actions = { + IconButton(onClick = { + showAddVoiceDialog = Voice.EMPTY + }) { + Icon(Icons.Default.Add, stringResource(id = R.string.add_voice)) + } + + var showSortOptions by rememberSaveable { mutableStateOf(false) } + IconButton(onClick = { showSortOptions = true }) { + Icon(Icons.Default.SortByAlpha, stringResource(id = R.string.sort)) + + DropdownMenu( + expanded = showSortOptions, + onDismissRequest = { showSortOptions = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.sort_by_name)) }, + onClick = { + showSortOptions = false + vm.sortByName() + } + ) + + DropdownMenuItem( + text = { Text(stringResource(R.string.sort_by_model)) }, + onClick = { + showSortOptions = false + vm.sortByModel() + } + ) + } + } + }) + }) { + var showOptions by rememberSaveable { mutableStateOf(false) } + IconButton(onClick = { showOptions = true }) { + Icon(Icons.Default.MoreVert, stringResource(id = R.string.more_options)) + + DropdownMenu(expanded = showOptions, onDismissRequest = { showOptions = false }) { + DeleteMenuItem { + showOptions = false + showDeleteDialog = vm.selects.toList() + } + } + } + } + }) { paddingValues -> + LaunchedEffect(key1 = vm) { + vm.load() + } + ErrorHandler(vm = vm) + + LaunchedEffect(key1 = vm.selects.size) { + selectionState.selectedCount.value = vm.selects.size + } + + val reorderState = + rememberReorderableLazyListState(listState = vm.listState, onMove = { from, to -> + vm.move(from.index, to.index) + }) + + if (vm.voices.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = stringResource(id = R.string.no_voices_tips), + style = MaterialTheme.typography.titleMedium + ) + } + } + + val isSelectMode = vm.selects.isNotEmpty() + LazyColumn( + Modifier + .padding(paddingValues) + .fillMaxSize() + .reorderable(reorderState), + state = reorderState.listState + ) { + items(vm.voices, key = { it.toString() }) { voice -> + ShadowReorderableItem(reorderableState = reorderState, key = voice.toString()) { + val enabled = voice.contains(TtsConfig.voice.value) + val selected = vm.isSelected(voice) + val available = remember(voice) { vm.isModelAvailable(voice) } + LaunchedEffect(key1 = available) { + if (!available && voice.contains(TtsConfig.voice.value)) + TtsConfig.voice.value = Voice.EMPTY + } + + fun select() { + if (selected) vm.unselect(voice) else vm.select(voice) + } + + Item( + modifier = Modifier + .animateItemPlacement() + .padding(4.dp), + reorderModifier = Modifier.detectReorder(reorderState), + available = available, + enabled = enabled, + selected = vm.isSelected(voice), + name = voice.name, + model = voice.model, + id = voice.id.toString(), + onClick = { + if (isSelectMode) select() + else { + if (available) + TtsConfig.voice.value = voice + else + context.toast( + context.getString(R.string.model_not_found, voice.model) + ) + } + }, + onLongClick = { + select() + }, + + onCopy = { showAddVoiceDialog = voice }, + onDelete = { showDeleteDialog = listOf(voice) }, + onAudition = { showAudition = voice }, + onEditName = { showEditNameDialog = voice } + ) + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun Item( + modifier: Modifier, + reorderModifier: Modifier, + + available: Boolean, + enabled: Boolean, + selected: Boolean, + + id: String, + name: String, + model: String, + + onClick: () -> Unit, + onLongClick: () -> Unit, + + onEditName: () -> Unit, + onAudition: () -> Unit, + onCopy: () -> Unit, + onDelete: () -> Unit, +) { + val context = LocalContext.current + val view = LocalView.current + val color = + if (available) + if (enabled) MaterialTheme.colorScheme.primary else Color.Unspecified + else MaterialTheme.colorScheme.error + + val tint = if (enabled) MaterialTheme.colorScheme.primary else LocalContentColor.current + val iconButtonColors = IconButtonDefaults.iconButtonColors(contentColor = tint) + + SelectableCard( + name = name, + selected = selected, + modifier + .clip(CardDefaults.shape) + .combinedClickable( + onClick = { + onClick() + }, + onLongClick = { + view.performLongPress() + onLongClick() + } + ), + ) { + Row(Modifier.padding(4.dp), verticalAlignment = Alignment.CenterVertically) { + VerticalBar(enabled = enabled) + + Column( + Modifier + .padding(start = 4.dp) + .weight(1f) + ) { + Text(text = name, style = MaterialTheme.typography.titleMedium, color = color) + Row { + if (id != "0") + Text( + modifier = Modifier.padding(end = 4.dp), + text = id, + style = MaterialTheme.typography.bodyMedium, + color = color + ) + + Text( + modifier = Modifier.semantics { + contentDescription = if (available) model + else context.getString(R.string.model_not_found, model) + }, + text = model, + style = MaterialTheme.typography.bodyMedium, + color = color + ) + } + } + + Row { + IconButton(enabled = available, onClick = onAudition, colors = iconButtonColors) { + Icon(Icons.Default.Headset, stringResource(id = R.string.audition)) + } + + var showOptions by rememberSaveable { mutableStateOf(false) } + IconButton( + modifier = reorderModifier, + colors = iconButtonColors, + onClick = { showOptions = true }) { + Icon( + Icons.Default.MoreVert, + stringResource(id = R.string.more_options), + ) + + DropdownMenu( + expanded = showOptions, + onDismissRequest = { showOptions = false }) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.edit_name)) }, + onClick = { + showOptions = false + onEditName() + }, + leadingIcon = { + Icon(Icons.Default.EditNote, null) + } + ) + + DropdownMenuItem( + text = { Text(stringResource(id = android.R.string.copy)) }, + onClick = { + showOptions = false + onCopy() + }, + leadingIcon = { + Icon(Icons.Default.ContentCopy, null) + } + ) + + DeleteMenuItem { + showOptions = false + onDelete() + } + } + } + } + } + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/voices/VoiceManagerViewModel.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/voices/VoiceManagerViewModel.kt new file mode 100644 index 000000000..46febd6ec --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/voices/VoiceManagerViewModel.kt @@ -0,0 +1,96 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.voices + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.viewModelScope +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.ConfigModelManager +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.ConfigVoiceManager +import com.k2fsa.sherpa.onnx.tts.engine.synthesizer.config.Voice +import com.k2fsa.sherpa.onnx.tts.engine.ui.ImplViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class VoiceManagerViewModel : ImplViewModel() { + var voices by mutableStateOf>(emptyList()) + val selects = mutableStateListOf() + val listState = LazyListState() + + fun isSelected(voice: Voice) = selects.contains(voice) + + fun select(voice: Voice) { + if (selects.contains(voice)) { + selects.remove(voice) + } else { + selects.add(voice) + } + } + + fun selectAll() { + selects.clear() + selects.addAll(voices) + } + + fun selectInvert() { + val newSelects = voices.filter { !selects.contains(it) } + selects.clear() + selects.addAll(newSelects) + } + + fun selectClear() { + selects.clear() + } + + fun load() { + viewModelScope.launch(Dispatchers.IO) { + runCatching { + ConfigVoiceManager.load() + if (ConfigModelManager.models().isEmpty()) + ConfigModelManager.load() + }.onSuccess { + ConfigVoiceManager.flow.collectLatest { + selects.clear() + voices = it + } + }.onFailure { + postError(it) + } + } + } + + fun move(from: Int, to: Int) { + ConfigVoiceManager.move(from, to) + } + + fun delete(voice: List) { + ConfigVoiceManager.removeAll(voice) + } + + fun addVoice(voice: Voice) { + ConfigVoiceManager.add(voice) + + } + + fun sortByName() { + ConfigVoiceManager.reset(voices.sortedBy { it.name }) + } + + fun sortByModel() { + ConfigVoiceManager.reset(voices.sortedBy { it.model + it.id }) + } + + fun updateVoice(voice: Voice) { + ConfigVoiceManager.update(voice) + } + + fun isModelAvailable(voice: Voice): Boolean { + return ConfigModelManager.models().any { it.id == voice.model } + } + + fun unselect(voice: Voice) { + selects.remove(voice) + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/AppDialog.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/AppDialog.kt new file mode 100644 index 000000000..393dfc348 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/AppDialog.kt @@ -0,0 +1,229 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.widgets + + import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import kotlin.math.max + +@Preview +@Composable +fun PreviewAppDialog() { + var show by remember { mutableStateOf(true) } + if (show) { + AppDialog(title = { + Text("Title") + }, content = { + Column(Modifier.verticalScroll(rememberScrollState())) { + for (i in 0..50) { + Text("Content") + } + } + }, buttons = { + TextButton(onClick = { + show = false + }) { + Text("Cancel") + } + TextButton(onClick = { + show = false + }) { + Text("OK") + } + }, onDismissRequest = { + show = false + }) + } + +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppDialog( + modifier: Modifier = Modifier, + onDismissRequest: () -> Unit, + properties: DialogProperties = DialogProperties(), + title: @Composable () -> Unit, + content: @Composable BoxScope.() -> Unit, + dialogContentPadding: PaddingValues = PaddingValues(12.dp), + buttons: @Composable BoxScope.() -> Unit = { + TextButton(onClick = onDismissRequest) { Text(stringResource(id = android.R.string.cancel)) } + }, +) = BasicAlertDialog( + modifier = modifier, + onDismissRequest = onDismissRequest, + properties = properties +) { + Surface( + tonalElevation = 8.dp, shadowElevation = 8.dp, shape = MaterialTheme.shapes.extraLarge + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(dialogContentPadding), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box(modifier = Modifier.align(Alignment.CenterHorizontally)) { + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.titleLarge) { + title() + } + } + + Box( + Modifier + .weight(weight = 1f, fill = false) + .align(Alignment.Start) + .padding(vertical = 8.dp) + ) { + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.titleMedium) { + content() + } + } + + + Box(modifier = Modifier.align(Alignment.End)) { + AppDialogFlowRow( + mainAxisSpacing = ButtonsMainAxisSpacing, + crossAxisSpacing = ButtonsCrossAxisSpacing + ) { + buttons() + } + } + } + } +} + +@Composable +fun CancelButton(modifier: Modifier = Modifier, onClick: () -> Unit) { + TextButton(modifier = modifier, onClick = onClick) { + Text(stringResource(id = android.R.string.cancel)) + } +} + +@Composable +fun OkButton(modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { + TextButton(enabled = enabled, modifier = modifier, onClick = onClick) { + Text(stringResource(id = android.R.string.ok)) + } +} + + +@Composable +internal fun AppDialogFlowRow( + mainAxisSpacing: Dp, crossAxisSpacing: Dp, content: @Composable () -> Unit +) { + Layout(content) { measurables, constraints -> + val sequences = mutableListOf>() + val crossAxisSizes = mutableListOf() + val crossAxisPositions = mutableListOf() + + var mainAxisSpace = 0 + var crossAxisSpace = 0 + + val currentSequence = mutableListOf() + var currentMainAxisSize = 0 + var currentCrossAxisSize = 0 + + // Return whether the placeable can be added to the current sequence. + fun canAddToCurrentSequence(placeable: Placeable) = + currentSequence.isEmpty() || currentMainAxisSize + mainAxisSpacing.roundToPx() + placeable.width <= constraints.maxWidth + + // Store current sequence information and start a new sequence. + fun startNewSequence() { + if (sequences.isNotEmpty()) { + crossAxisSpace += crossAxisSpacing.roundToPx() + } + // Ensures that confirming actions appear above dismissive actions. + sequences.add(0, currentSequence.toList()) + crossAxisSizes += currentCrossAxisSize + crossAxisPositions += crossAxisSpace + + crossAxisSpace += currentCrossAxisSize + mainAxisSpace = max(mainAxisSpace, currentMainAxisSize) + + currentSequence.clear() + currentMainAxisSize = 0 + currentCrossAxisSize = 0 + } + + for (measurable in measurables) { + // Ask the child for its preferred size. + val placeable = measurable.measure(constraints) + + // Start a new sequence if there is not enough space. + if (!canAddToCurrentSequence(placeable)) startNewSequence() + + // Add the child to the current sequence. + if (currentSequence.isNotEmpty()) { + currentMainAxisSize += mainAxisSpacing.roundToPx() + } + currentSequence.add(placeable) + currentMainAxisSize += placeable.width + currentCrossAxisSize = max(currentCrossAxisSize, placeable.height) + } + + if (currentSequence.isNotEmpty()) startNewSequence() + + val mainAxisLayoutSize = max(mainAxisSpace, constraints.minWidth) + + val crossAxisLayoutSize = max(crossAxisSpace, constraints.minHeight) + + val layoutWidth = mainAxisLayoutSize + + val layoutHeight = crossAxisLayoutSize + + layout(layoutWidth, layoutHeight) { + sequences.forEachIndexed { i, placeables -> + val childrenMainAxisSizes = IntArray(placeables.size) { j -> + placeables[j].width + if (j < placeables.lastIndex) mainAxisSpacing.roundToPx() else 0 + } + val arrangement = Arrangement.End + val mainAxisPositions = IntArray(childrenMainAxisSizes.size) { 0 } + with(arrangement) { + arrange( + mainAxisLayoutSize, + childrenMainAxisSizes, + layoutDirection, + mainAxisPositions + ) + } + placeables.forEachIndexed { j, placeable -> + placeable.place( + x = mainAxisPositions[j], y = crossAxisPositions[i] + ) + } + } + } + } +} + +private val ButtonsMainAxisSpacing = 8.dp +private val ButtonsCrossAxisSpacing = 12.dp \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/AppSelectionDialog.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/AppSelectionDialog.kt new file mode 100644 index 000000000..6159bf986 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/AppSelectionDialog.kt @@ -0,0 +1,163 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.widgets + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.utils.ClipboardUtils +import com.k2fsa.sherpa.onnx.tts.engine.utils.clickableRipple +import com.k2fsa.sherpa.onnx.tts.engine.utils.performLongPress +import com.k2fsa.sherpa.onnx.tts.engine.utils.toast +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive + +@Composable +fun AppSelectionDialog( + onDismissRequest: () -> Unit, title: @Composable () -> Unit, + value: Any, + values: List, + entries: List, + isLoading: Boolean = false, + searchEnabled: Boolean = values.size > 5, + + itemContent: @Composable RowScope.(Boolean, String, Any) -> Unit = { isSelected, entry, _ -> + Text( + entry, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(8.dp), + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + ) + }, + + buttons: @Composable BoxScope.() -> Unit = { + TextButton(onClick = onDismissRequest) { Text(stringResource(id = android.R.string.cancel)) } + }, + + onValueSame: (Any, Any) -> Boolean = { a, b -> a == b }, + onClick: (Any, String) -> Unit, +) { + val context = LocalContext.current + val view = LocalView.current + AppDialog( + title = title, + content = { + val state = rememberLazyListState() + LaunchedEffect(values) { + val index = values.indexOfFirst { onValueSame(it, value) } + if (index >= 0 && index < entries.size) + state.scrollToItem(index) + } + Column(modifier = Modifier.fillMaxWidth()) { + var searchText by rememberSaveable { mutableStateOf("") } + + if (searchEnabled) { + val keyboardController = LocalSoftwareKeyboardController.current + + var text by rememberSaveable { mutableStateOf("") } + DenseOutlinedField( + modifier = Modifier.align(Alignment.CenterHorizontally), + value = text, onValueChange = { text = it }, + label = { Text(stringResource(id = R.string.search)) }, + maxLines = 1, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { keyboardController?.hide() } + ) + ) + + LaunchedEffect(Unit) { + while (coroutineContext.isActive) { + delay(500) + searchText = text + } + } + } + + val isEmpty by remember { + derivedStateOf { state.layoutInfo.viewportSize == IntSize.Zero } + } + + if (searchText.isNotBlank() && isEmpty) + Text( + modifier = Modifier + .padding(horizontal = 8.dp, vertical = 4.dp) + .minimumInteractiveComponentSize() + .align(Alignment.CenterHorizontally), + text = stringResource(id = R.string.list_is_empty), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) + + LoadingContent( + modifier = Modifier.padding(vertical = 16.dp), + isLoading = isLoading + ) { + LazyColumn(state = state) { + itemsIndexed(entries) { i, entry -> + if (searchEnabled && searchText.isNotBlank() && + !entry.contains(searchText, ignoreCase = true)) return@itemsIndexed + + val current = values[i] + val isSelected = onValueSame(value, current) + Row( + Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .background(if (isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Unspecified) + .clickableRipple( + onClick = { onClick(current, entry) }, + onLongClick = { + view.performLongPress() + ClipboardUtils.copyText(entry) + context.toast(R.string.copied) + } + ) + .minimumInteractiveComponentSize(), + ) { + itemContent(isSelected, entry, value) + } + + } + } + } + } + }, + buttons = buttons, onDismissRequest = onDismissRequest, + ) +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/AppSelectionToolBar.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/AppSelectionToolBar.kt new file mode 100644 index 000000000..5699b57bf --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/AppSelectionToolBar.kt @@ -0,0 +1,74 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.widgets + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Deselect +import androidx.compose.material.icons.filled.SelectAll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.ui.res.stringResource +import com.k2fsa.sherpa.onnx.tts.engine.R + +class SelectionToolBarState( + val onSelectAll: () -> Unit, val onSelectInvert: () -> Unit, + val onSelectClear: () -> Unit +) { + internal val selectedCount: MutableState = mutableIntStateOf(0) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun InternalSelectionBar( + state: SelectionToolBarState, + actions: @Composable RowScope.() -> Unit, +) { + BackHandler { + state.onSelectClear() + } + + TopAppBar( + navigationIcon = { + IconButton(onClick = state.onSelectClear) { + Icon(Icons.Default.Close, contentDescription = stringResource(id = R.string.close)) + } + }, + title = { Text(state.selectedCount.value.toString()) }, actions = { + AppTooltip(tooltip = stringResource(android.R.string.selectAll)) { + IconButton(onClick = state.onSelectAll) { + Icon(Icons.Default.SelectAll, it) + } + } + + AppTooltip(tooltip = stringResource(id = R.string.invert_select)) { + IconButton(onClick = state.onSelectInvert) { + Icon(Icons.Default.Deselect, it) + } + } + actions() + } + ) +} + +@Composable +fun AppSelectionToolBar( + state: SelectionToolBarState, + mainBar: @Composable () -> Unit, + selectionActions: @Composable RowScope.() -> Unit +) { + Crossfade(targetState = state.selectedCount.value > 0, label = "") { selectMode -> + if (selectMode) { + InternalSelectionBar(state, actions = selectionActions) + } else { + mainBar() + } + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/AppSpinner.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/AppSpinner.kt new file mode 100644 index 000000000..ecab70f86 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/AppSpinner.kt @@ -0,0 +1,183 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.widgets + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalTextInputService +import androidx.compose.ui.platform.LocalTextToolbar +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import kotlin.math.max + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TextFieldSelectionDialog( + modifier: Modifier, + + label: @Composable () -> Unit = {}, + leadingIcon: @Composable (() -> Unit)? = null, + + value: Any, + values: List, + entries: List, + enabled: Boolean = true, + + onSelectedChange: (key: Any, value: String) -> Unit, + onValueSame: (current: Any, new: Any) -> Boolean = { current, new -> current == new }, +) { + val selectedText = entries.getOrNull(max(0, values.indexOf(value))) ?: "" + var expanded by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(values, entries) { + values.getOrNull(entries.indexOf(selectedText))?.let { + onSelectedChange.invoke(it, selectedText) + } + } + if (expanded) { + AppSelectionDialog( + onDismissRequest = { expanded = false }, + title = label, + value = value, + values = values, + entries = entries, + onClick = { v, entry -> + onSelectedChange.invoke(v, entry) + expanded = false + }, + onValueSame = onValueSame, + ) + } + + Box( + modifier = modifier + .clickable( + enabled = enabled, + role = Role.DropdownList, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { expanded = !expanded } + ) { + CompositionLocalProvider( + LocalTextInputService provides null, + LocalTextToolbar provides EmptyTextToolbar, + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + enabled = false, + colors = if (enabled) OutlinedTextFieldDefaults.colors( + disabledContainerColor = Color.Transparent, + disabledTextColor = MaterialTheme.colorScheme.onSurface, + disabledLabelColor = MaterialTheme.colorScheme.onSurface, + disabledPlaceholderColor = MaterialTheme.colorScheme.onSurface, + + disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + + disabledBorderColor = if (expanded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, + + disabledPrefixColor = MaterialTheme.colorScheme.onSurface, + disabledSuffixColor = MaterialTheme.colorScheme.onSurface, + ) + else + OutlinedTextFieldDefaults.colors(), + + leadingIcon = leadingIcon, + readOnly = true, + value = selectedText, + onValueChange = { }, + label = label, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + ) + } + } +} + +@Composable +fun AppSpinner( + modifier: Modifier = Modifier, + label: @Composable (() -> Unit), + leadingIcon: @Composable (() -> Unit)? = null, + + value: Any, + values: List, + entries: List, + maxDropDownCount: Int = 3, + enabled: Boolean = true, + + onValueSame: (current: Any, new: Any) -> Boolean = { current, new -> current == new }, + onSelectedChange: (key: Any, value: String) -> Unit, +) { + if (values.isNotEmpty() && !values.contains(value)) { + onSelectedChange.invoke(values[0], entries[0]) + } + + if (maxDropDownCount > 0 && values.size > maxDropDownCount) { + TextFieldSelectionDialog( + modifier = modifier, + label = label, + leadingIcon = leadingIcon, + value = value, + values = values, + entries = entries, + enabled = enabled, + onValueSame = onValueSame, + onSelectedChange = onSelectedChange, + ) + } else + DropdownTextField( + modifier = modifier, + label = label, + leadingIcon = leadingIcon, + value = value, + values = values, + entries = entries, + enabled = enabled, + onSelectedChange = onSelectedChange, + onValueSame = onValueSame, + ) + +// LaunchedEffect(keys) { +// keys.getOrNull(values.indexOf(selectedText))?.let { +// onSelectedChange.invoke(it, selectedText) +// } +// } + + +} + + +@Preview +@Composable +private fun ExposedDropTextFieldPreview() { + var key by remember { mutableIntStateOf(1) } + val list = 0.rangeTo(10).toList() + AppSpinner( + label = { Text("所属分组") }, + value = key, + values = list, + entries = list.map { it.toString() }, + maxDropDownCount = 2 + ) { k, _ -> + key = k as Int + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/AppTooltip.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/AppTooltip.kt new file mode 100644 index 000000000..8a8c57c8b --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/AppTooltip.kt @@ -0,0 +1,38 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.widgets + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import com.k2fsa.sherpa.onnx.tts.engine.utils.performLongPress + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppTooltip( + modifier: Modifier = Modifier, + tooltip: String, + content: @Composable (tooltip: String) -> Unit +) { + val state = rememberTooltipState() + TooltipBox( + modifier = modifier, + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { + PlainTooltip { + val view = LocalView.current + LaunchedEffect(key1 = Unit) { + view.performLongPress() + } + Text(tooltip) + } + }, + state = state, + content = { content(tooltip) }, + ) +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/DeleteForeverIcon.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/DeleteForeverIcon.kt new file mode 100644 index 000000000..d8859028a --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/DeleteForeverIcon.kt @@ -0,0 +1,24 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.widgets + +import android.annotation.SuppressLint +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.k2fsa.sherpa.onnx.tts.engine.R + +@Composable +fun DeleteForeverIcon( + contentDescription: String? = stringResource(id = R.string.delete), + @SuppressLint("ModifierParameter") modifier: Modifier = Modifier, +) { + Icon( + modifier = modifier, + imageVector = Icons.Default.DeleteForever, + tint = MaterialTheme.colorScheme.error, + contentDescription = contentDescription, + ) +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/DeleteMenuItem.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/DeleteMenuItem.kt new file mode 100644 index 000000000..d1a372a36 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/DeleteMenuItem.kt @@ -0,0 +1,24 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.widgets + +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.k2fsa.sherpa.onnx.tts.engine.R + +@Composable +fun DeleteMenuItem(modifier: Modifier = Modifier, onClick: () -> Unit) { + DropdownMenuItem( + modifier = modifier, + text = { + Text( + stringResource(id = R.string.delete), + color = MaterialTheme.colorScheme.error + ) + }, + leadingIcon = { DeleteForeverIcon(null) }, + onClick = onClick + ) +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/DenseTextField.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/DenseTextField.kt new file mode 100644 index 000000000..eee501386 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/DenseTextField.kt @@ -0,0 +1,243 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.widgets + +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp + + +@Composable +private fun textColor( + enabled: Boolean, + isError: Boolean, + interactionSource: InteractionSource +): State { + val focused by interactionSource.collectIsFocusedAsState() + + val targetValue = when { + !enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.32f) + isError -> MaterialTheme.colorScheme.error + focused -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.95f) + } + return rememberUpdatedState(targetValue) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DenseOutlinedField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = OutlinedTextFieldDefaults.shape, + colors: TextFieldColors = OutlinedTextFieldDefaults.colors() +) { + // If color is not provided via the text style, use content color as a default + val textColor = textStyle.color.takeOrElse { + textColor(enabled, isError, interactionSource).value + } + val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) + + CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) { + BasicTextField( + value = value, + modifier = if (label != null) { + modifier + // Merge semantics at the beginning of the modifier chain to ensure padding is + // considered part of the text field. + .semantics(mergeDescendants = true) {} + .padding(top = 8.dp) + } else { + modifier + }, +// .defaultMinSize( +// minWidth = OutlinedTextFieldDefaults.MinWidth, +// minHeight = OutlinedTextFieldDefaults.MinHeight +// ), + onValueChange = onValueChange, + enabled = enabled, + readOnly = readOnly, + textStyle = mergedTextStyle, + cursorBrush = SolidColor(if (isError) colors.errorCursorColor else colors.cursorColor), + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + interactionSource = interactionSource, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + decorationBox = @Composable { innerTextField -> + OutlinedTextFieldDefaults.DecorationBox( + value = value, + visualTransformation = visualTransformation, + innerTextField = innerTextField, + placeholder = placeholder, + label = label, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + singleLine = singleLine, + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors, + container = { + OutlinedTextFieldDefaults.ContainerBox( + enabled, + isError, + interactionSource, + colors, + shape + ) + }, + contentPadding = OutlinedTextFieldDefaults.contentPadding( + start = 12.dp, top = 10.dp, end = 12.dp, bottom = 10.dp, + ) + ) + } + ) + } +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DenseTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors() +) { + // If color is not provided via the text style, use content color as a default + val textColor = textStyle.color.takeOrElse { + textColor(enabled, isError, interactionSource).value + } + val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) + +// CompositionLocalProvider(LocalTextSelectionColors provides colors.selectionColors) { + BasicTextField( + value = value, + modifier = if (label != null) { + modifier + // Merge semantics at the beginning of the modifier chain to ensure padding is + // considered part of the text field. + .semantics(mergeDescendants = true) {} + .padding(top = 8.dp) + } else { + modifier + }, +// .defaultMinSize( +// minWidth = OutlinedTextFieldDefaults.MinWidth, +// minHeight = OutlinedTextFieldDefaults.MinHeight +// ), + onValueChange = onValueChange, + enabled = enabled, + readOnly = readOnly, + textStyle = mergedTextStyle, +// cursorBrush = SolidColor(colors.cursorColor(isError).value), + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + interactionSource = interactionSource, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + decorationBox = @Composable { innerTextField -> + TextFieldDefaults.DecorationBox( + value = value, + visualTransformation = visualTransformation, + innerTextField = innerTextField, + placeholder = placeholder, + label = label, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + singleLine = singleLine, + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors, + container = { + TextFieldDefaults.ContainerBox( + enabled, + isError, + interactionSource, + colors, + shape + ) + }, + contentPadding = PaddingValues( + start = 12.dp, top = 10.dp, end = 12.dp, bottom = 10.dp, + ) + ) + } + ) +// } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/DropdownTextField.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/DropdownTextField.kt new file mode 100644 index 000000000..d78402cd7 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/DropdownTextField.kt @@ -0,0 +1,114 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.widgets + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalTextInputService +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import kotlin.math.max + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun DropdownTextField( + modifier: Modifier = Modifier, + label: @Composable() (() -> Unit), + value: Any, + values: List, + entries: List, + enabled: Boolean = true, + leadingIcon: @Composable (() -> Unit)? = null, + onValueSame: (current: Any, new: Any) -> Boolean = { current, new -> current == new }, + onSelectedChange: (value: Any, entry: String) -> Unit, +) { + var selectedText = entries.getOrNull(max(0, values.indexOf(value))) ?: "" + var expanded by remember { mutableStateOf(false) } + + LaunchedEffect(values, entries) { + values.getOrNull(entries.indexOf(selectedText))?.let { + onSelectedChange.invoke(it, selectedText) + } + } + + CompositionLocalProvider( + LocalTextInputService provides null // Disable Keyboard + ) { + ExposedDropdownMenuBox( + modifier = modifier, + expanded = expanded, + onExpandedChange = { + if (enabled) expanded = !expanded + }, + ) { + OutlinedTextField( + modifier = Modifier + .menuAnchor() + .fillMaxWidth(), + leadingIcon = leadingIcon, + readOnly = true, + enabled = enabled, + value = selectedText, + onValueChange = { }, + label = label, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + entries.forEachIndexed { index, text -> + val checked = onValueSame(value, values[index]) + DropdownMenuItem( + text = { + Text( + text, + fontWeight = if (checked) FontWeight.Bold else FontWeight.Normal + ) + }, + onClick = { + expanded = false + selectedText = text + onSelectedChange.invoke(values[index], text) + }, modifier = Modifier.background( + if (checked) MaterialTheme.colorScheme.secondaryContainer + else Color.Unspecified + ) + ) + } + } + } + } +} + + +@Preview +@Composable +private fun PreviewDropdownTextField() { + var key by remember { mutableIntStateOf(1) } + AppSpinner( + label = { Text("所属分组") }, + value = key, + values = listOf(1, 2, 3), + entries = listOf("1", "2", "3"), + ) { k, _ -> + key = k as Int + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/EmptyTextToolbar.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/EmptyTextToolbar.kt new file mode 100644 index 000000000..4d9f667db --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/EmptyTextToolbar.kt @@ -0,0 +1,20 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.widgets + +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.platform.TextToolbar +import androidx.compose.ui.platform.TextToolbarStatus + +object EmptyTextToolbar : TextToolbar { + override val status: TextToolbarStatus = TextToolbarStatus.Hidden + + override fun hide() {} + + override fun showMenu( + rect: Rect, + onCopyRequested: (() -> Unit)?, + onPasteRequested: (() -> Unit)?, + onCutRequested: (() -> Unit)?, + onSelectAllRequested: (() -> Unit)?, + ) { + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/LabelSlider.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/LabelSlider.kt new file mode 100644 index 000000000..07213ba98 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/LabelSlider.kt @@ -0,0 +1,222 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.widgets + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.Icon +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.invisibleToUser +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.utils.performLongPress + +@Composable +fun IntSlider( + modifier: Modifier = Modifier, + enabled: Boolean = true, + label: String, + value: Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange +) { + LabelSlider( + modifier = modifier, + enabled = enabled, + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + text = label, + buttonSteps = 1f, + buttonLongSteps = 10f + ) +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun LabelSlider( + modifier: Modifier = Modifier, + enabled: Boolean = true, + value: Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange = 0f..1f, + steps: Int = 0, + onValueChangeFinished: (() -> Unit)? = null, + + showButton: Boolean = true, + buttonSteps: Float = 0.01f, + buttonLongSteps: Float = 0.1f, + + valueChange: (Float) -> Unit = { + if (it < valueRange.start) onValueChange(valueRange.start) + else if (it > valueRange.endInclusive) onValueChange(valueRange.endInclusive) + else onValueChange(it) + }, + + onValueRemove: (longClick: Boolean) -> Unit = { + valueChange(value - (if (it) buttonLongSteps else buttonSteps)) + }, + onValueAdd: (longClick: Boolean) -> Unit = { + valueChange(value + if (it) buttonLongSteps else buttonSteps) + }, + + text: String, +) { + LabelSlider( + modifier = modifier, + enabled = enabled, + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + steps = steps, + onValueChangeFinished = onValueChangeFinished, + showButton = showButton, + buttonSteps = buttonSteps, + buttonLongSteps = buttonLongSteps, + valueChange = valueChange, + onValueRemove = onValueRemove, + onValueAdd = onValueAdd, + a11yDescription = text, + ) { + Text(text = text, modifier = Modifier.semantics { invisibleToUser() }) + } +} + +@Composable +fun LabelSlider( + modifier: Modifier = Modifier, + enabled: Boolean = true, + value: Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange = 0f..1f, + steps: Int = 0, + onValueChangeFinished: (() -> Unit)? = null, + + showButton: Boolean = enabled, + buttonSteps: Float = 0.01f, + buttonLongSteps: Float = 0.1f, + + valueChange: (Float) -> Unit = { + if (it < valueRange.start) onValueChange(valueRange.start) + else if (it > valueRange.endInclusive) onValueChange(valueRange.endInclusive) + else onValueChange(it) + }, + + onValueRemove: (longClick: Boolean) -> Unit = { + valueChange(value - (if (it) buttonLongSteps else buttonSteps)) + }, + onValueAdd: (longClick: Boolean) -> Unit = { + valueChange(value + if (it) buttonLongSteps else buttonSteps) + }, + + a11yDescription: String = "", + text: @Composable BoxScope.() -> Unit, +) { + val view = LocalView.current + ConstraintLayout(modifier) { + val (textRef, sliderRef) = createRefs() + Box( + modifier = Modifier + .constrainAs(textRef) { + start.linkTo(parent.start) + top.linkTo(parent.top) + end.linkTo(parent.end) + } + .wrapContentHeight() + ) { + text() + } + Row(Modifier.constrainAs(sliderRef) { + start.linkTo(parent.start) + end.linkTo(parent.end) + top.linkTo(textRef.bottom, margin = (-12).dp) + }) { + if (showButton) + LongClickIconButton( + onClick = { + onValueRemove(false) + }, + onLongClick = { + onValueRemove(true) + }, + enabled = enabled && value > valueRange.start, + modifier = Modifier + .semantics { + contentDescription = a11yDescription + } + ) { + Icon(Icons.Default.Remove, stringResource(id = R.string.desc_seekbar_remove)) + } + Slider( + modifier = Modifier + .weight(1f) + .semantics { + stateDescription = a11yDescription + contentDescription = a11yDescription + }, + value = value, + onValueChange = { + onValueChange(it) + + if (it == valueRange.start || it == valueRange.endInclusive) + view.performLongPress() + }, + enabled = enabled, + valueRange = valueRange, + steps = steps, + onValueChangeFinished = onValueChangeFinished + ) + if (showButton) + LongClickIconButton( + onClick = { + onValueAdd(false) + }, + onLongClick = { + onValueAdd(true) + }, + enabled = enabled && value < valueRange.endInclusive, + modifier = Modifier + .semantics { + contentDescription = a11yDescription + } + ) { + Icon(Icons.Default.Add, stringResource(id = R.string.desc_seekbar_add)) + } + + } + } +} + +@Preview +@Composable +fun PreviewSlider() { + var value by remember { mutableFloatStateOf(0f) } + val str = "语速: $value" + LabelSlider( + value = value, + onValueChange = { value = it }, + valueRange = 0.1f..3.0f, + a11yDescription = str, + buttonSteps = 0.1f + ) { + Text(str) + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/LoadingContent.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/LoadingContent.kt new file mode 100644 index 000000000..e3a9ba5df --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/LoadingContent.kt @@ -0,0 +1,71 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.widgets + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.k2fsa.sherpa.onnx.tts.engine.R +import kotlinx.coroutines.delay + +@Composable +fun LoadingContent( + modifier: Modifier = Modifier, + isLoading: Boolean, + content: @Composable BoxScope.() -> Unit +) { + val context = LocalContext.current + Box(modifier) { + Box( + Modifier + .wrapContentSize() + .alpha(if (isLoading) 0.2f else 1f) + ) { + content() + } + + AnimatedVisibility( + visible = isLoading, modifier = Modifier + .size(64.dp) + .align(Alignment.Center) + ) { + CircularProgressIndicator(modifier = Modifier.semantics { + stateDescription = context.getString(R.string.loading) + }, strokeWidth = 8.dp) + } + } +} + +@Preview +@Composable +fun PreviewLoadingContent() { + MaterialTheme { + var loading by remember { mutableStateOf(true) } + LaunchedEffect(Unit) { + delay(3000) + loading = false + } + + LoadingContent(Modifier, loading) { + OutlinedTextField(value = "hello", onValueChange = {}, label = { Text("Label") }) + } + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/LongClickIconButton.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/LongClickIconButton.kt new file mode 100644 index 000000000..5ca962dfe --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/LongClickIconButton.kt @@ -0,0 +1,82 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.widgets + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.IconButtonColors +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.k2fsa.sherpa.onnx.tts.engine.utils.performLongPress + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LongClickIconButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, + onLongClick: () -> Unit, + onLongClickLabel: String? = null, + enabled: Boolean = true, + colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable () -> Unit +) { + val stateLayerSize = 40.0.dp + val context = LocalContext.current + val view = LocalView.current + Box( + modifier = modifier + .minimumInteractiveComponentSize() + .size(stateLayerSize) + .clip(CircleShape) + .background(color = colors.mContainerColor(enabled).value) + .combinedClickable( + onClick = onClick, + onLongClick = { + view.performLongPress() + onLongClick() + }, + onLongClickLabel = onLongClickLabel, + enabled = enabled, + role = Role.Button, + interactionSource = interactionSource, + indication = rememberRipple( + bounded = false, + radius = stateLayerSize / 2 + ) + ), + contentAlignment = Alignment.Center + ) { + val contentColor = colors.mContentColor(enabled).value + CompositionLocalProvider(LocalContentColor provides contentColor, content = content) + content() + } +} + +@Composable +internal fun IconButtonColors.mContainerColor(enabled: Boolean): State { + return rememberUpdatedState(if (enabled) containerColor else disabledContainerColor) +} + +@Composable +internal fun IconButtonColors.mContentColor(enabled: Boolean): State { + return rememberUpdatedState(if (enabled) contentColor else disabledContentColor) +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/SearchTextFieldInList.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/SearchTextFieldInList.kt new file mode 100644 index 000000000..51e277fad --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/SearchTextFieldInList.kt @@ -0,0 +1,50 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.widgets + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import com.k2fsa.sherpa.onnx.tts.engine.R +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive + +@Composable +fun SearchTextFieldInList( + modifier: Modifier, + onSearch: (String) -> Unit +) { + val keyboardController = LocalSoftwareKeyboardController.current + + var search by rememberSaveable { mutableStateOf("") } + + var text by rememberSaveable { mutableStateOf("") } + DenseOutlinedField( + modifier = modifier, + value = text, onValueChange = { text = it }, + label = { Text(stringResource(id = R.string.search)) }, + maxLines = 1, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { keyboardController?.hide() } + ) + ) + + LaunchedEffect(Unit) { + while (coroutineContext.isActive) { + delay(500) + if (text != search) { + search = text + onSearch(search) + } + } + } +} diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/SelectableCard.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/SelectableCard.kt new file mode 100644 index 000000000..de0d37ae2 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/SelectableCard.kt @@ -0,0 +1,70 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.widgets + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.unit.dp +import com.k2fsa.sherpa.onnx.tts.engine.R + +@Composable +fun VerticalBar( + modifier: Modifier = Modifier, + enabled: Boolean, + color: Color = MaterialTheme.colorScheme.primary +) { + val context = LocalContext.current + Box( + modifier = modifier + .background( + color = if (enabled) color else Color.Transparent, + shape = MaterialTheme.shapes.small + ) + .width(4.dp) + .height(32.dp) + .semantics { + this.stateDescription = if (enabled) context.getString(R.string.enabled) + else "" + } + ) +} + +@Composable +fun SelectableCard( + name: String, + selected: Boolean, + modifier: Modifier, + content: @Composable() (ColumnScope.() -> Unit) +) { + val context = LocalContext.current + val color = + if (selected) MaterialTheme.colorScheme.primary else Color.Unspecified + Card( + modifier = modifier + .semantics { + this.stateDescription = "$name ${if (!selected) "not" else ""} selected" + this.selected = selected + }, + colors = if (selected) CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy( + alpha = 0.5f + ) + ) + else CardDefaults.elevatedCardColors(), + border = if (selected) BorderStroke(1.dp, MaterialTheme.colorScheme.primary) else null, + content = content + ) +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/TextCheckBox.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/TextCheckBox.kt new file mode 100644 index 000000000..3f51f68dd --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/TextCheckBox.kt @@ -0,0 +1,44 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxColors +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.k2fsa.sherpa.onnx.tts.engine.utils.clickableRipple + +@Composable +fun TextCheckBox( + modifier: Modifier = Modifier, + text: @Composable RowScope.() -> Unit, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + colors: CheckboxColors = CheckboxDefaults.colors(), + + horizontalArrangement: Arrangement.Horizontal = Arrangement.Center, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, +) { + Row( + modifier + .height(48.dp) + .clip(MaterialTheme.shapes.small) + .clickableRipple(role = Role.Checkbox) { onCheckedChange(!checked) }, + verticalAlignment = verticalAlignment, + horizontalArrangement = horizontalArrangement, + ) { + Row(Modifier.padding(horizontal = 8.dp)) { + Checkbox(colors = colors, checked = checked, onCheckedChange = null) + text() + } + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/TextFieldDialog.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/TextFieldDialog.kt new file mode 100644 index 000000000..3653c8280 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/TextFieldDialog.kt @@ -0,0 +1,44 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.widgets + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource + +@Composable +fun TextFieldDialog( + title: String, + initialText: String, + onDismissRequest: () -> Unit, + onConfirm: (String) -> Unit +) { + var textValue by rememberSaveable { mutableStateOf(initialText) } + AlertDialog(onDismissRequest = onDismissRequest, + title = { + Text(title) + }, + text = { + OutlinedTextField( + value = textValue, onValueChange = { textValue = it }, + ) + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(id = android.R.string.cancel)) + } + }, + confirmButton = { + TextButton(onClick = { + onConfirm(textValue) + }) { + Text(stringResource(id = android.R.string.ok)) + } + } + ) +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/Widgets.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/Widgets.kt new file mode 100644 index 000000000..073c4d8a9 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/ui/widgets/Widgets.kt @@ -0,0 +1,84 @@ +package com.k2fsa.sherpa.onnx.tts.engine.ui.widgets + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import com.google.accompanist.systemuicontroller.rememberSystemUiController + + +@Composable +fun SetupSystemBars() { + val systemUiController = rememberSystemUiController() + val useDarkIcons = !isSystemInDarkTheme() + SideEffect { + systemUiController.setSystemBarsColor( + color = Color.Transparent, + darkIcons = useDarkIcons, + ) + } +} + +@Composable +fun BasicBroadcastReceiver( + intentFilter: IntentFilter, + onReceive: (Intent?) -> Unit, + onRegister: (BroadcastReceiver, Context) -> Unit, + onUnregister: (BroadcastReceiver, Context) -> Unit +) { + val context = LocalContext.current + val currentReceive by rememberUpdatedState(onReceive) + + DisposableEffect(context, intentFilter) { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + currentReceive(intent) + } + } + onRegister(receiver, context) + + onDispose { + onUnregister(receiver, context) + } + } +} + +/*@Composable +fun LocalBroadcastReceiver(intentFilter: IntentFilter, onReceive: (Intent?) -> Unit) { + BasicBroadcastReceiver( + intentFilter, + onReceive, + { obj, context -> + LocalBroadcastManager.getInstance(context).registerReceiver(obj, intentFilter) + }, + { obj, context -> LocalBroadcastManager.getInstance(context).unregisterReceiver(obj) } + ) +}*/ + +@Composable +fun SystemBroadcastReceiver( + intentFilter: IntentFilter, + onSystemEvent: (intent: Intent?) -> Unit +) { + BasicBroadcastReceiver( + intentFilter = intentFilter, onReceive = onSystemEvent, + onRegister = { obj, context -> + ContextCompat.registerReceiver( + context, + obj, + intentFilter, + ContextCompat.RECEIVER_EXPORTED + ) + }, + onUnregister = { obj, context -> context.unregisterReceiver(obj) } + ) +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/ClipBoardUtils.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/ClipBoardUtils.kt new file mode 100644 index 000000000..b9049596e --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/ClipBoardUtils.kt @@ -0,0 +1,96 @@ +package com.k2fsa.sherpa.onnx.tts.engine.utils + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.ClipboardManager.OnPrimaryClipChangedListener +import android.content.Context +import com.k2fsa.sherpa.onnx.tts.engine.App + + +/** + *
+ * author: Blankj
+ * blog  : http://blankj.com
+ * time  : 2016/09/25
+ * desc  : utils about clipboard
+
* + */ +object ClipboardUtils { + /** + * Copy the text to clipboard. + * + * The label equals name of package. + * + * @param text The text. + */ + fun copyText(text: CharSequence?) { + val cm = App.instance.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText(App.instance.getPackageName(), text)) + } + + /** + * Copy the text to clipboard. + * + * @param label The label. + * @param text The text. + */ + fun copyText(label: CharSequence?, text: CharSequence?) { + val cm = App.instance.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText(label, text)) + } + + /** + * Clear the clipboard. + */ + fun clear() { + val cm = App.instance.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText(null, "")) + } + + /** + * Return the label for clipboard. + * + * @return the label for clipboard + */ + fun getLabel(): CharSequence { + val cm = App.instance + .getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val des = cm.primaryClipDescription ?: return "" + return des.label ?: return "" + } + + /** + * Return the text for clipboard. + * + * @return the text for clipboard + */ + val text: CharSequence + get() { + val cm = + App.instance.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = cm.primaryClip + if (clip != null && clip.itemCount > 0) { + val text = clip.getItemAt(0).coerceToText(App.instance) + if (text != null) { + return text + } + } + return "" + } + + /** + * Add the clipboard changed listener. + */ + fun addChangedListener(listener: OnPrimaryClipChangedListener?) { + val cm = App.instance.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.addPrimaryClipChangedListener(listener) + } + + /** + * Remove the clipboard changed listener. + */ + fun removeChangedListener(listener: OnPrimaryClipChangedListener?) { + val cm = App.instance.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.removePrimaryClipChangedListener(listener) + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/CompressUtils.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/CompressUtils.kt new file mode 100644 index 000000000..d94067d72 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/CompressUtils.kt @@ -0,0 +1,81 @@ +package com.k2fsa.sherpa.onnx.tts.engine.utils + +import android.util.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import org.apache.commons.compress.archivers.ArchiveEntry +import org.apache.commons.compress.archivers.ArchiveInputStream +import org.apache.commons.io.FileUtils +import java.io.File +import kotlin.coroutines.coroutineContext + + +object CompressUtils { + const val TAG = "CompressUtils" + + fun interface ProgressListener { + fun onEntryProgress(name: String, entrySize: Long, bytes: Long) + } + + suspend fun ArchiveInputStream<*>.uncompress( + outputDir: String, + progressListener: ProgressListener + ) { + createFile(outputDir, "").mkdirs() + var totalBytes = 0L + + var entry: ArchiveEntry + try { + while (nextEntry.also { entry = it } != null) { + totalBytes += entry.size + val file = createFile(outputDir, entry.name) + + if (entry.isDirectory) { + file.mkdirs() + } else { + withContext(Dispatchers.IO) { + if (file.exists()) { + file.delete() + } else { + FileUtils.createParentDirectories(file) + file.createNewFile() + } + } + + file.outputStream().use { out -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytes = 0L + var len = 0 + while (read(buffer).also { len = it } != -1) { + if (!coroutineContext.isActive) { + throw CancellationException() + } + + out.write(buffer, 0, len) + bytes += len + progressListener.onEntryProgress(entry.name, entry.size, bytes) + } + } + } + } + } catch (_: NullPointerException) { + } + } + + + private fun createFile(outputDir: String, name: String): File { + return File(outputDir + File.separator + name) + } + + suspend fun deepGetFiles(file: File, onFile: suspend (File) -> Unit) { + if (file.isDirectory) { + file.listFiles()?.forEach { + deepGetFiles(it, onFile) + } + } else { + onFile(file) + } + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/Compressor.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/Compressor.kt new file mode 100644 index 000000000..5e9422a69 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/Compressor.kt @@ -0,0 +1,157 @@ +package com.k2fsa.sherpa.onnx.tts.engine.utils + +import com.k2fsa.sherpa.onnx.tts.engine.utils.CompressUtils.uncompress +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.isActive +import org.apache.commons.compress.archivers.ArchiveEntry +import org.apache.commons.compress.archivers.ArchiveInputStream +import org.apache.commons.compress.archivers.ArchiveOutputStream +import org.apache.commons.compress.archivers.tar.TarArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry +import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream +import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream +import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream +import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream +import org.apache.commons.compress.compressors.xz.XZCompressorInputStream +import org.apache.commons.compress.compressors.xz.XZCompressorOutputStream +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import kotlin.coroutines.coroutineContext + +object CompressorFactory { + val compressors = listOf( + TarBzip2Compressor(), + ZipCompressor(), + TarGzipCompressor(), + TarXzCompressor() + ) + + fun createCompressor(type: String): CompressorInterface? { + for (compressor in compressors) { + if (compressor.verifyType(type)) { + return compressor + } + } + return null + } +} + +interface CompressorInterface { + fun verifyType(type: String): Boolean + + suspend fun uncompress( + ins: InputStream, + outputDir: String, + progressListener: CompressUtils.ProgressListener + ) + + suspend fun compress( + dir: String, + ous: OutputStream, + progressListener: CompressUtils.ProgressListener + ) +} + +abstract class ImplCompressor(open val extName: List) : + CompressorInterface { + override fun verifyType(type: String): Boolean { + return extName.any { it.lowercase() == type.lowercase() } + } + + abstract fun archiveInputStream(ins: InputStream): ArchiveInputStream<*> + open fun archiveOutputStream(outs: OutputStream): ArchiveOutputStream { + TODO() + } + + override suspend fun uncompress( + ins: InputStream, + outputDir: String, + progressListener: CompressUtils.ProgressListener + ) { + archiveInputStream(ins).use { arIn -> + arIn.uncompress(outputDir, progressListener) + } + } + + override suspend fun compress( + dir: String, ous: OutputStream, progressListener: CompressUtils.ProgressListener + ) { + archiveOutputStream(ous).use { it -> + CompressUtils.deepGetFiles(File(dir)) { file -> + if (!coroutineContext.isActive) throw CancellationException() + + val entry = it.createArchiveEntry(file, file.relativeTo(File(dir)).path)!! + it.putArchiveEntry(entry) + file.inputStream().use { ins -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var len = 0 + var bytes = 0 + while (ins.read(buffer).also { len = it } != -1) { + if (!coroutineContext.isActive) throw CancellationException() + + it.write(buffer, 0, len) + bytes += len + progressListener.onEntryProgress(entry.name, entry.size, bytes.toLong()) + } + } + it.closeArchiveEntry() + } + + } + } +} + +class TarBzip2Compressor : ImplCompressor(listOf("tar.bz2", "tbz2")) { + override fun archiveInputStream(ins: InputStream): ArchiveInputStream<*> = + TarArchiveInputStream(BZip2CompressorInputStream(ins)) +} + +class ZipCompressor : ImplCompressor(listOf("zip")) { + override fun archiveInputStream(ins: InputStream): ArchiveInputStream<*> = + ZipArchiveInputStream(ins) + + override fun archiveOutputStream(outs: OutputStream): ArchiveOutputStream { + return ZipArchiveOutputStream(outs) + } + +// override suspend fun compress(dir: String, ous: OutputStream) { +// val file = File(dir) +// +// ZipArchiveOutputStream(ous).use { zipOus -> +// CompressUtils.deepGetFiles(file) { +// if (!coroutineContext.isActive) throw CancellationException() +// +// val relPath = it.relativeTo(file).path +// Log.e("TAG", it.absolutePath) +// val entry = zipOus.createArchiveEntry(it, relPath) +// entry.size = it.length() +// zipOus.putArchiveEntry(entry) +// it.inputStream().use { ins -> +// ins.copyTo(zipOus) +// } +// zipOus.closeArchiveEntry() +// } +// zipOus.finish() +// } +// } +} + +class TarGzipCompressor : ImplCompressor(listOf("tar.gz", "tgz")) { + override fun archiveInputStream(ins: InputStream): ArchiveInputStream<*> = + TarArchiveInputStream(GzipCompressorInputStream(ins)) + + override fun archiveOutputStream(outs: OutputStream): ArchiveOutputStream = + TarArchiveOutputStream(GzipCompressorOutputStream(outs)) +} + +class TarXzCompressor : ImplCompressor(listOf("tar.xz", "txz")) { + override fun archiveInputStream(ins: InputStream): ArchiveInputStream<*> = + TarArchiveInputStream(XZCompressorInputStream(ins)) + + override fun archiveOutputStream(outs: OutputStream): ArchiveOutputStream = + TarArchiveOutputStream(XZCompressorOutputStream(outs)) +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/ExtensionUtils.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/ExtensionUtils.kt new file mode 100644 index 000000000..7d7b16ff8 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/ExtensionUtils.kt @@ -0,0 +1,406 @@ +package com.k2fsa.sherpa.onnx.tts.engine.utils + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.Notification +import android.app.Service +import android.content.BroadcastReceiver +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ServiceInfo +import android.content.res.Resources +import android.graphics.Rect +import android.graphics.Typeface +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.SystemClock +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.text.style.UnderlineSpan +import android.util.DisplayMetrics +import android.view.HapticFeedbackConstants +import android.view.View +import android.view.WindowInsets +import android.view.WindowManager +import android.view.WindowMetrics +import androidx.activity.OnBackPressedCallback +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.lifecycle.LifecycleOwner +import androidx.navigation.NavController +import androidx.navigation.NavDeepLinkRequest +import androidx.navigation.NavDestination +import androidx.navigation.NavHostController +import androidx.navigation.NavOptions +import androidx.navigation.Navigator + + +@SuppressLint("RestrictedApi") +fun NavController.navigate( + route: String, + argsBuilder: Bundle.() -> Unit = {}, + navOptions: NavOptions? = null, + navigatorExtras: Navigator.Extras? = null +) { + navigate(route, Bundle().apply(argsBuilder), navOptions, navigatorExtras) +} + +/* +* 可传递 Bundle 到 Navigation +* */ +@SuppressLint("RestrictedApi") +fun NavController.navigate( + route: String, + args: Bundle, + navOptions: NavOptions? = null, + navigatorExtras: Navigator.Extras? = null +) { + val routeLink = NavDeepLinkRequest + .Builder + .fromUri(NavDestination.createRoute(route).toUri()) + .build() + + val deepLinkMatch = graph.matchDeepLink(routeLink) + if (deepLinkMatch != null) { + val destination = deepLinkMatch.destination + val id = destination.id + navigate(id, args, navOptions, navigatorExtras) + } else { + navigate(route, navOptions, navigatorExtras) + } +} + +/** + * 单例并清空其他栈 + */ +fun NavHostController.navigateSingleTop( + route: String, + args: Bundle? = null, + popUpToMain: Boolean = false +) { + val navController = this + val navOptions = NavOptions.Builder() + .setLaunchSingleTop(true) + .apply { + if (popUpToMain) setPopUpTo( + navController.graph.startDestinationId, + inclusive = false, + saveState = true + ) + } + .setRestoreState(true) + .build() + if (args == null) + navController.navigate(route, navOptions) + else + navController.navigate(route, args, navOptions) +} + +fun Long.formatFileSize(context: Context): String = + android.text.format.Formatter.formatFileSize(context, this) + +fun FloatArray.toByteArray(): ByteArray { + // byteArray is actually a ShortArray + val byteArray = ByteArray(this.size * 2) + for (i in this.indices) { + val sample = (this[i] * 32767).toInt() + byteArray[2 * i] = sample.toByte() + byteArray[2 * i + 1] = (sample shr 8).toByte() + } + return byteArray +} + +fun Service.startForegroundCompat( + notificationId: Int, + notification: Notification +) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // A14 + startForeground( + notificationId, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + startForeground(notificationId, notification) + } +} + +@Composable +fun Modifier.simpleVerticalScrollbar( + state: LazyListState, + width: Dp = 8.dp, + color: Color = MaterialTheme.colorScheme.secondary +): Modifier { + val targetAlpha = if (state.isScrollInProgress) 1f else 0f + val duration = if (state.isScrollInProgress) 150 else 500 + + val alpha by animateFloatAsState( + targetValue = targetAlpha, + animationSpec = tween(durationMillis = duration), label = "" + ) + + return drawWithContent { + drawContent() + + val firstVisibleElementIndex = state.layoutInfo.visibleItemsInfo.firstOrNull()?.index + val needDrawScrollbar = state.isScrollInProgress || alpha > 0.0f + + // Draw scrollbar if scrolling or if the animation is still running and lazy column has content + if (needDrawScrollbar && firstVisibleElementIndex != null) { + val elementHeight = this.size.height / state.layoutInfo.totalItemsCount + + val scrollbarOffsetY = + firstVisibleElementIndex * elementHeight + state.firstVisibleItemScrollOffset / 4 + +// val scrollbarOffsetY = firstVisibleElementIndex * elementHeight + val scrollbarHeight = state.layoutInfo.visibleItemsInfo.size * elementHeight + + drawRect( + color = color, + topLeft = Offset(this.size.width - width.toPx(), scrollbarOffsetY), + size = Size(width.toPx(), scrollbarHeight), + alpha = alpha + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun Modifier.clickableRipple( + enabled: Boolean = true, + role: Role? = null, + onLongClick: (() -> Unit)? = null, + onLongClickLabel: String? = null, + onClickLabel: String? = null, + onClick: () -> Unit, +) = + this.combinedClickable( + enabled = enabled, + role = role, + indication = rememberRipple(), + interactionSource = remember { MutableInteractionSource() }, + onClickLabel = onClickLabel, + onClick = onClick, + onLongClick = onLongClick, + onLongClickLabel = onLongClickLabel, + ) + +fun Spanned.toAnnotatedString(): AnnotatedString = buildAnnotatedString { + val spanned = this@toAnnotatedString + append(spanned.toString()) + getSpans(0, spanned.length, Any::class.java).forEach { span -> + val start = getSpanStart(span) + val end = getSpanEnd(span) + when (span) { + is StyleSpan -> when (span.style) { + Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) + Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end) + Typeface.BOLD_ITALIC -> addStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Italic + ), start, end + ) + } + + is UnderlineSpan -> addStyle( + SpanStyle(textDecoration = TextDecoration.Underline), + start, + end + ) + + is ForegroundColorSpan -> addStyle( + SpanStyle(color = Color(span.foregroundColor)), + start, + end + ) + } + } +} + +fun Context.registerGlobalReceiver( + actions: List, + receiver: BroadcastReceiver +) { + ContextCompat.registerReceiver(this, receiver, IntentFilter().apply { + actions.forEach { addAction(it) } + }, ContextCompat.RECEIVER_EXPORTED) +} + +fun View.performLongPress() { + this.isHapticFeedbackEnabled = true + this.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) +} + +fun Context.startActivity(clz: Class<*>) { + startActivity(Intent(this, clz).apply { action = Intent.ACTION_VIEW }) +} + +fun Uri.grantReadPermission(contentResolver: ContentResolver) { + contentResolver.takePersistableUriPermission( + this, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) +} + +fun Uri.grantReadWritePermission(contentResolver: ContentResolver) { + contentResolver.takePersistableUriPermission( + this, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) +} + +//fun Intent.getBinder(): IBinder? { +// val bundle = getBundleExtra(KeyConst.KEY_BUNDLE) +// return bundle?.getBinder(KeyConst.KEY_LARGE_DATA_BINDER) +//} +// +//fun Intent.setBinder(binder: IBinder) { +// putExtra( +// KeyConst.KEY_BUNDLE, +// Bundle().apply { +// putBinder(KeyConst.KEY_LARGE_DATA_BINDER, binder) +// }) +//} +// +//val Int.dp: Int get() = SizeUtils.dp2px(this.toFloat()) +// +//val Int.px: Int get() = SizeUtils.px2dp(this.toFloat()) + + +/** + * 重启当前 Activity + */ +fun Activity.restart() { + finish() + ContextCompat.startActivity(this, intent, null) +} + +val WindowManager.windowSize: DisplayMetrics + get() { + val displayMetrics = DisplayMetrics() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val windowMetrics: WindowMetrics = currentWindowMetrics + val insets = windowMetrics.windowInsets + .getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()) + displayMetrics.widthPixels = windowMetrics.bounds.width() - insets.left - insets.right + displayMetrics.heightPixels = windowMetrics.bounds.height() - insets.top - insets.bottom + } else { + @Suppress("DEPRECATION") + defaultDisplay.getMetrics(displayMetrics) + } + return displayMetrics + } + +@Suppress("DEPRECATION") +val Activity.displayHeight: Int + get() { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val windowMetrics = windowManager.currentWindowMetrics + val insets = windowMetrics.windowInsets.getInsetsIgnoringVisibility( + WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout() + ) + windowMetrics.bounds.height() - insets.bottom - insets.top + } else + windowManager.defaultDisplay.height + } + +/** + * 点击防抖动 + */ +fun View.clickWithThrottle(throttleTime: Long = 600L, action: (v: View) -> Unit) { + this.setOnClickListener(object : View.OnClickListener { + private var lastClickTime: Long = 0 + + override fun onClick(v: View) { + if (SystemClock.elapsedRealtime() - lastClickTime < throttleTime) return + else action(v) + + lastClickTime = SystemClock.elapsedRealtime() + } + }) +} + +/** + * View 是否在屏幕上可见 + */ +fun View.isVisibleOnScreen(): Boolean { + if (!isShown) { + return false + } + val actualPosition = Rect() + val isGlobalVisible = getGlobalVisibleRect(actualPosition) + val screenWidth = Resources.getSystem().displayMetrics.widthPixels + val screenHeight = Resources.getSystem().displayMetrics.heightPixels + val screen = Rect(0, 0, screenWidth, screenHeight) + return isGlobalVisible && Rect.intersects(actualPosition, screen) +} + + +/** + * 绑定返回键回调(建议使用该方法) + * @param owner Receive callbacks to a new OnBackPressedCallback when the given LifecycleOwner is at least started. + * This will automatically call addCallback(OnBackPressedCallback) and remove the callback as the lifecycle state changes. As a corollary, if your lifecycle is already at least started, calling this method will result in an immediate call to addCallback(OnBackPressedCallback). + * When the LifecycleOwner is destroyed, it will automatically be removed from the list of callbacks. The only time you would need to manually call OnBackPressedCallback.remove() is if you'd like to remove the callback prior to destruction of the associated lifecycle. + * @param onBackPressed 回调方法;返回true则表示消耗了按键事件,事件不会继续往下传递,相反返回false则表示没有消耗,事件继续往下传递 + * @return 注册的回调对象,如果想要移除注册的回调,直接通过调用[OnBackPressedCallback.remove]方法即可。 + */ +fun androidx.activity.ComponentActivity.addOnBackPressed( + owner: LifecycleOwner, + onBackPressed: () -> Boolean +): OnBackPressedCallback { + return backPressedCallback(onBackPressed).also { + onBackPressedDispatcher.addCallback(owner, it) + } +} + +/** + * 绑定返回键回调,未关联生命周期,建议使用关联生命周期的办法(尤其在fragment中使用,应该关联fragment的生命周期) + */ +fun androidx.activity.ComponentActivity.addOnBackPressed(onBackPressed: () -> Boolean): OnBackPressedCallback { + return backPressedCallback(onBackPressed).also { + onBackPressedDispatcher.addCallback(it) + } +} + +private fun androidx.activity.ComponentActivity.backPressedCallback(onBackPressed: () -> Boolean): OnBackPressedCallback { + return object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (!onBackPressed()) { + isEnabled = false + onBackPressedDispatcher.onBackPressed() + isEnabled = true + } + } + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/FileUtils.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/FileUtils.kt new file mode 100644 index 000000000..9a0b57472 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/FileUtils.kt @@ -0,0 +1,28 @@ +package com.k2fsa.sherpa.onnx.tts.engine.utils + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import java.io.File + + +fun Uri.fileName(context: Context): String { + var name: String? = null + scheme?.let { + when (it) { + "content" -> { + name = DocumentFile.fromSingleUri(context, this)?.name + } + + "file" -> { + path?.let { + name = File(it).name + } + } + + else -> {} + } + } + + return name ?: "" +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/HandlerUtils.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/HandlerUtils.kt new file mode 100644 index 000000000..1dee382b1 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/HandlerUtils.kt @@ -0,0 +1,51 @@ +@file:Suppress("unused") +/* https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/utils/HandlerUtils.kt */ +package com.k2fsa.sherpa.onnx.tts.engine.utils + +import android.os.Build.VERSION.SDK_INT +import android.os.Handler +import android.os.Looper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +/** This main looper cache avoids synchronization overhead when accessed repeatedly. */ +private val mainLooper: Looper = Looper.getMainLooper() + +private val mainThread: Thread = mainLooper.thread + +private val isMainThread: Boolean inline get() = mainThread === Thread.currentThread() + +fun buildMainHandler(): Handler { + return if (SDK_INT >= 28) Handler.createAsync(mainLooper) else try { + Handler::class.java.getDeclaredConstructor( + Looper::class.java, + Handler.Callback::class.java, + Boolean::class.javaPrimitiveType // async + ).newInstance(mainLooper, null, true) + } catch (ignored: NoSuchMethodException) { + // Hidden constructor absent. Fall back to non-async constructor. + Handler(mainLooper) + } +} + +private val mainHandler by lazy { buildMainHandler() } + +fun runOnUI(function: () -> Unit) { + if (isMainThread) { + function() + } else { + mainHandler.post(function) + } +} + +fun CoroutineScope.runOnIO(function: suspend () -> Unit) { + if (isMainThread) { + launch(IO) { + function() + } + } else { + runBlocking { function() } + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/LocaleUtils.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/LocaleUtils.kt new file mode 100644 index 000000000..71ee5009d --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/LocaleUtils.kt @@ -0,0 +1,77 @@ +package com.k2fsa.sherpa.onnx.tts.engine.utils + +import java.util.Locale + +object LocaleUtils { + +} + +val Locale.mustIso3Language: String + get() = try { + this.isO3Language + } catch (e: Exception) { + "" + } + +val Locale.mustIso3Country: String + get() = try { + this.isO3Country + } catch (e: Exception) { + "" + } + +val Locale.mustVariant: String + get() = try { + this.variant + } catch (e: Exception) { + "" + } + +fun Locale.toIso3Code(): String { + val lang = mustIso3Language + val country = mustIso3Country + val variant = mustVariant + + return when { + lang.isNotEmpty() && country.isNotEmpty() && variant.isNotEmpty() -> "$lang-$country-$variant" + lang.isNotEmpty() && country.isNotEmpty() -> "$lang-$country" + lang.isNotEmpty() -> lang + else -> "" + } +} + +fun String.toLocaleFromIso3(): Locale? { + return Locale.getAvailableLocales().find { it.toIso3Code() == this }?.let { + return it + } +} + +fun Locale.equalsIso3( + iso3Lang: String, + iso3Country: String = "", + iso3Variant: String = "" +): Boolean { + return this.mustIso3Language == iso3Lang && + this.mustIso3Country == iso3Country && + this.mustVariant == iso3Variant +} + +fun String.toLocale(): Locale { + val parts = split("-") + + return when (parts.size) { + 1 -> Locale(parts[0]) + 2 -> Locale(parts[0], parts[1]) + else -> Locale(this) + } +} + +fun newLocaleFromCode(code: String): Locale = code.toLocale() + +fun Locale.toCode(): String { + return try { + "$language-$country" + } catch (e: Exception) { + language + }.trimEnd('-') +} diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/NotificationUtils.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/NotificationUtils.kt new file mode 100644 index 000000000..b5004124f --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/NotificationUtils.kt @@ -0,0 +1,80 @@ +package com.k2fsa.sherpa.onnx.tts.engine.utils + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.annotation.RequiresApi +import com.k2fsa.sherpa.onnx.tts.engine.R +import com.k2fsa.sherpa.onnx.tts.engine.app +import com.k2fsa.sherpa.onnx.tts.engine.ui.MainActivity +import java.util.concurrent.atomic.AtomicLong + + +val pendingIntentFlags = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + PendingIntent.FLAG_UPDATE_CURRENT or + PendingIntent.FLAG_MUTABLE or + PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + +val notificationManager + get() = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + +@Suppress("DEPRECATION") +object NotificationUtils { + const val UNSPECIFIED_ID = -1 + + private val mAtomLong = AtomicLong(0) + + @Synchronized + fun nextNotificationId(): Int = mAtomLong.incrementAndGet().toInt() + + fun Context.notificationBuilder(channelId: String): Notification.Builder = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(this, channelId) + } else + Notification.Builder(this) + + @RequiresApi(Build.VERSION_CODES.O) + fun createChannel( + id: String, name: String, importance: Int = NotificationManager.IMPORTANCE_HIGH + ) { + val chan = NotificationChannel(id, name, importance) + chan.lightColor = android.graphics.Color.CYAN + chan.lockscreenVisibility = Notification.VISIBILITY_PUBLIC + notificationManager.createNotificationChannel(chan) + } + + fun Context.sendNotification( + notificationId: Int = nextNotificationId(), + channelId: String, + title: String, + content: String = "", + ) { + notificationManager.notify( + notificationId, + notificationBuilder(channelId).apply { + setContentTitle(title) + setContentText(content) + setSmallIcon(R.mipmap.ic_launcher) + setAutoCancel(true) + setContentIntent( + PendingIntent.getActivity( + this@sendNotification, + 0, + Intent(this@sendNotification, MainActivity::class.java), + pendingIntentFlags + ) + ) + }.build() + ) + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/PcmAudioPlayer.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/PcmAudioPlayer.kt new file mode 100644 index 000000000..3149cf939 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/PcmAudioPlayer.kt @@ -0,0 +1,110 @@ +package com.k2fsa.sherpa.onnx.tts.engine.utils + +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioTrack +import android.media.AudioTrack.PLAYSTATE_PLAYING +import android.util.Log +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import java.io.InputStream +import kotlin.coroutines.coroutineContext + +class PcmAudioPlayer { + companion object { + private const val TAG = "AudioTrackPlayer" + } + + private var audioTrack: AudioTrack? = null + private var currentSampleRate = 16000 + + @Suppress("DEPRECATION") + private fun createAudioTrack(sampleRate: Int = 16000): AudioTrack { + val mSampleRate = if (sampleRate == 0) 16000 else sampleRate + Log.d(TAG, "createAudioTrack: sampleRate=$mSampleRate") + + val bufferSize = AudioTrack.getMinBufferSize( + mSampleRate, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT + ) + return AudioTrack( + AudioManager.STREAM_MUSIC, + mSampleRate, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT, + bufferSize, + AudioTrack.MODE_STREAM + ) + } + + suspend fun play(inputStream: InputStream, sampleRate: Int = currentSampleRate) { + val bufferSize = AudioTrack.getMinBufferSize( + sampleRate, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT + ) + inputStream.readPcmChunk(chunkSize = bufferSize) { data -> + play(data, sampleRate) + } + } + + @Synchronized + fun play(audioData: ByteArray, sampleRate: Int = currentSampleRate) { + if (currentSampleRate == sampleRate) { + audioTrack = audioTrack ?: createAudioTrack(sampleRate) + } else { + audioTrack?.stop() + audioTrack?.release() + audioTrack = createAudioTrack(sampleRate) + currentSampleRate = sampleRate + } + + if (audioTrack!!.playState != PLAYSTATE_PLAYING) audioTrack!!.play() + + audioTrack!!.write(audioData, 0, audioData.size) + println("play done..") + } + + + fun stop() { + audioTrack?.stop() + } + + fun release() { + audioTrack?.release() + } + + suspend fun InputStream.readPcmChunk( + bufferSize: Int = 4096, + chunkSize: Int = 2048, + onRead: suspend (ByteArray) -> Unit + ) { + var bufferFilledCount = 0 + val buffer = ByteArray(bufferSize) + + while (coroutineContext.isActive) { + val readLen = + this.read(buffer, bufferFilledCount, chunkSize - bufferFilledCount) + if (readLen == -1) { + if (bufferFilledCount > 0) { + val chunkData = buffer.copyOfRange(0, bufferFilledCount) + onRead.invoke(chunkData) + } + break + } + if (readLen == 0) { + delay(100) + continue + } + + bufferFilledCount += readLen + if (bufferFilledCount >= chunkSize) { + val chunkData = buffer.copyOfRange(0, chunkSize) + + onRead.invoke(chunkData) + bufferFilledCount = 0 + } + } + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/ThrottleUtil.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/ThrottleUtil.kt new file mode 100644 index 000000000..b5a08643b --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/ThrottleUtil.kt @@ -0,0 +1,20 @@ +package com.k2fsa.sherpa.onnx.tts.engine.utils + +import kotlinx.coroutines.* + +@OptIn(DelicateCoroutinesApi::class) +class ThrottleUtil(private val scope: CoroutineScope = GlobalScope, val time: Long = 100L) { + var job: Job? = null + + fun runAction( + dispatcher: CoroutineDispatcher = Dispatchers.Main, + action: suspend () -> Unit, + ) { + job?.cancel() + job = null + job = scope.launch(dispatcher) { + delay(time) + action.invoke() + } + } +} \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/ToastUtils.kt b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/ToastUtils.kt new file mode 100644 index 000000000..c402256e4 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/java/com/k2fsa/sherpa/onnx/tts/engine/utils/ToastUtils.kt @@ -0,0 +1,49 @@ +@file:Suppress("unused") +/* https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/utils/ToastUtils.kt */ +package com.k2fsa.sherpa.onnx.tts.engine.utils + +import android.content.Context +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.fragment.app.Fragment + +fun Context.toast(@StringRes message: Int, vararg args: Any) { + runOnUI { + kotlin.runCatching { + Toast.makeText(this, getString(message, *args), Toast.LENGTH_SHORT).show() + } + } +} + +fun Context.toast(message: CharSequence?) { + runOnUI { + kotlin.runCatching { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + } +} + +fun Context.longToast(@StringRes message: Int, vararg args: Any) { + runOnUI { + kotlin.runCatching { + Toast.makeText(this, getString(message, *args), Toast.LENGTH_LONG).show() + } + } +} + +fun Context.longToast(message: CharSequence?) { + runOnUI { + kotlin.runCatching { + Toast.makeText(this, message, Toast.LENGTH_LONG).show() + } + } +} + + +fun Fragment.toast(@StringRes message: Int) = requireActivity().toast(message) + +fun Fragment.toast(message: CharSequence) = requireActivity().toast(message) + +fun Fragment.longToast(@StringRes message: Int) = requireContext().longToast(message) + +fun Fragment.longToast(message: CharSequence) = requireContext().longToast(message) \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/jniLibs/arm64-v8a/.gitkeep b/android/SherpaOnnxTtsEngine-NG/app/src/main/jniLibs/arm64-v8a/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/jniLibs/armeabi-v7a/.gitkeep b/android/SherpaOnnxTtsEngine-NG/app/src/main/jniLibs/armeabi-v7a/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/jniLibs/x86/.gitkeep b/android/SherpaOnnxTtsEngine-NG/app/src/main/jniLibs/x86/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/jniLibs/x86_64/.gitkeep b/android/SherpaOnnxTtsEngine-NG/app/src/main/jniLibs/x86_64/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..5d37a4866 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,5 @@ + + + diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/res/layout/big_text_view.xml b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/layout/big_text_view.xml new file mode 100644 index 000000000..e3818e9ec --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/layout/big_text_view.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..ef49c9917 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..ef49c9917 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..448417e3286edc60b350d1be6949bc834f67b7a9 GIT binary patch literal 2252 zcmV;-2s8ImNk&G*2mkKj|NE+FOKN)Q_mM|MYw{YWwF2FbNFBg~W?$PZg z-0HbyB>izBEWqz%oM;-5__12 z+hW{3St8CBaLMiviLQwaumyJ~yK@sSRxl7F**0zM(0?31fs3PSJ=?Zz+wVb;ZQFEo zza87QZQIWGPco4ndPB{&ZP{}Cin~iC`34p_h-5k|P)RPyUDE$DgAJhT+~J%6sR1UD-3ctwm9_6R4e1 z!b!uN;`d#FU^tCaG=P5^2l~Cs)Z}5Rc3fF0ic|c2oTfm~Ldw(RF!c)qs8U(+R87gl z`o?KXLQ*OajL63-N#=Qzsu~vtP?1ZuJ+dee^l%`ZWb1mC1>eMSiF6K0vLpvGNV2YX z6XU}G>av-1L6!rzkz}3jzat<^5--Uk!8tz=ktvH;OjCq4ijaoK#JkRqJfM^)G-yxy zU3+%ZUB>+@8np*g{v{eVNQnKp0D91qnZn~4DxOah@= z<;7{f!AfjwR|ugY_fE55C=hjqy<6eueuaK9h5rx*e{ceMKNy95Q8p3-5GtiAtI-jC z?t@e}PLTkL08r2ySMVEySOvXsf&m8!3Xx8q>mb3=N&tnPhNxgNr~ny4PQtu3a=q!|G6N55`D;8eCR#swUNh~(n~$hLADLfQP>Y<+`q<~&x7VD+XC&S zJAOc20bwpQ6LH~_DU+*WRnxXpJ*s8VI;epWP>4y`OUArwsK7TN<&E>alYCPH{kkmG z@WSc@jrumM#zy>fQdCM3b;?WNVT|)aT?6?&NlJ(c1YWJim4i&1=URaY|H?hncIKs# zSHuqyW9_sdE7UYZF;#|t0d$Sh8Q~AG0vA1?*2~G(=jpXyK}zYS|XJTo171p72u9*f`gUGz!GUnJtfA*rC)?Q^1_2GnzXy5X8|JGszOmm233;i4mbxQ zTF{4J)Vl$Js(Z|`EJmi>S4zUp1JYISAY8hu!|`~k6?v!>{r5QpNvCz@yOoyzlZ4U< zB82L!Rkb97kiU>G2FK@+Fb~a@`pQC#yShiSb_X}ElR>&3J_hW;V{x<)W)|?vJFz!* zh++JL)x8ifI5v*LzlbC!6Lckun+c+>+zz%7nu5UnfCC+ixMpquHQ+SYzz4yzm+G32 z#fba)LeGL4K#7iKKawD0XCn|vb{2T4wkBjh(*a(l zZ27$?MB?sk;_a+v4?K!Vay043RA2$T0E2{7H`cL4+P0VJ3O5gky>*DSaX5t!JT3Z7 zA3{V>;-iV3RL>C!4;E`r3EsVB97`z4+2qxO&rW2(fxLY5?6;@b8H#KUKMsFmQ7;G7?b$Y$adE_0!ov6+zh*g8?$ z{W3`BFx!S>$!#Rs=SLzk74h^mJkVTd=E@XTUbfBB3ZzYaXN^(PL)@X;LQjK)cCV}W z5x6w4&%HPfZ_CT{rA4wDc}2OW$1Q}PTfWk z)r?raAc+z}R9zKZf}-b8vg zEs@?$p=nyAcU@?j%@*TXmX}2NRz;+LQ{JX&Hk+o|G)>cNnr5?UnoYB5n$4zZHq934 z-L_U4Yp4nNavldeD1sP3CC90^69X8>0A?_NSqxwf_|yBw0LHRgxVy3tp(qUMjokZ| a{v4%1P*enlZCj-Cj|*P35XhQiGtUYj)N`a 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 UJ>WgRagg>cOVM%__$}DJ1VioOH~;_u literal 0 HcmV?d00001 diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..0c35850800c330f6bc0a78f94fbc21a83f004bde GIT binary patch literal 2546 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 I{%QQ10C~~tI{*Lx literal 0 HcmV?d00001 diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..ce9ceeb49278121ff811be030601e1fee45842be GIT binary patch literal 3070 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 literal 0 HcmV?d00001 diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b29b684a7d5c62eea6133ba75b0baaf1df6abde8 GIT binary patch literal 5620 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 OAYjo38>p(LoCg4ALhnof literal 0 HcmV?d00001 diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..940bd7e3b82d9492fdc2f70dc19d487e9d31974c GIT binary patch literal 4580 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 OWpU0Nm{X8VtP}tutN{f8 literal 0 HcmV?d00001 diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..2b30818b1403d9bb3b9740c951ca7d664216993f GIT binary patch literal 8872 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 literal 0 HcmV?d00001 diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..23accc49f710119af34810ef12e972ea3012efe0 GIT binary patch literal 6254 zcmV-!7?I~vNk&Fy7ytlQMM6+kP&iCl7ytk-zrZgLRpFhseYqqzpJ6#OGc$^rnKdis z?e1k}W@ct)W@ct)W@cuJzVA$*^Y!^Yvwh4oYxXF?$SCG9+k#_`T+FHQe);5 zyQW4Fb7Xu?$IRHZV%BW5ZT5(1aAjT19TWwdHaRswgW@f?Jprz0y7&9}q zs>~31R8nYWX15_ap`0;`$;=or(`;!(kzBKD+A_P%wxG@2Qzd3*&6YMY&5D_sM~>K@ zb;2}`*gnQtRhijS;=jNQp~aLs+o>8ecd%`?;Fy^i1tZqXnn^H@*ezzMODxUSj96+J zMG;D{ZQFFL)AVXbb>RQ|?z64ftT=fuwrvTvZ7U7)VtCx$A?|KK++DFxYSUswh`O5lRBMQ+0GwOZ&yiY+g4@QpYJy(Kyeb=WZ-{fAMqnPnIAqTrr$rYf`885 zLz$US=j>e}61|^uD62243`=|2&3WX0JJoBzlM31!5#>Dr! z3DR+zAVla5?BU;nLgAfP-W3Xy|7fSdU@$>?w=to28xyEo1R4{1IM9L4*I+OhBXyTT z;hjf56pGU^7-nfq=sm^+A{ql46WwSdc%Q@?fNO`Qm&_0RG0~z|xe0b6i)AZ-elTZoc38f>66SIZ%i`insReriSB!_FT!t^P%U;CY}L^ z>y@=1HY`JEF%sI)NAcFxjofguzwWt0f?js|RWkqyy|lN(O%Un~AyWDvUAwxT?@bNc z^+Yd`7YsmR&%Dh@@zT{|cY0u6yzY#8>a9kJm#&n$GiKDqTr-IWy4Pg@l6&GWM~j!P zl-e_!nVIecKr<6FT`sq0HFJFBYBCS>Gi#>Dk;NDh!vLg&)$?KVC;TU7T2^G2lkZ=J zF-Am=389IN`A9-PN!HxUJUvoCy`-RKQ4WQUWX+veXc=je(hl;d)FmL^m@0npuIL`C zNdQfJK?#GjH2t|mm9rR06^BcL(oKnjl(MH0<;)_=ori6|)Nw+gt-Ml337I8F;$Yu2 zlH%nbGKdOF=%I`;^f}Sp9g_m?;Th6EMkV)<+{?3iPDbDq~R}oV2wvR za5wrjY3OYh`2V8E8m@$-D@OqES1&g4@zDDre zCcr=qsPib-xp+owj3ha8CwBIO3aFKENK+dr+iZ`9h%_l!Th;EIz+J<^E-wB>lBWJA zuE{TG^lg;5b|MTfRBV{l_{RWJZ0uv?AVHNulvne~ru;?7CMZe$sL_v+w1Dy#L9sA0 zq8P9$n@rE(Ai2haT~hjighd0$yILg(NoXgvoUI_St{bWJaZHK*l%jf^{A*<;4%6zm zQtG%+YCAIkAiuvdt~j{2iWh#s@`<7+HDyd9(M3f7nG`s}> z2)u$S)*h@zJ|-8f;&3UrSp=Z6l~+gsc%k|HC#j=f> zPBs9v4jeJlirOv=06tT7mIUCC(ANLN0M+fCvdRv`D?G$VsYr4n@KV&xE~S<%(*qaV##L;YrU;;(J7Lj8bU8{~{)mV+QQ;y8zzAZcB}9p; zXLVd@0MhnnIK~3DmzXGp+&kz9;L*^_Y&wI8n|~tDRt09wVIxG8uzaBgkV$fkh#X!F zfX4)h^U1sd$SA=h(&S$+v$M}4J>)_}g)|EQ)Nr(mhkqi{L-#w)*yRNWN-$SN~HL~h0R(*UUV&1G5N0|#LH)L!PPp$4B8 z^J&$Rh$zE255Q?V5Yeju$T%^5JtZb?kvWt94}Rgq15L*njZe)$JW> zI@+)nPegPk0FXsyC~4y$Y5gD>V{b%66d1rB&ZMfXn|)CMCrRzdjo9%^cL1(@>qL}_ zy#jnw%#@6XRK@o*lr3eG?ehshn|@3n_wuaND1BuR7Z8!=(I)sR$r^hpm|NfXDhqQZ zqPV#yKOB-fWo4c1V<{Vco7rnmdlN+2=exjJ`-Qf3;%*Osm-!OyysT*yMir8XC~@_q zzB_JkO^w4I04iJrsN`M9E7w-B@hC3Lm|>VXM5^~e0NXsmM8B9+flc9`^?@vQ7$SN$ zTaG5a2Dz4-+_*MT=amAKI!dr~JwRY<-wDb$L_`}Yvcs`0X^<2r`_d*y!!NURB2)5? z4w_y>|yP#H1NbU@WNOr0qX*rB}BypS*9qWlfx>kohRYE_Dw`7+iUh5 zunVN&_~J@den6EQw_*ZT1lva{s>6zdc_SiCJls<%&Weq)Ro5{GIvD_{bmj8|@{Wi? zW|i*Cz1)y*ts>LpSnSNl9G%jM&J@+<9O`gtRK0U^qIOxIHELU~NwfZ+%U6Ghh!(#* zF4&}KZ*Xk6jA;zmRc+iZhu9PJqXwM)FJL42Rx4QM=|x2Xz&-bScWmfof%+d94^+2v zm^KY1O;(vc*2`HEJ%N7!E$3Qnda{t@8P))BxP7z6+X`#r8sgl+O#^^3 z!sTR|7;^@I0}BR`>$eu%C|DkFvqZ}WfL|k`HdyW$Ey#)Y&>q==K@U%Vv>IH0qC?qSdN)2(kWtZ>zRjx;Z#OeDZ>Ond-?BeZ>^mn5#^X~15nG!YP>YE zp9J75qASJCzpy6GvGbw=Rsr>r7h@^awVoLRwb$CRkWri`40FFkM*xU0MNG*A~HP?Pm-`bb$cw(2b}aTL9W@b3GfmIsmJmJI5DSx%IFC)H7Oj z15XR}H#!G#)^hl95P(PPU~$#})Flm4nj#`v{{E4BrGeOPMF%VZcoibcQie$^5Bn-N z9w}QZL_}3@U4R+=8WB+e^%5ljt2VRD3;}jON8J7K37h{I?apXZr!d zd>9K6w=C~Al9Ihq&MeFWK(M`gaU~+MT=|){;=n=eus$#W>4s=2Z-5||Ke51HOYDd3 zXc13H?41v`2`~U)Q{~1(ZT?{)B}{!%R@r5{R3!N;2g>r=fkwV*rsCDws^+8B1@`-L zlGgW#(=A3)wQq04{Ugf*@T3zbqL3|ptJ|(EiF2Y;B8l+{5BpaGP)}!ID<{b--=m5= zH>YBo8USv3Wb)0juvXrO?&M-rB;^YPU>k;HIHCnu13nKz(vOvq{3;7)BHv`|8Xmw8 z%YrEPQiH9pVuP#%q&peV+*i-KdEp|b4vPyi3UB93P&S$&#mT;@NTw?f=cq=pVTOPa zsh)t$_)u@>z*TNMeofHi;GQ!}jm(|IoeWRpkmb+)-^v%6`gnj{ zK%>N)=xXz`Y~k6D)P~qnBY0Qjo+fva}!w9;aOjEQ%q5=CvzTq@l5XVOwu=>ZWm7Oe~b zI1bFoMeHmBGFDc;@k-9v5jWm$Nh>R0JX;nm-VWW#o`NVL`yfLRCn1Nr>;v0_pSB%} zl1==uGkB{meHU-2oV@ZVSkA>35(jU2vH8_24hNu^ki|tqo{lw7PWKH}Bw(7cl+aIE zToE14^0)q)U-|pt+O}7M?Pb1Ps+FhiF1=CfvdOmXup#5tB zcnq}dy$6HjAj9Ee4=1=x_DH;!I|ohEoeOUZsjl2bBZ+q8EQ?7C+|E<;mLhqDl+WR7 zFLCFReq{NgW#0?XLh7YY;zHie&YUFB6vaxy(uE^rSn<;DX)aA2sWXoD3ThNhN^06# zPmEP3rkn`c8Wt!E=d}f@`DA79=fz!!s8cnh9^r#jBxsa#p~YGTD6n31v%x#GvdSPP za3d;v5&`KCfXt$N85m?ast7~xWYS9HQf%4sIbFW=PHpRg=S3zjIM?F(8FuD?b$~!> zsnu|Fa3aAwQ<;qUPzBUW4h8^f8%XiJm8`+I2;ZN{#O`gYKRPUqB~`0VQ*l@BJlMg? zpNG6V313SxfroR|I~S?p)E;lkBmB-ykO&xqY@k+34{IYcEMLCXSvIC`^lfBiD`~J> z771=Mu;SHcuS5cS{yaj&AE|up zB>{7N0`V|!{&@W@^Bi&=Nf}2T66C!*RJOBZ-*5(iOcNtwpk-h>*1YMc-Q{_$=Rr{CT56iku!DqYyTy|M2AHC? zAqGAGXvvBrw0P%+>-H8@-I zX*}Ozo^__r=`4YRc|^2|w76dr1K$20MTaaaK557GK3?`+!@&Uv=lEG0_J~+uy~ITX zP=+zyboSlQ(~M6rUsK41{fYL^B)!dDZGT~t0`_DNV1d6T0=C>V&!ArNq}MQx;DDVq0E|Hl_zrK*FJOVigjplX zvC!rf-2OGfcU1%2nv!q8%%5$~j$GUSzNHA>$(`IH!2d^D_E-K|B}*TU3(Y(^G&POT zC8}Orzq1)|{DhoKEjvlsTC4j?V5zTnR67tl8X3&u0wZ)dl;;Zr0BgI4F$mtBnF#;` zJ=Oc40Du7gzfH&x?E$O3pQ9@Y9(8DCM!0X|oi`zT^pDnNNg1rD z9&1R#*WM4CC7{NG%{RwF$u=Tz-rnaY%@*!4{IHi9pYWg&uphazdxP26w*IHDxmhA| z8OPH+%_fBA@xnt+Y8U0?I-IE-YC3<_zJstl&5R&7J#s8`bOHd?>|GwH4V9oIXYD|a z7S_tCYUjoRpq#_jg`=&LmnYUufB{GAD=o6knT$#>rsQ8KPb7YRS(Avf%%@iMOF}WC z@34k^{;bsq#ZW3*1VB!E@g@}1S$H$h_MW)x&&x)7%2Gy2BsWYD>XI3a_a68+xKp~b zlTicsta}JLfL3g!w0Z|Q(e*?maq~U(W}{3HYLgw!efzA|PfZ$1uY*%c1=c4QVC}vD zK>ZybbCAn6mr4MVu?f;ch~CTR_O6tgGdADFTqubT#}FnGn(0!x)nbIT9|qac{RaVH zlS{D)P%C|ztL@AO=kZS0bgILK$D1uEEpH}}vvdH=;6I#!gWBy69mOZ! zx!unD5X}wM?;Yky-hLQTpUy{*VLn*j;()J9iioBl5<1MAkX$&h!#MWVR-;2OC&5>l*7yhXSvFU|b58s^^$JBGC&$`Ev z`9dSCG8kdKM^pWHAJJDSX}H2{cwv!bGySakmZUXOUG{=1YipUY3OYM z>zTjG*86)+k96k`>UOHI+4=l&rdyX)z!1@D2VJaoR?qqvLPrK4^zID!lNo><2B4x( z!u?5fq{akkowC}n_LPE~ z+ppS9we?STC0ZNopXO!lWM_gd*Ym)F0Z2UZIg`f#ly);W zSTrjmj2IJokP#6r9`H38z^+ZAh!fj3-TR@oPVE1OjQw013zt3dsgyB}S>Cr2X9uv_ zcgD$ydy|=8BdqDQxz)vLeU1H*E$IBf7QA2p5*V;&Ms+1i8GzcJxbq`@67P(#))QK0 z-u6G9b-xkN(97%%iDo~sZPQZz>Qz|r33oRwD+J@fuy5t62HGF46wM82U#JaVvho|S zx5hzt(u1?DUDox5<%2@!j{!)F`a%^k096b?OOGOpi@lexq9av<2`D4_V%l&0n0yIo$L1kMN(-Um9PKxd}Bq5)|&ENdZC$5}ByuvfY z#l6F2UDjpALMtJ(+4Sqzuip@T<=%Q_ZFDm`IL`p|`GyNwZ!p@)j(79T1{d!K} z_CDP1pwIvGJd*V+&cRMq=+_^@1OJo3p*Yj)lyYl`t6I7?*rQU)8 zh-Uy&XUzbVGJw}bbGGz(kH`S}MF!CS2myv2%_6+uxVxD}#}RN#h5pz>ZrO1L?4ozx z{ysN)*Q{*(l_cS)-N)EzN6Tu2pq-ZKVZ}s ztlz`6Yd`C0gmu>|_@Z+UJB{@+07VOLjR9z20P7Xm6YCrG#nM0O3pF6>3pG&Shx`?h zJ+VIj{JgC#{L(sn!8S~;ICe1;VOV literal 0 HcmV?d00001 diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1269b99109736c69d82284120a1561485856590c GIT binary patch literal 12436 zcmV;FFl*0JNk&GDFaQ8oMM6+kP&iC~FaQ8AzrZgLRq0~3f7u+#c~8m>?jB}lPllO$ z$IW17EZ6tF&CJZq%-qw%%skV~%*@Q(cJpfA_x;}QrSehW3-?giJtqzwp+|Dq6HXQ? zP*D~uXr;o|lrl#?iYk&P%sES03a8?rcb4}k{9mXFGnNKj1DbV*+M^0}OT(jf*fY{n ztt#~MGBfX5-BN9sx*{zzowcP3RCu8xRhXB-D=||oQ=g7R)nUd8w}Z>*Ip}5H4Lh{V z>$DZd9#scROD%bM*Mnj5EU!DX%+=CzI1YA}hK{-qDxB7kXSBT4(m)u$pr<8Ic-b2| z6{h zi%W1zf(b0Deea6!&?$5tt zbo7}WlD^Kmv_fy;T=jM6;;pO1|35N{`JY-`G}Edj>{(m%|4E;si7^DH#&DdFBj5cx zPVYu0-|W|W>UGhD3q83KKW+s@s{hfF@c%z6aq=8hw9mLmN}dl&DgB^mANd2PbP7)B z5}c4tIH7xRLc=(rZ8)I;PG}S-lo-Y4$p7DKy1yH~=wB$M^n-8z+8*-(wyB^{Mt>>$dkiOZI!51yTf;|F3Q2H-`IrT1K@<-j1xMZwAg6Vjd3-kZ9^K~RNMz3-SyRLY*#6x zBXB}Lx@l@$#ET}IL|YWan|{6vaaE6@^|cg=-M(?0JjaNl7)92?m%%|4#TS0#yHzN( z^wnFOVA6k5rR;ePPUt++U)urkEQe<$-QPpegkD=5Q_|E^7+1n$o2QBsx|s0C32m3L z@NOX8AK+BCsZgksGl-6>!d1ea=i-FUqUi~x6Gaq1q?G=hZc;~ ztYR89Ui&3Tbd#;n5>O2u{zvtPq&-`!6R+DzBVqdN0J7FeD8*Gvp~gs0oX|ClVhDb> z(}-%!SQ|8;^by7q>Z+qq7CHe14 z4`1NaAfOV0)58HHm(!G5JlA!c9{064PSe5sL!VchYBZ6Mx*?d_6i_%FrPSiJ2KPUE z;N-bgPL(pE1YT8zx>l<>Q?E0$`uBoXhjFcsW19X>X}}d!7TYMA8Bo#GP>n8WwwqLU zYD|v-RjRUfCSijEpg+&5sqd7!Qe%~?8^!WEDrOZ*du>i)`Z(M4d~k{wF=8)ArxyC( zy4iTmQne;2C2&JWsiB)X?TMBLpL#yJ^m=k=dlJz|uBSZV$c&y^%(?2~EA_hr_aL_* zRbJiZS334nYVo<=6#Yh4(M8+8K}mtTCJQYs_P?|{4eRT|1FCvI+pYB@qtxKdD(e;D zq5nIpc56yLmBzMu*t<~VD@jD?-^D%JdEBF@RQGZTu=#wq#jly$k@%Yf3CI z$N+3Y@S4Dp(<@4aD<2W0lw2Z;Hi$yWfxC+t^w$Jr==WyTV(&}CnVu^3wThLUa-&DZ z@tWB6riRA*=DnRACB;bbM1U)}9ID95(C_aYhYzuj3F0KmyG$-&a7@(=#S zUYKia4PWL}mG<21}sW`n_9S&}y4SRjIOKI31GJZ=@Vcm+OFHIUR-j z>*?S^u*yc689{x{0QD@<-`%Reo7D_`UwIeW<|L+;P|3l& zkPiz0!`hQm2%)fpyj}o+;a5PYeDR>g!55Nlw_e$)RHe&8J{cr^-+1MSXAFuq3KqQY z^CKSyDYZC{5KIzNrIgO5aWb=pn7yCu27?Ji`>w~63DN2ydpkLhr21zRMjYtZjG*am zN-TnLDTI75YD-QX&+$~H$?hK^)L6EQvbWO0SBOvxgKq%cY<*@TC?|SCIp7bB5GvlU z>3kgo0BN^FW$mA7)HCKvWkLvihvUI)* zA{htil_wrKLHH&L9hX4!6EM`;O7P#}R?k-lYi%Xj_m|rcHQo2E$2GJ;DsHX)adm6-mQi310s;#VaT^SOEZhEF>e!cl2oMvd7lc$?4-_qp`NnjH)yVqH3Q6;cGLeDC@zg z0<5Kzm4zf}+EnM#M81PUUsv8OFlr>s5GZol>`k9lD!nE~hjX|u3^-K6$mep>mFop) zV;n=5R~Z<{aGwtk@-#Dr6BwB!YqFc~dh65m#%Fq*5ya_$WZ>TyhYgmul?BOAtu7+S zU6H(l5ZZrOb7-OWjrtR5z#V{a*SXOi4sMjgZ@&8tG0`-tyvDz9{LV<>%3VY$GtLr4 z2Smp{M}{5mBx%y~`U+KOYb&nS?D)HGWFs5-t}P9|?EJilS75B>xoY{heZ+SQ&%LyC3wrM>WOyIp7Gtg=qE3Z`*%^46{ zVW|$}YFv`#mzoS^8Ng%3MhMN66=YPmJtA8kBpg8v@ue))Xp%LybGB{TeD7OQMB!B! zrl{GNLo@e&u^WnVfKR>w#3*2(u(GN!Yf!?KNylDF$&2#uahQFTf7bpiB;2_I?6!$Q z+nPH$9WUN=whHQE6)^0!0U+k`icJWupxhvrUe8YVNwS`QS<;?> z3|Hs^Fk2VPfbRBQKg%%3)+wi#eO0HgR3Bg20;HJf;*iUge8(mSQZuB0HZatrzA_I(JfR00h zBz%g29%fcljxiQOw6BJ?rxB%uZb)wT@#(S_nNxD9!7G{MawApgGDOka>U!-%e!DJo z^o=Av%EP8M%x;B97!f#hIp|H7hAWHvQ;XqZj?!Mr$S>kFY_5vNzOk#iNf@f%s}(@2 zqlBkG^*AF5$Zy6>2%-C&g{0}9l${`i%vM&bBIEp5kM(WH4ADW&Cii+0)N2#US<*c0 z{t=b3BWKAdho;fqEaVo(Zf6!jp`R<~S`f3v$Defg2LldQx^iJtquOcY95#w|)jPl5 z%>Y>b0E9l*3;+OEh!Dy$2FYPx3qolBE3N(_=xp_hZtW;5a@PIcgR;72rMxx{{NE5l zFYx?8GJZ*c2iXPqVPszE8sQ_#oJw0C#UW->f`qPo##R?`VA5AEJ*^~237JB)Y_*<| z0FG7a@BpyEObDUut8SJ6sp$}*+amY@h=xZWli)74_>evBT`1p~HMH^w^s3cF zBacA?aQdYz+w9R3N#?V7(U&F{? z8gXUSDnt-pHg81Db(1K;j{njSCCR)6L}hR9&k_K7I=ZaBl{b7^02sCBlgn5dK#>c> zCP?VYTU@@`{)Je!UK5FUDIrr?jXzC&&d?UlmGT{lGKCq*wXD5?Rg>lVE)TM zoX}R$hA~&)(d^xEw?7XVXBS`L641VA^BF{=`)q9#2LN!Y^*aMdzRVD<)_3!i{ry(N zPQPpqTwEpLUQ-10r4;Vy~?qYK`r@ z%R#JlJQ1JA5TL8AAG5$}oWZ|v*u1PyJKbUC)$KIh;^VXWbw`VLIidl*ET1zwpPQ<@#)r0^f&t0$bC;ai)`d#<)EC%HhAE6yO2k|%5Fivq8;;z-2iZTX$s_ODA0?sHlCQ`8ulC}7Sj7cf!e4|MVt zQ$3`f8DFWFcWpCkc6(Q*yBzj{0WpV>6H0{aIotpq0~qQLrG8_pte3Ua{}guIr{KqN zG3`<(XE}}&+4_p@2H-w&K)|c!dV!@zY9KAB!C3j`+^?Meg?-b~O1)!j=^d3`6Qg(x zhzlH2&JqPY^Y6%k(eL04TbOlRt|xA-V0(I1^rffU2}*k0f(9_r(T(1#S{;Dj3FNuzzxwJQIFHq{nr;|h`;OZnHCJnVa}P=IQ>6CeknC{GhVk_ zFAd;MC&Q_a%~5*SrO6nO>ikh?W7*De<4u!LeTLv>-fK0gWM{8)Gr*|^xHC4%DQcX~ zyCyHp?`Y4porsp`i*Hv8rPNp=G&XRkEdbXtZ!+J)DOc)K-K<*h@TWRD9m<^1ms%dw zNSLu+U}T>zH*l}V;uPdt*==5AC)W^tb4ohg3P}!XC5&zp7NrHR{?=xAsHRWOEq;{a z&c)Y?{oVdM9Ysn%aZUwh7Cohu>=70fsX69F5FxW#tCWTpLcpZj!hO@_Jt7jDYO;$QMPhMT;pFjwlAg9I?j5Dg<2vF z_SbJ+xH%mxrO#Ggd5TMGB-`Vd7667SG4Me15bKz^?euzdXxQx`-ipOF{)JQ9QYy9M ztk1=shX0NMNrj|lgQS!;bIpBTqQ`TM_}Kp~*2zBiCOewHb9uJiMgxl87Lw5>>q(8A zSo6IfSq<5<*3XQordmn0X7uT&n(9Fi;vWCPA-tqo?O5GUK*uN0E!dg%mlD#%EqXN> z3#|+jju`+fhaVo^r(#K5w1*6OPSGtk;gzU1A}WNRQXluoq6q zZZ28>3FI0I7^=no7kM|E*s~!%@h2VjE~Z%?LC*rZiFn;*Wt#O;YHSfX<0-DMmEkAi zjde^p!z`C`4|J3#9R006&NhO@2KkZqI~Li_4buH&^em}X>Lj7YTCUK$mhWl~EVSFP zms@B#sr#dS_;g?a<4f6XTKHG2Cq0|fUEt4WnS^PJIpPW*b1as*$1{yP+vBf??&R|J zgv(j^(AF#GIr-?K`)=vjvn~eYgy6L4+>rDNG=QZV&vwLme5(uEe0C}mw?2*=^w)$H zBc$qd_3CnXM2*vUi;D{|phy=(K9&}149HgXtN(FB?4f`yoh;teVND!p3+I~lBT&ec z71-$(vgjcmnXxEiIT-_TtoKZV(tWOr*W`4;g10Y38!m`7eyFDfTXL+XS=30TXSVUj zI_A~23X&2u?Dh@Cj-NFyLVOb3|4^7U`z_s+?n*j-J_ck33SXY*=(;saR<@kde!t5r zc!cE^TOw;5I4O!X9?Lvy<@Ji}{1PN|Q; z?oFS}j-$Q#qXl@Zt1dlsaYyWE`a1>jt0;jYiUM+@C*m&X`zy_emPIy#$rzB~T@%j} z({NJ05U|#GyIviOV(IzA;XMq6)!2{}8QNl=s3o2E&Qm}7^vOIBMr7qS1%_qloQ@B%*u*vpXZsr|<8K@d-#g9dL3wkO}`F|nn`*#rY} z{mA<}LDb1^(%}G?TOvC?3aQ{PL$@ydqD9eB$lxRecee{dB^CupkCI>ju&^&aV zKcy30ZqR79$Bg%pCs?*CylZ#y(TP~}6X(}=XXGiwo}D`hcbBfia88cycOIc0W=-Z> z%8XomAb^|f<7`JcjAO3Zoa1{WjX!Z8Swf?JU7q<2-I?S}`iV!AXrbL?gBsITiyZOj z6KHVGW?rRb9~Zl_g|nfbC-n@yo}8%3z$-Wjo=5iYT^BdbZyXw!h%%Gjaa$OiSr||f zB&7T(+HgYBwxGBnPFYDYu(N=ioX$!T-{~zRgZp&RM2P3z6jUCw- zQ0QHk?nsk{la=T5J`XQJTN@{Y8|(#57Fwo*9j~rga39;Io=6(X^zrHAAO?sYEu+|e z+F%}2M~<73JnU`fu=S*4K>m`-m0@a}v7M96Om_~vXv~%e3060B)sd5(S}vKR-p4L@ zO}cz)>)l>H(K-3vzWazjG{tI~ zMQ~MP0swq*KBHb{Vb*jC22>^bbmWM<0SrjU@94-$;6oMfMO*~^ZzB+Y|9#k8^Ey}E zD6upQ`LKY?I`qKL7dy6WV-#~k;YKGo?i1kiZIAwilVub-&tpKz$vL?zTDs|E=*{$LaF?w&jAI>DY`W7`$n=#J2oR3`m^Y+@2S;5NmdWgc;a3vEu;1M>tjP z6PM>Kni>AduyC5&h!Z)?1R6i5c%-%sk}7V(6P6^#uOaaJ=4eD#}=_o%71q*Wl(T zQ5kGvK+0T}d|`Z_>h`rOWxaWw!xH={;6Y)E<#gzq6Awr<;1$$lEaaH^u~*Y7;gbNu zNdXz*v-n4xoZM!#`GgoyvY7Vo(ZK`SUe=pt5At$GfIq?q8e4zvVwmoZG&W-ehClQ> zrcVw20j$> zVKF_rspCIG00^@ks8$ml@(c0CB!R<Xo)96#HHl-W!+oilqr+455^E7Q6lxL=E~ zci!;l2A(wR6t_D8`H3Z_J38@X%PTZ>whFpJzFOow3=P zUO}nuv5AjB&^TJtv+M6}73yiRy7z)Jt@&cYn}ne=24^+~lqB{q z9;TcX>!!U~2uwT@AKQoh&A;0lu2Pdd766=K&j+W}kzqxZx~w0IpY|>aO^xSbK-UBq z962)H44pOSwB|}OE-Tl+Hdx>hWu3iOsja<&;{7Uqa^JJB)0#kNd<}rJ`!@jaIwOAs zUQ#aPOWj5n-}t>N;~Y19siurKLucJbi;4kV{7SnqYMhRBGZS**$MGgF4Q1Iaz_}jU z5a8x_`@^4`k2Y}lqF`qtqg9u5gkJKx5e40086gkZ~1hu68b{7e6&4(aL%=4xBmN z>~~xx26QUSoW2w+aOdy}*-%dO_u-4?Y!_~@zjYneC4q|AFo zK|(hLrzVS4Jf>&HFQrKtXV*CEVnEhgNWbKA?czX@OB*ZG*eU+*KKM#qtS)QImB>3y zeW#5e`#Mb-yS=r>B)P5JmT~%*u?A2V?i^A{S?B40fqS= zteu8jO1*aV*{X3GhCUU_2U+)D~h-z6cmqNEnGS3eae#>)Ci`mbHJR{q2~ayLx@8ZhK@; zGmXnpzT!vOT2`U+V~kiBxC)cxjBM=kM@^UY#`!fxBo?Ee;I0`ZV9y2oi#n65=>Eh5 zudXl&0~*K`Qzl>J&}5y&Ag>$SX~;Pp_Nc*34{d6X39Sod1FCi~-%|HxG^Su1hQKf=Mx3YbQfP5SVHl_iCF( z_rO8Dzd};uHS<{nkJ(3lM{Acm4ojEra_W;PYJNwy>u3w>CQkMllQz2IL~m#E?#DHE zvOpjj{y{jnO;(sS2`z9GIeoOZ95oT06SqDVCI`vyXzw1wOFwspcTEm4DLc7mp>VEe zc2f;!dTKToGO?3-p%)U4_xjSj*NqH187T(L8<4u=C z$1HEq?0>a83zL0zC5+GqK7rzSl^Nqv%R=qxwuSY7E{O3pQo3k5xzpG<4&ao*C4PQK zrhANq4YFr>i;EjalQ1LVK+B$bK01NG^o)C5N#)mwmw*tu&kynGkEKp~g6>0i9kjPr zs67dN!x&#KzDout`Uso{w~PNZaxQp~IJqcexngTlss}VB%PMLWBZKgyarRSP&ecC@ zvQ#lgo@GS3z5eooo$>1VP2;EM^n9|{#kZ4*&R;Qz^k#C-YwaIZcyc!TE2wcKIvEA> zvpNN-!C=zbDDIz8E`?M7*AH2~bR@IdJ3K;x?qL2r*S@23dOq3fBN-&IrbOqh*y_eL zh~)Xi*5=Ir!uK!4o{aG)9{l>Nqqf5f6BvH$bbkpYFcKb=Uaw~_*gtTY$q05$0 z=zi}Jc*)h@mlDRvX2U!&?a+ zjogF0O!~iDgWAkl)W2|2s6E-Zn0EH&girWqQ`#kwS9uJU!Q~(rterfcn0ghUe!g?x zQ3X5WiKV)N?k}9dfym%MRPYQ2xQ-!!Nc-!cV9B||$o8kqXAwAdV$Ub00R!9lATAlK zoyjVQqM0XZyzS>y0U8UawBD@lD}Atgz@R`dI1mWE4gmy*2?RX=>Y9cQF16fv*CXfl z<3-J*$#kzgEufl-FrdWI)19f!X}qZK z|IpwsU~avp0Rim-R`C0>q{xl$3ges);+PYLuZ5a zIz!ENf9w^2Vi>-G%)oMrZLil#em$lz{Q%kqtO%G_EO52)#A1Y$c5yRDr&IwIBU{}J zc@j8ob=rg~K(E^I^6J`vt(^b{0GO?!UJYpfD9+IITvPXUc1RW;tUmYIjj@_$$?D$= zcFX3b-*H!~iCQ1VQQvpI>*w0>h>c{r;#kVriCHMXw_iQ5wMO6#6`)VTIRN!rbxe>a zgRZR+gUb7Uot-QW%PW3*!dL`wViUlREdWhj0a_f!wLb|3VIcfZT1zKm`K5-YK2wHz z_~&da+cErJYe~*s_5I&9m^Yg1K?pr6JL8ew?wbdQxoJb_>F6@S$-Mma?EHpgEr%Z_ z%%Fg70L=hR4FbOu2Apg>@!Wv=^zpN$vv88LO96$*oWv!nqbH!-T!F@f9>oKftq2&q zqHUn9`HkNzH)|x@4-i7NSu@*C_7vy~{l|SFbk`8>M9o?A9GYn&uoyFNoNm6i$L3QfbnX-<|{oC{?M1{7}X^S1|-wR z5SW*>CSi;)N1)C#(2&rh)Q9=zCOu|aD?oeKfR@Km+Bl&Bs>xhP(aG{nCNzE*>wpat zq~0{~vEa@3f3W`R=tE!5mj@WL+yvQiCX9I!U_g~8pv!CiJ;o|P&vJm9R|rg4 zi^qlohzI+3s77B0eP8)t)0xnzq&NYBC#_Z&AykpHXU_ida|5p6W67jG&t?S>>S%qI+^rKDPdoWrS`5?S)PSUnV1GxS82{4^xqVa~>owj-sHY_w>R}1`yIF$@q=U=!uk3iYX1fY0 zH-1}qH+6-F@x*iW(D*SO4`LVALdGiGLY~2OGbGA}0p;;t=3IdWqBEa)^pvF+TkrVH z3d>f1as^GT1MuKYn#y9^$munf>qZep70P>@yrC`C&%b%fsbbKVDY~jgC8%CsMcNp| zwK4tBEuj2bJyM80S-!MV^q&noGr2>Y+xSroTcQ?021+hjG3h+oVB4JV=e*b`f&@N!DK&a8DWprWP4gKIr?xN5d29a_iUe^35;V^rkoc3qK*L zX1;&C>6O%f5n4&D7Huda1ogP1px*Q-sLvTirmxQ#sV6;DhbyQd*HuNaseGU_kUr@=}K>LY5!aO<#(aVtIjG7@jn+}q$r{&VMGXC*=3tSt!l1cV_@ihR#(W4<2KBcZXehsVX{f8hqd zs+aVgx^9WJ(ql`hwB9Tr-~QgKEBs%uGoD_kHC^=Ti%iVddy9*kyz8s2K6Gh^PfgtE zQnl1cCUR~k80`i^jE@27v^5frrUdz{$^1JdwF4n`t z*f`El<^~FbaZr#M~H}&F;e`L2Z@>>o1KeEy11Gay4G&C^p?B1(-pqV_gwNrG;7>y + استماع + متن نمونه + وارد کردن بسته مدل + فهرست خالی است. + ویرایش + زبان + نام نمایش + فایل مدل Onnx + جستجو + در حال بارگیری + کپی شد + پیکربندی TTS تنظیم نشده است + افزودن + حذف + مدیر مدل + تنظیمات + حذف نوار جستجو + افزودن نوار جستجو + بستن + موتور + مدت زمان تخریب + %1$s دقیقه + مدل به‌طور خودکار پس از اینکه برای مدت زمان مشخصی برای ذخیره حافظه در حال استفاده استفاده نشد، از بین می‌رود. + برای خروج دوباره به عقب فشار دهید + آیا می‌خواهید حذف کنید؟ + گزینه های بیشتر + لغو انتخاب + فعال + غیرفعال + تغییر زبان + انتخاب را معکوس کنید + هیچ فایلی انتخاب نشده است + سرویس فشرده + درحال فشرده‌سازی + فشرده‌شدن به پایان رسید + افزودن مدل‌ها + ❌ فقط [%1$s] فایل پشتیبانی می‌شود + ❌ دریافت نام فایل ممکن نیست + ❌ امکان اعطای مجوز خواندن وجود ندارد + کار پس‌زمینه اضافه شده است، لطفاً پیشرفت را از اعلان سیستم بررسی کنید. + در حال کپی کردن + واردات تکمیل شد + برای مرتب‌سازی بکشید + از %1$s تا %2$s + اجازه اعلان رد شده است، لطفاً تنظیمات را وارد کرده و آن را فعال کنید. + دانلود مدل + در حال بارگیری + حرکت فایل‌ها + نصب کننده مدل + نصب مدل انجام نشد ❌ + مدل نصب شد + وقت تمام شد + اندازه حافظه پنهان + حداکثر تعداد حافظه پنهان مدل + تعداد رشته ها + حداکثر تعداد رشته‌ها در هر مدل + کپی صدا + بلندگوی پیش فرض + بلندگو %1$s + مدیر صدا + افزودن صدا + مدل + خطا + صداهای تکراری + این مدل از بلندگوهای بیشتری پشتیبانی نمی‌کند + هیچ مدلی منتظر اضافه شدن به فهرست نیست + مرتب‌سازی + بر اساس نام + بر اساس مدل + لطفاً انتخاب کنید + "پیکربندی متن نمونه برای هر زبان" + ویرایش نام + بدون مدل، لطفاً از سمت راست بالا اضافه کنید. + صدایی وجود ندارد، لطفاً از سمت راست بالا اضافه کنید. + صدا + پراکسی Github + از آدرس اینترنتی پراکسی هنگام دانلود مدل ها از Github استفاده کنید + حذف مدل + حذف فایل مدل + ❌ مدل پیدا نشد: %1$s + صادرات + صادر نشد ❌ + در حال فشرده سازی + صادرات به پایان رسید + + diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/res/values-zh/strings.xml b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/values-zh/strings.xml new file mode 100644 index 000000000..dc47b4b22 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/values-zh/strings.xml @@ -0,0 +1,86 @@ + + 试听 + 示例文本 + 导入模型包 + 列表为空。 + 编辑 + 语言 + 显示名称 + Onnx模型文件 + 搜索 + 加载中 + 已复制 + TTS配置未设置 + 添加 + 删除 + 模型管理器 + 设置 + 滑动条删除 + 滑动条添加 + 关闭 + 引擎 + 超时销毁 + %1$s 分钟 + 模型在未使用一定时间后将自动销毁,以节省运行内存。 + 再次按返回键退出 + 您确定要删除吗? + 更多选项 + 取消选择 + 已启用 + 已禁用 + 更改语言 + 反选 + 未选择文件 + 压缩服务 + 解压中 + 解压完成 + 添加模型 + ❌ 仅支持 [%1$s] 文件 + ❌ 无法获取文件名 + ❌ 无法授予读取权限 + 后台任务已添加,请从系统通知中检查进度。 + 复制中 + 导入完成 + 拖动进行排序 + 从 %1$s 移动到 %2$s + 通知权限已拒绝,请转到设置并启用它。 + 下载模型 + 下载中 + 移动文件中 + 模型安装程序 + 模型安装失败 ❌ + 模型已安装 + 超时 + 缓存大小 + 模型缓存的最大数量 + 线程数 + 每个模型的最大线程数 + 复制语音 + 默认发言人 + 发言人 %1$s + 语音管理器 + 添加语音 + 模型 + 错误 + 重复的语音 + 此模型不支持更多的发言人 + 目录中没有等待添加的模型 + 排序 + 按名称排序 + 按模型排序 + 请选择 + "为每种语言配置示例文本" + 编辑名称 + 没有模型,请从右上角添加。 + 没有语音,请从右上角添加。 + 语音 + Github代理 + 从Github下载模型时使用代理URL + 删除模型 + 删除模型文件 + ❌ 未找到模型:%1$s + 导出 + 导出失败 ❌ + 压缩中 + 导出完成 + diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/res/values/colors.xml b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..f8c6127d3 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/res/values/ic_launcher_background.xml b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 000000000..287a66847 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #0b62c2 + \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/res/values/strings.xml b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..5cd7b6e0b --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/values/strings.xml @@ -0,0 +1,87 @@ + + Next-gen Kaldi: TTS + Audition + Sample text + Import model package + List is empty. + Edit + Language + Display name + Onnx model file + Search + Loading + Copied + TTS Config not set + Add + Delete + Model manager + Settings + Seekbar remove + Seekbar add + Close + Engine + Timeout destruction + %1$s min + The model is automatically destroyed after it has not been used for a specified period of time to save running memory. + Press back again to exit + Do you want to delete? + More options + Cancel select + Enabled + Disabled + Change language + Invert select + No file selected + Compress service + Decompressing + Decompressing finished + Add models + ❌ Only [%1$s] files are supported + ❌ Unable get file name + ❌ Unable to grant read permission + The background task has been added, please check the progress from the system notification. + Copying + Import completed + Drag to sort + From %1$s to %2$s + Notification permission is denied, please goto settings and enable it. + Download model + Downloading + Moving files + Model installer + Model installation failed ❌ + Model installed + Timed out + Cache size + Maximum number of model caches + Number of threads + Maximum number of threads per model + Copy voice + Default speaker + Speaker %1$s + Voice manager + Add voice + Model + Error + Duplicate voices + This model does not support more speakers + There are no models waiting to be added in the directory + Sort + By Name + By Model + Please select + "Configure sample text for each language " + Edit name + No model, please add from top-right. + No voice, please add from top-right. + Voice + Github proxy + Use proxy url when downloading models from Github + Delete model + Delete model file + ❌ Model not found: %1$s + Export + Export failed ❌ + Compressing + Export finished + \ No newline at end of file diff --git a/android/SherpaOnnxTtsEngine-NG/app/src/main/res/values/themes.xml b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/values/themes.xml new file mode 100644 index 000000000..012ac9706 --- /dev/null +++ b/android/SherpaOnnxTtsEngine-NG/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +