feat(chat): add per-message read-aloud TTS playback (#1305)

This commit is contained in:
m57 authored and GitHub committed 2026-09-27 19:43:50 +03:00
1 parent 2e0fd46a4c
commit 3c56026a1c
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,
+5
View File
@@ -237,6 +237,11 @@
<string name="content_desc_scroll_to_bottom">Scroll to bottom</string>
<string name="content_desc_copy">Copy</string>
<string name="content_desc_copied">Copied</string>
<string name="content_desc_speak">Read aloud</string>
<string name="content_desc_stop_speaking">Stop speaking</string>
<string name="tts_error_network">Could not reach the Hermes server for speech</string>
<string name="tts_error_synthesis">Could not synthesize the message audio</string>
<string name="tts_error_playback">Could not play the message audio</string>
<string name="content_desc_edit">Edit</string>
<string name="chat_msg_tokens">%1$s tok</string>
<string name="chat_msg_tps">%1$s t/s</string>
@@ -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<TtsSpeakResponse>(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<TtsSpeakResponse>(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<TtsSpeakResponse>(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<TtsSpeakResponse>(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<TtsSpeakResponse>(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<TtsSpeakResponse>(rawJson)
assertFalse(response.ok)
assertNull(response.data_url)
assertNull(response.mime_type)
assertNull(response.provider)
}
}
@@ -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<Context>()
every { mockContext.applicationContext } returns mockContext
every { mockContext.cacheDir } returns tempFolder.root
return mockContext
}
private fun listTempFiles(): List<File> =
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<HermesApiService>()
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<HermesApiService>()
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<HermesApiService>()
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<HermesApiService>()
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<HermesApiService>()
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<HermesApiService>()
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<HermesApiService>()
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<HermesApiService>()
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<HermesApiService>()
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<HermesApiService>()
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)
}
}
@@ -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<SpeechRequest> = 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<Unit>()
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<SpeechAudio>()
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<Unit>()
val resumeGate = CompletableDeferred<Unit>()
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<Unit>()
val resumeGate1 = CompletableDeferred<Unit>()
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<Unit>()
val resumeGate1 = CompletableDeferred<Unit>()
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<Unit>()
val resumeGate1 = CompletableDeferred<Unit>()
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<SpeechAudio>()
val gate2 = CompletableDeferred<SpeechAudio>()
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<SpeechAudio>()
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)
}
}
@@ -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<T> = 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("<T>"))
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 = "<p>This is a <b>bold</b> paragraph with a <br/> break.</p>"
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("***"))
}
}