feat(chat): add per-message read-aloud TTS playback (#1305)
This commit is contained in:
@@ -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.ToolsetProviderSelectRequest
|
||||||
import com.m57.hermescontrol.data.model.ToolsetProviderSelectResponse
|
import com.m57.hermescontrol.data.model.ToolsetProviderSelectResponse
|
||||||
import com.m57.hermescontrol.data.model.ToolsetToggleRequest
|
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.UpdateCheckResponse
|
||||||
import com.m57.hermescontrol.data.model.UpdateCronJobRequest
|
import com.m57.hermescontrol.data.model.UpdateCronJobRequest
|
||||||
import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest
|
import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest
|
||||||
@@ -1043,4 +1045,10 @@ interface HermesApiService : KanbanApiService {
|
|||||||
suspend fun transcribeAudio(
|
suspend fun transcribeAudio(
|
||||||
@Body body: AudioTranscriptionRequest,
|
@Body body: AudioTranscriptionRequest,
|
||||||
): Response<AudioTranscriptionResponse>
|
): 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.LogsScreen
|
||||||
import com.m57.hermescontrol.NavigationController
|
import com.m57.hermescontrol.NavigationController
|
||||||
import com.m57.hermescontrol.R
|
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.Attachment
|
||||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||||
import com.m57.hermescontrol.data.model.BusySendMode
|
import com.m57.hermescontrol.data.model.BusySendMode
|
||||||
import com.m57.hermescontrol.data.model.reasoningSupport
|
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.ConnectionStatus
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.notification.NotificationHelper
|
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.SearchBarRow
|
||||||
import com.m57.hermescontrol.ui.chat.components.SessionIntegrationsSheet
|
import com.m57.hermescontrol.ui.chat.components.SessionIntegrationsSheet
|
||||||
import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet
|
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.SubagentInspectionSheet
|
||||||
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
|
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
|
||||||
import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers
|
import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers
|
||||||
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
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.shouldShowProgressChip
|
||||||
import com.m57.hermescontrol.ui.chat.components.tailContentKey
|
import com.m57.hermescontrol.ui.chat.components.tailContentKey
|
||||||
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
|
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
|
||||||
@@ -249,6 +254,10 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val browserEvent = connectorsState.browserLaunchEvent
|
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 listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy)
|
||||||
val scrollScope = rememberCoroutineScope()
|
val scrollScope = rememberCoroutineScope()
|
||||||
val scrollController = rememberChatScrollController(listState, scrollScope)
|
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||||
@@ -799,6 +808,17 @@ fun ChatScreen(
|
|||||||
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
||||||
openingAttachmentPath = state.openingAttachmentPath,
|
openingAttachmentPath = state.openingAttachmentPath,
|
||||||
onImageClick = { viewingImage = it },
|
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 =
|
replyErrorContent =
|
||||||
state.replyFailure?.takeUnless { timelineState.isHistorical }?.let { failure ->
|
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.layout.size
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material.icons.Icons
|
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.Check
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.Stop
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -65,6 +67,8 @@ internal fun FullBleedAgentMessage(
|
|||||||
openingAttachmentPath: String? = null,
|
openingAttachmentPath: String? = null,
|
||||||
canSaveAttachment: Boolean = true,
|
canSaveAttachment: Boolean = true,
|
||||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
|
isSpeaking: Boolean = false,
|
||||||
|
onToggleSpeak: (() -> Unit)? = null,
|
||||||
messageStatsEnabled: Boolean = false,
|
messageStatsEnabled: Boolean = false,
|
||||||
showAssistantMessageTokens: Boolean = true,
|
showAssistantMessageTokens: Boolean = true,
|
||||||
showTokensPerSecond: Boolean = true,
|
showTokensPerSecond: Boolean = true,
|
||||||
@@ -162,6 +166,31 @@ internal fun FullBleedAgentMessage(
|
|||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
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) {
|
if (showTokenStat) {
|
||||||
AssistantStatItem(
|
AssistantStatItem(
|
||||||
value =
|
value =
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ fun FullBleedChatList(
|
|||||||
pagingSessionId: String? = null,
|
pagingSessionId: String? = null,
|
||||||
onLoadOlder: () -> Unit = viewModel::loadOlderMessages,
|
onLoadOlder: () -> Unit = viewModel::loadOlderMessages,
|
||||||
replyErrorContent: (@Composable () -> Unit)? = null,
|
replyErrorContent: (@Composable () -> Unit)? = null,
|
||||||
|
speakingMessageId: String? = null,
|
||||||
|
onToggleSpeak: ((ChatMessage) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) {
|
if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) {
|
||||||
Box(
|
Box(
|
||||||
@@ -363,6 +365,15 @@ fun FullBleedChatList(
|
|||||||
showReasoning = !hoistedReasoning,
|
showReasoning = !hoistedReasoning,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
val isSpeakingThis =
|
||||||
|
speakingMessageId != null &&
|
||||||
|
speakingMessageId == proseMessage.id
|
||||||
|
val toggleSpeakAction =
|
||||||
|
if (onToggleSpeak != null) {
|
||||||
|
{ onToggleSpeak(proseMessage) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
FullBleedAgentMessage(
|
FullBleedAgentMessage(
|
||||||
message = proseMessage,
|
message = proseMessage,
|
||||||
// Highlight only bubbles that actually contain a match —
|
// Highlight only bubbles that actually contain a match —
|
||||||
@@ -385,6 +396,8 @@ fun FullBleedChatList(
|
|||||||
openingAttachmentPath = openingAttachmentPath,
|
openingAttachmentPath = openingAttachmentPath,
|
||||||
canSaveAttachment = savingAttachmentPath == null,
|
canSaveAttachment = savingAttachmentPath == null,
|
||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
|
isSpeaking = isSpeakingThis,
|
||||||
|
onToggleSpeak = toggleSpeakAction,
|
||||||
messageStatsEnabled = messageStatsEnabled,
|
messageStatsEnabled = messageStatsEnabled,
|
||||||
showAssistantMessageTokens = showAssistantMessageTokens,
|
showAssistantMessageTokens = showAssistantMessageTokens,
|
||||||
showTokensPerSecond = showTokensPerSecond,
|
showTokensPerSecond = showTokensPerSecond,
|
||||||
|
|||||||
@@ -237,6 +237,11 @@
|
|||||||
<string name="content_desc_scroll_to_bottom">Scroll to bottom</string>
|
<string name="content_desc_scroll_to_bottom">Scroll to bottom</string>
|
||||||
<string name="content_desc_copy">Copy</string>
|
<string name="content_desc_copy">Copy</string>
|
||||||
<string name="content_desc_copied">Copied</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="content_desc_edit">Edit</string>
|
||||||
<string name="chat_msg_tokens">%1$s tok</string>
|
<string name="chat_msg_tokens">%1$s tok</string>
|
||||||
<string name="chat_msg_tps">%1$s t/s</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: "
|
||||||
|
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("***"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user