Implement build-aware TTS mode and voice selection policies (#331)
This commit is contained in:
parent
9510293ac3
commit
5971eaa571
6 changed files with 281 additions and 61 deletions
|
|
@ -127,6 +127,7 @@ android {
|
||||||
initWith(getByName("release"))
|
initWith(getByName("release"))
|
||||||
matchingFallbacks += listOf("release")
|
matchingFallbacks += listOf("release")
|
||||||
buildConfigField("boolean", "IS_OFFLINE", "true")
|
buildConfigField("boolean", "IS_OFFLINE", "true")
|
||||||
|
buildConfigField("String", "TTS_WORKER_URL", "\"\"")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1891,6 +1891,7 @@ fun DeviceVoicesTab(
|
||||||
val allLanguagesLabel = stringResource(R.string.filter_all)
|
val allLanguagesLabel = stringResource(R.string.filter_all)
|
||||||
var selectedLanguage by remember { mutableStateOf(allLanguagesLabel) }
|
var selectedLanguage by remember { mutableStateOf(allLanguagesLabel) }
|
||||||
var languageMenuExpanded by remember { mutableStateOf(false) }
|
var languageMenuExpanded by remember { mutableStateOf(false) }
|
||||||
|
val offlineNativeOnly = BuildConfig.IS_OFFLINE
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
val tts = TextToSpeech(context) { status ->
|
val tts = TextToSpeech(context) { status ->
|
||||||
|
|
@ -1903,20 +1904,35 @@ fun DeviceVoicesTab(
|
||||||
onDispose { tts.shutdown() }
|
onDispose { tts.shutdown() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(allVoices, savedVoiceName, offlineNativeOnly) {
|
||||||
|
if (
|
||||||
|
offlineNativeOnly &&
|
||||||
|
savedVoiceName != null &&
|
||||||
|
allVoices.any { voice -> voice.name == savedVoiceName && voice.isNetworkConnectionRequired }
|
||||||
|
) {
|
||||||
|
savedVoiceName = null
|
||||||
|
saveNativeVoice(context, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isTtsLoading) {
|
if (isTtsLoading) {
|
||||||
Box(modifier = Modifier.fillMaxWidth().height(150.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
Box(modifier = Modifier.fillMaxWidth().height(150.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val languages = remember(allVoices) {
|
val selectableVoices = remember(allVoices, offlineNativeOnly) {
|
||||||
val list = listOf(allLanguagesLabel) + allVoices.map { it.locale.displayLanguage }.filter { it.isNotBlank() }.distinct().sorted()
|
if (offlineNativeOnly) allVoices.filter { voice -> !voice.isNetworkConnectionRequired } else allVoices
|
||||||
|
}
|
||||||
|
|
||||||
|
val languages = remember(selectableVoices) {
|
||||||
|
val list = listOf(allLanguagesLabel) + selectableVoices.map { it.locale.displayLanguage }.filter { it.isNotBlank() }.distinct().sorted()
|
||||||
Timber.tag("TTS_DIAGNOSE").d("Languages list updated: size=${list.size}, items=$list")
|
Timber.tag("TTS_DIAGNOSE").d("Languages list updated: size=${list.size}, items=$list")
|
||||||
list
|
list
|
||||||
}
|
}
|
||||||
|
|
||||||
val filteredVoices = remember(allVoices, selectedLanguage) {
|
val filteredVoices = remember(selectableVoices, selectedLanguage) {
|
||||||
if (selectedLanguage == allLanguagesLabel) allVoices
|
if (selectedLanguage == allLanguagesLabel) selectableVoices
|
||||||
else allVoices.filter { it.locale.displayLanguage == selectedLanguage }
|
else selectableVoices.filter { it.locale.displayLanguage == selectedLanguage }
|
||||||
}
|
}
|
||||||
|
|
||||||
val isBaseMode = currentMode == TtsPlaybackManager.TtsMode.BASE
|
val isBaseMode = currentMode == TtsPlaybackManager.TtsMode.BASE
|
||||||
|
|
@ -1934,12 +1950,19 @@ fun DeviceVoicesTab(
|
||||||
try {
|
try {
|
||||||
val defaultLocale = Locale.getDefault()
|
val defaultLocale = Locale.getDefault()
|
||||||
language = defaultLocale
|
language = defaultLocale
|
||||||
val fallbackVoice =
|
val fallbackVoice = if (offlineNativeOnly) {
|
||||||
|
voices.firstOrNull { voice ->
|
||||||
|
voice.locale == defaultLocale && !voice.isNetworkConnectionRequired
|
||||||
|
} ?: voices.firstOrNull { voice ->
|
||||||
|
!voice.isNetworkConnectionRequired
|
||||||
|
}
|
||||||
|
} else {
|
||||||
defaultVoice ?: voices.firstOrNull { voice ->
|
defaultVoice ?: voices.firstOrNull { voice ->
|
||||||
voice.locale == defaultLocale && !voice.isNetworkConnectionRequired
|
voice.locale == defaultLocale && !voice.isNetworkConnectionRequired
|
||||||
} ?: voices.firstOrNull { voice ->
|
} ?: voices.firstOrNull { voice ->
|
||||||
voice.locale == defaultLocale
|
voice.locale == defaultLocale
|
||||||
}
|
}
|
||||||
|
}
|
||||||
fallbackVoice?.let { voice = it }
|
fallbackVoice?.let { voice = it }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.tag("TTS_DIAGNOSE").w(e, "Failed to reset preview engine to system default voice")
|
Timber.tag("TTS_DIAGNOSE").w(e, "Failed to reset preview engine to system default voice")
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.speech.tts.TextToSpeech
|
import android.speech.tts.TextToSpeech
|
||||||
import android.speech.tts.UtteranceProgressListener
|
import android.speech.tts.UtteranceProgressListener
|
||||||
|
import android.speech.tts.Voice
|
||||||
|
import com.aryan.reader.BuildConfig
|
||||||
import com.aryan.reader.epubreader.loadTtsPitch
|
import com.aryan.reader.epubreader.loadTtsPitch
|
||||||
import com.aryan.reader.epubreader.loadTtsSpeechRate
|
import com.aryan.reader.epubreader.loadTtsSpeechRate
|
||||||
import com.aryan.reader.loadNativeVoice
|
import com.aryan.reader.loadNativeVoice
|
||||||
|
|
@ -47,6 +49,38 @@ private const val START_TIMEOUT_RETRY_MS = 4000L
|
||||||
private const val PROCESS_TIMEOUT_MS = 15000L
|
private const val PROCESS_TIMEOUT_MS = 15000L
|
||||||
private const val MAX_RETRY_ATTEMPTS = 3
|
private const val MAX_RETRY_ATTEMPTS = 3
|
||||||
|
|
||||||
|
internal fun resolveNativeTtsVoiceForBuild(
|
||||||
|
preferredVoiceName: String?,
|
||||||
|
defaultVoice: Voice?,
|
||||||
|
availableVoices: Collection<Voice>?,
|
||||||
|
defaultLocale: Locale,
|
||||||
|
isOfflineBuild: Boolean
|
||||||
|
): Voice? {
|
||||||
|
val voices = availableVoices.orEmpty()
|
||||||
|
val preferredVoice = preferredVoiceName
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { name -> voices.firstOrNull { it.name == name } }
|
||||||
|
|
||||||
|
if (preferredVoice != null && (!isOfflineBuild || !preferredVoice.isNetworkConnectionRequired)) {
|
||||||
|
return preferredVoice
|
||||||
|
}
|
||||||
|
|
||||||
|
val localeOfflineVoice = voices.firstOrNull { voice ->
|
||||||
|
voice.locale == defaultLocale && !voice.isNetworkConnectionRequired
|
||||||
|
}
|
||||||
|
val anyOfflineVoice = voices.firstOrNull { voice -> !voice.isNetworkConnectionRequired }
|
||||||
|
|
||||||
|
return if (isOfflineBuild) {
|
||||||
|
localeOfflineVoice
|
||||||
|
?: anyOfflineVoice
|
||||||
|
?: defaultVoice?.takeUnless { it.isNetworkConnectionRequired }
|
||||||
|
} else {
|
||||||
|
defaultVoice
|
||||||
|
?: localeOfflineVoice
|
||||||
|
?: voices.firstOrNull { voice -> voice.locale == defaultLocale }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class BaseTtsSynthesizer(private val context: Context) {
|
class BaseTtsSynthesizer(private val context: Context) {
|
||||||
|
|
||||||
private var tts: TextToSpeech? = null
|
private var tts: TextToSpeech? = null
|
||||||
|
|
@ -149,51 +183,39 @@ class BaseTtsSynthesizer(private val context: Context) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val preferredVoiceName = loadNativeVoice(context)
|
val preferredVoiceName = loadNativeVoice(context)
|
||||||
|
|
||||||
if (preferredVoiceName.isNullOrBlank()) {
|
|
||||||
val defaultLocale = Locale.getDefault()
|
val defaultLocale = Locale.getDefault()
|
||||||
try {
|
val defaultVoice = tts?.defaultVoice
|
||||||
|
val availableVoices = tts?.voices
|
||||||
|
val targetVoice = resolveNativeTtsVoiceForBuild(
|
||||||
|
preferredVoiceName = preferredVoiceName,
|
||||||
|
defaultVoice = defaultVoice,
|
||||||
|
availableVoices = availableVoices,
|
||||||
|
defaultLocale = defaultLocale,
|
||||||
|
isOfflineBuild = BuildConfig.IS_OFFLINE
|
||||||
|
)
|
||||||
|
|
||||||
|
if (targetVoice == null) {
|
||||||
tts?.language = defaultLocale
|
tts?.language = defaultLocale
|
||||||
} catch (e: Exception) {
|
Timber.w("BaseTts: No suitable local voice found for locale $defaultLocale.")
|
||||||
Timber.e(e, "BaseTts: Failed to restore default language")
|
|
||||||
}
|
|
||||||
|
|
||||||
val defaultVoice = try {
|
|
||||||
tts?.defaultVoice ?: tts?.voices?.firstOrNull { voice ->
|
|
||||||
voice.locale == defaultLocale && !voice.isNetworkConnectionRequired
|
|
||||||
} ?: tts?.voices?.firstOrNull { voice ->
|
|
||||||
voice.locale == defaultLocale
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "BaseTts: Failed to query default voice")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (defaultVoice != null && tts?.voice?.name != defaultVoice.name) {
|
|
||||||
Timber.d("BaseTts: Restoring system default voice to ${defaultVoice.name} (${defaultVoice.locale})")
|
|
||||||
tts?.voice = defaultVoice
|
|
||||||
} else {
|
|
||||||
Timber.d("BaseTts: Using engine default voice for locale $defaultLocale")
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tts?.voice?.name == preferredVoiceName) return
|
if (
|
||||||
|
!preferredVoiceName.isNullOrBlank() &&
|
||||||
|
targetVoice.name != preferredVoiceName &&
|
||||||
|
BuildConfig.IS_OFFLINE
|
||||||
|
) {
|
||||||
|
Timber.w("BaseTts: Saved voice '$preferredVoiceName' requires network or is unavailable in offline build. Using ${targetVoice.name}.")
|
||||||
|
}
|
||||||
|
|
||||||
val availableVoices = tts?.voices
|
if (tts?.voice?.name != targetVoice.name) {
|
||||||
if (availableVoices != null) {
|
Timber.d("BaseTts: Setting native voice to ${targetVoice.name} (${targetVoice.locale})")
|
||||||
val targetVoice = availableVoices.find { it.name == preferredVoiceName }
|
|
||||||
if (targetVoice != null) {
|
|
||||||
Timber.d("BaseTts: Setting preferred voice to ${targetVoice.name} (${targetVoice.locale})")
|
|
||||||
try {
|
try {
|
||||||
tts?.language = targetVoice.locale
|
tts?.language = targetVoice.locale
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "BaseTts: Failed to set language for voice")
|
Timber.e(e, "BaseTts: Failed to set language for voice")
|
||||||
}
|
}
|
||||||
tts?.voice = targetVoice
|
tts?.voice = targetVoice
|
||||||
} else {
|
|
||||||
Timber.w("BaseTts: Preferred voice '$preferredVoiceName' not found in current engine.")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "BaseTts: Failed to apply preferred voice")
|
Timber.e(e, "BaseTts: Failed to apply preferred voice")
|
||||||
|
|
|
||||||
|
|
@ -58,19 +58,66 @@ fun loadTtsMode(context: Context): TtsPlaybackManager.TtsMode {
|
||||||
val savedModeName = prefs.getString("tts_mode", TtsPlaybackManager.TtsMode.BASE.name)
|
val savedModeName = prefs.getString("tts_mode", TtsPlaybackManager.TtsMode.BASE.name)
|
||||||
?: TtsPlaybackManager.TtsMode.BASE.name
|
?: TtsPlaybackManager.TtsMode.BASE.name
|
||||||
|
|
||||||
val isCloudAllowed = BuildConfig.TTS_WORKER_URL.isNotBlank() || isByokCloudTtsAvailable(context)
|
return resolveTtsModeForCurrentBuild(context, savedModeName)
|
||||||
|
}
|
||||||
|
|
||||||
return if (isCloudAllowed) {
|
@OptIn(UnstableApi::class)
|
||||||
try {
|
internal fun resolveTtsModeForCurrentBuild(
|
||||||
TtsPlaybackManager.TtsMode.valueOf(savedModeName)
|
context: Context,
|
||||||
|
requestedModeName: String?
|
||||||
|
): TtsPlaybackManager.TtsMode {
|
||||||
|
return resolveTtsModeForBuild(
|
||||||
|
requestedModeName = requestedModeName,
|
||||||
|
isOfflineBuild = BuildConfig.IS_OFFLINE,
|
||||||
|
isProBuild = BuildConfig.IS_PRO,
|
||||||
|
workerUrl = BuildConfig.TTS_WORKER_URL,
|
||||||
|
byokCloudAvailable = isByokCloudTtsAvailable(context)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
internal fun resolveTtsModeForBuild(
|
||||||
|
requestedModeName: String?,
|
||||||
|
isOfflineBuild: Boolean,
|
||||||
|
isProBuild: Boolean,
|
||||||
|
workerUrl: String,
|
||||||
|
byokCloudAvailable: Boolean
|
||||||
|
): TtsPlaybackManager.TtsMode {
|
||||||
|
val requestedMode = try {
|
||||||
|
TtsPlaybackManager.TtsMode.valueOf(
|
||||||
|
requestedModeName ?: TtsPlaybackManager.TtsMode.BASE.name
|
||||||
|
)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
TtsPlaybackManager.TtsMode.BASE
|
TtsPlaybackManager.TtsMode.BASE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requestedMode != TtsPlaybackManager.TtsMode.CLOUD) {
|
||||||
|
return requestedMode
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (isCloudTtsAllowedForBuild(
|
||||||
|
isOfflineBuild = isOfflineBuild,
|
||||||
|
isProBuild = isProBuild,
|
||||||
|
workerUrl = workerUrl,
|
||||||
|
byokCloudAvailable = byokCloudAvailable
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
TtsPlaybackManager.TtsMode.CLOUD
|
||||||
} else {
|
} else {
|
||||||
TtsPlaybackManager.TtsMode.BASE
|
TtsPlaybackManager.TtsMode.BASE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun isCloudTtsAllowedForBuild(
|
||||||
|
isOfflineBuild: Boolean,
|
||||||
|
isProBuild: Boolean,
|
||||||
|
workerUrl: String,
|
||||||
|
byokCloudAvailable: Boolean
|
||||||
|
): Boolean {
|
||||||
|
if (isOfflineBuild) return false
|
||||||
|
return (isProBuild && workerUrl.isNotBlank()) || byokCloudAvailable
|
||||||
|
}
|
||||||
|
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
class TtsController(context: Context) : Player.Listener {
|
class TtsController(context: Context) : Player.Listener {
|
||||||
|
|
||||||
|
|
@ -168,9 +215,10 @@ class TtsController(context: Context) : Player.Listener {
|
||||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("TtsController.start aborted because chunks is empty.")
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("TtsController.start aborted because chunks is empty.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Timber.d("UI sending START command with mode: $ttsMode")
|
val effectiveTtsMode = resolveTtsModeForCurrentBuild(context, ttsMode.name)
|
||||||
|
Timber.d("UI sending START command with mode: $effectiveTtsMode")
|
||||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
"TtsController.start. hasController=${mediaController != null}, chunks=${chunks.size}, continueSession=$continueSession, source=$playbackSource, mode=$ttsMode, book='${bookTitle.take(60)}', chapter='${chapterTitle.orEmpty().take(60)}', chapterIndex=$chapterIndex, totalChapters=$totalChapters"
|
"TtsController.start. hasController=${mediaController != null}, chunks=${chunks.size}, continueSession=$continueSession, source=$playbackSource, mode=$effectiveTtsMode, requestedMode=$ttsMode, book='${bookTitle.take(60)}', chapter='${chapterTitle.orEmpty().take(60)}', chapterIndex=$chapterIndex, totalChapters=$totalChapters"
|
||||||
)
|
)
|
||||||
|
|
||||||
val textList = ArrayList(chunks.map { it.text })
|
val textList = ArrayList(chunks.map { it.text })
|
||||||
|
|
@ -193,13 +241,13 @@ class TtsController(context: Context) : Player.Listener {
|
||||||
pageIndex?.let { putInt(KEY_PAGE_INDEX, it) }
|
pageIndex?.let { putInt(KEY_PAGE_INDEX, it) }
|
||||||
putInt(KEY_START_CHUNK_INDEX, startChunkIndex)
|
putInt(KEY_START_CHUNK_INDEX, startChunkIndex)
|
||||||
putBoolean(KEY_CONTINUE_SESSION, continueSession)
|
putBoolean(KEY_CONTINUE_SESSION, continueSession)
|
||||||
putString(KEY_TTS_MODE, ttsMode.name)
|
putString(KEY_TTS_MODE, effectiveTtsMode.name)
|
||||||
putString(KEY_PLAYBACK_SOURCE, playbackSource)
|
putString(KEY_PLAYBACK_SOURCE, playbackSource)
|
||||||
putString(KEY_AUTH_TOKEN, authToken)
|
putString(KEY_AUTH_TOKEN, authToken)
|
||||||
putFloat("playback_speed", loadTtsSpeechRate(context))
|
putFloat("playback_speed", loadTtsSpeechRate(context))
|
||||||
putFloat("playback_pitch", loadTtsPitch(context))
|
putFloat("playback_pitch", loadTtsPitch(context))
|
||||||
}
|
}
|
||||||
Timber.tag("TTS_CLOUD_DIAG").d("TtsController sending START. Mode: $ttsMode, Chunks: ${chunks.size}, Token present: ${!authToken.isNullOrBlank()}")
|
Timber.tag("TTS_CLOUD_DIAG").d("TtsController sending START. Mode: $effectiveTtsMode, Chunks: ${chunks.size}, Token present: ${!authToken.isNullOrBlank()}")
|
||||||
val controller = mediaController
|
val controller = mediaController
|
||||||
if (controller == null) {
|
if (controller == null) {
|
||||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e("Cannot send START command because MediaController is null.")
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e("Cannot send START command because MediaController is null.")
|
||||||
|
|
@ -240,12 +288,13 @@ class TtsController(context: Context) : Player.Listener {
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
fun changeTtsMode(mode: String) {
|
fun changeTtsMode(mode: String) {
|
||||||
Timber.d("UI sending CHANGE_TTS_MODE command.")
|
Timber.d("UI sending CHANGE_TTS_MODE command.")
|
||||||
|
val effectiveMode = resolveTtsModeForCurrentBuild(context, mode)
|
||||||
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||||
prefs.edit { putString("tts_mode", mode) }
|
prefs.edit { putString("tts_mode", effectiveMode.name) }
|
||||||
_ttsState.value = _ttsState.value.copy(ttsMode = mode)
|
_ttsState.value = _ttsState.value.copy(ttsMode = effectiveMode.name)
|
||||||
|
|
||||||
val args = Bundle().apply {
|
val args = Bundle().apply {
|
||||||
putString(KEY_TTS_MODE, mode)
|
putString(KEY_TTS_MODE, effectiveMode.name)
|
||||||
}
|
}
|
||||||
mediaController?.sendCustomCommand(CHANGE_TTS_MODE_COMMAND, args)
|
mediaController?.sendCustomCommand(CHANGE_TTS_MODE_COMMAND, args)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -444,7 +444,7 @@ class TtsPlaybackManager(
|
||||||
val startChunkIndex = args.getInt(KEY_START_CHUNK_INDEX, 0)
|
val startChunkIndex = args.getInt(KEY_START_CHUNK_INDEX, 0)
|
||||||
val ttsModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name)
|
val ttsModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name)
|
||||||
val playbackSource = args.getString(KEY_PLAYBACK_SOURCE)
|
val playbackSource = args.getString(KEY_PLAYBACK_SOURCE)
|
||||||
val ttsMode = try { TtsMode.valueOf(ttsModeName ?: TtsMode.CLOUD.name) } catch (_: Exception) { TtsMode.CLOUD }
|
val ttsMode = resolveTtsModeForCurrentBuild(appContext, ttsModeName)
|
||||||
|
|
||||||
val richChunks = if (cfis != null && offsets != null && chunks.size == cfis.size && chunks.size == offsets.size) {
|
val richChunks = if (cfis != null && offsets != null && chunks.size == cfis.size && chunks.size == offsets.size) {
|
||||||
chunks.mapIndexed { index, text ->
|
chunks.mapIndexed { index, text ->
|
||||||
|
|
@ -483,7 +483,7 @@ class TtsPlaybackManager(
|
||||||
}
|
}
|
||||||
CHANGE_TTS_MODE_COMMAND -> {
|
CHANGE_TTS_MODE_COMMAND -> {
|
||||||
val newModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name)
|
val newModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name)
|
||||||
val newMode = try { TtsMode.valueOf(newModeName) } catch (_: Exception) { TtsMode.CLOUD }
|
val newMode = resolveTtsModeForCurrentBuild(appContext, newModeName)
|
||||||
handleChangeTtsMode(newMode)
|
handleChangeTtsMode(newMode)
|
||||||
}
|
}
|
||||||
FLUSH_PREFETCH_COMMAND -> {
|
FLUSH_PREFETCH_COMMAND -> {
|
||||||
|
|
|
||||||
125
app/src/test/java/com/aryan/reader/tts/TtsModePolicyTest.kt
Normal file
125
app/src/test/java/com/aryan/reader/tts/TtsModePolicyTest.kt
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
package com.aryan.reader.tts
|
||||||
|
|
||||||
|
import android.speech.tts.Voice
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@androidx.annotation.OptIn(UnstableApi::class)
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class TtsModePolicyTest {
|
||||||
|
@Test
|
||||||
|
fun `offline builds force device tts even when cloud config is present`() {
|
||||||
|
val mode = resolveTtsModeForBuild(
|
||||||
|
requestedModeName = TtsPlaybackManager.TtsMode.CLOUD.name,
|
||||||
|
isOfflineBuild = true,
|
||||||
|
isProBuild = true,
|
||||||
|
workerUrl = "https://example.com/tts",
|
||||||
|
byokCloudAvailable = true
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(TtsPlaybackManager.TtsMode.BASE, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `oss builds ignore server backed cloud tts without byok`() {
|
||||||
|
val mode = resolveTtsModeForBuild(
|
||||||
|
requestedModeName = TtsPlaybackManager.TtsMode.CLOUD.name,
|
||||||
|
isOfflineBuild = false,
|
||||||
|
isProBuild = false,
|
||||||
|
workerUrl = "https://example.com/tts",
|
||||||
|
byokCloudAvailable = false
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(TtsPlaybackManager.TtsMode.BASE, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pro builds can use configured cloud tts`() {
|
||||||
|
val mode = resolveTtsModeForBuild(
|
||||||
|
requestedModeName = TtsPlaybackManager.TtsMode.CLOUD.name,
|
||||||
|
isOfflineBuild = false,
|
||||||
|
isProBuild = true,
|
||||||
|
workerUrl = "https://example.com/tts",
|
||||||
|
byokCloudAvailable = false
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(TtsPlaybackManager.TtsMode.CLOUD, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `oss builds can use byok cloud tts when online`() {
|
||||||
|
val mode = resolveTtsModeForBuild(
|
||||||
|
requestedModeName = TtsPlaybackManager.TtsMode.CLOUD.name,
|
||||||
|
isOfflineBuild = false,
|
||||||
|
isProBuild = false,
|
||||||
|
workerUrl = "",
|
||||||
|
byokCloudAvailable = true
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(TtsPlaybackManager.TtsMode.CLOUD, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `invalid tts mode falls back to device tts`() {
|
||||||
|
val mode = resolveTtsModeForBuild(
|
||||||
|
requestedModeName = "REMOTE",
|
||||||
|
isOfflineBuild = false,
|
||||||
|
isProBuild = true,
|
||||||
|
workerUrl = "https://example.com/tts",
|
||||||
|
byokCloudAvailable = false
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(TtsPlaybackManager.TtsMode.BASE, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `offline native tts ignores saved network voice`() {
|
||||||
|
val localVoice = voice("local", requiresNetwork = false)
|
||||||
|
val networkVoice = voice("network", requiresNetwork = true)
|
||||||
|
|
||||||
|
val resolved = resolveNativeTtsVoiceForBuild(
|
||||||
|
preferredVoiceName = networkVoice.name,
|
||||||
|
defaultVoice = networkVoice,
|
||||||
|
availableVoices = listOf(networkVoice, localVoice),
|
||||||
|
defaultLocale = Locale.US,
|
||||||
|
isOfflineBuild = true
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(localVoice.name, resolved?.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `online native tts keeps saved network voice`() {
|
||||||
|
val localVoice = voice("local", requiresNetwork = false)
|
||||||
|
val networkVoice = voice("network", requiresNetwork = true)
|
||||||
|
|
||||||
|
val resolved = resolveNativeTtsVoiceForBuild(
|
||||||
|
preferredVoiceName = networkVoice.name,
|
||||||
|
defaultVoice = localVoice,
|
||||||
|
availableVoices = listOf(localVoice, networkVoice),
|
||||||
|
defaultLocale = Locale.US,
|
||||||
|
isOfflineBuild = false
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(networkVoice.name, resolved?.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun voice(
|
||||||
|
name: String,
|
||||||
|
locale: Locale = Locale.US,
|
||||||
|
requiresNetwork: Boolean
|
||||||
|
): Voice {
|
||||||
|
return Voice(
|
||||||
|
name,
|
||||||
|
locale,
|
||||||
|
Voice.QUALITY_NORMAL,
|
||||||
|
Voice.LATENCY_NORMAL,
|
||||||
|
requiresNetwork,
|
||||||
|
emptySet<String>()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue