Initial commit

This commit is contained in:
Aryan 2026-02-24 17:37:40 +05:30
commit 6072b2ba29
844 changed files with 220532 additions and 0 deletions

View 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")
}

View 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
}

View 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.")
}
}

View 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()
}
}

View 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()
}
}