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