Tts upgrade (#25)

* enable cloud tts but only in pro(flavor) debug mode for testing

* Added a device voice settings sheet to allow users to select and preview system-level TTS voices.

* Refactored `DeviceVoiceSettingsSheet` to improve voice selection and filtering, and updated `BaseTtsSynthesizer` for better language handling.
This commit is contained in:
Aryan 2026-03-04 10:16:32 +05:30 committed by GitHub
parent 1ce353ad44
commit 3dc1843801
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 869 additions and 150 deletions

View file

@ -47,6 +47,7 @@ android {
buildConfigField("String", "VERIFIER_WORKER_URL", "\"\"")
buildConfigField("String", "FEEDBACK_WORKER_URL", "\"\"")
buildConfigField("boolean", "IS_PRO", "false")
buildConfigField("String", "TTS_WORKER_URL", "\"\"")
}
}

View file

@ -20,14 +20,20 @@
package com.aryan.reader
import android.content.Context
import timber.log.Timber
import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import android.speech.tts.TextToSpeech
import android.speech.tts.Voice
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -35,9 +41,14 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
@ -45,71 +56,80 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ArrowDropUp
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Smartphone
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.media3.common.util.UnstableApi
import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.tts.GOOGLE_TTS_SPEAKERS
import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.loadTtsMode
import com.aryan.reader.tts.rememberTtsController
import com.aryan.reader.tts.splitTextIntoChunks
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.media3.common.util.UnstableApi
import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.tts.rememberTtsController
import com.aryan.reader.tts.splitTextIntoChunks
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import org.commonmark.node.AbstractVisitor
import org.commonmark.node.Code
import org.commonmark.node.Emphasis
@ -121,8 +141,12 @@ import org.commonmark.node.SoftLineBreak
import org.commonmark.node.StrongEmphasis
import org.commonmark.node.Text
import org.commonmark.parser.Parser
import org.json.JSONObject
import timber.log.Timber
import java.io.File
import androidx.compose.runtime.DisposableEffect
import java.net.HttpURLConnection
import java.net.URL
import androidx.core.content.edit
const val aiServerBasePath = BuildConfig.AI_WORKER_URL
const val summarizeEndpoint = "/summarize"
@ -132,6 +156,8 @@ const val aiDefinitionUrl = aiServerBasePath + defineEndpoint
const val recapEndpoint = "/recap"
const val recapUrl = aiServerBasePath + recapEndpoint
const val PREF_NATIVE_TTS_VOICE = "native_tts_voice_name"
data class SearchResult(
val locationInSource: Int,
val locationTitle: String,
@ -340,6 +366,7 @@ fun SummarizationPopup(
) {
val ttsController = rememberTtsController()
val ttsState by ttsController.ttsState.collectAsState()
val context = LocalContext.current
LocalContext.current
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
@ -419,7 +446,7 @@ fun SummarizationPopup(
bookTitle = title,
chapterTitle = "Summary",
coverImageUri = null,
ttsMode = "BASE",
ttsMode = loadTtsMode(context),
playbackSource = "POPUP"
)
}
@ -508,7 +535,7 @@ fun AiDefinitionPopup(
) {
val ttsController = rememberTtsController()
val ttsState by ttsController.ttsState.collectAsState()
LocalContext.current
val context = LocalContext.current
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
@ -593,7 +620,7 @@ fun AiDefinitionPopup(
bookTitle = "AI Definition",
chapterTitle = word,
coverImageUri = null,
ttsMode = "BASE",
ttsMode = loadTtsMode(context),
playbackSource = "POPUP"
)
}
@ -982,3 +1009,590 @@ suspend fun fetchRecap(
}
}
}
@OptIn(UnstableApi::class)
@kotlin.OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TtsSettingsSheet(
isVisible: Boolean,
onDismiss: () -> Unit,
currentMode: TtsPlaybackManager.TtsMode,
onModeChange: (TtsPlaybackManager.TtsMode) -> Unit,
currentSpeakerId: String,
onSpeakerChange: (String) -> Unit,
isTtsActive: Boolean
) {
if (isVisible) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
rememberLazyListState()
val context = LocalContext.current
val scope = rememberCoroutineScope()
val samplePlayer = remember(context, scope) { SpeakerSamplePlayer(context, scope) }
DisposableEffect(Unit) {
onDispose { samplePlayer.release() }
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
contentWindowInsets = { WindowInsets.navigationBars }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 24.dp)
) {
Text(
text = "Text-to-Speech Settings",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)
if (isTtsActive) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(12.dp)
) {
Icon(Icons.Default.Stop, contentDescription = null, tint = MaterialTheme.colorScheme.onErrorContainer)
Spacer(Modifier.width(12.dp))
Text(
"Please stop playback to change settings.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
Text(
text = "Synthesis Mode",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 8.dp)
)
// Mode Selector
Row(
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(25.dp))
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
TtsPlaybackManager.TtsMode.entries.forEach { mode ->
val isSelected = currentMode == mode
val label = if (mode == TtsPlaybackManager.TtsMode.BASE) "On-Device" else "Cloud (HQ)"
val icon = if (mode == TtsPlaybackManager.TtsMode.BASE) Icons.Default.Smartphone else Icons.Default.Cloud
Surface(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clickable(enabled = !isTtsActive) { onModeChange(mode) },
shape = RoundedCornerShape(25.dp),
color = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent,
contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant
) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium
)
}
}
}
}
Spacer(Modifier.height(24.dp))
if (currentMode == TtsPlaybackManager.TtsMode.CLOUD) {
Text(
text = "Voice Selection",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 8.dp)
)
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp))
) {
items(GOOGLE_TTS_SPEAKERS) { (name, id) ->
val isSelected = currentSpeakerId == id
val isPlaying = samplePlayer.playingSpeakerId == id
val isLoading = samplePlayer.loadingSpeakerId == id
ListItem(
headlineContent = { Text(name, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal) },
leadingContent = {
if (isSelected) {
Icon(Icons.Default.Check, contentDescription = "Selected", tint = MaterialTheme.colorScheme.primary)
} else {
Icon(Icons.Default.GraphicEq, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f))
}
},
trailingContent = {
if (!isTtsActive) {
IconButton(onClick = { samplePlayer.playOrStop(id) }) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Icon(
imageVector = if (isPlaying) Icons.Default.Stop else Icons.Default.PlayArrow,
contentDescription = "Play Sample",
tint = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
},
colors = ListItemDefaults.colors(
containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) else Color.Transparent
),
modifier = Modifier.clickable(enabled = !isTtsActive) {
onSpeakerChange(id)
}
)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
}
}
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
contentAlignment = Alignment.Center
) {
Text(
"Using system default engine settings.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
fun loadNativeVoice(context: Context): String? {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getString(PREF_NATIVE_TTS_VOICE, null)
}
private fun saveNativeVoice(context: Context, @Suppress("SameParameterValue") voiceName: String?) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
if (voiceName == null) {
prefs.edit { remove(PREF_NATIVE_TTS_VOICE) }
} else {
prefs.edit { putString(PREF_NATIVE_TTS_VOICE, voiceName) }
}
}
@Composable
fun DeviceVoiceSettingsSheet(
isVisible: Boolean,
onDismiss: () -> Unit
) {
if (isVisible) {
val listState = rememberLazyListState()
val context = LocalContext.current
@Suppress("UnusedVariable", "Unused") val scope = rememberCoroutineScope()
var ttsEngine by remember { mutableStateOf<TextToSpeech?>(null) }
var allVoices by remember { mutableStateOf<List<Voice>>(emptyList()) }
var isTtsLoading by remember { mutableStateOf(true) }
var savedVoiceName by remember { mutableStateOf(loadNativeVoice(context)) }
val allLanguagesOption = "All Languages"
var selectedLanguage by remember { mutableStateOf(allLanguagesOption) }
val numberedVoiceNames = remember(allVoices) {
val nameMap = mutableMapOf<String, String>()
val groupedByLanguage = allVoices.groupBy { it.locale.displayName }
groupedByLanguage.forEach { (langName, voiceList) ->
if (voiceList.size > 1) {
voiceList.forEachIndexed { index, voice ->
val type = try {
if (voice.isNetworkConnectionRequired) "Online" else "Offline"
} catch (_: Exception) {
"Offline"
}
nameMap[voice.name] = "$langName ($type) - ${index + 1}"
}
} else {
val voice = voiceList[0]
val type = try {
if (voice.isNetworkConnectionRequired) "Online" else "Offline"
} catch (_: Exception) {
"Offline"
}
nameMap[voice.name] = "$langName ($type)"
}
}
nameMap
}
DisposableEffect(Unit) {
var tts: TextToSpeech? = null
tts = TextToSpeech(context) { status ->
if (status == TextToSpeech.SUCCESS) {
try {
val enginesVoices = tts?.voices
if (enginesVoices != null) {
allVoices = enginesVoices.toList().sortedBy { it.locale.displayName }
}
} catch (e: Exception) {
Timber.e(e, "Failed to fetch voices")
} finally {
isTtsLoading = false
}
} else {
isTtsLoading = false
Timber.e("TTS Initialization failed with status $status")
}
}
ttsEngine = tts
onDispose {
tts.shutdown()
}
}
val availableLanguages = remember(allVoices) {
val languages =
allVoices.asSequence().map { it.locale.displayLanguage }.filter { it.isNotBlank() }
.distinct().sorted().toMutableList()
languages.add(0, allLanguagesOption)
languages
}
LaunchedEffect(allVoices, savedVoiceName) {
if (savedVoiceName != null && allVoices.isNotEmpty()) {
val savedVoice = allVoices.find { it.name == savedVoiceName }
if (savedVoice != null) {
val voiceLanguage = savedVoice.locale.displayLanguage
if (selectedLanguage == allLanguagesOption) {
selectedLanguage = voiceLanguage
}
}
}
}
val filteredVoices = remember(selectedLanguage, allVoices) {
if (selectedLanguage == allLanguagesOption) {
allVoices
} else {
allVoices.filter { it.locale.displayLanguage == selectedLanguage }
}
}
LaunchedEffect(filteredVoices, savedVoiceName) {
if (savedVoiceName != null && filteredVoices.isNotEmpty()) {
val index = filteredVoices.indexOfFirst { it.name == savedVoiceName }
if (index != -1) {
Timber.d("Auto-scrolling to voice at index: $index")
delay(300)
listState.animateScrollToItem(index)
}
}
}
androidx.compose.ui.window.Dialog(
onDismissRequest = onDismiss, properties = androidx.compose.ui.window.DialogProperties(
usePlatformDefaultWidth = false
)
) {
Column(
modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Bottom
) {
Box(
modifier = Modifier.weight(1f).fillMaxWidth().clickable(
interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() },
indication = null,
onClick = onDismiss
)
)
Surface(
modifier = Modifier.fillMaxWidth().fillMaxHeight(0.9f),
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp
) {
Column(
modifier = Modifier.fillMaxSize()
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(horizontal = 24.dp).padding(bottom = 24.dp, top = 24.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "On-Device Voice Settings",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "Close Settings")
}
}
Surface(
color = if (savedVoiceName == null) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(16.dp),
tonalElevation = if (savedVoiceName == null) 4.dp else 0.dp,
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp).clickable {
savedVoiceName = null
saveNativeVoice(context, null)
Timber.d("Native TTS: Reset to System Default")
}) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Smartphone,
contentDescription = null,
tint = if (savedVoiceName == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "System Default",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "Matches your Android system settings",
style = MaterialTheme.typography.bodySmall
)
}
if (savedVoiceName == null) {
Icon(
Icons.Default.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp))
if (isTtsLoading) {
Box(
modifier = Modifier.fillMaxWidth().height(200.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
Text("Loading voices...", modifier = Modifier.padding(top = 48.dp))
}
} else if (allVoices.isEmpty()) {
Box(
modifier = Modifier.fillMaxWidth().height(100.dp),
contentAlignment = Alignment.Center
) {
Text(
"No voices available on this device.",
color = MaterialTheme.colorScheme.error
)
}
} else {
var expandedLanguageMenu by remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Specific Voices",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
Spacer(modifier = Modifier.height(8.dp))
Box(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
Surface(
modifier = Modifier.fillMaxWidth().height(50.dp)
.clickable { expandedLanguageMenu = true },
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface,
border = androidx.compose.foundation.BorderStroke(
1.dp, MaterialTheme.colorScheme.outlineVariant
)
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = selectedLanguage,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
DropdownMenu(
expanded = expandedLanguageMenu,
onDismissRequest = { expandedLanguageMenu = false },
modifier = Modifier.fillMaxWidth(0.85f).heightIn(max = 400.dp)
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
) {
availableLanguages.forEach { language ->
DropdownMenuItem(text = {
Text(
text = language,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
}, onClick = {
selectedLanguage = language
expandedLanguageMenu = false
})
}
}
}
if (filteredVoices.isNotEmpty()) {
Text(
text = "Available Voices (${filteredVoices.size})",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 8.dp, start = 4.dp)
)
LazyColumn(
state = listState,
modifier = Modifier.fillMaxWidth().weight(1f, fill = false)
.padding(vertical = 4.dp).border(
1.dp,
MaterialTheme.colorScheme.outlineVariant,
RoundedCornerShape(12.dp)
)
) {
items(
filteredVoices.size,
key = { filteredVoices[it].name }) { index ->
val voice = filteredVoices[index]
val isSelected = voice.name == savedVoiceName
val friendlyName = numberedVoiceNames[voice.name]
?: voice.locale.displayName
ListItem(
headlineContent = {
Text(
text = friendlyName,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurface
)
},
supportingContent = if (voice.locale.variant.isNotEmpty()) {
{ Text("Variant: ${voice.locale.variant}") }
} else null,
leadingContent = {
if (isSelected) {
Icon(
Icons.Default.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary
)
} else {
Spacer(modifier = Modifier.size(24.dp))
}
},
trailingContent = {
IconButton(onClick = {
val params = android.os.Bundle()
ttsEngine?.voice = voice
val sampleText =
"This is a sample of ${voice.locale.displayLanguage}."
ttsEngine?.speak(
sampleText,
TextToSpeech.QUEUE_FLUSH,
params,
"SAMPLE_ID"
)
}) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Play Sample",
tint = MaterialTheme.colorScheme.primary
)
}
},
modifier = Modifier.clickable {
savedVoiceName = voice.name
saveNativeVoice(context, voice.name)
}.background(
if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(
alpha = 0.2f
) else Color.Transparent
)
)
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(
alpha = 0.5f
)
)
}
}
} else {
Box(
modifier = Modifier.fillMaxWidth().padding(24.dp),
contentAlignment = Alignment.Center
) {
Text(
"No voices found for this language.",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
}
}
}

View file

@ -72,6 +72,7 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material.icons.filled.Menu
@ -145,6 +146,8 @@ fun EpubReaderTopBar(
onToggleVolumeScroll: (Boolean) -> Unit,
onTogglePageTurnAnimation: (Boolean) -> Unit,
onStartAutoScroll: () -> Unit,
onOpenTtsSettings: () -> Unit,
onOpenDeviceVoiceSettings: () -> Unit,
searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
modifier: Modifier = Modifier
) {
@ -268,6 +271,33 @@ fun EpubReaderTopBar(
onStartAutoScroll()
}
)
HorizontalDivider()
// *** ADDITION START ***
DropdownMenuItem(
text = { Text("TTS Voice Settings") },
onClick = {
showMoreMenu = false
onOpenDeviceVoiceSettings()
},
leadingIcon = {
Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp))
}
)
if (BuildConfig.DEBUG) {
DropdownMenuItem(
text = { Text("TTS Settings (Debug)") },
onClick = {
showMoreMenu = false
onOpenTtsSettings()
},
leadingIcon = {
Icon(painter = painterResource(id = R.drawable.text_to_speech), contentDescription = null, modifier = Modifier.size(20.dp))
}
)
}
}
}
}

View file

@ -127,10 +127,12 @@ import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.BannerMessage
import com.aryan.reader.BuildConfig
import com.aryan.reader.CustomTopBanner
import com.aryan.reader.DeviceVoiceSettingsSheet
import com.aryan.reader.RenderMode
import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.TtsSettingsSheet
import com.aryan.reader.countWords
import com.aryan.reader.data.CustomFontEntity
import com.aryan.reader.epub.EpubBook
@ -150,7 +152,7 @@ import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.paginatedreader.semanticBlockModule
import com.aryan.reader.rememberSearchState
import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
import com.aryan.reader.tts.loadTtsMode
import com.aryan.reader.tts.rememberTtsController
import com.aryan.reader.tts.splitTextIntoChunks
import kotlinx.coroutines.Job
@ -177,6 +179,7 @@ private const val AUTO_SCROLL_USE_SLIDER_KEY = "auto_scroll_use_slider"
private const val AUTO_SCROLL_MIN_SPEED_KEY = "auto_scroll_min_speed"
private const val AUTO_SCROLL_MAX_SPEED_KEY = "auto_scroll_max_speed"
private const val PAGE_TURN_ANIMATION_KEY = "page_turn_animation_enabled"
private const val TTS_MODE_KEY = "tts_mode"
private fun savePageTurnAnimationSetting(context: Context, isEnabled: Boolean) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
@ -229,6 +232,11 @@ private fun loadAutoScrollUseSlider(context: Context): Boolean {
return prefs.getBoolean(AUTO_SCROLL_USE_SLIDER_KEY, false)
}
private fun saveTtsMode(context: Context, modeName: String) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putString(TTS_MODE_KEY, modeName) }
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Composable
fun EpubReaderScreen(
@ -325,6 +333,8 @@ fun EpubReaderHost(
mutableStateOf(loadPageTurnAnimationSetting(context))
}
var currentTtsMode by remember { mutableStateOf(loadTtsMode(context)) }
val locatorConverter = remember(context) {
LocatorConverter(
bookCacheDao = BookCacheDatabase.getDatabase(context).bookCacheDao(),
@ -689,7 +699,7 @@ fun EpubReaderHost(
bookTitle = epubBook.title,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
ttsMode = TtsMode.BASE.name,
ttsMode = currentTtsMode,
playbackSource = "READER"
)
}
@ -708,6 +718,8 @@ fun EpubReaderHost(
var showPermissionRationaleDialog by remember { mutableStateOf(false) }
val isDarkTheme = isSystemInDarkTheme()
var showTtsSettingsSheet by remember { mutableStateOf(false) }
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
TtsSessionObserver(
ttsState = ttsState,
@ -731,7 +743,8 @@ fun EpubReaderHost(
},
onToggleTtsStartOnLoad = { shouldStart -> ttsShouldStartOnChapterLoad = shouldStart },
userStoppedTts = userStoppedTts,
scope = scope
scope = scope,
currentTtsMode = currentTtsMode
)
TtsHighlightHandler(
@ -1917,7 +1930,7 @@ fun EpubReaderHost(
bookTitle = epubBook.title,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
ttsMode = TtsMode.BASE.name,
ttsMode = currentTtsMode,
playbackSource = "READER"
)
} else {
@ -2741,7 +2754,9 @@ fun EpubReaderHost(
showBars = true
},
searchFocusRequester = searchFocusRequester,
modifier = Modifier.align(Alignment.TopCenter)
modifier = Modifier.align(Alignment.TopCenter),
onOpenTtsSettings = { showTtsSettingsSheet = true },
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true }
)
val autoScrollPadding by androidx.compose.animation.core.animateDpAsState(
@ -3206,6 +3221,31 @@ fun EpubReaderHost(
PageScrubbingAnimation(currentPage = sliderCurrentPage.roundToInt(), totalPages = total)
}
if (showTtsSettingsSheet) {
TtsSettingsSheet(
isVisible = true,
onDismiss = { showTtsSettingsSheet = false },
currentMode = currentTtsMode,
onModeChange = { newMode ->
currentTtsMode = newMode
saveTtsMode(context, newMode.name)
ttsController.changeTtsMode(newMode.name)
},
currentSpeakerId = ttsState.speakerId,
onSpeakerChange = { newSpeaker ->
ttsController.changeSpeaker(newSpeaker)
},
isTtsActive = (ttsState.isPlaying || ttsState.isLoading) && ttsState.playbackSource == "READER"
)
}
if (showDeviceVoiceSettingsSheet) {
DeviceVoiceSettingsSheet(
isVisible = true,
onDismiss = { showDeviceVoiceSettingsSheet = false }
)
}
if (showFontSelectionSheet) {
ModalBottomSheet(
onDismissRequest = { showFontSelectionSheet = false },

View file

@ -45,7 +45,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File
const val TAG_TTS_DIAGNOSIS = "TTS_DIAGNOSIS"
private const val TTS_MODE_KEY = "tts_mode"
data class TtsHighlightInfo(
@ -61,13 +60,6 @@ fun saveTtsMode(context: Context, mode: TtsMode) {
prefs.edit { putString(TTS_MODE_KEY, mode.name) }
}
@Suppress("unused")
@OptIn(UnstableApi::class)
fun loadTtsMode(): TtsMode {
// For this release, Cloud TTS is disabled. Force BASE mode.
return TtsMode.BASE
}
/**
* Helper to update the WebView auto-scroll state.
*/
@ -121,20 +113,20 @@ fun TtsSessionObserver(
onNavigateToChapter: (Int) -> Unit,
onToggleTtsStartOnLoad: (Boolean) -> Unit,
userStoppedTts: Boolean,
scope: CoroutineScope
scope: CoroutineScope,
currentTtsMode: TtsMode
) {
val prevTtsState = remember { mutableStateOf(ttsState) }
LaunchedEffect(ttsState) {
val wasPlaying = prevTtsState.value.isPlaying
val isPlaying = ttsState.isPlaying
val isChangingConfig = ttsState.isChangingConfig
val sessionFinished = ttsState.sessionFinished
val wasSessionFinished = prevTtsState.value.sessionFinished
val sessionEndedByStop = ttsState.sessionEndedByStop
val isReaderSource = ttsState.playbackSource == "READER"
if (!isChangingConfig && isReaderSource) {
if (isReaderSource) {
if (sessionFinished && !wasSessionFinished) {
Timber.d("TTS finished naturally. Checking for next content.")
@ -161,7 +153,8 @@ fun TtsSessionObserver(
epubBookTitle = epubBookTitle,
coverImagePath = coverImagePath,
onUpdateTtsChapter = onTtsChapterIndexChange,
scope = scope
scope = scope,
ttsMode = currentTtsMode
)
}
} else if (wasPlaying && !isPlaying && !sessionFinished) {
@ -271,14 +264,14 @@ private fun handlePaginatedAutoAdvance(
epubBookTitle: String,
coverImagePath: String?,
onUpdateTtsChapter: (Int?) -> Unit,
scope: CoroutineScope
scope: CoroutineScope,
ttsMode: TtsMode
) {
val lastPlayedChapter = currentTtsChapterIndex
if (lastPlayedChapter != null && lastPlayedChapter < chapters.size - 1) {
if (currentTtsChapterIndex != null && currentTtsChapterIndex < chapters.size - 1) {
Timber.d("Paginated: Searching for next TTS content...")
scope.launch {
var chapterToTry = lastPlayedChapter + 1
var chapterToTry = currentTtsChapterIndex + 1
var foundContent = false
val bookPaginator = paginator as? BookPaginator
@ -288,7 +281,6 @@ private fun handlePaginatedAutoAdvance(
}
while (chapterToTry < chapters.size) {
// Visually scroll to start of chapter
val targetPage = bookPaginator.chapterStartPageIndices[chapterToTry]
if (targetPage != null && pagerState.currentPage != targetPage) {
pagerState.animateScrollToPage(targetPage)
@ -309,13 +301,12 @@ private fun handlePaginatedAutoAdvance(
bookTitle = epubBookTitle,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
ttsMode = "BASE" // Defaulting to Base for safety
ttsMode = ttsMode
)
foundContent = true
break
} else {
Timber.d("Paginated: Chapter $chapterToTry is empty. Skipping.")
// Visually flip through empty pages if needed
val pageCount = bookPaginator.chapterPageCounts[chapterToTry] ?: 0
if (pageCount > 1) {
for (i in 1 until pageCount) {

View file

@ -26,12 +26,6 @@ import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.geometry.lerp
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.RectF
@ -99,6 +93,7 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.FullscreenExit
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
@ -134,10 +129,8 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
@ -174,13 +167,18 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.lerp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
@ -194,6 +192,7 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
@ -224,13 +223,14 @@ import androidx.paging.compose.itemKey
import com.aryan.reader.AiDefinitionPopup
import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.BuildConfig
import com.aryan.reader.DeviceVoiceSettingsSheet
import com.aryan.reader.MainViewModel
import com.aryan.reader.R
import com.aryan.reader.SearchResult
import com.aryan.reader.SearchTopBar
import com.aryan.reader.SummarizationPopup
import com.aryan.reader.SummarizationResult
import com.aryan.reader.SyncUpdateInfo
import com.aryan.reader.TtsSettingsSheet
import com.aryan.reader.epubreader.AutoScrollControls
import com.aryan.reader.fetchAiDefinition
import com.aryan.reader.paginatedreader.TtsChunk
@ -246,6 +246,8 @@ import com.aryan.reader.pdf.data.VirtualPage
import com.aryan.reader.rememberSearchState
import com.aryan.reader.summarizationUrl
import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.loadTtsMode
import com.aryan.reader.tts.rememberTtsController
import com.aryan.reader.tts.splitTextIntoChunks
import io.legere.pdfiumandroid.PdfDocument
@ -263,7 +265,6 @@ import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONArray
import org.json.JSONObject
import timber.log.Timber
@ -473,11 +474,6 @@ private fun loadPdfDarkMode(context: Context): Boolean {
return prefs.getBoolean(PDF_DARK_MODE_KEY, false)
}
@Suppress("unused")
private enum class TtsMode {
CLOUD, BASE
}
private data class TtsPageData(
val pageIndex: Int, val processedText: ProcessedText, val fromOcr: Boolean
)
@ -501,18 +497,13 @@ private fun flattenToc(bookmarks: List<PdfDocument.Bookmark>, level: Int = 0): L
return entries
}
@OptIn(UnstableApi::class)
@Suppress("unused")
private fun saveTtsMode(context: Context, mode: TtsMode) {
private fun saveTtsMode(context: Context, mode: TtsPlaybackManager.TtsMode) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(TTS_MODE_KEY, mode.name) }
}
@Suppress("unused")
private fun loadTtsMode(): TtsMode {
// For this release, Cloud TTS is disabled. Force BASE mode.
return TtsMode.BASE
}
private suspend fun renderPageToBitmap(doc: PdfDocumentKt, pageIndex: Int): Bitmap? {
return withContext(Dispatchers.IO) {
var page: PdfPageKt? = null
@ -658,6 +649,10 @@ fun PdfViewerScreen(
var isAutoScrollLocked by remember { mutableStateOf(loadPdfAutoScrollLocked(context)) }
var autoScrollUseSlider by remember { mutableStateOf(loadPdfAutoScrollUseSlider(context)) }
var currentTtsMode by remember { mutableStateOf(loadTtsMode(context)) }
var showTtsSettingsSheet by remember { mutableStateOf(false) }
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
fun triggerAutoScrollTempPause(durationMs: Long) {
if (!isAutoScrollModeActive || !isAutoScrollPlaying) return
@ -1981,7 +1976,7 @@ fun PdfViewerScreen(
bookTitle = bookTitle,
chapterTitle = pageTitle,
coverImageUri = null,
ttsMode = TtsMode.BASE.name,
ttsMode = currentTtsMode,
playbackSource = "READER"
)
@ -2688,6 +2683,8 @@ fun PdfViewerScreen(
searchState.onQueryChange("")
}
showTtsSettingsSheet -> showTtsSettingsSheet = false
else -> {
saveStateAndExit()
}
@ -4137,6 +4134,36 @@ fun PdfViewerScreen(
showBars = true
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("TTS Voice Settings") },
onClick = {
showMoreMenu = false
showDeviceVoiceSettingsSheet = true
},
leadingIcon = {
Icon(
imageVector = Icons.Default.GraphicEq,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
)
if (BuildConfig.DEBUG) {
DropdownMenuItem(
text = { Text("TTS Settings (Debug)") },
onClick = {
showMoreMenu = false
showTtsSettingsSheet = true
},
leadingIcon = {
Icon(painter = painterResource(id = R.drawable.text_to_speech), contentDescription = null, modifier = Modifier.size(20.dp))
}
)
}
HorizontalDivider()
DropdownMenuItem(text = {
Text(
@ -5410,6 +5437,31 @@ fun PdfViewerScreen(
})
}
if (showTtsSettingsSheet) {
TtsSettingsSheet(
isVisible = true,
onDismiss = { showTtsSettingsSheet = false },
currentMode = currentTtsMode,
onModeChange = { newMode ->
currentTtsMode = newMode
saveTtsMode(context, newMode)
ttsController.changeTtsMode(newMode.name)
},
currentSpeakerId = ttsState.speakerId,
onSpeakerChange = { newSpeaker ->
ttsController.changeSpeaker(newSpeaker)
},
isTtsActive = isTtsSessionActive
)
}
if (showDeviceVoiceSettingsSheet) {
DeviceVoiceSettingsSheet(
isVisible = true,
onDismiss = { showDeviceVoiceSettingsSheet = false }
)
}
if (clickedLinkUrl != null) {
val url = clickedLinkUrl!!
AlertDialog(

View file

@ -23,6 +23,7 @@ import android.content.Context
import android.os.Bundle
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import com.aryan.reader.loadNativeVoice
import timber.log.Timber
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.TimeoutCancellationException
@ -102,7 +103,6 @@ class BaseTtsSynthesizer(private val context: Context) {
if (isInitialized) return
return suspendCancellableCoroutine { continuation ->
Timber.d("BaseTts: Initializing TextToSpeech engine...")
// Use Application Context to prevent memory leaks and detachment issues
tts = TextToSpeech(context.applicationContext) { status ->
if (status == TextToSpeech.SUCCESS) {
isInitialized = true
@ -137,11 +137,38 @@ class BaseTtsSynthesizer(private val context: Context) {
} finally {
tts = null
isInitialized = false
// COOL-DOWN: Critical delay to allow OS Service to unbind/reset before we try to init again.
delay(350)
}
}
private fun applyPreferredVoice() {
if (tts == null) return
try {
val preferredVoiceName = loadNativeVoice(context) ?: return
if (tts?.voice?.name == preferredVoiceName) return
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})")
tts?.voice = targetVoice
try {
tts?.language = targetVoice.locale
} catch (e: Exception) {
Timber.e(e, "BaseTts: Failed to set language for voice")
}
} else {
Timber.w("BaseTts: Preferred voice '$preferredVoiceName' not found in current engine.")
}
}
} catch (e: Exception) {
Timber.e(e, "BaseTts: Failed to apply preferred voice")
}
}
suspend fun synthesizeToFile(text: String): Pair<File?, String?> {
if (text.isBlank()) {
return Pair(null, text)
@ -154,7 +181,6 @@ class BaseTtsSynthesizer(private val context: Context) {
val utteranceId = UUID.randomUUID().toString()
val tempFile = File.createTempFile("base_tts_", ".wav", context.cacheDir)
// Prepare signals
val resultDeferred = CompletableDeferred<Pair<File?, String?>>()
val startSignal = CompletableDeferred<Unit>()
@ -170,12 +196,12 @@ class BaseTtsSynthesizer(private val context: Context) {
}
}
applyPreferredVoice()
Timber.d("BaseTts: Requesting synthesis (Attempt $attempt). ID: $utteranceId")
requests[utteranceId] = RequestContext(resultDeferred, startSignal, tempFile, text)
// Prevention: No tts?.stop() here.
val ttsResult = tts?.synthesizeToFile(text, Bundle.EMPTY, tempFile, utteranceId)
if (ttsResult == TextToSpeech.ERROR) {
@ -202,7 +228,7 @@ class BaseTtsSynthesizer(private val context: Context) {
if (finalResult.first != null) {
result = finalResult
break // Success!
break
} else {
Timber.w("BaseTts: onError received during processing.")
throw IllegalStateException("TTS Engine reported onError")

View file

@ -36,6 +36,7 @@ import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.aryan.reader.BuildConfig
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors
@ -63,6 +64,28 @@ private fun loadSpeaker(context: Context): String {
return prefs.getString(TTS_SPEAKER_KEY, DEFAULT_SPEAKER_ID) ?: DEFAULT_SPEAKER_ID
}
@OptIn(UnstableApi::class)
@Suppress("KotlinConstantConditions")
fun loadTtsMode(context: Context): TtsPlaybackManager.TtsMode {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
val savedModeName = prefs.getString("tts_mode", TtsPlaybackManager.TtsMode.BASE.name)
?: TtsPlaybackManager.TtsMode.BASE.name
val isCloudAllowed = BuildConfig.DEBUG &&
BuildConfig.IS_PRO &&
BuildConfig.TTS_WORKER_URL.isNotBlank()
return if (isCloudAllowed) {
try {
TtsPlaybackManager.TtsMode.valueOf(savedModeName)
} catch (_: Exception) {
TtsPlaybackManager.TtsMode.BASE
}
} else {
TtsPlaybackManager.TtsMode.BASE
}
}
@UnstableApi
class TtsController(context: Context) : Player.Listener {
@ -135,7 +158,7 @@ class TtsController(context: Context) : Player.Listener {
bookTitle: String,
chapterTitle: String?,
coverImageUri: String?,
ttsMode: String,
ttsMode: TtsPlaybackManager.TtsMode,
playbackSource: String = "READER"
) {
if (chunks.isEmpty()) {
@ -143,7 +166,6 @@ class TtsController(context: Context) : Player.Listener {
return
}
Timber.d("UI sending START command with mode: $ttsMode")
Timber.d("TtsController: Sending START command. Chunk count: ${chunks.size}. Mode: $ttsMode. First chunk len: ${chunks.first().text.length}")
val textList = ArrayList(chunks.map { it.text })
val cfiList = ArrayList(chunks.map { it.sourceCfi })
@ -157,7 +179,7 @@ class TtsController(context: Context) : Player.Listener {
putString(KEY_BOOK_TITLE, bookTitle)
putString(KEY_CHAPTER_TITLE, chapterTitle)
putString(KEY_COVER_IMAGE_URI, coverImageUri)
putString(KEY_TTS_MODE, ttsMode)
putString(KEY_TTS_MODE, ttsMode.name)
putString(KEY_PLAYBACK_SOURCE, playbackSource)
}
mediaController?.sendCustomCommand(START_TTS_COMMAND, args)
@ -221,7 +243,6 @@ class TtsController(context: Context) : Player.Listener {
val serviceSpeaker = customState.getString("speakerId", _ttsState.value.speakerId)
val sessionEndedByStop = customState.getBoolean("sessionEndedByStop", false)
val isLoading = customState.getBoolean("isLoading", false)
val isChangingConfig = customState.getBoolean("isChangingConfig", false)
val sessionFinished = customState.getBoolean("sessionFinished", false)
val playbackSource = customState.getString("playbackSource")
@ -256,7 +277,6 @@ class TtsController(context: Context) : Player.Listener {
sessionEndedByStop = sessionEndedByStop,
currentWordSourceCfi = if (isPlaybackActive) currentWordSourceCfi else null,
currentWordStartOffset = if (isPlaybackActive) currentWordStartOffset else -1,
isChangingConfig = isChangingConfig,
sessionFinished = sessionFinished,
playbackSource = playbackSource
)

View file

@ -80,7 +80,6 @@ class TtsPlaybackManager(
private val prefetchingJobs = mutableMapOf<Int, Job>()
private var wordTrackingJob: Job? = null
private var preparationJob: Job? = null
private var isChangingConfig = false
enum class TtsMode {
CLOUD, BASE
@ -98,7 +97,6 @@ class TtsPlaybackManager(
val sessionEndedByStop: Boolean = false,
val currentWordSourceCfi: String? = null,
val currentWordStartOffset: Int = -1,
val isChangingConfig: Boolean = false,
val sessionFinished: Boolean = false,
val playbackSource: String? = null
)
@ -211,29 +209,8 @@ class TtsPlaybackManager(
private fun handleChangeTtsMode(newMode: TtsMode) {
if (currentTtsMode == newMode) return
preparationJob?.cancel()
isChangingConfig = true
val wasPlaying = player.isPlaying
val currentChunkIndex = player.currentMediaItem?.mediaId?.toIntOrNull()
val currentPosition = player.currentPosition
currentTtsMode = newMode
if (textChunks.isEmpty()) {
_ttsState.value = _ttsState.value.copy(isPlaying = false)
isChangingConfig = false
return
}
_ttsState.value = _ttsState.value.copy(isLoading = true, isPlaying = false, isChangingConfig = true)
player.stop()
player.clearMediaItems()
prefetchingJobs.values.forEach { it.cancel() }
prefetchingJobs.clear()
scope.launch {
clearAudioFiles()
}
preparationJob = scope.launch {
val startIndex = currentChunkIndex ?: 0
prepareAndPlayFirstChunk(startAtIndex = startIndex, playWhenReady = wasPlaying, startAtPosition = currentPosition)
}
Timber.d("TTS Mode changed to $newMode (pending next start)")
}
private fun handleStartTts(
@ -245,10 +222,6 @@ class TtsPlaybackManager(
ttsMode: TtsMode,
playbackSource: String?
) {
if (isChangingConfig) {
Timber.w("Ignoring START command because a config change is already in progress.")
return
}
if (chunks.isEmpty()) {
_ttsState.value = _ttsState.value.copy(errorMessage = "No text to read.")
return
@ -269,36 +242,15 @@ class TtsPlaybackManager(
private fun handleChangeSpeaker(newSpeakerId: String) {
if (currentSpeakerId == newSpeakerId) return
preparationJob?.cancel()
isChangingConfig = true
val wasPlaying = player.isPlaying
val currentChunkIndex = player.currentMediaItem?.mediaId?.toIntOrNull()
val currentPosition = player.currentPosition
currentSpeakerId = newSpeakerId
if (textChunks.isEmpty()) {
_ttsState.value = _ttsState.value.copy(speakerId = newSpeakerId)
isChangingConfig = false
return
}
_ttsState.value = _ttsState.value.copy(speakerId = newSpeakerId, isLoading = true, isPlaying = false, isChangingConfig = true)
player.stop()
player.clearMediaItems()
prefetchingJobs.values.forEach { it.cancel() }
prefetchingJobs.clear()
scope.launch {
clearAudioFiles()
}
preparationJob = scope.launch {
val startIndex = currentChunkIndex ?: 0
prepareAndPlayFirstChunk(startAtIndex = startIndex, playWhenReady = wasPlaying, startAtPosition = currentPosition)
}
Timber.d("Speaker changed to $newSpeakerId (pending next start)")
}
private suspend fun prepareAndPlayFirstChunk(startAtIndex: Int = 0, playWhenReady: Boolean = true, startAtPosition: Long = 0L) {
val firstChunk = textChunks.getOrNull(startAtIndex)
if (firstChunk == null) {
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Error starting playback.", isChangingConfig = false)
withContext(Dispatchers.Main) { isChangingConfig = false }
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Error starting playback.")
return
}
@ -323,20 +275,17 @@ class TtsPlaybackManager(
player.seekTo(startAtPosition)
}
player.playWhenReady = playWhenReady
isChangingConfig = false
_ttsState.value = _ttsState.value.copy(
isLoading = false,
isPlaying = playWhenReady,
currentText = serverText,
sourceCfi = updatedChunk.sourceCfi,
startOffsetInSource = updatedChunk.startOffsetInSource,
isChangingConfig = false
startOffsetInSource = updatedChunk.startOffsetInSource
)
}
prefetchNextChunkAudio(startAtIndex)
} else {
withContext(Dispatchers.Main) { isChangingConfig = false }
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Failed to load audio.", isChangingConfig = false)
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Failed to load audio.")
}
}
@ -456,7 +405,6 @@ class TtsPlaybackManager(
if (isLastChunkInSession || textChunks.isEmpty()) {
nextState = nextState.copy(sessionFinished = true)
} else {
// Check if prefetch is active for the next chunk
val nextIdx = currentChunkIndex + 1
val isPrefetching = prefetchingJobs.containsKey(nextIdx)
@ -472,9 +420,6 @@ class TtsPlaybackManager(
_ttsState.value = nextState
if (!isPlaying && player.playbackState == Player.STATE_IDLE) {
if (isChangingConfig) {
return
}
if (!nextState.sessionEndedByStop) {
handleStopTts(userInitiated = true)
}
@ -620,7 +565,6 @@ class TtsPlaybackManager(
putBoolean("sessionEndedByStop", state.sessionEndedByStop)
putString("currentWordSourceCfi", state.currentWordSourceCfi)
putInt("currentWordStartOffset", state.currentWordStartOffset)
putBoolean("isChangingConfig", state.isChangingConfig)
putBoolean("sessionFinished", state.sessionFinished)
putString("playbackSource", state.playbackSource)
}

View file

@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.net.toUri
import com.aryan.reader.BuildConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -34,7 +35,7 @@ import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
const val googleCloudWorkerTtsUrl = ""
const val googleCloudWorkerTtsUrl = BuildConfig.TTS_WORKER_URL
const val TTS_SAMPLE_TEXT = "The greater danger for most of us lies not in setting our aim too high and falling short; but in setting our aim too low, and achieving our mark."