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,23 +227,7 @@ fun ChatScreen(
} }
} }
fun readAloud(content: String, messageId: Long) { fun playTtsUrl(url: String, messageId: Long) {
val cfg = session.config ?: return
if (playingTtsForId == messageId) {
ttsPlayerRef.value?.release()
ttsPlayerRef.value = null
playingTtsForId = null
return
}
ttsPlayerRef.value?.release()
playingTtsForId = messageId
scope.launch {
val url = KaizenApi.generateSpeech(cfg.baseUrl, cfg.token, content)
if (url == null) {
playingTtsForId = null
Toast.makeText(context, context.getString(R.string.chat_tts_error), Toast.LENGTH_SHORT).show()
return@launch
}
try { try {
val player = MediaPlayer().apply { val player = MediaPlayer().apply {
setDataSource(url) setDataSource(url)
@ -266,6 +250,33 @@ fun ChatScreen(
playingTtsForId = null playingTtsForId = null
} }
} }
fun readAloud(content: String, messageId: Long, wireId: String) {
val cfg = session.config ?: return
if (playingTtsForId == messageId) {
ttsPlayerRef.value?.release()
ttsPlayerRef.value = null
playingTtsForId = null
return
}
ttsPlayerRef.value?.release()
playingTtsForId = messageId
val voice = "Kore"
scope.launch {
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
}
chat.messageDao.updateTts(wireId, url, voice)
playTtsUrl(url, messageId)
}
} }
LaunchedEffect(session.config?.baseUrl, session.config?.token) { LaunchedEffect(session.config?.baseUrl, session.config?.token) {
@ -737,7 +748,7 @@ fun ChatScreen(
} }
} else null, } else null,
onReadAloud = if (message.role == Role.Assistant && !message.streaming && message.content.isNotBlank()) { content -> onReadAloud = if (message.role == Role.Assistant && !message.streaming && message.content.isNotBlank()) { content ->
readAloud(content, message.id) readAloud(content, message.id, message.wireId)
} else null, } else null,
isPlayingTts = playingTtsForId == message.id, isPlayingTts = playingTtsForId == message.id,
) )

View file

@ -8,5 +8,6 @@ import dev.kaizen.app.db.MessageRepository
class ChatViewModel(context: Context) { class ChatViewModel(context: Context) {
private val db = KaizenDatabase.get(context) private val db = KaizenDatabase.get(context)
val conversationRepo = ConversationRepository(db.conversationDao()) 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 sortOrder: Int,
val reasoning: String? = null, val reasoning: String? = null,
val sources: List<SearchSource> = emptyList(), val sources: List<SearchSource> = emptyList(),
val ttsUrl: String? = null,
val ttsVoice: String? = null,
) )

View file

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

View file

@ -21,4 +21,10 @@ interface MessageDao {
@Query("SELECT DISTINCT conversationId FROM messages") @Query("SELECT DISTINCT conversationId FROM messages")
suspend fun cachedConversationIds(): List<String> 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?
} }