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

@ -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)
}
_ttsState.value = _ttsState.value.copy(speakerId = newSpeakerId)
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."