Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
10e18cf657
6 changed files with 281 additions and 61 deletions
|
|
@ -127,6 +127,7 @@ android {
|
|||
initWith(getByName("release"))
|
||||
matchingFallbacks += listOf("release")
|
||||
buildConfigField("boolean", "IS_OFFLINE", "true")
|
||||
buildConfigField("String", "TTS_WORKER_URL", "\"\"")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1891,6 +1891,7 @@ fun DeviceVoicesTab(
|
|||
val allLanguagesLabel = stringResource(R.string.filter_all)
|
||||
var selectedLanguage by remember { mutableStateOf(allLanguagesLabel) }
|
||||
var languageMenuExpanded by remember { mutableStateOf(false) }
|
||||
val offlineNativeOnly = BuildConfig.IS_OFFLINE
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val tts = TextToSpeech(context) { status ->
|
||||
|
|
@ -1903,20 +1904,35 @@ fun DeviceVoicesTab(
|
|||
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) {
|
||||
Box(modifier = Modifier.fillMaxWidth().height(150.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
||||
return
|
||||
}
|
||||
|
||||
val languages = remember(allVoices) {
|
||||
val list = listOf(allLanguagesLabel) + allVoices.map { it.locale.displayLanguage }.filter { it.isNotBlank() }.distinct().sorted()
|
||||
val selectableVoices = remember(allVoices, offlineNativeOnly) {
|
||||
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")
|
||||
list
|
||||
}
|
||||
|
||||
val filteredVoices = remember(allVoices, selectedLanguage) {
|
||||
if (selectedLanguage == allLanguagesLabel) allVoices
|
||||
else allVoices.filter { it.locale.displayLanguage == selectedLanguage }
|
||||
val filteredVoices = remember(selectableVoices, selectedLanguage) {
|
||||
if (selectedLanguage == allLanguagesLabel) selectableVoices
|
||||
else selectableVoices.filter { it.locale.displayLanguage == selectedLanguage }
|
||||
}
|
||||
|
||||
val isBaseMode = currentMode == TtsPlaybackManager.TtsMode.BASE
|
||||
|
|
@ -1934,12 +1950,19 @@ fun DeviceVoicesTab(
|
|||
try {
|
||||
val defaultLocale = Locale.getDefault()
|
||||
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 ->
|
||||
voice.locale == defaultLocale && !voice.isNetworkConnectionRequired
|
||||
} ?: voices.firstOrNull { voice ->
|
||||
voice.locale == defaultLocale
|
||||
}
|
||||
}
|
||||
fallbackVoice?.let { voice = it }
|
||||
} catch (e: Exception) {
|
||||
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.speech.tts.TextToSpeech
|
||||
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.loadTtsSpeechRate
|
||||
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 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) {
|
||||
|
||||
private var tts: TextToSpeech? = null
|
||||
|
|
@ -149,51 +183,39 @@ class BaseTtsSynthesizer(private val context: Context) {
|
|||
|
||||
try {
|
||||
val preferredVoiceName = loadNativeVoice(context)
|
||||
|
||||
if (preferredVoiceName.isNullOrBlank()) {
|
||||
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
|
||||
} catch (e: Exception) {
|
||||
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")
|
||||
}
|
||||
Timber.w("BaseTts: No suitable local voice found for locale $defaultLocale.")
|
||||
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 (availableVoices != null) {
|
||||
val targetVoice = availableVoices.find { it.name == preferredVoiceName }
|
||||
if (targetVoice != null) {
|
||||
Timber.d("BaseTts: Setting preferred voice to ${targetVoice.name} (${targetVoice.locale})")
|
||||
if (tts?.voice?.name != targetVoice.name) {
|
||||
Timber.d("BaseTts: Setting native voice to ${targetVoice.name} (${targetVoice.locale})")
|
||||
try {
|
||||
tts?.language = targetVoice.locale
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "BaseTts: Failed to set language for voice")
|
||||
}
|
||||
tts?.voice = targetVoice
|
||||
} else {
|
||||
Timber.w("BaseTts: Preferred voice '$preferredVoiceName' not found in current engine.")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
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)
|
||||
?: TtsPlaybackManager.TtsMode.BASE.name
|
||||
|
||||
val isCloudAllowed = BuildConfig.TTS_WORKER_URL.isNotBlank() || isByokCloudTtsAvailable(context)
|
||||
return resolveTtsModeForCurrentBuild(context, savedModeName)
|
||||
}
|
||||
|
||||
return if (isCloudAllowed) {
|
||||
try {
|
||||
TtsPlaybackManager.TtsMode.valueOf(savedModeName)
|
||||
@OptIn(UnstableApi::class)
|
||||
internal fun resolveTtsModeForCurrentBuild(
|
||||
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) {
|
||||
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 {
|
||||
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
|
||||
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.")
|
||||
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(
|
||||
"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 })
|
||||
|
|
@ -193,13 +241,13 @@ class TtsController(context: Context) : Player.Listener {
|
|||
pageIndex?.let { putInt(KEY_PAGE_INDEX, it) }
|
||||
putInt(KEY_START_CHUNK_INDEX, startChunkIndex)
|
||||
putBoolean(KEY_CONTINUE_SESSION, continueSession)
|
||||
putString(KEY_TTS_MODE, ttsMode.name)
|
||||
putString(KEY_TTS_MODE, effectiveTtsMode.name)
|
||||
putString(KEY_PLAYBACK_SOURCE, playbackSource)
|
||||
putString(KEY_AUTH_TOKEN, authToken)
|
||||
putFloat("playback_speed", loadTtsSpeechRate(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
|
||||
if (controller == 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")
|
||||
fun changeTtsMode(mode: String) {
|
||||
Timber.d("UI sending CHANGE_TTS_MODE command.")
|
||||
val effectiveMode = resolveTtsModeForCurrentBuild(context, mode)
|
||||
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||
prefs.edit { putString("tts_mode", mode) }
|
||||
_ttsState.value = _ttsState.value.copy(ttsMode = mode)
|
||||
prefs.edit { putString("tts_mode", effectiveMode.name) }
|
||||
_ttsState.value = _ttsState.value.copy(ttsMode = effectiveMode.name)
|
||||
|
||||
val args = Bundle().apply {
|
||||
putString(KEY_TTS_MODE, mode)
|
||||
putString(KEY_TTS_MODE, effectiveMode.name)
|
||||
}
|
||||
mediaController?.sendCustomCommand(CHANGE_TTS_MODE_COMMAND, args)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -444,7 +444,7 @@ class TtsPlaybackManager(
|
|||
val startChunkIndex = args.getInt(KEY_START_CHUNK_INDEX, 0)
|
||||
val ttsModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name)
|
||||
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) {
|
||||
chunks.mapIndexed { index, text ->
|
||||
|
|
@ -483,7 +483,7 @@ class TtsPlaybackManager(
|
|||
}
|
||||
CHANGE_TTS_MODE_COMMAND -> {
|
||||
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)
|
||||
}
|
||||
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