feat: support native tts engine on Android (#1387)
This commit is contained in:
@@ -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}} صوت"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}} φωνές"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}} आवाज़ें"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}}音声"
|
||||
}
|
||||
|
||||
@@ -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}}개의 음성"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}} голоса"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}} голоси"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}} 种声音"
|
||||
}
|
||||
|
||||
@@ -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}} 種語音"
|
||||
}
|
||||
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+39
-144
@@ -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<String>("")
|
||||
private var currentLang = AtomicReference<String>("en-US")
|
||||
private var currentRate = AtomicReference<Float>(1.0f)
|
||||
private var currentPitch = AtomicReference<Float>(1.0f)
|
||||
|
||||
// Event channels for each speaking session
|
||||
private val eventChannels = ConcurrentHashMap<String, Channel<TTSMessageEvent>>()
|
||||
private val speakingJobs = ConcurrentHashMap<String, Job>()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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() {
|
||||
|
||||
-13
@@ -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"]
|
||||
-13
@@ -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"]
|
||||
-13
@@ -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"]
|
||||
-13
@@ -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"]
|
||||
+13
@@ -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"]
|
||||
+13
@@ -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"]
|
||||
-13
@@ -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"]
|
||||
+54
-135
@@ -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.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:allow-get-granularities`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the get_granularities command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:deny-get-granularities`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the get_granularities command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:allow-get-speaking-lang`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the get_speaking_lang command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:deny-get-speaking-lang`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the get_speaking_lang command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:allow-get-voice-id`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the get_voice_id command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:deny-get-voice-id`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the get_voice_id command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:allow-get-voices`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the get_voices command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:deny-get-voices`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the get_voices command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:allow-init`
|
||||
|
||||
</td>
|
||||
@@ -213,6 +106,58 @@ Denies the pause command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:allow-registerListener`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the registerListener command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:deny-registerListener`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the registerListener command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:allow-remove-listener`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the remove_listener command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:deny-remove-listener`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the remove_listener command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:allow-resume`
|
||||
|
||||
</td>
|
||||
@@ -265,32 +210,6 @@ Denies the set_pitch command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:allow-set-primary-lang`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the set_primary_lang command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:deny-set-primary-lang`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the set_primary_lang command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:allow-set-rate`
|
||||
|
||||
</td>
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
+26
-62
@@ -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`"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,74 +5,49 @@ use crate::NativeTtsExt;
|
||||
use crate::Result;
|
||||
|
||||
#[command]
|
||||
pub async fn init<R: Runtime>(app: AppHandle<R>) -> Result<bool> {
|
||||
pub(crate) async fn init<R: Runtime>(app: AppHandle<R>) -> Result<InitResponse> {
|
||||
app.native_tts().init()
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn speak<R: Runtime>(app: AppHandle<R>, args: SpeakArgs) -> Result<String> {
|
||||
app.native_tts().speak(args)
|
||||
pub(crate) async fn speak<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
payload: SpeakArgs,
|
||||
) -> Result<SpeakResponse> {
|
||||
app.native_tts().speak(payload)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn pause<R: Runtime>(app: AppHandle<R>) -> Result<()> {
|
||||
pub(crate) async fn pause<R: Runtime>(app: AppHandle<R>) -> Result<()> {
|
||||
app.native_tts().pause()
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn resume<R: Runtime>(app: AppHandle<R>) -> Result<()> {
|
||||
pub(crate) async fn resume<R: Runtime>(app: AppHandle<R>) -> Result<()> {
|
||||
app.native_tts().resume()
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn stop<R: Runtime>(app: AppHandle<R>) -> Result<()> {
|
||||
pub(crate) async fn stop<R: Runtime>(app: AppHandle<R>) -> Result<()> {
|
||||
app.native_tts().stop()
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn set_primary_lang<R: Runtime>(app: AppHandle<R>, args: SetLangArgs) -> Result<()> {
|
||||
app.native_tts().set_primary_lang(args)
|
||||
pub(crate) async fn set_rate<R: Runtime>(app: AppHandle<R>, payload: SetRateArgs) -> Result<()> {
|
||||
app.native_tts().set_rate(payload)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn set_rate<R: Runtime>(app: AppHandle<R>, args: SetRateArgs) -> Result<()> {
|
||||
app.native_tts().set_rate(args)
|
||||
pub(crate) async fn set_pitch<R: Runtime>(app: AppHandle<R>, payload: SetPitchArgs) -> Result<()> {
|
||||
app.native_tts().set_pitch(payload)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn set_pitch<R: Runtime>(app: AppHandle<R>, args: SetPitchArgs) -> Result<()> {
|
||||
app.native_tts().set_pitch(args)
|
||||
pub(crate) async fn set_voice<R: Runtime>(app: AppHandle<R>, payload: SetVoiceArgs) -> Result<()> {
|
||||
app.native_tts().set_voice(payload)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn set_voice<R: Runtime>(app: AppHandle<R>, args: SetVoiceArgs) -> Result<()> {
|
||||
app.native_tts().set_voice(args)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn get_all_voices<R: Runtime>(app: AppHandle<R>) -> Result<Vec<TTSVoice>> {
|
||||
pub(crate) async fn get_all_voices<R: Runtime>(app: AppHandle<R>) -> Result<GetVoicesResponse> {
|
||||
app.native_tts().get_all_voices()
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn get_voices<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
args: GetVoicesArgs,
|
||||
) -> Result<Vec<TTSVoice>> {
|
||||
app.native_tts().get_voices(args)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn get_granularities<R: Runtime>(app: AppHandle<R>) -> Result<Vec<TTSGranularity>> {
|
||||
app.native_tts().get_granularities()
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn get_voice_id<R: Runtime>(app: AppHandle<R>) -> Result<String> {
|
||||
app.native_tts().get_voice_id()
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn get_speaking_lang<R: Runtime>(app: AppHandle<R>) -> Result<String> {
|
||||
app.native_tts().get_speaking_lang()
|
||||
}
|
||||
|
||||
@@ -14,10 +14,10 @@ pub fn init<R: Runtime, C: DeserializeOwned>(
|
||||
pub struct NativeTts<R: Runtime>(AppHandle<R>);
|
||||
|
||||
impl<R: Runtime> NativeTts<R> {
|
||||
pub fn init(&self) -> crate::Result<bool> {
|
||||
pub fn init(&self) -> crate::Result<InitResponse> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
pub fn speak(&self, _args: SpeakArgs) -> crate::Result<String> {
|
||||
pub fn speak(&self, _args: SpeakArgs) -> crate::Result<SpeakResponse> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
pub fn pause(&self) -> crate::Result<()> {
|
||||
@@ -29,9 +29,6 @@ impl<R: Runtime> NativeTts<R> {
|
||||
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<R: Runtime> NativeTts<R> {
|
||||
pub fn set_voice(&self, _args: SetVoiceArgs) -> crate::Result<()> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
pub fn get_all_voices(&self) -> crate::Result<Vec<TTSVoice>> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
pub fn get_voices(&self, _args: GetVoicesArgs) -> crate::Result<Vec<TTSVoice>> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
pub fn get_granularities(&self) -> crate::Result<Vec<TTSGranularity>> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
pub fn get_voice_id(&self) -> crate::Result<String> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
pub fn get_speaking_lang(&self) -> crate::Result<String> {
|
||||
pub fn get_all_voices(&self) -> crate::Result<GetVoicesResponse> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,12 +44,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
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)]
|
||||
|
||||
@@ -25,14 +25,16 @@ pub fn init<R: Runtime, C: DeserializeOwned>(
|
||||
pub struct NativeTts<R: Runtime>(PluginHandle<R>);
|
||||
|
||||
impl<R: Runtime> NativeTts<R> {
|
||||
pub fn init(&self) -> crate::Result<bool> {
|
||||
pub fn init(&self) -> crate::Result<InitResponse> {
|
||||
self.0.run_mobile_plugin("init", ()).map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeTts<R> {
|
||||
pub fn speak(&self, args: SpeakArgs) -> crate::Result<String> {
|
||||
self.0.run_mobile_plugin("speak", args).map_err(Into::into)
|
||||
pub fn speak(&self, payload: SpeakArgs) -> crate::Result<SpeakResponse> {
|
||||
self.0
|
||||
.run_mobile_plugin("speak", payload)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,73 +57,33 @@ impl<R: Runtime> NativeTts<R> {
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeTts<R> {
|
||||
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<R: Runtime> NativeTts<R> {
|
||||
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<R: Runtime> NativeTts<R> {
|
||||
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<R: Runtime> NativeTts<R> {
|
||||
pub fn set_voice(&self, args: SetVoiceArgs) -> crate::Result<()> {
|
||||
self.0
|
||||
.run_mobile_plugin("set_voice", args)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeTts<R> {
|
||||
pub fn get_voices(&self, args: GetVoicesArgs) -> crate::Result<Vec<TTSVoice>> {
|
||||
self.0
|
||||
.run_mobile_plugin("get_voices", args)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeTts<R> {
|
||||
pub fn get_all_voices(&self) -> crate::Result<Vec<TTSVoice>> {
|
||||
pub fn get_all_voices(&self) -> crate::Result<GetVoicesResponse> {
|
||||
self.0
|
||||
.run_mobile_plugin("get_all_voices", ())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeTts<R> {
|
||||
pub fn get_granularities(&self) -> crate::Result<Vec<TTSGranularity>> {
|
||||
self.0
|
||||
.run_mobile_plugin("get_granularities", ())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeTts<R> {
|
||||
pub fn get_voice_id(&self) -> crate::Result<String> {
|
||||
self.0
|
||||
.run_mobile_plugin("get_voice_id", ())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeTts<R> {
|
||||
pub fn get_speaking_lang(&self) -> crate::Result<String> {
|
||||
self.0
|
||||
.run_mobile_plugin("get_speaking_lang", ())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
pub mark: Option<String>,
|
||||
}
|
||||
|
||||
#[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<TTSVoice>,
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<TTSVoice[]>;
|
||||
onGetVoices: (lang: string) => Promise<TTSVoicesGroup[]>;
|
||||
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<TTSVoice[]>([]);
|
||||
const [voiceGroups, setVoiceGroups] = useState<TTSVoicesGroup[]>([]);
|
||||
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)}
|
||||
>
|
||||
<div className='flex items-center px-2'>
|
||||
<span style={{ minWidth: `${defaultIconSize}px` }}>
|
||||
<span
|
||||
style={{
|
||||
width: `${defaultIconSize}px`,
|
||||
height: `${defaultIconSize}px`,
|
||||
}}
|
||||
>
|
||||
{timeoutOption === option.value && <MdCheck className='text-base-content' />}
|
||||
</span>
|
||||
<span className={clsx('text-base sm:text-sm')}>{option.label}</span>
|
||||
@@ -282,21 +287,52 @@ const TTSPanel = ({
|
||||
'mt-4 inline max-h-96 w-[250px] overflow-y-scroll',
|
||||
)}
|
||||
>
|
||||
{voices.map((voice, index) => (
|
||||
<li
|
||||
key={`${index}-${voice.id}`}
|
||||
onClick={() => !voice.disabled && handleSelectVoice(voice.id, voice.lang)}
|
||||
>
|
||||
<div className='flex items-center px-2'>
|
||||
<span style={{ minWidth: `${defaultIconSize}px` }}>
|
||||
{selectedVoice === voice.id && <MdCheck className='text-base-content' />}
|
||||
</span>
|
||||
<span className={clsx('text-base sm:text-sm', voice.disabled && 'text-gray-400')}>
|
||||
{voice.name}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{voiceGroups.map((voiceGroup, index) => {
|
||||
return (
|
||||
voiceGroup.voices.length > 0 && (
|
||||
<div key={voiceGroup.id} className=''>
|
||||
<div className='flex items-center gap-2 px-2 py-1'>
|
||||
<span
|
||||
style={{ width: `${defaultIconSize}px`, height: `${defaultIconSize}px` }}
|
||||
></span>
|
||||
<span className='text-sm text-gray-400 sm:text-xs'>
|
||||
{_('{{engine}}: {{count}} voices', {
|
||||
engine: _(voiceGroup.name),
|
||||
count: voiceGroup.voices.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{voiceGroup.voices.map((voice, voiceIndex) => (
|
||||
<li
|
||||
key={`${index}-${voiceGroup.id}-${voiceIndex}`}
|
||||
onClick={() => !voice.disabled && handleSelectVoice(voice.id, voice.lang)}
|
||||
>
|
||||
<div className='flex items-center px-2'>
|
||||
<span
|
||||
style={{
|
||||
width: `${defaultIconSize}px`,
|
||||
height: `${defaultIconSize}px`,
|
||||
}}
|
||||
>
|
||||
{selectedVoice === voice.id && (
|
||||
<MdCheck className='text-base-content' />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
'text-base sm:text-sm',
|
||||
voice.disabled && 'text-gray-400',
|
||||
)}
|
||||
>
|
||||
{_(voice.name)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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(/^<break\b[^>]*>/i, '');
|
||||
return `
|
||||
<speak version="1.0" xml:lang="${lang}">
|
||||
<voice name="${voice}">
|
||||
<prosody rate="${rate}">
|
||||
${cleanedText}
|
||||
</prosody>
|
||||
</voice>
|
||||
</speak>
|
||||
`;
|
||||
};
|
||||
|
||||
const genSendContent = (headerObj: Record<string, string>, content: string) => {
|
||||
let header = '';
|
||||
for (const key of Object.keys(headerObj)) {
|
||||
|
||||
@@ -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<TTSMessageEvent> {
|
||||
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<TTSMessageEvent>((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<TTSVoice[]> {
|
||||
this.#voices.forEach((voice) => {
|
||||
voice.disabled = !this.available;
|
||||
voice.disabled = !this.initialized;
|
||||
});
|
||||
return this.#voices;
|
||||
}
|
||||
|
||||
async getVoices(lang: string): Promise<TTSVoice[]> {
|
||||
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<void> {
|
||||
this.initialized = false;
|
||||
this.#audioElement = null;
|
||||
this.#voices = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string>;
|
||||
|
||||
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<TTSMessageEvent>) => void) | null;
|
||||
finished: boolean;
|
||||
}
|
||||
>();
|
||||
|
||||
constructor(controller?: TTSController) {
|
||||
this.controller = controller;
|
||||
}
|
||||
|
||||
private async setupEventListener(): Promise<void> {
|
||||
try {
|
||||
if (this.#eventListener) return;
|
||||
this.#eventListener = await addPluginListener<TTSEventPayload>(
|
||||
'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<boolean> {
|
||||
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<TTSMessageEvent | void>((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<null>((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<string, TTSVoice[]>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<boolean>;
|
||||
shutdown(): Promise<void>;
|
||||
speak(ssml: string, signal: AbortSignal, preload?: boolean): AsyncIterable<TTSMessageEvent>;
|
||||
pause(): Promise<void>;
|
||||
resume(): Promise<void>;
|
||||
pause(): Promise<boolean>;
|
||||
resume(): Promise<boolean>;
|
||||
stop(): Promise<void>;
|
||||
setPrimaryLang(lang: string): void;
|
||||
setRate(rate: number): Promise<void>;
|
||||
setPitch(pitch: number): Promise<void>;
|
||||
setVoice(voice: string): Promise<void>;
|
||||
getAllVoices(): Promise<TTSVoice[]>;
|
||||
getVoices(lang: string): Promise<TTSVoice[]>;
|
||||
getVoices(lang: string): Promise<TTSVoicesGroup[]>;
|
||||
getGranularities(): TTSGranularity[];
|
||||
getVoiceId(): string;
|
||||
getSpeakingLang(): string;
|
||||
|
||||
@@ -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<void> | 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<SpeechSynthesisVoice | null>,
|
||||
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<void>((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<TTSVoice[]> {
|
||||
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<void> {
|
||||
this.initialized = false;
|
||||
this.#voices = [];
|
||||
await this.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void>;
|
||||
close: () => void;
|
||||
|
||||
@@ -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(/^<break\b[^>]*>/i, '');
|
||||
return `
|
||||
<speak version="1.0" xml:lang="${lang}">
|
||||
<voice name="${voice}">
|
||||
<prosody rate="${rate}" >
|
||||
${cleanedText}
|
||||
</prosody>
|
||||
</voice>
|
||||
</speak>
|
||||
`;
|
||||
};
|
||||
|
||||
export const parseSSMLMarks = (ssml: string) => {
|
||||
const speakMatch = ssml.match(/<speak[^>]*xml:lang="([^"]+)"[^>]*>/i);
|
||||
const defaultLang = speakMatch?.[1] ?? 'en';
|
||||
|
||||
Reference in New Issue
Block a user