feat: support native tts engine on Android (#1387)

This commit is contained in:
Huang Xin
2025-06-12 23:56:14 +08:00
committed by GitHub
parent 28c6c1ec58
commit 658969e97a
52 changed files with 931 additions and 755 deletions
@@ -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}} 種語音"
}
@@ -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)
}
}
@@ -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() {
@@ -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"]
@@ -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"]
@@ -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"]
@@ -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"]
@@ -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"]
@@ -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"]
@@ -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"]
@@ -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",
]
@@ -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>,
}
+1
View File
@@ -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 -13
View File
@@ -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();
}
}
+8 -12
View File
@@ -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 -2
View File
@@ -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;
+13
View File
@@ -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';