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:
parent
1ce353ad44
commit
3dc1843801
10 changed files with 869 additions and 150 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue