feat(chat): add per-message read-aloud TTS playback (#1305)
This commit is contained in:
14 files changed
+2370
No files matched your search
@@ -0,0 +1,26 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Request body for `POST /api/audio/speak` — the dashboard's server-side
|
||||
* text-to-speech relay.
|
||||
*/
|
||||
@Serializable
|
||||
data class TtsSpeakRequest(
|
||||
val text: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Response of `POST /api/audio/speak`.
|
||||
*
|
||||
* Returns base64 encoded [data_url] with [mime_type] and the [provider] used,
|
||||
* or [ok] = false on failure.
|
||||
*/
|
||||
@Serializable
|
||||
data class TtsSpeakResponse(
|
||||
val ok: Boolean = false,
|
||||
val data_url: String? = null,
|
||||
val mime_type: String? = null,
|
||||
val provider: String? = null,
|
||||
)
|
||||
@@ -134,6 +134,8 @@ import com.m57.hermescontrol.data.model.ToolsetPostSetupResponse
|
||||
import com.m57.hermescontrol.data.model.ToolsetProviderSelectRequest
|
||||
import com.m57.hermescontrol.data.model.ToolsetProviderSelectResponse
|
||||
import com.m57.hermescontrol.data.model.ToolsetToggleRequest
|
||||
import com.m57.hermescontrol.data.model.TtsSpeakRequest
|
||||
import com.m57.hermescontrol.data.model.TtsSpeakResponse
|
||||
import com.m57.hermescontrol.data.model.UpdateCheckResponse
|
||||
import com.m57.hermescontrol.data.model.UpdateCronJobRequest
|
||||
import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest
|
||||
@@ -1043,4 +1045,10 @@ interface HermesApiService : KanbanApiService {
|
||||
suspend fun transcribeAudio(
|
||||
@Body body: AudioTranscriptionRequest,
|
||||
): Response<AudioTranscriptionResponse>
|
||||
|
||||
@POST("api/audio/speak")
|
||||
suspend fun speakText(
|
||||
@Body request: TtsSpeakRequest,
|
||||
@Query("profile") profile: String? = null,
|
||||
): Response<TtsSpeakResponse>
|
||||
}
|
||||
@@ -84,10 +84,12 @@ import com.m57.hermescontrol.HistoryScreen
|
||||
import com.m57.hermescontrol.LogsScreen
|
||||
import com.m57.hermescontrol.NavigationController
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.data.model.reasoningSupport
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.notification.NotificationHelper
|
||||
@@ -111,10 +113,13 @@ import com.m57.hermescontrol.ui.chat.components.ReplyErrorCard
|
||||
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
|
||||
import com.m57.hermescontrol.ui.chat.components.SessionIntegrationsSheet
|
||||
import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet
|
||||
import com.m57.hermescontrol.ui.chat.components.SpeechRequest
|
||||
import com.m57.hermescontrol.ui.chat.components.SpeechText
|
||||
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
||||
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatSpeech
|
||||
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
|
||||
import com.m57.hermescontrol.ui.chat.components.tailContentKey
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
|
||||
@@ -249,6 +254,10 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
val browserEvent = connectorsState.browserLaunchEvent
|
||||
val speechController = rememberChatSpeech()
|
||||
val speakingMessageId by speechController.speakingId.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
val activeSessionId by ActiveSessionHolder.activeSessionId.collectAsStateWithLifecycle()
|
||||
val listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy)
|
||||
val scrollScope = rememberCoroutineScope()
|
||||
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||
@@ -799,6 +808,17 @@ fun ChatScreen(
|
||||
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
||||
openingAttachmentPath = state.openingAttachmentPath,
|
||||
onImageClick = { viewingImage = it },
|
||||
speakingMessageId = speakingMessageId,
|
||||
onToggleSpeak = { message ->
|
||||
val scopeKey = "${dataScope?.inMemoryKey(localKey = activeSessionId ?: "none")}"
|
||||
speechController.toggle(
|
||||
SpeechRequest(
|
||||
scopeKey = scopeKey,
|
||||
messageId = message.id,
|
||||
text = SpeechText.stripMarkdownForSpeech(message.content),
|
||||
),
|
||||
)
|
||||
},
|
||||
replyErrorContent =
|
||||
state.replyFailure?.takeUnless { timelineState.isHistorical }?.let { failure ->
|
||||
{
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import com.m57.hermescontrol.data.model.TtsSpeakRequest
|
||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.Base64
|
||||
import java.util.UUID
|
||||
|
||||
private const val TTS_CACHE_DIR_NAME = "speech_tts"
|
||||
private const val TTS_FILE_PREFIX = "tts_"
|
||||
private const val TTS_STALE_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000L // 24 hours
|
||||
|
||||
class FileSpeechAudio(
|
||||
val file: File,
|
||||
) : SpeechAudio {
|
||||
override fun dispose() {
|
||||
runCatching { file.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Android implementation of [SpeechSynthesizer] backed by Hermes TTS REST API.
|
||||
*
|
||||
* Audio is decoded from base64 data URLs directly to temporary files in application cache.
|
||||
* Stale orphan cache files (created in previous sessions or left behind by unexpected crashes)
|
||||
* older than [TTS_STALE_CACHE_MAX_AGE_MS] are opportunistically pruned during synthesis on the
|
||||
* IO dispatcher before creating new files.
|
||||
*
|
||||
* Limitation note: Single-active controller usage is expected. Active audio files in current use
|
||||
* by the active session are protected from cleanup by the age threshold, but concurrent multi-process
|
||||
* or out-of-band manipulation of cache files is not synchronized.
|
||||
*/
|
||||
class AndroidSpeechSynthesizer(
|
||||
context: Context,
|
||||
private val api: HermesApiService,
|
||||
private val profile: String?,
|
||||
) : SpeechSynthesizer {
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
override suspend fun synthesize(request: SpeechRequest): SpeechAudio {
|
||||
val response =
|
||||
api.speakText(
|
||||
request = TtsSpeakRequest(request.text),
|
||||
profile = profile,
|
||||
)
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("TTS request failed with code ${response.code()}")
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw IOException("Empty TTS response body")
|
||||
if (!body.ok) {
|
||||
throw IOException("TTS synthesis reported failure")
|
||||
}
|
||||
|
||||
val dataUrl = body.data_url
|
||||
if (dataUrl.isNullOrBlank()) {
|
||||
throw IOException("Missing data URL in TTS response")
|
||||
}
|
||||
|
||||
var ownedFile: File? = null
|
||||
try {
|
||||
val audioFile =
|
||||
withContext(Dispatchers.IO) {
|
||||
val comma = dataUrl.indexOf(',')
|
||||
if (comma < 0) {
|
||||
throw IOException("Malformed data URL in TTS response")
|
||||
}
|
||||
|
||||
val meta = dataUrl.substring(0, comma)
|
||||
if (!meta.startsWith("data:audio/", ignoreCase = true)) {
|
||||
throw IOException("Expected audio data URL")
|
||||
}
|
||||
if (!meta.endsWith(";base64", ignoreCase = true)) {
|
||||
throw IOException("Expected base64-encoded audio data URL")
|
||||
}
|
||||
|
||||
val ext =
|
||||
when {
|
||||
meta.contains(
|
||||
"audio/mpeg",
|
||||
ignoreCase = true,
|
||||
) || meta.contains("audio/mp3", ignoreCase = true) -> "mp3"
|
||||
|
||||
meta.contains("audio/wav", ignoreCase = true) -> "wav"
|
||||
|
||||
meta.contains("audio/ogg", ignoreCase = true) -> "ogg"
|
||||
|
||||
meta.contains("audio/aac", ignoreCase = true) -> "aac"
|
||||
|
||||
meta.contains("audio/opus", ignoreCase = true) -> "opus"
|
||||
|
||||
meta.contains("audio/flac", ignoreCase = true) -> "flac"
|
||||
|
||||
else -> "audio"
|
||||
}
|
||||
|
||||
val cacheDir = File(appContext.cacheDir, TTS_CACHE_DIR_NAME).apply { mkdirs() }
|
||||
cleanStaleCacheFiles(cacheDir)
|
||||
|
||||
val file = File(cacheDir, "${TTS_FILE_PREFIX}${UUID.randomUUID()}.$ext")
|
||||
ownedFile = file
|
||||
|
||||
val rawStream = CharSequenceInputStream(dataUrl, comma + 1)
|
||||
val decodingStream = Base64.getDecoder().wrap(rawStream)
|
||||
|
||||
var totalBytesWritten = 0L
|
||||
decodingStream.use { input ->
|
||||
FileOutputStream(file).use { output ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var bytesRead: Int
|
||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
output.write(buffer, 0, bytesRead)
|
||||
totalBytesWritten += bytesRead
|
||||
}
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Java's Base64.getDecoder().wrap() stops decoding upon encountering padding ('=').
|
||||
// Verify that the underlying stream contains no unparsed trailing non-whitespace characters.
|
||||
if (rawStream.read() != -1) {
|
||||
throw IOException("Trailing garbage characters found after base64 data")
|
||||
}
|
||||
|
||||
if (totalBytesWritten == 0L) {
|
||||
throw IOException("Decoded audio file is empty")
|
||||
}
|
||||
|
||||
file
|
||||
}
|
||||
|
||||
ownedFile = null
|
||||
return FileSpeechAudio(audioFile)
|
||||
} catch (e: CancellationException) {
|
||||
ownedFile?.let { runCatching { it.delete() } }
|
||||
throw e
|
||||
} catch (t: Throwable) {
|
||||
ownedFile?.let { runCatching { it.delete() } }
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanStaleCacheFiles(cacheDir: File) {
|
||||
val now = System.currentTimeMillis()
|
||||
val files =
|
||||
cacheDir.listFiles { _, name ->
|
||||
name.startsWith(TTS_FILE_PREFIX)
|
||||
} ?: return
|
||||
|
||||
for (file in files) {
|
||||
if (now - file.lastModified() > TTS_STALE_CACHE_MAX_AGE_MS) {
|
||||
runCatching { file.delete() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class CharSequenceInputStream(
|
||||
private val value: CharSequence,
|
||||
startIndex: Int,
|
||||
) : InputStream() {
|
||||
private var index = startIndex
|
||||
|
||||
override fun read(): Int {
|
||||
while (index < value.length) {
|
||||
val c = value[index++]
|
||||
if (!c.isWhitespace()) {
|
||||
if (c.code > 127) {
|
||||
throw IOException("Non-ASCII character in base64 data")
|
||||
}
|
||||
return c.code
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
override fun read(
|
||||
b: ByteArray,
|
||||
off: Int,
|
||||
len: Int,
|
||||
): Int {
|
||||
if (len == 0) return 0
|
||||
var bytesRead = 0
|
||||
while (bytesRead < len) {
|
||||
val c = read()
|
||||
if (c == -1) {
|
||||
return if (bytesRead == 0) -1 else bytesRead
|
||||
}
|
||||
b[off + bytesRead] = c.toByte()
|
||||
bytesRead++
|
||||
}
|
||||
return bytesRead
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AndroidSpeechPlayer(
|
||||
context: Context,
|
||||
) : SpeechPlayer {
|
||||
private val appContext = context.applicationContext
|
||||
private var exoPlayer: ExoPlayer? = null
|
||||
private var activeListener: Player.Listener? = null
|
||||
|
||||
override fun play(
|
||||
audio: SpeechAudio,
|
||||
onEnded: () -> Unit,
|
||||
onError: () -> Unit,
|
||||
) {
|
||||
if (audio !is FileSpeechAudio) {
|
||||
onError()
|
||||
return
|
||||
}
|
||||
|
||||
val file = audio.file
|
||||
if (!file.exists() || file.length() == 0L) {
|
||||
onError()
|
||||
return
|
||||
}
|
||||
|
||||
val player =
|
||||
exoPlayer ?: ExoPlayer
|
||||
.Builder(appContext)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes
|
||||
.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_SPEECH)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build(),
|
||||
true,
|
||||
).setHandleAudioBecomingNoisy(true)
|
||||
.build()
|
||||
.also { exoPlayer = it }
|
||||
|
||||
activeListener?.let { player.removeListener(it) }
|
||||
|
||||
val listener =
|
||||
object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
player.removeListener(this)
|
||||
activeListener = null
|
||||
onEnded()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
player.removeListener(this)
|
||||
activeListener = null
|
||||
onError()
|
||||
}
|
||||
}
|
||||
|
||||
activeListener = listener
|
||||
player.addListener(listener)
|
||||
|
||||
val mediaItem = MediaItem.fromUri(Uri.fromFile(file))
|
||||
player.setMediaItem(mediaItem)
|
||||
player.prepare()
|
||||
player.playWhenReady = true
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
val player = exoPlayer ?: return
|
||||
activeListener?.let { player.removeListener(it) }
|
||||
activeListener = null
|
||||
player.stop()
|
||||
player.clearMediaItems()
|
||||
player.release()
|
||||
exoPlayer = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
|
||||
/**
|
||||
* Creates and remembers a screen/list-level [SpeechController] scoped to the active
|
||||
* chat session, server endpoint, profile, and auth state.
|
||||
*
|
||||
* Handles lifecycle events:
|
||||
* - Resets (stops playback and cleans cache) on [Lifecycle.Event.ON_STOP]
|
||||
* - Resets on composition disposal or when the server endpoint, profile, auth state, or session changes
|
||||
*/
|
||||
@Composable
|
||||
fun rememberChatSpeech(
|
||||
synthesizer: SpeechSynthesizer? = null,
|
||||
player: SpeechPlayer? = null,
|
||||
onError: (() -> Unit)? = null,
|
||||
): SpeechController {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val errorSynthesisMessage = stringResource(R.string.tts_error_synthesis)
|
||||
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
val activeSessionId by ActiveSessionHolder.activeSessionId.collectAsStateWithLifecycle()
|
||||
|
||||
val profile = dataScope?.activeProfileId
|
||||
val scopeKey = dataScope?.inMemoryKey(localKey = activeSessionId ?: "none").orEmpty()
|
||||
|
||||
val resolvedSynthesizer =
|
||||
remember(synthesizer, dataScope) {
|
||||
synthesizer ?: AndroidSpeechSynthesizer(
|
||||
context = context,
|
||||
api = ApiClient.hermesApi,
|
||||
profile = profile,
|
||||
)
|
||||
}
|
||||
|
||||
val resolvedPlayer =
|
||||
remember(player, context) {
|
||||
player ?: AndroidSpeechPlayer(context)
|
||||
}
|
||||
|
||||
val controller =
|
||||
remember(scope, resolvedSynthesizer, resolvedPlayer, errorSynthesisMessage) {
|
||||
SpeechController(
|
||||
scope = scope,
|
||||
synthesizer = resolvedSynthesizer,
|
||||
player = resolvedPlayer,
|
||||
onError = {
|
||||
onError?.invoke() ?: Toast
|
||||
.makeText(
|
||||
context,
|
||||
errorSynthesisMessage,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Reset when session scope changes (session/profile/server/auth switch)
|
||||
DisposableEffect(controller, scopeKey) {
|
||||
onDispose {
|
||||
controller.reset()
|
||||
}
|
||||
}
|
||||
|
||||
// Reset on ON_STOP lifecycle event
|
||||
DisposableEffect(lifecycleOwner, controller) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP) {
|
||||
controller.reset()
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
controller.reset()
|
||||
}
|
||||
}
|
||||
|
||||
return controller
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class SpeechRequest(
|
||||
val scopeKey: String,
|
||||
val messageId: String,
|
||||
val text: String,
|
||||
)
|
||||
|
||||
interface SpeechAudio {
|
||||
fun dispose()
|
||||
}
|
||||
|
||||
interface SpeechSynthesizer {
|
||||
suspend fun synthesize(request: SpeechRequest): SpeechAudio
|
||||
}
|
||||
|
||||
interface SpeechPlayer {
|
||||
fun play(
|
||||
audio: SpeechAudio,
|
||||
onEnded: () -> Unit,
|
||||
onError: () -> Unit,
|
||||
)
|
||||
|
||||
fun stop()
|
||||
}
|
||||
|
||||
class SpeechController(
|
||||
private val scope: CoroutineScope,
|
||||
private val synthesizer: SpeechSynthesizer,
|
||||
private val player: SpeechPlayer,
|
||||
private val onError: () -> Unit,
|
||||
) {
|
||||
private val _speakingId = MutableStateFlow<String?>(null)
|
||||
val speakingId: StateFlow<String?> = _speakingId.asStateFlow()
|
||||
|
||||
private var activeRequest: SpeechRequest? = null
|
||||
private var activeJob: Job? = null
|
||||
private var generation: Long = 0L
|
||||
|
||||
private var cachedRequest: SpeechRequest? = null
|
||||
private var cachedAudio: SpeechAudio? = null
|
||||
|
||||
fun toggle(request: SpeechRequest) {
|
||||
if (request.text.isBlank()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (activeRequest == request) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
start(request)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
generation++
|
||||
activeJob?.cancel()
|
||||
activeJob = null
|
||||
activeRequest = null
|
||||
player.stop()
|
||||
_speakingId.value = null
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
stop()
|
||||
disposeCache()
|
||||
}
|
||||
|
||||
private fun start(request: SpeechRequest) {
|
||||
generation++
|
||||
val currentGen = generation
|
||||
|
||||
activeJob?.cancel()
|
||||
player.stop()
|
||||
|
||||
if (cachedRequest != null && cachedRequest != request) {
|
||||
disposeCache()
|
||||
}
|
||||
|
||||
activeRequest = request
|
||||
_speakingId.value = request.messageId
|
||||
|
||||
val cached = cachedAudio
|
||||
if (cached != null && cachedRequest == request) {
|
||||
playAudio(cached, currentGen)
|
||||
return
|
||||
}
|
||||
|
||||
activeJob =
|
||||
scope.launch {
|
||||
var synthesizedAudio: SpeechAudio? = null
|
||||
try {
|
||||
val audio = synthesizer.synthesize(request)
|
||||
synthesizedAudio = audio
|
||||
|
||||
if (generation != currentGen) {
|
||||
audio.dispose()
|
||||
return@launch
|
||||
}
|
||||
|
||||
cachedAudio?.dispose()
|
||||
cachedRequest = request
|
||||
cachedAudio = audio
|
||||
synthesizedAudio = null
|
||||
|
||||
playAudio(audio, currentGen)
|
||||
} catch (e: CancellationException) {
|
||||
synthesizedAudio?.dispose()
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
synthesizedAudio?.dispose()
|
||||
if (generation == currentGen) {
|
||||
handlePlaybackFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun playAudio(
|
||||
audio: SpeechAudio,
|
||||
gen: Long,
|
||||
) {
|
||||
try {
|
||||
var completed = false
|
||||
player.play(
|
||||
audio = audio,
|
||||
onEnded = {
|
||||
if (generation == gen && !completed) {
|
||||
completed = true
|
||||
handlePlaybackFinished()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
if (generation == gen && !completed) {
|
||||
completed = true
|
||||
handlePlaybackFailure()
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (generation == gen) {
|
||||
handlePlaybackFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePlaybackFinished() {
|
||||
activeJob = null
|
||||
activeRequest = null
|
||||
player.stop()
|
||||
_speakingId.value = null
|
||||
}
|
||||
|
||||
private fun handlePlaybackFailure() {
|
||||
activeJob = null
|
||||
activeRequest = null
|
||||
player.stop()
|
||||
_speakingId.value = null
|
||||
onError()
|
||||
}
|
||||
|
||||
private fun disposeCache() {
|
||||
cachedAudio?.dispose()
|
||||
cachedAudio = null
|
||||
cachedRequest = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
/**
|
||||
* Plain-text preparation for per-message read-aloud (TTS speak button).
|
||||
*
|
||||
* Pure Kotlin — no Android imports — so it stays unit-testable on the JVM.
|
||||
* Strips Markdown formatting while preserving readable prose, inline code,
|
||||
* fenced code contents, link titles/alt texts, and Unicode/emojis.
|
||||
*/
|
||||
object SpeechText {
|
||||
// Fenced code blocks: ``` or ~~~ with optional info string, spanning to closing fence or EOF.
|
||||
private val FENCED_CODE_BLOCK_REGEX =
|
||||
Regex("(?m)^[ \\t]*(`{3,}|~{3,})[^\\r\\n`~]*\\r?\\n([\\s\\S]*?)(?:^[ \\t]*\\1[ \\t]*(?:\\r?\\n|\\Z)|\\Z)")
|
||||
|
||||
// Placeholders with null byte to guarantee safety from standard markdown text
|
||||
private const val CODE_SPAN_PREFIX = "\u0000HERMESCODE"
|
||||
private const val CODE_SPAN_SUFFIX = "\u0000"
|
||||
|
||||
// Inline code: `code` or multiple backticks ``code``
|
||||
private val INLINE_CODE_REGEX = Regex("(`+)(.+?)\\1")
|
||||
|
||||
// Markdown structures
|
||||
private val IMAGE_LINK_REGEX = Regex("!\\[([^\\]]*)\\]\\([^)]*\\)")
|
||||
private val LINK_REGEX = Regex("\\[([^\\]]*)\\]\\([^)]*\\)")
|
||||
private val URL_REGEX = Regex("https?://\\S+")
|
||||
private val HTML_TAG_REGEX = Regex("<[^>]+>")
|
||||
|
||||
// Block elements (multiline)
|
||||
private val HEADING_REGEX = Regex("(?m)^\\s{0,3}#{1,6}\\s+")
|
||||
private val BLOCKQUOTE_REGEX = Regex("(?m)^\\s*>\\s?")
|
||||
private val LIST_MARKER_REGEX = Regex("(?m)^\\s*(?:[-*+]|\\d+[.)])\\s+")
|
||||
private val HORIZONTAL_RULE_REGEX = Regex("(?m)^\\s*([-_*])(?:\\s*\\1){2,}\\s*$")
|
||||
|
||||
// Emphasis / strikethrough markers
|
||||
private val BOLD_REGEX = Regex("(\\*\\*|__)(.*?)\\1")
|
||||
private val ITALIC_REGEX = Regex("(\\*|_)(.*?)\\1")
|
||||
private val STRIKETHROUGH_REGEX = Regex("~~(.*?)~~")
|
||||
|
||||
// Punctuation set including Arabic question mark (\u061F) and semicolon (\u061B), and standard ones
|
||||
// Punctuations that can end a sentence or pause: . , ! ? ; : ؟ ؛
|
||||
private const val PUNCTUATION_CLASS = "[.,!?;:\\u061F\\u061B]"
|
||||
private val PUNCT_BEFORE_NEWLINE_REGEX = Regex("($PUNCTUATION_CLASS)[^\\S\\r\\n]*(?:\\r?\\n[^\\S\\r\\n]*)+")
|
||||
private val PLAIN_NEWLINE_REGEX = Regex("[^\\S\\r\\n]*(?:\\r?\\n[^\\S\\r\\n]*)+")
|
||||
private val WHITESPACE_REGEX = Regex("\\s+")
|
||||
private val SPACE_BEFORE_PUNCT_REGEX = Regex("\\s+($PUNCTUATION_CLASS)")
|
||||
|
||||
fun stripMarkdownForSpeech(text: String): String {
|
||||
if (text.isBlank()) return ""
|
||||
|
||||
val codeSpans = mutableListOf<String>()
|
||||
|
||||
// 1. Protect fenced code blocks before prose processing.
|
||||
// Delimiters and info string are stripped; code content whitespace is normalized for speech.
|
||||
var out =
|
||||
FENCED_CODE_BLOCK_REGEX.replace(text) { matchResult ->
|
||||
val rawContent = matchResult.groupValues[2]
|
||||
val normalizedContent =
|
||||
rawContent
|
||||
.trim()
|
||||
.split(
|
||||
WHITESPACE_REGEX,
|
||||
).filter { it.isNotEmpty() }
|
||||
.joinToString(" ")
|
||||
val index = codeSpans.size
|
||||
codeSpans.add(normalizedContent)
|
||||
"\n$CODE_SPAN_PREFIX$index$CODE_SPAN_SUFFIX\n"
|
||||
}
|
||||
|
||||
// 2. Protect inline code spans before prose processing.
|
||||
out =
|
||||
INLINE_CODE_REGEX.replace(out) { matchResult ->
|
||||
val content = matchResult.groupValues[2].trim()
|
||||
val index = codeSpans.size
|
||||
codeSpans.add(content)
|
||||
"$CODE_SPAN_PREFIX$index$CODE_SPAN_SUFFIX"
|
||||
}
|
||||
|
||||
// 3. Process prose markdown elements
|
||||
// Images: keep alt text, drop the URL.
|
||||
out = out.replace(IMAGE_LINK_REGEX, "$1")
|
||||
// Links: keep visible text, drop the target.
|
||||
out = out.replace(LINK_REGEX, "$1")
|
||||
// Autolinks / bare URLs: drop the URL, keep surrounding prose.
|
||||
out = out.replace(URL_REGEX, " ")
|
||||
// HTML tags.
|
||||
out = out.replace(HTML_TAG_REGEX, " ")
|
||||
// Headings, blockquotes, list markers, horizontal rules.
|
||||
out = out.replace(HEADING_REGEX, "")
|
||||
out = out.replace(BLOCKQUOTE_REGEX, "")
|
||||
out = out.replace(LIST_MARKER_REGEX, "")
|
||||
out = out.replace(HORIZONTAL_RULE_REGEX, " ")
|
||||
// Table pipes
|
||||
out = out.replace("|", " ")
|
||||
// Emphasis / strikethrough markers
|
||||
out = out.replace(BOLD_REGEX, "$2")
|
||||
out = out.replace(ITALIC_REGEX, "$2")
|
||||
out = out.replace(STRIKETHROUGH_REGEX, "$1")
|
||||
|
||||
// 4. Normalise newlines into sensible pauses without accidental global punctuation mutation.
|
||||
// If line ends with punctuation, keep it with a trailing space.
|
||||
// Otherwise, replace newline with ". " to create a pause.
|
||||
out = out.replace(PUNCT_BEFORE_NEWLINE_REGEX, "$1 ")
|
||||
out = out.replace(PLAIN_NEWLINE_REGEX, ". ")
|
||||
|
||||
// 5. Restore protected code spans (both fenced and inline)
|
||||
for ((index, code) in codeSpans.withIndex()) {
|
||||
out = out.replace("$CODE_SPAN_PREFIX$index$CODE_SPAN_SUFFIX", code)
|
||||
}
|
||||
|
||||
// 6. Collapse multiple whitespaces and trim
|
||||
out = out.replace(WHITESPACE_REGEX, " ").trim()
|
||||
|
||||
// 7. Tidy spaces before punctuation left behind by marker removal.
|
||||
out = out.replace(SPACE_BEFORE_PUNCT_REGEX, "$1")
|
||||
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,10 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -65,6 +67,8 @@ internal fun FullBleedAgentMessage(
|
||||
openingAttachmentPath: String? = null,
|
||||
canSaveAttachment: Boolean = true,
|
||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||
isSpeaking: Boolean = false,
|
||||
onToggleSpeak: (() -> Unit)? = null,
|
||||
messageStatsEnabled: Boolean = false,
|
||||
showAssistantMessageTokens: Boolean = true,
|
||||
showTokensPerSecond: Boolean = true,
|
||||
@@ -162,6 +166,31 @@ internal fun FullBleedAgentMessage(
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (onToggleSpeak != null) {
|
||||
IconButton(
|
||||
onClick = onToggleSpeak,
|
||||
modifier = Modifier.size(28.dp).testTag("fullbleed_speak"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (isSpeaking) {
|
||||
Icons.Filled.Stop
|
||||
} else {
|
||||
Icons.AutoMirrored.Filled.VolumeUp
|
||||
},
|
||||
contentDescription =
|
||||
stringResource(
|
||||
if (isSpeaking) {
|
||||
R.string.content_desc_stop_speaking
|
||||
} else {
|
||||
R.string.content_desc_speak
|
||||
},
|
||||
),
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showTokenStat) {
|
||||
AssistantStatItem(
|
||||
value =
|
||||
|
||||
@@ -105,6 +105,8 @@ fun FullBleedChatList(
|
||||
pagingSessionId: String? = null,
|
||||
onLoadOlder: () -> Unit = viewModel::loadOlderMessages,
|
||||
replyErrorContent: (@Composable () -> Unit)? = null,
|
||||
speakingMessageId: String? = null,
|
||||
onToggleSpeak: ((ChatMessage) -> Unit)? = null,
|
||||
) {
|
||||
if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) {
|
||||
Box(
|
||||
@@ -363,6 +365,15 @@ fun FullBleedChatList(
|
||||
showReasoning = !hoistedReasoning,
|
||||
)
|
||||
} else {
|
||||
val isSpeakingThis =
|
||||
speakingMessageId != null &&
|
||||
speakingMessageId == proseMessage.id
|
||||
val toggleSpeakAction =
|
||||
if (onToggleSpeak != null) {
|
||||
{ onToggleSpeak(proseMessage) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
FullBleedAgentMessage(
|
||||
message = proseMessage,
|
||||
// Highlight only bubbles that actually contain a match —
|
||||
@@ -385,6 +396,8 @@ fun FullBleedChatList(
|
||||
openingAttachmentPath = openingAttachmentPath,
|
||||
canSaveAttachment = savingAttachmentPath == null,
|
||||
onImageClick = onImageClick,
|
||||
isSpeaking = isSpeakingThis,
|
||||
onToggleSpeak = toggleSpeakAction,
|
||||
messageStatsEnabled = messageStatsEnabled,
|
||||
showAssistantMessageTokens = showAssistantMessageTokens,
|
||||
showTokensPerSecond = showTokensPerSecond,
|
||||
|
||||
Reference in new issue
Block a user