feat(chat): add per-message read-aloud TTS playback (#1305)
This commit is contained in:
14 files changed
+2370
No files matched your search
@@ -0,0 +1,26 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Request body for `POST /api/audio/speak` — the dashboard's server-side
|
||||
* text-to-speech relay.
|
||||
*/
|
||||
@Serializable
|
||||
data class TtsSpeakRequest(
|
||||
val text: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Response of `POST /api/audio/speak`.
|
||||
*
|
||||
* Returns base64 encoded [data_url] with [mime_type] and the [provider] used,
|
||||
* or [ok] = false on failure.
|
||||
*/
|
||||
@Serializable
|
||||
data class TtsSpeakResponse(
|
||||
val ok: Boolean = false,
|
||||
val data_url: String? = null,
|
||||
val mime_type: String? = null,
|
||||
val provider: String? = null,
|
||||
)
|
||||
@@ -134,6 +134,8 @@ import com.m57.hermescontrol.data.model.ToolsetPostSetupResponse
|
||||
import com.m57.hermescontrol.data.model.ToolsetProviderSelectRequest
|
||||
import com.m57.hermescontrol.data.model.ToolsetProviderSelectResponse
|
||||
import com.m57.hermescontrol.data.model.ToolsetToggleRequest
|
||||
import com.m57.hermescontrol.data.model.TtsSpeakRequest
|
||||
import com.m57.hermescontrol.data.model.TtsSpeakResponse
|
||||
import com.m57.hermescontrol.data.model.UpdateCheckResponse
|
||||
import com.m57.hermescontrol.data.model.UpdateCronJobRequest
|
||||
import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest
|
||||
@@ -1043,4 +1045,10 @@ interface HermesApiService : KanbanApiService {
|
||||
suspend fun transcribeAudio(
|
||||
@Body body: AudioTranscriptionRequest,
|
||||
): Response<AudioTranscriptionResponse>
|
||||
|
||||
@POST("api/audio/speak")
|
||||
suspend fun speakText(
|
||||
@Body request: TtsSpeakRequest,
|
||||
@Query("profile") profile: String? = null,
|
||||
): Response<TtsSpeakResponse>
|
||||
}
|
||||
@@ -84,10 +84,12 @@ import com.m57.hermescontrol.HistoryScreen
|
||||
import com.m57.hermescontrol.LogsScreen
|
||||
import com.m57.hermescontrol.NavigationController
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.data.model.reasoningSupport
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.notification.NotificationHelper
|
||||
@@ -111,10 +113,13 @@ import com.m57.hermescontrol.ui.chat.components.ReplyErrorCard
|
||||
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
|
||||
import com.m57.hermescontrol.ui.chat.components.SessionIntegrationsSheet
|
||||
import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet
|
||||
import com.m57.hermescontrol.ui.chat.components.SpeechRequest
|
||||
import com.m57.hermescontrol.ui.chat.components.SpeechText
|
||||
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
||||
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatSpeech
|
||||
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
|
||||
import com.m57.hermescontrol.ui.chat.components.tailContentKey
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
|
||||
@@ -249,6 +254,10 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
val browserEvent = connectorsState.browserLaunchEvent
|
||||
val speechController = rememberChatSpeech()
|
||||
val speakingMessageId by speechController.speakingId.collectAsStateWithLifecycle()
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
val activeSessionId by ActiveSessionHolder.activeSessionId.collectAsStateWithLifecycle()
|
||||
val listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy)
|
||||
val scrollScope = rememberCoroutineScope()
|
||||
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||
@@ -799,6 +808,17 @@ fun ChatScreen(
|
||||
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
||||
openingAttachmentPath = state.openingAttachmentPath,
|
||||
onImageClick = { viewingImage = it },
|
||||
speakingMessageId = speakingMessageId,
|
||||
onToggleSpeak = { message ->
|
||||
val scopeKey = "${dataScope?.inMemoryKey(localKey = activeSessionId ?: "none")}"
|
||||
speechController.toggle(
|
||||
SpeechRequest(
|
||||
scopeKey = scopeKey,
|
||||
messageId = message.id,
|
||||
text = SpeechText.stripMarkdownForSpeech(message.content),
|
||||
),
|
||||
)
|
||||
},
|
||||
replyErrorContent =
|
||||
state.replyFailure?.takeUnless { timelineState.isHistorical }?.let { failure ->
|
||||
{
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import com.m57.hermescontrol.data.model.TtsSpeakRequest
|
||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.Base64
|
||||
import java.util.UUID
|
||||
|
||||
private const val TTS_CACHE_DIR_NAME = "speech_tts"
|
||||
private const val TTS_FILE_PREFIX = "tts_"
|
||||
private const val TTS_STALE_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000L // 24 hours
|
||||
|
||||
class FileSpeechAudio(
|
||||
val file: File,
|
||||
) : SpeechAudio {
|
||||
override fun dispose() {
|
||||
runCatching { file.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Android implementation of [SpeechSynthesizer] backed by Hermes TTS REST API.
|
||||
*
|
||||
* Audio is decoded from base64 data URLs directly to temporary files in application cache.
|
||||
* Stale orphan cache files (created in previous sessions or left behind by unexpected crashes)
|
||||
* older than [TTS_STALE_CACHE_MAX_AGE_MS] are opportunistically pruned during synthesis on the
|
||||
* IO dispatcher before creating new files.
|
||||
*
|
||||
* Limitation note: Single-active controller usage is expected. Active audio files in current use
|
||||
* by the active session are protected from cleanup by the age threshold, but concurrent multi-process
|
||||
* or out-of-band manipulation of cache files is not synchronized.
|
||||
*/
|
||||
class AndroidSpeechSynthesizer(
|
||||
context: Context,
|
||||
private val api: HermesApiService,
|
||||
private val profile: String?,
|
||||
) : SpeechSynthesizer {
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
override suspend fun synthesize(request: SpeechRequest): SpeechAudio {
|
||||
val response =
|
||||
api.speakText(
|
||||
request = TtsSpeakRequest(request.text),
|
||||
profile = profile,
|
||||
)
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("TTS request failed with code ${response.code()}")
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw IOException("Empty TTS response body")
|
||||
if (!body.ok) {
|
||||
throw IOException("TTS synthesis reported failure")
|
||||
}
|
||||
|
||||
val dataUrl = body.data_url
|
||||
if (dataUrl.isNullOrBlank()) {
|
||||
throw IOException("Missing data URL in TTS response")
|
||||
}
|
||||
|
||||
var ownedFile: File? = null
|
||||
try {
|
||||
val audioFile =
|
||||
withContext(Dispatchers.IO) {
|
||||
val comma = dataUrl.indexOf(',')
|
||||
if (comma < 0) {
|
||||
throw IOException("Malformed data URL in TTS response")
|
||||
}
|
||||
|
||||
val meta = dataUrl.substring(0, comma)
|
||||
if (!meta.startsWith("data:audio/", ignoreCase = true)) {
|
||||
throw IOException("Expected audio data URL")
|
||||
}
|
||||
if (!meta.endsWith(";base64", ignoreCase = true)) {
|
||||
throw IOException("Expected base64-encoded audio data URL")
|
||||
}
|
||||
|
||||
val ext =
|
||||
when {
|
||||
meta.contains(
|
||||
"audio/mpeg",
|
||||
ignoreCase = true,
|
||||
) || meta.contains("audio/mp3", ignoreCase = true) -> "mp3"
|
||||
|
||||
meta.contains("audio/wav", ignoreCase = true) -> "wav"
|
||||
|
||||
meta.contains("audio/ogg", ignoreCase = true) -> "ogg"
|
||||
|
||||
meta.contains("audio/aac", ignoreCase = true) -> "aac"
|
||||
|
||||
meta.contains("audio/opus", ignoreCase = true) -> "opus"
|
||||
|
||||
meta.contains("audio/flac", ignoreCase = true) -> "flac"
|
||||
|
||||
else -> "audio"
|
||||
}
|
||||
|
||||
val cacheDir = File(appContext.cacheDir, TTS_CACHE_DIR_NAME).apply { mkdirs() }
|
||||
cleanStaleCacheFiles(cacheDir)
|
||||
|
||||
val file = File(cacheDir, "${TTS_FILE_PREFIX}${UUID.randomUUID()}.$ext")
|
||||
ownedFile = file
|
||||
|
||||
val rawStream = CharSequenceInputStream(dataUrl, comma + 1)
|
||||
val decodingStream = Base64.getDecoder().wrap(rawStream)
|
||||
|
||||
var totalBytesWritten = 0L
|
||||
decodingStream.use { input ->
|
||||
FileOutputStream(file).use { output ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var bytesRead: Int
|
||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
output.write(buffer, 0, bytesRead)
|
||||
totalBytesWritten += bytesRead
|
||||
}
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Java's Base64.getDecoder().wrap() stops decoding upon encountering padding ('=').
|
||||
// Verify that the underlying stream contains no unparsed trailing non-whitespace characters.
|
||||
if (rawStream.read() != -1) {
|
||||
throw IOException("Trailing garbage characters found after base64 data")
|
||||
}
|
||||
|
||||
if (totalBytesWritten == 0L) {
|
||||
throw IOException("Decoded audio file is empty")
|
||||
}
|
||||
|
||||
file
|
||||
}
|
||||
|
||||
ownedFile = null
|
||||
return FileSpeechAudio(audioFile)
|
||||
} catch (e: CancellationException) {
|
||||
ownedFile?.let { runCatching { it.delete() } }
|
||||
throw e
|
||||
} catch (t: Throwable) {
|
||||
ownedFile?.let { runCatching { it.delete() } }
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanStaleCacheFiles(cacheDir: File) {
|
||||
val now = System.currentTimeMillis()
|
||||
val files =
|
||||
cacheDir.listFiles { _, name ->
|
||||
name.startsWith(TTS_FILE_PREFIX)
|
||||
} ?: return
|
||||
|
||||
for (file in files) {
|
||||
if (now - file.lastModified() > TTS_STALE_CACHE_MAX_AGE_MS) {
|
||||
runCatching { file.delete() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class CharSequenceInputStream(
|
||||
private val value: CharSequence,
|
||||
startIndex: Int,
|
||||
) : InputStream() {
|
||||
private var index = startIndex
|
||||
|
||||
override fun read(): Int {
|
||||
while (index < value.length) {
|
||||
val c = value[index++]
|
||||
if (!c.isWhitespace()) {
|
||||
if (c.code > 127) {
|
||||
throw IOException("Non-ASCII character in base64 data")
|
||||
}
|
||||
return c.code
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
override fun read(
|
||||
b: ByteArray,
|
||||
off: Int,
|
||||
len: Int,
|
||||
): Int {
|
||||
if (len == 0) return 0
|
||||
var bytesRead = 0
|
||||
while (bytesRead < len) {
|
||||
val c = read()
|
||||
if (c == -1) {
|
||||
return if (bytesRead == 0) -1 else bytesRead
|
||||
}
|
||||
b[off + bytesRead] = c.toByte()
|
||||
bytesRead++
|
||||
}
|
||||
return bytesRead
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AndroidSpeechPlayer(
|
||||
context: Context,
|
||||
) : SpeechPlayer {
|
||||
private val appContext = context.applicationContext
|
||||
private var exoPlayer: ExoPlayer? = null
|
||||
private var activeListener: Player.Listener? = null
|
||||
|
||||
override fun play(
|
||||
audio: SpeechAudio,
|
||||
onEnded: () -> Unit,
|
||||
onError: () -> Unit,
|
||||
) {
|
||||
if (audio !is FileSpeechAudio) {
|
||||
onError()
|
||||
return
|
||||
}
|
||||
|
||||
val file = audio.file
|
||||
if (!file.exists() || file.length() == 0L) {
|
||||
onError()
|
||||
return
|
||||
}
|
||||
|
||||
val player =
|
||||
exoPlayer ?: ExoPlayer
|
||||
.Builder(appContext)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes
|
||||
.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_SPEECH)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build(),
|
||||
true,
|
||||
).setHandleAudioBecomingNoisy(true)
|
||||
.build()
|
||||
.also { exoPlayer = it }
|
||||
|
||||
activeListener?.let { player.removeListener(it) }
|
||||
|
||||
val listener =
|
||||
object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
player.removeListener(this)
|
||||
activeListener = null
|
||||
onEnded()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
player.removeListener(this)
|
||||
activeListener = null
|
||||
onError()
|
||||
}
|
||||
}
|
||||
|
||||
activeListener = listener
|
||||
player.addListener(listener)
|
||||
|
||||
val mediaItem = MediaItem.fromUri(Uri.fromFile(file))
|
||||
player.setMediaItem(mediaItem)
|
||||
player.prepare()
|
||||
player.playWhenReady = true
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
val player = exoPlayer ?: return
|
||||
activeListener?.let { player.removeListener(it) }
|
||||
activeListener = null
|
||||
player.stop()
|
||||
player.clearMediaItems()
|
||||
player.release()
|
||||
exoPlayer = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
|
||||
/**
|
||||
* Creates and remembers a screen/list-level [SpeechController] scoped to the active
|
||||
* chat session, server endpoint, profile, and auth state.
|
||||
*
|
||||
* Handles lifecycle events:
|
||||
* - Resets (stops playback and cleans cache) on [Lifecycle.Event.ON_STOP]
|
||||
* - Resets on composition disposal or when the server endpoint, profile, auth state, or session changes
|
||||
*/
|
||||
@Composable
|
||||
fun rememberChatSpeech(
|
||||
synthesizer: SpeechSynthesizer? = null,
|
||||
player: SpeechPlayer? = null,
|
||||
onError: (() -> Unit)? = null,
|
||||
): SpeechController {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val errorSynthesisMessage = stringResource(R.string.tts_error_synthesis)
|
||||
|
||||
val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle()
|
||||
val activeSessionId by ActiveSessionHolder.activeSessionId.collectAsStateWithLifecycle()
|
||||
|
||||
val profile = dataScope?.activeProfileId
|
||||
val scopeKey = dataScope?.inMemoryKey(localKey = activeSessionId ?: "none").orEmpty()
|
||||
|
||||
val resolvedSynthesizer =
|
||||
remember(synthesizer, dataScope) {
|
||||
synthesizer ?: AndroidSpeechSynthesizer(
|
||||
context = context,
|
||||
api = ApiClient.hermesApi,
|
||||
profile = profile,
|
||||
)
|
||||
}
|
||||
|
||||
val resolvedPlayer =
|
||||
remember(player, context) {
|
||||
player ?: AndroidSpeechPlayer(context)
|
||||
}
|
||||
|
||||
val controller =
|
||||
remember(scope, resolvedSynthesizer, resolvedPlayer, errorSynthesisMessage) {
|
||||
SpeechController(
|
||||
scope = scope,
|
||||
synthesizer = resolvedSynthesizer,
|
||||
player = resolvedPlayer,
|
||||
onError = {
|
||||
onError?.invoke() ?: Toast
|
||||
.makeText(
|
||||
context,
|
||||
errorSynthesisMessage,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Reset when session scope changes (session/profile/server/auth switch)
|
||||
DisposableEffect(controller, scopeKey) {
|
||||
onDispose {
|
||||
controller.reset()
|
||||
}
|
||||
}
|
||||
|
||||
// Reset on ON_STOP lifecycle event
|
||||
DisposableEffect(lifecycleOwner, controller) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP) {
|
||||
controller.reset()
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
controller.reset()
|
||||
}
|
||||
}
|
||||
|
||||
return controller
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class SpeechRequest(
|
||||
val scopeKey: String,
|
||||
val messageId: String,
|
||||
val text: String,
|
||||
)
|
||||
|
||||
interface SpeechAudio {
|
||||
fun dispose()
|
||||
}
|
||||
|
||||
interface SpeechSynthesizer {
|
||||
suspend fun synthesize(request: SpeechRequest): SpeechAudio
|
||||
}
|
||||
|
||||
interface SpeechPlayer {
|
||||
fun play(
|
||||
audio: SpeechAudio,
|
||||
onEnded: () -> Unit,
|
||||
onError: () -> Unit,
|
||||
)
|
||||
|
||||
fun stop()
|
||||
}
|
||||
|
||||
class SpeechController(
|
||||
private val scope: CoroutineScope,
|
||||
private val synthesizer: SpeechSynthesizer,
|
||||
private val player: SpeechPlayer,
|
||||
private val onError: () -> Unit,
|
||||
) {
|
||||
private val _speakingId = MutableStateFlow<String?>(null)
|
||||
val speakingId: StateFlow<String?> = _speakingId.asStateFlow()
|
||||
|
||||
private var activeRequest: SpeechRequest? = null
|
||||
private var activeJob: Job? = null
|
||||
private var generation: Long = 0L
|
||||
|
||||
private var cachedRequest: SpeechRequest? = null
|
||||
private var cachedAudio: SpeechAudio? = null
|
||||
|
||||
fun toggle(request: SpeechRequest) {
|
||||
if (request.text.isBlank()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (activeRequest == request) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
start(request)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
generation++
|
||||
activeJob?.cancel()
|
||||
activeJob = null
|
||||
activeRequest = null
|
||||
player.stop()
|
||||
_speakingId.value = null
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
stop()
|
||||
disposeCache()
|
||||
}
|
||||
|
||||
private fun start(request: SpeechRequest) {
|
||||
generation++
|
||||
val currentGen = generation
|
||||
|
||||
activeJob?.cancel()
|
||||
player.stop()
|
||||
|
||||
if (cachedRequest != null && cachedRequest != request) {
|
||||
disposeCache()
|
||||
}
|
||||
|
||||
activeRequest = request
|
||||
_speakingId.value = request.messageId
|
||||
|
||||
val cached = cachedAudio
|
||||
if (cached != null && cachedRequest == request) {
|
||||
playAudio(cached, currentGen)
|
||||
return
|
||||
}
|
||||
|
||||
activeJob =
|
||||
scope.launch {
|
||||
var synthesizedAudio: SpeechAudio? = null
|
||||
try {
|
||||
val audio = synthesizer.synthesize(request)
|
||||
synthesizedAudio = audio
|
||||
|
||||
if (generation != currentGen) {
|
||||
audio.dispose()
|
||||
return@launch
|
||||
}
|
||||
|
||||
cachedAudio?.dispose()
|
||||
cachedRequest = request
|
||||
cachedAudio = audio
|
||||
synthesizedAudio = null
|
||||
|
||||
playAudio(audio, currentGen)
|
||||
} catch (e: CancellationException) {
|
||||
synthesizedAudio?.dispose()
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
synthesizedAudio?.dispose()
|
||||
if (generation == currentGen) {
|
||||
handlePlaybackFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun playAudio(
|
||||
audio: SpeechAudio,
|
||||
gen: Long,
|
||||
) {
|
||||
try {
|
||||
var completed = false
|
||||
player.play(
|
||||
audio = audio,
|
||||
onEnded = {
|
||||
if (generation == gen && !completed) {
|
||||
completed = true
|
||||
handlePlaybackFinished()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
if (generation == gen && !completed) {
|
||||
completed = true
|
||||
handlePlaybackFailure()
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (generation == gen) {
|
||||
handlePlaybackFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePlaybackFinished() {
|
||||
activeJob = null
|
||||
activeRequest = null
|
||||
player.stop()
|
||||
_speakingId.value = null
|
||||
}
|
||||
|
||||
private fun handlePlaybackFailure() {
|
||||
activeJob = null
|
||||
activeRequest = null
|
||||
player.stop()
|
||||
_speakingId.value = null
|
||||
onError()
|
||||
}
|
||||
|
||||
private fun disposeCache() {
|
||||
cachedAudio?.dispose()
|
||||
cachedAudio = null
|
||||
cachedRequest = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
/**
|
||||
* Plain-text preparation for per-message read-aloud (TTS speak button).
|
||||
*
|
||||
* Pure Kotlin — no Android imports — so it stays unit-testable on the JVM.
|
||||
* Strips Markdown formatting while preserving readable prose, inline code,
|
||||
* fenced code contents, link titles/alt texts, and Unicode/emojis.
|
||||
*/
|
||||
object SpeechText {
|
||||
// Fenced code blocks: ``` or ~~~ with optional info string, spanning to closing fence or EOF.
|
||||
private val FENCED_CODE_BLOCK_REGEX =
|
||||
Regex("(?m)^[ \\t]*(`{3,}|~{3,})[^\\r\\n`~]*\\r?\\n([\\s\\S]*?)(?:^[ \\t]*\\1[ \\t]*(?:\\r?\\n|\\Z)|\\Z)")
|
||||
|
||||
// Placeholders with null byte to guarantee safety from standard markdown text
|
||||
private const val CODE_SPAN_PREFIX = "\u0000HERMESCODE"
|
||||
private const val CODE_SPAN_SUFFIX = "\u0000"
|
||||
|
||||
// Inline code: `code` or multiple backticks ``code``
|
||||
private val INLINE_CODE_REGEX = Regex("(`+)(.+?)\\1")
|
||||
|
||||
// Markdown structures
|
||||
private val IMAGE_LINK_REGEX = Regex("!\\[([^\\]]*)\\]\\([^)]*\\)")
|
||||
private val LINK_REGEX = Regex("\\[([^\\]]*)\\]\\([^)]*\\)")
|
||||
private val URL_REGEX = Regex("https?://\\S+")
|
||||
private val HTML_TAG_REGEX = Regex("<[^>]+>")
|
||||
|
||||
// Block elements (multiline)
|
||||
private val HEADING_REGEX = Regex("(?m)^\\s{0,3}#{1,6}\\s+")
|
||||
private val BLOCKQUOTE_REGEX = Regex("(?m)^\\s*>\\s?")
|
||||
private val LIST_MARKER_REGEX = Regex("(?m)^\\s*(?:[-*+]|\\d+[.)])\\s+")
|
||||
private val HORIZONTAL_RULE_REGEX = Regex("(?m)^\\s*([-_*])(?:\\s*\\1){2,}\\s*$")
|
||||
|
||||
// Emphasis / strikethrough markers
|
||||
private val BOLD_REGEX = Regex("(\\*\\*|__)(.*?)\\1")
|
||||
private val ITALIC_REGEX = Regex("(\\*|_)(.*?)\\1")
|
||||
private val STRIKETHROUGH_REGEX = Regex("~~(.*?)~~")
|
||||
|
||||
// Punctuation set including Arabic question mark (\u061F) and semicolon (\u061B), and standard ones
|
||||
// Punctuations that can end a sentence or pause: . , ! ? ; : ؟ ؛
|
||||
private const val PUNCTUATION_CLASS = "[.,!?;:\\u061F\\u061B]"
|
||||
private val PUNCT_BEFORE_NEWLINE_REGEX = Regex("($PUNCTUATION_CLASS)[^\\S\\r\\n]*(?:\\r?\\n[^\\S\\r\\n]*)+")
|
||||
private val PLAIN_NEWLINE_REGEX = Regex("[^\\S\\r\\n]*(?:\\r?\\n[^\\S\\r\\n]*)+")
|
||||
private val WHITESPACE_REGEX = Regex("\\s+")
|
||||
private val SPACE_BEFORE_PUNCT_REGEX = Regex("\\s+($PUNCTUATION_CLASS)")
|
||||
|
||||
fun stripMarkdownForSpeech(text: String): String {
|
||||
if (text.isBlank()) return ""
|
||||
|
||||
val codeSpans = mutableListOf<String>()
|
||||
|
||||
// 1. Protect fenced code blocks before prose processing.
|
||||
// Delimiters and info string are stripped; code content whitespace is normalized for speech.
|
||||
var out =
|
||||
FENCED_CODE_BLOCK_REGEX.replace(text) { matchResult ->
|
||||
val rawContent = matchResult.groupValues[2]
|
||||
val normalizedContent =
|
||||
rawContent
|
||||
.trim()
|
||||
.split(
|
||||
WHITESPACE_REGEX,
|
||||
).filter { it.isNotEmpty() }
|
||||
.joinToString(" ")
|
||||
val index = codeSpans.size
|
||||
codeSpans.add(normalizedContent)
|
||||
"\n$CODE_SPAN_PREFIX$index$CODE_SPAN_SUFFIX\n"
|
||||
}
|
||||
|
||||
// 2. Protect inline code spans before prose processing.
|
||||
out =
|
||||
INLINE_CODE_REGEX.replace(out) { matchResult ->
|
||||
val content = matchResult.groupValues[2].trim()
|
||||
val index = codeSpans.size
|
||||
codeSpans.add(content)
|
||||
"$CODE_SPAN_PREFIX$index$CODE_SPAN_SUFFIX"
|
||||
}
|
||||
|
||||
// 3. Process prose markdown elements
|
||||
// Images: keep alt text, drop the URL.
|
||||
out = out.replace(IMAGE_LINK_REGEX, "$1")
|
||||
// Links: keep visible text, drop the target.
|
||||
out = out.replace(LINK_REGEX, "$1")
|
||||
// Autolinks / bare URLs: drop the URL, keep surrounding prose.
|
||||
out = out.replace(URL_REGEX, " ")
|
||||
// HTML tags.
|
||||
out = out.replace(HTML_TAG_REGEX, " ")
|
||||
// Headings, blockquotes, list markers, horizontal rules.
|
||||
out = out.replace(HEADING_REGEX, "")
|
||||
out = out.replace(BLOCKQUOTE_REGEX, "")
|
||||
out = out.replace(LIST_MARKER_REGEX, "")
|
||||
out = out.replace(HORIZONTAL_RULE_REGEX, " ")
|
||||
// Table pipes
|
||||
out = out.replace("|", " ")
|
||||
// Emphasis / strikethrough markers
|
||||
out = out.replace(BOLD_REGEX, "$2")
|
||||
out = out.replace(ITALIC_REGEX, "$2")
|
||||
out = out.replace(STRIKETHROUGH_REGEX, "$1")
|
||||
|
||||
// 4. Normalise newlines into sensible pauses without accidental global punctuation mutation.
|
||||
// If line ends with punctuation, keep it with a trailing space.
|
||||
// Otherwise, replace newline with ". " to create a pause.
|
||||
out = out.replace(PUNCT_BEFORE_NEWLINE_REGEX, "$1 ")
|
||||
out = out.replace(PLAIN_NEWLINE_REGEX, ". ")
|
||||
|
||||
// 5. Restore protected code spans (both fenced and inline)
|
||||
for ((index, code) in codeSpans.withIndex()) {
|
||||
out = out.replace("$CODE_SPAN_PREFIX$index$CODE_SPAN_SUFFIX", code)
|
||||
}
|
||||
|
||||
// 6. Collapse multiple whitespaces and trim
|
||||
out = out.replace(WHITESPACE_REGEX, " ").trim()
|
||||
|
||||
// 7. Tidy spaces before punctuation left behind by marker removal.
|
||||
out = out.replace(SPACE_BEFORE_PUNCT_REGEX, "$1")
|
||||
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,10 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -65,6 +67,8 @@ internal fun FullBleedAgentMessage(
|
||||
openingAttachmentPath: String? = null,
|
||||
canSaveAttachment: Boolean = true,
|
||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||
isSpeaking: Boolean = false,
|
||||
onToggleSpeak: (() -> Unit)? = null,
|
||||
messageStatsEnabled: Boolean = false,
|
||||
showAssistantMessageTokens: Boolean = true,
|
||||
showTokensPerSecond: Boolean = true,
|
||||
@@ -162,6 +166,31 @@ internal fun FullBleedAgentMessage(
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (onToggleSpeak != null) {
|
||||
IconButton(
|
||||
onClick = onToggleSpeak,
|
||||
modifier = Modifier.size(28.dp).testTag("fullbleed_speak"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (isSpeaking) {
|
||||
Icons.Filled.Stop
|
||||
} else {
|
||||
Icons.AutoMirrored.Filled.VolumeUp
|
||||
},
|
||||
contentDescription =
|
||||
stringResource(
|
||||
if (isSpeaking) {
|
||||
R.string.content_desc_stop_speaking
|
||||
} else {
|
||||
R.string.content_desc_speak
|
||||
},
|
||||
),
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showTokenStat) {
|
||||
AssistantStatItem(
|
||||
value =
|
||||
|
||||
@@ -105,6 +105,8 @@ fun FullBleedChatList(
|
||||
pagingSessionId: String? = null,
|
||||
onLoadOlder: () -> Unit = viewModel::loadOlderMessages,
|
||||
replyErrorContent: (@Composable () -> Unit)? = null,
|
||||
speakingMessageId: String? = null,
|
||||
onToggleSpeak: ((ChatMessage) -> Unit)? = null,
|
||||
) {
|
||||
if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) {
|
||||
Box(
|
||||
@@ -363,6 +365,15 @@ fun FullBleedChatList(
|
||||
showReasoning = !hoistedReasoning,
|
||||
)
|
||||
} else {
|
||||
val isSpeakingThis =
|
||||
speakingMessageId != null &&
|
||||
speakingMessageId == proseMessage.id
|
||||
val toggleSpeakAction =
|
||||
if (onToggleSpeak != null) {
|
||||
{ onToggleSpeak(proseMessage) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
FullBleedAgentMessage(
|
||||
message = proseMessage,
|
||||
// Highlight only bubbles that actually contain a match —
|
||||
@@ -385,6 +396,8 @@ fun FullBleedChatList(
|
||||
openingAttachmentPath = openingAttachmentPath,
|
||||
canSaveAttachment = savingAttachmentPath == null,
|
||||
onImageClick = onImageClick,
|
||||
isSpeaking = isSpeakingThis,
|
||||
onToggleSpeak = toggleSpeakAction,
|
||||
messageStatsEnabled = messageStatsEnabled,
|
||||
showAssistantMessageTokens = showAssistantMessageTokens,
|
||||
showTokensPerSecond = showTokensPerSecond,
|
||||
|
||||
@@ -237,6 +237,11 @@
|
||||
<string name="content_desc_scroll_to_bottom">Scroll to bottom</string>
|
||||
<string name="content_desc_copy">Copy</string>
|
||||
<string name="content_desc_copied">Copied</string>
|
||||
<string name="content_desc_speak">Read aloud</string>
|
||||
<string name="content_desc_stop_speaking">Stop speaking</string>
|
||||
<string name="tts_error_network">Could not reach the Hermes server for speech</string>
|
||||
<string name="tts_error_synthesis">Could not synthesize the message audio</string>
|
||||
<string name="tts_error_playback">Could not play the message audio</string>
|
||||
<string name="content_desc_edit">Edit</string>
|
||||
<string name="chat_msg_tokens">%1$s tok</string>
|
||||
<string name="chat_msg_tps">%1$s t/s</string>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TtsTest {
|
||||
private val json =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `serialize TtsSpeakRequest with literal text`() {
|
||||
val request = TtsSpeakRequest(text = "Hello, world!")
|
||||
val encoded = json.encodeToString(request)
|
||||
|
||||
assertEquals("""{"text":"Hello, world!"}""", encoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize TtsSpeakResponse with all fields present`() {
|
||||
val rawJson =
|
||||
"""
|
||||
{
|
||||
"ok": true,
|
||||
"data_url": "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=",
|
||||
"mime_type": "audio/wav",
|
||||
"provider": "piper"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
|
||||
|
||||
assertTrue(response.ok)
|
||||
assertEquals(
|
||||
"data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=",
|
||||
response.data_url,
|
||||
)
|
||||
assertEquals("audio/wav", response.mime_type)
|
||||
assertEquals("piper", response.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize TtsSpeakResponse with optional provider missing`() {
|
||||
val rawJson =
|
||||
"""
|
||||
{
|
||||
"ok": true,
|
||||
"data_url": "data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/",
|
||||
"mime_type": "audio/mp3"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
|
||||
|
||||
assertTrue(response.ok)
|
||||
assertEquals(
|
||||
"data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/",
|
||||
response.data_url,
|
||||
)
|
||||
assertEquals("audio/mp3", response.mime_type)
|
||||
assertNull(response.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize TtsSpeakResponse with provider explicitly null`() {
|
||||
val rawJson =
|
||||
"""
|
||||
{
|
||||
"ok": true,
|
||||
"data_url": "data:audio/ogg;base64,T2dnUwACAAAAAAAAAAA=",
|
||||
"mime_type": "audio/ogg",
|
||||
"provider": null
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
|
||||
|
||||
assertTrue(response.ok)
|
||||
assertEquals("data:audio/ogg;base64,T2dnUwACAAAAAAAAAAA=", response.data_url)
|
||||
assertEquals("audio/ogg", response.mime_type)
|
||||
assertNull(response.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize TtsSpeakResponse with missing ok defaults to false`() {
|
||||
val rawJson =
|
||||
"""
|
||||
{
|
||||
"data_url": null,
|
||||
"mime_type": null
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
|
||||
|
||||
assertFalse(response.ok)
|
||||
assertNull(response.data_url)
|
||||
assertNull(response.mime_type)
|
||||
assertNull(response.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize TtsSpeakResponse with empty json defaults to ok false and nulls`() {
|
||||
val rawJson = "{}"
|
||||
|
||||
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
|
||||
|
||||
assertFalse(response.ok)
|
||||
assertNull(response.data_url)
|
||||
assertNull(response.mime_type)
|
||||
assertNull(response.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserialize TtsSpeakResponse with unknown fields ignores extra properties`() {
|
||||
val rawJson =
|
||||
"""
|
||||
{
|
||||
"ok": false,
|
||||
"error": "Provider not reachable",
|
||||
"status_code": 500
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
|
||||
|
||||
assertFalse(response.ok)
|
||||
assertNull(response.data_url)
|
||||
assertNull(response.mime_type)
|
||||
assertNull(response.provider)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.content.Context
|
||||
import com.m57.hermescontrol.data.model.TtsSpeakRequest
|
||||
import com.m57.hermescontrol.data.model.TtsSpeakResponse
|
||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import retrofit2.Response
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.Base64
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AndroidSpeechTest {
|
||||
@get:Rule
|
||||
val tempFolder = TemporaryFolder()
|
||||
|
||||
private fun createContext(): Context {
|
||||
val mockContext = mockk<Context>()
|
||||
every { mockContext.applicationContext } returns mockContext
|
||||
every { mockContext.cacheDir } returns tempFolder.root
|
||||
return mockContext
|
||||
}
|
||||
|
||||
private fun listTempFiles(): List<File> =
|
||||
tempFolder.root
|
||||
.walkTopDown()
|
||||
.filter { it.isFile }
|
||||
.toList()
|
||||
|
||||
@Test
|
||||
fun `synthesize decodes small real base64 audio bytes to exact file bytes and passes text and profile`() =
|
||||
runTest {
|
||||
val context = createContext()
|
||||
val api = mockk<HermesApiService>()
|
||||
val expectedBytes = byteArrayOf(0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00)
|
||||
val base64Data = Base64.getEncoder().encodeToString(expectedBytes)
|
||||
val dataUrl = "data:audio/wav;base64,$base64Data"
|
||||
|
||||
coEvery { api.speakText(any(), any()) } returns
|
||||
Response.success(
|
||||
TtsSpeakResponse(
|
||||
ok = true,
|
||||
data_url = dataUrl,
|
||||
mime_type = "audio/wav",
|
||||
provider = "piper",
|
||||
),
|
||||
)
|
||||
|
||||
val synthesizer: SpeechSynthesizer =
|
||||
AndroidSpeechSynthesizer(
|
||||
context = context,
|
||||
api = api,
|
||||
profile = "work-profile",
|
||||
)
|
||||
|
||||
val request =
|
||||
SpeechRequest(
|
||||
scopeKey = "session-42",
|
||||
messageId = "msg-101",
|
||||
text = "Hello world",
|
||||
)
|
||||
|
||||
val resultAudio: SpeechAudio = synthesizer.synthesize(request)
|
||||
assertTrue("Expected result to be FileSpeechAudio", resultAudio is FileSpeechAudio)
|
||||
|
||||
val fileAudio = resultAudio as FileSpeechAudio
|
||||
val audioFile = fileAudio.file
|
||||
|
||||
assertTrue("Synthesized audio file must exist", audioFile.exists())
|
||||
assertArrayEquals("Decoded file bytes must match input bytes", expectedBytes, audioFile.readBytes())
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
api.speakText(
|
||||
request = TtsSpeakRequest(text = "Hello world"),
|
||||
profile = "work-profile",
|
||||
)
|
||||
}
|
||||
|
||||
fileAudio.dispose()
|
||||
assertFalse("File must be deleted after dispose()", audioFile.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `synthesize passes null profile when synthesizer constructed without profile`() =
|
||||
runTest {
|
||||
val context = createContext()
|
||||
val api = mockk<HermesApiService>()
|
||||
val rawBytes = byteArrayOf(0x01, 0x02, 0x03, 0x04)
|
||||
val base64Data = Base64.getEncoder().encodeToString(rawBytes)
|
||||
|
||||
coEvery { api.speakText(any(), any()) } returns
|
||||
Response.success(
|
||||
TtsSpeakResponse(
|
||||
ok = true,
|
||||
data_url = "data:audio/mp3;base64,$base64Data",
|
||||
mime_type = "audio/mp3",
|
||||
provider = null,
|
||||
),
|
||||
)
|
||||
|
||||
val synthesizer: SpeechSynthesizer =
|
||||
AndroidSpeechSynthesizer(
|
||||
context = context,
|
||||
api = api,
|
||||
profile = null,
|
||||
)
|
||||
|
||||
val request =
|
||||
SpeechRequest(
|
||||
scopeKey = "session-1",
|
||||
messageId = "msg-1",
|
||||
text = "Testing null profile",
|
||||
)
|
||||
|
||||
val audio = synthesizer.synthesize(request)
|
||||
assertTrue(audio is FileSpeechAudio)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
api.speakText(
|
||||
request = TtsSpeakRequest(text = "Testing null profile"),
|
||||
profile = null,
|
||||
)
|
||||
}
|
||||
|
||||
audio.dispose()
|
||||
assertEquals(0, listTempFiles().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HTTP or network failure throws exception and leaves no temp files`() =
|
||||
runTest {
|
||||
val context = createContext()
|
||||
val api = mockk<HermesApiService>()
|
||||
|
||||
coEvery { api.speakText(any(), any()) } throws IOException("Network connection lost")
|
||||
|
||||
val synthesizer: SpeechSynthesizer =
|
||||
AndroidSpeechSynthesizer(
|
||||
context = context,
|
||||
api = api,
|
||||
profile = "test-profile",
|
||||
)
|
||||
|
||||
val request =
|
||||
SpeechRequest(
|
||||
scopeKey = "session-err",
|
||||
messageId = "msg-err",
|
||||
text = "Network fail",
|
||||
)
|
||||
|
||||
try {
|
||||
synthesizer.synthesize(request)
|
||||
fail("Expected IOException to be thrown")
|
||||
} catch (e: Exception) {
|
||||
assertNotNull(e)
|
||||
}
|
||||
|
||||
assertEquals("No temp files should remain after network failure", 0, listTempFiles().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `response with ok false throws exception and leaves no temp files`() =
|
||||
runTest {
|
||||
val context = createContext()
|
||||
val api = mockk<HermesApiService>()
|
||||
|
||||
coEvery { api.speakText(any(), any()) } returns
|
||||
Response.success(
|
||||
TtsSpeakResponse(
|
||||
ok = false,
|
||||
data_url = null,
|
||||
mime_type = null,
|
||||
provider = null,
|
||||
),
|
||||
)
|
||||
|
||||
val synthesizer: SpeechSynthesizer =
|
||||
AndroidSpeechSynthesizer(
|
||||
context = context,
|
||||
api = api,
|
||||
profile = "profile-1",
|
||||
)
|
||||
|
||||
val request =
|
||||
SpeechRequest(
|
||||
scopeKey = "s1",
|
||||
messageId = "m1",
|
||||
text = "Failed ok=false",
|
||||
)
|
||||
|
||||
try {
|
||||
synthesizer.synthesize(request)
|
||||
fail("Expected synthesize to fail when ok is false")
|
||||
} catch (e: Exception) {
|
||||
assertNotNull(e)
|
||||
}
|
||||
|
||||
assertEquals("No temp files should remain after ok=false", 0, listTempFiles().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed data URI scheme throws exception and leaves no temp files`() =
|
||||
runTest {
|
||||
val context = createContext()
|
||||
val api = mockk<HermesApiService>()
|
||||
|
||||
coEvery { api.speakText(any(), any()) } returns
|
||||
Response.success(
|
||||
TtsSpeakResponse(
|
||||
ok = true,
|
||||
data_url = "not-a-data-uri",
|
||||
mime_type = "audio/wav",
|
||||
provider = "piper",
|
||||
),
|
||||
)
|
||||
|
||||
val synthesizer: SpeechSynthesizer =
|
||||
AndroidSpeechSynthesizer(
|
||||
context = context,
|
||||
api = api,
|
||||
profile = null,
|
||||
)
|
||||
|
||||
val request =
|
||||
SpeechRequest(
|
||||
scopeKey = "s1",
|
||||
messageId = "m1",
|
||||
text = "Malformed scheme",
|
||||
)
|
||||
|
||||
try {
|
||||
synthesizer.synthesize(request)
|
||||
fail("Expected failure for malformed data URI")
|
||||
} catch (e: Exception) {
|
||||
assertNotNull(e)
|
||||
}
|
||||
|
||||
assertEquals("No temp files should remain after malformed URI failure", 0, listTempFiles().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-audio data URI throws exception and leaves no temp files`() =
|
||||
runTest {
|
||||
val context = createContext()
|
||||
val api = mockk<HermesApiService>()
|
||||
val textBase64 = Base64.getEncoder().encodeToString("hello world".toByteArray())
|
||||
|
||||
coEvery { api.speakText(any(), any()) } returns
|
||||
Response.success(
|
||||
TtsSpeakResponse(
|
||||
ok = true,
|
||||
data_url = "data:text/plain;base64,$textBase64",
|
||||
mime_type = "text/plain",
|
||||
provider = "text",
|
||||
),
|
||||
)
|
||||
|
||||
val synthesizer: SpeechSynthesizer =
|
||||
AndroidSpeechSynthesizer(
|
||||
context = context,
|
||||
api = api,
|
||||
profile = null,
|
||||
)
|
||||
|
||||
val request =
|
||||
SpeechRequest(
|
||||
scopeKey = "s1",
|
||||
messageId = "m1",
|
||||
text = "Non audio data URI",
|
||||
)
|
||||
|
||||
try {
|
||||
synthesizer.synthesize(request)
|
||||
fail("Expected failure for non-audio data URI")
|
||||
} catch (e: Exception) {
|
||||
assertNotNull(e)
|
||||
}
|
||||
|
||||
assertEquals("No temp files should remain after non-audio data URI failure", 0, listTempFiles().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid base64 payload throws exception and leaves no temp files`() =
|
||||
runTest {
|
||||
val context = createContext()
|
||||
val api = mockk<HermesApiService>()
|
||||
|
||||
coEvery { api.speakText(any(), any()) } returns
|
||||
Response.success(
|
||||
TtsSpeakResponse(
|
||||
ok = true,
|
||||
data_url = "data:audio/wav;base64,!!!NotBase64Chars!!!",
|
||||
mime_type = "audio/wav",
|
||||
provider = "piper",
|
||||
),
|
||||
)
|
||||
|
||||
val synthesizer: SpeechSynthesizer =
|
||||
AndroidSpeechSynthesizer(
|
||||
context = context,
|
||||
api = api,
|
||||
profile = null,
|
||||
)
|
||||
|
||||
val request =
|
||||
SpeechRequest(
|
||||
scopeKey = "s1",
|
||||
messageId = "m1",
|
||||
text = "Invalid base64",
|
||||
)
|
||||
|
||||
try {
|
||||
synthesizer.synthesize(request)
|
||||
fail("Expected failure for invalid base64")
|
||||
} catch (e: Exception) {
|
||||
assertNotNull(e)
|
||||
}
|
||||
|
||||
assertEquals("No temp files should remain after base64 decoding failure", 0, listTempFiles().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty data URI payload throws exception and leaves no temp files`() =
|
||||
runTest {
|
||||
val context = createContext()
|
||||
val api = mockk<HermesApiService>()
|
||||
|
||||
coEvery { api.speakText(any(), any()) } returns
|
||||
Response.success(
|
||||
TtsSpeakResponse(
|
||||
ok = true,
|
||||
data_url = "data:audio/wav;base64,",
|
||||
mime_type = "audio/wav",
|
||||
provider = "piper",
|
||||
),
|
||||
)
|
||||
|
||||
val synthesizer: SpeechSynthesizer =
|
||||
AndroidSpeechSynthesizer(
|
||||
context = context,
|
||||
api = api,
|
||||
profile = null,
|
||||
)
|
||||
|
||||
val request =
|
||||
SpeechRequest(
|
||||
scopeKey = "s1",
|
||||
messageId = "m1",
|
||||
text = "Empty payload",
|
||||
)
|
||||
|
||||
try {
|
||||
synthesizer.synthesize(request)
|
||||
fail("Expected failure for empty base64 payload")
|
||||
} catch (e: Exception) {
|
||||
assertNotNull(e)
|
||||
}
|
||||
|
||||
assertEquals("No temp files should remain after empty payload failure", 0, listTempFiles().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null data_url throws exception and leaves no temp files`() =
|
||||
runTest {
|
||||
val context = createContext()
|
||||
val api = mockk<HermesApiService>()
|
||||
|
||||
coEvery { api.speakText(any(), any()) } returns
|
||||
Response.success(
|
||||
TtsSpeakResponse(
|
||||
ok = true,
|
||||
data_url = null,
|
||||
mime_type = "audio/wav",
|
||||
provider = "piper",
|
||||
),
|
||||
)
|
||||
|
||||
val synthesizer: SpeechSynthesizer =
|
||||
AndroidSpeechSynthesizer(
|
||||
context = context,
|
||||
api = api,
|
||||
profile = null,
|
||||
)
|
||||
|
||||
val request =
|
||||
SpeechRequest(
|
||||
scopeKey = "s1",
|
||||
messageId = "m1",
|
||||
text = "Null data url",
|
||||
)
|
||||
|
||||
try {
|
||||
synthesizer.synthesize(request)
|
||||
fail("Expected failure when data_url is null")
|
||||
} catch (e: Exception) {
|
||||
assertNotNull(e)
|
||||
}
|
||||
|
||||
assertEquals("No temp files should remain after null data_url failure", 0, listTempFiles().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `padded valid base64 followed by garbage throws exception and leaves no temp files`() =
|
||||
runTest {
|
||||
val context = createContext()
|
||||
val api = mockk<HermesApiService>()
|
||||
val validPaddedBase64 = "AQ==" // 1 byte decoded (0x01), padded with ==
|
||||
val payloadWithTrailingGarbage = validPaddedBase64 + "garbage"
|
||||
|
||||
coEvery { api.speakText(any(), any()) } returns
|
||||
Response.success(
|
||||
TtsSpeakResponse(
|
||||
ok = true,
|
||||
data_url = "data:audio/wav;base64,$payloadWithTrailingGarbage",
|
||||
mime_type = "audio/wav",
|
||||
provider = "piper",
|
||||
),
|
||||
)
|
||||
|
||||
val synthesizer: SpeechSynthesizer =
|
||||
AndroidSpeechSynthesizer(
|
||||
context = context,
|
||||
api = api,
|
||||
profile = null,
|
||||
)
|
||||
|
||||
val request =
|
||||
SpeechRequest(
|
||||
scopeKey = "s1",
|
||||
messageId = "m1",
|
||||
text = "Garbage after padding",
|
||||
)
|
||||
|
||||
try {
|
||||
synthesizer.synthesize(request)
|
||||
fail("Expected failure for base64 with trailing garbage after padding")
|
||||
} catch (e: Exception) {
|
||||
assertNotNull(e)
|
||||
}
|
||||
|
||||
assertEquals("No temp files should remain after trailing garbage failure", 0, listTempFiles().size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SpeechControllerTest {
|
||||
private class FakeAudio(
|
||||
val tag: String,
|
||||
) : SpeechAudio {
|
||||
var isDisposed: Boolean = false
|
||||
private set
|
||||
|
||||
override fun dispose() {
|
||||
isDisposed = true
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeSynthesizer : SpeechSynthesizer {
|
||||
var calls: MutableList<SpeechRequest> = mutableListOf()
|
||||
var synthesizeBlock: (suspend (SpeechRequest) -> SpeechAudio)? = null
|
||||
|
||||
override suspend fun synthesize(request: SpeechRequest): SpeechAudio {
|
||||
calls.add(request)
|
||||
return synthesizeBlock?.invoke(request) ?: FakeAudio(request.text)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakePlayer : SpeechPlayer {
|
||||
var currentlyPlaying: SpeechAudio? = null
|
||||
var lastEndedCallback: (() -> Unit)? = null
|
||||
var lastErrorCallback: (() -> Unit)? = null
|
||||
var playCallCount: Int = 0
|
||||
var stopCallCount: Int = 0
|
||||
var playThrows: Throwable? = null
|
||||
|
||||
override fun play(
|
||||
audio: SpeechAudio,
|
||||
onEnded: () -> Unit,
|
||||
onError: () -> Unit,
|
||||
) {
|
||||
playCallCount++
|
||||
currentlyPlaying = audio
|
||||
lastEndedCallback = onEnded
|
||||
lastErrorCallback = onError
|
||||
playThrows?.let { throw it }
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
stopCallCount++
|
||||
currentlyPlaying = null
|
||||
lastEndedCallback = null
|
||||
lastErrorCallback = null
|
||||
}
|
||||
}
|
||||
|
||||
private class Fixture(
|
||||
val scope: TestScope,
|
||||
) {
|
||||
val synthesizer = FakeSynthesizer()
|
||||
val player = FakePlayer()
|
||||
val errors = mutableListOf<Unit>()
|
||||
|
||||
val controller =
|
||||
SpeechController(
|
||||
scope = scope,
|
||||
synthesizer = synthesizer,
|
||||
player = player,
|
||||
onError = { errors.add(Unit) },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggle with blank text is a no-op`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val blankReq = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = " ")
|
||||
|
||||
fixture.controller.toggle(blankReq)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertEquals(0, fixture.synthesizer.calls.size)
|
||||
assertEquals(0, fixture.player.playCallCount)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggle with empty text is a no-op`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val emptyReq = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "")
|
||||
|
||||
fixture.controller.toggle(emptyReq)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertEquals(0, fixture.synthesizer.calls.size)
|
||||
assertEquals(0, fixture.player.playCallCount)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggle synthesizes and plays audio, setting speakingId`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
|
||||
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
assertEquals(1, fixture.synthesizer.calls.size)
|
||||
assertEquals(req, fixture.synthesizer.calls[0])
|
||||
assertEquals(1, fixture.player.playCallCount)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggle on same active request stops playback and clears speakingId`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
|
||||
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertTrue(fixture.player.stopCallCount >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggle on same active request while still synthesizing stops and cancels synthesis`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val gate = CompletableDeferred<SpeechAudio>()
|
||||
fixture.synthesizer.synthesizeBlock = { gate.await() }
|
||||
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
|
||||
fixture.controller.toggle(req)
|
||||
runCurrent()
|
||||
|
||||
// Toggle same request while synthesis is inflight
|
||||
fixture.controller.toggle(req)
|
||||
runCurrent()
|
||||
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
|
||||
// Even if synthesis completes later, generation fencing ensures it never plays
|
||||
val audio = FakeAudio("Hello world")
|
||||
gate.complete(audio)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(0, fixture.player.playCallCount)
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `noncooperative synthesis cancelled during synthesis still disposes returned audio without playing`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
val resumeGate = CompletableDeferred<Unit>()
|
||||
val audio = FakeAudio("Stale noncooperative audio")
|
||||
|
||||
fixture.synthesizer.synthesizeBlock = {
|
||||
try {
|
||||
gate.await()
|
||||
} catch (t: Throwable) {
|
||||
withContext(NonCancellable) {
|
||||
resumeGate.await()
|
||||
}
|
||||
}
|
||||
audio
|
||||
}
|
||||
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text")
|
||||
fixture.controller.toggle(req)
|
||||
runCurrent()
|
||||
|
||||
// Cancel by stopping
|
||||
fixture.controller.stop()
|
||||
runCurrent()
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
|
||||
// Now let noncooperative synthesis produce audio after cancellation
|
||||
resumeGate.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Audio must be disposed, never played, no error emitted, and speakingId remains null
|
||||
assertTrue(audio.isDisposed)
|
||||
assertEquals(0, fixture.player.playCallCount)
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `noncooperative synthesis superseded by replacement disposes stale audio without touching newer state`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val gate1 = CompletableDeferred<Unit>()
|
||||
val resumeGate1 = CompletableDeferred<Unit>()
|
||||
val audio1 = FakeAudio("Stale noncooperative audio 1")
|
||||
val audio2 = FakeAudio("New active audio 2")
|
||||
|
||||
fixture.synthesizer.synthesizeBlock = { req ->
|
||||
if (req.messageId == "msg-1") {
|
||||
try {
|
||||
gate1.await()
|
||||
} catch (t: Throwable) {
|
||||
withContext(NonCancellable) {
|
||||
resumeGate1.await()
|
||||
}
|
||||
}
|
||||
audio1
|
||||
} else {
|
||||
audio2
|
||||
}
|
||||
}
|
||||
|
||||
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text")
|
||||
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text")
|
||||
|
||||
fixture.controller.toggle(req1)
|
||||
runCurrent()
|
||||
|
||||
// Supersede with req2
|
||||
fixture.controller.toggle(req2)
|
||||
advanceUntilIdle()
|
||||
|
||||
// req2 should now be playing
|
||||
assertEquals("msg-2", fixture.controller.speakingId.value)
|
||||
assertEquals(audio2, fixture.player.currentlyPlaying)
|
||||
assertEquals(1, fixture.player.playCallCount)
|
||||
|
||||
// Now release stale noncooperative synthesis from req1
|
||||
resumeGate1.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Stale audio1 must be disposed, no extra play calls, speakingId untouched, no errors
|
||||
assertTrue(audio1.isDisposed)
|
||||
assertFalse(audio2.isDisposed)
|
||||
assertEquals("msg-2", fixture.controller.speakingId.value)
|
||||
assertEquals(audio2, fixture.player.currentlyPlaying)
|
||||
assertEquals(1, fixture.player.playCallCount)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `noncooperative synthesis throwing failure after replacement is ignored and does not invoke onError`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val gate1 = CompletableDeferred<Unit>()
|
||||
val resumeGate1 = CompletableDeferred<Unit>()
|
||||
val audio2 = FakeAudio("Active audio 2")
|
||||
|
||||
fixture.synthesizer.synthesizeBlock = { req ->
|
||||
if (req.messageId == "msg-1") {
|
||||
try {
|
||||
gate1.await()
|
||||
} catch (t: Throwable) {
|
||||
withContext(NonCancellable) {
|
||||
resumeGate1.await()
|
||||
}
|
||||
}
|
||||
throw IllegalStateException("Stale synthesis explosion")
|
||||
} else {
|
||||
audio2
|
||||
}
|
||||
}
|
||||
|
||||
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text")
|
||||
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text")
|
||||
|
||||
fixture.controller.toggle(req1)
|
||||
runCurrent()
|
||||
|
||||
// Replace with req2
|
||||
fixture.controller.toggle(req2)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("msg-2", fixture.controller.speakingId.value)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
|
||||
// Release stale failure
|
||||
resumeGate1.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Stale failure must not invoke onError or clear msg-2
|
||||
assertEquals(0, fixture.errors.size)
|
||||
assertEquals("msg-2", fixture.controller.speakingId.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stop and restart race on same request ensures stale inflight completion does not disrupt new cycle`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val gate1 = CompletableDeferred<Unit>()
|
||||
val resumeGate1 = CompletableDeferred<Unit>()
|
||||
val audio1 = FakeAudio("Cycle 1 audio")
|
||||
val audio2 = FakeAudio("Cycle 2 audio")
|
||||
|
||||
var callIndex = 0
|
||||
fixture.synthesizer.synthesizeBlock = {
|
||||
val idx = ++callIndex
|
||||
if (idx == 1) {
|
||||
try {
|
||||
gate1.await()
|
||||
} catch (t: Throwable) {
|
||||
withContext(NonCancellable) {
|
||||
resumeGate1.await()
|
||||
}
|
||||
}
|
||||
audio1
|
||||
} else {
|
||||
audio2
|
||||
}
|
||||
}
|
||||
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Same text")
|
||||
|
||||
// Cycle 1: start synthesis
|
||||
fixture.controller.toggle(req)
|
||||
runCurrent()
|
||||
|
||||
// Toggle same request while synthesizing -> cancels cycle 1
|
||||
fixture.controller.toggle(req)
|
||||
runCurrent()
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
|
||||
// Cycle 2: restart SAME request immediately
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Cycle 2 has synthesized audio2 and is playing it
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
assertEquals(audio2, fixture.player.currentlyPlaying)
|
||||
assertEquals(1, fixture.player.playCallCount)
|
||||
|
||||
// Stale cycle 1 finally returns audio1
|
||||
resumeGate1.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Stale audio1 must be disposed and cycle 2 remains active and playing audio2
|
||||
assertTrue(audio1.isDisposed)
|
||||
assertFalse(audio2.isDisposed)
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
assertEquals(audio2, fixture.player.currentlyPlaying)
|
||||
assertEquals(1, fixture.player.playCallCount)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stop stops playback and clears speakingId`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
|
||||
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
|
||||
val stopCallsBefore = fixture.player.stopCallCount
|
||||
fixture.controller.stop()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertTrue(fixture.player.stopCallCount > stopCallsBefore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replacement immediately stops active playback and cancels old request`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text")
|
||||
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text")
|
||||
|
||||
fixture.controller.toggle(req1)
|
||||
advanceUntilIdle()
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
|
||||
fixture.controller.toggle(req2)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("msg-2", fixture.controller.speakingId.value)
|
||||
assertEquals(2, fixture.synthesizer.calls.size)
|
||||
assertEquals(2, fixture.player.playCallCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replacement cancels inflight synthesis and generation fencing rejects stale completion`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val gate1 = CompletableDeferred<SpeechAudio>()
|
||||
val gate2 = CompletableDeferred<SpeechAudio>()
|
||||
|
||||
fixture.synthesizer.synthesizeBlock = { req ->
|
||||
if (req.messageId == "msg-1") gate1.await() else gate2.await()
|
||||
}
|
||||
|
||||
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text")
|
||||
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text")
|
||||
|
||||
fixture.controller.toggle(req1)
|
||||
runCurrent()
|
||||
|
||||
fixture.controller.toggle(req2)
|
||||
runCurrent()
|
||||
|
||||
// Complete old stale synthesis
|
||||
val audio1 = FakeAudio("First text")
|
||||
gate1.complete(audio1)
|
||||
runCurrent()
|
||||
|
||||
// Complete new synthesis
|
||||
val audio2 = FakeAudio("Second text")
|
||||
gate2.complete(audio2)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("msg-2", fixture.controller.speakingId.value)
|
||||
assertEquals(1, fixture.player.playCallCount)
|
||||
assertEquals(audio2, fixture.player.currentlyPlaying)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `player onEnded callback resets speakingId to null`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
|
||||
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
|
||||
val onEnded = fixture.player.lastEndedCallback
|
||||
onEnded?.invoke()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale onEnded callback from superseded generation does not clear active speakingId`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First")
|
||||
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second")
|
||||
|
||||
fixture.controller.toggle(req1)
|
||||
advanceUntilIdle()
|
||||
val staleOnEnded = fixture.player.lastEndedCallback
|
||||
|
||||
fixture.controller.toggle(req2)
|
||||
advanceUntilIdle()
|
||||
assertEquals("msg-2", fixture.controller.speakingId.value)
|
||||
|
||||
staleOnEnded?.invoke()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Still speaking msg-2!
|
||||
assertEquals("msg-2", fixture.controller.speakingId.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale onError callback from superseded generation is ignored and does not invoke onError`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First")
|
||||
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second")
|
||||
|
||||
fixture.controller.toggle(req1)
|
||||
advanceUntilIdle()
|
||||
val staleOnError = fixture.player.lastErrorCallback
|
||||
|
||||
fixture.controller.toggle(req2)
|
||||
advanceUntilIdle()
|
||||
assertEquals("msg-2", fixture.controller.speakingId.value)
|
||||
|
||||
staleOnError?.invoke()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("msg-2", fixture.controller.speakingId.value)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `player onError callback clears speakingId and triggers onError`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
|
||||
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
|
||||
val onError = fixture.player.lastErrorCallback
|
||||
onError?.invoke()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertEquals(1, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `synthesizer failure triggers onError and clears speakingId`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
fixture.synthesizer.synthesizeBlock = {
|
||||
throw IllegalStateException("Synthesis failed")
|
||||
}
|
||||
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertEquals(1, fixture.errors.size)
|
||||
assertEquals(0, fixture.player.playCallCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancellations during synthesis do not trigger onError`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val gate = CompletableDeferred<SpeechAudio>()
|
||||
fixture.synthesizer.synthesizeBlock = { gate.await() }
|
||||
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
|
||||
fixture.controller.toggle(req)
|
||||
runCurrent()
|
||||
|
||||
// Stop cancels the inflight coroutine
|
||||
fixture.controller.stop()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaying same request uses cached audio without re-synthesizing and stop preserves cache`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Cached audio")
|
||||
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
assertEquals(1, fixture.synthesizer.calls.size)
|
||||
assertEquals(1, fixture.player.playCallCount)
|
||||
|
||||
// Stop playback — must preserve replay cache
|
||||
fixture.controller.stop()
|
||||
advanceUntilIdle()
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
|
||||
// Toggle again with same request -> should replay from cache without re-synthesizing
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
assertEquals(1, fixture.synthesizer.calls.size)
|
||||
assertEquals(2, fixture.player.playCallCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `request with different text invalidates cache and disposes old audio`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val audio1 = FakeAudio("Text 1")
|
||||
val audio2 = FakeAudio("Text 2")
|
||||
fixture.synthesizer.synthesizeBlock = { req ->
|
||||
if (req.text == "Text 1") audio1 else audio2
|
||||
}
|
||||
|
||||
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Text 1")
|
||||
fixture.controller.toggle(req1)
|
||||
advanceUntilIdle()
|
||||
assertEquals(audio1, fixture.player.currentlyPlaying)
|
||||
assertFalse(audio1.isDisposed)
|
||||
|
||||
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Text 2")
|
||||
fixture.controller.toggle(req2)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(audio2, fixture.player.currentlyPlaying)
|
||||
assertTrue(audio1.isDisposed)
|
||||
assertEquals(2, fixture.synthesizer.calls.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `request with different scope invalidates cache and disposes old audio`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val audio1 = FakeAudio("Scope 1 audio")
|
||||
val audio2 = FakeAudio("Scope 2 audio")
|
||||
fixture.synthesizer.synthesizeBlock = { req ->
|
||||
if (req.scopeKey == "session-1") audio1 else audio2
|
||||
}
|
||||
|
||||
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello")
|
||||
fixture.controller.toggle(req1)
|
||||
advanceUntilIdle()
|
||||
assertFalse(audio1.isDisposed)
|
||||
|
||||
val req2 = SpeechRequest(scopeKey = "session-2", messageId = "msg-1", text = "Hello")
|
||||
fixture.controller.toggle(req2)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(audio2, fixture.player.currentlyPlaying)
|
||||
assertTrue(audio1.isDisposed)
|
||||
assertEquals(2, fixture.synthesizer.calls.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaying cached request when player throws does not escape toggle and clears speakingId`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Cached text")
|
||||
|
||||
// Initial successful playback
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
assertEquals(1, fixture.synthesizer.calls.size)
|
||||
assertEquals(1, fixture.player.playCallCount)
|
||||
|
||||
// Complete initial playback
|
||||
fixture.player.lastEndedCallback?.invoke()
|
||||
advanceUntilIdle()
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertEquals(0, fixture.errors.size)
|
||||
|
||||
// Configure player to throw synchronously on replay
|
||||
val stopCallsBeforeReplay = fixture.player.stopCallCount
|
||||
fixture.player.playThrows = IllegalStateException("Player init failed")
|
||||
|
||||
// Replay exact cached request
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Must NOT escape toggle, speakingId null, onError exactly once,
|
||||
// partially initialized player stopped, no resynthesis
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertEquals(1, fixture.errors.size)
|
||||
assertTrue(fixture.player.stopCallCount > stopCallsBeforeReplay)
|
||||
assertEquals(1, fixture.synthesizer.calls.size)
|
||||
assertEquals(2, fixture.player.playCallCount)
|
||||
|
||||
// Replay cache is retained: if player recovers, replaying exact request does not re-synthesize
|
||||
fixture.player.playThrows = null
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
assertEquals(1, fixture.synthesizer.calls.size)
|
||||
assertEquals(3, fixture.player.playCallCount)
|
||||
assertEquals(1, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial play when player throws does not escape toggle and clears speakingId`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Initial throw text")
|
||||
|
||||
fixture.player.playThrows = IllegalStateException("Player failed synchronously on first play")
|
||||
|
||||
// Initial noncached play throw
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Must NOT escape toggle, speakingId null, onError exactly once, stopped partially initialized player
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertEquals(1, fixture.errors.size)
|
||||
assertTrue(fixture.player.stopCallCount >= 1)
|
||||
assertEquals(1, fixture.synthesizer.calls.size)
|
||||
assertEquals(1, fixture.player.playCallCount)
|
||||
|
||||
// Replay cache retained: if player recovers, next toggle on exact
|
||||
// request uses cached audio without re-synthesizing
|
||||
fixture.player.playThrows = null
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
assertEquals(1, fixture.synthesizer.calls.size)
|
||||
assertEquals(2, fixture.player.playCallCount)
|
||||
assertEquals(1, fixture.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset clears speakingId, stops player, and disposes cached audio`() =
|
||||
runTest {
|
||||
val fixture = Fixture(this)
|
||||
val audio = FakeAudio("Reset audio")
|
||||
fixture.synthesizer.synthesizeBlock = { audio }
|
||||
|
||||
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Reset audio")
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
assertFalse(audio.isDisposed)
|
||||
|
||||
fixture.controller.reset()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(fixture.controller.speakingId.value)
|
||||
assertTrue(audio.isDisposed)
|
||||
assertTrue(fixture.player.stopCallCount >= 1)
|
||||
|
||||
// Re-toggling same request after reset must re-synthesize because cache was cleared/disposed
|
||||
val audio2 = FakeAudio("Reset audio 2")
|
||||
fixture.synthesizer.synthesizeBlock = { audio2 }
|
||||
fixture.controller.toggle(req)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(2, fixture.synthesizer.calls.size)
|
||||
assertEquals("msg-1", fixture.controller.speakingId.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SpeechTextTest {
|
||||
@Test
|
||||
fun `blank input returns blank string`() {
|
||||
assertEquals("", SpeechText.stripMarkdownForSpeech(""))
|
||||
assertEquals("", SpeechText.stripMarkdownForSpeech(" "))
|
||||
assertEquals("", SpeechText.stripMarkdownForSpeech("\n\t\n "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plain text is preserved without changes`() {
|
||||
val input = "Hello world this is a test"
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertEquals("Hello world this is a test", result.trim())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bold and italic markers are removed keeping prose`() {
|
||||
val input = "This is **bold** text and *italic* text and ***bold italic*** text"
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertFalse(result.contains("*"))
|
||||
assertTrue(result.contains("bold"))
|
||||
assertTrue(result.contains("italic"))
|
||||
assertTrue(result.contains("bold italic"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `strikethrough markers are removed keeping inner text`() {
|
||||
val input = "This is ~~cancelled~~ completed"
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertFalse(result.contains("~"))
|
||||
assertTrue(result.contains("cancelled"))
|
||||
assertTrue(result.contains("completed"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `headers keep title text without hash characters`() {
|
||||
val input =
|
||||
"""
|
||||
# Header 1
|
||||
## Header 2
|
||||
### Header 3
|
||||
""".trimIndent()
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertFalse(result.contains("#"))
|
||||
assertTrue(result.contains("Header 1"))
|
||||
assertTrue(result.contains("Header 2"))
|
||||
assertTrue(result.contains("Header 3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inline code keeps code content without backticks`() {
|
||||
val input = "Run `val my_value = 42` to configure"
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertFalse(result.contains("`"))
|
||||
assertTrue(result.contains("val my_value = 42"))
|
||||
assertTrue(result.contains("my_value"))
|
||||
assertTrue(result.contains("to configure"))
|
||||
assertFalse(result.contains(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fenced code block keeps code body and removes fences and language identifier`() {
|
||||
val input =
|
||||
"""
|
||||
Here is some code:
|
||||
```kotlin
|
||||
fun main() {
|
||||
println("Hello")
|
||||
}
|
||||
```
|
||||
Done.
|
||||
""".trimIndent()
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertFalse(result.contains("```"))
|
||||
assertFalse(result.contains("kotlin"))
|
||||
assertTrue(result.contains("fun main()"))
|
||||
assertTrue(result.contains("""println("Hello")"""))
|
||||
assertTrue(result.contains("Here is some code:"))
|
||||
assertTrue(result.contains("Done."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fenced code block body retains underscores, generics, multiplication, and pipes`() {
|
||||
val input =
|
||||
"""
|
||||
Here is complex code:
|
||||
```typescript
|
||||
const a_b_c: List<T> = items.map(x => x * 2 | 0);
|
||||
```
|
||||
Done...
|
||||
""".trimIndent()
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertFalse(result.contains("```"))
|
||||
assertFalse(result.contains("typescript"))
|
||||
assertTrue(result.contains("a_b_c"))
|
||||
assertTrue(result.contains("<T>"))
|
||||
assertTrue(result.contains("* 2"))
|
||||
assertTrue(result.contains("| 0"))
|
||||
assertTrue(result.contains("Here is complex code:"))
|
||||
assertTrue(result.contains("Done..."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `markdown links keep label text and strip destination url`() {
|
||||
val input = "Check out [Hermes Documentation](https://hermes-agent.nousresearch.com/docs) for info"
|
||||
val result = SpeechText.stripMarkdownForSpeech(input)
|
||||
assertTrue(result.contains("Hermes Documentation"))
|
||||
assertFalse(result.contains("https://hermes-agent.nousresearch.com/docs"))
|
||||
assertFalse(result.contains("[") || result.contains("]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `image markdown keeps alt text and strips image url`() {
|
||||
val input = "Here is a diagram: "
|
||||
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