From 658969e97ac642aa80598c568678ec2a9b45b230 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 12 Jun 2025 23:56:14 +0800 Subject: [PATCH] feat: support native tts engine on Android (#1387) --- .../public/locales/ar/translation.json | 10 +- .../public/locales/de/translation.json | 4 +- .../public/locales/el/translation.json | 4 +- .../public/locales/es/translation.json | 5 +- .../public/locales/fr/translation.json | 5 +- .../public/locales/hi/translation.json | 4 +- .../public/locales/id/translation.json | 3 +- .../public/locales/it/translation.json | 5 +- .../public/locales/ja/translation.json | 3 +- .../public/locales/ko/translation.json | 3 +- .../public/locales/nl/translation.json | 4 +- .../public/locales/pl/translation.json | 6 +- .../public/locales/pt/translation.json | 5 +- .../public/locales/ru/translation.json | 6 +- .../public/locales/tr/translation.json | 4 +- .../public/locales/uk/translation.json | 6 +- .../public/locales/vi/translation.json | 3 +- .../public/locales/zh-CN/translation.json | 3 +- .../public/locales/zh-TW/translation.json | 3 +- .../java/ExampleInstrumentedTest.kt | 4 +- .../android/src/main/java/NativeTTSPlugin.kt | 183 +++-------- .../plugins/tauri-plugin-native-tts/build.rs | 7 +- .../commands/get_granularities.toml | 13 - .../commands/get_speaking_lang.toml | 13 - .../autogenerated/commands/get_voice_id.toml | 13 - .../autogenerated/commands/get_voices.toml | 13 - .../commands/registerListener.toml | 13 + .../commands/remove_listener.toml | 13 + .../commands/set_primary_lang.toml | 13 - .../permissions/autogenerated/reference.md | 189 +++-------- .../permissions/default.toml | 7 +- .../permissions/schemas/schema.json | 88 ++--- .../tauri-plugin-native-tts/src/commands.rs | 57 +--- .../tauri-plugin-native-tts/src/desktop.rs | 21 +- .../tauri-plugin-native-tts/src/lib.rs | 5 - .../tauri-plugin-native-tts/src/mobile.rs | 62 +--- .../tauri-plugin-native-tts/src/models.rs | 30 +- apps/readest-app/src-tauri/src/lib.rs | 1 + .../app/reader/components/tts/TTSControl.tsx | 4 +- .../app/reader/components/tts/TTSPanel.tsx | 78 +++-- apps/readest-app/src/libs/edgeTTS.ts | 14 +- .../src/services/tts/EdgeTTSClient.ts | 111 +++---- .../src/services/tts/NativeTTSClient.ts | 308 ++++++++++++++++++ .../readest-app/src/services/tts/TTSClient.ts | 20 +- .../src/services/tts/TTSController.ts | 122 +++++-- apps/readest-app/src/services/tts/TTSData.ts | 30 ++ apps/readest-app/src/services/tts/TTSUtils.ts | 13 + .../src/services/tts/WebSpeechClient.ts | 122 +++---- apps/readest-app/src/services/tts/index.ts | 1 + apps/readest-app/src/services/tts/types.ts | 16 + apps/readest-app/src/types/view.ts | 3 +- apps/readest-app/src/utils/ssml.ts | 13 + 52 files changed, 931 insertions(+), 755 deletions(-) delete mode 100644 apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/get_granularities.toml delete mode 100644 apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/get_speaking_lang.toml delete mode 100644 apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/get_voice_id.toml delete mode 100644 apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/get_voices.toml create mode 100644 apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/registerListener.toml create mode 100644 apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/remove_listener.toml delete mode 100644 apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/set_primary_lang.toml create mode 100644 apps/readest-app/src/services/tts/NativeTTSClient.ts diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index e7b97415..afd240d1 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -236,7 +236,7 @@ "Border Color": "لون الحدود", "Border Frame": "إطار الحدود", "Show Header": "إظهار الترويسة", - "Show Footer": "إظهار التذييل" + "Show Footer": "إظهار التذييل", "Small": "صغير", "Large": "كبير", "Auto": "تلقائي", @@ -357,5 +357,11 @@ "Quota Exceeded": "تجاوزت الحصة المخصصة", "{{percentage}}% of Daily Translation Characters Used.": "تم استخدام ما نسبته {{percentage}}% من إجمالي أحرف الترجمة المسموح بها يوميًا.", "Translation Characters": "أحرف الترجمة", - "Daily translation quota reached. Select another translate service to proceed.": "تم استنفاد حصة الترجمة اليومية. قم بتحديد خدمة ترجمة أخرى للاستمرار." + "Daily translation quota reached. Select another translate service to proceed.": "تم استنفاد حصة الترجمة اليومية. قم بتحديد خدمة ترجمة أخرى للاستمرار.", + "{{engine}}: {{count}} voices_zero": "{{engine}}: لا توجد أصوات", + "{{engine}}: {{count}} voices_one": "{{engine}}: صوت واحد", + "{{engine}}: {{count}} voices_two": "{{engine}}: صوتان", + "{{engine}}: {{count}} voices_few": "{{engine}}: {{count}} أصوات", + "{{engine}}: {{count}} voices_many": "{{engine}}: {{count}} صوتاً", + "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} صوت" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 98cb9b6e..eae6bff5 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -349,5 +349,7 @@ "Quota Exceeded": "Limit überschritten", "{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% der täglichen Übersetzungszeichen verwendet.", "Translation Characters": "Übersetzungszeichen", - "Daily translation quota reached. Select another translate service to proceed.": "Das tägliche Übersetzungslimit wurde erreicht. Wählen Sie einen anderen Übersetzungsdienst, um fortzufahren." + "Daily translation quota reached. Select another translate service to proceed.": "Das tägliche Übersetzungslimit wurde erreicht. Wählen Sie einen anderen Übersetzungsdienst, um fortzufahren.", + "{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} Stimme", + "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} Stimmen" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 652c857e..3bae8838 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -349,5 +349,7 @@ "Quota Exceeded": "Υπέρβαση ορίου", "{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% των ημερήσιων χαρακτήρων μετάφρασης χρησιμοποιήθηκαν.", "Translation Characters": "Χαρακτήρες μετάφρασης", - "Daily translation quota reached. Select another translate service to proceed.": "Έχετε φτάσει το ημερήσιο όριο χαρακτήρων μετάφρασης. Επιλέξτε άλλη υπηρεσία μετάφρασης για να συνεχίσετε." + "Daily translation quota reached. Select another translate service to proceed.": "Έχετε φτάσει το ημερήσιο όριο χαρακτήρων μετάφρασης. Επιλέξτε άλλη υπηρεσία μετάφρασης για να συνεχίσετε.", + "{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} φωνή", + "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} φωνές" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 16695260..e8ca9b33 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -351,5 +351,8 @@ "Quota Exceeded": "Límite superado", "{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% de caracteres de traducción diarios utilizados.", "Translation Characters": "Caracteres de traducción", - "Daily translation quota reached. Select another translate service to proceed.": "Cuota diaria de traducción alcanzada. Selecciona otro servicio de traducción para continuar." + "Daily translation quota reached. Select another translate service to proceed.": "Cuota diaria de traducción alcanzada. Selecciona otro servicio de traducción para continuar.", + "{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} voz", + "{{engine}}: {{count}} voices_many": "{{engine}}: {{count}} voces", + "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} voces" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index c65eaf15..1da48b0f 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -351,5 +351,8 @@ "Quota Exceeded": "Quota dépassé", "{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% des caractères de traduction quotidiens utilisés.", "Translation Characters": "Caractères de traduction", - "Daily translation quota reached. Select another translate service to proceed.": "Quota de traduction quotidien atteint. Sélectionnez un autre service de traduction pour continuer." + "Daily translation quota reached. Select another translate service to proceed.": "Quota de traduction quotidien atteint. Sélectionnez un autre service de traduction pour continuer.", + "{{engine}}: {{count}} voices_one": "{{engine}} : {{count}} voix", + "{{engine}}: {{count}} voices_many": "{{engine}} : {{count}} voix", + "{{engine}}: {{count}} voices_other": "{{engine}} : {{count}} voix" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index eb19ecc7..e5b98e26 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -349,5 +349,7 @@ "Quota Exceeded": "सीमा पार", "{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% दैनिक अनुवाद वर्णों का उपयोग किया गया है।", "Translation Characters": "अनुवाद वर्ण", - "Daily translation quota reached. Select another translate service to proceed.": "दैनिक अनुवाद कोटा पूरा हो गया है। आगे बढ़ने के लिए कोई अन्य अनुवाद सेवा चुनें।" + "Daily translation quota reached. Select another translate service to proceed.": "दैनिक अनुवाद कोटा पूरा हो गया है। आगे बढ़ने के लिए कोई अन्य अनुवाद सेवा चुनें।", + "{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} आवाज़", + "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} आवाज़ें" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index a9c8a841..926a7cbf 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -347,5 +347,6 @@ "Quota Exceeded": "Kuota terlampaui", "{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% dari Karakter Terjemahan Harian Digunakan.", "Translation Characters": "Karakter Terjemahan", - "Daily translation quota reached. Select another translate service to proceed.": "Kuota terjemahan harian tercapai. Pilih layanan terjemahan lain untuk melanjutkan." + "Daily translation quota reached. Select another translate service to proceed.": "Kuota terjemahan harian tercapai. Pilih layanan terjemahan lain untuk melanjutkan.", + "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} suara" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index 5a4cce11..4d637099 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -351,5 +351,8 @@ "Quota Exceeded": "Limite superato", "{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% dei caratteri di traduzione giornalieri utilizzati.", "Translation Characters": "Caratteri di traduzione", - "Daily translation quota reached. Select another translate service to proceed.": "Quota di traduzione giornaliera raggiunta. Seleziona un altro servizio di traduzione per procedere." + "Daily translation quota reached. Select another translate service to proceed.": "Quota di traduzione giornaliera raggiunta. Seleziona un altro servizio di traduzione per procedere.", + "{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} voce", + "{{engine}}: {{count}} voices_many": "{{engine}}: {{count}} voci", + "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} voci" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index d871122d..2d1d375d 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -347,5 +347,6 @@ "Quota Exceeded": "上限超過", "{{percentage}}% of Daily Translation Characters Used.": "日次翻訳文字数の使用量は{{percentage}}%です。", "Translation Characters": "翻訳文字数", - "Daily translation quota reached. Select another translate service to proceed.": "日次翻訳クォータに達しました。続行するには別の翻訳サービスを選択してください。" + "Daily translation quota reached. Select another translate service to proceed.": "日次翻訳クォータに達しました。続行するには別の翻訳サービスを選択してください。", + "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}}音声" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index ec0c8daa..0f684642 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -347,5 +347,6 @@ "Quota Exceeded": "한도 초과", "{{percentage}}% of Daily Translation Characters Used.": "일일 번역 문자 사용량 {{percentage}}%.", "Translation Characters": "번역 문자", - "Daily translation quota reached. Select another translate service to proceed.": "일일 번역 할당량에 도달했습니다. 계속 진행하려면 다른 번역 서비스를 선택하세요." + "Daily translation quota reached. Select another translate service to proceed.": "일일 번역 할당량에 도달했습니다. 계속 진행하려면 다른 번역 서비스를 선택하세요.", + "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}}개의 음성" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 4489fe62..ef848a56 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -349,5 +349,7 @@ "Quota Exceeded": "Limiet overschreden", "{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% van de dagelijkse vertaaltekens gebruikt.", "Translation Characters": "Vertaaltekens", - "Daily translation quota reached. Select another translate service to proceed.": "Dagelijkse vertaalquotum bereikt. Selecteer een andere vertaalservice om door te gaan." + "Daily translation quota reached. Select another translate service to proceed.": "Dagelijkse vertaalquotum bereikt. Selecteer een andere vertaalservice om door te gaan.", + "{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} stem", + "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} stemmen" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index d5eecfa1..b70d5c31 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -353,5 +353,9 @@ "Quota Exceeded": "Limit przekroczony", "{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% użytych znaków tłumaczenia dziennego.", "Translation Characters": "Znaki tłumaczenia", - "Daily translation quota reached. Select another translate service to proceed.": "Przekroczono dzienny limit znaków tłumaczenia. Wybierz inną usługę tłumaczenia, aby kontynuować." + "Daily translation quota reached. Select another translate service to proceed.": "Przekroczono dzienny limit znaków tłumaczenia. Wybierz inną usługę tłumaczenia, aby kontynuować.", + "{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} głos", + "{{engine}}: {{count}} voices_few": "{{engine}}: {{count}} głosy", + "{{engine}}: {{count}} voices_many": "{{engine}}: {{count}} głosów", + "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} głosu" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index 588c5e8b..88341789 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -351,5 +351,8 @@ "Quota Exceeded": "Limite excedido", "{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% dos caracteres de tradução diários usados.", "Translation Characters": "Caracteres de Tradução", - "Daily translation quota reached. Select another translate service to proceed.": "Quota diária de tradução atingida. Selecione outro serviço de tradução para continuar." + "Daily translation quota reached. Select another translate service to proceed.": "Quota diária de tradução atingida. Selecione outro serviço de tradução para continuar.", + "{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} voz", + "{{engine}}: {{count}} voices_many": "{{engine}}: {{count}} vozes", + "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} vozes" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index c1b05d3c..615b8f92 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -353,5 +353,9 @@ "Quota Exceeded": "Лимит превышен", "{{percentage}}% of Daily Translation Characters Used.": "Использовано {{percentage}}% от суточного лимита символов перевода.", "Translation Characters": "Символы перевода", - "Daily translation quota reached. Select another translate service to proceed.": "Достигнут суточный лимит символов перевода. Выберите другой сервис перевода для продолжения." + "Daily translation quota reached. Select another translate service to proceed.": "Достигнут суточный лимит символов перевода. Выберите другой сервис перевода для продолжения.", + "{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} голос", + "{{engine}}: {{count}} voices_few": "{{engine}}: {{count}} голоса", + "{{engine}}: {{count}} voices_many": "{{engine}}: {{count}} голосов", + "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} голоса" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index 155deb65..dee7b6af 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -349,5 +349,7 @@ "Quota Exceeded": "Kota aşıldı", "{{percentage}}% of Daily Translation Characters Used.": "Çeviri için günlük karakterlerin %{{percentage}}'si kullanıldı.", "Translation Characters": "Çeviri Karakterleri", - "Daily translation quota reached. Select another translate service to proceed.": "Günlük çeviri kotası aşıldı. Devam etmek için başka bir çeviri hizmeti seçin." + "Daily translation quota reached. Select another translate service to proceed.": "Günlük çeviri kotası aşıldı. Devam etmek için başka bir çeviri hizmeti seçin.", + "{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} ses", + "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} sesler" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 35fb5405..e6dbd4d5 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -353,5 +353,9 @@ "Quota Exceeded": "Перевищено ліміт", "{{percentage}}% of Daily Translation Characters Used.": "Використано {{percentage}}% символів перекладу за день.", "Translation Characters": "Символи перекладу", - "Daily translation quota reached. Select another translate service to proceed.": "Досягнуто добового ліміту перекладу. Виберіть іншу службу перекладу для продовження." + "Daily translation quota reached. Select another translate service to proceed.": "Досягнуто добового ліміту перекладу. Виберіть іншу службу перекладу для продовження.", + "{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} голос", + "{{engine}}: {{count}} voices_few": "{{engine}}: {{count}} голоси", + "{{engine}}: {{count}} voices_many": "{{engine}}: {{count}} голосів", + "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} голоси" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 80d683b2..9650f063 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -347,5 +347,6 @@ "Quota Exceeded": "Vượt hạn mức", "{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% số ký tự dịch hàng ngày đã sử dụng.", "Translation Characters": "Những ký tự dịch", - "Daily translation quota reached. Select another translate service to proceed.": "Đã đạt hạn mức dịch hàng ngày. Chọn dịch vụ dịch khác để tiếp tục." + "Daily translation quota reached. Select another translate service to proceed.": "Đã đạt hạn mức dịch hàng ngày. Chọn dịch vụ dịch khác để tiếp tục.", + "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} giọng" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index d4619c45..86e7abe7 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -347,5 +347,6 @@ "Quota Exceeded": "额度不足", "{{percentage}}% of Daily Translation Characters Used.": "已使用 {{percentage}}% 的每日翻译字符数", "Translation Characters": "翻译字数", - "Daily translation quota reached. Select another translate service to proceed.": "已达到每日翻译配额,请选择其他翻译服务继续。" + "Daily translation quota reached. Select another translate service to proceed.": "已达到每日翻译配额,请选择其他翻译服务继续。", + "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} 种声音" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index 4ba27fe6..f9944818 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -347,5 +347,6 @@ "Quota Exceeded": "額度不足", "{{percentage}}% of Daily Translation Characters Used.": "已使用 {{percentage}}% 的每日翻譯字數", "Translation Characters": "翻譯字數", - "Daily translation quota reached. Select another translate service to proceed.": "已達到每日翻譯字數限制,請選擇其他翻譯服務繼續。" + "Daily translation quota reached. Select another translate service to proceed.": "已達到每日翻譯字數限制,請選擇其他翻譯服務繼續。", + "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} 種語音" } diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/androidTest/java/ExampleInstrumentedTest.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/androidTest/java/ExampleInstrumentedTest.kt index 367e44f6..e1c0456c 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/androidTest/java/ExampleInstrumentedTest.kt +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/androidTest/java/ExampleInstrumentedTest.kt @@ -1,4 +1,4 @@ -package com.readest.native-tts +package com.readest.native_tts import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -19,6 +19,6 @@ class ExampleInstrumentedTest { fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.readest.native-tts", appContext.packageName) + assertEquals("com.readest.native_tts", appContext.packageName) } } diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/NativeTTSPlugin.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/NativeTTSPlugin.kt index 00590b2f..8147c227 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/NativeTTSPlugin.kt +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/NativeTTSPlugin.kt @@ -20,10 +20,10 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import org.json.JSONArray import org.json.JSONObject -import java.util.* import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference +import java.util.* data class TTSVoiceData( val id: String, @@ -38,11 +38,26 @@ data class TTSMessageEvent( val mark: String? = null ) -enum class TTSGranularity(val value: String) { - WORD("word"), - SENTENCE("sentence"), - PARAGRAPH("paragraph") -} +@InvokeArg +class SpeakArgs( + val text: String? = "", + val preload: Boolean? = false +) + +@InvokeArg +class SetRateArgs( + val rate: Float? = 1.0f +) + +@InvokeArg +class SetPitchArgs( + val pitch: Float? = 1.0f +) + +@InvokeArg +class SetVoiceArgs( + val voice: String? = null +) @TauriPlugin class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { @@ -56,12 +71,9 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { private var isInitialized = AtomicBoolean(false) private var isPaused = AtomicBoolean(false) private var isSpeaking = AtomicBoolean(false) - private var currentVoiceId = AtomicReference("") - private var currentLang = AtomicReference("en-US") private var currentRate = AtomicReference(1.0f) private var currentPitch = AtomicReference(1.0f) - // Event channels for each speaking session private val eventChannels = ConcurrentHashMap>() private val speakingJobs = ConcurrentHashMap() @@ -90,16 +102,19 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { TextToSpeech.SUCCESS -> { setupTTSListener() isInitialized.set(true) + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) continuation.resume(true) {} } else -> { Log.e(TAG, "TTS initialization failed with status: $status") + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) continuation.resume(false) {} } } } } catch (e: Exception) { Log.e(TAG, "Exception during TTS initialization", e) + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) continuation.resume(false) {} } } @@ -120,7 +135,8 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { closeEventChannel(id) } } - + + @Deprecated("deprecated in API level 21") override fun onError(utteranceId: String?) { utteranceId?.let { id -> isSpeaking.set(false) @@ -129,6 +145,14 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { } } + override fun onError(utteranceId: String?, errorCode: Int) { + utteranceId?.let { id -> + isSpeaking.set(false) + sendEvent(id, TTSMessageEvent("error", "TTS playback error:$errorCode")) + closeEventChannel(id) + } + } + override fun onRangeStart(utteranceId: String?, start: Int, end: Int, frame: Int) { utteranceId?.let { id -> sendEvent(id, TTSMessageEvent("boundary", "range", "pos:$start-$end")) @@ -140,9 +164,10 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { @Command fun speak(invoke: Invoke) { val args = invoke.parseArgs(SpeakArgs::class.java) + val text = args.text ?: "" - if (!isInitialized.get()) { - invoke.reject("TTS not initialized") + if (text.isEmpty()) { + invoke.reject("Text cannot be empty") return } @@ -154,11 +179,10 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { eventChannels[utteranceId] = eventChannel val speakJob = launch { - speakText(args.ssml, utteranceId, args.preload ?: false) + speakText(text, utteranceId, args.preload ?: false) } speakingJobs[utteranceId] = speakJob - // Return the utterance ID so frontend can listen to events val result = JSObject().apply { put("utteranceId", utteranceId) } @@ -174,12 +198,9 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { } } - private suspend fun speakText(ssml: String, utteranceId: String, preload: Boolean) { + private suspend fun speakText(text: String, utteranceId: String, preload: Boolean) { withContext(Dispatchers.Main) { try { - // Parse SSML and extract text - val text = parseSSML(ssml) - textToSpeech?.apply { setSpeechRate(currentRate.get()) setPitch(currentPitch.get()) @@ -205,18 +226,9 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { } } - private fun parseSSML(ssml: String): String { - // Simple SSML parsing - extract text content - return ssml - .replace(Regex("<[^>]*>"), " ") - .replace(Regex("\\s+"), " ") - .trim() - } - private fun startEventStream(utteranceId: String) { coroutineScope.launch { val channel = eventChannels[utteranceId] ?: return@launch - try { for (event in channel) { val eventData = JSObject().apply { @@ -225,8 +237,6 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { event.message?.let { put("message", it) } event.mark?.let { put("mark", it) } } - - // Send event to frontend via Tauri event system trigger(CHANNEL_NAME, eventData) } } catch (e: Exception) { @@ -266,7 +276,6 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { @Command fun resume(invoke: Invoke) { - // Android TTS doesn't have native resume, so we'll need to track state try { isPaused.set(false) invoke.resolve() @@ -281,8 +290,6 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { if (textToSpeech?.stop() == TextToSpeech.SUCCESS) { isSpeaking.set(false) isPaused.set(false) - - // Cancel all active speaking jobs and close channels speakingJobs.values.forEach { it.cancel() } eventChannels.values.forEach { it.close() } speakingJobs.clear() @@ -297,29 +304,6 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { } } - @Command - fun set_primary_lang(invoke: Invoke) { - val args = invoke.parseArgs(SetLangArgs::class.java) - try { - val locale = Locale.forLanguageTag(args.lang) - val result = textToSpeech?.setLanguage(locale) - - when (result) { - TextToSpeech.LANG_AVAILABLE, - TextToSpeech.LANG_COUNTRY_AVAILABLE, - TextToSpeech.LANG_COUNTRY_VAR_AVAILABLE -> { - currentLang.set(args.lang) - invoke.resolve() - } - else -> { - invoke.reject("Language not supported: ${args.lang}") - } - } - } catch (e: Exception) { - invoke.reject("Exception setting language: ${e.message}") - } - } - @Command fun set_rate(invoke: Invoke) { val args = invoke.parseArgs(SetRateArgs::class.java) @@ -352,7 +336,6 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { if (targetVoice != null) { val result = textToSpeech?.setVoice(targetVoice) if (result == TextToSpeech.SUCCESS) { - currentVoiceId.set(args.voice) invoke.resolve() } else { invoke.reject("Failed to set voice: ${args.voice}") @@ -386,68 +369,6 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { } } - @Command - fun get_voices(invoke: Invoke) { - val args = invoke.parseArgs(GetVoicesArgs::class.java) - try { - val locale = Locale.forLanguageTag(args.lang) - val voices = textToSpeech?.voices?.filter { voice -> - voice.locale.language == locale.language - }?.map { voice -> - JSObject().apply { - put("id", voice.name) - put("name", voice.name) - put("lang", voice.locale.toLanguageTag()) - put("disabled", false) - } - } ?: emptyList() - - val result = JSObject().apply { - put("voices", JSONArray(voices)) - } - invoke.resolve(result) - } catch (e: Exception) { - invoke.reject("Exception getting voices for language: ${e.message}") - } - } - - @Command - fun get_granularities(invoke: Invoke) { - try { - val granularities = TTSGranularity.values().map { it.value } - val result = JSObject().apply { - put("granularities", JSONArray(granularities)) - } - invoke.resolve(result) - } catch (e: Exception) { - invoke.reject("Exception getting granularities: ${e.message}") - } - } - - @Command - fun get_voice_id(invoke: Invoke) { - try { - val result = JSObject().apply { - put("voiceId", currentVoiceId.get()) - } - invoke.resolve(result) - } catch (e: Exception) { - invoke.reject("Exception getting voice ID: ${e.message}") - } - } - - @Command - fun get_speaking_lang(invoke: Invoke) { - try { - val result = JSObject().apply { - put("lang", currentLang.get()) - } - invoke.resolve(result) - } catch (e: Exception) { - invoke.reject("Exception getting speaking language: ${e.message}") - } - } - fun destroy() { coroutineScope.cancel() textToSpeech?.shutdown() @@ -457,29 +378,3 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { speakingJobs.clear() } } - -// Data classes for command arguments -data class SpeakArgs( - val ssml: String, - val preload: Boolean? = false -) - -data class SetLangArgs( - val lang: String -) - -data class SetRateArgs( - val rate: Float -) - -data class SetPitchArgs( - val pitch: Float -) - -data class SetVoiceArgs( - val voice: String -) - -data class GetVoicesArgs( - val lang: String -) \ No newline at end of file diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/build.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/build.rs index e6bab73d..a7d2cd81 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/build.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/build.rs @@ -7,12 +7,9 @@ const COMMANDS: &[&str] = &[ "set_rate", "set_pitch", "set_voice", - "set_primary_lang", - "get_voices", - "get_voice_id", "get_all_voices", - "get_granularities", - "get_speaking_lang", + "registerListener", + "remove_listener", ]; fn main() { diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/get_granularities.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/get_granularities.toml deleted file mode 100644 index 3bf10d09..00000000 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/get_granularities.toml +++ /dev/null @@ -1,13 +0,0 @@ -# Automatically generated - DO NOT EDIT! - -"$schema" = "../../schemas/schema.json" - -[[permission]] -identifier = "allow-get-granularities" -description = "Enables the get_granularities command without any pre-configured scope." -commands.allow = ["get_granularities"] - -[[permission]] -identifier = "deny-get-granularities" -description = "Denies the get_granularities command without any pre-configured scope." -commands.deny = ["get_granularities"] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/get_speaking_lang.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/get_speaking_lang.toml deleted file mode 100644 index d8157db1..00000000 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/get_speaking_lang.toml +++ /dev/null @@ -1,13 +0,0 @@ -# Automatically generated - DO NOT EDIT! - -"$schema" = "../../schemas/schema.json" - -[[permission]] -identifier = "allow-get-speaking-lang" -description = "Enables the get_speaking_lang command without any pre-configured scope." -commands.allow = ["get_speaking_lang"] - -[[permission]] -identifier = "deny-get-speaking-lang" -description = "Denies the get_speaking_lang command without any pre-configured scope." -commands.deny = ["get_speaking_lang"] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/get_voice_id.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/get_voice_id.toml deleted file mode 100644 index 8ff6b0a0..00000000 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/get_voice_id.toml +++ /dev/null @@ -1,13 +0,0 @@ -# Automatically generated - DO NOT EDIT! - -"$schema" = "../../schemas/schema.json" - -[[permission]] -identifier = "allow-get-voice-id" -description = "Enables the get_voice_id command without any pre-configured scope." -commands.allow = ["get_voice_id"] - -[[permission]] -identifier = "deny-get-voice-id" -description = "Denies the get_voice_id command without any pre-configured scope." -commands.deny = ["get_voice_id"] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/get_voices.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/get_voices.toml deleted file mode 100644 index c0949c07..00000000 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/get_voices.toml +++ /dev/null @@ -1,13 +0,0 @@ -# Automatically generated - DO NOT EDIT! - -"$schema" = "../../schemas/schema.json" - -[[permission]] -identifier = "allow-get-voices" -description = "Enables the get_voices command without any pre-configured scope." -commands.allow = ["get_voices"] - -[[permission]] -identifier = "deny-get-voices" -description = "Denies the get_voices command without any pre-configured scope." -commands.deny = ["get_voices"] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/registerListener.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/registerListener.toml new file mode 100644 index 00000000..f035e956 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/registerListener.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-registerListener" +description = "Enables the registerListener command without any pre-configured scope." +commands.allow = ["registerListener"] + +[[permission]] +identifier = "deny-registerListener" +description = "Denies the registerListener command without any pre-configured scope." +commands.deny = ["registerListener"] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/remove_listener.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/remove_listener.toml new file mode 100644 index 00000000..9f315e51 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/remove_listener.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-remove-listener" +description = "Enables the remove_listener command without any pre-configured scope." +commands.allow = ["remove_listener"] + +[[permission]] +identifier = "deny-remove-listener" +description = "Denies the remove_listener command without any pre-configured scope." +commands.deny = ["remove_listener"] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/set_primary_lang.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/set_primary_lang.toml deleted file mode 100644 index 28fe8d64..00000000 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/commands/set_primary_lang.toml +++ /dev/null @@ -1,13 +0,0 @@ -# Automatically generated - DO NOT EDIT! - -"$schema" = "../../schemas/schema.json" - -[[permission]] -identifier = "allow-set-primary-lang" -description = "Enables the set_primary_lang command without any pre-configured scope." -commands.allow = ["set_primary_lang"] - -[[permission]] -identifier = "deny-set-primary-lang" -description = "Denies the set_primary_lang command without any pre-configured scope." -commands.deny = ["set_primary_lang"] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/reference.md b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/reference.md index 386c66d3..080bd489 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/reference.md +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/autogenerated/reference.md @@ -12,12 +12,9 @@ Default permissions for the plugin - `allow-set-rate` - `allow-set-pitch` - `allow-set-voice` -- `allow-set-primary-lang` -- `allow-get-voices` -- `allow-get-voice-id` - `allow-get-all-voices` -- `allow-get-granularities` -- `allow-get-speaking-lang` +- `allow-registerListener` +- `allow-remove-listener` ## Permission Table @@ -57,110 +54,6 @@ Denies the get_all_voices command without any pre-configured scope. -`native-tts:allow-get-granularities` - - - - -Enables the get_granularities command without any pre-configured scope. - - - - - - - -`native-tts:deny-get-granularities` - - - - -Denies the get_granularities command without any pre-configured scope. - - - - - - - -`native-tts:allow-get-speaking-lang` - - - - -Enables the get_speaking_lang command without any pre-configured scope. - - - - - - - -`native-tts:deny-get-speaking-lang` - - - - -Denies the get_speaking_lang command without any pre-configured scope. - - - - - - - -`native-tts:allow-get-voice-id` - - - - -Enables the get_voice_id command without any pre-configured scope. - - - - - - - -`native-tts:deny-get-voice-id` - - - - -Denies the get_voice_id command without any pre-configured scope. - - - - - - - -`native-tts:allow-get-voices` - - - - -Enables the get_voices command without any pre-configured scope. - - - - - - - -`native-tts:deny-get-voices` - - - - -Denies the get_voices command without any pre-configured scope. - - - - - - - `native-tts:allow-init` @@ -213,6 +106,58 @@ Denies the pause command without any pre-configured scope. +`native-tts:allow-registerListener` + + + + +Enables the registerListener command without any pre-configured scope. + + + + + + + +`native-tts:deny-registerListener` + + + + +Denies the registerListener command without any pre-configured scope. + + + + + + + +`native-tts:allow-remove-listener` + + + + +Enables the remove_listener command without any pre-configured scope. + + + + + + + +`native-tts:deny-remove-listener` + + + + +Denies the remove_listener command without any pre-configured scope. + + + + + + + `native-tts:allow-resume` @@ -265,32 +210,6 @@ Denies the set_pitch command without any pre-configured scope. -`native-tts:allow-set-primary-lang` - - - - -Enables the set_primary_lang command without any pre-configured scope. - - - - - - - -`native-tts:deny-set-primary-lang` - - - - -Denies the set_primary_lang command without any pre-configured scope. - - - - - - - `native-tts:allow-set-rate` diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/default.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/default.toml index c03b1de4..7c2de83c 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/default.toml +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/default.toml @@ -9,10 +9,7 @@ permissions = [ "allow-set-rate", "allow-set-pitch", "allow-set-voice", - "allow-set-primary-lang", - "allow-get-voices", - "allow-get-voice-id", "allow-get-all-voices", - "allow-get-granularities", - "allow-get-speaking-lang", + "allow-registerListener", + "allow-remove-listener", ] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/schemas/schema.json b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/schemas/schema.json index 56b85518..4cf53bc9 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/schemas/schema.json +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/permissions/schemas/schema.json @@ -306,54 +306,6 @@ "const": "deny-get-all-voices", "markdownDescription": "Denies the get_all_voices command without any pre-configured scope." }, - { - "description": "Enables the get_granularities command without any pre-configured scope.", - "type": "string", - "const": "allow-get-granularities", - "markdownDescription": "Enables the get_granularities command without any pre-configured scope." - }, - { - "description": "Denies the get_granularities command without any pre-configured scope.", - "type": "string", - "const": "deny-get-granularities", - "markdownDescription": "Denies the get_granularities command without any pre-configured scope." - }, - { - "description": "Enables the get_speaking_lang command without any pre-configured scope.", - "type": "string", - "const": "allow-get-speaking-lang", - "markdownDescription": "Enables the get_speaking_lang command without any pre-configured scope." - }, - { - "description": "Denies the get_speaking_lang command without any pre-configured scope.", - "type": "string", - "const": "deny-get-speaking-lang", - "markdownDescription": "Denies the get_speaking_lang command without any pre-configured scope." - }, - { - "description": "Enables the get_voice_id command without any pre-configured scope.", - "type": "string", - "const": "allow-get-voice-id", - "markdownDescription": "Enables the get_voice_id command without any pre-configured scope." - }, - { - "description": "Denies the get_voice_id command without any pre-configured scope.", - "type": "string", - "const": "deny-get-voice-id", - "markdownDescription": "Denies the get_voice_id command without any pre-configured scope." - }, - { - "description": "Enables the get_voices command without any pre-configured scope.", - "type": "string", - "const": "allow-get-voices", - "markdownDescription": "Enables the get_voices command without any pre-configured scope." - }, - { - "description": "Denies the get_voices command without any pre-configured scope.", - "type": "string", - "const": "deny-get-voices", - "markdownDescription": "Denies the get_voices command without any pre-configured scope." - }, { "description": "Enables the init command without any pre-configured scope.", "type": "string", @@ -378,6 +330,30 @@ "const": "deny-pause", "markdownDescription": "Denies the pause command without any pre-configured scope." }, + { + "description": "Enables the registerListener command without any pre-configured scope.", + "type": "string", + "const": "allow-registerListener", + "markdownDescription": "Enables the registerListener command without any pre-configured scope." + }, + { + "description": "Denies the registerListener command without any pre-configured scope.", + "type": "string", + "const": "deny-registerListener", + "markdownDescription": "Denies the registerListener command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, { "description": "Enables the resume command without any pre-configured scope.", "type": "string", @@ -402,18 +378,6 @@ "const": "deny-set-pitch", "markdownDescription": "Denies the set_pitch command without any pre-configured scope." }, - { - "description": "Enables the set_primary_lang command without any pre-configured scope.", - "type": "string", - "const": "allow-set-primary-lang", - "markdownDescription": "Enables the set_primary_lang command without any pre-configured scope." - }, - { - "description": "Denies the set_primary_lang command without any pre-configured scope.", - "type": "string", - "const": "deny-set-primary-lang", - "markdownDescription": "Denies the set_primary_lang command without any pre-configured scope." - }, { "description": "Enables the set_rate command without any pre-configured scope.", "type": "string", @@ -463,10 +427,10 @@ "markdownDescription": "Denies the stop command without any pre-configured scope." }, { - "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-init`\n- `allow-speak`\n- `allow-stop`\n- `allow-pause`\n- `allow-resume`\n- `allow-set-rate`\n- `allow-set-pitch`\n- `allow-set-voice`\n- `allow-set-primary-lang`\n- `allow-get-voices`\n- `allow-get-voice-id`\n- `allow-get-all-voices`\n- `allow-get-granularities`\n- `allow-get-speaking-lang`", + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-init`\n- `allow-speak`\n- `allow-stop`\n- `allow-pause`\n- `allow-resume`\n- `allow-set-rate`\n- `allow-set-pitch`\n- `allow-set-voice`\n- `allow-get-all-voices`\n- `allow-registerListener`\n- `allow-remove-listener`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-init`\n- `allow-speak`\n- `allow-stop`\n- `allow-pause`\n- `allow-resume`\n- `allow-set-rate`\n- `allow-set-pitch`\n- `allow-set-voice`\n- `allow-set-primary-lang`\n- `allow-get-voices`\n- `allow-get-voice-id`\n- `allow-get-all-voices`\n- `allow-get-granularities`\n- `allow-get-speaking-lang`" + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-init`\n- `allow-speak`\n- `allow-stop`\n- `allow-pause`\n- `allow-resume`\n- `allow-set-rate`\n- `allow-set-pitch`\n- `allow-set-voice`\n- `allow-get-all-voices`\n- `allow-registerListener`\n- `allow-remove-listener`" } ] } diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/commands.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/commands.rs index d1d2f779..d7978b42 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/commands.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/commands.rs @@ -5,74 +5,49 @@ use crate::NativeTtsExt; use crate::Result; #[command] -pub async fn init(app: AppHandle) -> Result { +pub(crate) async fn init(app: AppHandle) -> Result { app.native_tts().init() } #[command] -pub async fn speak(app: AppHandle, args: SpeakArgs) -> Result { - app.native_tts().speak(args) +pub(crate) async fn speak( + app: AppHandle, + payload: SpeakArgs, +) -> Result { + app.native_tts().speak(payload) } #[command] -pub async fn pause(app: AppHandle) -> Result<()> { +pub(crate) async fn pause(app: AppHandle) -> Result<()> { app.native_tts().pause() } #[command] -pub async fn resume(app: AppHandle) -> Result<()> { +pub(crate) async fn resume(app: AppHandle) -> Result<()> { app.native_tts().resume() } #[command] -pub async fn stop(app: AppHandle) -> Result<()> { +pub(crate) async fn stop(app: AppHandle) -> Result<()> { app.native_tts().stop() } #[command] -pub async fn set_primary_lang(app: AppHandle, args: SetLangArgs) -> Result<()> { - app.native_tts().set_primary_lang(args) +pub(crate) async fn set_rate(app: AppHandle, payload: SetRateArgs) -> Result<()> { + app.native_tts().set_rate(payload) } #[command] -pub async fn set_rate(app: AppHandle, args: SetRateArgs) -> Result<()> { - app.native_tts().set_rate(args) +pub(crate) async fn set_pitch(app: AppHandle, payload: SetPitchArgs) -> Result<()> { + app.native_tts().set_pitch(payload) } #[command] -pub async fn set_pitch(app: AppHandle, args: SetPitchArgs) -> Result<()> { - app.native_tts().set_pitch(args) +pub(crate) async fn set_voice(app: AppHandle, payload: SetVoiceArgs) -> Result<()> { + app.native_tts().set_voice(payload) } #[command] -pub async fn set_voice(app: AppHandle, args: SetVoiceArgs) -> Result<()> { - app.native_tts().set_voice(args) -} - -#[command] -pub async fn get_all_voices(app: AppHandle) -> Result> { +pub(crate) async fn get_all_voices(app: AppHandle) -> Result { app.native_tts().get_all_voices() } - -#[command] -pub async fn get_voices( - app: AppHandle, - args: GetVoicesArgs, -) -> Result> { - app.native_tts().get_voices(args) -} - -#[command] -pub async fn get_granularities(app: AppHandle) -> Result> { - app.native_tts().get_granularities() -} - -#[command] -pub async fn get_voice_id(app: AppHandle) -> Result { - app.native_tts().get_voice_id() -} - -#[command] -pub async fn get_speaking_lang(app: AppHandle) -> Result { - app.native_tts().get_speaking_lang() -} diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/desktop.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/desktop.rs index 0223bcd2..42fe356c 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/desktop.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/desktop.rs @@ -14,10 +14,10 @@ pub fn init( pub struct NativeTts(AppHandle); impl NativeTts { - pub fn init(&self) -> crate::Result { + pub fn init(&self) -> crate::Result { Err(crate::Error::UnsupportedPlatformError) } - pub fn speak(&self, _args: SpeakArgs) -> crate::Result { + pub fn speak(&self, _args: SpeakArgs) -> crate::Result { Err(crate::Error::UnsupportedPlatformError) } pub fn pause(&self) -> crate::Result<()> { @@ -29,9 +29,6 @@ impl NativeTts { pub fn stop(&self) -> crate::Result<()> { Err(crate::Error::UnsupportedPlatformError) } - pub fn set_primary_lang(&self, _args: SetLangArgs) -> crate::Result<()> { - Err(crate::Error::UnsupportedPlatformError) - } pub fn set_rate(&self, _args: SetRateArgs) -> crate::Result<()> { Err(crate::Error::UnsupportedPlatformError) } @@ -41,19 +38,7 @@ impl NativeTts { pub fn set_voice(&self, _args: SetVoiceArgs) -> crate::Result<()> { Err(crate::Error::UnsupportedPlatformError) } - pub fn get_all_voices(&self) -> crate::Result> { - Err(crate::Error::UnsupportedPlatformError) - } - pub fn get_voices(&self, _args: GetVoicesArgs) -> crate::Result> { - Err(crate::Error::UnsupportedPlatformError) - } - pub fn get_granularities(&self) -> crate::Result> { - Err(crate::Error::UnsupportedPlatformError) - } - pub fn get_voice_id(&self) -> crate::Result { - Err(crate::Error::UnsupportedPlatformError) - } - pub fn get_speaking_lang(&self) -> crate::Result { + pub fn get_all_voices(&self) -> crate::Result { Err(crate::Error::UnsupportedPlatformError) } } diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/lib.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/lib.rs index 36137c0d..20045008 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/lib.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/lib.rs @@ -44,12 +44,7 @@ pub fn init() -> TauriPlugin { commands::set_rate, commands::set_pitch, commands::set_voice, - commands::set_primary_lang, - commands::get_voices, - commands::get_voice_id, commands::get_all_voices, - commands::get_granularities, - commands::get_speaking_lang ]) .setup(|app, api| { #[cfg(mobile)] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/mobile.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/mobile.rs index f1bc3968..fcc181c6 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/mobile.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/mobile.rs @@ -25,14 +25,16 @@ pub fn init( pub struct NativeTts(PluginHandle); impl NativeTts { - pub fn init(&self) -> crate::Result { + pub fn init(&self) -> crate::Result { self.0.run_mobile_plugin("init", ()).map_err(Into::into) } } impl NativeTts { - pub fn speak(&self, args: SpeakArgs) -> crate::Result { - self.0.run_mobile_plugin("speak", args).map_err(Into::into) + pub fn speak(&self, payload: SpeakArgs) -> crate::Result { + self.0 + .run_mobile_plugin("speak", payload) + .map_err(Into::into) } } @@ -55,73 +57,33 @@ impl NativeTts { } impl NativeTts { - pub fn set_primary_lang(&self, args: SetLangArgs) -> crate::Result<()> { + pub fn set_rate(&self, payload: SetRateArgs) -> crate::Result<()> { self.0 - .run_mobile_plugin("set_primary_lang", args) + .run_mobile_plugin("set_rate", payload) .map_err(Into::into) } } impl NativeTts { - pub fn set_rate(&self, args: SetRateArgs) -> crate::Result<()> { + pub fn set_pitch(&self, payload: SetPitchArgs) -> crate::Result<()> { self.0 - .run_mobile_plugin("set_rate", args) + .run_mobile_plugin("set_pitch", payload) .map_err(Into::into) } } impl NativeTts { - pub fn set_pitch(&self, args: SetPitchArgs) -> crate::Result<()> { + pub fn set_voice(&self, payload: SetVoiceArgs) -> crate::Result<()> { self.0 - .run_mobile_plugin("set_pitch", args) + .run_mobile_plugin("set_voice", payload) .map_err(Into::into) } } impl NativeTts { - pub fn set_voice(&self, args: SetVoiceArgs) -> crate::Result<()> { - self.0 - .run_mobile_plugin("set_voice", args) - .map_err(Into::into) - } -} - -impl NativeTts { - pub fn get_voices(&self, args: GetVoicesArgs) -> crate::Result> { - self.0 - .run_mobile_plugin("get_voices", args) - .map_err(Into::into) - } -} - -impl NativeTts { - pub fn get_all_voices(&self) -> crate::Result> { + pub fn get_all_voices(&self) -> crate::Result { self.0 .run_mobile_plugin("get_all_voices", ()) .map_err(Into::into) } } - -impl NativeTts { - pub fn get_granularities(&self) -> crate::Result> { - self.0 - .run_mobile_plugin("get_granularities", ()) - .map_err(Into::into) - } -} - -impl NativeTts { - pub fn get_voice_id(&self) -> crate::Result { - self.0 - .run_mobile_plugin("get_voice_id", ()) - .map_err(Into::into) - } -} - -impl NativeTts { - pub fn get_speaking_lang(&self) -> crate::Result { - self.0 - .run_mobile_plugin("get_speaking_lang", ()) - .map_err(Into::into) - } -} diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/models.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/models.rs index 6d6f04d8..8689673a 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/models.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/models.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct TTSVoice { pub id: String, pub name: String, @@ -10,50 +11,53 @@ pub struct TTSVoice { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct TTSMessageEvent { pub code: String, // 'boundary' | 'error' | 'end' pub message: Option, pub mark: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum TTSGranularity { - #[serde(rename = "word")] - Word, - #[serde(rename = "sentence")] - Sentence, - #[serde(rename = "paragraph")] - Paragraph, +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InitResponse { + pub success: bool, } #[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct SpeakArgs { - pub ssml: String, + pub text: String, #[serde(default)] pub preload: bool, } #[derive(Debug, Serialize, Deserialize)] -pub struct SetLangArgs { - pub lang: String, +#[serde(rename_all = "camelCase")] +pub struct SpeakResponse { + pub utterance_id: String, } #[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct SetRateArgs { pub rate: f32, } #[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct SetPitchArgs { pub pitch: f32, } #[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct SetVoiceArgs { pub voice: String, } #[derive(Debug, Serialize, Deserialize)] -pub struct GetVoicesArgs { - pub lang: String, +#[serde(rename_all = "camelCase")] +pub struct GetVoicesResponse { + pub voices: Vec, } diff --git a/apps/readest-app/src-tauri/src/lib.rs b/apps/readest-app/src-tauri/src/lib.rs index 1d25d548..dee2ce37 100644 --- a/apps/readest-app/src-tauri/src/lib.rs +++ b/apps/readest-app/src-tauri/src/lib.rs @@ -134,6 +134,7 @@ pub fn run() { .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_native_bridge::init()) + .plugin(tauri_plugin_native_tts::init()) .plugin(tauri_plugin_fs::init()); #[cfg(desktop)] diff --git a/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx b/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx index f9e0fa62..e5815a68 100644 --- a/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx +++ b/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx @@ -90,7 +90,7 @@ const TTSControl = () => { useEffect(() => { return () => { if (ttsControllerRef.current) { - ttsControllerRef.current.kill(); + ttsControllerRef.current.shutdown(); ttsControllerRef.current = null; } }; @@ -202,7 +202,7 @@ const TTSControl = () => { unblockAudio(); } setTtsClientsInitialized(false); - const ttsController = new TTSController(view); + const ttsController = new TTSController(appService, view); await ttsController.init(); await ttsController.initViewTTS(); const ssml = view.tts?.from(ttsFromRange); diff --git a/apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx b/apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx index 86ae084d..12344990 100644 --- a/apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx +++ b/apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx @@ -3,7 +3,7 @@ import React, { useState, ChangeEvent, useEffect } from 'react'; import { MdPlayCircle, MdPauseCircle, MdFastRewind, MdFastForward, MdAlarm } from 'react-icons/md'; import { RiVoiceAiFill } from 'react-icons/ri'; import { MdCheck } from 'react-icons/md'; -import { TTSVoice } from '@/services/tts'; +import { TTSVoicesGroup } from '@/services/tts'; import { useEnv } from '@/context/EnvContext'; import { useReaderStore } from '@/store/readerStore'; import { TranslationFunc, useTranslation } from '@/hooks/useTranslation'; @@ -20,7 +20,7 @@ type TTSPanelProps = { onBackward: () => void; onForward: () => void; onSetRate: (rate: number) => void; - onGetVoices: (lang: string) => Promise; + onGetVoices: (lang: string) => Promise; onSetVoice: (voice: string, lang: string) => void; onGetVoiceId: () => string; onSelectTimeout: (bookKey: string, value: number) => void; @@ -119,7 +119,7 @@ const TTSPanel = ({ const { settings, setSettings, saveSettings } = useSettingsStore(); const viewSettings = getViewSettings(bookKey); - const [voices, setVoices] = useState([]); + const [voiceGroups, setVoiceGroups] = useState([]); const [rate, setRate] = useState(viewSettings?.ttsRate ?? 1.0); const [selectedVoice, setSelectedVoice] = useState(viewSettings?.ttsVoice ?? ''); @@ -177,8 +177,8 @@ const TTSPanel = ({ useEffect(() => { const fetchVoices = async () => { - const voices = await onGetVoices(ttsLang); - setVoices(voices); + const voiceGroups = await onGetVoices(ttsLang); + setVoiceGroups(voiceGroups); }; fetchVoices(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -261,7 +261,12 @@ const TTSPanel = ({ onClick={() => onSelectTimeout(bookKey, option.value)} >
- + {timeoutOption === option.value && } {option.label} @@ -282,21 +287,52 @@ const TTSPanel = ({ 'mt-4 inline max-h-96 w-[250px] overflow-y-scroll', )} > - {voices.map((voice, index) => ( -
  • !voice.disabled && handleSelectVoice(voice.id, voice.lang)} - > -
    - - {selectedVoice === voice.id && } - - - {voice.name} - -
    -
  • - ))} + {voiceGroups.map((voiceGroup, index) => { + return ( + voiceGroup.voices.length > 0 && ( +
    +
    + + + {_('{{engine}}: {{count}} voices', { + engine: _(voiceGroup.name), + count: voiceGroup.voices.length, + })} + +
    + {voiceGroup.voices.map((voice, voiceIndex) => ( +
  • !voice.disabled && handleSelectVoice(voice.id, voice.lang)} + > +
    + + {selectedVoice === voice.id && ( + + )} + + + {_(voice.name)} + +
    +
  • + ))} +
    + ) + ); + })}
    diff --git a/apps/readest-app/src/libs/edgeTTS.ts b/apps/readest-app/src/libs/edgeTTS.ts index 7c809272..79416785 100644 --- a/apps/readest-app/src/libs/edgeTTS.ts +++ b/apps/readest-app/src/libs/edgeTTS.ts @@ -1,6 +1,7 @@ import { md5 } from 'js-md5'; import { randomMd5 } from '@/utils/misc'; import { LRUCache } from '@/utils/lru'; +import { genSSML } from '@/utils/ssml'; const EDGE_SPEECH_URL = 'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1'; @@ -173,19 +174,6 @@ export class EdgeSpeechTTS { }, }); - const genSSML = (lang: string, text: string, voice: string, rate: number) => { - const cleanedText = text.replace(/^]*>/i, ''); - return ` - - - - ${cleanedText} - - - - `; - }; - const genSendContent = (headerObj: Record, content: string) => { let header = ''; for (const key of Object.keys(headerObj)) { diff --git a/apps/readest-app/src/services/tts/EdgeTTSClient.ts b/apps/readest-app/src/services/tts/EdgeTTSClient.ts index 7e5bd800..9e8e02ae 100644 --- a/apps/readest-app/src/services/tts/EdgeTTSClient.ts +++ b/apps/readest-app/src/services/tts/EdgeTTSClient.ts @@ -1,26 +1,28 @@ import { getUserLocale } from '@/utils/misc'; -import { TTSClient, TTSMessageEvent, TTSVoice } from './TTSClient'; +import { TTSClient, TTSMessageEvent } from './TTSClient'; import { EdgeSpeechTTS, EdgeTTSPayload } from '@/libs/edgeTTS'; import { parseSSMLLang, parseSSMLMarks } from '@/utils/ssml'; -import { TTSGranularity } from '@/types/view'; import { TTSController } from './TTSController'; import { TTSUtils } from './TTSUtils'; +import { TTSGranularity, TTSVoice, TTSVoicesGroup } from './types'; export class EdgeTTSClient implements TTSClient { + name = 'edge-tts'; + initialized = false; controller?: TTSController; + + #voices: TTSVoice[] = []; #primaryLang = 'en'; #speakingLang = ''; + #currentVoiceId = ''; #rate = 1.0; #pitch = 1.0; - #voice: TTSVoice | null = null; - #voices: TTSVoice[] = []; - #edgeTTS: EdgeSpeechTTS; + #edgeTTS: EdgeSpeechTTS; #audioElement: HTMLAudioElement | null = null; #isPlaying = false; #pausedAt = 0; #startedAt = 0; - available = true; constructor(controller?: TTSController) { this.controller = controller; @@ -37,11 +39,11 @@ export class EdgeTTSClient implements TTSClient { rate: 1.0, pitch: 1.0, }); - this.available = true; + this.initialized = true; } catch { - this.available = false; + this.initialized = false; } - return this.available; + return this.initialized; } getPayload = (lang: string, text: string, voiceId: string) => { @@ -49,21 +51,15 @@ export class EdgeTTSClient implements TTSClient { }; getVoiceIdFromLang = async (lang: string) => { - let voiceId = 'en-US-AriaNeural'; - const preferredVoiceId = TTSUtils.getPreferredVoice('edge-tts', lang); + const preferredVoiceId = TTSUtils.getPreferredVoice(this.name, lang); const preferredVoice = this.#voices.find((v) => v.id === preferredVoiceId); - this.#voice = preferredVoice ? preferredVoice : (await this.getVoices(lang))[0] || null; - if (this.#voice) { - voiceId = this.#voice.id; - } - return voiceId; + const defaultVoice = preferredVoice + ? preferredVoice + : (await this.getVoices(lang))[0]?.voices[0] || null; + return defaultVoice?.id || this.#currentVoiceId || 'en-US-AriaNeural'; }; - async *speak( - ssml: string, - signal: AbortSignal, - preload = false, - ): AsyncGenerator { + async *speak(ssml: string, signal: AbortSignal, preload = false) { const { marks } = parseSSMLMarks(ssml); let defaultLang = parseSSMLLang(ssml) || 'en'; if (defaultLang === 'en' && this.#primaryLang && this.#primaryLang !== 'en') { @@ -78,6 +74,7 @@ export class EdgeTTSClient implements TTSClient { const { language } = mark; const voiceLang = language || defaultLang; const voiceId = await this.getVoiceIdFromLang(voiceLang); + this.#currentVoiceId = voiceId; await this.#edgeTTS .createAudio(this.getPayload(voiceLang, mark.text, voiceId)) .catch((err) => { @@ -103,7 +100,7 @@ export class EdgeTTSClient implements TTSClient { yield { code: 'end', message: 'Preload finished', - }; + } as TTSMessageEvent; return; } else { @@ -115,7 +112,7 @@ export class EdgeTTSClient implements TTSClient { yield { code: 'error', message: 'Aborted', - }; + } as TTSMessageEvent; break; } try { @@ -138,7 +135,7 @@ export class EdgeTTSClient implements TTSClient { code: 'boundary', message: `Start chunk: ${mark.name}`, mark: mark.name, - }; + } as TTSMessageEvent; const result = await new Promise((resolve) => { const cleanUp = () => { @@ -180,14 +177,14 @@ export class EdgeTTSClient implements TTSClient { yield { code: 'end', message: `Chunk finished: ${mark.name}`, - }; + } as TTSMessageEvent; continue; } console.log('Error:', error); yield { code: 'error', message: error instanceof Error ? error.message : String(error), - }; + } as TTSMessageEvent; break; } @@ -196,17 +193,19 @@ export class EdgeTTSClient implements TTSClient { } async pause() { - if (!this.#isPlaying || !this.#audioElement) return; + if (!this.#isPlaying || !this.#audioElement) return true; this.#pausedAt = this.#audioElement.currentTime - this.#startedAt; await this.#audioElement.pause(); this.#isPlaying = false; + return true; } async resume() { - if (this.#isPlaying || !this.#audioElement) return; + if (this.#isPlaying || !this.#audioElement) return true; await this.#audioElement.play(); this.#isPlaying = true; this.#startedAt = this.#audioElement.currentTime - this.#pausedAt; + return true; } async stop() { @@ -231,10 +230,6 @@ export class EdgeTTSClient implements TTSClient { } } - setPrimaryLang(lang: string) { - this.#primaryLang = lang; - } - async setRate(rate: number) { // The Edge TTS API uses rate in [0.5 .. 2.0]. this.#rate = rate; @@ -248,42 +243,36 @@ export class EdgeTTSClient implements TTSClient { async setVoice(voice: string) { const selectedVoice = this.#voices.find((v) => v.id === voice); if (selectedVoice) { - this.#voice = selectedVoice; + this.#currentVoiceId = selectedVoice.id; } } async getAllVoices(): Promise { this.#voices.forEach((voice) => { - voice.disabled = !this.available; + voice.disabled = !this.initialized; }); return this.#voices; } - async getVoices(lang: string): Promise { + async getVoices(lang: string) { const locale = lang === 'en' ? getUserLocale(lang) || lang : lang; const voices = await this.getAllVoices(); - return voices - .filter( - (v) => v.lang.startsWith(locale) || (lang === 'en' && ['en-US', 'en-GB'].includes(v.lang)), - ) - .sort((a, b) => { - const aRegion = a.lang.split('-')[1] || ''; - const bRegion = b.lang.split('-')[1] || ''; - if (aRegion === bRegion) { - return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; - } - if (aRegion === 'CN') return -1; - if (bRegion === 'CN') return 1; - if (aRegion === 'TW') return -1; - if (bRegion === 'TW') return 1; - if (aRegion === 'HK') return -1; - if (bRegion === 'HK') return 1; - if (aRegion === 'US') return -1; - if (bRegion === 'US') return 1; - if (aRegion === 'GB') return -1; - if (bRegion === 'GB') return 1; - return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; - }); + const filteredVoices = voices.filter( + (v) => v.lang.startsWith(locale) || (lang === 'en' && ['en-US', 'en-GB'].includes(v.lang)), + ); + + const voicesGroup: TTSVoicesGroup = { + id: 'edge-tts', + name: 'Edge TTS', + voices: filteredVoices, + disabled: !this.initialized || filteredVoices.length === 0, + }; + + return [voicesGroup]; + } + + setPrimaryLang(lang: string) { + this.#primaryLang = lang; } getGranularities(): TTSGranularity[] { @@ -291,10 +280,16 @@ export class EdgeTTSClient implements TTSClient { } getVoiceId(): string { - return this.#voice?.id || ''; + return this.#currentVoiceId; } getSpeakingLang(): string { return this.#speakingLang; } + + async shutdown(): Promise { + this.initialized = false; + this.#audioElement = null; + this.#voices = []; + } } diff --git a/apps/readest-app/src/services/tts/NativeTTSClient.ts b/apps/readest-app/src/services/tts/NativeTTSClient.ts new file mode 100644 index 00000000..1244acd7 --- /dev/null +++ b/apps/readest-app/src/services/tts/NativeTTSClient.ts @@ -0,0 +1,308 @@ +import { invoke } from '@tauri-apps/api/core'; +import { addPluginListener, PluginListener } from '@tauri-apps/api/core'; +import { getUserLocale } from '@/utils/misc'; +import { parseSSMLLang, parseSSMLMarks } from '@/utils/ssml'; +import { stubTranslation as _ } from '@/utils/misc'; +import { TTSClient, TTSMessageEvent } from './TTSClient'; +import { TTSGranularity, TTSMark, TTSVoice, TTSVoicesGroup } from './types'; +import { TTSUtils } from './TTSUtils'; +import { TTSController } from './TTSController'; + +type TTSEventPayload = { + utteranceId: string; +} & TTSMessageEvent; + +const TTSEngines = { + default: 'System TTS', + mstrans: 'MSTrans TTS', + microsoft: 'Microsoft TTS', + edgetts: 'Edge TTS', + google: 'Google TTS', + gemini: 'Gemini TTS', + weread: 'WeRead TTS', +} as Record; + +export class NativeTTSClient implements TTSClient { + name = 'native-tts'; + initialized = false; + controller?: TTSController; + + #voices: TTSVoice[] = []; + #primaryLang = 'en'; + #speakingLang = ''; + #currentVoiceId = ''; + #rate = 1.0; + #pitch = 1.0; + + #eventListener: PluginListener | null = null; + #activeUtterances = new Map< + string, + { + eventQueue: TTSMessageEvent[]; + resolver: ((value: IteratorResult) => void) | null; + finished: boolean; + } + >(); + + constructor(controller?: TTSController) { + this.controller = controller; + } + + private async setupEventListener(): Promise { + try { + if (this.#eventListener) return; + this.#eventListener = await addPluginListener( + 'native-tts', + 'tts_events', + (event) => { + const { utteranceId, code, message, mark } = event; + + const utteranceData = this.#activeUtterances.get(utteranceId); + if (!utteranceData) return; + + const ttsEvent: TTSMessageEvent = { code, message, mark }; + utteranceData.eventQueue.push(ttsEvent); + if (code === 'end' || code === 'error') { + utteranceData.finished = true; + if (utteranceData.resolver) { + utteranceData.resolver({ value: undefined, done: true }); + } + } else if (utteranceData.resolver) { + utteranceData.resolver({ value: ttsEvent, done: false }); + utteranceData.resolver = null; + } + }, + ); + } catch (error) { + console.error('Failed to setup TTS event listener:', error); + } + } + + async init(): Promise { + const result = await invoke<{ success: boolean }>('plugin:native-tts|init'); + this.initialized = result.success; + if (this.initialized) { + this.setupEventListener(); + } + return this.initialized; + } + + async *speakMark(mark: TTSMark, preload: boolean, defaultLang: string, signal: AbortSignal) { + if (preload) { + yield { code: 'end', message: 'Dummy preload finished' } as TTSMessageEvent; + return; + } + const { language } = mark; + const voiceLang = language || defaultLang; + const voiceId = await this.getVoiceIdFromLang(voiceLang); + this.#currentVoiceId = voiceId; + this.#speakingLang = voiceLang; + await this.setVoice(voiceId); + try { + const result = await invoke<{ utteranceId: string }>('plugin:native-tts|speak', { + payload: { text: mark.text, preload }, + }); + + const utteranceId = result.utteranceId; + this.#activeUtterances.set(utteranceId, { + eventQueue: [], + resolver: null, + finished: false, + }); + + const abortHandler = () => { + const utteranceData = this.#activeUtterances.get(utteranceId); + if (utteranceData && utteranceData.resolver) { + const error = { code: 'error', message: 'Aborted' } as TTSMessageEvent; + utteranceData.resolver({ value: error, done: true }); + utteranceData.resolver = null; + } + this.stop(); + }; + + signal.addEventListener('abort', abortHandler); + + try { + while (true) { + const utteranceData = this.#activeUtterances.get(utteranceId); + if (!utteranceData) break; + + if (utteranceData.eventQueue.length > 0) { + const event = utteranceData.eventQueue.shift()!; + event.mark = mark.name; + yield event; + + if (event.code === 'end' || event.code === 'error') { + break; + } + } else if (utteranceData.finished) { + break; + } else { + const eventPromise = new Promise((resolve) => { + const utteranceData = this.#activeUtterances.get(utteranceId); + if (!utteranceData) return resolve(); + utteranceData.resolver = (res) => + resolve(res.value?.code === 'error' ? res.value : undefined); + }); + const timeoutPromise = new Promise((resolve) => + setTimeout(() => resolve(null), 100), + ); + const result = await Promise.race([eventPromise, timeoutPromise]); + if (result) yield result; + } + + if (signal.aborted) { + break; + } + } + } finally { + signal.removeEventListener('abort', abortHandler); + this.#activeUtterances.delete(utteranceId); + } + } catch (error) { + console.error('Failed to speak:', error); + throw error; + } + } + + async *speak(ssml: string, signal: AbortSignal, preload: boolean = false) { + const { marks } = parseSSMLMarks(ssml); + let defaultLang = parseSSMLLang(ssml) || 'en'; + if (defaultLang === 'en' && this.#primaryLang && this.#primaryLang !== 'en') { + defaultLang = this.#primaryLang; + } + + for (const mark of marks) { + for await (const ev of this.speakMark(mark, preload, defaultLang, signal)) { + if (signal.aborted) { + yield { code: 'error', message: 'Aborted' } as TTSMessageEvent; + return; + } + yield ev; + } + } + } + + async getVoiceIdFromLang(lang: string) { + const preferredVoiceId = TTSUtils.getPreferredVoice(this.name, lang); + const preferredVoice = this.#voices.find((v) => v.id === preferredVoiceId); + const defaultVoice = preferredVoice + ? preferredVoice + : (await this.getVoices(lang))[0]?.voices[0] || null; + return defaultVoice?.id || 'NOT_SET'; + } + + async pause() { + await invoke('plugin:native-tts|pause'); + return false; + } + + async resume() { + // No-op for Android TextToSpeech + await invoke('plugin:native-tts|resume'); + return false; + } + + async stop() { + await invoke('plugin:native-tts|stop'); + this.#activeUtterances.clear(); + } + + async setRate(rate: number) { + // Power the rate to match the EdgeTTS behavior + this.#rate = parseFloat(Math.pow(rate, 2.5).toFixed(2)); + await invoke('plugin:native-tts|set_rate', { payload: { rate: this.#rate } }); + } + + async setPitch(pitch: number) { + this.#pitch = pitch; + await invoke('plugin:native-tts|set_pitch', { payload: { pitch: this.#pitch } }); + } + + async setVoice(voice: string) { + this.#currentVoiceId = voice; + await invoke('plugin:native-tts|set_voice', { payload: { voice } }); + } + + async getAllVoices() { + if (this.#voices.length > 0) { + return this.#voices; + } + try { + const result = await invoke<{ voices: TTSVoice[] }>('plugin:native-tts|get_all_voices'); + this.#voices = result.voices; + return this.#voices; + } catch (error) { + console.error('Failed to get all voices:', error); + return []; + } + } + + async getVoices(lang: string) { + const locale = lang === 'en' ? getUserLocale(lang) || lang : lang; + const voices = await this.getAllVoices(); + const filteredVoices = voices.filter( + (v) => v.lang.startsWith(locale) || (lang === 'en' && ['en-US', 'en-GB'].includes(v.lang)), + ); + + const voiceGroups = new Map(); + filteredVoices.forEach((voice) => { + const { name, lang } = voice; + let groupId = voice.id.split('_')[0]!; + if (groupId in TTSEngines) { + voice.name = name + .replace(`${groupId}_`, '') + .replace(`${lang}-`, '') + .replace('Neural', '') + .trim(); + } else { + groupId = 'default'; + } + voice.name = voice.name.replace('NOT_SET', _('Default')); + if (!voiceGroups.has(groupId)) { + voiceGroups.set(groupId, []); + } + voiceGroups.get(groupId)!.push(voice); + }); + + return Array.from(voiceGroups.entries()) + .map( + ([groupId, voices]) => + ({ + id: groupId, + name: TTSEngines[groupId] || groupId, + voices: voices, + disabled: !this.initialized || voices.length === 0, + }) as TTSVoicesGroup, + ) + .sort((a, b) => { + if (a.id === 'default') return -1; + if (b.id === 'default') return 1; + return a.id.localeCompare(b.id); + }); + } + + setPrimaryLang(lang: string) { + this.#primaryLang = lang; + } + + getGranularities(): TTSGranularity[] { + return ['sentence']; + } + + getVoiceId() { + return this.#currentVoiceId; + } + + getSpeakingLang() { + return this.#speakingLang; + } + + async shutdown() { + if (this.#eventListener) { + this.#eventListener.unregister(); + this.#eventListener = null; + } + await this.stop(); + } +} diff --git a/apps/readest-app/src/services/tts/TTSClient.ts b/apps/readest-app/src/services/tts/TTSClient.ts index 9a6b68ef..330f91a2 100644 --- a/apps/readest-app/src/services/tts/TTSClient.ts +++ b/apps/readest-app/src/services/tts/TTSClient.ts @@ -1,6 +1,6 @@ -import { TTSGranularity } from '@/types/view'; +import { TTSGranularity, TTSVoice, TTSVoicesGroup } from './types'; -export type TTSMessageCode = 'boundary' | 'error' | 'end'; +type TTSMessageCode = 'boundary' | 'error' | 'end'; export interface TTSMessageEvent { code: TTSMessageCode; @@ -8,25 +8,21 @@ export interface TTSMessageEvent { mark?: string; } -export interface TTSVoice { - id: string; - name: string; - lang: string; - disabled?: boolean; -} - export interface TTSClient { + name: string; + initialized: boolean; init(): Promise; + shutdown(): Promise; speak(ssml: string, signal: AbortSignal, preload?: boolean): AsyncIterable; - pause(): Promise; - resume(): Promise; + pause(): Promise; + resume(): Promise; stop(): Promise; setPrimaryLang(lang: string): void; setRate(rate: number): Promise; setPitch(pitch: number): Promise; setVoice(voice: string): Promise; getAllVoices(): Promise; - getVoices(lang: string): Promise; + getVoices(lang: string): Promise; getGranularities(): TTSGranularity[]; getVoiceId(): string; getSpeakingLang(): string; diff --git a/apps/readest-app/src/services/tts/TTSController.ts b/apps/readest-app/src/services/tts/TTSController.ts index c69ce5d1..98c85f83 100644 --- a/apps/readest-app/src/services/tts/TTSController.ts +++ b/apps/readest-app/src/services/tts/TTSController.ts @@ -1,50 +1,77 @@ -import { FoliateView, TTSGranularity } from '@/types/view'; -import { TTSClient, TTSMessageCode, TTSVoice } from './TTSClient'; +import { FoliateView } from '@/types/view'; +import { AppService } from '@/types/system'; import { parseSSMLMarks } from '@/utils/ssml'; +import { TTSGranularity, TTSMark, TTSVoice } from './types'; import { WebSpeechClient } from './WebSpeechClient'; +import { NativeTTSClient } from './NativeTTSClient'; import { EdgeTTSClient } from './EdgeTTSClient'; import { TTSUtils } from './TTSUtils'; -import { TTSMark } from './types'; +import { TTSClient } from './TTSClient'; type TTSState = | 'stopped' | 'playing' | 'paused' + | 'stop-paused' | 'backward-paused' | 'forward-paused' | 'setrate-paused' | 'setvoice-paused'; export class TTSController extends EventTarget { - state: TTSState = 'stopped'; + appService: AppService | null = null; view: FoliateView; #nossmlCnt: number = 0; #currentSpeakAbortController: AbortController | null = null; #currentSpeakPromise: Promise | null = null; + state: TTSState = 'stopped'; ttsLang: string = ''; ttsRate: number = 1.0; ttsClient: TTSClient; ttsWebClient: TTSClient; ttsEdgeClient: TTSClient; + ttsNativeClient: TTSClient | null = null; ttsWebVoices: TTSVoice[] = []; ttsEdgeVoices: TTSVoice[] = []; + ttsNativeVoices: TTSVoice[] = []; - constructor(view: FoliateView) { + constructor(appService: AppService | null, view: FoliateView) { super(); this.ttsWebClient = new WebSpeechClient(this); this.ttsEdgeClient = new EdgeTTSClient(this); + // TODO: implement native TTS client for iOS and PC + if (appService?.isAndroidApp) { + this.ttsNativeClient = new NativeTTSClient(this); + } this.ttsClient = this.ttsWebClient; + this.appService = appService; this.view = view; } async init() { - await this.ttsWebClient.init(); - const success = await this.ttsEdgeClient.init(); - if (success) { - this.ttsClient = this.ttsEdgeClient; - } else { - this.ttsClient = this.ttsWebClient; + const availableClients = []; + if (this.ttsNativeClient && (await this.ttsNativeClient.init())) { + availableClients.push(this.ttsNativeClient); + this.ttsNativeVoices = await this.ttsNativeClient.getAllVoices(); + } + if (await this.ttsEdgeClient.init()) { + availableClients.push(this.ttsEdgeClient); + } + if (await this.ttsWebClient.init()) { + availableClients.push(this.ttsWebClient); + } + const preferredClientName = TTSUtils.getPreferredClient(); + if (preferredClientName) { + const preferredClient = availableClients.find( + (client) => client.name === preferredClientName, + ); + if (preferredClient) { + this.ttsClient = preferredClient; + } + } + if (!this.ttsClient) { + this.ttsClient = availableClients[0] || this.ttsWebClient; } this.ttsWebVoices = await this.ttsWebClient.getAllVoices(); this.ttsEdgeVoices = await this.ttsEdgeClient.getAllVoices(); @@ -124,7 +151,7 @@ export class TTSController extends EventTarget { return await this.forward(); } const iter = await this.ttsClient.speak(ssml, signal); - let lastCode: TTSMessageCode = 'boundary'; + let lastCode; for await (const { code, mark } of iter) { if (signal.aborted) { resolve(); @@ -182,7 +209,10 @@ export class TTSController extends EventTarget { async pause() { this.state = 'paused'; - await this.ttsClient.pause().catch((e) => this.error(e)); + if (!(await this.ttsClient.pause().catch((e) => this.error(e)))) { + await this.stop(); + this.state = 'stop-paused'; + } } async resume() { @@ -197,12 +227,18 @@ export class TTSController extends EventTarget { await this.ttsClient.stop().catch((e) => this.error(e)); if (this.#currentSpeakPromise) { - await this.#currentSpeakPromise.catch((e) => this.error(e)); + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error('Stop operation timed out')), 3000), + ); + await Promise.race([this.#currentSpeakPromise.catch((e) => this.error(e)), timeout]).catch( + (e) => this.error(e), + ); + this.#currentSpeakPromise = null; } this.state = 'stopped'; } - // goto previous sentence + // goto previous paragraph async backward() { await this.initViewTTS(); if (this.state === 'playing') { @@ -215,7 +251,7 @@ export class TTSController extends EventTarget { } } - // goto next sentence + // goto next paragraph async forward() { await this.initViewTTS(); if (this.state === 'playing') { @@ -235,8 +271,9 @@ export class TTSController extends EventTarget { } async setPrimaryLang(lang: string) { - this.ttsEdgeClient.setPrimaryLang(lang); - this.ttsWebClient.setPrimaryLang(lang); + if (this.ttsEdgeClient.initialized) this.ttsEdgeClient.setPrimaryLang(lang); + if (this.ttsWebClient.initialized) this.ttsWebClient.setPrimaryLang(lang); + if (this.ttsNativeClient?.initialized) this.ttsNativeClient?.setPrimaryLang(lang); } async setRate(rate: number) { @@ -248,7 +285,30 @@ export class TTSController extends EventTarget { async getVoices(lang: string) { const ttsWebVoices = await this.ttsWebClient.getVoices(lang); const ttsEdgeVoices = await this.ttsEdgeClient.getVoices(lang); - return [...ttsEdgeVoices, ...ttsWebVoices]; + const ttsNativeVoices = (await this.ttsNativeClient?.getVoices(lang)) ?? []; + + const voicesGroups = [...ttsNativeVoices, ...ttsEdgeVoices, ...ttsWebVoices]; + voicesGroups.forEach((group) => { + group.voices = group.voices.sort((a, b) => { + const aRegion = a.lang.split('-')[1] || ''; + const bRegion = b.lang.split('-')[1] || ''; + if (aRegion === bRegion) { + return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; + } + if (aRegion === 'CN') return -1; + if (bRegion === 'CN') return 1; + if (aRegion === 'TW') return -1; + if (bRegion === 'TW') return 1; + if (aRegion === 'HK') return -1; + if (bRegion === 'HK') return 1; + if (aRegion === 'US') return -1; + if (bRegion === 'US') return 1; + if (aRegion === 'GB') return -1; + if (bRegion === 'GB') return 1; + return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; + }); + }); + return voicesGroups; } async setVoice(voiceId: string, lang: string) { @@ -256,15 +316,24 @@ export class TTSController extends EventTarget { const useEdgeTTS = !!this.ttsEdgeVoices.find( (voice) => (voiceId === '' || voice.id === voiceId) && !voice.disabled, ); + const useNativeTTS = !!this.ttsNativeVoices.find( + (voice) => (voiceId === '' || voice.id === voiceId) && !voice.disabled, + ); if (useEdgeTTS) { this.ttsClient = this.ttsEdgeClient; await this.ttsClient.setRate(this.ttsRate); - TTSUtils.setPreferredVoice('edge-tts', lang, voiceId); + } else if (useNativeTTS) { + if (!this.ttsNativeClient) { + throw new Error('Native TTS client is not available'); + } + this.ttsClient = this.ttsNativeClient; + await this.ttsClient.setRate(this.ttsRate); } else { this.ttsClient = this.ttsWebClient; await this.ttsClient.setRate(this.ttsRate); - TTSUtils.setPreferredVoice('web-speech', lang, voiceId); } + TTSUtils.setPreferredClient(this.ttsClient.name); + TTSUtils.setPreferredVoice(this.ttsClient.name, lang, voiceId); await this.ttsClient.setVoice(voiceId); } @@ -285,7 +354,16 @@ export class TTSController extends EventTarget { this.state = 'stopped'; } - async kill() { + async shutdown() { await this.stop(); + if (this.ttsWebClient.initialized) { + await this.ttsWebClient.shutdown(); + } + if (this.ttsEdgeClient.initialized) { + await this.ttsEdgeClient.shutdown(); + } + if (this.ttsNativeClient?.initialized) { + await this.ttsNativeClient.shutdown(); + } } } diff --git a/apps/readest-app/src/services/tts/TTSData.ts b/apps/readest-app/src/services/tts/TTSData.ts index ad238ca7..823a40ec 100644 --- a/apps/readest-app/src/services/tts/TTSData.ts +++ b/apps/readest-app/src/services/tts/TTSData.ts @@ -1,2 +1,32 @@ export const SILENCE_DATA = 'data:audio/mp3;base64,//MkxAAHiAICWABElBeKPL/RANb2w+yiT1g/gTok//lP/W/l3h8QO/OCdCqCW2Cw//MkxAQHkAIWUAhEmAQXWUOFW2dxPu//9mr60ElY5sseQ+xxesmHKtZr7bsqqX2L//MkxAgFwAYiQAhEAC2hq22d3///9FTV6tA36JdgBJoOGgc+7qvqej5Zu7/7uI9l//MkxBQHAAYi8AhEAO193vt9KGOq+6qcT7hhfN5FTInmwk8RkqKImTM55pRQHQSq//MkxBsGkgoIAABHhTACIJLf99nVI///yuW1uBqWfEu7CgNPWGpUadBmZ////4sL//MkxCMHMAH9iABEmAsKioqKigsLCwtVTEFNRTMuOTkuNVVVVVVVVVVVVVVVVVVV//MkxCkECAUYCAAAAFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV'; + +export const WEB_SPEECH_BLACKLISTED_VOICES = [ + 'Albert', + 'Bad News', + 'Bahh', + 'Bells', + 'Boing', + 'Bubbles', + 'Cellos', + 'Eddy', + 'Flo', + 'Fred', + 'Good News', + 'Grandma', + 'Grandpa', + 'Jester', + 'Junior', + 'Kathy', + 'Organ', + 'Ralph', + 'Reed', + 'Rocko', + 'Sandy', + 'Shelley', + 'Superstar', + 'Trinoids', + 'Whisper', + 'Wobble', + 'Zarvox', +]; diff --git a/apps/readest-app/src/services/tts/TTSUtils.ts b/apps/readest-app/src/services/tts/TTSUtils.ts index fd17b2bd..f3bfc3c3 100644 --- a/apps/readest-app/src/services/tts/TTSUtils.ts +++ b/apps/readest-app/src/services/tts/TTSUtils.ts @@ -1,11 +1,19 @@ export class TTSUtils { private static readonly LOCAL_STORAGE_KEY = 'ttsPreferredVoices'; + private static readonly PREFERRED_CLIENT_KEY = 'preferredClient'; private static normalizeLanguage(language: string): string { if (!language) return 'n/a'; return language.toLowerCase().slice(0, 2); } + static setPreferredClient(engine: string): void { + if (!engine) return; + const preferences = this.getPreferences(); + preferences[this.PREFERRED_CLIENT_KEY] = engine; + localStorage.setItem(this.LOCAL_STORAGE_KEY, JSON.stringify(preferences)); + } + static setPreferredVoice(engine: string, language: string, voiceId: string): void { if (!engine || !language || !voiceId) return; const preferences = this.getPreferences(); @@ -14,6 +22,11 @@ export class TTSUtils { localStorage.setItem(this.LOCAL_STORAGE_KEY, JSON.stringify(preferences)); } + static getPreferredClient(): string | null { + const preferences = this.getPreferences(); + return preferences[this.PREFERRED_CLIENT_KEY] || null; + } + static getPreferredVoice(engine: string, language: string): string | null { const preferences = this.getPreferences(); const lang = this.normalizeLanguage(language); diff --git a/apps/readest-app/src/services/tts/WebSpeechClient.ts b/apps/readest-app/src/services/tts/WebSpeechClient.ts index 6c0a6739..bd9fbb15 100644 --- a/apps/readest-app/src/services/tts/WebSpeechClient.ts +++ b/apps/readest-app/src/services/tts/WebSpeechClient.ts @@ -1,40 +1,10 @@ import { getUserLocale } from '@/utils/misc'; -import { TTSClient, TTSMessageEvent, TTSVoice } from './TTSClient'; +import { TTSClient, TTSMessageEvent } from './TTSClient'; import { parseSSMLLang, parseSSMLMarks } from '@/utils/ssml'; -import { TTSGranularity } from '@/types/view'; +import { TTSGranularity, TTSMark, TTSVoice, TTSVoicesGroup } from './types'; +import { WEB_SPEECH_BLACKLISTED_VOICES } from './TTSData'; import { TTSController } from './TTSController'; import { TTSUtils } from './TTSUtils'; -import { TTSMark } from './types'; - -const BLACKLISTED_VOICES = [ - 'Albert', - 'Bad News', - 'Bahh', - 'Bells', - 'Boing', - 'Bubbles', - 'Cellos', - 'Eddy', - 'Flo', - 'Fred', - 'Good News', - 'Grandma', - 'Grandpa', - 'Jester', - 'Junior', - 'Kathy', - 'Organ', - 'Ralph', - 'Reed', - 'Rocko', - 'Sandy', - 'Shelley', - 'Superstar', - 'Trinoids', - 'Whisper', - 'Wobble', - 'Zarvox', -]; interface TTSBoundaryEvent { type: 'boundary' | 'end' | 'error'; @@ -52,6 +22,7 @@ async function* speakWithMarks( getRate: () => number, getPitch: () => number, getVoice: (lang: string) => Promise, + setCurrentVoice: (voiceId: string) => void, setSpeakingLang: (lang: string) => void, dispatchSpeakMark: (mark: TTSMark) => void, ) { @@ -63,7 +34,6 @@ async function* speakWithMarks( for (const mark of marks) { const { language } = mark; const voiceLang = language || defaultLang; - setSpeakingLang(voiceLang); dispatchSpeakMark(mark); utterance.text = mark.text; utterance.rate = getRate(); @@ -71,9 +41,11 @@ async function* speakWithMarks( const voice = await getVoice(voiceLang); if (voice) { utterance.voice = voice; + setCurrentVoice(voice.voiceURI); } if (voiceLang) { utterance.lang = voiceLang; + setSpeakingLang(voiceLang); } yield { @@ -102,16 +74,23 @@ async function* speakWithMarks( } } +type WebSpeechVoice = SpeechSynthesisVoice & { + id: string; +}; + export class WebSpeechClient implements TTSClient { + name = 'web-speech'; + initialized = false; controller?: TTSController; + + #voices: WebSpeechVoice[] = []; #primaryLang = 'en'; #speakingLang = ''; + #currentVoiceId = ''; #rate = 1.0; #pitch = 1.0; - #voice: SpeechSynthesisVoice | null = null; - #voices: SpeechSynthesisVoice[] = []; + #synth = window.speechSynthesis; - available = true; constructor(controller?: TTSController) { this.controller = controller; @@ -119,12 +98,16 @@ export class WebSpeechClient implements TTSClient { async init() { if (!this.#synth) { - this.available = false; - return this.available; + this.initialized = false; + return this.initialized; } await new Promise((resolve) => { const populateVoices = () => { - this.#voices = this.#synth.getVoices(); + this.#voices = this.#synth.getVoices().map((voice) => { + const webSpeechVoice = voice as WebSpeechVoice; + webSpeechVoice.id = voice.voiceURI || voice.name; + return webSpeechVoice; + }); // console.log('Voices', this.#voices); if (this.#voices.length > 0) { resolve(); @@ -140,19 +123,22 @@ export class WebSpeechClient implements TTSClient { resolve(); } }); - return this.available; + this.initialized = true; + return this.initialized; } - getVoiceFromLang = async (lang: string) => { - const preferredVoiceId = TTSUtils.getPreferredVoice('web-speech', lang); - const preferredVoice = this.#voices.find((v) => v.voiceURI === preferredVoiceId); - if (preferredVoice) { - this.#voice = preferredVoice; - } else { - const voiceId = (await this.getVoices(lang))[0]?.id ?? ''; - this.#voice = this.#voices.find((v) => v.voiceURI === voiceId) || null; - } - return this.#voice; + getVoiceIdFromLang = async (lang: string) => { + const preferredVoiceId = TTSUtils.getPreferredVoice(this.name, lang); + const preferredVoice = this.#voices.find((v) => v.id === preferredVoiceId); + const defaultVoice = preferredVoice + ? preferredVoice + : (await this.getVoices(lang))[0]?.voices[0] || null; + return defaultVoice?.id || this.#currentVoiceId || ''; + }; + + getWebSpeechVoiceFromLang = async (lang: string) => { + const voiceId = await this.getVoiceIdFromLang(lang); + return this.#voices.find((v) => v.id === voiceId) || null; }; async *speak( @@ -173,7 +159,8 @@ export class WebSpeechClient implements TTSClient { defaultLang, () => this.#rate, () => this.#pitch, - this.getVoiceFromLang, + this.getWebSpeechVoiceFromLang, + (voiceId) => (this.#currentVoiceId = voiceId), (lang) => (this.#speakingLang = lang), (mark) => this.controller?.dispatchSpeakMark(mark), )) { @@ -198,10 +185,12 @@ export class WebSpeechClient implements TTSClient { async pause() { this.#synth.pause(); + return true; } async resume() { this.#synth.resume(); + return true; } async stop() { @@ -223,19 +212,19 @@ export class WebSpeechClient implements TTSClient { } async setVoice(voiceId: string) { - const selectedVoice = this.#voices.find((v) => v.voiceURI === voiceId); + const selectedVoice = this.#voices.find((v) => v.id === voiceId); if (selectedVoice) { - this.#voice = selectedVoice; + this.#currentVoiceId = selectedVoice.id; } } async getAllVoices(): Promise { const voices = this.#voices.map((voice) => { return { - id: voice.voiceURI, + id: voice.id, name: voice.name, lang: voice.lang, - disabled: !this.available, + disabled: !this.initialized, } as TTSVoice; }); return voices; @@ -247,7 +236,7 @@ export class WebSpeechClient implements TTSClient { return !id.includes('com.apple') || id.includes('com.apple.voice.compact'); }; const isNotBlacklisted = (voice: SpeechSynthesisVoice) => { - return BLACKLISTED_VOICES.some((name) => voice.name.includes(name)) === false; + return WEB_SPEECH_BLACKLISTED_VOICES.some((name) => voice.name.includes(name)) === false; }; const filteredVoices = this.#voices .filter( @@ -275,9 +264,16 @@ export class WebSpeechClient implements TTSClient { return true; }); voices.forEach((voice) => { - voice.disabled = !this.available; + voice.disabled = !this.initialized; }); - return voices; + + const voicesGroup: TTSVoicesGroup = { + id: 'web-speech-api', + name: 'Web TTS', + voices, + disabled: !this.initialized || voices.length === 0, + }; + return [voicesGroup]; } getGranularities(): TTSGranularity[] { @@ -287,10 +283,16 @@ export class WebSpeechClient implements TTSClient { } getVoiceId(): string { - return this.#voice?.voiceURI ?? ''; + return this.#currentVoiceId; } getSpeakingLang(): string { return this.#speakingLang; } + + async shutdown(): Promise { + this.initialized = false; + this.#voices = []; + await this.stop(); + } } diff --git a/apps/readest-app/src/services/tts/index.ts b/apps/readest-app/src/services/tts/index.ts index a12e725c..4bd2b53c 100644 --- a/apps/readest-app/src/services/tts/index.ts +++ b/apps/readest-app/src/services/tts/index.ts @@ -2,5 +2,6 @@ export * from './types'; export * from './TTSClient'; export * from './WebSpeechClient'; export * from './EdgeTTSClient'; +export * from './NativeTTSClient'; export * from './TTSController'; export * from './TTSData'; diff --git a/apps/readest-app/src/services/tts/types.ts b/apps/readest-app/src/services/tts/types.ts index 0f39c058..b3a4182f 100644 --- a/apps/readest-app/src/services/tts/types.ts +++ b/apps/readest-app/src/services/tts/types.ts @@ -1,3 +1,19 @@ +export type TTSGranularity = 'sentence' | 'word'; + +export type TTSVoice = { + id: string; + name: string; + lang: string; + disabled?: boolean; +}; + +export type TTSVoicesGroup = { + id: string; + name: string; + voices: TTSVoice[]; + disabled?: boolean; +}; + export type TTSMark = { offset: number; name: string; diff --git a/apps/readest-app/src/types/view.ts b/apps/readest-app/src/types/view.ts index 9b379a00..f8bc6211 100644 --- a/apps/readest-app/src/types/view.ts +++ b/apps/readest-app/src/types/view.ts @@ -1,9 +1,8 @@ import { BookDoc } from '@/libs/document'; import { BookNote, BookSearchConfig, BookSearchResult } from '@/types/book'; +import { TTSGranularity } from '@/services/tts'; import { TTS } from 'foliate-js/tts.js'; -export type TTSGranularity = 'sentence' | 'word'; - export interface FoliateView extends HTMLElement { open: (book: BookDoc) => Promise; close: () => void; diff --git a/apps/readest-app/src/utils/ssml.ts b/apps/readest-app/src/utils/ssml.ts index 59582ca8..f2e5ac98 100644 --- a/apps/readest-app/src/utils/ssml.ts +++ b/apps/readest-app/src/utils/ssml.ts @@ -3,6 +3,19 @@ import { TTSMark } from '@/services/tts/types'; const cleanTextContent = (text: string) => text.replace(/\r\n/g, ' ').replace(/\r/g, ' ').replace(/\n/g, ' ').trimStart(); +export const genSSML = (lang: string, text: string, voice: string, rate: number) => { + const cleanedText = text.replace(/^]*>/i, ''); + return ` + + + + ${cleanedText} + + + + `; +}; + export const parseSSMLMarks = (ssml: string) => { const speakMatch = ssml.match(/]*xml:lang="([^"]+)"[^>]*>/i); const defaultLang = speakMatch?.[1] ?? 'en';