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

This commit is contained in:
m57 authored and GitHub committed 2026-09-27 19:43:50 +03:00
1 parent 2e0fd46a4c
commit 3c56026a1c
14 files changed
+2370

No files matched your search

@@ -0,0 +1,140 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class TtsTest {
private val json =
Json {
ignoreUnknownKeys = true
isLenient = true
}
@Test
fun `serialize TtsSpeakRequest with literal text`() {
val request = TtsSpeakRequest(text = "Hello, world!")
val encoded = json.encodeToString(request)
assertEquals("""{"text":"Hello, world!"}""", encoded)
}
@Test
fun `deserialize TtsSpeakResponse with all fields present`() {
val rawJson =
"""
{
"ok": true,
"data_url": "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=",
"mime_type": "audio/wav",
"provider": "piper"
}
""".trimIndent()
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
assertTrue(response.ok)
assertEquals(
"data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=",
response.data_url,
)
assertEquals("audio/wav", response.mime_type)
assertEquals("piper", response.provider)
}
@Test
fun `deserialize TtsSpeakResponse with optional provider missing`() {
val rawJson =
"""
{
"ok": true,
"data_url": "data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/",
"mime_type": "audio/mp3"
}
""".trimIndent()
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
assertTrue(response.ok)
assertEquals(
"data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/",
response.data_url,
)
assertEquals("audio/mp3", response.mime_type)
assertNull(response.provider)
}
@Test
fun `deserialize TtsSpeakResponse with provider explicitly null`() {
val rawJson =
"""
{
"ok": true,
"data_url": "data:audio/ogg;base64,T2dnUwACAAAAAAAAAAA=",
"mime_type": "audio/ogg",
"provider": null
}
""".trimIndent()
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
assertTrue(response.ok)
assertEquals("data:audio/ogg;base64,T2dnUwACAAAAAAAAAAA=", response.data_url)
assertEquals("audio/ogg", response.mime_type)
assertNull(response.provider)
}
@Test
fun `deserialize TtsSpeakResponse with missing ok defaults to false`() {
val rawJson =
"""
{
"data_url": null,
"mime_type": null
}
""".trimIndent()
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
assertFalse(response.ok)
assertNull(response.data_url)
assertNull(response.mime_type)
assertNull(response.provider)
}
@Test
fun `deserialize TtsSpeakResponse with empty json defaults to ok false and nulls`() {
val rawJson = "{}"
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
assertFalse(response.ok)
assertNull(response.data_url)
assertNull(response.mime_type)
assertNull(response.provider)
}
@Test
fun `deserialize TtsSpeakResponse with unknown fields ignores extra properties`() {
val rawJson =
"""
{
"ok": false,
"error": "Provider not reachable",
"status_code": 500
}
""".trimIndent()
val response = json.decodeFromString<TtsSpeakResponse>(rawJson)
assertFalse(response.ok)
assertNull(response.data_url)
assertNull(response.mime_type)
assertNull(response.provider)
}
}
@@ -0,0 +1,458 @@
package com.m57.hermescontrol.ui.chat.components
import android.content.Context
import com.m57.hermescontrol.data.model.TtsSpeakRequest
import com.m57.hermescontrol.data.model.TtsSpeakResponse
import com.m57.hermescontrol.data.remote.HermesApiService
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import retrofit2.Response
import java.io.File
import java.io.IOException
import java.util.Base64
@OptIn(ExperimentalCoroutinesApi::class)
class AndroidSpeechTest {
@get:Rule
val tempFolder = TemporaryFolder()
private fun createContext(): Context {
val mockContext = mockk<Context>()
every { mockContext.applicationContext } returns mockContext
every { mockContext.cacheDir } returns tempFolder.root
return mockContext
}
private fun listTempFiles(): List<File> =
tempFolder.root
.walkTopDown()
.filter { it.isFile }
.toList()
@Test
fun `synthesize decodes small real base64 audio bytes to exact file bytes and passes text and profile`() =
runTest {
val context = createContext()
val api = mockk<HermesApiService>()
val expectedBytes = byteArrayOf(0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00)
val base64Data = Base64.getEncoder().encodeToString(expectedBytes)
val dataUrl = "data:audio/wav;base64,$base64Data"
coEvery { api.speakText(any(), any()) } returns
Response.success(
TtsSpeakResponse(
ok = true,
data_url = dataUrl,
mime_type = "audio/wav",
provider = "piper",
),
)
val synthesizer: SpeechSynthesizer =
AndroidSpeechSynthesizer(
context = context,
api = api,
profile = "work-profile",
)
val request =
SpeechRequest(
scopeKey = "session-42",
messageId = "msg-101",
text = "Hello world",
)
val resultAudio: SpeechAudio = synthesizer.synthesize(request)
assertTrue("Expected result to be FileSpeechAudio", resultAudio is FileSpeechAudio)
val fileAudio = resultAudio as FileSpeechAudio
val audioFile = fileAudio.file
assertTrue("Synthesized audio file must exist", audioFile.exists())
assertArrayEquals("Decoded file bytes must match input bytes", expectedBytes, audioFile.readBytes())
coVerify(exactly = 1) {
api.speakText(
request = TtsSpeakRequest(text = "Hello world"),
profile = "work-profile",
)
}
fileAudio.dispose()
assertFalse("File must be deleted after dispose()", audioFile.exists())
}
@Test
fun `synthesize passes null profile when synthesizer constructed without profile`() =
runTest {
val context = createContext()
val api = mockk<HermesApiService>()
val rawBytes = byteArrayOf(0x01, 0x02, 0x03, 0x04)
val base64Data = Base64.getEncoder().encodeToString(rawBytes)
coEvery { api.speakText(any(), any()) } returns
Response.success(
TtsSpeakResponse(
ok = true,
data_url = "data:audio/mp3;base64,$base64Data",
mime_type = "audio/mp3",
provider = null,
),
)
val synthesizer: SpeechSynthesizer =
AndroidSpeechSynthesizer(
context = context,
api = api,
profile = null,
)
val request =
SpeechRequest(
scopeKey = "session-1",
messageId = "msg-1",
text = "Testing null profile",
)
val audio = synthesizer.synthesize(request)
assertTrue(audio is FileSpeechAudio)
coVerify(exactly = 1) {
api.speakText(
request = TtsSpeakRequest(text = "Testing null profile"),
profile = null,
)
}
audio.dispose()
assertEquals(0, listTempFiles().size)
}
@Test
fun `HTTP or network failure throws exception and leaves no temp files`() =
runTest {
val context = createContext()
val api = mockk<HermesApiService>()
coEvery { api.speakText(any(), any()) } throws IOException("Network connection lost")
val synthesizer: SpeechSynthesizer =
AndroidSpeechSynthesizer(
context = context,
api = api,
profile = "test-profile",
)
val request =
SpeechRequest(
scopeKey = "session-err",
messageId = "msg-err",
text = "Network fail",
)
try {
synthesizer.synthesize(request)
fail("Expected IOException to be thrown")
} catch (e: Exception) {
assertNotNull(e)
}
assertEquals("No temp files should remain after network failure", 0, listTempFiles().size)
}
@Test
fun `response with ok false throws exception and leaves no temp files`() =
runTest {
val context = createContext()
val api = mockk<HermesApiService>()
coEvery { api.speakText(any(), any()) } returns
Response.success(
TtsSpeakResponse(
ok = false,
data_url = null,
mime_type = null,
provider = null,
),
)
val synthesizer: SpeechSynthesizer =
AndroidSpeechSynthesizer(
context = context,
api = api,
profile = "profile-1",
)
val request =
SpeechRequest(
scopeKey = "s1",
messageId = "m1",
text = "Failed ok=false",
)
try {
synthesizer.synthesize(request)
fail("Expected synthesize to fail when ok is false")
} catch (e: Exception) {
assertNotNull(e)
}
assertEquals("No temp files should remain after ok=false", 0, listTempFiles().size)
}
@Test
fun `malformed data URI scheme throws exception and leaves no temp files`() =
runTest {
val context = createContext()
val api = mockk<HermesApiService>()
coEvery { api.speakText(any(), any()) } returns
Response.success(
TtsSpeakResponse(
ok = true,
data_url = "not-a-data-uri",
mime_type = "audio/wav",
provider = "piper",
),
)
val synthesizer: SpeechSynthesizer =
AndroidSpeechSynthesizer(
context = context,
api = api,
profile = null,
)
val request =
SpeechRequest(
scopeKey = "s1",
messageId = "m1",
text = "Malformed scheme",
)
try {
synthesizer.synthesize(request)
fail("Expected failure for malformed data URI")
} catch (e: Exception) {
assertNotNull(e)
}
assertEquals("No temp files should remain after malformed URI failure", 0, listTempFiles().size)
}
@Test
fun `non-audio data URI throws exception and leaves no temp files`() =
runTest {
val context = createContext()
val api = mockk<HermesApiService>()
val textBase64 = Base64.getEncoder().encodeToString("hello world".toByteArray())
coEvery { api.speakText(any(), any()) } returns
Response.success(
TtsSpeakResponse(
ok = true,
data_url = "data:text/plain;base64,$textBase64",
mime_type = "text/plain",
provider = "text",
),
)
val synthesizer: SpeechSynthesizer =
AndroidSpeechSynthesizer(
context = context,
api = api,
profile = null,
)
val request =
SpeechRequest(
scopeKey = "s1",
messageId = "m1",
text = "Non audio data URI",
)
try {
synthesizer.synthesize(request)
fail("Expected failure for non-audio data URI")
} catch (e: Exception) {
assertNotNull(e)
}
assertEquals("No temp files should remain after non-audio data URI failure", 0, listTempFiles().size)
}
@Test
fun `invalid base64 payload throws exception and leaves no temp files`() =
runTest {
val context = createContext()
val api = mockk<HermesApiService>()
coEvery { api.speakText(any(), any()) } returns
Response.success(
TtsSpeakResponse(
ok = true,
data_url = "data:audio/wav;base64,!!!NotBase64Chars!!!",
mime_type = "audio/wav",
provider = "piper",
),
)
val synthesizer: SpeechSynthesizer =
AndroidSpeechSynthesizer(
context = context,
api = api,
profile = null,
)
val request =
SpeechRequest(
scopeKey = "s1",
messageId = "m1",
text = "Invalid base64",
)
try {
synthesizer.synthesize(request)
fail("Expected failure for invalid base64")
} catch (e: Exception) {
assertNotNull(e)
}
assertEquals("No temp files should remain after base64 decoding failure", 0, listTempFiles().size)
}
@Test
fun `empty data URI payload throws exception and leaves no temp files`() =
runTest {
val context = createContext()
val api = mockk<HermesApiService>()
coEvery { api.speakText(any(), any()) } returns
Response.success(
TtsSpeakResponse(
ok = true,
data_url = "data:audio/wav;base64,",
mime_type = "audio/wav",
provider = "piper",
),
)
val synthesizer: SpeechSynthesizer =
AndroidSpeechSynthesizer(
context = context,
api = api,
profile = null,
)
val request =
SpeechRequest(
scopeKey = "s1",
messageId = "m1",
text = "Empty payload",
)
try {
synthesizer.synthesize(request)
fail("Expected failure for empty base64 payload")
} catch (e: Exception) {
assertNotNull(e)
}
assertEquals("No temp files should remain after empty payload failure", 0, listTempFiles().size)
}
@Test
fun `null data_url throws exception and leaves no temp files`() =
runTest {
val context = createContext()
val api = mockk<HermesApiService>()
coEvery { api.speakText(any(), any()) } returns
Response.success(
TtsSpeakResponse(
ok = true,
data_url = null,
mime_type = "audio/wav",
provider = "piper",
),
)
val synthesizer: SpeechSynthesizer =
AndroidSpeechSynthesizer(
context = context,
api = api,
profile = null,
)
val request =
SpeechRequest(
scopeKey = "s1",
messageId = "m1",
text = "Null data url",
)
try {
synthesizer.synthesize(request)
fail("Expected failure when data_url is null")
} catch (e: Exception) {
assertNotNull(e)
}
assertEquals("No temp files should remain after null data_url failure", 0, listTempFiles().size)
}
@Test
fun `padded valid base64 followed by garbage throws exception and leaves no temp files`() =
runTest {
val context = createContext()
val api = mockk<HermesApiService>()
val validPaddedBase64 = "AQ==" // 1 byte decoded (0x01), padded with ==
val payloadWithTrailingGarbage = validPaddedBase64 + "garbage"
coEvery { api.speakText(any(), any()) } returns
Response.success(
TtsSpeakResponse(
ok = true,
data_url = "data:audio/wav;base64,$payloadWithTrailingGarbage",
mime_type = "audio/wav",
provider = "piper",
),
)
val synthesizer: SpeechSynthesizer =
AndroidSpeechSynthesizer(
context = context,
api = api,
profile = null,
)
val request =
SpeechRequest(
scopeKey = "s1",
messageId = "m1",
text = "Garbage after padding",
)
try {
synthesizer.synthesize(request)
fail("Expected failure for base64 with trailing garbage after padding")
} catch (e: Exception) {
assertNotNull(e)
}
assertEquals("No temp files should remain after trailing garbage failure", 0, listTempFiles().size)
}
}
@@ -0,0 +1,738 @@
package com.m57.hermescontrol.ui.chat.components
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SpeechControllerTest {
private class FakeAudio(
val tag: String,
) : SpeechAudio {
var isDisposed: Boolean = false
private set
override fun dispose() {
isDisposed = true
}
}
private class FakeSynthesizer : SpeechSynthesizer {
var calls: MutableList<SpeechRequest> = mutableListOf()
var synthesizeBlock: (suspend (SpeechRequest) -> SpeechAudio)? = null
override suspend fun synthesize(request: SpeechRequest): SpeechAudio {
calls.add(request)
return synthesizeBlock?.invoke(request) ?: FakeAudio(request.text)
}
}
private class FakePlayer : SpeechPlayer {
var currentlyPlaying: SpeechAudio? = null
var lastEndedCallback: (() -> Unit)? = null
var lastErrorCallback: (() -> Unit)? = null
var playCallCount: Int = 0
var stopCallCount: Int = 0
var playThrows: Throwable? = null
override fun play(
audio: SpeechAudio,
onEnded: () -> Unit,
onError: () -> Unit,
) {
playCallCount++
currentlyPlaying = audio
lastEndedCallback = onEnded
lastErrorCallback = onError
playThrows?.let { throw it }
}
override fun stop() {
stopCallCount++
currentlyPlaying = null
lastEndedCallback = null
lastErrorCallback = null
}
}
private class Fixture(
val scope: TestScope,
) {
val synthesizer = FakeSynthesizer()
val player = FakePlayer()
val errors = mutableListOf<Unit>()
val controller =
SpeechController(
scope = scope,
synthesizer = synthesizer,
player = player,
onError = { errors.add(Unit) },
)
}
@Test
fun `toggle with blank text is a no-op`() =
runTest {
val fixture = Fixture(this)
val blankReq = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = " ")
fixture.controller.toggle(blankReq)
advanceUntilIdle()
assertNull(fixture.controller.speakingId.value)
assertEquals(0, fixture.synthesizer.calls.size)
assertEquals(0, fixture.player.playCallCount)
assertEquals(0, fixture.errors.size)
}
@Test
fun `toggle with empty text is a no-op`() =
runTest {
val fixture = Fixture(this)
val emptyReq = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "")
fixture.controller.toggle(emptyReq)
advanceUntilIdle()
assertNull(fixture.controller.speakingId.value)
assertEquals(0, fixture.synthesizer.calls.size)
assertEquals(0, fixture.player.playCallCount)
assertEquals(0, fixture.errors.size)
}
@Test
fun `toggle synthesizes and plays audio, setting speakingId`() =
runTest {
val fixture = Fixture(this)
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals("msg-1", fixture.controller.speakingId.value)
assertEquals(1, fixture.synthesizer.calls.size)
assertEquals(req, fixture.synthesizer.calls[0])
assertEquals(1, fixture.player.playCallCount)
assertEquals(0, fixture.errors.size)
}
@Test
fun `toggle on same active request stops playback and clears speakingId`() =
runTest {
val fixture = Fixture(this)
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals("msg-1", fixture.controller.speakingId.value)
fixture.controller.toggle(req)
advanceUntilIdle()
assertNull(fixture.controller.speakingId.value)
assertTrue(fixture.player.stopCallCount >= 1)
}
@Test
fun `toggle on same active request while still synthesizing stops and cancels synthesis`() =
runTest {
val fixture = Fixture(this)
val gate = CompletableDeferred<SpeechAudio>()
fixture.synthesizer.synthesizeBlock = { gate.await() }
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
fixture.controller.toggle(req)
runCurrent()
// Toggle same request while synthesis is inflight
fixture.controller.toggle(req)
runCurrent()
assertNull(fixture.controller.speakingId.value)
// Even if synthesis completes later, generation fencing ensures it never plays
val audio = FakeAudio("Hello world")
gate.complete(audio)
advanceUntilIdle()
assertEquals(0, fixture.player.playCallCount)
assertNull(fixture.controller.speakingId.value)
assertEquals(0, fixture.errors.size)
}
@Test
fun `noncooperative synthesis cancelled during synthesis still disposes returned audio without playing`() =
runTest {
val fixture = Fixture(this)
val gate = CompletableDeferred<Unit>()
val resumeGate = CompletableDeferred<Unit>()
val audio = FakeAudio("Stale noncooperative audio")
fixture.synthesizer.synthesizeBlock = {
try {
gate.await()
} catch (t: Throwable) {
withContext(NonCancellable) {
resumeGate.await()
}
}
audio
}
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text")
fixture.controller.toggle(req)
runCurrent()
// Cancel by stopping
fixture.controller.stop()
runCurrent()
assertNull(fixture.controller.speakingId.value)
// Now let noncooperative synthesis produce audio after cancellation
resumeGate.complete(Unit)
advanceUntilIdle()
// Audio must be disposed, never played, no error emitted, and speakingId remains null
assertTrue(audio.isDisposed)
assertEquals(0, fixture.player.playCallCount)
assertNull(fixture.controller.speakingId.value)
assertEquals(0, fixture.errors.size)
}
@Test
fun `noncooperative synthesis superseded by replacement disposes stale audio without touching newer state`() =
runTest {
val fixture = Fixture(this)
val gate1 = CompletableDeferred<Unit>()
val resumeGate1 = CompletableDeferred<Unit>()
val audio1 = FakeAudio("Stale noncooperative audio 1")
val audio2 = FakeAudio("New active audio 2")
fixture.synthesizer.synthesizeBlock = { req ->
if (req.messageId == "msg-1") {
try {
gate1.await()
} catch (t: Throwable) {
withContext(NonCancellable) {
resumeGate1.await()
}
}
audio1
} else {
audio2
}
}
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text")
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text")
fixture.controller.toggle(req1)
runCurrent()
// Supersede with req2
fixture.controller.toggle(req2)
advanceUntilIdle()
// req2 should now be playing
assertEquals("msg-2", fixture.controller.speakingId.value)
assertEquals(audio2, fixture.player.currentlyPlaying)
assertEquals(1, fixture.player.playCallCount)
// Now release stale noncooperative synthesis from req1
resumeGate1.complete(Unit)
advanceUntilIdle()
// Stale audio1 must be disposed, no extra play calls, speakingId untouched, no errors
assertTrue(audio1.isDisposed)
assertFalse(audio2.isDisposed)
assertEquals("msg-2", fixture.controller.speakingId.value)
assertEquals(audio2, fixture.player.currentlyPlaying)
assertEquals(1, fixture.player.playCallCount)
assertEquals(0, fixture.errors.size)
}
@Test
fun `noncooperative synthesis throwing failure after replacement is ignored and does not invoke onError`() =
runTest {
val fixture = Fixture(this)
val gate1 = CompletableDeferred<Unit>()
val resumeGate1 = CompletableDeferred<Unit>()
val audio2 = FakeAudio("Active audio 2")
fixture.synthesizer.synthesizeBlock = { req ->
if (req.messageId == "msg-1") {
try {
gate1.await()
} catch (t: Throwable) {
withContext(NonCancellable) {
resumeGate1.await()
}
}
throw IllegalStateException("Stale synthesis explosion")
} else {
audio2
}
}
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text")
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text")
fixture.controller.toggle(req1)
runCurrent()
// Replace with req2
fixture.controller.toggle(req2)
advanceUntilIdle()
assertEquals("msg-2", fixture.controller.speakingId.value)
assertEquals(0, fixture.errors.size)
// Release stale failure
resumeGate1.complete(Unit)
advanceUntilIdle()
// Stale failure must not invoke onError or clear msg-2
assertEquals(0, fixture.errors.size)
assertEquals("msg-2", fixture.controller.speakingId.value)
}
@Test
fun `stop and restart race on same request ensures stale inflight completion does not disrupt new cycle`() =
runTest {
val fixture = Fixture(this)
val gate1 = CompletableDeferred<Unit>()
val resumeGate1 = CompletableDeferred<Unit>()
val audio1 = FakeAudio("Cycle 1 audio")
val audio2 = FakeAudio("Cycle 2 audio")
var callIndex = 0
fixture.synthesizer.synthesizeBlock = {
val idx = ++callIndex
if (idx == 1) {
try {
gate1.await()
} catch (t: Throwable) {
withContext(NonCancellable) {
resumeGate1.await()
}
}
audio1
} else {
audio2
}
}
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Same text")
// Cycle 1: start synthesis
fixture.controller.toggle(req)
runCurrent()
// Toggle same request while synthesizing -> cancels cycle 1
fixture.controller.toggle(req)
runCurrent()
assertNull(fixture.controller.speakingId.value)
// Cycle 2: restart SAME request immediately
fixture.controller.toggle(req)
advanceUntilIdle()
// Cycle 2 has synthesized audio2 and is playing it
assertEquals("msg-1", fixture.controller.speakingId.value)
assertEquals(audio2, fixture.player.currentlyPlaying)
assertEquals(1, fixture.player.playCallCount)
// Stale cycle 1 finally returns audio1
resumeGate1.complete(Unit)
advanceUntilIdle()
// Stale audio1 must be disposed and cycle 2 remains active and playing audio2
assertTrue(audio1.isDisposed)
assertFalse(audio2.isDisposed)
assertEquals("msg-1", fixture.controller.speakingId.value)
assertEquals(audio2, fixture.player.currentlyPlaying)
assertEquals(1, fixture.player.playCallCount)
assertEquals(0, fixture.errors.size)
}
@Test
fun `stop stops playback and clears speakingId`() =
runTest {
val fixture = Fixture(this)
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals("msg-1", fixture.controller.speakingId.value)
val stopCallsBefore = fixture.player.stopCallCount
fixture.controller.stop()
advanceUntilIdle()
assertNull(fixture.controller.speakingId.value)
assertTrue(fixture.player.stopCallCount > stopCallsBefore)
}
@Test
fun `replacement immediately stops active playback and cancels old request`() =
runTest {
val fixture = Fixture(this)
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text")
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text")
fixture.controller.toggle(req1)
advanceUntilIdle()
assertEquals("msg-1", fixture.controller.speakingId.value)
fixture.controller.toggle(req2)
advanceUntilIdle()
assertEquals("msg-2", fixture.controller.speakingId.value)
assertEquals(2, fixture.synthesizer.calls.size)
assertEquals(2, fixture.player.playCallCount)
}
@Test
fun `replacement cancels inflight synthesis and generation fencing rejects stale completion`() =
runTest {
val fixture = Fixture(this)
val gate1 = CompletableDeferred<SpeechAudio>()
val gate2 = CompletableDeferred<SpeechAudio>()
fixture.synthesizer.synthesizeBlock = { req ->
if (req.messageId == "msg-1") gate1.await() else gate2.await()
}
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text")
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text")
fixture.controller.toggle(req1)
runCurrent()
fixture.controller.toggle(req2)
runCurrent()
// Complete old stale synthesis
val audio1 = FakeAudio("First text")
gate1.complete(audio1)
runCurrent()
// Complete new synthesis
val audio2 = FakeAudio("Second text")
gate2.complete(audio2)
advanceUntilIdle()
assertEquals("msg-2", fixture.controller.speakingId.value)
assertEquals(1, fixture.player.playCallCount)
assertEquals(audio2, fixture.player.currentlyPlaying)
assertEquals(0, fixture.errors.size)
}
@Test
fun `player onEnded callback resets speakingId to null`() =
runTest {
val fixture = Fixture(this)
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals("msg-1", fixture.controller.speakingId.value)
val onEnded = fixture.player.lastEndedCallback
onEnded?.invoke()
advanceUntilIdle()
assertNull(fixture.controller.speakingId.value)
assertEquals(0, fixture.errors.size)
}
@Test
fun `stale onEnded callback from superseded generation does not clear active speakingId`() =
runTest {
val fixture = Fixture(this)
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First")
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second")
fixture.controller.toggle(req1)
advanceUntilIdle()
val staleOnEnded = fixture.player.lastEndedCallback
fixture.controller.toggle(req2)
advanceUntilIdle()
assertEquals("msg-2", fixture.controller.speakingId.value)
staleOnEnded?.invoke()
advanceUntilIdle()
// Still speaking msg-2!
assertEquals("msg-2", fixture.controller.speakingId.value)
}
@Test
fun `stale onError callback from superseded generation is ignored and does not invoke onError`() =
runTest {
val fixture = Fixture(this)
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First")
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second")
fixture.controller.toggle(req1)
advanceUntilIdle()
val staleOnError = fixture.player.lastErrorCallback
fixture.controller.toggle(req2)
advanceUntilIdle()
assertEquals("msg-2", fixture.controller.speakingId.value)
staleOnError?.invoke()
advanceUntilIdle()
assertEquals("msg-2", fixture.controller.speakingId.value)
assertEquals(0, fixture.errors.size)
}
@Test
fun `player onError callback clears speakingId and triggers onError`() =
runTest {
val fixture = Fixture(this)
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals("msg-1", fixture.controller.speakingId.value)
val onError = fixture.player.lastErrorCallback
onError?.invoke()
advanceUntilIdle()
assertNull(fixture.controller.speakingId.value)
assertEquals(1, fixture.errors.size)
}
@Test
fun `synthesizer failure triggers onError and clears speakingId`() =
runTest {
val fixture = Fixture(this)
fixture.synthesizer.synthesizeBlock = {
throw IllegalStateException("Synthesis failed")
}
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
fixture.controller.toggle(req)
advanceUntilIdle()
assertNull(fixture.controller.speakingId.value)
assertEquals(1, fixture.errors.size)
assertEquals(0, fixture.player.playCallCount)
}
@Test
fun `cancellations during synthesis do not trigger onError`() =
runTest {
val fixture = Fixture(this)
val gate = CompletableDeferred<SpeechAudio>()
fixture.synthesizer.synthesizeBlock = { gate.await() }
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world")
fixture.controller.toggle(req)
runCurrent()
// Stop cancels the inflight coroutine
fixture.controller.stop()
advanceUntilIdle()
assertNull(fixture.controller.speakingId.value)
assertEquals(0, fixture.errors.size)
}
@Test
fun `replaying same request uses cached audio without re-synthesizing and stop preserves cache`() =
runTest {
val fixture = Fixture(this)
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Cached audio")
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals(1, fixture.synthesizer.calls.size)
assertEquals(1, fixture.player.playCallCount)
// Stop playback — must preserve replay cache
fixture.controller.stop()
advanceUntilIdle()
assertNull(fixture.controller.speakingId.value)
// Toggle again with same request -> should replay from cache without re-synthesizing
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals("msg-1", fixture.controller.speakingId.value)
assertEquals(1, fixture.synthesizer.calls.size)
assertEquals(2, fixture.player.playCallCount)
}
@Test
fun `request with different text invalidates cache and disposes old audio`() =
runTest {
val fixture = Fixture(this)
val audio1 = FakeAudio("Text 1")
val audio2 = FakeAudio("Text 2")
fixture.synthesizer.synthesizeBlock = { req ->
if (req.text == "Text 1") audio1 else audio2
}
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Text 1")
fixture.controller.toggle(req1)
advanceUntilIdle()
assertEquals(audio1, fixture.player.currentlyPlaying)
assertFalse(audio1.isDisposed)
val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Text 2")
fixture.controller.toggle(req2)
advanceUntilIdle()
assertEquals(audio2, fixture.player.currentlyPlaying)
assertTrue(audio1.isDisposed)
assertEquals(2, fixture.synthesizer.calls.size)
}
@Test
fun `request with different scope invalidates cache and disposes old audio`() =
runTest {
val fixture = Fixture(this)
val audio1 = FakeAudio("Scope 1 audio")
val audio2 = FakeAudio("Scope 2 audio")
fixture.synthesizer.synthesizeBlock = { req ->
if (req.scopeKey == "session-1") audio1 else audio2
}
val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello")
fixture.controller.toggle(req1)
advanceUntilIdle()
assertFalse(audio1.isDisposed)
val req2 = SpeechRequest(scopeKey = "session-2", messageId = "msg-1", text = "Hello")
fixture.controller.toggle(req2)
advanceUntilIdle()
assertEquals(audio2, fixture.player.currentlyPlaying)
assertTrue(audio1.isDisposed)
assertEquals(2, fixture.synthesizer.calls.size)
}
@Test
fun `replaying cached request when player throws does not escape toggle and clears speakingId`() =
runTest {
val fixture = Fixture(this)
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Cached text")
// Initial successful playback
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals("msg-1", fixture.controller.speakingId.value)
assertEquals(1, fixture.synthesizer.calls.size)
assertEquals(1, fixture.player.playCallCount)
// Complete initial playback
fixture.player.lastEndedCallback?.invoke()
advanceUntilIdle()
assertNull(fixture.controller.speakingId.value)
assertEquals(0, fixture.errors.size)
// Configure player to throw synchronously on replay
val stopCallsBeforeReplay = fixture.player.stopCallCount
fixture.player.playThrows = IllegalStateException("Player init failed")
// Replay exact cached request
fixture.controller.toggle(req)
advanceUntilIdle()
// Must NOT escape toggle, speakingId null, onError exactly once,
// partially initialized player stopped, no resynthesis
assertNull(fixture.controller.speakingId.value)
assertEquals(1, fixture.errors.size)
assertTrue(fixture.player.stopCallCount > stopCallsBeforeReplay)
assertEquals(1, fixture.synthesizer.calls.size)
assertEquals(2, fixture.player.playCallCount)
// Replay cache is retained: if player recovers, replaying exact request does not re-synthesize
fixture.player.playThrows = null
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals("msg-1", fixture.controller.speakingId.value)
assertEquals(1, fixture.synthesizer.calls.size)
assertEquals(3, fixture.player.playCallCount)
assertEquals(1, fixture.errors.size)
}
@Test
fun `initial play when player throws does not escape toggle and clears speakingId`() =
runTest {
val fixture = Fixture(this)
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Initial throw text")
fixture.player.playThrows = IllegalStateException("Player failed synchronously on first play")
// Initial noncached play throw
fixture.controller.toggle(req)
advanceUntilIdle()
// Must NOT escape toggle, speakingId null, onError exactly once, stopped partially initialized player
assertNull(fixture.controller.speakingId.value)
assertEquals(1, fixture.errors.size)
assertTrue(fixture.player.stopCallCount >= 1)
assertEquals(1, fixture.synthesizer.calls.size)
assertEquals(1, fixture.player.playCallCount)
// Replay cache retained: if player recovers, next toggle on exact
// request uses cached audio without re-synthesizing
fixture.player.playThrows = null
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals("msg-1", fixture.controller.speakingId.value)
assertEquals(1, fixture.synthesizer.calls.size)
assertEquals(2, fixture.player.playCallCount)
assertEquals(1, fixture.errors.size)
}
@Test
fun `reset clears speakingId, stops player, and disposes cached audio`() =
runTest {
val fixture = Fixture(this)
val audio = FakeAudio("Reset audio")
fixture.synthesizer.synthesizeBlock = { audio }
val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Reset audio")
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals("msg-1", fixture.controller.speakingId.value)
assertFalse(audio.isDisposed)
fixture.controller.reset()
advanceUntilIdle()
assertNull(fixture.controller.speakingId.value)
assertTrue(audio.isDisposed)
assertTrue(fixture.player.stopCallCount >= 1)
// Re-toggling same request after reset must re-synthesize because cache was cleared/disposed
val audio2 = FakeAudio("Reset audio 2")
fixture.synthesizer.synthesizeBlock = { audio2 }
fixture.controller.toggle(req)
advanceUntilIdle()
assertEquals(2, fixture.synthesizer.calls.size)
assertEquals("msg-1", fixture.controller.speakingId.value)
}
}
@@ -0,0 +1,250 @@
package com.m57.hermescontrol.ui.chat.components
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SpeechTextTest {
@Test
fun `blank input returns blank string`() {
assertEquals("", SpeechText.stripMarkdownForSpeech(""))
assertEquals("", SpeechText.stripMarkdownForSpeech(" "))
assertEquals("", SpeechText.stripMarkdownForSpeech("\n\t\n "))
}
@Test
fun `plain text is preserved without changes`() {
val input = "Hello world this is a test"
val result = SpeechText.stripMarkdownForSpeech(input)
assertEquals("Hello world this is a test", result.trim())
}
@Test
fun `bold and italic markers are removed keeping prose`() {
val input = "This is **bold** text and *italic* text and ***bold italic*** text"
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains("*"))
assertTrue(result.contains("bold"))
assertTrue(result.contains("italic"))
assertTrue(result.contains("bold italic"))
}
@Test
fun `strikethrough markers are removed keeping inner text`() {
val input = "This is ~~cancelled~~ completed"
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains("~"))
assertTrue(result.contains("cancelled"))
assertTrue(result.contains("completed"))
}
@Test
fun `headers keep title text without hash characters`() {
val input =
"""
# Header 1
## Header 2
### Header 3
""".trimIndent()
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains("#"))
assertTrue(result.contains("Header 1"))
assertTrue(result.contains("Header 2"))
assertTrue(result.contains("Header 3"))
}
@Test
fun `inline code keeps code content without backticks`() {
val input = "Run `val my_value = 42` to configure"
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains("`"))
assertTrue(result.contains("val my_value = 42"))
assertTrue(result.contains("my_value"))
assertTrue(result.contains("to configure"))
assertFalse(result.contains(" "))
}
@Test
fun `fenced code block keeps code body and removes fences and language identifier`() {
val input =
"""
Here is some code:
```kotlin
fun main() {
println("Hello")
}
```
Done.
""".trimIndent()
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains("```"))
assertFalse(result.contains("kotlin"))
assertTrue(result.contains("fun main()"))
assertTrue(result.contains("""println("Hello")"""))
assertTrue(result.contains("Here is some code:"))
assertTrue(result.contains("Done."))
}
@Test
fun `fenced code block body retains underscores, generics, multiplication, and pipes`() {
val input =
"""
Here is complex code:
```typescript
const a_b_c: List<T> = items.map(x => x * 2 | 0);
```
Done...
""".trimIndent()
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains("```"))
assertFalse(result.contains("typescript"))
assertTrue(result.contains("a_b_c"))
assertTrue(result.contains("<T>"))
assertTrue(result.contains("* 2"))
assertTrue(result.contains("| 0"))
assertTrue(result.contains("Here is complex code:"))
assertTrue(result.contains("Done..."))
}
@Test
fun `markdown links keep label text and strip destination url`() {
val input = "Check out [Hermes Documentation](https://hermes-agent.nousresearch.com/docs) for info"
val result = SpeechText.stripMarkdownForSpeech(input)
assertTrue(result.contains("Hermes Documentation"))
assertFalse(result.contains("https://hermes-agent.nousresearch.com/docs"))
assertFalse(result.contains("[") || result.contains("]"))
}
@Test
fun `image markdown keeps alt text and strips image url`() {
val input = "Here is a diagram: ![Architecture diagram](https://example.com/arch.png)"
val result = SpeechText.stripMarkdownForSpeech(input)
assertTrue(result.contains("Architecture diagram"))
assertFalse(result.contains("https://example.com/arch.png"))
assertFalse(result.contains("!["))
}
@Test
fun `bare urls are removed`() {
val input = "Visit https://example.com or http://test.org/path?arg=1 today"
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains("https://example.com"))
assertFalse(result.contains("http://test.org/path?arg=1"))
assertTrue(result.contains("Visit"))
assertTrue(result.contains("today"))
}
@Test
fun `html tags are stripped keeping inner text`() {
val input = "<p>This is a <b>bold</b> paragraph with a <br/> break.</p>"
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains("<"))
assertFalse(result.contains(">"))
assertTrue(result.contains("This is a"))
assertTrue(result.contains("bold"))
assertTrue(result.contains("paragraph with a"))
assertTrue(result.contains("break."))
}
@Test
fun `blockquote markers are removed keeping content`() {
val input =
"""
> This is a quote.
> It spans multiple lines.
""".trimIndent()
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains(">"))
assertTrue(result.contains("This is a quote."))
assertTrue(result.contains("It spans multiple lines."))
}
@Test
fun `unordered and ordered list markers are handled sensibly`() {
val input =
"""
Items:
* First item
* Second item
1. Step one
2. Step two
""".trimIndent()
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains("*"))
assertFalse(result.contains("1."))
assertFalse(result.contains("2."))
assertTrue(result.contains("First item"))
assertTrue(result.contains("Second item"))
assertTrue(result.contains("Step one"))
assertTrue(result.contains("Step two"))
}
@Test
fun `arabic text is preserved intact`() {
val input = "مرحبا بك في تطبيق هيرمس! **أهلا وسهلا**"
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains("*"))
assertTrue(result.contains("مرحبا بك في تطبيق هيرمس!"))
assertTrue(result.contains("أهلا وسهلا"))
}
@Test
fun `unicode and emoji are preserved`() {
val input = "Hello 👋 world! 🚀 Enjoy the day ☀️"
val result = SpeechText.stripMarkdownForSpeech(input)
assertTrue(result.contains("👋"))
assertTrue(result.contains("🚀"))
assertTrue(result.contains("☀️"))
assertTrue(result.contains("Hello"))
assertTrue(result.contains("world!"))
}
@Test
fun `punctuation and code punctuation edge cases are handled without double punctuation`() {
val input = "Wait... is that it?! Yes, it is: done."
val result = SpeechText.stripMarkdownForSpeech(input)
assertTrue(result.contains("Wait..."))
assertTrue(result.contains("done."))
assertFalse(result.contains("..,"))
assertFalse(result.contains(",,"))
}
@Test
fun `newlines retain punctuation without introducing duplicate or mixed punctuation and normalize whitespace`() {
val input =
"""
Done.
Next!
مرحبا؟
Again
""".trimIndent()
val result = SpeechText.stripMarkdownForSpeech(input)
assertFalse(result.contains(". ."))
assertFalse(result.contains("!."))
assertFalse(result.contains("؟."))
assertFalse(result.contains("\n\n"))
assertTrue(result.contains("Done."))
assertTrue(result.contains("Next!"))
assertTrue(result.contains("مرحبا؟"))
assertTrue(result.contains("Again"))
}
@Test
fun `horizontal rules are removed`() {
val input =
"""
Section 1
---
Section 2
***
Section 3
""".trimIndent()
val result = SpeechText.stripMarkdownForSpeech(input)
assertTrue(result.contains("Section 1"))
assertTrue(result.contains("Section 2"))
assertTrue(result.contains("Section 3"))
assertFalse(result.contains("---"))
assertFalse(result.contains("***"))
}
}