fix: Keep translated speech matched to the right sentence
Some checks are pending
Build / build (push) Waiting to run

Reading a translated page aloud could still play the wrong translation
in a few cases. When the same sentence appeared twice in a row, the
reader sometimes still pointed at the first copy when the voice asked
for the second, so the second copy reused the first one's translation.
Skipping forward or back between identical sentences had the same
problem. The player now waits briefly for the reader to move to the
new copy, and remembers where a skip started so it doesn't settle on
that spot again. When two copies sit in exactly the same surroundings
the reader can't tell them apart, so after a short wait the player
reads on instead of stopping with an error.

Audio that finished after the translation settings changed could also
play in the old language. The player now checks the settings again
when the audio arrives and fetches fresh audio if they changed, even
when the old request failed because of that change.

Finally, when translating a whole page failed, the failure only held
back sentences for the exact settings in use. It now holds them back
under any settings until the page is retried, so the reader doesn't
quietly fall back to the original text.
This commit is contained in:
Chmouel Boudjnah 2026-10-12 19:28:53 +02:00
parent e0ca4acb2b
commit 348bde6298
9 changed files with 429 additions and 35 deletions

View file

@ -118,7 +118,7 @@ class SentenceTranslations(
}
private suspend fun resolve(sentence: PageSentence, context: String?, identity: String): Translated {
pageFailureFor(sentence, identity)?.let { throw it }
pageFailureFor(sentence)?.let { throw it }
acceptedFor(sentence, identity)?.let { return it }
val key = sentence.text to context
while (true) {
@ -158,11 +158,11 @@ class SentenceTranslations(
?.second
/** The failure the active page walk left for [sentence] under [identity], if any. */
private fun pageFailureFor(sentence: PageSentence, identity: String): TranslationError? {
private fun pageFailureFor(sentence: PageSentence): TranslationError? {
val scope = pageScope ?: return null
return pageFailures[Triple(sentence.href, sentence.selector, sentence.text)]
?.firstOrNull {
it.scope === scope && it.identity == identity && sameBefore(it.before, sentence.before)
it.scope === scope && sameBefore(it.before, sentence.before)
}
?.error
}
@ -191,7 +191,7 @@ class SentenceTranslations(
// Another context may have been accepted for this place meanwhile: the first one stands, shown and spoken alike.
private fun settled(sentence: PageSentence, translated: Translated): Translated {
pageFailureFor(sentence, translated.by)?.let { throw it }
pageFailureFor(sentence)?.let { throw it }
accept(sentence, translated)
return acceptedFor(sentence, translated.by) ?: translated
}

View file

@ -25,6 +25,22 @@ import org.readium.r2.shared.util.Url
typealias SpeechNavigator =
TtsNavigator<SpeechTtsSettings, SpeechTtsPreferences, SpeechTtsEngine.Error, SpeechTtsEngine.Voice>
internal fun matchesSpeechRequestLocation(
utterance: String,
locator: Locator,
text: String,
previousPlace: Locator?,
allowSamePlace: Boolean,
skipFromPlace: Locator? = null,
): Boolean {
if (utterance != text) return false
return when {
skipFromPlace != null -> !SpeechKey.sameLocation(text, locator, skipFromPlace)
allowSamePlace || previousPlace == null -> true
else -> !SpeechKey.sameLocation(text, locator, previousPlace)
}
}
/** Opens a paused navigator at [initial], its engine reporting to [observer]. Null when the book cannot be read aloud. */
@OptIn(ExperimentalReadiumApi::class)
fun interface NavigatorOpener {
@ -50,6 +66,7 @@ class ReadAloudPlayback(
private val onStopRequested: () -> Unit = {},
private val onChapterEnded: () -> Unit = {},
private val stepTimeoutMillis: Long = 2_000,
private val distinctPlaceGraceMillis: Long = 500,
/**
* Read translated: each request is filed under the book's sentence the
* navigator is on ([keyFor]), not its words alone, since the same words
@ -96,6 +113,8 @@ class ReadAloudPlayback(
// Where each recent request is in the book, once told.
private val places = HashMap<TtsEngine.RequestId, Locator>()
private val skipOrigins = HashMap<TtsEngine.RequestId, Locator>()
private var pendingSkipOrigin: Locator? = null
private val mutableHeard = MutableStateFlow<Locator?>(null)
@ -225,13 +244,17 @@ class ReadAloudPlayback(
fun skipToNext() {
mutableFailure.value = null
skips++
mutableNavigator.value?.skipToNextUtterance()
val navigator = mutableNavigator.value
pendingSkipOrigin = navigator?.location?.value?.utteranceLocator
navigator?.skipToNextUtterance()
}
fun skipToPrevious() {
mutableFailure.value = null
skips++
mutableNavigator.value?.skipToPreviousUtterance()
val navigator = mutableNavigator.value
pendingSkipOrigin = navigator?.location?.value?.utteranceLocator
navigator?.skipToPreviousUtterance()
}
fun close() {
@ -242,6 +265,8 @@ class ReadAloudPlayback(
mutableNavigator.value = null
spoken.clear()
places.clear()
skipOrigins.clear()
pendingSkipOrigin = null
waitFor(null)
}
@ -288,6 +313,8 @@ class ReadAloudPlayback(
val previous = mutableNavigator.value
spoken.clear()
places.clear()
skipOrigins.clear()
pendingSkipOrigin = null
waitFor(null)
mutableNavigator.value = fresh
previous?.close()
@ -306,21 +333,53 @@ class ReadAloudPlayback(
return false
}
}
pendingSkipOrigin?.let { skipOrigins[requestId] = it }
pendingSkipOrigin = null
spoken[requestId] = text
if (spoken.size > SPOKEN_KEPT) {
val oldest = spoken.keys.first()
spoken.remove(oldest)
places.remove(oldest)
skipOrigins.remove(oldest)
}
return true
}
override suspend fun keyFor(requestId: TtsEngine.RequestId, text: String): SpeechKey? {
override suspend fun keyFor(requestId: TtsEngine.RequestId, text: String): SpeechKey? =
keyFor(requestId, text, reusingHeldAudio = false)
override suspend fun keyFor(
requestId: TtsEngine.RequestId,
text: String,
reusingHeldAudio: Boolean,
): SpeechKey? {
if (!anchored) return SpeechKey(text)
val navigator = mutableNavigator.value ?: return null
if (requestId !in spoken) return null
// Readium publishes the sentence it asks for, possibly a moment after asking.
val location = withTimeoutOrNull(stepTimeoutMillis) {
val skipFromPlace = skipOrigins.remove(requestId)
val previousPlace = spoken.entries
.takeWhile { it.key != requestId }
.lastOrNull { (previousId, previousText) ->
previousText == text && previousId in places
}
?.let { places[it.key] }
val distinct = skipFromPlace != null || (!reusingHeldAudio && previousPlace != null)
// Readium publishes the sentence it asks for, possibly a moment after
// asking, and until then may still show the same words at their last
// place. Identical words in identical surroundings have no distinct
// place to wait for, and file under the same key whichever is shown.
val location = (if (distinct) withTimeoutOrNull(distinctPlaceGraceMillis) {
navigator.location.first {
matchesSpeechRequestLocation(
utterance = it.utterance,
locator = it.utteranceLocator,
text = text,
previousPlace = previousPlace,
allowSamePlace = reusingHeldAudio,
skipFromPlace = skipFromPlace,
)
}
} else null) ?: withTimeoutOrNull(stepTimeoutMillis) {
navigator.location.first { it.utterance == text }
} ?: return null
if (mutableNavigator.value !== navigator || requestId !in spoken) return null
@ -328,6 +387,12 @@ class ReadAloudPlayback(
return SpeechKey.at(text, location.utteranceLocator, translationIdentity())
}
override suspend fun refreshKey(
requestId: TtsEngine.RequestId,
text: String,
key: SpeechKey,
): SpeechKey = if (anchored) key.copy(translationIdentity = translationIdentity()) else key
override fun previousOf(requestId: TtsEngine.RequestId): String? {
val place = places[requestId] ?: return null
val ids = spoken.keys.toList()

View file

@ -33,5 +33,9 @@ data class SpeechKey(
selector = locator.locations.otherLocations[CSS_SELECTOR] as? String,
before = locator.text.before,
)
/** Whether [one] and [other] name the same occurrence of [text]. */
fun sameLocation(text: String, one: Locator, other: Locator): Boolean =
at(text, one) == at(text, other)
}
}

View file

@ -72,6 +72,18 @@ interface SpeechObserver {
*/
suspend fun keyFor(requestId: TtsEngine.RequestId, text: String): SpeechKey? = SpeechKey(text)
/**
* [reusingHeldAudio] is true when this request resumes audio held for the same sentence.
*/
suspend fun keyFor(
requestId: TtsEngine.RequestId,
text: String,
reusingHeldAudio: Boolean,
): SpeechKey? = keyFor(requestId, text)
/** Rechecks mutable settings after audio arrives without relocating this request's sentence. */
suspend fun refreshKey(requestId: TtsEngine.RequestId, text: String, key: SpeechKey): SpeechKey = key
/**
* The book's text straight before the request [keyFor] placed, for a
* translation made now to read it in context; null when unknown.
@ -210,7 +222,7 @@ class SpeechTtsEngine(
finish(request) { onInterrupted(requestId) }
return@launch
}
val key = observer.keyFor(requestId, text)
val key = observer.keyFor(requestId, text, reusingHeldAudio = resumed != null)
if (request.finished) return@launch
if (key == null) {
request.audio?.cancel()
@ -225,13 +237,13 @@ class SpeechTtsEngine(
request.key = null
request.from = 0
}
val audio = request.audio ?: run {
if (request.audio == null) {
request.key = key
cache.claim(key, observer.previousOf(requestId)).also { request.audio = it }
request.audio = cache.claim(key, observer.previousOf(requestId))
}
observer.onWaiting(requestId, true)
val speech = try {
audio.await()
awaitCurrentAudio(request)
} catch (e: CancellationException) {
throw e
} catch (e: SpeechError) {
@ -260,6 +272,38 @@ class SpeechTtsEngine(
}
}
private suspend fun awaitCurrentAudio(request: Request): SpeechAudio {
var audio = checkNotNull(request.audio)
while (true) {
val speech = try {
audio.await()
} catch (e: CancellationException) {
throw e
} catch (e: SpeechError) {
if (!refreshAudio(request)) throw e
audio = checkNotNull(request.audio)
continue
} catch (e: TranslationError) {
if (!refreshAudio(request)) throw e
audio = checkNotNull(request.audio)
continue
}
if (request.finished || !refreshAudio(request)) return speech
audio = checkNotNull(request.audio)
}
}
private suspend fun refreshAudio(request: Request): Boolean {
val previous = checkNotNull(request.key)
val current = observer.refreshKey(request.id, request.text, previous)
if (current == previous) return false
request.audio?.cancel()
request.key = current
request.from = 0
request.audio = cache.claim(current, observer.previousOf(request.id))
return true
}
override fun stop() {
val heard = output.halt()
val request = current ?: return

View file

@ -202,10 +202,43 @@ class OpenAiChatClientTest {
}
@Test
fun `a redirect is not followed`(): Unit = runBlocking {
server.enqueue(MockResponse.Builder().code(302).addHeader("Location", server.url("/elsewhere").toString()).build())
expect<TranslationError.Service> { runBlocking { client.translate(base, "secret", "m", "fr", "en", "x") } }
assertEquals(1, server.requestCount)
fun `model discovery sends the configured key as a bearer token`(): Unit = runBlocking {
server.enqueue(json("""{"data":[{"id":"gpt-4o-mini"}]}"""))
assertEquals(listOf(TextModel("gpt-4o-mini")), client.textModels(base, "discovery-key"))
val request = server.takeRequest()
assertEquals("/v1/models", request.url.encodedPath)
assertEquals("Bearer discovery-key", request.headers["Authorization"])
}
@Test
fun `a key that cannot be a header is rejected without repeating it`(): Unit = runBlocking {
val error = expect<TranslationError.InvalidKey> {
runBlocking { client.translate(base, "secret\nkey", "m", "fr", "en", "x") }
}
assertFalse(error.message.orEmpty().contains("secret"))
assertEquals(0, server.requestCount)
}
@Test
fun `a redirect does not send the key or passage to another server`(): Unit = runBlocking {
MockWebServer().use { elsewhere ->
elsewhere.start(InetAddress.getByName("127.0.0.1"), 0)
server.enqueue(
MockResponse.Builder()
.code(307)
.addHeader("Location", elsewhere.url("/elsewhere").toString())
.build(),
)
expect<TranslationError.Service> {
runBlocking { client.translate(base, "secret", "m", "fr", "en", "private passage") }
}
assertEquals(1, server.requestCount)
assertEquals(0, elsewhere.requestCount)
}
}
@Test

View file

@ -160,7 +160,7 @@ class SentenceTranslationsTest {
}
@Test
fun `a page failure stays with the identity that failed the request`() = runTest {
fun `a page failure stays visible until that sentence is retried`() = runTest {
val translator = Translator().apply {
failWith = TranslationError.Refused()
identityAfterFailure = "svc-2"
@ -173,8 +173,16 @@ class SentenceTranslationsTest {
assertTrue(failed.exceptionOrNull() is TranslationError.Refused)
translator.failWith = null
translator.identityAfterFailure = null
assertEquals("UN<>", translations.resolve(at("un", null), null).text)
translator.identity = "svc-2"
val stillFailed = runCatching { translations.resolve(at("un", null), null) }
assertTrue(stillFailed.exceptionOrNull() is TranslationError.Refused)
assertEquals(1, translator.asked.size)
val retried = translations.resolveForPage(scope, at("un", null), null)
assertEquals("UN<>", retried.text)
assertEquals("svc-2", retried.by)
assertEquals(2, translator.asked.size)
assertEquals(retried, translations.resolve(at("un", null), null))
translations.endPageWalk(scope)
}

View file

@ -14,6 +14,7 @@ import kotlinx.coroutines.withContext
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
@ -91,18 +92,22 @@ class ReadAloudPlaybackTest {
@Before
fun setUp() = runBlocking {
clock.start()
val context = ApplicationProvider.getApplicationContext<Application>()
val http = DefaultHttpClient()
val assets = AssetRetriever(context.contentResolver, http)
val epub = File(folder.root, "book.epub").also(::writeEpub)
val asset = assets.retrieve(epub.toUrl(isDirectory = false)).getOrNull()!!
publication = PublicationOpener(DefaultPublicationParser(context, http, assets, pdfFactory = null))
.open(asset, allowUserInteraction = false).getOrNull()!!
publication = openPublication(epub)
cache = SpeechCache(scope, synthesize)
playback = newPlayback()
}
private suspend fun openPublication(epub: File): Publication {
val context = ApplicationProvider.getApplicationContext<Application>()
val http = DefaultHttpClient()
val assets = AssetRetriever(context.contentResolver, http)
val asset = assets.retrieve(epub.toUrl(isDirectory = false)).getOrNull()!!
return PublicationOpener(DefaultPublicationParser(context, http, assets, pdfFactory = null))
.open(asset, allowUserInteraction = false).getOrNull()!!
}
private val synthesize: suspend (String) -> SpeechAudio = { text ->
val nth = requested.getOrPut(text) { AtomicInteger() }.incrementAndGet()
// A late answer, as from a slow network.
@ -124,6 +129,7 @@ class ReadAloudPlaybackTest {
/** Fails the nth translation of a sentence, once. */
private val translationFailures = ConcurrentHashMap<String, Int>()
private val translationsAsked = ConcurrentHashMap<String, AtomicInteger>()
private val translationHoldsByRequest = ConcurrentHashMap<Pair<String, Int>, CompletableDeferred<Unit>>()
/** The text each translation was asked with as the one before it, in the order asked. */
private val translatedAfter: MutableList<Pair<String, String?>> = Collections.synchronizedList(mutableListOf())
@ -132,13 +138,19 @@ class ReadAloudPlaybackTest {
private fun translationOf(key: SpeechKey) = "T[${key.text}]#${key.place?.before.orEmpty().length}"
/** Reads the book translated: each request translated by a fake, where it is, before its audio. */
private fun readTranslated(prefetch: Boolean = false) {
private fun readTranslated(prefetch: Boolean = false, repeatedCount: Int? = null) {
runBlocking(main) { playback.close() }
if (repeatedCount != null) {
publication.close()
val epub = File(folder.root, "repeated-book.epub").also { writeEpub(it, repeatedCount) }
publication = runBlocking { openPublication(epub) }
}
cache = SpeechCache(scope, synthesize, prepare = { key, previous ->
translatedKeys += key
translatedAfter += key.text to previous
val nth = translationsAsked.getOrPut(key.text) { AtomicInteger() }.incrementAndGet()
translationHolds[key.text]?.await()
translationHoldsByRequest[key.text to nth]?.await()
if (translationFailures[key.text] == nth) {
translationFailures.remove(key.text)
throw com.chmouel.liseur.translate.TranslationError.Refused()
@ -235,6 +247,14 @@ class ReadAloudPlaybackTest {
}
}
private fun awaitTranslations(text: String, count: Int) {
val deadline = System.currentTimeMillis() + 10_000
while ((translationsAsked[text]?.get() ?: 0) < count) {
check(System.currentTimeMillis() < deadline) { "only asked ${translationsAsked[text]?.get() ?: 0} times for $text" }
Thread.sleep(10)
}
}
@Test
fun aSentenceWhoseAudioIsLateIsWaitingUntilPaused() {
holds[SECOND] = CompletableDeferred()
@ -672,6 +692,45 @@ class ReadAloudPlaybackTest {
assertEquals(keys.map(::translationOf), played.take(7))
}
@Test
fun anAdvancingRepeatedSentenceWaitsForItsOwnLocationButResumeCanReuseOne() {
val first = chapterStart.copy(
locations = chapterStart.locations.copy(position = 1),
text = Locator.Text(before = "First occurrence", highlight = REPEATED),
)
val second = chapterStart.copy(
locations = chapterStart.locations.copy(position = 2),
text = Locator.Text(before = "Second occurrence", highlight = REPEATED),
)
assertFalse(matchesSpeechRequestLocation(REPEATED, first, REPEATED, first, allowSamePlace = false))
assertTrue(matchesSpeechRequestLocation(REPEATED, second, REPEATED, first, allowSamePlace = false))
assertTrue(matchesSpeechRequestLocation(REPEATED, first, REPEATED, first, allowSamePlace = true))
// Skipping back from an occurrence waits for any other place, even one heard before.
assertFalse(
matchesSpeechRequestLocation(REPEATED, first, REPEATED, null, allowSamePlace = true, skipFromPlace = first),
)
assertTrue(
matchesSpeechRequestLocation(REPEATED, second, REPEATED, second, allowSamePlace = false, skipFromPlace = first),
)
}
@Test
fun repeatedSentencesWithTheSameSurroundingsPlayThrough() {
// Longer than Readium's context, so every repeat after the first has the same words before it.
readTranslated(repeatedCount = 4)
runBlocking(main) { playback.start(chapterStart) }
awaitPlayed(9)
assertTrue("playback failure: ${playback.failure.value}", playback.failure.value == null)
val repeats = synchronized(translatedKeys) { translatedKeys.filter { it.text == REPEATED } }
assertEquals(4, repeats.size)
assertTrue(repeats[0] != repeats[1])
assertEquals(1, repeats.drop(1).distinct().size)
assertEquals(4, played.count { it.startsWith("T[$REPEATED]") })
assertTrue(played[8].startsWith("T[$LAST]"))
}
@Test
fun aLateTranslationHoldsTheVoiceOnItsSentenceThenSpeaksItOnce() {
readTranslated()
@ -800,6 +859,86 @@ class ReadAloudPlaybackTest {
assertEquals(SECOND to null, translatedAfter.last { it.first == SECOND })
}
@Test
fun skippingBackTwiceUsesTheLiveReadiumPosition() {
readTranslated()
translationHoldsByRequest[REPEATED to 2] = CompletableDeferred()
translationHoldsByRequest[REPEATED to 3] = CompletableDeferred()
runBlocking(main) { playback.start(chapterStart) }
awaitPlayed(5)
awaitTranslations(REPEATED, 2)
runBlocking(main) { playback.skipToPrevious() }
awaitTranslations(REPEATED, 3)
runBlocking(main) { playback.skipToPrevious() }
awaitPlayed(6)
val repeats = synchronized(translatedKeys) { translatedKeys.filter { it.text == REPEATED } }
// The first skip landed on the earlier occurrence, not the one it left.
assertEquals(repeats[0], repeats[2])
// The second skip went back from there, to the sentence before it.
assertEquals(translationOf(translatedKeys.last { it.text == FOURTH }), played[5])
assertEquals(FOURTH to null, translatedAfter.last { it.first == FOURTH })
}
@Test
fun skippingBackWhilePausedUsesTheLiveReadiumPosition() {
readTranslated()
val lateTranslation = CompletableDeferred<Unit>()
translationHolds[FOURTH] = lateTranslation
runBlocking(main) { playback.start(chapterStart) }
awaitPlayed(3)
awaitWaiting(true)
runBlocking(main) { playback.pause() }
awaitWaiting(false)
runBlocking(main) {
playback.skipToPrevious()
playback.resume()
}
lateTranslation.complete(Unit)
awaitPlayed(4)
assertEquals(THIRD, translatedKeys.last().text)
}
@Test
fun skippingForwardFromARepeatedSentenceLandsOnTheNextOccurrence() {
readTranslated(repeatedCount = 3)
translationHoldsByRequest[REPEATED to 1] = CompletableDeferred()
runBlocking(main) { playback.start(chapterStart) }
awaitPlayed(4)
awaitTranslations(REPEATED, 1)
runBlocking(main) { playback.skipToNext() }
awaitPlayed(5)
val repeats = synchronized(translatedKeys) { translatedKeys.filter { it.text == REPEATED } }
assertNotEquals(repeats[0], repeats[1])
assertEquals(translationOf(repeats[1]), played[4])
}
@Test
fun skippingBackFromAnIdenticalSentenceWaitsForItsEarlierPlace() {
readTranslated()
val firstTranslation = CompletableDeferred<Unit>()
translationHolds[REPEATED] = firstTranslation
runBlocking(main) { playback.start(chapterStart) }
awaitPlayed(4)
awaitWaiting(true)
val secondTranslation = CompletableDeferred<Unit>()
translationHolds[REPEATED] = secondTranslation
firstTranslation.complete(Unit)
awaitPlayed(5)
awaitWaiting(true)
runBlocking(main) { playback.skipToPrevious() }
secondTranslation.complete(Unit)
awaitPlayed(6)
assertEquals(translationOf(translatedKeys[4]), played[5])
}
@Test
fun closingWhileATranslationIsLateSpeaksNothingMore() {
readTranslated()
@ -815,7 +954,7 @@ class ReadAloudPlaybackTest {
assertEquals(FIRST, playback.heard.value?.text?.highlight)
}
private fun writeEpub(target: File) {
private fun writeEpub(target: File, repeatedCount: Int = 2) {
ZipOutputStream(target.outputStream()).use { zip ->
val mimetype = "application/epub+zip".toByteArray()
zip.setMethod(ZipOutputStream.STORED)
@ -869,7 +1008,7 @@ class ReadAloudPlaybackTest {
<html xmlns="http://www.w3.org/1999/xhtml">
<head><title>One</title></head>
<body>
<p>$FIRST $SECOND $THIRD $FOURTH $REPEATED $REPEATED $LAST ${TAIL.joinToString(" ")}</p>
<p>$FIRST $SECOND $THIRD $FOURTH ${List(repeatedCount) { REPEATED }.joinToString(" ")} $LAST ${TAIL.joinToString(" ")}</p>
</body>
</html>""",
)

View file

@ -9,6 +9,7 @@ import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.job
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import com.chmouel.liseur.translate.TranslationError
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
@ -72,6 +73,7 @@ class SpeechTtsEngineTest {
private val events = LinkedBlockingQueue<String>()
private val waits = LinkedBlockingQueue<String>()
private val heldAudioResumes = LinkedBlockingQueue<Boolean>()
@Volatile private var skipCount = 0
@Volatile private var translationIdentity: String? = null
@ -86,6 +88,17 @@ class SpeechTtsEngineTest {
override suspend fun keyFor(requestId: TtsEngine.RequestId, text: String) =
SpeechKey(text, translationIdentity = translationIdentity)
override suspend fun keyFor(
requestId: TtsEngine.RequestId,
text: String,
reusingHeldAudio: Boolean,
) = SpeechKey(text, translationIdentity = translationIdentity).also {
heldAudioResumes.put(reusingHeldAudio)
}
override suspend fun refreshKey(requestId: TtsEngine.RequestId, text: String, key: SpeechKey) =
key.copy(translationIdentity = translationIdentity)
override fun onWaiting(requestId: TtsEngine.RequestId, waiting: Boolean) =
waits.put("${if (waiting) "waiting" else "done waiting"} ${requestId.value}")
@ -126,6 +139,9 @@ class SpeechTtsEngineTest {
private fun nextPlay() = requireNotNull(plays.poll(2, TimeUnit.SECONDS)) { "no playback" }
private fun nextHeldAudioResume() =
requireNotNull(heldAudioResumes.poll(2, TimeUnit.SECONDS)) { "no key request" }
@Test
fun speaksThenReportsDoneOnMain() {
speak("1", "Hello there.")
@ -155,6 +171,7 @@ class SpeechTtsEngineTest {
fun speakingAStoppedSentenceAgainPlaysOnASecondBackWithoutFetchingIt() {
speak("1", "Hello.")
nextRequest().second.complete(SpeechAudio(ByteArray(SECONDS_3)))
assertEquals(false, nextHeldAudioResume())
assertEquals("start 1@main", nextEvent())
nextPlay()
assertEquals(0, playedFrom.poll(2, TimeUnit.SECONDS))
@ -163,6 +180,7 @@ class SpeechTtsEngineTest {
assertEquals("interrupted 1@main", nextEvent())
speak("2", "Hello.")
assertEquals(true, nextHeldAudioResume())
assertEquals("start 2@main", nextEvent())
nextPlay()
assertEquals(SpeechAudio.SAMPLE_RATE, playedFrom.poll(2, TimeUnit.SECONDS))
@ -173,6 +191,7 @@ class SpeechTtsEngineTest {
onMain { engine.stop() }
assertEquals("interrupted 2@main", nextEvent())
speak("3", "Hello.")
assertEquals(true, nextHeldAudioResume())
assertEquals("start 3@main", nextEvent())
nextPlay().complete(Unit)
assertEquals(SpeechAudio.SAMPLE_RATE, playedFrom.poll(2, TimeUnit.SECONDS))
@ -184,7 +203,9 @@ class SpeechTtsEngineTest {
fun changingTranslationIdentityWhilePausedFetchesFreshAudioFromTheBeginning() {
translationIdentity = "old"
speak("1", "Hello.")
nextRequest().second.complete(SpeechAudio(ByteArray(SECONDS_3)))
val initial = nextRequest()
assertEquals(false, nextHeldAudioResume())
initial.second.complete(SpeechAudio(ByteArray(SECONDS_3)))
assertEquals("start 1@main", nextEvent())
nextPlay()
assertEquals(0, playedFrom.poll(2, TimeUnit.SECONDS))
@ -194,6 +215,7 @@ class SpeechTtsEngineTest {
translationIdentity = "new"
speak("2", "Hello.")
assertEquals(true, nextHeldAudioResume())
val (text, reply) = nextRequest()
assertEquals("Hello.", text)
reply.complete(SpeechAudio(ByteArray(SECONDS_3)))
@ -205,6 +227,62 @@ class SpeechTtsEngineTest {
assertNull(requests.poll(200, TimeUnit.MILLISECONDS))
}
@Test
fun changingTranslationIdentityWhileFetchingDiscardsTheOldAudio() {
translationIdentity = "old"
speak("1", "Hello.")
val old = nextRequest()
assertEquals(false, nextHeldAudioResume())
translationIdentity = "new"
old.second.complete(SpeechAudio(ByteArray(SECONDS_3)))
val fresh = nextRequest()
assertEquals("Hello.", fresh.first)
fresh.second.complete(SpeechAudio(ByteArray(SECONDS_3)))
assertEquals("start 1@main", nextEvent())
nextPlay().complete(Unit)
assertEquals("done 1@main", nextEvent())
assertEquals(0, playedFrom.poll(2, TimeUnit.SECONDS))
assertNull(requests.poll(200, TimeUnit.MILLISECONDS))
}
@Test
fun aTranslationFailingBecauseTheServiceChangedFetchesFreshAudio() {
translationIdentity = "old"
speak("1", "Hello.")
val old = nextRequest()
assertEquals(false, nextHeldAudioResume())
translationIdentity = "new"
old.second.completeExceptionally(TranslationError.Changed())
val fresh = nextRequest()
assertEquals("Hello.", fresh.first)
fresh.second.complete(SpeechAudio(ByteArray(SECONDS_3)))
assertEquals("start 1@main", nextEvent())
nextPlay().complete(Unit)
assertEquals("done 1@main", nextEvent())
noMoreEvents()
assertNull(requests.poll(200, TimeUnit.MILLISECONDS))
}
@Test
fun stoppingWhileTheFreshAudioIsFetchedInterruptsOnce() {
translationIdentity = "old"
speak("1", "Hello.")
val old = nextRequest()
assertEquals(false, nextHeldAudioResume())
translationIdentity = "new"
old.second.completeExceptionally(TranslationError.Changed())
nextRequest()
onMain { engine.stop() }
assertEquals("interrupted 1@main", nextEvent())
noMoreEvents()
assertTrue(plays.isEmpty())
}
@Test
fun speakingASentenceStoppedWhileFetchingWaitsForTheSameRequest() {
speak("1", "Hello.")

View file

@ -124,10 +124,12 @@ class TranslatedSpeechTest {
assertEquals(2, translator.asked.size)
translator.identity = "svc-2"
val underNewService = TranslatedSpeech(translations, { listOf(it) }, { listOf(it) })
.prepare(SpeechKey("untranslated", book[1], "svc-2"), previous = "first")
assertEquals(listOf("<untranslated>"), underNewService)
assertEquals(3, translator.asked.size)
val stillUntranslated = runCatching {
TranslatedSpeech(translations, { listOf(it) }, { listOf(it) })
.prepare(SpeechKey("untranslated", book[1], "svc-2"), previous = "first")
}
assertTrue(stillUntranslated.exceptionOrNull() is TranslationError.Refused)
assertEquals(2, translator.asked.size)
assertTrue(translations.acquire())
page.stop()
@ -142,6 +144,10 @@ class TranslatedSpeechTest {
runCurrent()
assertEquals(listOf("first", "untranslated"), restartedPage.swaps.value["ch1.xhtml"]?.map { it.sentence.text })
assertEquals(4, translator.asked.size)
val spoken = TranslatedSpeech(translations, { listOf(it) }, { listOf(it) })
.prepare(SpeechKey("untranslated", book[1], "svc-2"), previous = "first")
assertEquals(listOf("<untranslated>"), spoken)
assertEquals(4, translator.asked.size)
restartedPage.stop()
translations.release()
}
@ -198,13 +204,30 @@ class TranslatedSpeechTest {
}
@Test
fun `a grouped request cannot join more PCM than the cache budget`() = runTest {
fun `a grouped request accepts the exact per-utterance PCM limit`() = runTest {
val chunk = ByteArray(SpeechAudio.MAX_PCM_BYTES)
val cache = SpeechCache(
backgroundScope,
{ SpeechAudio(chunk) },
prepare = { _, _ -> listOf("one", "two") },
)
val audio = cache.claim(SpeechKey("group", place)).await()
assertEquals(SpeechCache.MAX_BYTES.toInt(), audio.pcm.size)
}
@Test
fun `a grouped request rejects one byte over the PCM limit before joining`() = runTest {
val scope = CoroutineScope(SupervisorJob() + StandardTestDispatcher(testScheduler))
val chunk = ByteArray(SpeechAudio.MAX_PCM_BYTES)
var synthesized = 0
val cache = SpeechCache(
scope,
{ synthesized++; SpeechAudio(chunk) },
{ text ->
synthesized++
SpeechAudio(if (text == "three") byteArrayOf(0) else chunk)
},
prepare = { _, _ -> listOf("one", "two", "three") },
)