V1.0.43 oss (#202)

* Added support for AI and Cloud credits in the Pro flavor.

* Implemented credit-based authentication and authorization for AI features and Cloud TTS.

* Updated AI feature access and purchase handling to a credit-based system.

* Refactored and enhanced the Text-to-Speech (TTS) system with persistent caching and a redesigned UI.

* Improved TTS cache management by organizing audio files by book title and adding a detailed cache storage UI.

* Refactored the TTS service to use a WebSocket-based Gemini Live connection for cloud audio generation.

* Removed the TTS cache settings tab and simplified voice sample playback by removing local caching logic.

* Implemented a low-latency streaming mechanism for Cloud TTS using a custom `ConcurrentInputStream` and `ExoPlayer` data source.

* Improved cloud TTS stability and prefetching logic in `TtsService` and `TtsPlaybackManager`.

* Implemented AI summarization caching and cost tracking in the EPUB reader.

* Enhanced chapter summary caching and UI feedback.

* limit summaries for pro users to 10 per day

* Implemented local caching for Cloud TTS audio chunks.

* Removed the Free tier tab from `ProScreen` and simplified the subscription interface. Updated tab logic to focus on Pro and Credits, including a new cost breakdown section for AI and Cloud TTS features.

* Refactored HTML parsing to include all child nodes during content chunking and semantic block parsing.

* Improved image rendering consistency in epub pagination reader

* Improved HTML parsing in `HtmlParser.kt` to better handle complex nested structures

* Improved CSS styling support in the epub paginated reader for word spacing and text decorations.

* Implemented scroll throttling in `epub_reader.js` to improve performance during scroll events

* Improved CFI resolution and scrolling reliability in EPUB reader

* Optimized PaginatedReader performance by caching text decorations.

* Implemented batching for recent file database operations to handle large datasets and introduced `RecentFileSummary` to optimize data retrieval by excluding heavy JSON columns.

* Improved navigation stability by wrapping `navController.navigate` and `popBackStack` calls in a try-catch block to handle `IllegalStateException` during concurrent transitions. Additionally, refined the backstack check for the main route to prevent redundant pops.

* feat(tts): redesign TTS controls with overlay UI and cache management

* Expanded and improved the TTS (Text-to-Speech) capabilities, particularly for Cloud voices.

* Improved TTS playback control and cache management.

* Integrated the TTS cache manager into the settings sheet and improved the TTS configuration UI.

* Updated `DeviceVoicesTab` to respect the current TTS mode, disabling voice selection when not in `BASE` mode.

* Improved error handling and state management for Cloud TTS in `TtsService` and `TtsPlaybackManager`.

* Improved TTS voice selection UI and sample playback logic.

* Updated `TtsUtils` and `TtsService` to remove `chunkIndex` from TTS cache filenames. Refined the cache file naming convention to rely on text and speaker hashes, and updated the cache file filter logic to correctly identify speakers in both legacy and new filename formats.

* Optimized tile rendering and state propagation in PDF viewer

* Added "Expand All", "Collapse All", and "Locate" functionality to the Table of Contents in both EPUB and PDF readers.

* Added sign-in requirement for credit purchases and improved purchase migration logic.

* Updated `EpubReaderTts` to support authenticated TTS requests by passing an auth token provider. The `ttsController.start` method now includes an `authToken` retrieved via `getAuthToken` and explicitly sets the `playbackSource` to "READER".

* feat(ai): replace summarization popup with a comprehensive AI Hub Bottom Sheet

* Improved locator logic and block traversal in `BookPaginator`.

* Updated AI features and Cloud TTS logic.

* Added manual clear and auto-reset functionality for AI summaries and recaps

* Optimized file importing, EPUB parsing, and TTS playback concurrency.

* Restricted TTS mode to BASE in OSS flavor and fixed TTS mode persistence in PDF viewer

* Bump version to 1.0.43(44)
This commit is contained in:
Aryan 2026-04-18 16:46:58 +05:30 committed by GitHub
parent e8f6be2800
commit 46620fa71a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 4412 additions and 2406 deletions

View file

@ -23,8 +23,6 @@ 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
@ -33,23 +31,33 @@ 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.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.json.JSONArray
import org.json.JSONObject
import timber.log.Timber
import java.io.File
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
data class WordTimingInfo(val word: String, val startTime: Double)
data class TtsAudioData(
val audioFile: File?,
val serverText: String?,
val wordTimings: List<WordTimingInfo>?
val wordTimings: List<WordTimingInfo>?,
val error: String? = null,
val streamUri: String? = null
)
data class PageCharacterRange(
@ -59,6 +67,165 @@ data class PageCharacterRange(
val endOffset: Int
)
class ConcurrentInputStream : java.io.InputStream() {
private val queue = java.util.concurrent.LinkedBlockingQueue<ByteArray>()
private var currentBuffer: ByteArray? = null
private var bufferPos = 0
private var eofReached = false
var isFinished = false
private set
var isClosed = false
private set
fun write(data: ByteArray) {
if (!isClosed) queue.offer(data)
}
override fun read(): Int {
val b = ByteArray(1)
val readCount = read(b, 0, 1)
return if (readCount == -1) -1 else b[0].toInt() and 0xFF
}
override fun read(b: ByteArray, off: Int, len: Int): Int {
if (eofReached) {
isFinished = true
return -1
}
if (len == 0) return 0
if (currentBuffer == null || bufferPos >= currentBuffer!!.size) {
try {
// Blocks here safely until data arrives
currentBuffer = queue.take()
bufferPos = 0
if (currentBuffer!!.isEmpty()) {
eofReached = true
isFinished = true
return -1
}
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
return -1
}
}
val available = currentBuffer!!.size - bufferPos
val toCopy = len.coerceAtMost(available)
System.arraycopy(currentBuffer!!, bufferPos, b, off, toCopy)
bufferPos += toCopy
return toCopy
}
override fun close() {
if (!isClosed) {
isClosed = true
queue.offer(ByteArray(0)) // Send EOF marker
}
}
}
object StreamRegistry {
private val streams = java.util.concurrent.ConcurrentHashMap<String, java.io.InputStream>()
private val totalBytesMap = java.util.concurrent.ConcurrentHashMap<String, Long>()
private val finishedMap = java.util.concurrent.ConcurrentHashMap<String, Boolean>()
fun register(id: String, stream: java.io.InputStream) {
streams[id] = stream
totalBytesMap[id] = 0L
finishedMap[id] = false
}
fun get(id: String): java.io.InputStream? = streams[id]
fun markFinished(id: String, totalBytes: Long) {
totalBytesMap[id] = totalBytes
finishedMap[id] = true
}
fun getStreamMetadata(id: String): Pair<Boolean, Long> {
return (finishedMap[id] ?: false) to (totalBytesMap[id] ?: 0L)
}
fun remove(id: String) {
streams.remove(id)?.let { try { it.close() } catch (_: Exception) {} }
totalBytesMap.remove(id)
finishedMap.remove(id)
}
fun clear() {
streams.values.forEach { try { it.close() } catch (_: Exception) {} }
streams.clear()
}
}
@UnstableApi
class InputStreamDataSource : androidx.media3.datasource.BaseDataSource(true) {
private var inputStream: java.io.InputStream? = null
private var opened = false
private var uri: android.net.Uri? = null
private var bytesReadTotal: Long = 0
override fun open(dataSpec: androidx.media3.datasource.DataSpec): Long {
uri = dataSpec.uri
Timber.tag("TTS_CLOUD_DIAG").d("InputStreamDataSource.open called for $uri, position=${dataSpec.position}")
val streamId = uri?.host ?: uri?.lastPathSegment ?: throw java.io.IOException("No stream ID")
val stream = StreamRegistry.get(streamId) ?: throw java.io.IOException("Stream not found")
if (stream is ConcurrentInputStream && stream.isFinished) {
Timber.tag("TTS_CLOUD_DIAG").d("InputStreamDataSource.open returning 0 bytes for finished stream to prevent retry.")
opened = true
transferInitializing(dataSpec)
transferStarted(dataSpec)
return 0
}
inputStream = stream
opened = true
transferInitializing(dataSpec)
if (dataSpec.position > bytesReadTotal) {
val toSkip = dataSpec.position - bytesReadTotal
var skipped = 0L
while (skipped < toSkip) {
val s = inputStream?.skip(toSkip - skipped) ?: 0L
if (s <= 0L) break
skipped += s
}
bytesReadTotal += skipped
}
transferStarted(dataSpec)
return C.LENGTH_UNSET.toLong()
}
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
if (length == 0) return 0
return try {
val bytesRead = inputStream?.read(buffer, offset, length) ?: -1
if (bytesRead == -1) {
Timber.tag("TTS_CLOUD_DIAG").d("InputStreamDataSource EOF reached for $uri")
return C.RESULT_END_OF_INPUT
}
bytesReadTotal += bytesRead
bytesTransferred(bytesRead)
bytesRead
} catch (e: java.io.IOException) {
Timber.tag("TTS_CLOUD_DIAG").e(e, "Stream read interrupted/broken for $uri")
C.RESULT_END_OF_INPUT
}
}
override fun getUri(): android.net.Uri? = uri
override fun close() {
if (opened) {
opened = false
transferEnded()
}
}
}
@UnstableApi
class TtsService : MediaSessionService() {
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
@ -66,6 +233,7 @@ class TtsService : MediaSessionService() {
private lateinit var player: ExoPlayer
private lateinit var playbackManager: TtsPlaybackManager
private lateinit var baseTtsSynthesizer: BaseTtsSynthesizer
private lateinit var cacheManager: TtsCacheManager
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
@ -81,116 +249,317 @@ class TtsService : MediaSessionService() {
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 okHttpClient = OkHttpClient.Builder().build()
private val liveClient by lazy {
GeminiLiveClient(okHttpClient) { errorMsg ->
if (::playbackManager.isInitialized) {
playbackManager.forceStopWithError(errorMsg)
}
}
}
private val downloadAudioChunk: suspend (String, String) -> TtsAudioData =
{ chunkToSpeak, speakerId ->
downloadFromTtsServer(
chunkToSpeak,
speakerId,
googleCloudWorkerTtsUrl,
".mp3"
)
class GeminiLiveClient(
private val client: OkHttpClient,
private val onAsyncError: (String) -> Unit = {}
) {
private var webSocket: WebSocket? = null
private val connectionMutex = Mutex()
private val generationMutex = Mutex()
private var clientScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var audioChannel = Channel<GeminiWsEvent>(Channel.UNLIMITED)
private var setupDeferred = CompletableDeferred<Boolean>().apply { complete(false) }
var connectedSpeaker: String? = null
sealed class GeminiWsEvent {
data class Audio(val bytes: ByteArray) : GeminiWsEvent()
object TurnComplete : GeminiWsEvent()
data class Error(val message: String) : GeminiWsEvent()
}
suspend fun ensureConnected(serverUrl: String, speaker: String, authToken: String?) = connectionMutex.withLock {
if (webSocket != null) {
if (connectedSpeaker == speaker) {
val isSetup = try { setupDeferred.await() } catch(_: Exception) { false }
if (isSetup) return@withLock
}
Timber.tag("TTS_CLOUD_DIAG").d("Closing existing WS. Speaker changed or setup failed.")
webSocket?.close(1000, "Reconnecting")
webSocket = null
}
val sanitizedUrl = serverUrl.removeSuffix("/")
val wsUrlStr = sanitizedUrl.replace("https://", "wss://").replace("http://", "ws://")
val url = "$wsUrlStr/live?speaker=$speaker&token=${authToken ?: ""}"
Timber.tag("TTS_CLOUD_DIAG").d("Connecting to WS: $url")
val request = Request.Builder().url(url).build()
val connectedDeferred = CompletableDeferred<Boolean>()
var connectionError: String? = null
setupDeferred = CompletableDeferred()
connectedSpeaker = speaker
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Timber.tag("TTS_CLOUD_DIAG").d("WS Opened. Sending Setup configuration to Gemini...")
val systemPrompt = """
You are a professional audiobook narrator.
Your ONLY task is to read the exact text provided to you, word for word, neutral emotion, and with good pacing.
Do NOT add any conversational filler, acknowledgments, or extra words (e.g., do not say "Sure, here is the text").
Do NOT skip any parts or summarize. Output ONLY the audio reading of the provided text. If you encounter unreadable, non-verbal, or non-linguistic content (e.g., symbols like "※▼◆", raw formatting markers, broken characters, or pure punctuation clusters with no readable words), silently skip it and continue reading.
""".trimIndent()
val setupMsg = JSONObject().apply {
put("setup", JSONObject().apply {
put("model", "models/gemini-3.1-flash-live-preview")
put("systemInstruction", JSONObject().apply {
put("parts", org.json.JSONArray().apply {
put(JSONObject().apply {
put("text", systemPrompt)
})
})
})
put("generationConfig", JSONObject().apply {
put("responseModalities", org.json.JSONArray().apply { put("AUDIO") })
put("speechConfig", JSONObject().apply {
put("voiceConfig", JSONObject().apply {
put("prebuiltVoiceConfig", JSONObject().apply {
put("voiceName", speaker)
})
})
})
})
})
}.toString()
webSocket.send(setupMsg)
connectedDeferred.complete(true)
}
override fun onMessage(webSocket: WebSocket, text: String) {
try {
val json = JSONObject(text)
if (json.has("error")) {
val errObj = json.opt("error")
val errMsg = if (errObj is JSONObject) errObj.toString() else errObj?.toString() ?: "Unknown API Error"
Timber.tag("TTS_CLOUD_DIAG").e("API ERROR RETURNED: $errMsg")
audioChannel.trySend(GeminiWsEvent.Error(errMsg))
setupDeferred.complete(false)
return
}
if (json.has("setupComplete")) {
setupDeferred.complete(true)
}
val serverContent = json.optJSONObject("serverContent")
if (serverContent != null) {
val turnComplete = serverContent.optBoolean("turnComplete", false)
val modelTurn = serverContent.optJSONObject("modelTurn")
val parts = modelTurn?.optJSONArray("parts")
if (parts != null) {
for (i in 0 until parts.length()) {
val part = parts.getJSONObject(i)
val inlineData = part.optJSONObject("inlineData")
if (inlineData != null) {
val b64 = inlineData.optString("data")
if (b64.isNotEmpty()) {
val bytes = android.util.Base64.decode(b64, android.util.Base64.DEFAULT)
audioChannel.trySend(GeminiWsEvent.Audio(bytes))
}
}
}
}
if (turnComplete) {
audioChannel.trySend(GeminiWsEvent.TurnComplete)
}
}
} catch (e: Exception) {
Timber.tag("TTS_CLOUD_DIAG").e(e, "Error parsing WS message text")
}
}
override fun onMessage(webSocket: WebSocket, bytes: okio.ByteString) {
onMessage(webSocket, bytes.utf8())
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
connectionError = if (response?.code == 402) {
"INSUFFICIENT_CREDITS"
} else {
"WS Failure: ${t.message} | Response: ${response?.code}"
}
Timber.tag("TTS_CLOUD_DIAG").e(t)
audioChannel.trySend(GeminiWsEvent.Error(connectionError))
this@GeminiLiveClient.webSocket = null
connectedDeferred.complete(false)
setupDeferred.complete(false)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
audioChannel.trySend(GeminiWsEvent.Error("Connection Closed: $reason"))
this@GeminiLiveClient.webSocket = null
setupDeferred.complete(false)
}
})
val isConnected = connectedDeferred.await()
if (!isConnected) throw IllegalStateException(connectionError ?: "Failed to connect to proxy WebSocket")
val isSetup = try {
kotlinx.coroutines.withTimeout(10000L) { setupDeferred.await() }
} catch (_: Exception) { false }
if (!isSetup) {
webSocket?.close(1000, "Setup failed")
webSocket = null
connectedSpeaker = null
throw IllegalStateException("Failed to complete Gemini setup")
} else {
Timber.tag("TTS_CLOUD_DIAG").d("Gemini setup complete")
}
}
fun generateChunk(text: String, cacheFile: File?): TtsAudioData {
if (text.isBlank()) return TtsAudioData(null, null, null, "Text is blank")
val streamId = java.util.UUID.randomUUID().toString()
val concurrentStream = ConcurrentInputStream()
StreamRegistry.register(streamId, concurrentStream)
val header = createWavHeaderUnknownLength(24000)
concurrentStream.write(header)
clientScope.launch {
generationMutex.withLock {
var fileOutputStream: java.io.FileOutputStream? = null
var tempFile: File? = null
try {
if (!isActive) return@launch
// Prepare cache temp file
if (cacheFile != null) {
tempFile = File(cacheFile.absolutePath + ".tmp")
fileOutputStream = java.io.FileOutputStream(tempFile)
fileOutputStream.write(header)
}
Timber.tag("TTS_CLOUD_DIAG").d("Starting API generation task for chunk: ${text.take(15)}...")
audioChannel = Channel(Channel.UNLIMITED)
val chunkGenStartTime = System.currentTimeMillis()
var firstByteTime = -1L
val payload = JSONObject().apply {
put("realtimeInput", JSONObject().apply {
put("text", text)
})
}.toString()
val sent = webSocket?.send(payload) ?: false
if (!sent) {
Timber.tag("TTS_CLOUD_DIAG").e("Failed to send text payload over WS")
return@launch
}
var receivedAudioBytes = 0
kotlinx.coroutines.withTimeout(30000L) {
for (event in audioChannel) {
when (event) {
is GeminiWsEvent.Audio -> {
if (firstByteTime == -1L) {
firstByteTime = System.currentTimeMillis()
Timber.tag("TTS_CLOUD_DIAG").i("TTFB: ${firstByteTime - chunkGenStartTime}ms")
}
concurrentStream.write(event.bytes)
fileOutputStream?.write(event.bytes)
receivedAudioBytes += event.bytes.size
}
is GeminiWsEvent.TurnComplete -> {
Timber.tag("TTS_CLOUD_DIAG").i("Chunk generation complete. Bytes: $receivedAudioBytes")
StreamRegistry.markFinished(streamId, receivedAudioBytes.toLong() + 44)
fileOutputStream?.close()
fileOutputStream = null
if (tempFile != null && cacheFile != null && receivedAudioBytes > 0) {
patchWavHeader(tempFile, receivedAudioBytes)
tempFile.renameTo(cacheFile)
Timber.tag("TTS_CLOUD_DIAG").d("Successfully cached chunk to ${cacheFile.name}")
}
break
}
is GeminiWsEvent.Error -> {
Timber.tag("TTS_CLOUD_DIAG").e("WS Error received: ${event.message}")
onAsyncError(event.message)
break
}
}
}
}
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
Timber.tag("TTS_CLOUD_DIAG").e(e, "Timeout waiting for audio/TurnComplete")
} catch (e: kotlinx.coroutines.CancellationException) {
Timber.tag("TTS_CLOUD_DIAG").i(e, "Streaming job cancelled due to user skip/flush")
} catch (e: Exception) {
Timber.tag("TTS_CLOUD_DIAG").e(e, "Exception piping audio")
} finally {
Timber.tag("TTS_CLOUD_DIAG").d("Closing stream for ${text.take(15)}")
concurrentStream.close()
fileOutputStream?.close()
if (cacheFile != null && !cacheFile.exists()) {
tempFile?.delete()
}
}
}
}
return TtsAudioData(null, text, emptyList(), streamUri = "ttsstream://$streamId")
}
fun close() {
clientScope.cancel()
clientScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
webSocket?.close(1000, "Context Reset")
webSocket = null
connectedSpeaker = null
setupDeferred = CompletableDeferred<Boolean>().apply { complete(false) }
StreamRegistry.clear()
}
}
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 ->
val audioGenerator: suspend (bookTitle: String, chapterTitle: String?, chunkIndex: Int, totalChunks: Int, text: String, speaker: String, mode: TtsMode, authToken: String?) -> TtsAudioData =
{ bookTitle, chapterTitle, chunkIndex, totalChunks, text, speaker, mode, authToken ->
cacheManager.saveTotalChunks(bookTitle, chapterTitle, totalChunks)
when (mode) {
TtsMode.CLOUD -> downloadAudioChunk(text, speaker)
TtsMode.CLOUD -> {
val cachedFile = cacheManager.getCacheFile(bookTitle, chapterTitle, text, speaker, mode)
if (cachedFile.exists() && cachedFile.length() > 44) {
Timber.tag("TTS_CLOUD_DIAG").i("Using cached audio for chunk $chunkIndex")
TtsAudioData(audioFile = cachedFile, serverText = text, wordTimings = emptyList(), error = null, streamUri = null)
} else {
try {
liveClient.ensureConnected(googleCloudWorkerTtsUrl, speaker, authToken)
liveClient.generateChunk(text, cachedFile)
} catch (e: Exception) {
Timber.tag("TTS_CLOUD_DIAG").e(e, "Cloud TTS generation failed")
TtsAudioData(audioFile = null, serverText = null, wordTimings = null, error = e.message ?: "Failed to connect to TTS service")
}
}
}
TtsMode.BASE -> synthesizeBaseTtsChunk(text)
}
}
@ -199,6 +568,8 @@ class TtsService : MediaSessionService() {
super.onCreate()
Timber.d("TtsService created.")
cacheManager = TtsCacheManager(this)
baseTtsSynthesizer = BaseTtsSynthesizer(this)
scope.launch {
try {
@ -213,14 +584,49 @@ class TtsService : MediaSessionService() {
.setUsage(C.USAGE_MEDIA)
.build()
val defaultDataSourceFactory = androidx.media3.datasource.DefaultDataSource.Factory(this)
val dataSourceFactory = androidx.media3.datasource.DataSource.Factory {
object : androidx.media3.datasource.DataSource {
private var dataSource: androidx.media3.datasource.DataSource? = null
private val defaultDataSource = defaultDataSourceFactory.createDataSource()
private val streamDataSource = InputStreamDataSource()
override fun addTransferListener(transferListener: androidx.media3.datasource.TransferListener) {
defaultDataSource.addTransferListener(transferListener)
streamDataSource.addTransferListener(transferListener)
}
override fun open(dataSpec: androidx.media3.datasource.DataSpec): Long {
dataSource = if (dataSpec.uri.scheme == "ttsstream") {
streamDataSource
} else {
defaultDataSource
}
return dataSource!!.open(dataSpec)
}
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
return dataSource!!.read(buffer, offset, length)
}
override fun getUri(): android.net.Uri? = dataSource?.uri
override fun close() {
dataSource?.close()
}
}
}
player = ExoPlayer.Builder(this)
.setAudioAttributes(audioAttributes, true)
.setHandleAudioBecomingNoisy(true)
.setMediaSourceFactory(androidx.media3.exoplayer.source.DefaultMediaSourceFactory(this).setDataSourceFactory(dataSourceFactory))
.build()
playbackManager = TtsPlaybackManager(
player = player,
generateAudioChunk = audioGenerator
generateAudioChunk = audioGenerator,
onResetContext = { liveClient.close() }
)
mediaSession = MediaSession.Builder(this, player)