mirror of
https://github.com/chmouel/liseur.git
synced 2026-10-19 01:11:15 +00:00
fix: Keep translated speech matched to the right sentence
Some checks are pending
Build / build (push) Waiting to run
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:
parent
e0ca4acb2b
commit
348bde6298
9 changed files with 429 additions and 35 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>""",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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") },
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue