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:
parent
9e1f30de1f
commit
c4884a3748
5 changed files with 46 additions and 26 deletions
|
|
@ -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
|
val cfg = session.config ?: return
|
||||||
if (playingTtsForId == messageId) {
|
if (playingTtsForId == messageId) {
|
||||||
ttsPlayerRef.value?.release()
|
ttsPlayerRef.value?.release()
|
||||||
|
|
@ -237,34 +261,21 @@ fun ChatScreen(
|
||||||
}
|
}
|
||||||
ttsPlayerRef.value?.release()
|
ttsPlayerRef.value?.release()
|
||||||
playingTtsForId = messageId
|
playingTtsForId = messageId
|
||||||
|
val voice = "Kore"
|
||||||
scope.launch {
|
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) {
|
if (url == null) {
|
||||||
playingTtsForId = null
|
playingTtsForId = null
|
||||||
Toast.makeText(context, context.getString(R.string.chat_tts_error), Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, context.getString(R.string.chat_tts_error), Toast.LENGTH_SHORT).show()
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
try {
|
chat.messageDao.updateTts(wireId, url, voice)
|
||||||
val player = MediaPlayer().apply {
|
playTtsUrl(url, messageId)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue