diff --git a/app/src/main/kotlin/com/chmouel/liseur/translate/SentenceTranslations.kt b/app/src/main/kotlin/com/chmouel/liseur/translate/SentenceTranslations.kt index 132983ad..8a97c26c 100644 --- a/app/src/main/kotlin/com/chmouel/liseur/translate/SentenceTranslations.kt +++ b/app/src/main/kotlin/com/chmouel/liseur/translate/SentenceTranslations.kt @@ -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 } diff --git a/app/src/main/kotlin/com/chmouel/liseur/tts/ReadAloudPlayback.kt b/app/src/main/kotlin/com/chmouel/liseur/tts/ReadAloudPlayback.kt index 9272c495..aad57675 100644 --- a/app/src/main/kotlin/com/chmouel/liseur/tts/ReadAloudPlayback.kt +++ b/app/src/main/kotlin/com/chmouel/liseur/tts/ReadAloudPlayback.kt @@ -25,6 +25,22 @@ import org.readium.r2.shared.util.Url typealias SpeechNavigator = TtsNavigator +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() + private val skipOrigins = HashMap() + private var pendingSkipOrigin: Locator? = null private val mutableHeard = MutableStateFlow(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() diff --git a/app/src/main/kotlin/com/chmouel/liseur/tts/SpeechKey.kt b/app/src/main/kotlin/com/chmouel/liseur/tts/SpeechKey.kt index 2fa7c0b3..d48f5282 100644 --- a/app/src/main/kotlin/com/chmouel/liseur/tts/SpeechKey.kt +++ b/app/src/main/kotlin/com/chmouel/liseur/tts/SpeechKey.kt @@ -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) } } diff --git a/app/src/main/kotlin/com/chmouel/liseur/tts/SpeechTtsEngine.kt b/app/src/main/kotlin/com/chmouel/liseur/tts/SpeechTtsEngine.kt index 0a03b1b7..7d50e795 100644 --- a/app/src/main/kotlin/com/chmouel/liseur/tts/SpeechTtsEngine.kt +++ b/app/src/main/kotlin/com/chmouel/liseur/tts/SpeechTtsEngine.kt @@ -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 diff --git a/app/src/test/kotlin/com/chmouel/liseur/translate/OpenAiChatClientTest.kt b/app/src/test/kotlin/com/chmouel/liseur/translate/OpenAiChatClientTest.kt index 62d96004..0ceff7dd 100644 --- a/app/src/test/kotlin/com/chmouel/liseur/translate/OpenAiChatClientTest.kt +++ b/app/src/test/kotlin/com/chmouel/liseur/translate/OpenAiChatClientTest.kt @@ -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 { 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 { + 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 { + runBlocking { client.translate(base, "secret", "m", "fr", "en", "private passage") } + } + + assertEquals(1, server.requestCount) + assertEquals(0, elsewhere.requestCount) + } } @Test diff --git a/app/src/test/kotlin/com/chmouel/liseur/translate/SentenceTranslationsTest.kt b/app/src/test/kotlin/com/chmouel/liseur/translate/SentenceTranslationsTest.kt index 9f8e0790..74c49c94 100644 --- a/app/src/test/kotlin/com/chmouel/liseur/translate/SentenceTranslationsTest.kt +++ b/app/src/test/kotlin/com/chmouel/liseur/translate/SentenceTranslationsTest.kt @@ -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) } diff --git a/app/src/test/kotlin/com/chmouel/liseur/tts/ReadAloudPlaybackTest.kt b/app/src/test/kotlin/com/chmouel/liseur/tts/ReadAloudPlaybackTest.kt index 929055e6..7aefabe2 100644 --- a/app/src/test/kotlin/com/chmouel/liseur/tts/ReadAloudPlaybackTest.kt +++ b/app/src/test/kotlin/com/chmouel/liseur/tts/ReadAloudPlaybackTest.kt @@ -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() - 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() + 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() private val translationsAsked = ConcurrentHashMap() + private val translationHoldsByRequest = ConcurrentHashMap, CompletableDeferred>() /** The text each translation was asked with as the one before it, in the order asked. */ private val translatedAfter: MutableList> = 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() + 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() + translationHolds[REPEATED] = firstTranslation + runBlocking(main) { playback.start(chapterStart) } + awaitPlayed(4) + awaitWaiting(true) + + val secondTranslation = CompletableDeferred() + 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 { One -

$FIRST $SECOND $THIRD $FOURTH $REPEATED $REPEATED $LAST ${TAIL.joinToString(" ")}

+

$FIRST $SECOND $THIRD $FOURTH ${List(repeatedCount) { REPEATED }.joinToString(" ")} $LAST ${TAIL.joinToString(" ")}

""", ) diff --git a/app/src/test/kotlin/com/chmouel/liseur/tts/SpeechTtsEngineTest.kt b/app/src/test/kotlin/com/chmouel/liseur/tts/SpeechTtsEngineTest.kt index 63d9a6fe..1045de35 100644 --- a/app/src/test/kotlin/com/chmouel/liseur/tts/SpeechTtsEngineTest.kt +++ b/app/src/test/kotlin/com/chmouel/liseur/tts/SpeechTtsEngineTest.kt @@ -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() private val waits = LinkedBlockingQueue() + private val heldAudioResumes = LinkedBlockingQueue() @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.") diff --git a/app/src/test/kotlin/com/chmouel/liseur/tts/TranslatedSpeechTest.kt b/app/src/test/kotlin/com/chmouel/liseur/tts/TranslatedSpeechTest.kt index 725c8eb6..aa4c1daf 100644 --- a/app/src/test/kotlin/com/chmouel/liseur/tts/TranslatedSpeechTest.kt +++ b/app/src/test/kotlin/com/chmouel/liseur/tts/TranslatedSpeechTest.kt @@ -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(""), 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(""), 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") }, )