Initial commit
This commit is contained in:
commit
6072b2ba29
844 changed files with 220532 additions and 0 deletions
223
app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt
Normal file
223
app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
// BaseTtsSynthesizer.kt
|
||||
package com.aryan.reader.tts
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import timber.log.Timber
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
private const val START_TIMEOUT_FAST_MS = 750L
|
||||
private const val START_TIMEOUT_RETRY_MS = 2500L
|
||||
private const val PROCESS_TIMEOUT_MS = 4000L
|
||||
private const val MAX_RETRY_ATTEMPTS = 3
|
||||
|
||||
class BaseTtsSynthesizer(private val context: Context) {
|
||||
|
||||
private var tts: TextToSpeech? = null
|
||||
private var isInitialized = false
|
||||
private val mutex = Mutex()
|
||||
|
||||
private data class RequestContext(
|
||||
val resultDeferred: CompletableDeferred<Pair<File?, String?>>,
|
||||
val startSignal: CompletableDeferred<Unit>,
|
||||
val file: File,
|
||||
val text: String
|
||||
)
|
||||
|
||||
private val requests = ConcurrentHashMap<String, RequestContext>()
|
||||
|
||||
private val sharedListener = object : UtteranceProgressListener() {
|
||||
override fun onStart(utteranceId: String?) {
|
||||
Timber.d("BaseTts: onStart $utteranceId [Thread: ${Thread.currentThread().name}]")
|
||||
utteranceId?.let { id ->
|
||||
requests[id]?.startSignal?.complete(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDone(utteranceId: String?) {
|
||||
utteranceId?.let { id ->
|
||||
val req = requests.remove(id)
|
||||
if (req != null) {
|
||||
Timber.d("BaseTts: onDone $id. [Thread: ${Thread.currentThread().name}]")
|
||||
req.resultDeferred.complete(Pair(req.file, req.text))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("OVERRIDE_DEPRECATION")
|
||||
override fun onError(utteranceId: String?) {
|
||||
onError(utteranceId, -1)
|
||||
}
|
||||
|
||||
override fun onError(utteranceId: String?, errorCode: Int) {
|
||||
Timber.e("BaseTts: onError $utteranceId code=$errorCode [Thread: ${Thread.currentThread().name}]")
|
||||
utteranceId?.let { id ->
|
||||
val req = requests.remove(id)
|
||||
req?.resultDeferred?.complete(Pair(null, null))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun initialize() {
|
||||
mutex.withLock {
|
||||
if (!isInitialized) {
|
||||
initializeEngineLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun initializeEngineLocked() {
|
||||
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
|
||||
Timber.d("TextToSpeech engine initialized successfully.")
|
||||
try {
|
||||
val result = tts?.setLanguage(Locale.getDefault())
|
||||
if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) {
|
||||
Timber.e("Default language not supported/missing data")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error setting language")
|
||||
}
|
||||
|
||||
tts?.setOnUtteranceProgressListener(sharedListener)
|
||||
if (continuation.isActive) continuation.resume(Unit)
|
||||
} else {
|
||||
Timber.e("Failed to initialize TextToSpeech engine. Status: $status")
|
||||
if (continuation.isActive) continuation.resumeWithException(IllegalStateException("TTS initialization failed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun shutdownEngineLocked() {
|
||||
Timber.w("BaseTts: Shutting down TTS engine for recovery.")
|
||||
try {
|
||||
requests.clear()
|
||||
tts?.stop()
|
||||
tts?.shutdown()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error shutting down TTS")
|
||||
} finally {
|
||||
tts = null
|
||||
isInitialized = false
|
||||
// COOL-DOWN: Critical delay to allow OS Service to unbind/reset before we try to init again.
|
||||
delay(350)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun synthesizeToFile(text: String): Pair<File?, String?> {
|
||||
if (text.isBlank()) {
|
||||
return Pair(null, text)
|
||||
}
|
||||
|
||||
return mutex.withLock {
|
||||
var result: Pair<File?, String?> = Pair(null, null)
|
||||
|
||||
for (attempt in 1..MAX_RETRY_ATTEMPTS) {
|
||||
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>()
|
||||
|
||||
try {
|
||||
if (!isInitialized) {
|
||||
try {
|
||||
initializeEngineLocked()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "BaseTts: Init failed on attempt $attempt")
|
||||
if (attempt == MAX_RETRY_ATTEMPTS) return@withLock Pair(null, null)
|
||||
delay(200)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
Timber.e("synthesizeToFile returned immediate ERROR for $utteranceId.")
|
||||
requests.remove(utteranceId)
|
||||
throw IllegalStateException("TTS Engine returned ERROR")
|
||||
}
|
||||
|
||||
val startTimeout = if (attempt == 1) START_TIMEOUT_FAST_MS else START_TIMEOUT_RETRY_MS
|
||||
|
||||
try {
|
||||
withTimeout(startTimeout) {
|
||||
startSignal.await()
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
Timber.w("BaseTts: ZOMBIE DETECTED. onStart not received within ${startTimeout}ms.")
|
||||
throw ZombieEngineException()
|
||||
}
|
||||
|
||||
try {
|
||||
val finalResult = withTimeout(PROCESS_TIMEOUT_MS) {
|
||||
resultDeferred.await()
|
||||
}
|
||||
|
||||
if (finalResult.first != null) {
|
||||
result = finalResult
|
||||
break // Success!
|
||||
} else {
|
||||
Timber.w("BaseTts: onError received during processing.")
|
||||
throw IllegalStateException("TTS Engine reported onError")
|
||||
}
|
||||
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
Timber.w("BaseTts: PROCESSING STUCK. onDone not received within ${PROCESS_TIMEOUT_MS}ms.")
|
||||
throw IllegalStateException("Processing Timeout")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Timber.w("BaseTts: Failure on attempt $attempt. Reason: ${e.message}")
|
||||
|
||||
tempFile.delete()
|
||||
requests.remove(utteranceId)
|
||||
|
||||
if (attempt < MAX_RETRY_ATTEMPTS) {
|
||||
shutdownEngineLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
requests.clear()
|
||||
tts?.stop()
|
||||
tts?.shutdown()
|
||||
isInitialized = false
|
||||
Timber.d("TextToSpeech engine shut down.")
|
||||
}
|
||||
|
||||
private class ZombieEngineException : Exception("Engine failed to start")
|
||||
}
|
||||
284
app/src/main/java/com/aryan/reader/tts/TtsController.kt
Normal file
284
app/src/main/java/com/aryan/reader/tts/TtsController.kt
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
// TtsController.kt
|
||||
package com.aryan.reader.tts
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import timber.log.Timber
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.MediaItem
|
||||
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.tts.TtsPlaybackManager.TtsState
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val SETTINGS_PREFS_NAME = "epub_reader_settings"
|
||||
private const val TTS_SPEAKER_KEY = "tts_speaker"
|
||||
|
||||
private fun saveSpeaker(context: Context, speakerId: String) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putString(TTS_SPEAKER_KEY, speakerId) }
|
||||
}
|
||||
|
||||
private fun loadSpeaker(context: Context): String {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getString(TTS_SPEAKER_KEY, DEFAULT_SPEAKER_ID) ?: DEFAULT_SPEAKER_ID
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
class TtsController(context: Context) : Player.Listener {
|
||||
|
||||
private val context = context.applicationContext
|
||||
|
||||
private val _ttsState = MutableStateFlow(TtsState())
|
||||
val ttsState = _ttsState.asStateFlow()
|
||||
|
||||
private var mediaController: MediaController? = null
|
||||
private var controllerFuture: ListenableFuture<MediaController>? = null
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private var pollingJob: Job? = null
|
||||
|
||||
init {
|
||||
val initialSpeakerId = loadSpeaker(this.context)
|
||||
_ttsState.value = _ttsState.value.copy(speakerId = initialSpeakerId)
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
if (mediaController != null || controllerFuture != null) return
|
||||
|
||||
val sessionToken = SessionToken(context, ComponentName(context, TtsService::class.java))
|
||||
val future = MediaController.Builder(context, sessionToken).buildAsync()
|
||||
controllerFuture = future
|
||||
|
||||
future.addListener(
|
||||
{
|
||||
try {
|
||||
if (future.isCancelled) return@addListener
|
||||
|
||||
val controller = future.get()
|
||||
|
||||
if (controllerFuture != future) {
|
||||
Timber.d("MediaController connected after release. Releasing immediately.")
|
||||
controller.release()
|
||||
return@addListener
|
||||
}
|
||||
|
||||
mediaController = controller
|
||||
controllerFuture = null
|
||||
|
||||
mediaController?.addListener(this)
|
||||
Timber.d("MediaController connected.")
|
||||
updateStateFromController()
|
||||
startPolling()
|
||||
} catch (e: Exception) {
|
||||
Timber.w("Failed to connect MediaController: ${e.message}")
|
||||
if (controllerFuture == future) {
|
||||
controllerFuture = null
|
||||
}
|
||||
}
|
||||
},
|
||||
MoreExecutors.directExecutor()
|
||||
)
|
||||
}
|
||||
|
||||
private fun startPolling() {
|
||||
if (pollingJob?.isActive == true) return
|
||||
pollingJob = scope.launch {
|
||||
while (isActive) {
|
||||
updateStateFromController()
|
||||
delay(150)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun start(
|
||||
chunks: List<com.aryan.reader.paginatedreader.TtsChunk>,
|
||||
bookTitle: String,
|
||||
chapterTitle: String?,
|
||||
coverImageUri: String?,
|
||||
ttsMode: String,
|
||||
playbackSource: String = "READER"
|
||||
) {
|
||||
if (chunks.isEmpty()) {
|
||||
Timber.w("TtsController: start called with empty chunks!")
|
||||
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 })
|
||||
val offsetList = ArrayList(chunks.map { it.startOffsetInSource })
|
||||
|
||||
val args = Bundle().apply {
|
||||
putStringArrayList(KEY_TEXT_CHUNKS, textList)
|
||||
putStringArrayList(KEY_SOURCE_CFIS, cfiList)
|
||||
putIntegerArrayList(KEY_START_OFFSETS, offsetList)
|
||||
putString(KEY_SPEAKER_ID, _ttsState.value.speakerId)
|
||||
putString(KEY_BOOK_TITLE, bookTitle)
|
||||
putString(KEY_CHAPTER_TITLE, chapterTitle)
|
||||
putString(KEY_COVER_IMAGE_URI, coverImageUri)
|
||||
putString(KEY_TTS_MODE, ttsMode)
|
||||
putString(KEY_PLAYBACK_SOURCE, playbackSource)
|
||||
}
|
||||
mediaController?.sendCustomCommand(START_TTS_COMMAND, args)
|
||||
|
||||
val metadataBuilder = androidx.media3.common.MediaMetadata.Builder()
|
||||
.setArtist(bookTitle)
|
||||
.setTitle(chapterTitle ?: "Reading Aloud")
|
||||
coverImageUri?.let { metadataBuilder.setArtworkUri(it.toUri()) }
|
||||
|
||||
val metadata = MediaItem.Builder()
|
||||
.setMediaId("tts_session")
|
||||
.setMediaMetadata(metadataBuilder.build())
|
||||
.build()
|
||||
mediaController?.setMediaItem(metadata)
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
mediaController?.pause()
|
||||
}
|
||||
|
||||
fun resume() {
|
||||
mediaController?.play()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
Timber.d("UI sending STOP command.")
|
||||
mediaController?.sendCustomCommand(STOP_TTS_COMMAND, Bundle.EMPTY)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
fun changeSpeaker(speakerId: String) {
|
||||
Timber.d("UI sending CHANGE_SPEAKER command.")
|
||||
saveSpeaker(context, speakerId)
|
||||
_ttsState.value = _ttsState.value.copy(speakerId = speakerId)
|
||||
|
||||
val args = Bundle().apply {
|
||||
putString(KEY_SPEAKER_ID, speakerId)
|
||||
}
|
||||
mediaController?.sendCustomCommand(CHANGE_SPEAKER_COMMAND, args)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
fun changeTtsMode(mode: String) {
|
||||
Timber.d("UI sending CHANGE_TTS_MODE command.")
|
||||
val args = Bundle().apply {
|
||||
putString(KEY_TTS_MODE, mode)
|
||||
}
|
||||
mediaController?.sendCustomCommand(CHANGE_TTS_MODE_COMMAND, args)
|
||||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
updateStateFromController()
|
||||
}
|
||||
|
||||
private fun updateStateFromController() {
|
||||
mediaController?.let { controller ->
|
||||
val customState = controller.customLayout.firstOrNull()?.extras ?: Bundle.EMPTY
|
||||
val currentMediaItem = controller.currentMediaItem
|
||||
val currentTextFromMediaItem = currentMediaItem?.mediaMetadata?.subtitle?.toString()
|
||||
val isPlaybackActive = controller.isPlaying || controller.playbackState == Player.STATE_READY || controller.playbackState == Player.STATE_BUFFERING
|
||||
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")
|
||||
|
||||
val mediaItemExtras = currentMediaItem?.mediaMetadata?.extras
|
||||
val sourceCfi = mediaItemExtras?.getString("sourceCfi")
|
||||
val startOffset = mediaItemExtras?.getInt("startOffset", -1) ?: -1
|
||||
val currentWordSourceCfi = customState.getString("currentWordSourceCfi")
|
||||
val currentWordStartOffset = customState.getInt("currentWordStartOffset", -1)
|
||||
|
||||
val currentState = _ttsState.value
|
||||
_ttsState.value = currentState.copy(
|
||||
isPlaying = controller.isPlaying,
|
||||
isLoading = isLoading,
|
||||
currentText = if (isPlaybackActive) {
|
||||
currentTextFromMediaItem ?: customState.getString("currentText")
|
||||
} else {
|
||||
if (isLoading) currentState.currentText else null
|
||||
},
|
||||
errorMessage = customState.getString("errorMessage"),
|
||||
speakerId = serviceSpeaker,
|
||||
sourceCfi = if (isPlaybackActive) {
|
||||
sourceCfi
|
||||
} else {
|
||||
if (isLoading) currentState.sourceCfi else null
|
||||
},
|
||||
startOffsetInSource = if (isPlaybackActive) {
|
||||
startOffset
|
||||
} else {
|
||||
if (isLoading) currentState.startOffsetInSource else -1
|
||||
},
|
||||
playbackState = controller.playbackState,
|
||||
sessionEndedByStop = sessionEndedByStop,
|
||||
currentWordSourceCfi = if (isPlaybackActive) currentWordSourceCfi else null,
|
||||
currentWordStartOffset = if (isPlaybackActive) currentWordStartOffset else -1,
|
||||
isChangingConfig = isChangingConfig,
|
||||
sessionFinished = sessionFinished,
|
||||
playbackSource = playbackSource
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
pollingJob?.cancel()
|
||||
scope.cancel()
|
||||
|
||||
val future = controllerFuture
|
||||
controllerFuture = null
|
||||
if (future != null && !future.isDone) {
|
||||
future.cancel(true)
|
||||
}
|
||||
|
||||
mediaController?.removeListener(this)
|
||||
mediaController?.release()
|
||||
mediaController = null
|
||||
Timber.d("MediaController released.")
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun rememberTtsController(): TtsController {
|
||||
val context = LocalContext.current
|
||||
val controller = remember {
|
||||
TtsController(context)
|
||||
}
|
||||
|
||||
LaunchedEffect(controller) {
|
||||
controller.connect()
|
||||
}
|
||||
|
||||
DisposableEffect(controller) {
|
||||
onDispose {
|
||||
controller.release()
|
||||
}
|
||||
}
|
||||
|
||||
return controller
|
||||
}
|
||||
629
app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt
Normal file
629
app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt
Normal file
|
|
@ -0,0 +1,629 @@
|
|||
// TtsPlaybackManager.kt
|
||||
package com.aryan.reader.tts
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import timber.log.Timber
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.CommandButton
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.SessionCommand
|
||||
import androidx.media3.session.SessionResult
|
||||
import com.aryan.reader.R
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import androidx.core.net.toUri
|
||||
import com.aryan.reader.paginatedreader.TimedWord
|
||||
import com.aryan.reader.paginatedreader.TtsChunk
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
val START_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.START", Bundle.EMPTY)
|
||||
val STOP_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.STOP", Bundle.EMPTY)
|
||||
val CHANGE_SPEAKER_COMMAND = SessionCommand("com.aryan.reader.tts.CHANGE_SPEAKER", Bundle.EMPTY)
|
||||
private val STATE_UPDATE_COMMAND = SessionCommand("com.aryan.reader.tts.STATE_UPDATE", Bundle.EMPTY)
|
||||
val CHANGE_TTS_MODE_COMMAND = SessionCommand("com.aryan.reader.tts.CHANGE_MODE", Bundle.EMPTY)
|
||||
|
||||
const val KEY_TEXT_CHUNKS = "KEY_TEXT_CHUNKS"
|
||||
const val KEY_SOURCE_CFIS = "KEY_SOURCE_CFIS"
|
||||
const val KEY_START_OFFSETS = "KEY_START_OFFSETS"
|
||||
const val KEY_SPEAKER_ID = "KEY_SPEAKER_ID"
|
||||
const val KEY_BOOK_TITLE = "KEY_BOOK_TITLE"
|
||||
const val KEY_CHAPTER_TITLE = "KEY_CHAPTER_TITLE"
|
||||
const val KEY_COVER_IMAGE_URI = "KEY_COVER_IMAGE_URI"
|
||||
const val KEY_TTS_MODE = "KEY_TTS_MODE"
|
||||
const val KEY_WORD_TIMESTAMPS = "KEY_WORD_TIMESTAMPS"
|
||||
const val KEY_WORD_OFFSETS = "KEY_WORD_OFFSETS"
|
||||
const val KEY_PLAYBACK_SOURCE = "KEY_PLAYBACK_SOURCE"
|
||||
|
||||
private const val PREFETCH_LOOKAHEAD = 2
|
||||
|
||||
@UnstableApi
|
||||
class TtsPlaybackManager(
|
||||
private val player: Player,
|
||||
private val generateAudioChunk: suspend (textChunk: String, speakerId: String, mode: TtsMode) -> TtsAudioData
|
||||
) : MediaSession.Callback, Player.Listener {
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var mediaSession: MediaSession? = null
|
||||
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
|
||||
}
|
||||
|
||||
data class TtsState(
|
||||
val isPlaying: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val currentText: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
val speakerId: String = DEFAULT_SPEAKER_ID,
|
||||
val sourceCfi: String? = null,
|
||||
val startOffsetInSource: Int = -1,
|
||||
val playbackState: Int = Player.STATE_IDLE,
|
||||
val sessionEndedByStop: Boolean = false,
|
||||
val currentWordSourceCfi: String? = null,
|
||||
val currentWordStartOffset: Int = -1,
|
||||
val isChangingConfig: Boolean = false,
|
||||
val sessionFinished: Boolean = false,
|
||||
val playbackSource: String? = null
|
||||
)
|
||||
|
||||
private val _ttsState = MutableStateFlow(TtsState())
|
||||
|
||||
private var textChunks: List<TtsChunk> = emptyList()
|
||||
private var audioFiles: MutableMap<Int, File> = mutableMapOf()
|
||||
private var currentSpeakerId = DEFAULT_SPEAKER_ID
|
||||
private var bookTitle: String? = null
|
||||
private var chapterTitle: String? = null
|
||||
private var coverImageUri: String? = null
|
||||
private var currentTtsMode = TtsMode.CLOUD
|
||||
|
||||
init {
|
||||
player.addListener(this)
|
||||
_ttsState.onEach { newState ->
|
||||
mediaSession?.let { session ->
|
||||
val layout = listOf(
|
||||
createStateButton(newState),
|
||||
createStopCommandButton()
|
||||
)
|
||||
session.setCustomLayout(layout)
|
||||
}
|
||||
}.launchIn(scope)
|
||||
}
|
||||
|
||||
fun setMediaSession(session: MediaSession) {
|
||||
this.mediaSession = session
|
||||
}
|
||||
|
||||
override fun onConnect(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo
|
||||
): MediaSession.ConnectionResult {
|
||||
val availableSessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
|
||||
.add(START_TTS_COMMAND)
|
||||
.add(STOP_TTS_COMMAND)
|
||||
.add(CHANGE_SPEAKER_COMMAND)
|
||||
.add(CHANGE_TTS_MODE_COMMAND)
|
||||
.build()
|
||||
val availablePlayerCommands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
|
||||
.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
||||
.remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
||||
.remove(Player.COMMAND_SEEK_TO_NEXT)
|
||||
.remove(Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||
.build()
|
||||
|
||||
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
|
||||
.setAvailableSessionCommands(availableSessionCommands)
|
||||
.setAvailablePlayerCommands(availablePlayerCommands)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onAddMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItems: List<MediaItem>
|
||||
): ListenableFuture<List<MediaItem>> {
|
||||
return Futures.immediateFuture(mediaItems)
|
||||
}
|
||||
|
||||
override fun onCustomCommand(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
customCommand: SessionCommand,
|
||||
args: Bundle
|
||||
): ListenableFuture<SessionResult> {
|
||||
when (customCommand) {
|
||||
START_TTS_COMMAND -> {
|
||||
val chunks = args.getStringArrayList(KEY_TEXT_CHUNKS) ?: emptyList()
|
||||
Timber.d("TtsService: START command received. Size: ${chunks.size}")
|
||||
val cfis = args.getStringArrayList(KEY_SOURCE_CFIS)
|
||||
val offsets = args.getIntegerArrayList(KEY_START_OFFSETS)
|
||||
val speakerId = args.getString(KEY_SPEAKER_ID, DEFAULT_SPEAKER_ID)
|
||||
val bookTitle = args.getString(KEY_BOOK_TITLE)
|
||||
val chapterTitle = args.getString(KEY_CHAPTER_TITLE)
|
||||
val coverImageUri = args.getString(KEY_COVER_IMAGE_URI)
|
||||
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 richChunks = if (cfis != null && offsets != null && chunks.size == cfis.size && chunks.size == offsets.size) {
|
||||
chunks.mapIndexed { index, text ->
|
||||
TtsChunk(text, cfis[index], offsets[index])
|
||||
}
|
||||
} else {
|
||||
chunks.map { TtsChunk(it, "", -1) }
|
||||
}
|
||||
|
||||
handleStartTts(richChunks, speakerId, bookTitle, chapterTitle, coverImageUri, ttsMode, playbackSource)
|
||||
}
|
||||
STOP_TTS_COMMAND -> {
|
||||
Timber.d("Received STOP command.")
|
||||
handleStopTts(userInitiated = true)
|
||||
}
|
||||
CHANGE_SPEAKER_COMMAND -> {
|
||||
val newSpeakerId = args.getString(KEY_SPEAKER_ID, DEFAULT_SPEAKER_ID)
|
||||
handleChangeSpeaker(newSpeakerId)
|
||||
}
|
||||
CHANGE_TTS_MODE_COMMAND -> {
|
||||
val newModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name)
|
||||
val newMode = try { TtsMode.valueOf(newModeName) } catch (_: Exception) { TtsMode.CLOUD }
|
||||
handleChangeTtsMode(newMode)
|
||||
}
|
||||
}
|
||||
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleStartTts(
|
||||
chunks: List<TtsChunk>,
|
||||
speakerId: String,
|
||||
bookTitle: String?,
|
||||
chapterTitle: String?,
|
||||
coverImageUri: String?,
|
||||
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
|
||||
}
|
||||
handleStopTts(clearState = false)
|
||||
textChunks = chunks
|
||||
currentSpeakerId = speakerId
|
||||
currentTtsMode = ttsMode
|
||||
this.bookTitle = bookTitle
|
||||
this.chapterTitle = chapterTitle
|
||||
this.coverImageUri = coverImageUri
|
||||
_ttsState.value = TtsState(isLoading = true, speakerId = speakerId, playbackSource = playbackSource)
|
||||
|
||||
preparationJob = scope.launch {
|
||||
prepareAndPlayFirstChunk()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
return
|
||||
}
|
||||
|
||||
val ttsAudioData = generateAudioChunk(firstChunk.text, currentSpeakerId, currentTtsMode)
|
||||
val audioFile = ttsAudioData.audioFile
|
||||
val serverText = ttsAudioData.serverText
|
||||
|
||||
if (audioFile != null && serverText != null) {
|
||||
audioFiles[startAtIndex] = audioFile
|
||||
|
||||
val updatedChunk = processWordTimings(firstChunk, serverText, ttsAudioData.wordTimings)
|
||||
val mutableChunks = textChunks.toMutableList()
|
||||
mutableChunks[startAtIndex] = updatedChunk
|
||||
textChunks = mutableChunks.toList()
|
||||
|
||||
val mediaItem = createMediaItem(serverText, audioFile.absolutePath, startAtIndex, updatedChunk)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
player.setMediaItem(mediaItem)
|
||||
player.prepare()
|
||||
if (startAtPosition > 0) {
|
||||
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
|
||||
)
|
||||
}
|
||||
prefetchNextChunkAudio(startAtIndex)
|
||||
} else {
|
||||
withContext(Dispatchers.Main) { isChangingConfig = false }
|
||||
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Failed to load audio.", isChangingConfig = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun processWordTimings(
|
||||
originalChunk: TtsChunk,
|
||||
@Suppress("unused") serverText: String,
|
||||
wordTimings: List<WordTimingInfo>?
|
||||
): TtsChunk {
|
||||
if (wordTimings.isNullOrEmpty()) {
|
||||
return originalChunk
|
||||
}
|
||||
|
||||
val timedWords = mutableListOf<TimedWord>()
|
||||
var currentSearchIndex = 0
|
||||
wordTimings.forEach { timingInfo ->
|
||||
val wordIndex = originalChunk.text.indexOf(timingInfo.word, startIndex = currentSearchIndex, ignoreCase = false)
|
||||
if (wordIndex != -1) {
|
||||
timedWords.add(
|
||||
TimedWord(
|
||||
word = timingInfo.word,
|
||||
startTime = timingInfo.startTime,
|
||||
startOffset = originalChunk.startOffsetInSource + wordIndex
|
||||
)
|
||||
)
|
||||
currentSearchIndex = wordIndex + timingInfo.word.length
|
||||
} else {
|
||||
Timber.w("Could not find server word '${timingInfo.word}' in original chunk text")
|
||||
}
|
||||
}
|
||||
return originalChunk.copy(timedWords = timedWords)
|
||||
}
|
||||
|
||||
private fun handleStopTts(clearState: Boolean = true, userInitiated: Boolean = false) {
|
||||
preparationJob?.cancel()
|
||||
wordTrackingJob?.cancel()
|
||||
if (clearState) {
|
||||
val finalState = TtsState(sessionEndedByStop = userInitiated)
|
||||
_ttsState.value = finalState
|
||||
mediaSession?.let { session ->
|
||||
val layout = listOf(
|
||||
createStateButton(finalState),
|
||||
createStopCommandButton()
|
||||
)
|
||||
session.setCustomLayout(layout)
|
||||
}
|
||||
}
|
||||
|
||||
player.stop()
|
||||
player.clearMediaItems()
|
||||
textChunks = emptyList()
|
||||
prefetchingJobs.values.forEach { it.cancel() }
|
||||
prefetchingJobs.clear()
|
||||
|
||||
scope.launch {
|
||||
clearAudioFiles()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
val newPlaylistIndex = player.currentMediaItemIndex
|
||||
if (newPlaylistIndex == C.INDEX_UNSET) return
|
||||
|
||||
val currentChunkIndex = mediaItem?.mediaId?.toIntOrNull() ?: return
|
||||
|
||||
val newText = mediaItem.mediaMetadata.subtitle?.toString()
|
||||
val extras = mediaItem.mediaMetadata.extras
|
||||
val sourceCfi = extras?.getString("sourceCfi")
|
||||
val startOffset = extras?.getInt("startOffset", -1) ?: -1
|
||||
|
||||
_ttsState.value = _ttsState.value.copy(
|
||||
currentText = newText,
|
||||
sourceCfi = sourceCfi,
|
||||
startOffsetInSource = startOffset
|
||||
)
|
||||
|
||||
wordTrackingJob?.cancel()
|
||||
if (player.isPlaying) {
|
||||
wordTrackingJob = scope.launch {
|
||||
trackWordByWord()
|
||||
}
|
||||
}
|
||||
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO && newPlaylistIndex > 0) {
|
||||
val previousMediaItem = player.getMediaItemAt(newPlaylistIndex - 1)
|
||||
val previousChunkIndex = previousMediaItem.mediaId.toIntOrNull()
|
||||
|
||||
if (previousChunkIndex != null) {
|
||||
scope.launch {
|
||||
audioFiles.remove(previousChunkIndex)?.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
prefetchNextChunkAudio(currentChunkIndex)
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
var nextState = _ttsState.value.copy(isPlaying = isPlaying)
|
||||
|
||||
if (isPlaying) {
|
||||
if (nextState.isLoading) {
|
||||
nextState = nextState.copy(isLoading = false)
|
||||
}
|
||||
wordTrackingJob?.cancel()
|
||||
wordTrackingJob = scope.launch {
|
||||
trackWordByWord()
|
||||
}
|
||||
} else {
|
||||
wordTrackingJob?.cancel()
|
||||
nextState = nextState.copy(
|
||||
currentWordSourceCfi = null,
|
||||
currentWordStartOffset = -1
|
||||
)
|
||||
|
||||
val currentChunkIndex = player.currentMediaItemIndex
|
||||
val isLastChunkInSession = textChunks.isNotEmpty() && currentChunkIndex == textChunks.size - 1
|
||||
|
||||
if (player.playbackState == Player.STATE_ENDED) {
|
||||
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)
|
||||
|
||||
if (!isPrefetching) {
|
||||
Timber.w("BUFFERING: Stalled at chunk $currentChunkIndex. Restarting prefetch for $nextIdx.")
|
||||
prefetchNextChunkAudio(currentChunkIndex)
|
||||
}
|
||||
nextState = nextState.copy(isLoading = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ttsState.value = nextState
|
||||
|
||||
if (!isPlaying && player.playbackState == Player.STATE_IDLE) {
|
||||
if (isChangingConfig) {
|
||||
return
|
||||
}
|
||||
if (!nextState.sessionEndedByStop) {
|
||||
handleStopTts(userInitiated = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
Timber.e(error, "Player error: ${error.message}")
|
||||
_ttsState.value = _ttsState.value.copy(errorMessage = "Playback error: ${error.message}")
|
||||
handleStopTts(userInitiated = true)
|
||||
}
|
||||
|
||||
private fun prefetchNextChunkAudio(currentIndex: Int) {
|
||||
for (i in 1..PREFETCH_LOOKAHEAD) {
|
||||
val targetIndex = currentIndex + i
|
||||
if (targetIndex < textChunks.size) {
|
||||
if (prefetchingJobs.containsKey(targetIndex)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (audioFiles.containsKey(targetIndex)) {
|
||||
continue
|
||||
}
|
||||
|
||||
Timber.d("PlaybackManager: Scheduling prefetch for chunk $targetIndex")
|
||||
|
||||
val job = scope.launch {
|
||||
val nextChunk = textChunks[targetIndex]
|
||||
val ttsAudioData = generateAudioChunk(nextChunk.text, currentSpeakerId, currentTtsMode)
|
||||
val audioFile = ttsAudioData.audioFile
|
||||
val serverText = ttsAudioData.serverText
|
||||
|
||||
if (audioFile != null && serverText != null) {
|
||||
audioFiles[targetIndex] = audioFile
|
||||
|
||||
val updatedChunk = processWordTimings(nextChunk, serverText, ttsAudioData.wordTimings)
|
||||
val mutableChunks = textChunks.toMutableList()
|
||||
mutableChunks[targetIndex] = updatedChunk
|
||||
textChunks = mutableChunks.toList()
|
||||
|
||||
val nextMediaItem = createMediaItem(serverText, audioFile.absolutePath, targetIndex, updatedChunk)
|
||||
withContext(Dispatchers.Main) {
|
||||
val wasLoading = _ttsState.value.isLoading
|
||||
|
||||
var exists = false
|
||||
for (k in 0 until player.mediaItemCount) {
|
||||
if (player.getMediaItemAt(k).mediaId == targetIndex.toString()) {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!exists) {
|
||||
player.addMediaItem(nextMediaItem)
|
||||
}
|
||||
|
||||
if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && targetIndex == player.currentMediaItemIndex + 1) {
|
||||
player.seekToNextMediaItem()
|
||||
player.play()
|
||||
} else if (wasLoading && targetIndex == player.currentMediaItemIndex + 1) {
|
||||
_ttsState.value = _ttsState.value.copy(isLoading = false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Timber.e("Prefetch: Failed to download chunk $targetIndex")
|
||||
}
|
||||
}
|
||||
prefetchingJobs[targetIndex] = job
|
||||
job.invokeOnCompletion {
|
||||
prefetchingJobs.remove(targetIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun trackWordByWord() {
|
||||
while (true) {
|
||||
val currentMediaItem = withContext(Dispatchers.Main) { player.currentMediaItem } ?: break
|
||||
val playbackPosition = withContext(Dispatchers.Main) { player.currentPosition }
|
||||
|
||||
val extras = currentMediaItem.mediaMetadata.extras ?: break
|
||||
val timestamps = extras.getDoubleArray(KEY_WORD_TIMESTAMPS) ?: break
|
||||
val offsets = extras.getIntArray(KEY_WORD_OFFSETS) ?: break
|
||||
val sourceCfi = extras.getString("sourceCfi") ?: break
|
||||
|
||||
val currentWordIndex = timestamps.indexOfLast { (it * 1000).toLong() <= playbackPosition }
|
||||
|
||||
if (currentWordIndex != -1) {
|
||||
val currentWordOffset = offsets[currentWordIndex]
|
||||
if (_ttsState.value.currentWordStartOffset != currentWordOffset || _ttsState.value.currentWordSourceCfi != sourceCfi) {
|
||||
_ttsState.value = _ttsState.value.copy(
|
||||
currentWordSourceCfi = sourceCfi,
|
||||
currentWordStartOffset = currentWordOffset
|
||||
)
|
||||
}
|
||||
}
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMediaItem(text: String, path: String, index: Int, chunk: TtsChunk): MediaItem {
|
||||
val extras = Bundle().apply {
|
||||
putString("sourceCfi", chunk.sourceCfi)
|
||||
putInt("startOffset", chunk.startOffsetInSource)
|
||||
if (chunk.timedWords.isNotEmpty()) {
|
||||
val timestamps = chunk.timedWords.map { it.startTime }.toDoubleArray()
|
||||
val offsets = chunk.timedWords.map { it.startOffset }.toIntArray()
|
||||
putDoubleArray(KEY_WORD_TIMESTAMPS, timestamps)
|
||||
putIntArray(KEY_WORD_OFFSETS, offsets)
|
||||
}
|
||||
}
|
||||
|
||||
val metadata = MediaMetadata.Builder()
|
||||
.setArtist(bookTitle)
|
||||
.setTitle(chapterTitle)
|
||||
.setSubtitle(text)
|
||||
.setArtworkUri(coverImageUri?.toUri())
|
||||
.setTrackNumber(index + 1)
|
||||
.setTotalTrackCount(textChunks.size)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
|
||||
return MediaItem.Builder()
|
||||
.setUri(Uri.fromFile(File(path)))
|
||||
.setMediaId(index.toString())
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
private suspend fun clearAudioFiles() {
|
||||
withContext(Dispatchers.IO) {
|
||||
audioFiles.values.forEach { it.delete() }
|
||||
audioFiles.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("Deprecation")
|
||||
private fun createStateButton(state: TtsState): CommandButton {
|
||||
val bundle = Bundle().apply {
|
||||
putBoolean("isLoading", state.isLoading)
|
||||
putString("currentText", state.currentText)
|
||||
putString("errorMessage", state.errorMessage)
|
||||
putString("speakerId", state.speakerId)
|
||||
putBoolean("sessionEndedByStop", state.sessionEndedByStop)
|
||||
putString("currentWordSourceCfi", state.currentWordSourceCfi)
|
||||
putInt("currentWordStartOffset", state.currentWordStartOffset)
|
||||
putBoolean("isChangingConfig", state.isChangingConfig)
|
||||
putBoolean("sessionFinished", state.sessionFinished)
|
||||
putString("playbackSource", state.playbackSource)
|
||||
}
|
||||
return CommandButton.Builder()
|
||||
.setSessionCommand(STATE_UPDATE_COMMAND)
|
||||
.setDisplayName("TtsState")
|
||||
.setExtras(bundle)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Suppress("Deprecation")
|
||||
private fun createStopCommandButton(): CommandButton {
|
||||
return CommandButton.Builder()
|
||||
.setDisplayName("Stop TTS")
|
||||
.setSessionCommand(STOP_TTS_COMMAND)
|
||||
.setIconResId(R.drawable.close)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun release() {
|
||||
player.removeListener(this)
|
||||
handleStopTts(userInitiated = true)
|
||||
Timber.d("TtsPlaybackManager released.")
|
||||
}
|
||||
}
|
||||
237
app/src/main/java/com/aryan/reader/tts/TtsService.kt
Normal file
237
app/src/main/java/com/aryan/reader/tts/TtsService.kt
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
// TtsService.kt
|
||||
package com.aryan.reader.tts
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Base64
|
||||
import timber.log.Timber
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONArray
|
||||
|
||||
data class WordTimingInfo(val word: String, val startTime: Double)
|
||||
data class TtsAudioData(
|
||||
val audioFile: File?,
|
||||
val serverText: String?,
|
||||
val wordTimings: List<WordTimingInfo>?
|
||||
)
|
||||
|
||||
data class PageCharacterRange(
|
||||
val pageInChapter: Int,
|
||||
val cfi: String,
|
||||
val startOffset: Int,
|
||||
val endOffset: Int
|
||||
)
|
||||
|
||||
@UnstableApi
|
||||
class TtsService : MediaSessionService() {
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private var mediaSession: MediaSession? = null
|
||||
private lateinit var player: ExoPlayer
|
||||
private lateinit var playbackManager: TtsPlaybackManager
|
||||
private lateinit var baseTtsSynthesizer: BaseTtsSynthesizer
|
||||
|
||||
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
|
||||
if (startInForegroundRequired) {
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic function to download TTS audio from a server endpoint.
|
||||
* This is used for both the self-hosted server and the Google Cloud worker.
|
||||
*
|
||||
* @param chunkToSpeak The text to synthesize.
|
||||
* @param speakerId The identifier for the voice.
|
||||
* @param serverUrl The base URL of the TTS server.
|
||||
* @param audioFileExtension The file extension for the temporary audio file (e.g., ".flac", ".mp3").
|
||||
* @return A pair containing the temporary audio file and the text chunk returned by the server, or null if it fails.
|
||||
*/
|
||||
private suspend fun downloadFromTtsServer(
|
||||
chunkToSpeak: String,
|
||||
speakerId: String,
|
||||
serverUrl: String,
|
||||
audioFileExtension: String
|
||||
): TtsAudioData {
|
||||
if (chunkToSpeak.isBlank()) {
|
||||
return TtsAudioData(null, null, null)
|
||||
}
|
||||
return withContext(Dispatchers.IO) {
|
||||
var tempAudioFile: File? = null
|
||||
try {
|
||||
val url = URL(serverUrl)
|
||||
val connection = url.openConnection() as HttpURLConnection
|
||||
connection.requestMethod = "POST"
|
||||
connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8")
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.connectTimeout = 15000
|
||||
connection.readTimeout = 60000
|
||||
connection.doOutput = true
|
||||
connection.doInput = true
|
||||
|
||||
val jsonPayload = JSONObject()
|
||||
jsonPayload.put("text", chunkToSpeak)
|
||||
jsonPayload.put("speaker", speakerId)
|
||||
val jsonInputString = jsonPayload.toString()
|
||||
connection.outputStream.use { os ->
|
||||
val input = jsonInputString.toByteArray(Charsets.UTF_8)
|
||||
os.write(input, 0, input.size)
|
||||
}
|
||||
|
||||
val responseCode = connection.responseCode
|
||||
if (responseCode != HttpURLConnection.HTTP_OK) {
|
||||
val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { "" }
|
||||
Timber.e("TTS Server request failed with code: $responseCode for URL: $serverUrl. Body: $errorBody")
|
||||
return@withContext TtsAudioData(null, null, null)
|
||||
}
|
||||
|
||||
val responseBody =
|
||||
connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
|
||||
val jsonResponse = JSONObject(responseBody)
|
||||
if (jsonResponse.has("audio_base64") && jsonResponse.has("text_chunk")) {
|
||||
val audioBase64 = jsonResponse.getString("audio_base64")
|
||||
val serverTextChunk = jsonResponse.getString("text_chunk")
|
||||
val audioBytes = Base64.decode(audioBase64, Base64.DEFAULT)
|
||||
|
||||
val wordTimings = mutableListOf<WordTimingInfo>()
|
||||
if (jsonResponse.has("word_timings")) {
|
||||
val timingsArray: JSONArray = jsonResponse.getJSONArray("word_timings")
|
||||
for (i in 0 until timingsArray.length()) {
|
||||
val timingObject = timingsArray.getJSONObject(i)
|
||||
wordTimings.add(
|
||||
WordTimingInfo(
|
||||
word = timingObject.getString("word"),
|
||||
startTime = timingObject.getDouble("startTime")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tempAudioFile = File.createTempFile(
|
||||
"tts_audio_chunk_",
|
||||
audioFileExtension,
|
||||
applicationContext.cacheDir
|
||||
)
|
||||
FileOutputStream(tempAudioFile).use { output -> output.write(audioBytes) }
|
||||
TtsAudioData(tempAudioFile, serverTextChunk, wordTimings)
|
||||
} else {
|
||||
Timber.e("DownloadAudioChunk: 'audio_base64' or 'text_chunk' field missing."
|
||||
)
|
||||
TtsAudioData(null, null, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "DownloadAudioChunk: TTS Request Exception: ${e.message}")
|
||||
tempAudioFile?.delete()
|
||||
TtsAudioData(null, null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val downloadAudioChunk: suspend (String, String) -> TtsAudioData =
|
||||
{ chunkToSpeak, speakerId ->
|
||||
downloadFromTtsServer(
|
||||
chunkToSpeak,
|
||||
speakerId,
|
||||
googleCloudWorkerTtsUrl,
|
||||
".mp3"
|
||||
)
|
||||
}
|
||||
|
||||
private val synthesizeBaseTtsChunk: suspend (String) -> TtsAudioData =
|
||||
{ chunkToSpeak ->
|
||||
val (file, text) = baseTtsSynthesizer.synthesizeToFile(chunkToSpeak)
|
||||
TtsAudioData(file, text, null)
|
||||
}
|
||||
|
||||
private val audioGenerator: suspend (text: String, speaker: String, mode: TtsMode) -> TtsAudioData =
|
||||
{ text, speaker, mode ->
|
||||
when (mode) {
|
||||
TtsMode.CLOUD -> downloadAudioChunk(text, speaker)
|
||||
TtsMode.BASE -> synthesizeBaseTtsChunk(text)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Timber.d("TtsService created.")
|
||||
|
||||
baseTtsSynthesizer = BaseTtsSynthesizer(this)
|
||||
scope.launch {
|
||||
try {
|
||||
baseTtsSynthesizer.initialize()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Base TTS synthesizer failed to initialize")
|
||||
}
|
||||
}
|
||||
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_SPEECH)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build()
|
||||
|
||||
player = ExoPlayer.Builder(this)
|
||||
.setAudioAttributes(audioAttributes, true)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.build()
|
||||
|
||||
playbackManager = TtsPlaybackManager(
|
||||
player = player,
|
||||
generateAudioChunk = audioGenerator
|
||||
)
|
||||
|
||||
mediaSession = MediaSession.Builder(this, player)
|
||||
.setCallback(playbackManager)
|
||||
.build()
|
||||
|
||||
mediaSession?.let { playbackManager.setMediaSession(it) }
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
if (!player.playWhenReady) {
|
||||
stopSelf()
|
||||
}
|
||||
Timber.d("onTaskRemoved called, stopping service.")
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
||||
return mediaSession
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Timber.d("TtsService is being destroyed.")
|
||||
baseTtsSynthesizer.shutdown()
|
||||
playbackManager.release()
|
||||
mediaSession?.run {
|
||||
player.release()
|
||||
release()
|
||||
mediaSession = null
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
176
app/src/main/java/com/aryan/reader/tts/TtsUtils.kt
Normal file
176
app/src/main/java/com/aryan/reader/tts/TtsUtils.kt
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
// TtsUtils.kt
|
||||
package com.aryan.reader.tts
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaPlayer
|
||||
import timber.log.Timber
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
const val googleCloudWorkerTtsUrl = ""
|
||||
|
||||
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."
|
||||
|
||||
const val TTS_CHUNK_MAX_LENGTH = 250
|
||||
|
||||
const val DEFAULT_SPEAKER_ID = "en-US-Standard-F"
|
||||
|
||||
@Suppress("unused")
|
||||
val GOOGLE_TTS_SPEAKERS = listOf(
|
||||
"US Female: F" to "en-US-Standard-F",
|
||||
"US Female: H" to "en-US-Standard-H",
|
||||
"US Male: I" to "en-US-Standard-I",
|
||||
"US Male: J" to "en-US-Standard-J"
|
||||
)
|
||||
|
||||
fun splitTextIntoChunks(text: String, maxLengthPerChunk: Int = TTS_CHUNK_MAX_LENGTH): List<String> {
|
||||
if (text.isBlank()) return emptyList()
|
||||
val sentenceBoundaryRegex = Regex("""(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=[.?!\n])\s+""")
|
||||
val sentences = text.trim().split(sentenceBoundaryRegex).filter { it.isNotBlank() }
|
||||
|
||||
if (sentences.isEmpty()) return emptyList()
|
||||
|
||||
val chunks = mutableListOf<String>()
|
||||
val currentChunk = StringBuilder()
|
||||
|
||||
for (sentence in sentences) {
|
||||
if (sentence.length > maxLengthPerChunk) {
|
||||
if (currentChunk.isNotEmpty()) {
|
||||
chunks.add(currentChunk.toString())
|
||||
currentChunk.clear()
|
||||
}
|
||||
chunks.add(sentence)
|
||||
continue
|
||||
}
|
||||
|
||||
if (currentChunk.isNotEmpty() && currentChunk.length + sentence.length + 1 > maxLengthPerChunk) {
|
||||
chunks.add(currentChunk.toString())
|
||||
currentChunk.clear()
|
||||
currentChunk.append(sentence)
|
||||
} else {
|
||||
if (currentChunk.isNotEmpty()) {
|
||||
currentChunk.append(" ")
|
||||
}
|
||||
currentChunk.append(sentence)
|
||||
}
|
||||
}
|
||||
if (currentChunk.isNotEmpty()) {
|
||||
chunks.add(currentChunk.toString())
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
class SpeakerSamplePlayer(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope
|
||||
) {
|
||||
private val sampleMediaPlayer = MediaPlayer()
|
||||
var loadingSpeakerId by mutableStateOf<String?>(null)
|
||||
var playingSpeakerId by mutableStateOf<String?>(null)
|
||||
|
||||
init {
|
||||
sampleMediaPlayer.setOnErrorListener { mp, what, extra ->
|
||||
Timber.e("MediaPlayer error: what=$what, extra=$extra. Resetting.")
|
||||
playingSpeakerId = null
|
||||
loadingSpeakerId = null
|
||||
try {
|
||||
mp.reset()
|
||||
} catch (e: IllegalStateException) {
|
||||
Timber.e("Error resetting MediaPlayer: ${e.message}")
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
fun playOrStop(speakerId: String) {
|
||||
scope.launch {
|
||||
when {
|
||||
playingSpeakerId == speakerId -> {
|
||||
sampleMediaPlayer.stop()
|
||||
sampleMediaPlayer.reset()
|
||||
playingSpeakerId = null
|
||||
}
|
||||
loadingSpeakerId == speakerId -> {
|
||||
loadingSpeakerId = null
|
||||
}
|
||||
else -> playSample(speakerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun playSample(speakerId: String) {
|
||||
if (sampleMediaPlayer.isPlaying) {
|
||||
sampleMediaPlayer.stop()
|
||||
}
|
||||
sampleMediaPlayer.reset()
|
||||
loadingSpeakerId = speakerId
|
||||
playingSpeakerId = null
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = URL(googleCloudWorkerTtsUrl)
|
||||
val connection = url.openConnection() as HttpURLConnection
|
||||
connection.requestMethod = "POST"
|
||||
connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8")
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.connectTimeout = 15000
|
||||
connection.readTimeout = 30000
|
||||
connection.doOutput = true
|
||||
connection.doInput = true
|
||||
|
||||
val jsonPayload = JSONObject().apply {
|
||||
put("text", TTS_SAMPLE_TEXT)
|
||||
put("speaker", speakerId)
|
||||
}
|
||||
connection.outputStream.use { os ->
|
||||
os.write(jsonPayload.toString().toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
|
||||
|
||||
if (connection.responseCode == HttpURLConnection.HTTP_OK) {
|
||||
val responseBody = connection.inputStream.bufferedReader().use { it.readText() }
|
||||
val audioBase64 = JSONObject(responseBody).getString("audio_base64")
|
||||
|
||||
val dataUri = "data:audio/mpeg;base64,$audioBase64"
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (loadingSpeakerId != speakerId) {
|
||||
return@withContext
|
||||
}
|
||||
sampleMediaPlayer.setDataSource(context, dataUri.toUri())
|
||||
sampleMediaPlayer.setOnPreparedListener { mp ->
|
||||
if (loadingSpeakerId == speakerId) {
|
||||
mp.start()
|
||||
playingSpeakerId = speakerId
|
||||
loadingSpeakerId = null
|
||||
}
|
||||
}
|
||||
sampleMediaPlayer.setOnCompletionListener {
|
||||
if (playingSpeakerId == speakerId) playingSpeakerId = null
|
||||
}
|
||||
sampleMediaPlayer.prepareAsync()
|
||||
}
|
||||
} else {
|
||||
Timber.e("Failed to fetch sample for $speakerId. Code: ${connection.responseCode}")
|
||||
withContext(Dispatchers.Main) { if (loadingSpeakerId == speakerId) loadingSpeakerId = null }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Exception playing sample for $speakerId: ${e.message}")
|
||||
withContext(Dispatchers.Main) { if (loadingSpeakerId == speakerId) loadingSpeakerId = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
fun release() {
|
||||
sampleMediaPlayer.release()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue