From 3c56026a1c3d79d2db295ac16967bb8cec6b1ded Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:43:50 +0300 Subject: [PATCH] feat(chat): add per-message read-aloud TTS playback (#1305) --- .../com/m57/hermescontrol/data/model/Tts.kt | 26 + .../data/remote/HermesApiService.kt | 8 + .../m57/hermescontrol/ui/chat/ChatScreen.kt | 20 + .../ui/chat/components/AndroidSpeech.kt | 288 +++++++ .../ui/chat/components/RememberChatSpeech.kt | 99 +++ .../ui/chat/components/SpeechController.kt | 178 +++++ .../ui/chat/components/SpeechText.kt | 118 +++ .../chat/fullbleed/FullBleedAgentMessage.kt | 29 + .../ui/chat/fullbleed/FullBleedChatList.kt | 13 + app/src/main/res/values/strings.xml | 5 + .../m57/hermescontrol/data/model/TtsTest.kt | 140 ++++ .../ui/chat/components/AndroidSpeechTest.kt | 458 +++++++++++ .../chat/components/SpeechControllerTest.kt | 738 ++++++++++++++++++ .../ui/chat/components/SpeechTextTest.kt | 250 ++++++ 14 files changed, 2370 insertions(+) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/Tts.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/AndroidSpeech.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/RememberChatSpeech.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/SpeechController.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/SpeechText.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/model/TtsTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/components/AndroidSpeechTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/components/SpeechControllerTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/components/SpeechTextTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Tts.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Tts.kt new file mode 100644 index 00000000..85ff2e49 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Tts.kt @@ -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, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index c51156a9..a2a486c6 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -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 + + @POST("api/audio/speak") + suspend fun speakText( + @Body request: TtsSpeakRequest, + @Query("profile") profile: String? = null, + ): Response } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 49585ae6..6dd3b8e9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -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 -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/AndroidSpeech.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/AndroidSpeech.kt new file mode 100644 index 00000000..1b7c046c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/AndroidSpeech.kt @@ -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 + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/RememberChatSpeech.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/RememberChatSpeech.kt new file mode 100644 index 00000000..9ce3769d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/RememberChatSpeech.kt @@ -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 +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SpeechController.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SpeechController.kt new file mode 100644 index 00000000..88f7958c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SpeechController.kt @@ -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(null) + val speakingId: StateFlow = _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 + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SpeechText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SpeechText.kt new file mode 100644 index 00000000..ad0668b9 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SpeechText.kt @@ -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() + + // 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 + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt index 604d7fae..8899c7d4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt @@ -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 = diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt index 431b0195..6d0bb55b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt @@ -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, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ae26e415..c05a133d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -237,6 +237,11 @@ Scroll to bottom Copy Copied + Read aloud + Stop speaking + Could not reach the Hermes server for speech + Could not synthesize the message audio + Could not play the message audio Edit %1$s tok %1$s t/s diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/TtsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/TtsTest.kt new file mode 100644 index 00000000..1c0f3515 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/model/TtsTest.kt @@ -0,0 +1,140 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TtsTest { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + @Test + fun `serialize TtsSpeakRequest with literal text`() { + val request = TtsSpeakRequest(text = "Hello, world!") + val encoded = json.encodeToString(request) + + assertEquals("""{"text":"Hello, world!"}""", encoded) + } + + @Test + fun `deserialize TtsSpeakResponse with all fields present`() { + val rawJson = + """ + { + "ok": true, + "data_url": "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=", + "mime_type": "audio/wav", + "provider": "piper" + } + """.trimIndent() + + val response = json.decodeFromString(rawJson) + + assertTrue(response.ok) + assertEquals( + "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=", + response.data_url, + ) + assertEquals("audio/wav", response.mime_type) + assertEquals("piper", response.provider) + } + + @Test + fun `deserialize TtsSpeakResponse with optional provider missing`() { + val rawJson = + """ + { + "ok": true, + "data_url": "data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/", + "mime_type": "audio/mp3" + } + """.trimIndent() + + val response = json.decodeFromString(rawJson) + + assertTrue(response.ok) + assertEquals( + "data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/", + response.data_url, + ) + assertEquals("audio/mp3", response.mime_type) + assertNull(response.provider) + } + + @Test + fun `deserialize TtsSpeakResponse with provider explicitly null`() { + val rawJson = + """ + { + "ok": true, + "data_url": "data:audio/ogg;base64,T2dnUwACAAAAAAAAAAA=", + "mime_type": "audio/ogg", + "provider": null + } + """.trimIndent() + + val response = json.decodeFromString(rawJson) + + assertTrue(response.ok) + assertEquals("data:audio/ogg;base64,T2dnUwACAAAAAAAAAAA=", response.data_url) + assertEquals("audio/ogg", response.mime_type) + assertNull(response.provider) + } + + @Test + fun `deserialize TtsSpeakResponse with missing ok defaults to false`() { + val rawJson = + """ + { + "data_url": null, + "mime_type": null + } + """.trimIndent() + + val response = json.decodeFromString(rawJson) + + assertFalse(response.ok) + assertNull(response.data_url) + assertNull(response.mime_type) + assertNull(response.provider) + } + + @Test + fun `deserialize TtsSpeakResponse with empty json defaults to ok false and nulls`() { + val rawJson = "{}" + + val response = json.decodeFromString(rawJson) + + assertFalse(response.ok) + assertNull(response.data_url) + assertNull(response.mime_type) + assertNull(response.provider) + } + + @Test + fun `deserialize TtsSpeakResponse with unknown fields ignores extra properties`() { + val rawJson = + """ + { + "ok": false, + "error": "Provider not reachable", + "status_code": 500 + } + """.trimIndent() + + val response = json.decodeFromString(rawJson) + + assertFalse(response.ok) + assertNull(response.data_url) + assertNull(response.mime_type) + assertNull(response.provider) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/components/AndroidSpeechTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/AndroidSpeechTest.kt new file mode 100644 index 00000000..9136b061 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/AndroidSpeechTest.kt @@ -0,0 +1,458 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.content.Context +import com.m57.hermescontrol.data.model.TtsSpeakRequest +import com.m57.hermescontrol.data.model.TtsSpeakResponse +import com.m57.hermescontrol.data.remote.HermesApiService +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import retrofit2.Response +import java.io.File +import java.io.IOException +import java.util.Base64 + +@OptIn(ExperimentalCoroutinesApi::class) +class AndroidSpeechTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private fun createContext(): Context { + val mockContext = mockk() + every { mockContext.applicationContext } returns mockContext + every { mockContext.cacheDir } returns tempFolder.root + return mockContext + } + + private fun listTempFiles(): List = + tempFolder.root + .walkTopDown() + .filter { it.isFile } + .toList() + + @Test + fun `synthesize decodes small real base64 audio bytes to exact file bytes and passes text and profile`() = + runTest { + val context = createContext() + val api = mockk() + val expectedBytes = byteArrayOf(0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00) + val base64Data = Base64.getEncoder().encodeToString(expectedBytes) + val dataUrl = "data:audio/wav;base64,$base64Data" + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = dataUrl, + mime_type = "audio/wav", + provider = "piper", + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = "work-profile", + ) + + val request = + SpeechRequest( + scopeKey = "session-42", + messageId = "msg-101", + text = "Hello world", + ) + + val resultAudio: SpeechAudio = synthesizer.synthesize(request) + assertTrue("Expected result to be FileSpeechAudio", resultAudio is FileSpeechAudio) + + val fileAudio = resultAudio as FileSpeechAudio + val audioFile = fileAudio.file + + assertTrue("Synthesized audio file must exist", audioFile.exists()) + assertArrayEquals("Decoded file bytes must match input bytes", expectedBytes, audioFile.readBytes()) + + coVerify(exactly = 1) { + api.speakText( + request = TtsSpeakRequest(text = "Hello world"), + profile = "work-profile", + ) + } + + fileAudio.dispose() + assertFalse("File must be deleted after dispose()", audioFile.exists()) + } + + @Test + fun `synthesize passes null profile when synthesizer constructed without profile`() = + runTest { + val context = createContext() + val api = mockk() + val rawBytes = byteArrayOf(0x01, 0x02, 0x03, 0x04) + val base64Data = Base64.getEncoder().encodeToString(rawBytes) + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = "data:audio/mp3;base64,$base64Data", + mime_type = "audio/mp3", + provider = null, + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = null, + ) + + val request = + SpeechRequest( + scopeKey = "session-1", + messageId = "msg-1", + text = "Testing null profile", + ) + + val audio = synthesizer.synthesize(request) + assertTrue(audio is FileSpeechAudio) + + coVerify(exactly = 1) { + api.speakText( + request = TtsSpeakRequest(text = "Testing null profile"), + profile = null, + ) + } + + audio.dispose() + assertEquals(0, listTempFiles().size) + } + + @Test + fun `HTTP or network failure throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + + coEvery { api.speakText(any(), any()) } throws IOException("Network connection lost") + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = "test-profile", + ) + + val request = + SpeechRequest( + scopeKey = "session-err", + messageId = "msg-err", + text = "Network fail", + ) + + try { + synthesizer.synthesize(request) + fail("Expected IOException to be thrown") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after network failure", 0, listTempFiles().size) + } + + @Test + fun `response with ok false throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = false, + data_url = null, + mime_type = null, + provider = null, + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = "profile-1", + ) + + val request = + SpeechRequest( + scopeKey = "s1", + messageId = "m1", + text = "Failed ok=false", + ) + + try { + synthesizer.synthesize(request) + fail("Expected synthesize to fail when ok is false") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after ok=false", 0, listTempFiles().size) + } + + @Test + fun `malformed data URI scheme throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = "not-a-data-uri", + mime_type = "audio/wav", + provider = "piper", + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = null, + ) + + val request = + SpeechRequest( + scopeKey = "s1", + messageId = "m1", + text = "Malformed scheme", + ) + + try { + synthesizer.synthesize(request) + fail("Expected failure for malformed data URI") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after malformed URI failure", 0, listTempFiles().size) + } + + @Test + fun `non-audio data URI throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + val textBase64 = Base64.getEncoder().encodeToString("hello world".toByteArray()) + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = "data:text/plain;base64,$textBase64", + mime_type = "text/plain", + provider = "text", + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = null, + ) + + val request = + SpeechRequest( + scopeKey = "s1", + messageId = "m1", + text = "Non audio data URI", + ) + + try { + synthesizer.synthesize(request) + fail("Expected failure for non-audio data URI") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after non-audio data URI failure", 0, listTempFiles().size) + } + + @Test + fun `invalid base64 payload throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = "data:audio/wav;base64,!!!NotBase64Chars!!!", + mime_type = "audio/wav", + provider = "piper", + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = null, + ) + + val request = + SpeechRequest( + scopeKey = "s1", + messageId = "m1", + text = "Invalid base64", + ) + + try { + synthesizer.synthesize(request) + fail("Expected failure for invalid base64") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after base64 decoding failure", 0, listTempFiles().size) + } + + @Test + fun `empty data URI payload throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = "data:audio/wav;base64,", + mime_type = "audio/wav", + provider = "piper", + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = null, + ) + + val request = + SpeechRequest( + scopeKey = "s1", + messageId = "m1", + text = "Empty payload", + ) + + try { + synthesizer.synthesize(request) + fail("Expected failure for empty base64 payload") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after empty payload failure", 0, listTempFiles().size) + } + + @Test + fun `null data_url throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = null, + mime_type = "audio/wav", + provider = "piper", + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = null, + ) + + val request = + SpeechRequest( + scopeKey = "s1", + messageId = "m1", + text = "Null data url", + ) + + try { + synthesizer.synthesize(request) + fail("Expected failure when data_url is null") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after null data_url failure", 0, listTempFiles().size) + } + + @Test + fun `padded valid base64 followed by garbage throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + val validPaddedBase64 = "AQ==" // 1 byte decoded (0x01), padded with == + val payloadWithTrailingGarbage = validPaddedBase64 + "garbage" + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = "data:audio/wav;base64,$payloadWithTrailingGarbage", + mime_type = "audio/wav", + provider = "piper", + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = null, + ) + + val request = + SpeechRequest( + scopeKey = "s1", + messageId = "m1", + text = "Garbage after padding", + ) + + try { + synthesizer.synthesize(request) + fail("Expected failure for base64 with trailing garbage after padding") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after trailing garbage failure", 0, listTempFiles().size) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SpeechControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SpeechControllerTest.kt new file mode 100644 index 00000000..92e04e14 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SpeechControllerTest.kt @@ -0,0 +1,738 @@ +package com.m57.hermescontrol.ui.chat.components + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SpeechControllerTest { + private class FakeAudio( + val tag: String, + ) : SpeechAudio { + var isDisposed: Boolean = false + private set + + override fun dispose() { + isDisposed = true + } + } + + private class FakeSynthesizer : SpeechSynthesizer { + var calls: MutableList = mutableListOf() + var synthesizeBlock: (suspend (SpeechRequest) -> SpeechAudio)? = null + + override suspend fun synthesize(request: SpeechRequest): SpeechAudio { + calls.add(request) + return synthesizeBlock?.invoke(request) ?: FakeAudio(request.text) + } + } + + private class FakePlayer : SpeechPlayer { + var currentlyPlaying: SpeechAudio? = null + var lastEndedCallback: (() -> Unit)? = null + var lastErrorCallback: (() -> Unit)? = null + var playCallCount: Int = 0 + var stopCallCount: Int = 0 + var playThrows: Throwable? = null + + override fun play( + audio: SpeechAudio, + onEnded: () -> Unit, + onError: () -> Unit, + ) { + playCallCount++ + currentlyPlaying = audio + lastEndedCallback = onEnded + lastErrorCallback = onError + playThrows?.let { throw it } + } + + override fun stop() { + stopCallCount++ + currentlyPlaying = null + lastEndedCallback = null + lastErrorCallback = null + } + } + + private class Fixture( + val scope: TestScope, + ) { + val synthesizer = FakeSynthesizer() + val player = FakePlayer() + val errors = mutableListOf() + + val controller = + SpeechController( + scope = scope, + synthesizer = synthesizer, + player = player, + onError = { errors.add(Unit) }, + ) + } + + @Test + fun `toggle with blank text is a no-op`() = + runTest { + val fixture = Fixture(this) + val blankReq = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = " ") + + fixture.controller.toggle(blankReq) + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertEquals(0, fixture.synthesizer.calls.size) + assertEquals(0, fixture.player.playCallCount) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `toggle with empty text is a no-op`() = + runTest { + val fixture = Fixture(this) + val emptyReq = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "") + + fixture.controller.toggle(emptyReq) + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertEquals(0, fixture.synthesizer.calls.size) + assertEquals(0, fixture.player.playCallCount) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `toggle synthesizes and plays audio, setting speakingId`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + + fixture.controller.toggle(req) + advanceUntilIdle() + + assertEquals("msg-1", fixture.controller.speakingId.value) + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(req, fixture.synthesizer.calls[0]) + assertEquals(1, fixture.player.playCallCount) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `toggle on same active request stops playback and clears speakingId`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + + fixture.controller.toggle(req) + advanceUntilIdle() + assertEquals("msg-1", fixture.controller.speakingId.value) + + fixture.controller.toggle(req) + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertTrue(fixture.player.stopCallCount >= 1) + } + + @Test + fun `toggle on same active request while still synthesizing stops and cancels synthesis`() = + runTest { + val fixture = Fixture(this) + val gate = CompletableDeferred() + fixture.synthesizer.synthesizeBlock = { gate.await() } + + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + fixture.controller.toggle(req) + runCurrent() + + // Toggle same request while synthesis is inflight + fixture.controller.toggle(req) + runCurrent() + + assertNull(fixture.controller.speakingId.value) + + // Even if synthesis completes later, generation fencing ensures it never plays + val audio = FakeAudio("Hello world") + gate.complete(audio) + advanceUntilIdle() + + assertEquals(0, fixture.player.playCallCount) + assertNull(fixture.controller.speakingId.value) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `noncooperative synthesis cancelled during synthesis still disposes returned audio without playing`() = + runTest { + val fixture = Fixture(this) + val gate = CompletableDeferred() + val resumeGate = CompletableDeferred() + val audio = FakeAudio("Stale noncooperative audio") + + fixture.synthesizer.synthesizeBlock = { + try { + gate.await() + } catch (t: Throwable) { + withContext(NonCancellable) { + resumeGate.await() + } + } + audio + } + + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text") + fixture.controller.toggle(req) + runCurrent() + + // Cancel by stopping + fixture.controller.stop() + runCurrent() + assertNull(fixture.controller.speakingId.value) + + // Now let noncooperative synthesis produce audio after cancellation + resumeGate.complete(Unit) + advanceUntilIdle() + + // Audio must be disposed, never played, no error emitted, and speakingId remains null + assertTrue(audio.isDisposed) + assertEquals(0, fixture.player.playCallCount) + assertNull(fixture.controller.speakingId.value) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `noncooperative synthesis superseded by replacement disposes stale audio without touching newer state`() = + runTest { + val fixture = Fixture(this) + val gate1 = CompletableDeferred() + val resumeGate1 = CompletableDeferred() + val audio1 = FakeAudio("Stale noncooperative audio 1") + val audio2 = FakeAudio("New active audio 2") + + fixture.synthesizer.synthesizeBlock = { req -> + if (req.messageId == "msg-1") { + try { + gate1.await() + } catch (t: Throwable) { + withContext(NonCancellable) { + resumeGate1.await() + } + } + audio1 + } else { + audio2 + } + } + + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text") + val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text") + + fixture.controller.toggle(req1) + runCurrent() + + // Supersede with req2 + fixture.controller.toggle(req2) + advanceUntilIdle() + + // req2 should now be playing + assertEquals("msg-2", fixture.controller.speakingId.value) + assertEquals(audio2, fixture.player.currentlyPlaying) + assertEquals(1, fixture.player.playCallCount) + + // Now release stale noncooperative synthesis from req1 + resumeGate1.complete(Unit) + advanceUntilIdle() + + // Stale audio1 must be disposed, no extra play calls, speakingId untouched, no errors + assertTrue(audio1.isDisposed) + assertFalse(audio2.isDisposed) + assertEquals("msg-2", fixture.controller.speakingId.value) + assertEquals(audio2, fixture.player.currentlyPlaying) + assertEquals(1, fixture.player.playCallCount) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `noncooperative synthesis throwing failure after replacement is ignored and does not invoke onError`() = + runTest { + val fixture = Fixture(this) + val gate1 = CompletableDeferred() + val resumeGate1 = CompletableDeferred() + val audio2 = FakeAudio("Active audio 2") + + fixture.synthesizer.synthesizeBlock = { req -> + if (req.messageId == "msg-1") { + try { + gate1.await() + } catch (t: Throwable) { + withContext(NonCancellable) { + resumeGate1.await() + } + } + throw IllegalStateException("Stale synthesis explosion") + } else { + audio2 + } + } + + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text") + val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text") + + fixture.controller.toggle(req1) + runCurrent() + + // Replace with req2 + fixture.controller.toggle(req2) + advanceUntilIdle() + + assertEquals("msg-2", fixture.controller.speakingId.value) + assertEquals(0, fixture.errors.size) + + // Release stale failure + resumeGate1.complete(Unit) + advanceUntilIdle() + + // Stale failure must not invoke onError or clear msg-2 + assertEquals(0, fixture.errors.size) + assertEquals("msg-2", fixture.controller.speakingId.value) + } + + @Test + fun `stop and restart race on same request ensures stale inflight completion does not disrupt new cycle`() = + runTest { + val fixture = Fixture(this) + val gate1 = CompletableDeferred() + val resumeGate1 = CompletableDeferred() + val audio1 = FakeAudio("Cycle 1 audio") + val audio2 = FakeAudio("Cycle 2 audio") + + var callIndex = 0 + fixture.synthesizer.synthesizeBlock = { + val idx = ++callIndex + if (idx == 1) { + try { + gate1.await() + } catch (t: Throwable) { + withContext(NonCancellable) { + resumeGate1.await() + } + } + audio1 + } else { + audio2 + } + } + + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Same text") + + // Cycle 1: start synthesis + fixture.controller.toggle(req) + runCurrent() + + // Toggle same request while synthesizing -> cancels cycle 1 + fixture.controller.toggle(req) + runCurrent() + assertNull(fixture.controller.speakingId.value) + + // Cycle 2: restart SAME request immediately + fixture.controller.toggle(req) + advanceUntilIdle() + + // Cycle 2 has synthesized audio2 and is playing it + assertEquals("msg-1", fixture.controller.speakingId.value) + assertEquals(audio2, fixture.player.currentlyPlaying) + assertEquals(1, fixture.player.playCallCount) + + // Stale cycle 1 finally returns audio1 + resumeGate1.complete(Unit) + advanceUntilIdle() + + // Stale audio1 must be disposed and cycle 2 remains active and playing audio2 + assertTrue(audio1.isDisposed) + assertFalse(audio2.isDisposed) + assertEquals("msg-1", fixture.controller.speakingId.value) + assertEquals(audio2, fixture.player.currentlyPlaying) + assertEquals(1, fixture.player.playCallCount) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `stop stops playback and clears speakingId`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + + fixture.controller.toggle(req) + advanceUntilIdle() + assertEquals("msg-1", fixture.controller.speakingId.value) + + val stopCallsBefore = fixture.player.stopCallCount + fixture.controller.stop() + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertTrue(fixture.player.stopCallCount > stopCallsBefore) + } + + @Test + fun `replacement immediately stops active playback and cancels old request`() = + runTest { + val fixture = Fixture(this) + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text") + val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text") + + fixture.controller.toggle(req1) + advanceUntilIdle() + assertEquals("msg-1", fixture.controller.speakingId.value) + + fixture.controller.toggle(req2) + advanceUntilIdle() + + assertEquals("msg-2", fixture.controller.speakingId.value) + assertEquals(2, fixture.synthesizer.calls.size) + assertEquals(2, fixture.player.playCallCount) + } + + @Test + fun `replacement cancels inflight synthesis and generation fencing rejects stale completion`() = + runTest { + val fixture = Fixture(this) + val gate1 = CompletableDeferred() + val gate2 = CompletableDeferred() + + fixture.synthesizer.synthesizeBlock = { req -> + if (req.messageId == "msg-1") gate1.await() else gate2.await() + } + + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text") + val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text") + + fixture.controller.toggle(req1) + runCurrent() + + fixture.controller.toggle(req2) + runCurrent() + + // Complete old stale synthesis + val audio1 = FakeAudio("First text") + gate1.complete(audio1) + runCurrent() + + // Complete new synthesis + val audio2 = FakeAudio("Second text") + gate2.complete(audio2) + advanceUntilIdle() + + assertEquals("msg-2", fixture.controller.speakingId.value) + assertEquals(1, fixture.player.playCallCount) + assertEquals(audio2, fixture.player.currentlyPlaying) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `player onEnded callback resets speakingId to null`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + + fixture.controller.toggle(req) + advanceUntilIdle() + assertEquals("msg-1", fixture.controller.speakingId.value) + + val onEnded = fixture.player.lastEndedCallback + onEnded?.invoke() + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `stale onEnded callback from superseded generation does not clear active speakingId`() = + runTest { + val fixture = Fixture(this) + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First") + val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second") + + fixture.controller.toggle(req1) + advanceUntilIdle() + val staleOnEnded = fixture.player.lastEndedCallback + + fixture.controller.toggle(req2) + advanceUntilIdle() + assertEquals("msg-2", fixture.controller.speakingId.value) + + staleOnEnded?.invoke() + advanceUntilIdle() + + // Still speaking msg-2! + assertEquals("msg-2", fixture.controller.speakingId.value) + } + + @Test + fun `stale onError callback from superseded generation is ignored and does not invoke onError`() = + runTest { + val fixture = Fixture(this) + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First") + val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second") + + fixture.controller.toggle(req1) + advanceUntilIdle() + val staleOnError = fixture.player.lastErrorCallback + + fixture.controller.toggle(req2) + advanceUntilIdle() + assertEquals("msg-2", fixture.controller.speakingId.value) + + staleOnError?.invoke() + advanceUntilIdle() + + assertEquals("msg-2", fixture.controller.speakingId.value) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `player onError callback clears speakingId and triggers onError`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + + fixture.controller.toggle(req) + advanceUntilIdle() + assertEquals("msg-1", fixture.controller.speakingId.value) + + val onError = fixture.player.lastErrorCallback + onError?.invoke() + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertEquals(1, fixture.errors.size) + } + + @Test + fun `synthesizer failure triggers onError and clears speakingId`() = + runTest { + val fixture = Fixture(this) + fixture.synthesizer.synthesizeBlock = { + throw IllegalStateException("Synthesis failed") + } + + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + fixture.controller.toggle(req) + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertEquals(1, fixture.errors.size) + assertEquals(0, fixture.player.playCallCount) + } + + @Test + fun `cancellations during synthesis do not trigger onError`() = + runTest { + val fixture = Fixture(this) + val gate = CompletableDeferred() + fixture.synthesizer.synthesizeBlock = { gate.await() } + + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + fixture.controller.toggle(req) + runCurrent() + + // Stop cancels the inflight coroutine + fixture.controller.stop() + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `replaying same request uses cached audio without re-synthesizing and stop preserves cache`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Cached audio") + + fixture.controller.toggle(req) + advanceUntilIdle() + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(1, fixture.player.playCallCount) + + // Stop playback — must preserve replay cache + fixture.controller.stop() + advanceUntilIdle() + assertNull(fixture.controller.speakingId.value) + + // Toggle again with same request -> should replay from cache without re-synthesizing + fixture.controller.toggle(req) + advanceUntilIdle() + + assertEquals("msg-1", fixture.controller.speakingId.value) + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(2, fixture.player.playCallCount) + } + + @Test + fun `request with different text invalidates cache and disposes old audio`() = + runTest { + val fixture = Fixture(this) + val audio1 = FakeAudio("Text 1") + val audio2 = FakeAudio("Text 2") + fixture.synthesizer.synthesizeBlock = { req -> + if (req.text == "Text 1") audio1 else audio2 + } + + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Text 1") + fixture.controller.toggle(req1) + advanceUntilIdle() + assertEquals(audio1, fixture.player.currentlyPlaying) + assertFalse(audio1.isDisposed) + + val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Text 2") + fixture.controller.toggle(req2) + advanceUntilIdle() + + assertEquals(audio2, fixture.player.currentlyPlaying) + assertTrue(audio1.isDisposed) + assertEquals(2, fixture.synthesizer.calls.size) + } + + @Test + fun `request with different scope invalidates cache and disposes old audio`() = + runTest { + val fixture = Fixture(this) + val audio1 = FakeAudio("Scope 1 audio") + val audio2 = FakeAudio("Scope 2 audio") + fixture.synthesizer.synthesizeBlock = { req -> + if (req.scopeKey == "session-1") audio1 else audio2 + } + + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello") + fixture.controller.toggle(req1) + advanceUntilIdle() + assertFalse(audio1.isDisposed) + + val req2 = SpeechRequest(scopeKey = "session-2", messageId = "msg-1", text = "Hello") + fixture.controller.toggle(req2) + advanceUntilIdle() + + assertEquals(audio2, fixture.player.currentlyPlaying) + assertTrue(audio1.isDisposed) + assertEquals(2, fixture.synthesizer.calls.size) + } + + @Test + fun `replaying cached request when player throws does not escape toggle and clears speakingId`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Cached text") + + // Initial successful playback + fixture.controller.toggle(req) + advanceUntilIdle() + assertEquals("msg-1", fixture.controller.speakingId.value) + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(1, fixture.player.playCallCount) + + // Complete initial playback + fixture.player.lastEndedCallback?.invoke() + advanceUntilIdle() + assertNull(fixture.controller.speakingId.value) + assertEquals(0, fixture.errors.size) + + // Configure player to throw synchronously on replay + val stopCallsBeforeReplay = fixture.player.stopCallCount + fixture.player.playThrows = IllegalStateException("Player init failed") + + // Replay exact cached request + fixture.controller.toggle(req) + advanceUntilIdle() + + // Must NOT escape toggle, speakingId null, onError exactly once, + // partially initialized player stopped, no resynthesis + assertNull(fixture.controller.speakingId.value) + assertEquals(1, fixture.errors.size) + assertTrue(fixture.player.stopCallCount > stopCallsBeforeReplay) + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(2, fixture.player.playCallCount) + + // Replay cache is retained: if player recovers, replaying exact request does not re-synthesize + fixture.player.playThrows = null + fixture.controller.toggle(req) + advanceUntilIdle() + + assertEquals("msg-1", fixture.controller.speakingId.value) + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(3, fixture.player.playCallCount) + assertEquals(1, fixture.errors.size) + } + + @Test + fun `initial play when player throws does not escape toggle and clears speakingId`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Initial throw text") + + fixture.player.playThrows = IllegalStateException("Player failed synchronously on first play") + + // Initial noncached play throw + fixture.controller.toggle(req) + advanceUntilIdle() + + // Must NOT escape toggle, speakingId null, onError exactly once, stopped partially initialized player + assertNull(fixture.controller.speakingId.value) + assertEquals(1, fixture.errors.size) + assertTrue(fixture.player.stopCallCount >= 1) + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(1, fixture.player.playCallCount) + + // Replay cache retained: if player recovers, next toggle on exact + // request uses cached audio without re-synthesizing + fixture.player.playThrows = null + fixture.controller.toggle(req) + advanceUntilIdle() + + assertEquals("msg-1", fixture.controller.speakingId.value) + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(2, fixture.player.playCallCount) + assertEquals(1, fixture.errors.size) + } + + @Test + fun `reset clears speakingId, stops player, and disposes cached audio`() = + runTest { + val fixture = Fixture(this) + val audio = FakeAudio("Reset audio") + fixture.synthesizer.synthesizeBlock = { audio } + + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Reset audio") + fixture.controller.toggle(req) + advanceUntilIdle() + assertEquals("msg-1", fixture.controller.speakingId.value) + assertFalse(audio.isDisposed) + + fixture.controller.reset() + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertTrue(audio.isDisposed) + assertTrue(fixture.player.stopCallCount >= 1) + + // Re-toggling same request after reset must re-synthesize because cache was cleared/disposed + val audio2 = FakeAudio("Reset audio 2") + fixture.synthesizer.synthesizeBlock = { audio2 } + fixture.controller.toggle(req) + advanceUntilIdle() + + assertEquals(2, fixture.synthesizer.calls.size) + assertEquals("msg-1", fixture.controller.speakingId.value) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SpeechTextTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SpeechTextTest.kt new file mode 100644 index 00000000..c4c7d4cd --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SpeechTextTest.kt @@ -0,0 +1,250 @@ +package com.m57.hermescontrol.ui.chat.components + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SpeechTextTest { + @Test + fun `blank input returns blank string`() { + assertEquals("", SpeechText.stripMarkdownForSpeech("")) + assertEquals("", SpeechText.stripMarkdownForSpeech(" ")) + assertEquals("", SpeechText.stripMarkdownForSpeech("\n\t\n ")) + } + + @Test + fun `plain text is preserved without changes`() { + val input = "Hello world this is a test" + val result = SpeechText.stripMarkdownForSpeech(input) + assertEquals("Hello world this is a test", result.trim()) + } + + @Test + fun `bold and italic markers are removed keeping prose`() { + val input = "This is **bold** text and *italic* text and ***bold italic*** text" + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("*")) + assertTrue(result.contains("bold")) + assertTrue(result.contains("italic")) + assertTrue(result.contains("bold italic")) + } + + @Test + fun `strikethrough markers are removed keeping inner text`() { + val input = "This is ~~cancelled~~ completed" + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("~")) + assertTrue(result.contains("cancelled")) + assertTrue(result.contains("completed")) + } + + @Test + fun `headers keep title text without hash characters`() { + val input = + """ + # Header 1 + ## Header 2 + ### Header 3 + """.trimIndent() + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("#")) + assertTrue(result.contains("Header 1")) + assertTrue(result.contains("Header 2")) + assertTrue(result.contains("Header 3")) + } + + @Test + fun `inline code keeps code content without backticks`() { + val input = "Run `val my_value = 42` to configure" + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("`")) + assertTrue(result.contains("val my_value = 42")) + assertTrue(result.contains("my_value")) + assertTrue(result.contains("to configure")) + assertFalse(result.contains(" ")) + } + + @Test + fun `fenced code block keeps code body and removes fences and language identifier`() { + val input = + """ + Here is some code: + ```kotlin + fun main() { + println("Hello") + } + ``` + Done. + """.trimIndent() + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("```")) + assertFalse(result.contains("kotlin")) + assertTrue(result.contains("fun main()")) + assertTrue(result.contains("""println("Hello")""")) + assertTrue(result.contains("Here is some code:")) + assertTrue(result.contains("Done.")) + } + + @Test + fun `fenced code block body retains underscores, generics, multiplication, and pipes`() { + val input = + """ + Here is complex code: + ```typescript + const a_b_c: List = items.map(x => x * 2 | 0); + ``` + Done... + """.trimIndent() + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("```")) + assertFalse(result.contains("typescript")) + assertTrue(result.contains("a_b_c")) + assertTrue(result.contains("")) + assertTrue(result.contains("* 2")) + assertTrue(result.contains("| 0")) + assertTrue(result.contains("Here is complex code:")) + assertTrue(result.contains("Done...")) + } + + @Test + fun `markdown links keep label text and strip destination url`() { + val input = "Check out [Hermes Documentation](https://hermes-agent.nousresearch.com/docs) for info" + val result = SpeechText.stripMarkdownForSpeech(input) + assertTrue(result.contains("Hermes Documentation")) + assertFalse(result.contains("https://hermes-agent.nousresearch.com/docs")) + assertFalse(result.contains("[") || result.contains("]")) + } + + @Test + fun `image markdown keeps alt text and strips image url`() { + val input = "Here is a diagram: ![Architecture diagram](https://example.com/arch.png)" + val result = SpeechText.stripMarkdownForSpeech(input) + assertTrue(result.contains("Architecture diagram")) + assertFalse(result.contains("https://example.com/arch.png")) + assertFalse(result.contains("![")) + } + + @Test + fun `bare urls are removed`() { + val input = "Visit https://example.com or http://test.org/path?arg=1 today" + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("https://example.com")) + assertFalse(result.contains("http://test.org/path?arg=1")) + assertTrue(result.contains("Visit")) + assertTrue(result.contains("today")) + } + + @Test + fun `html tags are stripped keeping inner text`() { + val input = "

This is a bold paragraph with a
break.

" + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("<")) + assertFalse(result.contains(">")) + assertTrue(result.contains("This is a")) + assertTrue(result.contains("bold")) + assertTrue(result.contains("paragraph with a")) + assertTrue(result.contains("break.")) + } + + @Test + fun `blockquote markers are removed keeping content`() { + val input = + """ + > This is a quote. + > It spans multiple lines. + """.trimIndent() + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains(">")) + assertTrue(result.contains("This is a quote.")) + assertTrue(result.contains("It spans multiple lines.")) + } + + @Test + fun `unordered and ordered list markers are handled sensibly`() { + val input = + """ + Items: + * First item + * Second item + 1. Step one + 2. Step two + """.trimIndent() + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("*")) + assertFalse(result.contains("1.")) + assertFalse(result.contains("2.")) + assertTrue(result.contains("First item")) + assertTrue(result.contains("Second item")) + assertTrue(result.contains("Step one")) + assertTrue(result.contains("Step two")) + } + + @Test + fun `arabic text is preserved intact`() { + val input = "مرحبا بك في تطبيق هيرمس! **أهلا وسهلا**" + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("*")) + assertTrue(result.contains("مرحبا بك في تطبيق هيرمس!")) + assertTrue(result.contains("أهلا وسهلا")) + } + + @Test + fun `unicode and emoji are preserved`() { + val input = "Hello 👋 world! 🚀 Enjoy the day ☀️" + val result = SpeechText.stripMarkdownForSpeech(input) + assertTrue(result.contains("👋")) + assertTrue(result.contains("🚀")) + assertTrue(result.contains("☀️")) + assertTrue(result.contains("Hello")) + assertTrue(result.contains("world!")) + } + + @Test + fun `punctuation and code punctuation edge cases are handled without double punctuation`() { + val input = "Wait... is that it?! Yes, it is: done." + val result = SpeechText.stripMarkdownForSpeech(input) + assertTrue(result.contains("Wait...")) + assertTrue(result.contains("done.")) + assertFalse(result.contains("..,")) + assertFalse(result.contains(",,")) + } + + @Test + fun `newlines retain punctuation without introducing duplicate or mixed punctuation and normalize whitespace`() { + val input = + """ + Done. + Next! + مرحبا؟ + Again + """.trimIndent() + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains(". .")) + assertFalse(result.contains("!.")) + assertFalse(result.contains("؟.")) + assertFalse(result.contains("\n\n")) + assertTrue(result.contains("Done.")) + assertTrue(result.contains("Next!")) + assertTrue(result.contains("مرحبا؟")) + assertTrue(result.contains("Again")) + } + + @Test + fun `horizontal rules are removed`() { + val input = + """ + Section 1 + --- + Section 2 + *** + Section 3 + """.trimIndent() + val result = SpeechText.stripMarkdownForSpeech(input) + assertTrue(result.contains("Section 1")) + assertTrue(result.contains("Section 2")) + assertTrue(result.contains("Section 3")) + assertFalse(result.contains("---")) + assertFalse(result.contains("***")) + } +}