feat: TTS audio cache in Room — skip regeneration on replay

- MessageEntity gets ttsUrl + ttsVoice fields (Room version 2→3)
- MessageDao.getTtsUrl() checks cache before API call
- MessageDao.updateTts() saves URL + voice after generation
- readAloud() checks Room first, only calls /api/v1/audio-gen on miss
- Voice change invalidates cache automatically (query includes voice)
- Default voice "Kore" (Gemini-TTS)
This commit is contained in:
Bruno Deanoz 2026-06-23 00:33:09 +02:00
parent 9e1f30de1f
commit c4884a3748
5 changed files with 46 additions and 26 deletions

View file

@ -227,7 +227,31 @@ fun ChatScreen(
}
}
fun readAloud(content: String, messageId: Long) {
fun playTtsUrl(url: String, messageId: Long) {
try {
val player = MediaPlayer().apply {
setDataSource(url)
prepareAsync()
setOnPreparedListener { start() }
setOnCompletionListener {
release()
ttsPlayerRef.value = null
playingTtsForId = null
}
setOnErrorListener { _, _, _ ->
release()
ttsPlayerRef.value = null
playingTtsForId = null
true
}
}
ttsPlayerRef.value = player
} catch (_: Exception) {
playingTtsForId = null
}
}
fun readAloud(content: String, messageId: Long, wireId: String) {
val cfg = session.config ?: return
if (playingTtsForId == messageId) {
ttsPlayerRef.value?.release()
@ -237,34 +261,21 @@ fun ChatScreen(
}
ttsPlayerRef.value?.release()
playingTtsForId = messageId
val voice = "Kore"
scope.launch {
val url = KaizenApi.generateSpeech(cfg.baseUrl, cfg.token, content)
val cached = chat.messageDao.getTtsUrl(wireId, voice)
if (cached != null) {
playTtsUrl(cached, messageId)
return@launch
}
val url = KaizenApi.generateSpeech(cfg.baseUrl, cfg.token, content, voice)
if (url == null) {
playingTtsForId = null
Toast.makeText(context, context.getString(R.string.chat_tts_error), Toast.LENGTH_SHORT).show()
return@launch
}
try {
val player = MediaPlayer().apply {
setDataSource(url)
prepareAsync()
setOnPreparedListener { start() }
setOnCompletionListener {
release()
ttsPlayerRef.value = null
playingTtsForId = null
}
setOnErrorListener { _, _, _ ->
release()
ttsPlayerRef.value = null
playingTtsForId = null
true
}
}
ttsPlayerRef.value = player
} catch (_: Exception) {
playingTtsForId = null
}
chat.messageDao.updateTts(wireId, url, voice)
playTtsUrl(url, messageId)
}
}
@ -737,7 +748,7 @@ fun ChatScreen(
}
} else null,
onReadAloud = if (message.role == Role.Assistant && !message.streaming && message.content.isNotBlank()) { content ->
readAloud(content, message.id)
readAloud(content, message.id, message.wireId)
} else null,
isPlayingTts = playingTtsForId == message.id,
)

View file

@ -8,5 +8,6 @@ import dev.kaizen.app.db.MessageRepository
class ChatViewModel(context: Context) {
private val db = KaizenDatabase.get(context)
val conversationRepo = ConversationRepository(db.conversationDao())
val messageRepo = MessageRepository(db.messageDao())
val messageDao = db.messageDao()
val messageRepo = MessageRepository(messageDao)
}

View file

@ -36,4 +36,6 @@ data class MessageEntity(
val sortOrder: Int,
val reasoning: String? = null,
val sources: List<SearchSource> = emptyList(),
val ttsUrl: String? = null,
val ttsVoice: String? = null,
)

View file

@ -8,7 +8,7 @@ import androidx.room.TypeConverters
@Database(
entities = [ConversationEntity::class, MessageEntity::class],
version = 2,
version = 3,
exportSchema = false,
)
@TypeConverters(Converters::class)

View file

@ -21,4 +21,10 @@ interface MessageDao {
@Query("SELECT DISTINCT conversationId FROM messages")
suspend fun cachedConversationIds(): List<String>
@Query("UPDATE messages SET ttsUrl = :url, ttsVoice = :voice WHERE id = :messageId")
suspend fun updateTts(messageId: String, url: String, voice: String)
@Query("SELECT ttsUrl FROM messages WHERE id = :messageId AND ttsVoice = :voice")
suspend fun getTtsUrl(messageId: String, voice: String): String?
}